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

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 pane | Spawn form |
|---|---|
![]() | ![]() |
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.
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.
| Feature | How |
|---|---|
| Spawn Agent button | The n: + Spawn agent button in the swarm pane, + Spawn agent in the band above the prompt, or /swarm spawn. |
| Spawn form | Transport (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 workspace | Every 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 state | starting, 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 use | Per agent: total, input, output, cache read, cache write, current context size, and turn count. Plus a swarm total. |
| Agent view | Click 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 errors | A 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 icon | A 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". |
| Messaging | Message 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 / interrupt | Per 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 all | k: 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-restart | Off 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 rosters | r: 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 swarm | Each 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). |
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:
swarm (wt.exe -w swarm new-tab …), or a plain console window if Windows Terminal is missing.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.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.
| Mode | Separate session | Teammate (enforced by the mod) |
|---|---|---|
| Ask me (default) | --permission-mode default | Anything the lead would ask about waits in the swarm pane |
| Accept edits | acceptEdits | Edit, Write, and NotebookEdit allowed; the rest as default |
| Auto | auto | Follows the lead session's own mode |
| Plan / read-only | plan | Edits denied, and anything needing approval denied, with a reason Claude can act on |
| Don't ask | dontAsk | Anything not pre-approved is denied |
| Bypass all | --dangerously-skip-permissions | Everything 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.
/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.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.n spawn, r rosters, k kill all (twice), x clear finished.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.c close.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.
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).
.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.
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.
▲ line in the pane, a warning in each agent's panel, and a toast.git worktree so edits never collide, with a merge view.turn.step can refuse further steps).~/.claude/tasks/<team>/) with owners, color-coded by agent, plus drag-to-assign.$.model.complete) in the pane.MIT. See LICENSE.
hooks/register.js 1804 lines1// 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 lines1// 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}
510types/index.d.ts 8 lines1declare module 'claude-code' {
2 interface PluginState {
3 'swarm-mod': {
4 snapshot: { agents: Record<string, unknown>; order: string[] }
5 }
6 }
7}
8