SLOPSHOPPER

swarm-mod

Spawn, watch and steer a swarm of Claude Code agents from a side pane

newpanebandguardcommandtool
v0.3.1MITupdated 2026-10-08netgfx/claude-swarm-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · swarm-mod
│ ┃ Swarm ✕ › fix the failing auth test and add an audit log call │ ┃ SWARM 0 agents · 0 working │ ┃ [ n: + Spawn agent ] r: Rosters ⏺ Read(src/auth.ts) │ ┃ No agents yet. Spawn one, or launch a saved ⎿ Read 6 lines │ ┃ roster (r). ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /swarm │ │ swarm + Spawn agent ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
swarm + Spawn agent ⟨Claude Code's own drawing⟩
Pane · Swarm
SWARM 0 agents · 0 working [ n: + Spawn agent ] r: Rosters No agents yet. Spawn one, or launch a saved roster (r).
Pane · Spawn agent
Spawn a swarm agent Runs as: Teammate (agent team, in this session) ▾ Agent: Default (general-purpose) ▾ Model: Inherit (lead's model) ▾ Thinking effort: Default ▾ Name: scout ●●● Color: sky ▾ Approval mode: Ask me (default) ▾ Auto-restart: Off ▾ Task: what should it work on? Workspace: /work/app Agent teams are off (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1). It will run as a named background agent instead. [ Spawn scout ] [ Cancel ]
README

swarm-mod

A Claude Code mod for running a swarm of agents from a side pane. You spawn agents, watch their live state and token use, answer their approvals and questions, and message them, all from the session you started in.

Swarm paneSpawn form
The swarm pane: a Spawn agent button, Rosters, one finished reviewer agent with its token count, Broadcast and Clear finishedThe spawn form: transport, agent type, model, thinking effort, name, color, approval mode, auto-restart, task and workspace

Supports Windows and macOS (Linux best effort). Tested with Claude Code v2.1.289 on Windows 11 (PowerShell 7 and Windows Terminal). The macOS paths are covered by unit tests, and the generated launch script was checked with a POSIX shell, but they haven't been run on a Mac yet.

Install

This repo is a Claude Code plugin marketplace. Inside Claude Code:

/plugin marketplace add netgfx/claude-swarm-mod
/plugin install swarm-mod@claude-swarm-mod

Or from a shell:

claude plugin marketplace add netgfx/claude-swarm-mod
claude plugin install swarm-mod@claude-swarm-mod

Restart Claude Code (or run /reload-plugins), then type /swarm. To get updates later, run claude plugin marketplace update claude-swarm-mod.

For teammates that join this session's agent team, also set CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 (see Transports). To run it from a local checkout instead, see Load it from a checkout.

What it does

FeatureHow
Spawn Agent buttonThe n: + Spawn agent button in the swarm pane, + Spawn agent in the band above the prompt, or /swarm spawn.
Spawn formTransport (teammate or separate session), agent type (built-ins plus ~/.claude/agents and .claude/agents files, plus any type Claude Code offers, including plugin agents), model, thinking effort, instance name (unique, suggested for you), color (suggested at random from a 12-color palette, never one a live agent already has), approval mode, and an optional first task.
Same workspaceEvery agent starts in the lead session's working directory. Teammates share the lead's process. Separate sessions Set-Location / cd there before claude starts.
Live statestarting, working (with the current tool, such as Bash: npm test), waiting, idle, done, failed, stopped, exited, no heartbeat, plus ! needs approval and ? has a question.
Token usePer agent: total, input, output, cache read, cache write, current context size, and turn count. Plus a swarm total.
Agent viewClick a name to open its panel. On a wide fullscreen terminal it's a docked side panel; otherwise it opens inline in the main area. e expands it (with the teammate's conversation), c / Esc closes it and restores the normal layout. A separate session also has w: Open its window, which brings its exact tab forward: by tty in Terminal.app or iTerm2 on macOS, by window title on Windows.
Spawn errorsA failed agent's row shows a short failed · copy error button. Pressing it copies the whole error to the clipboard (or press o in its panel), so a long InputValidationError isn't cut off.
Needs-you iconA red ! review / magenta ? answer button on the agent's row and in the band, plus a toast. Its panel has Approve / Deny / Use its own prompt, or the question's options plus a free-text "Other".
MessagingMessage one agent from its panel, or broadcast to every live agent. This uses cross-session messaging ($.session.send): { agentId } for teammates, { sessionId } for separate sessions.
Kill / interruptPer agent, k: Kill. Teammates are stopped with TaskStop. A separate session's process is ended by its PID (taskkill /T /F on Windows, kill -TERM on macOS), after checking that the PID still belongs to claude, so a reused PID is never killed. If that check fails, the session ends itself. i: Interrupt stops only the current turn.
Kill allk: Kill all in the swarm pane (press twice within 6 s to confirm), or /swarm kill-all. Pending approvals are denied so nothing waits on a dead agent, and killed agents are never auto-restarted.
Auto-restartOff by default. Turn it on in the spawn form or with a in an agent's panel. A teammate that failed, or a separate session that crashed, exited unexpectedly, or hung (no heartbeat for a full minute of the main session's own time, so waking from sleep doesn't count), comes back with the same name, color, model, effort, approval mode and task. The task is prefixed with a note to check the workspace first. The backoff is 5 s, 15 s and 45 s, for up to 3 restarts. A hung session is killed first. Kill, /exit, and the agent panel's stop are treated as deliberate and never undone. p: Respawn brings back any stopped agent by hand.
Saved rostersr: Rosters (or /swarm rosters) saves the current swarm under a name: each agent's name, color, type, model, effort, approval mode, transport, task and auto-restart. Launch a roster in any workspace with one click (or /swarm load <name>). Names and colors already in use are adjusted (scout → scout-2). Rosters live in the mod store, shared by every session on this machine.
Restore last swarmEach workspace's latest swarm is saved automatically. When you start Claude Code there again, the band and the pane offer Restore last swarm (N) (or /swarm restore).

Transports

Teammate (agent team). The mod calls $.agent.spawn({ name, subagentType, model, prompt }). $.agent.spawn only accepts a model family alias (opus, sonnet, haiku, fable), so the model picked in the form (stored as a full id such as claude-sonnet-5-5) is mapped to its alias first; Inherit sends no model. With CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 set, that joins this session's team. Without it, the agent runs as a named background agent that you can still message, and the form warns you. The agent's loop runs inside the lead's process, so the mod sees its model steps (tokens, plus the per-agent effort, applied by rewriting turn.step), its tool calls (activity, edited files), and its permission checks (the approval mode, applied in tool.check).

Separate Claude Code session. The mod writes ~/.claude/swarm-mod/launch/<id>.ps1 (Windows), .command (macOS), or .sh (Linux), then opens a terminal:

  • Windows: a tab in a Windows Terminal window named swarm (wt.exe -w swarm new-tab …), or a plain console window if Windows Terminal is missing.
  • macOS: a new iTerm2 window when the lead runs in iTerm2 (TERM_PROGRAM=iTerm.app), otherwise a new Terminal.app window (open -a Terminal <id>.command, which needs no permissions). The .command script records its own tty and runs claude through your login shell ($SHELL -lic; fish falls back to zsh/bash), so claude and node resolve from the PATH your shell normally has, even when the lead runs in the Desktop app.
  • Linux: x-terminal-emulator, gnome-terminal, konsole, or xterm.

The new terminal runs:

claude --name <name> --plugin-dir <this mod> [--agent <type>] [--model <m>] [--effort <e>]
       [--permission-mode <mode> | --dangerously-skip-permissions] --settings <worker-settings.json>

--model gets the full model id, which the CLI accepts.

worker-settings.json sets crossSessionInbound: "accept", so the lead's messages get through whatever the worker's permission mode. The first task is passed in SWARM_MOD_TASK and the worker submits it itself. Neither the task nor the JSON goes on the command line, because Windows PowerShell 5.1 mangles quotes inside native arguments.

In that session the same mod runs in worker mode. It reports status, activity, tokens, edited files, its last answer, and any pending approval or question to $.store (shared by all sessions on the machine). The lead checks for updates every 1.5 s and marks a worker no heartbeat after 20 s of silence.

Approval modes

ModeSeparate sessionTeammate (enforced by the mod)
Ask me (default)--permission-mode defaultAnything the lead would ask about waits in the swarm pane
Accept editsacceptEditsEdit, Write, and NotebookEdit allowed; the rest as default
AutoautoFollows the lead session's own mode
Plan / read-onlyplanEdits denied, and anything needing approval denied, with a reason Claude can act on
Don't askdontAskAnything not pre-approved is denied
Bypass all--dangerously-skip-permissionsEverything allowed except explicit deny rules

Claude Code can't set a teammate's permission mode at spawn time, so the mod applies the mode itself at tool.check, matching the check to the agent through tool_use_id.

A request waiting for you is held for up to 15 minutes for teammates and 3 minutes for workers. After that it falls back to Claude Code's normal prompt, in the lead for a teammate or in the worker's own window for a worker. The mod never approves anything unless you press Approve or picked the agent's approval mode yourself.

Commands, tool, and keys

  • /swarm: open the swarm pane.
  • /swarm spawn: open the spawn form.
  • /swarm status: print the roster.
  • /swarm rosters: open the rosters pane.
  • /swarm save <name> and /swarm load <name>: save or launch a roster.
  • /swarm restore: bring back this workspace's last swarm.
  • /swarm kill-all: kill every agent.
  • /swarm band off|on: hide or show the band.
  • /swarm-spawn: open the spawn form.
  • Tool mcp__swarm-mod__swarm_status: lets the lead Claude read the roster (status, activity, tokens, what's waiting on you, last answers) before it coordinates.
  • Pane keys: n spawn, r rosters, k kill all (twice), x clear finished.
  • Agent panel keys: y approve, d deny, l use its own prompt, o copy its error, w open its window, i interrupt, k kill, p respawn, a toggle auto-restart, e expand, r remove, c close.
  • Rosters pane: type a name and press Enter to save; Launch or Delete each roster; c close.

Load it from a checkout

PowerShell:

claude plugin validate D:\Projects\Projects\claude-plugins\swarm-mod
claude --plugin-dir D:\Projects\Projects\claude-plugins\swarm-mod
# optional, for teammates:
$env:CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1'

Bash/Zsh:

claude plugin validate ~/mods/swarm-mod
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 claude --plugin-dir ~/mods/swarm-mod

On macOS, the first time you use w: Open its window (or launch into iTerm2), macOS asks whether your terminal or the Claude app may control Terminal or iTerm. Allow it, or later in System Settings > Privacy & Security > Automation. Opening a Terminal.app worker doesn't need that permission.

For the Desktop app (Code tab), add the folder to env.CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (paths separated by ; on Windows, : on macOS), then start a new session. The sidebar docks in a fullscreen terminal at least 144 columns wide (110 once you've opened it yourself). Narrower than that, panes open inline above the prompt.

When swarm-mod is installed from the marketplace, separate-session workers get --plugin-dir pointing at the installed copy, so they run the same version as the lead.

Tests

Set-Location D:\Projects\Projects\claude-plugins\swarm-mod; claude plugin test

41 tests in two files cover the following. tests/lifecycle.test.ts covers kill-all (the two-press confirm, TaskStop, kill by PID, the reused-PID guard, a worker ending itself), the auto-restart policy (failure, deliberate exits, hang detection, backoff, the restart limit), respawn and the auto-restart toggle, saving, launching and deleting rosters (with name and color fitting), restoring the last swarm, and the new panes on both surfaces. tests/swarm.test.ts covers: color picking, name rules, mapping a picked model to the alias $.agent.spawn accepts, approval-mode decisions, quoting of the worker CLI and launch script, path conflicts, the spawn form, per-agent effort and token counting, accept-edits and plan modes, an approval held and then approved from the pane, a question answered from the pane, launches on Windows and macOS (Terminal.app and iTerm2, the login-shell script, the tty record, focus by tty with balanced AppleScript blocks, case-insensitive paths on APFS), worker reporting, lead pickup and messaging, every pane plus the band on both terminal and Desktop, and a refused spawn keeping its full error (which you can copy from the agent's row).

Releasing

.claude-plugin/plugin.json is the plugin manifest and .claude-plugin/marketplace.json makes this repo a one-plugin marketplace ("source": "./"). claude plugin validate . checks both. When you release, bump version in both files so claude plugin marketplace update picks up the change.

What the mod calls (from claude plugin validate)

$.agent.spawn/list, $.session.send/messages/cwd/id, $.tool.call (TaskStop), $.tool.register, $.turn.abort, $.prompt.submit (a worker's first task), $.process.run (opening terminals: wt.exe/cmd start on Windows, open/osascript on macOS; focusing windows: PowerShell AppActivate or osascript; a worker reading its own PID (powershell / sh + ps); killing a worker: checking the name with Get-Process/ps, then taskkill/kill; ping/sleep as a one-second wait while a request is held; uname; chmod), $.fs.write (launch scripts under ~/.claude/swarm-mod/launch), $.fs.list/read (agent definitions, a worker's recorded tty), $.store (w:<id> worker reports, r:<id> lead replies and kill requests, roster:<name> saved rosters, last:<workspace> each workspace's last swarm, band), and $.state (the roster, kept across hot reloads).

Environment variables read: OS, USERPROFILE, HOME, TERM_PROGRAM, CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, and the worker's SWARM_MOD_WORKER/LEAD/NAME/APPROVAL/TASK.

Extras built in

  • File-conflict radar: when two agents edit the same file (case-insensitive on Windows and macOS, matching NTFS and APFS defaults), you get a ▲ line in the pane, a warning in each agent's panel, and a toast.
  • Broadcast to every live agent.
  • Finished and failed lines in each agent's log, with toasts on failures.
  • Status tool so the lead Claude can coordinate the swarm with up-to-date information.

Ideas for later

  1. Worktree isolation per agent: an option to start a separate session in its own git worktree so edits never collide, with a merge view.
  2. Token and cost budgets: a per-agent or swarm-wide cap that warns at 80% and interrupts at 100% (turn.step can refuse further steps).
  3. Task board: show the team's shared task list (~/.claude/tasks/<team>/) with owners, color-coded by agent, plus drag-to-assign.
  4. File locks: when the conflict radar fires, the second agent's edit waits for the lead's OK, not just a warning.
  5. Supervisor digest: every N minutes, a Haiku summary of what each agent did ($.model.complete) in the pane.
  6. Stuck-task nudges: ping a teammate that sits on an in-progress task too long.
  7. Pause all: interrupt every agent's turn without killing it, then resume.
  8. Auto-launch a roster when a workspace opens (opt-in per roster).
  9. Remote approvals from your phone through Remote Control, using the same approval path.

License

MIT. See LICENSE.

Source 3 files
hooks/register.js 1804 lines
1// swarm-mod: spawn, watch and steer a swarm of Claude Code agents from a pane.
2//
3// Two transports:
4//  - teammate: $.agent.spawn with a name, so it joins this session's agent team
5//    (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1) or runs as a named background agent.
6//    Its loop runs in this process, so these hooks see its steps, tools and checks.
7//  - session: a separate interactive `claude` in a new terminal, started in this
8//    workspace with this mod loaded. There the mod runs in "worker" mode and
9//    reports to the lead through $.store; the lead messages it over
10//    cross-session messaging and answers its approvals through $.store.
11
12import {
13  PALETTE, MODES, MODELS, EFFORTS, APPROVALS, BUILTIN_AGENTS, EDIT_TOOLS,
14  colorName, pickColor, labelOf, suggestName, validateName, fmtTokens, emptyTokens,
15  addUsage, totalTokens, clip, summarizeTool, describeCall, baseName, decide, statusView,
16  isLive, launchScript, launchCandidates, WORKER_SETTINGS, focusArgv, parseAgentFile, findConflicts, randomId,
17  selfPidArgv, parsePidLine, pidNameArgv, killArgv, RESTART_MAX, restartDelayMs, needsRestart, restartTask,
18  rosterEntry, rosterKey, lastRosterKey, planRoster, validRosterName, modelAlias,
19} from './lib.js'
20
21const MAIN = 'swarm'
22const WIZARD = 'swarm-spawn'
23const DETAIL = 'swarm-a-'
24const ROSTERS = 'swarm-rosters'
25const STATUS_TOOL = 'swarm_status'
26const TEAMMATE_HOLD_MS = 15 * 60 * 1000 // then the normal permission prompt
27const WORKER_HOLD_MS = 3 * 60 * 1000 // then the worker's own prompt
28const STALE_MS = 20000
29const LAUNCH_TIMEOUT_MS = 90000
30const LOG_MAX = 40
31const NAMES = ['scout', 'forge', 'atlas', 'echo', 'nova', 'pixel', 'rook', 'sage', 'vega', 'zephyr', 'orion', 'quill']
32
33// ---- lead state (module variables; snapshot kept in $.state across reloads) ----
34let agents = {} // key -> agent record
35let order = [] // keys, spawn order
36let platform = null // 'windows' | 'mac' | 'linux'
37let homeDir = ''
38let leadId = ''
39let callInfo = {} // tool_use_id -> { key, tool, input, workerMain }
40let decisions = {} // reqId -> decision, for holds in this process
41let agentTypes = {} // agent type -> description
42let draft = null // the spawn form
43let wizardError = ''
44let openPick = null // the spawn-form field whose option list is open
45let qDraft = {} // reqId -> { [question]: answer }
46let bandOn = true
47let expanded = {}
48let snapshotDirty = false
49let ticking = false
50let seenAttention = {}
51let seenConflicts = {}
52let conflicts = []
53let killArmed = false // kill-all asks for a second press
54let rosterList = [] // saved rosters, loaded when the roster pane opens
55let rosterNameDraft = ''
56let rosterError = ''
57let lastRoster = null // this workspace's last swarm, offered after a restart of Claude Code
58let lastSavedSig = ''
59
60// ---- worker state (only when this session was launched by a lead) ----
61let worker = null // { id, lead, name, approval }
62let w = null // what the worker reports
63let wDirty = false
64let wLastFlush = 0
65let wSeenAbort = 0
66
67// ======================= helpers that take $ =======================
68
69async function detectPlatform($) {
70  if (platform) return platform
71  let isWin = false
72  try {
73    const cwd = await $.session.cwd()
74    isWin = /^[A-Za-z]:[\\/]/.test(cwd) || cwd.startsWith('\\\\')
75  } catch {}
76  if (!isWin) {
77    try {
78      isWin = (await $.env.get('OS')) === 'Windows_NT'
79    } catch {}
80  }
81  if (isWin) {
82    platform = 'windows'
83  } else {
84    platform = 'linux'
85    try {
86      const r = await $.process.run(['uname', '-s'], { timeoutMs: 5000 })
87      if (String(r.stdout).trim() === 'Darwin') platform = 'mac'
88    } catch {}
89  }
90  return platform
91}
92
93function sep() {
94  return platform === 'windows' ? '\\' : '/'
95}
96
97async function toast($, text) {
98  try {
99    await $.ui.toast(text)
100  } catch {}
101}
102
103function changed($) {
104  snapshotDirty = true
105  $.ui.invalidate('ui.render')
106}
107
108async function persist($) {
109  if (!snapshotDirty) return
110  snapshotDirty = false
111  try {
112    await $.state.set({ plugin: 'swarm-mod', key: 'snapshot' }, { agents, order })
113  } catch {}
114}
115
116async function restore($) {
117  try {
118    const { value } = await $.state.get({ plugin: 'swarm-mod', key: 'snapshot' })
119    if (value && Array.isArray(value.order) && value.agents) {
120      agents = { ...value.agents }
121      order = value.order.filter((k) => agents[k])
122    }
123  } catch {}
124}
125
126// Waits about a second without a timer (hook budgets exclude time in $ calls)
127async function waitTick($) {
128  const argv = platform === 'windows' ? ['ping', '-n', '2', '127.0.0.1'] : ['sleep', '1']
129  try {
130    await $.process.run(argv, { timeoutMs: 5000 })
131  } catch {
132    await $.clock.sleep(250)
133  }
134}
135
136function pushLog(a, text, now) {
137  a.log = [...(a.log || []), { t: now, text: clip(text, 160) }].slice(-LOG_MAX)
138}
139
140function addFile(a, path) {
141  if (!path) return
142  const files = a.files || []
143  if (!files.includes(path)) a.files = [...files, path].slice(-200)
144}
145
146function keyOfAgentId(agentId) {
147  if (!agentId) return null
148  for (const k of order) if (agents[k] && agents[k].agentId === agentId) return k
149  return null
150}
151
152// Matches a loop id to a teammate whose spawn has not resolved yet
153async function resolveKey($, agentId) {
154  const k = keyOfAgentId(agentId)
155  if (k || !agentId) return k
156  const waiting = order.filter((x) => agents[x].transport === 'teammate' && !agents[x].agentId && agents[x].status === 'pending')
157  if (!waiting.length) return null
158  try {
159    const list = await $.agent.list()
160    const hit = list.find((x) => x.id === agentId)
161    if (!hit) return null
162    const kk = waiting.find((x) => agents[x].name === hit.name || String(hit.teammateId || '').startsWith(agents[x].name + '@'))
163    if (kk) {
164      agents[kk].agentId = agentId
165      return kk
166    }
167  } catch {}
168  return null
169}
170
171function liveColors(exceptKey) {
172  return order.filter((k) => k !== exceptKey && isLive(agents[k])).map((k) => agents[k].color)
173}
174
175// ======================= worker side =======================
176
177async function workerBoot($) {
178  w = {
179    status: 'idle', activity: '', tokens: emptyTokens(), ctx: 0, turns: 0, pending: null,
180    log: [], files: [], lastAnswer: '', turnId: '', sessionId: '', startedAt: (await $.clock.now()),
181  }
182  try {
183    w.sessionId = await $.session.id()
184  } catch {}
185  try {
186    const r = await $.store.get('r:' + worker.id)
187    wSeenAbort = (r && r.abortSeq) || 0
188  } catch {}
189  await workerFlush($)
190  try {
191    await $.ui.status('swarm worker "' + worker.name + '" · reporting to the lead session')
192  } catch {}
193  // this session's own pid, so the lead can end it (kill, or restart when it hangs)
194  try {
195    const r = await $.process.run(selfPidArgv(await detectPlatform($)), { timeoutMs: 20000 })
196    const p = parsePidLine(r.stdout)
197    if (p) {
198      w.pid = p.pid
199      w.pidName = p.name
200      await workerFlush($)
201    }
202  } catch {}
203}
204
205// The worker's first task, given in the spawn form
206async function submitTask($, text) {
207  try {
208    await $.prompt.submit({ text })
209  } catch (err) {
210    $.ui.log('swarm: could not submit the first task: ' + (err && err.message), { to: 'debug' })
211  }
212}
213
214async function workerFlush($) {
215  if (!worker || !w) return
216  wDirty = false
217  wLastFlush = (await $.clock.now())
218  try {
219    await $.store.set('w:' + worker.id, { ...w, name: worker.name, heartbeat: wLastFlush })
220  } catch {}
221}
222
223function workerLog(text, now) {
224  if (!w) return
225  w.log = [...w.log, { t: now, text: clip(text, 160) }].slice(-LOG_MAX)
226  wDirty = true
227}
228
229async function workerTick($) {
230  let r = null
231  try {
232    r = await $.store.get('r:' + worker.id)
233  } catch {}
234  if (r && r.abortSeq && r.abortSeq > wSeenAbort) {
235    wSeenAbort = r.abortSeq
236    if (w.turnId && w.status === 'working') {
237      try {
238        await $.turn.abort({ turnId: w.turnId })
239        workerLog('interrupted by the lead', (await $.clock.now()))
240      } catch {}
241    }
242  }
243  if (r && r.killed && w.status !== 'killed') {
244    // the lead killed this agent but could not end the process itself
245    w.status = 'killed'
246    w.pending = null
247    await workerFlush($)
248    if (w.pid) {
249      try {
250        await $.process.run(killArgv(await detectPlatform($), w.pid), { timeoutMs: 15000 })
251      } catch {}
252    }
253    return
254  }
255  if (wDirty || (await $.clock.now()) - wLastFlush > 5000) await workerFlush($)
256}
257
258// ======================= lead side: polling =======================
259
260function mergeReport(a, rep) {
261  const fields = ['status', 'activity', 'tokens', 'ctx', 'turns', 'log', 'files', 'lastAnswer', 'sessionId', 'heartbeat', 'pid', 'pidName', 'exitReason']
262  for (const f of fields) if (rep[f] !== undefined) a[f] = rep[f]
263  a.unresponsiveSince = null
264  a.pending = rep.pending && rep.pending.reqId !== a.resolvedReq ? rep.pending : null
265}
266
267async function leadTick($) {
268  let dirty = false
269  const now = (await $.clock.now())
270
271  if (order.some((k) => agents[k].transport === 'teammate' && isLive(agents[k]))) {
272    let list = null
273    try {
274      list = await $.agent.list()
275    } catch {}
276    if (list) {
277      for (const k of order) {
278        const a = agents[k]
279        if (a.transport !== 'teammate' || a.agentId || a.status !== 'pending') continue
280        const hit = list.find((x) => x.name === a.name || String(x.teammateId || '').startsWith(a.name + '@'))
281        if (hit) {
282          a.agentId = hit.id
283          dirty = true
284        }
285      }
286      for (const k of order) {
287        const a = agents[k]
288        if (a.transport !== 'teammate' || !a.agentId || !isLive(a)) continue
289        const hit = list.find((x) => x.id === a.agentId)
290        const status = hit ? hit.status : 'completed'
291        if (hit && hit.teammateId && a.teammateId !== hit.teammateId) {
292          a.teammateId = hit.teammateId
293          dirty = true
294        }
295        if (status !== a.status) {
296          a.status = status
297          if (status === 'failed') await toast($, a.name + ' failed')
298          dirty = true
299        }
300      }
301    }
302  }
303
304  for (const k of order) {
305    const a = agents[k]
306    if (a.transport !== 'session' || a.killedByUser || a.restartAt || (['exited', 'failed'].includes(a.status) && !a.heartbeat)) continue
307    let rep = null
308    try {
309      rep = await $.store.get('w:' + k)
310    } catch {}
311    if (rep && rep.heartbeat) {
312      if (rep.heartbeat !== a.heartbeat) {
313        mergeReport(a, rep)
314        dirty = true
315      } else if (isLive(a) && a.status !== 'unresponsive' && now - rep.heartbeat > STALE_MS) {
316        a.status = 'unresponsive'
317        a.unresponsiveSince = now
318        dirty = true
319      }
320    } else if (a.status === 'launching' && now - a.startedAt > LAUNCH_TIMEOUT_MS) {
321      fail(a, 'No report from the new session. Is `claude` on PATH there? Script: ' + (a.script || '?'))
322      dirty = true
323    }
324  }
325
326  for (const k of order) {
327    const a = agents[k]
328    if (a.pending && seenAttention[k] !== a.pending.reqId) {
329      seenAttention[k] = a.pending.reqId
330      await toast($, a.name + (a.pending.kind === 'approval' ? ' needs approval' : ' has a question') + ' · open the swarm pane')
331    }
332  }
333
334  conflicts = findConflicts(order.map((k) => agents[k]), platform !== 'linux')
335  for (const c of conflicts) {
336    const id = c.path + '|' + c.keys.join(',')
337    if (!seenConflicts[id]) {
338      seenConflicts[id] = true
339      await toast($, '▲ ' + c.keys.map((k) => agents[k].name).join(' & ') + ' both edited ' + baseName(c.path))
340      dirty = true
341    }
342  }
343
344  // auto-restart: wait the backoff, then bring it back with the same settings
345  for (const k of [...order]) {
346    const a = agents[k]
347    if (!a) continue
348    if (!needsRestart(a, now)) {
349      if (a.restartAt) {
350        a.restartAt = null
351        dirty = true
352      }
353      continue
354    }
355    if (!a.restartAt) {
356      const delay = restartDelayMs(a.restarts || 0)
357      a.restartAt = now + delay
358      a.activity = 'restarting in ' + Math.round(delay / 1000) + 's (' + ((a.restarts || 0) + 1) + '/' + RESTART_MAX + ')'
359      await toast($, a.name + ' ' + (a.status === 'unresponsive' ? 'stopped responding' : a.status) + ' · auto-restart in ' + Math.round(delay / 1000) + 's')
360      dirty = true
361      continue
362    }
363    if (now >= a.restartAt) {
364      await restartAgent($, k)
365      dirty = true
366    }
367  }
368
369  if (dirty) changed($)
370}
371
372async function tick($) {
373  if (ticking) return
374  ticking = true
375  try {
376    if (worker && w) await workerTick($)
377    if (order.length) await leadTick($)
378    await persist($)
379    await saveLastRoster($)
380  } finally {
381    ticking = false
382  }
383}
384
385// ======================= kill, restart =======================
386
387// Ends a separate session's process, after checking its pid still names the same program
388async function killPid($, a) {
389  if (!a.pid) return false
390  const os = await detectPlatform($)
391  try {
392    const r = await $.process.run(pidNameArgv(os, a.pid), { timeoutMs: 15000 })
393    const name = baseName(String(r.stdout || '').trim())
394    if (!name || (a.pidName && name.toLowerCase() !== String(a.pidName).toLowerCase())) return false
395    const k = await $.process.run(killArgv(os, a.pid), { timeoutMs: 15000 })
396    return k.exitCode === 0
397  } catch {
398    return false
399  }
400}
401
402async function killAgent($, key) {
403  const a = agents[key]
404  if (!a) return false
405  const wasLive = isLive(a) || !!a.restartAt
406  a.killedByUser = true
407  a.restartAt = null
408  // release a request it is holding, so nothing waits on a dead agent
409  if (a.pending) {
410    decisions[a.pending.reqId] = { decision: 'deny' }
411    a.pending = null
412  }
413  if (a.transport === 'teammate') {
414    if (isLive(a)) {
415      try {
416        await $.tool.call({ tool: 'TaskStop', task_id: a.teammateId || a.name })
417      } catch (err) {
418        $.ui.log('swarm: TaskStop ' + a.name + ': ' + (err && err.message), { to: 'debug' })
419      }
420    }
421  } else {
422    let cur = null
423    try {
424      cur = await $.store.get('r:' + key)
425    } catch {}
426    try {
427      await $.store.set('r:' + key, { ...(cur || { decisions: {}, abortSeq: 0 }), killed: true })
428    } catch {}
429    const ended = await killPid($, a)
430    pushLog(a, ended ? 'process ' + a.pid + ' ended' : 'asked the session to end itself', await $.clock.now())
431  }
432  a.status = 'killed'
433  a.activity = ''
434  pushLog(a, '■ killed by you', await $.clock.now())
435  changed($)
436  return wasLive
437}
438
439async function killAll($) {
440  killArmed = false
441  let n = 0
442  for (const k of [...order]) if (agents[k] && (await killAgent($, k))) n++
443  await toast($, 'Killed ' + n + ' agent' + (n === 1 ? '' : 's'))
444  changed($)
445  return n
446}
447
448async function restartAgent($, key, manual) {
449  const a = agents[key]
450  if (!a) return
451  a.restartAt = null
452  if (manual) {
453    a.killedByUser = false
454    a.restarts = 0
455    pushLog(a, '↻ respawned by you', await $.clock.now())
456  } else {
457    a.restarts = (a.restarts || 0) + 1
458    pushLog(a, '↻ auto-restart ' + a.restarts + '/' + RESTART_MAX, await $.clock.now())
459  }
460  a.pending = null
461  a.error = ''
462  if (a.transport === 'session') {
463    if (a.status === 'unresponsive' || (manual && isLive(a))) await killPid($, a)
464    try {
465      await $.store.delete('w:' + key)
466    } catch {}
467    a.heartbeat = null
468    a.unresponsiveSince = null
469    a.exitReason = null
470    a.pid = null
471    a.pidName = null
472    a.sessionId = ''
473    a.status = 'launching'
474    a.startedAt = await $.clock.now()
475    await launchWorker($, a, manual ? a.task : restartTask(a.task))
476  } else {
477    a.agentId = null
478    a.teammateId = null
479    a.status = 'pending'
480    await spawnTeammate($, a, manual ? a.task : restartTask(a.task))
481  }
482  await toast($, a.name + (manual ? ' respawned' : ' restarted (' + a.restarts + '/' + RESTART_MAX + ')'))
483  changed($)
484}
485
486// ======================= saved rosters =======================
487
488function currentRoster() {
489  return order.map((k) => rosterEntry(agents[k]))
490}
491
492// Keeps this workspace's latest swarm, offered again when Claude Code restarts here
493async function saveLastRoster($) {
494  if (worker || !order.length) return
495  const entries = currentRoster()
496  const sig = JSON.stringify(entries)
497  if (sig === lastSavedSig) return
498  lastSavedSig = sig
499  try {
500    const cwd = await $.session.cwd()
501    await $.store.set(lastRosterKey(cwd, platform !== 'linux'), { cwd, savedAt: await $.clock.now(), agents: entries })
502  } catch {}
503}
504
505async function loadRosters($) {
506  const list = []
507  let keys = []
508  try {
509    keys = await $.store.keys()
510  } catch {}
511  for (const key of keys) {
512    if (!key.startsWith('roster:')) continue
513    try {
514      const v = await $.store.get(key)
515      if (v && Array.isArray(v.agents)) list.push({ ...v, key, name: key.slice('roster:'.length) })
516    } catch {}
517  }
518  rosterList = list.sort((x, y) => (y.savedAt || 0) - (x.savedAt || 0))
519  return rosterList
520}
521
522async function saveRoster($, rawName) {
523  const name = String(rawName || '').trim()
524  if (!validRosterName(name)) {
525    rosterError = 'Roster name: 1-40 letters, digits, spaces, . _ or -'
526    $.ui.invalidate('ui.render')
527    return false
528  }
529  if (!order.length) {
530    rosterError = 'The swarm is empty; spawn agents first'
531    $.ui.invalidate('ui.render')
532    return false
533  }
534  let cwd = ''
535  try {
536    cwd = await $.session.cwd()
537  } catch {}
538  await loadRosters($)
539  const existed = rosterList.some((r) => r.name === name)
540  await $.store.set(rosterKey(name), { cwd, savedAt: await $.clock.now(), agents: currentRoster() })
541  rosterError = ''
542  rosterNameDraft = ''
543  await loadRosters($)
544  await toast($, (existed ? 'Updated' : 'Saved') + ' roster "' + name + '" (' + order.length + ' agents)')
545  $.ui.invalidate('ui.render')
546  return true
547}
548
549async function deleteRoster($, name) {
550  try {
551    await $.store.delete(rosterKey(name))
552  } catch {}
553  await loadRosters($)
554  $.ui.invalidate('ui.render')
555}
556
557// Spawns every agent of a roster into this workspace, with free names and colors
558async function launchRoster($, entries, label) {
559  const plan = planRoster(entries || [], order.map((k) => agents[k].name), liveColors())
560  lastRoster = null
561  try {
562    await $.ui.open({ id: MAIN, title: 'Swarm' })
563  } catch {}
564  for (const e of plan) await spawnSpec($, e)
565  await toast($, 'Spawned ' + plan.length + ' agent' + (plan.length === 1 ? '' : 's') + (label ? ' from "' + label + '"' : ''))
566  return plan.length
567}
568
569async function openRosters($) {
570  await loadRosters($)
571  rosterError = ''
572  let cwd = ''
573  try {
574    cwd = await $.session.cwd()
575  } catch {}
576  if (!rosterNameDraft) rosterNameDraft = baseName(String(cwd).replace(/[\\/]+$/, '')) || 'my swarm'
577  await $.ui.open({ id: ROSTERS, title: 'Rosters', focus: true, closeOnEscape: true })
578}
579
580// ======================= holds (approvals and questions) =======================
581
582async function setPending($, key, pending) {
583  if (key) {
584    const a = agents[key]
585    a.pending = pending
586    pushLog(a, (pending.kind === 'approval' ? '! approval: ' + pending.summary : '? question'), (await $.clock.now()))
587    if (seenAttention[key] !== pending.reqId) {
588      seenAttention[key] = pending.reqId
589      await toast($, a.name + (pending.kind === 'approval' ? ' needs approval' : ' has a question') + ' · open the swarm pane')
590    }
591    changed($)
592  } else if (w) {
593    w.pending = pending
594    workerLog(pending.kind === 'approval' ? '! approval: ' + pending.summary : '? question', (await $.clock.now()))
595    await workerFlush($)
596    try {
597      await $.ui.status('swarm: waiting for the lead to ' + (pending.kind === 'approval' ? 'approve ' + pending.summary : 'answer a question') + ' (falls back to the prompt here)')
598    } catch {}
599  }
600}
601
602async function clearPending($, key, reqId) {
603  if (key) {
604    if (agents[key] && agents[key].pending && agents[key].pending.reqId === reqId) agents[key].pending = null
605    changed($)
606  } else if (w) {
607    if (w.pending && w.pending.reqId === reqId) w.pending = null
608    await workerFlush($)
609    try {
610      await $.ui.status('swarm worker "' + worker.name + '" · reporting to the lead session')
611    } catch {}
612  }
613}
614
615// Waits for a decision from the pane (this process) or the lead (worker); null on timeout
616async function holdFor($, reqId, ms, signal) {
617  const until = (await $.clock.now()) + ms
618  while ((await $.clock.now()) < until) {
619    if (signal && signal.aborted) return null
620    if (decisions[reqId]) {
621      const d = decisions[reqId]
622      delete decisions[reqId]
623      return d
624    }
625    if (worker) {
626      let r = null
627      try {
628        r = await $.store.get('r:' + worker.id)
629      } catch {}
630      const d = r && r.decisions && r.decisions[reqId]
631      if (d) return d
632    }
633    await waitTick($)
634  }
635  return null
636}
637
638async function holdQuestion($, key, e, signal) {
639  const reqId = randomId()
640  const questions = (e.questions || []).map((q) => ({
641    question: q.question,
642    header: q.header || '',
643    multiSelect: !!q.multiSelect,
644    options: (q.options || []).map((o) => ({ label: o.label, description: o.description || '' })),
645  }))
646  await setPending($, key, { kind: 'question', reqId, tool: 'AskUserQuestion', summary: clip(questions[0] && questions[0].question, 60), questions, since: (await $.clock.now()) })
647  const d = await holdFor($, reqId, key ? TEAMMATE_HOLD_MS : WORKER_HOLD_MS, signal)
648  await clearPending($, key, reqId)
649  if (!d || !d.answers) return null
650  return { questions: e.questions, answers: d.answers }
651}
652
653// The lead resolves an agent's pending request
654async function resolvePending($, key, decision) {
655  const a = agents[key]
656  const p = a && a.pending
657  if (!p) return
658  if (a.transport === 'session') {
659    let cur = null
660    try {
661      cur = await $.store.get('r:' + key)
662    } catch {}
663    cur = cur || { decisions: {}, abortSeq: 0 }
664    const kept = Object.entries(cur.decisions || {}).slice(-20)
665    await $.store.set('r:' + key, { ...cur, decisions: { ...Object.fromEntries(kept), [p.reqId]: decision } })
666  } else {
667    decisions[p.reqId] = decision
668  }
669  a.resolvedReq = p.reqId
670  a.pending = null
671  delete qDraft[p.reqId]
672  const what = decision.answers ? 'answered' : decision.decision === 'allow' ? 'approved' : decision.decision === 'deny' ? 'denied' : 'sent to its own prompt'
673  pushLog(a, 'you ' + what + ': ' + p.summary, (await $.clock.now()))
674  changed($)
675}
676
677// ======================= spawning =======================
678
679async function scanAgentFiles($) {
680  const dirs = ['.claude' + sep() + 'agents']
681  if (homeDir) dirs.unshift(homeDir + sep() + '.claude' + sep() + 'agents')
682  for (const d of dirs) {
683    let entries = []
684    try {
685      entries = await $.fs.list(d)
686    } catch {
687      continue
688    }
689    for (const ent of entries.slice(0, 80)) {
690      if (ent.kind === 'directory' || !/\.md$/i.test(ent.name)) continue
691      try {
692        const parsed = parseAgentFile(await $.fs.read(d + sep() + ent.name))
693        if (parsed && !agentTypes[parsed.name]) agentTypes[parsed.name] = parsed.description
694      } catch {}
695    }
696  }
697}
698
699async function openWizard($) {
700  await scanAgentFiles($)
701  const taken = order.map((k) => agents[k].name)
702  let teamsOn = false
703  try {
704    teamsOn = (await $.env.get('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS')) === '1'
705  } catch {}
706  let cwd = ''
707  try {
708    cwd = await $.session.cwd()
709  } catch {}
710  draft = {
711    mode: 'teammate',
712    agentType: 'default',
713    model: 'inherit',
714    effort: 'default',
715    approval: 'default',
716    name: suggestName(NAMES.find((n) => !taken.map((t) => t.toLowerCase()).includes(n)) || 'agent', taken),
717    color: pickColor(liveColors()),
718    task: '',
719    teamsOn,
720    autoRestart: false,
721    cwd,
722  }
723  wizardError = ''
724  openPick = null
725  await $.ui.open({ id: WIZARD, title: 'Spawn agent', focus: true, closeOnEscape: true, rows: 36 })
726}
727
728async function spawnTeammate($, a, taskOverride) {
729  let cwd = ''
730  try {
731    cwd = await $.session.cwd()
732  } catch {}
733  const prompt = (taskOverride || a.task || '').trim() ||
734    'You are "' + a.name + '", a member of the user\'s agent swarm, working in ' + cwd + '. Introduce yourself in one line, then wait for instructions.'
735  const args = { prompt, description: 'swarm: ' + a.name, name: a.name }
736  if (a.agentType && a.agentType !== 'default') args.subagentType = a.agentType
737  const alias = a.model && a.model !== 'inherit' ? modelAlias(a.model) : ''
738  if (alias) args.model = alias
739  let r
740  try {
741    r = await $.agent.spawn(args)
742  } catch (err) {
743    fail(a, 'Spawn failed: ' + String((err && err.message) || err))
744    await toast($, a.activity)
745    return
746  }
747  if (!r || r.deny) {
748    fail(a, 'Spawn refused: ' + String((r && r.deny) || 'no answer from the agent spawner'))
749    await toast($, a.activity)
750    return
751  }
752  a.agentId = r.agentId
753  a.teammateId = r.teammateId
754  a.resolvedModel = r.model
755  if (r.teammateId) a.name = String(r.teammateId).split('@')[0] || a.name
756  // without an id yet, it stays pending and is matched by name from $.agent.list()
757  if (a.agentId && a.status === 'pending') a.status = 'running'
758  a.activity = ''
759  pushLog(a, r.teammateId ? 'joined the team as ' + r.teammateId : 'started as a named background agent', (await $.clock.now()))
760}
761
762async function launchWorker($, a, taskOverride) {
763  const os = await detectPlatform($)
764  let cwd = ''
765  try {
766    cwd = await $.session.cwd()
767  } catch {}
768  if (!homeDir) {
769    fail(a, 'Could not find the home directory to write the launch script')
770    return
771  }
772  const s = sep()
773  const dir = homeDir + s + '.claude' + s + 'swarm-mod' + s + 'launch' + s
774  const script = dir + a.key + (os === 'windows' ? '.ps1' : os === 'mac' ? '.command' : '.sh')
775  const settingsPath = dir + 'worker-settings.json'
776  const ttyPath = os === 'windows' ? '' : dir + a.key + '.tty'
777  const spec = {
778    id: a.key, lead: leadId, name: a.name, cwd, pluginRoot: $.plugin.root, settingsPath, ttyPath,
779    agentType: a.agentType, model: a.model, effort: a.effort, approval: a.approval, task: taskOverride || a.task,
780  }
781  a.script = script
782  a.ttyPath = ttyPath
783  let iterm = false
784  if (os === 'mac') {
785    try {
786      iterm = (await $.env.get('TERM_PROGRAM')) === 'iTerm.app'
787    } catch {}
788  }
789  try {
790    await $.fs.write(settingsPath, WORKER_SETTINGS)
791    await $.fs.write(script, launchScript(os === 'windows' ? 'windows' : 'posix', spec))
792    await $.store.set('r:' + a.key, { decisions: {}, abortSeq: 0 })
793  } catch (err) {
794    fail(a, 'Could not write the launch script: ' + String((err && err.message) || err))
795    return
796  }
797  if (os !== 'windows') {
798    try {
799      await $.process.run(['chmod', '+x', script], { timeoutMs: 5000 })
800    } catch {}
801  }
802  let lastErr = ''
803  for (const { argv, app } of launchCandidates(os, script, spec, { iterm })) {
804    try {
805      const r = await $.process.run(argv, { cwd, timeoutMs: 15000 })
806      if (r.exitCode === 0) {
807        a.termApp = app
808        pushLog(a, 'launched in a new ' + app + ' window', (await $.clock.now()))
809        a.activity = 'waiting for the session to start…'
810        return
811      }
812      lastErr = String(r.stderr || r.stdout || 'exit ' + r.exitCode).trim()
813    } catch (err) {
814      lastErr = String(err && err.message)
815    }
816  }
817  fail(a, 'Could not open a terminal (' + String(lastErr) + '). Run it yourself: ' + script)
818}
819
820async function spawnFromDraft($) {
821  const d = draft
822  if (!d) return
823  const taken = order.map((k) => agents[k].name)
824  const name = String(d.name || '').trim()
825  const err = validateName(name, taken)
826  if (err) {
827    wizardError = err
828    $.ui.invalidate('ui.render')
829    return
830  }
831  if (liveColors().includes(d.color) && liveColors().length < PALETTE.length) {
832    wizardError = 'That color is in use; pick another'
833    $.ui.invalidate('ui.render')
834    return
835  }
836  draft = null
837  try {
838    await $.ui.close({ id: WIZARD })
839  } catch {}
840  try {
841    await $.ui.open({ id: MAIN, title: 'Swarm' })
842  } catch {}
843  await spawnSpec($, {
844    name, color: d.color, agentType: d.agentType, model: d.model, effort: d.effort, approval: d.approval,
845    transport: d.mode, task: d.task || '', autoRestart: !!d.autoRestart,
846  })
847}
848
849// Adds one agent to the swarm and starts it: from the form, a roster, or a restore
850async function spawnSpec($, spec) {
851  const key = randomId()
852  const transport = spec.transport === 'session' ? 'session' : 'teammate'
853  const a = {
854    key, name: spec.name, color: spec.color, agentType: spec.agentType || 'default', model: spec.model || 'inherit',
855    effort: spec.effort || 'default', approval: spec.approval || 'default', transport,
856    autoRestart: !!spec.autoRestart, restarts: 0,
857    status: transport === 'session' ? 'launching' : 'pending', activity: 'spawning…',
858    tokens: emptyTokens(), ctx: 0, turns: 0, pending: null, log: [], files: [], lastAnswer: '',
859    startedAt: await $.clock.now(), task: spec.task || '',
860  }
861  agents[key] = a
862  order = [...order, key]
863  lastRoster = null
864  changed($)
865  if (transport === 'teammate') await spawnTeammate($, a)
866  else await launchWorker($, a)
867  changed($)
868  return key
869}
870
871// ======================= actions on an agent =======================
872
873// Marks an agent failed: the whole error is kept for copying, a short line is shown
874function fail(a, text) {
875  a.status = 'failed'
876  a.error = String(text)
877  a.activity = clip(a.error, 140)
878}
879
880// Copies an agent's whole error (not the clipped line) to the clipboard of the surface pressed on
881async function copyError($, key, press) {
882  const a = agents[key]
883  if (!a) return
884  const text = a.error || a.activity || ''
885  if (!text) return toast($, a.name + ' has no error text to copy')
886  let r
887  try {
888    r = await $.ui.copy({ text, ...(press && press.surface ? { surface: press.surface } : {}) })
889  } catch (err) {
890    r = { isCopied: false, reason: err && err.message }
891  }
892  await toast($, r.isCopied ? 'Copied ' + a.name + "'s error (" + text.length + ' chars)' : 'Could not copy the error: ' + r.reason)
893}
894
895async function sendTo($, key, text) {
896  const a = agents[key]
897  const t = String(text || '').trim()
898  if (!a || !t) return false
899  let to = null
900  if (a.transport === 'session') {
901    if (!a.sessionId) {
902      await toast($, a.name + ' has not reported its session yet')
903      return false
904    }
905    to = { sessionId: a.sessionId }
906  } else if (a.agentId) {
907    to = { agentId: a.agentId }
908  }
909  if (!to) return false
910  try {
911    let r = await $.session.send({ to, text: t })
912    if (!r.isDelivered && a.transport === 'teammate') r = await $.session.send({ to: a.name, text: t })
913    if (r.isDelivered) {
914      pushLog(a, '→ you: ' + t, (await $.clock.now()))
915      changed($)
916      return true
917    }
918    await toast($, 'Not delivered to ' + a.name + ': ' + r.reason)
919  } catch (err) {
920    await toast($, 'Not delivered to ' + a.name + ': ' + (err && err.message))
921  }
922  return false
923}
924
925async function broadcast($, text) {
926  let n = 0
927  for (const k of order) if (isLive(agents[k]) && (await sendTo($, k, text))) n++
928  await toast($, 'Broadcast reached ' + n + ' agent' + (n === 1 ? '' : 's'))
929}
930
931async function stopAgent($, key) {
932  const a = agents[key]
933  if (!a) return
934  if (a.transport === 'session') {
935    let cur = null
936    try {
937      cur = await $.store.get('r:' + key)
938    } catch {}
939    cur = cur || { decisions: {}, abortSeq: 0 }
940    await $.store.set('r:' + key, { ...cur, abortSeq: (cur.abortSeq || 0) + 1 })
941    pushLog(a, 'interrupt requested', (await $.clock.now()))
942  } else {
943    try {
944      await $.tool.call({ tool: 'TaskStop', task_id: a.teammateId || a.name })
945      pushLog(a, 'stop requested', (await $.clock.now()))
946    } catch (err) {
947      await toast($, 'Could not stop ' + a.name + ': ' + (err && err.message))
948    }
949  }
950  changed($)
951}
952
953async function focusWindow($, key) {
954  const a = agents[key]
955  if (!a) return
956  const os = await detectPlatform($)
957  let tty = ''
958  if (a.ttyPath) {
959    try {
960      tty = String(await $.fs.read(a.ttyPath)).trim()
961    } catch {}
962  }
963  try {
964    const r = await $.process.run(focusArgv(os, a.name, { tty: /^\/dev\//.test(tty) ? tty : '', app: a.termApp }), { timeoutMs: 10000 })
965    if (r.exitCode !== 0) throw new Error(String(r.stderr || 'exit ' + r.exitCode).trim())
966  } catch (err) {
967    const hint = os === 'mac' ? ' (allow Automation for your terminal in System Settings > Privacy & Security)' : ''
968    await toast($, 'Could not bring ' + a.name + ' forward' + hint + '; look for its "' + a.name + '" window or tab')
969  }
970}
971
972async function removeAgent($, key) {
973  const a = agents[key]
974  if (!a) return
975  delete agents[key]
976  order = order.filter((k) => k !== key)
977  try {
978    await $.store.delete('w:' + key)
979    await $.store.delete('r:' + key)
980  } catch {}
981  try {
982    await $.ui.close({ id: DETAIL + key })
983  } catch {}
984  changed($)
985}
986
987async function openDetail($, key) {
988  const a = agents[key]
989  if (!a) return
990  await $.ui.open({
991    id: DETAIL + key,
992    title: a.name,
993    focus: true,
994    closeOnEscape: true,
995    ...(expanded[key] ? { rows: 40, columns: 110 } : {}),
996  })
997}
998
999function statusText() {
1000  if (!order.length) return 'The swarm is empty. The user spawns agents from the swarm pane (/swarm).'
1001  return order
1002    .map((k) => {
1003      const a = agents[k]
1004      const sv = statusView(a)
1005      return [
1006        a.name + ' (' + a.transport + ', ' + a.agentType + ', ' + labelOf(MODELS, a.model) + ', effort ' + a.effort + ', approval ' + a.approval + ')',
1007        'status: ' + sv.word + (a.activity ? ' - ' + a.activity : ''),
1008        'tokens: ' + fmtTokens(totalTokens(a.tokens)) + ', turns: ' + (a.turns || 0) + (a.autoRestart ? ', auto-restart on (' + (a.restarts || 0) + '/' + RESTART_MAX + ' used)' : ''),
1009        a.pending ? 'waiting on the user: ' + a.pending.summary : '',
1010        a.lastAnswer ? 'last answer: ' + clip(a.lastAnswer, 300) : '',
1011      ].filter(Boolean).join('\n  ')
1012    })
1013    .join('\n')
1014}
1015
1016// ======================= drawing =======================
1017
1018function counts() {
1019  const list = order.map((k) => agents[k])
1020  return {
1021    list,
1022    working: list.filter((a) => ['running', 'working'].includes(a.status)).length,
1023    attention: list.filter((a) => a.pending).length,
1024    total: list.reduce((s, a) => s + totalTokens(a.tokens), 0),
1025  }
1026}
1027
1028function renderMain($, e) {
1029  const { Box, Text, Button, Input } = $.ui.resolve(e)
1030  const { list, working, attention, total } = counts()
1031  const rows = []
1032  rows.push(
1033    Box({
1034      key: 'head',
1035      flexDirection: 'row',
1036      columnGap: 1,
1037      children: [
1038        Text({ bold: true, color: '#c678dd', children: ['SWARM'] }),
1039        Text({ dimColor: true, children: [list.length + ' agents · ' + working + ' working'] }),
1040        ...(attention ? [Text({ bold: true, color: '#ff5555', children: ['· ' + attention + ' need you'] })] : []),
1041      ],
1042    }),
1043  )
1044  const liveCount = list.filter((a) => isLive(a) || a.restartAt).length
1045  rows.push(
1046    Box({
1047      key: 'toolbar',
1048      flexDirection: 'row',
1049      columnGap: 2,
1050      children: [
1051        Button({ key: 'spawn', label: 'n: + Spawn agent', hotkey: 'n', onPress: () => openWizard($) }),
1052        Button({ key: 'rosters', label: 'Rosters', hotkey: 'r', plain: true, onPress: () => openRosters($) }),
1053        ...(liveCount
1054          ? [Button({
1055              key: 'kill-all',
1056              label: killArmed ? 'k: Press again to kill ' + liveCount : 'Kill all',
1057              hotkey: 'k',
1058              ...(killArmed ? {} : { plain: true }),
1059              onPress: async () => {
1060                if (!killArmed) {
1061                  killArmed = true
1062                  $.ui.invalidate('ui.render')
1063                  $.clock.after(6000, () => {
1064                    killArmed = false
1065                    $.ui.invalidate('ui.render')
1066                  })
1067                  return
1068                }
1069                await killAll($)
1070              },
1071            })]
1072          : []),
1073      ],
1074    }),
1075  )
1076  if (lastRoster && lastRoster.agents && lastRoster.agents.length) {
1077    rows.push(
1078      Button({
1079        key: 'restore-last',
1080        label: 'Restore last swarm here (' + lastRoster.agents.length + ' agents: ' + clip(lastRoster.agents.map((x) => x.name).join(', '), 50) + ')',
1081        onPress: () => launchRoster($, lastRoster.agents, 'last swarm'),
1082      }),
1083    )
1084  }
1085  if (!list.length) {
1086    rows.push(Text({ dimColor: true, children: ['No agents yet. Spawn one, or launch a saved roster (r).'] }))
1087  }
1088  for (const a of list) {
1089    const sv = statusView(a)
1090    rows.push(
1091      Box({
1092        key: 'row-' + a.key,
1093        flexDirection: 'column',
1094        children: [
1095          Box({
1096            flexDirection: 'row',
1097            columnGap: 1,
1098            children: [
1099              Text({ color: a.color, children: ['●'] }),
1100              Button({ key: 'open-' + a.key, label: a.name, plain: true, onPress: () => openDetail($, a.key) }),
1101              a.status === 'failed'
1102                ? Button({ key: 'err-' + a.key, label: sv.glyph + ' ' + sv.word + ' · copy error', plain: true, onPress: async (press) => { await copyError($, a.key, press); await openDetail($, a.key) } })
1103                : Text({ color: sv.color, bold: !!sv.bold, children: [sv.glyph + ' ' + sv.word] }),
1104              ...(a.pending
1105                ? [Button({ key: 'attn-' + a.key, label: a.pending.kind === 'approval' ? '! review' : '? answer', onPress: () => openDetail($, a.key) })]
1106                : []),
1107            ],
1108          }),
1109          Text({
1110            dimColor: true,
1111            wrap: 'truncate-end',
1112            children: ['  ' + (a.activity || (a.transport === 'session' ? 'own window' : 'teammate')) + ' · ' + fmtTokens(totalTokens(a.tokens)) + ' tok' + (a.autoRestart ? ' · ↻' + (a.restarts ? ' ' + a.restarts + '/' + RESTART_MAX : '') : '')],
1113          }),
1114        ],
1115      }),
1116    )
1117  }
1118  for (const c of conflicts) {
1119    rows.push(Text({ key: 'conf-' + c.path, color: '#e5c07b', wrap: 'truncate-end', children: ['▲ ' + c.keys.map((k) => (agents[k] ? agents[k].name : k)).join(' & ') + ' both edited ' + baseName(c.path)] }))
1120  }
1121  if (list.length) {
1122    rows.push(Text({ dimColor: true, children: ['total ' + fmtTokens(total) + ' tokens'] }))
1123    rows.push(
1124      Input({
1125        key: 'broadcast',
1126        label: 'Broadcast',
1127        placeholder: 'message every live agent',
1128        value: '',
1129        submitLabel: 'send',
1130        onSubmit: (v) => broadcast($, v),
1131      }),
1132    )
1133    if (list.some((a) => !isLive(a))) {
1134      rows.push(
1135        Button({
1136          key: 'clear-done',
1137          label: 'Clear finished',
1138          hotkey: 'x',
1139          plain: true,
1140          onPress: async () => {
1141            for (const a of list) if (!isLive(a)) await removeAgent($, a.key)
1142          },
1143        }),
1144      )
1145    }
1146  }
1147  return Box({ flexDirection: 'column', gap: 0, children: rows })
1148}
1149
1150// One spawn-form field as a dropdown built from Buttons, so a click works as well as Enter:
1151// pressing the field opens its options; pressing an option picks it, closes the list and
1152// moves the focus to the next field. Focus leaving an open list closes it (ui.focus hook).
1153async function moveFocus($, key) {
1154  try {
1155    await $.ui.focus({ requestId: WIZARD, key })
1156  } catch {}
1157}
1158
1159function picker($, ui, f) {
1160  const { Box, Button } = ui
1161  const cur = f.options.find((o) => o.value === f.value) || f.options[0]
1162  const isOpen = openPick === f.key
1163  const head = Button({
1164    key: 'w-' + f.key,
1165    label: f.label + ': ' + cur.label + (isOpen ? ' ▴' : ' ▾'),
1166    plain: true,
1167    onPress: async () => {
1168      openPick = isOpen ? null : f.key
1169      $.ui.invalidate('ui.render')
1170      if (!isOpen) await moveFocus($, 'w-' + f.key + ':' + cur.value)
1171    },
1172  })
1173  const rows = [f.prefix ? Box({ flexDirection: 'row', columnGap: 1, children: [...f.prefix, head] }) : head]
1174  if (isOpen) {
1175    rows.push(Box({
1176      flexDirection: 'column',
1177      paddingLeft: 2,
1178      children: f.options.map((o) => Button({
1179        key: 'w-' + f.key + ':' + o.value,
1180        label: (o.value === cur.value ? '● ' : '○ ') + o.label,
1181        plain: true,
1182        onPress: async () => {
1183          f.set(o.value)
1184          openPick = null
1185          $.ui.invalidate('ui.render')
1186          await moveFocus($, f.next)
1187        },
1188      })),
1189    }))
1190  }
1191  return Box({ key: 'f-' + f.key, flexDirection: 'column', children: rows })
1192}
1193
1194function renderWizard($, e) {
1195  const { Box, Text, Button, Input } = $.ui.resolve(e)
1196  const redraw = () => $.ui.invalidate('ui.render')
1197  if (!draft) {
1198    return Box({ flexDirection: 'column', children: [Text({ dimColor: true, children: ['Nothing to spawn.'] }), Button({ key: 'w-close', label: 'Close', onPress: () => $.ui.close({ id: WIZARD }) })] })
1199  }
1200  const d = draft
hooks/lib.js 510 lines
1// Pure helpers for swarm-mod: no mods API calls here, so they can be imported
2// and unit tested freely.
3
4// Distinct, readable on dark and light themes. Hex works on both surfaces.
5export const PALETTE = [
6  { hex: '#e06c75', name: 'coral' },
7  { hex: '#98c379', name: 'green' },
8  { hex: '#e5c07b', name: 'gold' },
9  { hex: '#61afef', name: 'blue' },
10  { hex: '#c678dd', name: 'violet' },
11  { hex: '#56b6c2', name: 'teal' },
12  { hex: '#d19a66', name: 'orange' },
13  { hex: '#ff79c6', name: 'pink' },
14  { hex: '#8be9fd', name: 'sky' },
15  { hex: '#50fa7b', name: 'mint' },
16  { hex: '#bd93f9', name: 'lavender' },
17  { hex: '#f1fa8c', name: 'lemon' },
18]
19
20export function colorName(hex) {
21  const hit = PALETTE.find((c) => c.hex === hex)
22  return hit ? hit.name : hex
23}
24
25// A random palette color no live agent uses; any color once all are taken
26export function pickColor(used, rand = Math.random) {
27  const free = PALETTE.filter((c) => !used.includes(c.hex))
28  const pool = free.length ? free : PALETTE
29  return pool[Math.floor(rand() * pool.length) % pool.length].hex
30}
31
32export const MODES = [
33  { value: 'teammate', label: 'Teammate (agent team, in this session)' },
34  { value: 'session', label: 'Separate Claude Code session (own window)' },
35]
36
37export const MODELS = [
38  { value: 'inherit', label: "Inherit (lead's model)" },
39  { value: 'claude-opus-5-5', label: 'Opus 5.5' },
40  { value: 'claude-sonnet-5-5', label: 'Sonnet 5.5' },
41  { value: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
42  { value: 'claude-fable-5-1', label: 'Fable 5.1' },
43]
44
45// $.agent.spawn only takes family aliases; `claude --model` takes full ids.
46// Maps a stored model value to its alias, or '' when it has none.
47export function modelAlias(model) {
48  const m = String(model || '').toLowerCase()
49  return ['opus', 'sonnet', 'haiku', 'fable'].find((f) => m === f || m.includes('-' + f + '-') || m.startsWith(f + '-')) || ''
50}
51
52export const EFFORTS = [
53  { value: 'default', label: 'Default' },
54  { value: 'low', label: 'Low' },
55  { value: 'medium', label: 'Medium' },
56  { value: 'high', label: 'High' },
57  { value: 'xhigh', label: 'Extra high' },
58  { value: 'max', label: 'Max' },
59]
60
61export const APPROVALS = [
62  { value: 'default', label: 'Ask me (default)' },
63  { value: 'acceptEdits', label: 'Accept edits' },
64  { value: 'auto', label: 'Auto' },
65  { value: 'plan', label: 'Plan / read-only' },
66  { value: 'dontAsk', label: "Don't ask (deny unless pre-approved)" },
67  { value: 'bypassPermissions', label: 'Bypass all checks (dangerous)' },
68]
69
70export const BUILTIN_AGENTS = ['general-purpose', 'Explore', 'Plan']
71
72export const EDIT_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'MultiEdit']
73
74export const NAME_RE = /^[A-Za-z0-9_-]{1,40}$/
75
76export function labelOf(list, value) {
77  const hit = list.find((o) => o.value === value)
78  return hit ? hit.label : String(value)
79}
80
81// "scout", "scout-2", ... the first one nobody has
82export function suggestName(base, taken) {
83  const clean = String(base || 'agent').replace(/[^A-Za-z0-9_-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 30) || 'agent'
84  const lower = taken.map((t) => t.toLowerCase())
85  if (!lower.includes(clean.toLowerCase())) return clean
86  for (let i = 2; i < 1000; i++) {
87    const n = clean + '-' + i
88    if (!lower.includes(n.toLowerCase())) return n
89  }
90  return clean + '-' + Date.now()
91}
92
93export function validateName(name, taken) {
94  if (!NAME_RE.test(name)) return 'Name: 1-40 letters, digits, - or _'
95  if (taken.map((t) => t.toLowerCase()).includes(name.toLowerCase())) return 'Name "' + name + '" is taken in this swarm'
96  return null
97}
98
99export function fmtTokens(n) {
100  const v = Number(n) || 0
101  if (v < 1000) return String(v)
102  if (v < 1e6) return (v / 1000).toFixed(v < 1e4 ? 1 : 0) + 'k'
103  return (v / 1e6).toFixed(2) + 'M'
104}
105
106export function emptyTokens() {
107  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
108}
109
110// Adds one API response's usage; returns that response's context size
111export function addUsage(tokens, usage) {
112  if (!usage) return 0
113  tokens.input += usage.input_tokens || 0
114  tokens.output += usage.output_tokens || 0
115  tokens.cacheRead += usage.cache_read_input_tokens || 0
116  tokens.cacheWrite += usage.cache_creation_input_tokens || 0
117  return (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0)
118}
119
120export function totalTokens(t) {
121  if (!t) return 0
122  return (t.input || 0) + (t.output || 0) + (t.cacheRead || 0) + (t.cacheWrite || 0)
123}
124
125function clip(s, n) {
126  const one = String(s ?? '').replace(/\s+/g, ' ').trim()
127  return one.length > n ? one.slice(0, n - 1) + '…' : one
128}
129export { clip }
130
131// One line saying what a tool call does, for the activity column
132export function summarizeTool(tool, input) {
133  const i = input || {}
134  if (tool === 'Bash' || tool === 'PowerShell') return tool + ': ' + clip(i.command, 60)
135  if (i.file_path) return tool + ': ' + baseName(i.file_path)
136  if (i.notebook_path) return tool + ': ' + baseName(i.notebook_path)
137  if (tool === 'Grep' || tool === 'Glob') return tool + ': ' + clip(i.pattern, 40)
138  if (tool === 'WebFetch') return 'WebFetch: ' + clip(i.url, 50)
139  if (tool === 'WebSearch') return 'WebSearch: ' + clip(i.query, 50)
140  if (tool === 'Agent') return 'Agent: ' + clip(i.description || i.name, 40)
141  if (tool === 'SendMessage') return 'SendMessage → ' + clip(i.to, 30)
142  if (tool === 'AskUserQuestion') return 'Asking a question'
143  return tool
144}
145
146// The detail of a held tool call, as shown in the approval box
147export function describeCall(tool, input) {
148  const i = input || {}
149  if (i.command) return String(i.command)
150  if (i.file_path && i.old_string !== undefined) return i.file_path + '\n- ' + clip(i.old_string, 300) + '\n+ ' + clip(i.new_string, 300)
151  if (i.file_path && i.content !== undefined) return i.file_path + '\n' + clip(i.content, 400)
152  try {
153    return clip(JSON.stringify(i), 600)
154  } catch {
155    return tool
156  }
157}
158
159export function baseName(p) {
160  const parts = String(p).split(/[\\/]/)
161  return parts[parts.length - 1] || String(p)
162}
163
164export function normPath(p, isWindows) {
165  const s = String(p || '').replace(/\\/g, '/')
166  return isWindows ? s.toLowerCase() : s
167}
168
169// The decision a teammate's approval mode gives, from the engine's own
170export function decide(mode, tool, engine) {
171  if (engine === 'deny') return 'deny'
172  const isEdit = EDIT_TOOLS.includes(tool)
173  switch (mode) {
174    case 'bypassPermissions':
175      return 'allow'
176    case 'acceptEdits':
177      return isEdit ? 'allow' : engine
178    case 'plan':
179      if (isEdit) return 'deny'
180      return engine === 'ask' ? 'deny' : engine
181    case 'dontAsk':
182      return engine === 'ask' ? 'deny' : engine
183    default:
184      return engine
185  }
186}
187
188// Status → glyph, word and color. Single-width glyphs only.
189export function statusView(a) {
190  if (a.pending && a.pending.kind === 'approval') return { glyph: '!', word: 'needs approval', color: '#ff5555', bold: true }
191  if (a.pending && a.pending.kind === 'question') return { glyph: '?', word: 'has a question', color: '#ff79c6', bold: true }
192  switch (a.status) {
193    case 'launching':
194    case 'pending':
195    case 'starting':
196      return { glyph: '○', word: 'starting', color: 'gray' }
197    case 'running':
198    case 'working':
199      return { glyph: '↯', word: 'working', color: '#50fa7b' }
200    case 'waiting':
201      return { glyph: '…', word: 'waiting', color: '#e5c07b' }
202    case 'idle':
203      return { glyph: '○', word: 'idle', color: '#61afef' }
204    case 'completed':
205      return { glyph: '✓', word: 'done', color: '#98c379' }
206    case 'failed':
207      return { glyph: '✕', word: 'failed', color: '#ff5555' }
208    case 'killed':
209      return { glyph: '✕', word: 'stopped', color: 'gray' }
210    case 'exited':
211      return { glyph: '✕', word: 'exited', color: 'gray' }
212    case 'unresponsive':
213      return { glyph: '▲', word: 'no heartbeat', color: '#e5c07b' }
214    default:
215      return { glyph: '·', word: String(a.status || 'unknown'), color: 'gray' }
216  }
217}
218
219export function isLive(a) {
220  return !['completed', 'failed', 'killed', 'exited'].includes(a.status)
221}
222
223// ---------- launching a separate Claude Code session ----------
224
225export function psQuote(s) {
226  return "'" + String(s).replace(/'/g, "''") + "'"
227}
228
229export function shQuote(s) {
230  return "'" + String(s).replace(/'/g, "'\\''") + "'"
231}
232
233// The claude CLI arguments for a worker session
234export function claudeArgs(spec) {
235  const args = ['--name', spec.name, '--plugin-dir', spec.pluginRoot]
236  if (spec.agentType && spec.agentType !== 'default' && !BUILTIN_AGENTS.includes(spec.agentType)) args.push('--agent', spec.agentType)
237  if (spec.model && spec.model !== 'inherit') args.push('--model', spec.model)
238  if (spec.effort && spec.effort !== 'default') args.push('--effort', spec.effort)
239  if (spec.approval === 'bypassPermissions') args.push('--dangerously-skip-permissions')
240  else if (spec.approval && spec.approval !== 'default') args.push('--permission-mode', spec.approval)
241  // Let the lead's messages through whatever the worker's permission mode. A file, not
242  // inline JSON: Windows PowerShell 5.1 mangles quotes inside native arguments.
243  if (spec.settingsPath) args.push('--settings', spec.settingsPath)
244  // The first task is not an argument either: it travels in SWARM_MOD_TASK and the
245  // worker submits it, so no quoting of free text reaches a command line.
246  return args
247}
248
249export function windowTitle(name) {
250  return 'swarm: ' + name
251}
252
253// The script a new terminal runs: identity in env, cd to the lead's workspace, start claude
254export function launchScript(os, spec) {
255  const args = claudeArgs(spec)
256  if (os === 'windows') {
257    return [
258      '# swarm-mod worker launcher (generated)',
259      '$env:SWARM_MOD_WORKER = ' + psQuote(spec.id),
260      '$env:SWARM_MOD_LEAD = ' + psQuote(spec.lead),
261      '$env:SWARM_MOD_NAME = ' + psQuote(spec.name),
262      '$env:SWARM_MOD_APPROVAL = ' + psQuote(spec.approval || 'default'),
263      '$env:SWARM_MOD_TASK = ' + psQuote((spec.task || '').trim()),
264      'Set-Location -LiteralPath ' + psQuote(spec.cwd),
265      'try { $Host.UI.RawUI.WindowTitle = ' + psQuote(windowTitle(spec.name)) + ' } catch {}',
266      '& claude ' + args.map(psQuote).join(' '),
267      '',
268    ].join('\r\n')
269  }
270  return [
271    '#!/bin/sh',
272    '# swarm-mod worker launcher (generated)',
273    'export SWARM_MOD_WORKER=' + shQuote(spec.id),
274    'export SWARM_MOD_LEAD=' + shQuote(spec.lead),
275    'export SWARM_MOD_NAME=' + shQuote(spec.name),
276    'export SWARM_MOD_APPROVAL=' + shQuote(spec.approval || 'default'),
277    'export SWARM_MOD_TASK=' + shQuote((spec.task || '').trim()),
278    'cd ' + shQuote(spec.cwd) + ' || exit 1',
279    // which terminal tab this is, so the lead can bring it forward later
280    ...(spec.ttyPath ? ['tty > ' + shQuote(spec.ttyPath) + ' 2>/dev/null || :'] : []),
281    "printf '\\033]0;%s\\007' " + shQuote(windowTitle(spec.name)),
282    // through the login shell, so PATH (claude, node) is what the user's own shell has,
283    // whichever app started this script; fish and other non-POSIX shells fall back to zsh/bash
284    'case "${SHELL##*/}" in bash|zsh|ksh|sh|dash) LSH="$SHELL" ;; *) LSH=/bin/zsh; [ -x "$LSH" ] || LSH=/bin/bash ;; esac',
285    `exec "$LSH" -lic 'exec claude "$@"' claude ` + args.map(shQuote).join(' '),
286    '',
287  ].join('\n')
288}
289
290// AppleScript string literal
291function asQuote(s) {
292  return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'
293}
294
295// Ways to open a terminal running the script, best first: [{ argv, app }]
296export function launchCandidates(os, scriptPath, spec, prefs = {}) {
297  if (os === 'windows') {
298    const ps = ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-NoExit', '-File', scriptPath]
299    return [
300      // one Windows Terminal window named "swarm", one tab per agent
301      { app: 'wt', argv: ['wt.exe', '-w', 'swarm', 'new-tab', '--title', windowTitle(spec.name), '-d', spec.cwd, ...ps] },
302      // plain console window; start's first quoted argument is the title
303      { app: 'console', argv: ['cmd', '/d', '/c', 'start', windowTitle(spec.name) + ' ', '/D', spec.cwd, ...ps] },
304    ]
305  }
306  if (os === 'mac') {
307    const list = []
308    if (prefs.iterm) {
309      // the lead runs in iTerm2: a new iTerm2 window (asks once for Automation permission)
310      list.push({
311        app: 'iTerm',
312        argv: ['osascript', '-e', 'tell application "iTerm"', '-e', 'activate', '-e',
313          'create window with default profile command ' + asQuote('/bin/sh ' + shQuote(scriptPath)), '-e', 'end tell'],
314      })
315    }
316    // Terminal.app runs a .command file in a new window; needs no Automation permission
317    list.push({ app: 'Terminal', argv: ['open', '-a', 'Terminal', scriptPath] })
318    return list
319  }
320  return [
321    { app: 'x-terminal-emulator', argv: ['x-terminal-emulator', '-e', '/bin/sh', scriptPath] },
322    { app: 'gnome-terminal', argv: ['gnome-terminal', '--', '/bin/sh', scriptPath] },
323    { app: 'konsole', argv: ['konsole', '-e', '/bin/sh', scriptPath] },
324    { app: 'xterm', argv: ['xterm', '-e', '/bin/sh', scriptPath] },
325  ]
326}
327
328// Bring a worker's window to the front (best effort). Claude Code retitles its
329// terminal, so on macOS the tab is found by the tty the launch script recorded.
330export function focusArgv(os, name, where = {}) {
331  const title = windowTitle(name)
332  if (os === 'windows') {
333    return ['powershell', '-NoProfile', '-NonInteractive', '-Command',
334      '$s = New-Object -ComObject WScript.Shell; if (-not $s.AppActivate(' + psQuote(title) + ')) { [void]$s.AppActivate(' + psQuote(name) + ') }']
335  }
336  if (os === 'mac') {
337    const tty = where.tty ? asQuote(where.tty) : null
338    if (where.app === 'iTerm') {
339      return ['osascript',
340        '-e', 'tell application "iTerm"',
341        '-e', 'activate',
342        ...(tty ? [
343          '-e', 'repeat with w in windows',
344          '-e', 'repeat with t in tabs of w',
345          '-e', 'repeat with s in sessions of t',
346          '-e', 'if tty of s is ' + tty + ' then',
347          '-e', 'select w',
348          '-e', 'select t',
349          '-e', 'select s',
350          '-e', 'return "ok"',
351          '-e', 'end if',
352          '-e', 'end repeat',
353          '-e', 'end repeat',
354          '-e', 'end repeat',
355        ] : []),
356        '-e', 'end tell']
357    }
358    return ['osascript',
359      '-e', 'tell application "Terminal"',
360      '-e', 'activate',
361      ...(tty ? [
362        '-e', 'repeat with w in windows',
363        '-e', 'repeat with t in tabs of w',
364        '-e', 'if tty of t is ' + tty + ' then',
365        '-e', 'set selected tab of w to t',
366        '-e', 'set index of w to 1',
367        '-e', 'return "ok"',
368        '-e', 'end if',
369        '-e', 'end repeat',
370        '-e', 'end repeat',
371      ] : []),
372      '-e', 'try',
373      '-e', 'set index of (first window whose name contains ' + asQuote(name) + ') to 1',
374      '-e', 'end try',
375      '-e', 'end tell']
376  }
377  return ['wmctrl', '-a', title]
378}
379
380// Parses `name:` and `description:` from an agent file's frontmatter
381export function parseAgentFile(text) {
382  const m = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(String(text))
383  if (!m) return null
384  const field = (k) => {
385    const r = new RegExp('^' + k + ':\\s*(.*)$', 'm').exec(m[1])
386    return r ? r[1].trim().replace(/^["']|["']$/g, '') : ''
387  }
388  const name = field('name')
389  return name ? { name, description: field('description') } : null
390}
391
392// Files two or more agents have edited: [{ path, keys }]
393// caseInsensitive: Windows, and macOS (APFS is case-insensitive by default)
394export function findConflicts(agents, caseInsensitive) {
395  const byPath = new Map()
396  for (const a of agents) {
397    for (const f of a.files || []) {
398      const k = normPath(f, caseInsensitive)
399      if (!byPath.has(k)) byPath.set(k, { path: f, keys: [] })
400      const entry = byPath.get(k)
401      if (!entry.keys.includes(a.key)) entry.keys.push(a.key)
402    }
403  }
404  return [...byPath.values()].filter((e) => e.keys.length > 1)
405}
406
407export function randomId(rand = Math.random) {
408  let s = ''
409  for (let i = 0; i < 10; i++) s += Math.floor(rand() * 36).toString(36)
410  return s
411}
412
413// The settings file a worker starts with
414export const WORKER_SETTINGS = JSON.stringify({ crossSessionInbound: 'accept' }, null, 2) + '\n'
415
416// ---------- process ids (kill a separate session) ----------
417
418// Run from inside a session: prints "<claude pid> <process name>" (the caller's parent)
419export function selfPidArgv(os) {
420  if (os === 'windows') {
421    return ['powershell', '-NoProfile', '-NonInteractive', '-Command',
422      "$p = (Get-CimInstance Win32_Process -Filter ('ProcessId=' + $PID)).ParentProcessId; Write-Output ('' + $p + ' ' + (Get-Process -Id $p).ProcessName)"]
423  }
424  return ['/bin/sh', '-c', 'echo "$PPID $(ps -o comm= -p $PPID)"']
425}
426
427export function parsePidLine(text) {
428  const m = /^\s*(\d+)\s+(.+?)\s*$/m.exec(String(text || ''))
429  if (!m) return null
430  return { pid: Number(m[1]), name: baseName(m[2]) }
431}
432
433// Prints the name of a process now, to check a pid was not reused before killing it
434export function pidNameArgv(os, pid) {
435  if (os === 'windows') {
436    return ['powershell', '-NoProfile', '-NonInteractive', '-Command', '(Get-Process -Id ' + Number(pid) + ' -ErrorAction SilentlyContinue).ProcessName']
437  }
438  return ['ps', '-o', 'comm=', '-p', String(Number(pid))]
439}
440
441export function killArgv(os, pid) {
442  if (os === 'windows') return ['taskkill', '/PID', String(Number(pid)), '/T', '/F']
443  return ['kill', '-TERM', String(Number(pid))]
444}
445
446// ---------- auto-restart ----------
447
448export const RESTART_MAX = 3
449export const UNRESPONSIVE_RESTART_MS = 60000
450
451// Seconds to wait before restart n (0-based): 5, 15, 45
452export function restartDelayMs(n) {
453  return 5000 * Math.pow(3, Math.max(0, n))
454}
455
456// Session-end reasons that mean the person ended the worker on purpose
457export const DELIBERATE_EXITS = ['prompt_input_exit', 'logout']
458
459// Whether an agent ended in a way auto-restart should undo. Stopping it yourself
460// (Kill, /exit, or the agent panel's stop) never counts. `now` is the lead's clock:
461// a worker counts as hung once the lead has seen it silent for a full minute, so a
462// laptop waking from sleep does not restart everything.
463export function needsRestart(a, now) {
464  if (!a.autoRestart || a.killedByUser || (a.restarts || 0) >= RESTART_MAX) return false
465  if (a.transport === 'teammate') return a.status === 'failed'
466  if (a.status === 'exited') return !DELIBERATE_EXITS.includes(a.exitReason)
467  if (a.status === 'failed') return true
468  return a.status === 'unresponsive' && !!a.unresponsiveSince && now - a.unresponsiveSince > UNRESPONSIVE_RESTART_MS
469}
470
471export function restartTask(task) {
472  const note = '(You were restarted after your previous run ended unexpectedly. Check the current state of the workspace before continuing.)'
473  return String(task || '').trim() ? note + '\n\n' + String(task).trim() : note
474}
475
476// ---------- saved rosters ----------
477
478export const ROSTER_FIELDS = ['name', 'color', 'agentType', 'model', 'effort', 'approval', 'transport', 'task', 'autoRestart']
479
480export function rosterEntry(a) {
481  const e = {}
482  for (const f of ROSTER_FIELDS) if (a[f] !== undefined) e[f] = a[f]
483  return e
484}
485
486export function rosterKey(name) {
487  return 'roster:' + String(name).trim()
488}
489
490export function lastRosterKey(cwd, caseInsensitive) {
491  return 'last:' + normPath(cwd, caseInsensitive).replace(/\/+$/, '')
492}
493
494// A roster's entries made to fit beside the agents already here: free names and colors
495export function planRoster(entries, takenNames, usedColors, rand = Math.random) {
496  const names = [...takenNames]
497  const colors = [...usedColors]
498  return entries.map((e) => {
499    const name = suggestName(e.name, names)
500    names.push(name)
501    const color = e.color && !colors.includes(e.color) ? e.color : pickColor(colors, rand)
502    colors.push(color)
503    return { ...e, name, color }
504  })
505}
506
507export function validRosterName(name) {
508  return /^[A-Za-z0-9 _.-]{1,40}$/.test(String(name || '').trim())
509}
510
types/index.d.ts 8 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'swarm-mod': {
4      snapshot: { agents: Record<string, unknown>; order: string[] }
5    }
6  }
7}
8