Mid-task messaging for Claude Code agent teams: SendMessage reaches a running teammate at its next step, plus hold, standing orders, broadcast and live agent…

<img src="docs/images/hero.png" width="100%" alt="BetterAgentMessaging, for Claude Code subagents and agent teams: messages reach a working agent at its next step, not after it finishes. A train graph shows three agents climbing through their steps while team-lead's messages land on them mid-task, one agent paused by a hold.">
A Claude Code mod that delivers messages to agents while they are working, instead of after they finish.
An independent open-source project, not affiliated with or endorsed by Anthropic. It uses Claude Code's mods API, which is in early access and may change.
In an agent team, a teammate running in its own pane receives messages only between turns. If Claude tells a working teammate to stop or change course, the teammate reads the message after it has finished its task. Reports from teammates to Claude are held the same way, until Claude's own turn ends.
<img src="docs/images/before-after.png" width="100%" alt="Chart of a teammate's steps over time after Claude tells it, during step 2, to stop after step 4. Without the mod it runs all 10 steps and reads the message when the task is done, 184 seconds later. With the mod it reads the message at its next step, 7 seconds later, and stops at step 4.">
The runs behind this chart are in docs/experiment.md.
hold: blocks every tool call of an agent and of the helpers it starts. hold ship: blocks only pushes, merges, releases, publishing, deploys and recursive deletes, from Bash or, on Windows, PowerShell. release lifts either. Claude Code enforces holds; the agent does not have to comply.standing: sets a rule that is restated to the agent after context compaction. Sent to all, it also goes to agents started later./agent-status lists every agent, and Claude is told when an agent looks stuck.main or lead are delivered to Claude, and a teammate that finishes without reporting has its whole final answer forwarded. A stopped teammate's old idle notice, which Claude Code can hand over hours late at the next spawn, is dropped.claude plugin install better-agent-messaging --marketplace flyryan/BetterAgentMessaging-CC
Restart any open Claude Code sessions afterwards. The mod installs at user scope (the default), so it loads in every Claude Code process, including each teammate's pane. Inside Claude Code the same install is /plugin install better-agent-messaging --marketplace flyryan/BetterAgentMessaging-CC.
Requires Claude Code 2.1.294 or later. Works with background subagents and with agent teams; teammates in their own tmux panes benefit most.
claude plugin list # listed and enabled?
claude plugin update better-agent-messaging # then restart open sessions
claude plugin uninstall better-agent-messaging
/agent-status exists only while the mod is loaded.
There is nothing to set up. Ask Claude in plain language, for example "hold the reviewer's pushes until QA signs off", and Claude puts the matching directive on the first line of its message to that agent. The mod teaches Claude the directives and adds them to every agent's instructions.
| First line | Effect |
|---|---|
hold: <reason> | Refuses every tool call except messaging, until release. |
hold ship: <reason> | Refuses git push, PR merges, releases, npm publish, deploys and rm -rf (Remove-Item -Recurse, rd /s in PowerShell), until release. |
release or release: <note> | Lifts a hold. |
standing: <rule> | Keeps a rule in force across compaction. |
standing clear | Drops all standing rules. |
A message to all reaches every running or idle agent.
<img src="docs/images/team-window.png" width="100%" alt="A tmux window with three Claude Code panes. Left, Claude as team-lead, which has just sent two messages mid-task; above its prompt one dim line reads: review can't ship: QA hasn't signed off. Top right, teammate impl, on step 4 of 8; above its prompt: team-lead sent (unread): also write DONE to impl.log after your last step. Bottom right, teammate review, on check 4; above its prompt: team-lead sent (unread): hold ship: QA hasn't signed off, and below it, team-lead blocked shipping: QA hasn't signed off.">
The line above each prompt shows what is not visible elsewhere: a mid-task message until it is read (team-lead sent (unread): …, then (read) for 15 seconds, the sender's name in its team colour), a teammate's own hold (team-lead blocked shipping: …), and in Claude's pane any agent that is paused or stuck (review can't ship: …, impl looks stuck: in Bash for 47m). team-lead is Claude's name in its team.
<img src="docs/images/ship-hold.png" width="664" alt="Teammate review's pane. It runs git push origin main once, and the harness refuses it: Error: On a ship hold by team-lead: QA hasn't signed off. Pushing, merging, releasing, publishing, deploying and rm -rf are refused until team-lead sends release. Other work may continue. review reports the refusal and stops without retrying. Above its prompt: team-lead blocked shipping: QA hasn't signed off.">
A push attempted under hold ship: is refused by Claude Code.
Set in /config under the mod's name:
| Setting | Default | Meaning |
|---|---|---|
staleMinutes | 20 | An agent with no step and no tool for this long is reported as stuck. |
toolStaleMinutes | 45 | An agent inside one tool call for this long is reported as stuck. |
shipPattern | empty | A regular expression for more Bash or PowerShell commands hold ship: refuses. The built-in list always applies. |
trace | false | Writes every decision the mod makes to ~/.claude/better-agent-messaging/trace/. |
team-lead. The team mailbox is a file any process of the same user can write, so this guards against mistakes, not attacks.hold ship: matches Bash and PowerShell command text against a pattern. It is a safety net, not a sandbox.Every Claude Code process in the team loads the mod. Each one handles delivery into its own conversation and enforces directives for the agents it runs; teammates write a small status file that Claude's process reads. docs/how-it-works.md covers the delivery path, the hold gate, the status files and the Claude Code events the mod uses.
git clone https://github.com/flyryan/BetterAgentMessaging-CC
cd BetterAgentMessaging-CC
claude --plugin-dir . # run a session with your working copy
claude plugin validate . # check what Claude Code will load
claude plugin test . # run the tests
tsc -p . # type-check, once a session has loaded the mod
MIT © Ryan Duff
hooks/register.tsx 777 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, SessionSendResult } from 'claude-code'
3
4import type { AgentRow, Delivery, Hold, TeammateStatus } from '../types'
5import { gateVerdict, SHELL_TOOLS, shipMatcher } from './gate'
6import {
7 arrivalLine,
8 CONTROLLER_NOTE,
9 CONTROLLER_SECTION,
10 controllerAlias,
11 deliveryNotice,
12 deliveryText,
13 extractStanding,
14 forwardedAnswer,
15 heldLine,
16 idleNotice,
17 isBroadcast,
18 isProtocolJson,
19 parseVerb,
20 spawnProtocol,
21 standingBlock,
22 staleText,
23 teammateFrame,
24 type Verb,
25 wakeText,
26} from './protocol'
27import { homeDir, isWindows, parseRole, ROLE_WALK, type Role } from './role'
28import { agentTable, alertLines, rowLine, staleness } from './table'
29import { traceLine } from './trace'
30
31// Every process of an agent team loads this module: the lead and each pane
32// teammate (the engine hands teammates the session's plugin folders). Each one
33// fixes delivery for its own main loop, enforces the directives aimed at the
34// loops it runs, and the lead gathers the teammates' status files into its
35// view. In-process subagents need no delivery fix: the engine already hands
36// them a message at their next tool round.
37
38const PANE = 'better-agent-messaging'
39const COMMAND = 'agent-status'
40// An agent's status that still takes messages.
41const LIVE = new Set(['pending', 'running', 'waiting', 'idle'])
42
43const holdsAtom = atom({ plugin: 'better-agent-messaging', key: 'holds' } as const, {})
44const standingAtom = atom({ plugin: 'better-agent-messaging', key: 'standing' } as const, {})
45const rowsAtom = atom({ plugin: 'better-agent-messaging', key: 'rows' } as const, [])
46const turnAtom = atom({ plugin: 'better-agent-messaging', key: 'isTurnRunning' } as const, false)
47const arrivalAtom = atom({ plugin: 'better-agent-messaging', key: 'arrival' } as const, null)
48const heldAtom = atom({ plugin: 'better-agent-messaging', key: 'held' } as const, [])
49// An idle notice older than this, from a teammate no longer running, is the
50// engine's late hand-over of a stopped teammate's last notice: dropped.
51const STALE_IDLE_MS = 2 * 60_000
52// How long a read arrival stays in the band; an unread one stays until it is read.
53const READ_MS = 15_000
54
55type Activity = { tool?: string; toolSince?: number; lastActivity: number; inFlight: number }
56
57let role: Role = { kind: 'lead', chain: [] }
58let home = ''
59let sessionId = ''
60// A pane teammate's first mailbox deliveries can arrive before its
61// session.start has run, so every hook that needs the role waits on this.
62let identity: Promise<void> | undefined
63let tracePath: string | undefined
64let traceWrites: Promise<void> = Promise.resolve()
65// Mirrors of the host-held state, read back at every session.start (a reload).
66let holds: Record<string, Hold> = {}
67let standing: Record<string, string[]> = {}
68// This process's main loop: whether a turn runs, and who sent what it has
69// not yet carried in a model request.
70let isTurnRunning = false
71let pending: Delivery[] = []
72// Where the main loop's turn stands: a model request in flight, or the last
73// response asked for no tool, so no request may follow. A message arriving
74// then is held until the response shows whether another request carries it.
75let isRequesting = false
76let isLastStep = false
77let held: Delivery[] = []
78// The prompt turn.complete submitted for what no request carried, until the
79// turn it starts begins: the band keeps those messages unread until then.
80let wakePrompt: string | undefined
81// Whether this turn reached team-lead with a SendMessage (a pane teammate).
82let hasReportedThisTurn = false
83// How the lead learns the directives: its system-prompt section, or, where
84// Claude Code's security default skips this mod there, a note in its conversation.
85let hasComposed = false
86// A system prompt composed only to see whether this mod's hook runs; the text is dropped.
87const COMPOSE_PROBE = {
88 model: 'claude-opus-5-5', promptModel: 'claude-opus-5-5', surfaces: [], tools: [], outputStyle: null, traits: [],
89} as const
90let teaching: 'prompt' | 'note' | undefined
91const activity = new Map<string, Activity>()
92// Which loop spawned each agent of this process, so a hold reaches helpers.
93const parents = new Map<string, string>()
94let isDirty = true
95let lastStatusWrite = 0
96let lastRows = ''
97const alerted = new Set<string>()
98const statusCache = new Map<string, { mtimeMs: number; status: TeammateStatus }>()
99
100const safe = (s: string) => s.replace(/[^A-Za-z0-9._-]/g, '_')
101const statusPath = (team: string, name: string) =>
102 `${home}/.claude/better-agent-messaging/${safe(team)}/agents/${safe(name)}.json`
103const isPane = (a: AgentInfo) => a.teammateId !== undefined && a.id === a.teammateId
104const controllerOf = (a: AgentInfo) => (a.teammateId !== undefined ? 'team-lead' : 'main')
105
106function trace($: EngineInterface, ev: string, data: Record<string, unknown> = {}): void {
107 const file = tracePath
108 if (file === undefined) return
109 const snapshot = traceLine(ev, data)
110 traceWrites = traceWrites.then(() => $.fs.write(file, snapshot)).catch(() => undefined)
111}
112
113async function detectRole($: EngineInterface): Promise<Role> {
114 // Windows has no `ps -o` (Git Bash's ps is MSYS's), and agent teammates run
115 // there in Claude Code's own process, never a pane: this process is the lead.
116 if (isWindows(await $.env.get('OS'))) return { kind: 'lead', chain: [] }
117 try {
118 const ran = await $.process.run(['sh', '-c', ROLE_WALK], { timeoutMs: 5000 })
119 return parseRole(ran.stdout)
120 } catch {
121 return { kind: 'lead', chain: [] }
122 }
123}
124
125function ensureIdentity($: EngineInterface): Promise<void> {
126 identity ??= (async () => {
127 role = await detectRole($)
128 home = homeDir({
129 os: await $.env.get('OS'),
130 home: await $.env.get('HOME'),
131 userProfile: await $.env.get('USERPROFILE'),
132 })
133 sessionId = await $.session.id()
134 })()
135 return identity
136}
137
138function begin(key: string, tool: string): void {
139 const now = Date.now()
140 const seen = activity.get(key)
141 activity.set(key, {
142 tool,
143 toolSince: seen?.toolSince ?? now,
144 lastActivity: now,
145 inFlight: (seen?.inFlight ?? 0) + 1,
146 })
147 isDirty = true
148}
149
150function end(key: string): void {
151 const seen = activity.get(key)
152 const inFlight = Math.max(0, (seen?.inFlight ?? 1) - 1)
153 activity.set(key, inFlight === 0
154 ? { lastActivity: Date.now(), inFlight }
155 : { ...(seen ?? { lastActivity: Date.now() }), lastActivity: Date.now(), inFlight })
156 isDirty = true
157}
158
159function bump(key: string): void {
160 const seen = activity.get(key)
161 activity.set(key, { ...(seen ?? { inFlight: 0 }), lastActivity: Date.now() })
162 isDirty = true
163}
164
165/**
166 * The hold over a loop: its own, else the nearest one up the chain of loops
167 * that spawned it. In a pane teammate's process every loop is the teammate's
168 * own work, so a helper whose parent is unknown (spawned before a reload)
169 * still falls under the teammate's hold on `main`.
170 */
171function holdFor(agentId: string | undefined): Hold | undefined {
172 let key = agentId ?? 'main'
173 for (let depth = 0; depth < 16; depth += 1) {
174 const hold = holds[key]
175 if (hold !== undefined || key === 'main') return hold
176 key = parents.get(key) ?? 'main'
177 }
178 return undefined
179}
180
181async function applyVerb($: EngineInterface, key: string, verb: Verb, by: string): Promise<void> {
182 if (verb.kind === 'hold') {
183 holds = { ...holds, [key]: { scope: verb.scope, reason: verb.reason, by, at: Date.now() } }
184 } else if (verb.kind === 'release') {
185 const { [key]: _released, ...rest } = holds
186 holds = rest
187 } else if (verb.kind === 'standing') {
188 const rules = standing[key] ?? []
189 if (!rules.includes(verb.rule)) standing = { ...standing, [key]: [...rules, verb.rule] }
190 } else {
191 standing = { ...standing, [key]: [] }
192 }
193 isDirty = true
194 trace($, 'verb', { key, verb, by })
195 await update($, holdsAtom, () => holds)
196 await update($, standingAtom, () => standing)
197}
198
199async function resolveAgent($: EngineInterface, to: string): Promise<AgentInfo | undefined> {
200 const agents = await $.agent.list()
201 return agents.find(a => a.id === to || a.teammateId === to || a.name === to)
202}
203
204async function broadcast($: EngineInterface, text: string, verb: Verb | undefined): Promise<SessionSendResult> {
205 const agents = (await $.agent.list()).filter(a => LIVE.has(a.status))
206 if (verb?.kind === 'standing' || verb?.kind === 'standing-clear') await applyVerb($, '*', verb, 'main')
207 let sent = 0
208 for (const agent of agents) {
209 // A pane teammate enforces a directive in its own process when the
210 // message reaches it; an in-process loop is enforced here.
211 if (verb !== undefined && !isPane(agent)) await applyVerb($, agent.id, verb, controllerOf(agent))
212 try {
213 const result = await $.session.send({ to: { agentId: agent.id }, text })
214 if (result.isDelivered) sent += 1
215 } catch {
216 // one agent that cannot take it does not stop the rest
217 }
218 }
219 trace($, 'broadcast', { agents: agents.map(a => a.name ?? a.id), sent })
220 if (sent > 0 || verb?.kind === 'standing' || verb?.kind === 'standing-clear') return { isDelivered: true }
221 return { isDelivered: false, reason: 'No running or idle agent or teammate to broadcast to.' }
222}
223
224async function teammateStatus($: EngineInterface, team: string, name: string): Promise<TeammateStatus | undefined> {
225 const path = statusPath(team, name)
226 try {
227 const stat = await $.fs.stat(path)
228 const cached = statusCache.get(path)
229 if (cached !== undefined && cached.mtimeMs === stat.mtimeMs) return cached.status
230 const status = JSON.parse(String(await $.fs.read(path))) as TeammateStatus
231 statusCache.set(path, { mtimeMs: stat.mtimeMs, status })
232 return status
233 } catch {
234 return undefined
235 }
236}
237
238async function writeStatus($: EngineInterface, turn?: TeammateStatus['turn']): Promise<void> {
239 if (role.kind !== 'teammate') return
240 const now = Date.now()
241 if (turn === undefined && !isDirty && now - lastStatusWrite < 30_000) return
242 isDirty = false
243 lastStatusWrite = now
244 const act = activity.get('main')
245 const status: TeammateStatus = {
246 name: role.name,
247 team: role.team,
248 sessionId,
249 turn: turn ?? (isTurnRunning ? 'running' : 'idle'),
250 tool: act?.tool,
251 toolSince: act?.toolSince,
252 lastActivity: act?.lastActivity ?? now,
253 hold: holds.main,
254 standing: standing.main ?? [],
255 unseen: pending.length + held.length,
256 updatedAt: now,
257 }
258 await $.fs.write(statusPath(role.team, role.name), JSON.stringify(status)).catch(() => undefined)
259}
260
261/** Wakes the controller: a note in its running turn, or a turn of its own. */
262async function wake($: EngineInterface, text: string): Promise<void> {
263 if (isTurnRunning) {
264 await $.session
265 .append({ message: { type: 'user', content: [{ type: 'text', text: `<system-reminder>\n${text}\n</system-reminder>` }] } })
266 .catch(() => undefined)
267 } else {
268 void $.prompt.submit({ text }).catch(() => undefined)
269 }
270}
271
272async function refresh($: EngineInterface, thresholds: { staleMs: number; toolStaleMs: number }): Promise<AgentRow[]> {
273 const now = Date.now()
274 let agents: AgentInfo[]
275 try {
276 agents = await $.agent.list()
277 } catch {
278 return []
279 }
280 const rows: AgentRow[] = []
281 for (const agent of agents) {
282 if (!LIVE.has(agent.status)) continue
283 if (isPane(agent)) {
284 const [name = agent.id, team = ''] = agent.id.split('@')
285 const status = await teammateStatus($, team, name)
286 const isFresh = status !== undefined && now - status.updatedAt < 120_000
287 rows.push({
288 key: agent.id,
289 name: agent.name ?? name,
290 kind: 'teammate',
291 status: isFresh ? (status.turn === 'running' ? 'running' : 'idle') : agent.status,
292 tool: status?.tool,
293 toolSince: status?.toolSince,
294 lastActivity: status?.lastActivity,
295 hold: status?.hold,
296 standing: status?.standing.length ?? 0,
297 unseen: status?.unseen ?? 0,
298 })
299 } else {
300 const act = activity.get(agent.id)
301 rows.push({
302 key: agent.id,
303 name: agent.name ?? agent.description,
304 kind: agent.teammateId !== undefined ? 'teammate' : 'subagent',
305 status: agent.status,
306 tool: act?.tool,
307 toolSince: act?.toolSince,
308 lastActivity: act?.lastActivity,
309 hold: holds[agent.id],
310 standing: (standing[agent.id] ?? []).length,
311 unseen: 0,
312 })
313 }
314 }
315 for (const row of rows) row.stale = staleness(row, now, thresholds)
316
317 for (const row of rows) {
318 if (row.stale === undefined) {
319 alerted.delete(row.key)
320 continue
321 }
322 if (alerted.has(row.key)) continue
323 alerted.add(row.key)
324 const text = staleText(row.name, row.stale)
325 trace($, 'stale', { key: row.key, why: row.stale })
326 $.ui.toast(text)
327 await wake($, text)
328 }
329
330 const serialized = JSON.stringify(rows)
331 if (serialized !== lastRows || rows.some(r => r.status === 'running')) {
332 lastRows = serialized
333 await update($, rowsAtom, () => rows)
334 }
335 return rows
336}
337
338/**
339 * Puts a team message into the running turn, where the next model request
340 * carries it, and gives the person a notice of it: the model's row is hidden
341 * from the transcript view, as every row a plugin adds for the model is.
342 */
343async function deliverNow($: EngineInterface, d: Delivery): Promise<boolean> {
344 const { from, text } = d
345 // An engine notice held behind a message goes in as the engine frames it, unannounced.
346 const isNotice = isProtocolJson(text)
347 const appended = await $.session
348 .append({ message: { type: 'user', content: [{ type: 'text', text: isNotice ? teammateFrame(from, text) : deliveryText(from, text) }] } })
349 .catch(() => undefined)
350 if (appended === undefined || appended.deny !== undefined) return false
351 if (isNotice) return true
352 await $.session
353 .append({ message: { type: 'system', content: [{ type: 'text', text: deliveryNotice(from, text) }] } })
354 .catch(() => undefined)
355 pending = [...pending, d]
356 isDirty = true
357 trace($, 'delivered', { from, length: text.length })
358 return true
359}
360
361/** Whether a held delivery is a message, not one of the engine's own notices. */
362const isMessage = (d: Delivery) => !isProtocolJson(d.text)
363
364/**
365 * Delivers the messages held for a step's end, now that another request will
366 * carry them. The engine's notices held behind them keep waiting for the turn's
367 * end, where the engine would deliver them and the person sees them drawn;
368 * `withNotices` puts them in too, unseen, for a turn the person stopped.
369 */
370async function deliverHeld($: EngineInterface, withNotices = false): Promise<void> {
371 const waiting = held
372 held = []
373 for (const d of waiting) {
374 // An append the engine refuses goes back to wait for the turn's end.
375 if ((!withNotices && !isMessage(d)) || !(await deliverNow($, d))) held = [...held, d]
376 }
377 await update($, heldAtom, () => held)
378}
379
380const colorCache = new Map<string, { mtimeMs: number; colors: Record<string, string> }>()
381
382/**
383 * A teammate's colour in its team (`blue`), as its team's config names it;
384 * none for team-lead. Read when its message arrives: a stopped teammate leaves
385 * the config.
386 */
387async function senderColor($: EngineInterface, name: string): Promise<string | undefined> {
388 let team = role.kind === 'teammate' ? role.team : undefined
389 if (team === undefined) {
390 const agents = await $.agent.list().catch(() => [] as AgentInfo[])
391 team = agents.find(a => a.teammateId?.startsWith(`${name}@`) === true)?.teammateId?.split('@')[1]
392 }
393 if (team === undefined || home.length === 0) return undefined
394 const path = `${home}/.claude/teams/${team}/config.json`
395 try {
396 const stat = await $.fs.stat(path)
397 let cached = colorCache.get(path)
398 if (cached === undefined || cached.mtimeMs !== stat.mtimeMs) {
399 const config = JSON.parse(String(await $.fs.read(path))) as { members?: { name?: unknown; color?: unknown }[] }
400 const colors: Record<string, string> = {}
401 for (const m of config.members ?? []) {
402 if (typeof m.name === 'string' && typeof m.color === 'string') colors[m.name] = m.color
403 }
404 cached = { mtimeMs: stat.mtimeMs, colors }
405 colorCache.set(path, cached)
406 }
407 return cached.colors[name]
408 } catch {
409 return undefined
410 }
411}
412
413/** The band's arrival, read now that a model request carries it; it clears a little later. */
414async function markRead($: EngineInterface): Promise<void> {
415 const arrival = await read($, arrivalAtom)
416 if (arrival === null || arrival.readAt !== undefined) return
417 await update($, arrivalAtom, last => (last !== null && last.at === arrival.at ? { ...last, readAt: Date.now() } : last))
418 $.clock.after(READ_MS, () => {
419 void update($, arrivalAtom, last => (last !== null && last.at === arrival.at ? null : last))
420 })
421}
422
423/**
424 * The lead's directives as a note in its conversation, where its system prompt
425 * is out of reach. A conversation that already holds one (this mod reloaded, the
426 * session resumed) gets no second; after a compaction it has none, so it gets one.
427 */
428async function teachByNote($: EngineInterface, isAfterCompaction: boolean): Promise<void> {
429 if (!isAfterCompaction) {
430 const head = CONTROLLER_NOTE.split('\n')[0] ?? ''
431 const messages = await $.session.messages().catch(() => [])
432 if (Array.isArray(messages) && messages.some(m => m.role === 'user' && m.text.startsWith(head))) return
433 }
434 const message = { type: 'user' as const, content: [{ type: 'text' as const, text: CONTROLLER_NOTE }] }
435 await $.session.append({ message }).catch(() => undefined)
436 trace($, 'taught by note', {})
437}
438
439export const register: Register = (on, options) => {
440 const ship = shipMatcher(String(options.shipPattern ?? ''))
441 const thresholds = {
442 staleMs: Number(options.staleMinutes ?? 20) * 60_000,
443 toolStaleMs: Number(options.toolStaleMinutes ?? 45) * 60_000,
444 }
445
446 on('session.start', async ($, e, next) => {
447 const started = await next(e)
448 await ensureIdentity($)
449 if (options.trace === true && home.length > 0) {
450 const label = role.kind === 'teammate' ? `${safe(role.team)}--${safe(role.name)}` : 'lead'
451 tracePath = `${home}/.claude/better-agent-messaging/trace/${label}--${sessionId.slice(0, 8)}.jsonl`
452 }
453 // What the host kept across a reload, under anything applied before this ran.
454 holds = { ...(await read($, holdsAtom)), ...holds }
455 held = [...(await read($, heldAtom)), ...held]
456 standing = { ...(await read($, standingAtom)), ...standing }
457 // A reload mid-turn: the turn is still running, and its next message must not wait.
458 isTurnRunning = isTurnRunning || (await read($, turnAtom))
459 trace($, 'session.start', { role })
460
461 if (role.kind === 'lead') {
462 // The band above the prompt shows what needs attention; no status-line entry.
463 $.ui.status(undefined)
464 await $.command.register({
465 name: COMMAND,
466 description: 'Show every subagent and teammate: what it is doing, holds, standing orders, stale agents',
467 })
468 $.clock.every(5000, () => {
469 void refresh($, thresholds)
470 })
471 } else {
472 $.clock.every(5000, () => {
473 void writeStatus($)
474 })
475 void writeStatus($, 'idle')
476 }
477 return started
478 })
479
480 on('session.end', async ($, e, next) => {
481 await writeStatus($, 'ended')
482 return next(e)
483 })
484
485 // The controller learns the directives from its own system prompt.
486 on('prompt.compose', async ($, e, next) => {
487 hasComposed = true
488 const composed = await next(e)
489 await ensureIdentity($)
490 if (role.kind !== 'lead' || !e.tools.includes('SendMessage') || !e.tools.includes('Agent')) return composed
491 return {
492 sections: [...composed.sections, { id: 'better-agent-messaging:controller', text: CONTROLLER_SECTION, scope: 'session' }],
493 }
494 })
495
496 // Every agent that can take a message mid-task learns how in its brief.
497 // Every agent is recorded under the loop that spawned it, for holds.
498 on('agent.spawn', async ($, e, next) => {
499 const isTeammate = e.isTeammate === true
500 let input = e
501 if (!e.fork && e.workflow === undefined && (e.background || isTeammate)) {
502 await ensureIdentity($)
503 const controller = isTeammate ? 'team-lead' : 'main'
504 const rules = [...(standing['*'] ?? []), ...(role.kind === 'teammate' ? standing.main ?? [] : [])]
505 trace($, 'spawn', { name: e.name, isTeammate, rules: rules.length })
506 input = { ...e, prompt: `${e.prompt}\n${spawnProtocol(controller, isTeammate, rules)}` }
507 }
508 const spawned = await next(input)
509 if (spawned.agentId !== undefined) parents.set(spawned.agentId, e.parentAgentId ?? 'main')
510 return spawned
511 }).catch(($, e, next) => next(e))
512
513 // A pane teammate's own sends: "main", "lead" and the like mean its
514 // controller, which in its process is team-lead ("main" would be itself).
515 // The controller's sends: directives for in-process loops, and broadcasts.
516 on('session.send', async ($, e, next) => {
517 if (e.agentId !== undefined) return next(e)
518 await ensureIdentity($)
519 if (role.kind === 'teammate') {
520 const to = e.origin.kind === 'model' && controllerAlias(e.to, role.team) ? 'team-lead' : e.to
521 if (to !== e.to) trace($, 'readdressed', { from: e.to, to })
522 const sent = await next(to === e.to ? e : { ...e, to })
523 if (to === 'team-lead' && sent.isDelivered) hasReportedThisTurn = true
524 return sent
525 }
526 const verb = parseVerb(e.text)
527 if (isBroadcast(e.to)) return broadcast($, e.text, verb)
528 if (verb !== undefined) {
529 const target = await resolveAgent($, e.to)
530 if (target !== undefined && !isPane(target)) await applyVerb($, target.id, verb, controllerOf(target))
531 }
532 return next(e)
533 }).catch(($, e, next) => next(e))
534
535 // A team message reaching this process's main loop while its turn runs goes
536 // into the turn, where the engine would hold it until the turn ends: now, or
537 // once the model's response shows another request follows. In the lead, a
538 // stopped teammate's idle notice the engine hands over late is dropped.
539 on('session.receive', async ($, e, next) => {
540 if (e.agentId !== undefined || !('teammate' in e.origin)) return next(e)
541 if (isProtocolJson(e.text)) {
542 // Behind a held message, the engine would deliver its own notice (a
543 // teammate's idle notice, a moment after its report) first: it waits too.
544 if (isTurnRunning && held.length > 0) {
545 const from = e.origin.teammate
546 held = [...held, { from, text: e.text, color: held.find(d => d.from === from)?.color }]
547 await update($, heldAtom, () => held)
548 trace($, 'held', { from, notice: true })
549 return { consumed: 'better-agent-messaging: held behind a message held for the end of the model\'s response' }
550 }
551 const idle = idleNotice(e.text, Date.now())
552 if (idle === undefined || idle.ageMs < STALE_IDLE_MS) return next(e)
553 await ensureIdentity($)
554 if (role.kind !== 'lead') return next(e)
555 const sender = await resolveAgent($, idle.from)
556 if (sender !== undefined && LIVE.has(sender.status)) return next(e)
557 trace($, 'stale idle dropped', { from: idle.from, ageMs: idle.ageMs })
558 return { consumed: 'better-agent-messaging: a stopped teammate\'s idle notice, delivered late' }
559 }
560 await ensureIdentity($)
561 const from = e.origin.teammate
562 if (role.kind === 'teammate' && from === 'team-lead') {
563 const verb = parseVerb(e.text)
564 if (verb !== undefined) await applyVerb($, 'main', verb, from)
565 for (const rule of extractStanding(e.text)) await applyVerb($, 'main', { kind: 'standing', rule }, from)
566 }
567 if (!isTurnRunning) return next(e)
568 const delivery: Delivery = { from, text: e.text, color: await senderColor($, from) }
569 // While the model writes, whether another request follows is not known
570 // yet: the message waits for the response (turn.step), and if none
571 // follows it starts the next turn as the engine would (turn.complete).
572 const isHeld = isRequesting || isLastStep
573 if (isHeld) {
574 held = [...held, delivery]
575 await update($, heldAtom, () => held)
576 isDirty = true
577 trace($, 'held', { from, length: e.text.length })
578 } else if (!(await deliverNow($, delivery))) {
579 return next(e)
580 }
581 // The person's copy until the model reads it: the band above the prompt.
582 const at = Date.now()
583 await update($, arrivalAtom, () => ({ from, text: e.text, at, color: delivery.color }))
584 return { consumed: isHeld ? 'better-agent-messaging: held for the end of the model\'s response' : 'better-agent-messaging: put into the running turn' }
585 }).catch(($, e, next) => next(e))
586
587 on('turn.start', async ($, e, next) => {
588 // The wake prompt's own turn: its first request carries what it held.
589 if (wakePrompt !== undefined && e.text === wakePrompt) wakePrompt = undefined
590 isTurnRunning = true
591 isRequesting = false
592 isLastStep = false
593 hasReportedThisTurn = false
594 bump('main')
595 await update($, turnAtom, () => true)
596 if (teaching === undefined && role.kind === 'lead') {
597 // Compose once to learn whether this mod's prompt.compose hook runs here.
598 if (!hasComposed) await $.prompt.compose(COMPOSE_PROBE).catch(() => undefined)
599 teaching = hasComposed ? 'prompt' : 'note'
600 if (teaching === 'note') await teachByNote($, false)
601 }
602 return next(e)
603 })
604
605 on('turn.step', async function* ($, e, next) {
606 bump(e.agentId ?? 'main')
607 if (e.agentId !== undefined) return yield* next(e)
608 // This request carries every row appended before it; a message the band
609 // shows that is still held, or waits for the wake prompt's turn, is not.
610 pending = []
611 // The last response asked for no tool and the engine went on anyway (a
612 // retry, a continuation): what waited goes in for the request after this.
613 if (held.some(isMessage)) await deliverHeld($)
614 else if (wakePrompt === undefined) await markRead($)
615 isRequesting = true
616 isLastStep = false
617 let isContinuing = false
618 try {
619 const response = yield* next(e)
620 isContinuing = response.toolUses.length > 0
621 return response
622 } finally {
623 isRequesting = false
624 // Tools run next and their results go back in another request, which
625 // carries what waited; with no tool asked for, the turn is ending.
626 if (isContinuing) {
627 if (held.some(isMessage)) await deliverHeld($)
628 } else {
629 isLastStep = true
630 }
631 }
632 })
633
634 on('turn.complete', async ($, e, next) => {
635 const done = await next(e)
636 if (e.agentId !== undefined) return done
637 isTurnRunning = false
638 isRequesting = false
639 isLastStep = false
640 isDirty = true
641 await update($, turnAtom, () => false)
642 // What no request carried starts the next turn, as the engine would have
643 // delivered it after the turn: a message held for the last response, and
644 // (rarely) one put in the conversation that the turn ended before reading,
645 // given again so the turn it starts reads it where the person sees it.
646 // A turn the person stopped stays stopped: what was held goes into the
647 // conversation, the band shows it unread, and their next prompt carries it.
648 if (e.isAborted) {
649 await deliverHeld($, true)
650 pending = []
651 held = []
652 }
653 const unread = [...pending, ...held]
654 held = []
655 pending = []
656 await update($, heldAtom, () => held)
657 if (unread.length > 0) {
658 trace($, 'wake', { from: unread.map(d => d.from) })
659 const text = wakeText(unread)
660 wakePrompt = text
661 void $.prompt.submit({ text, asUser: true }).catch(() => {
662 if (wakePrompt === text) wakePrompt = undefined
663 })
664 }
665 // A pane teammate whose turn ended without reaching team-lead: the lead
666 // would get only the idle notice's cut copy, so the whole answer goes too.
667 if (role.kind === 'teammate' && !hasReportedThisTurn && !e.isAborted && e.answer.trim().length > 0) {
668 trace($, 'forwarded', { length: e.answer.length })
669 void $.session.send({ to: 'team-lead', text: forwardedAnswer(e.answer) }).catch(() => undefined)
670 }
671 void writeStatus($, 'idle')
672 return done
673 })
674
675 const verdictFor = (agentId: string | undefined, tool: string, input: unknown) => {
676 const command = SHELL_TOOLS.has(tool) ? (input as { command?: unknown }).command : undefined
677 return gateVerdict(holdFor(agentId), tool, typeof command === 'string' ? command : undefined, ship)
678 }
679 // One hook per tool call: the hold gate first (a held loop's calls are
680 // refused in the process that runs it), then liveness for every loop, and
681 // the live table on the controller's ListAgents. The catch fails closed for
682 // held loops only.
683 on('tool.call', async ($, e, next) => {
684 const verdict = verdictFor(e.agentId, e.tool, e)
685 if (verdict !== undefined) {
686 trace($, 'gate', { agentId: e.agentId, tool: e.tool })
687 return { deny: verdict }
688 }
689 const key = e.agentId ?? 'main'
690 begin(key, e.tool)
691 try {
692 const ran = await next(e)
693 if (e.tool !== 'ListAgents' || e.agentId !== undefined || ran.deny !== undefined || role.kind !== 'lead') return ran
694 const table = agentTable(await refresh($, thresholds), Date.now())
695 return table === undefined ? ran : { ...ran, context: [...(ran.context ?? []), table] }
696 } finally {
697 end(key)
698 }
699 }).catch(($, e, next) => {
700 if (next.called) return next(e)
701 const verdict = verdictFor(e.agentId, e.tool, e)
702 return verdict === undefined ? next(e) : { deny: verdict }
703 })
704
705 // Standing orders survive an agent's compaction.
706 on('session.compact', async ($, e, next) => {
707 const compacted = await next(e)
708 if (compacted.skip !== undefined || e.trigger === 'precompute') return compacted
709 if (e.agentId === undefined && teaching === 'note') await teachByNote($, true)
710 const rules = e.agentId === undefined
711 ? role.kind === 'teammate' ? standing.main ?? [] : []
712 : [...(standing['*'] ?? []), ...(standing[e.agentId] ?? [])]
713 if (rules.length > 0) {
714 const message = { type: 'user' as const, content: [{ type: 'text' as const, text: standingBlock(rules, 'compacted') }] }
715 await $.session.append(e.agentId === undefined ? { message } : { message, agentId: e.agentId }).catch(() => undefined)
716 trace($, 'restated', { agentId: e.agentId, rules: rules.length })
717 }
718 return compacted
719 }).catch(($, e, next) => next(e))
720
721 on('command.run', { command: COMMAND }, async $ => {
722 await refresh($, thresholds)
723 await $.ui.open({ id: PANE, title: 'Agents' })
724 return { text: 'Agent status is open in a pane.' }
725 })
726
727 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
728 const { Box, Text } = $.ui.resolve(e)
729 const rows = await read($, rowsAtom)
730 const now = Date.now()
731 return (
732 <Box flexDirection="column">
733 {rows.length === 0 && <Text dimColor>No running or idle subagents or teammates.</Text>}
734 {rows.map(row => (
735 <Text
736 color={row.stale !== undefined ? 'yellow' : undefined}
737 dimColor={row.status === 'idle'}
738 wrap="truncate-end"
739 >
740 {rowLine(row, now)}
741 </Text>
742 ))}
743 </Box>
744 )
745 })
746
747 // The band above the prompt, drawn in every pane, and only for what the agent
748 // list under the prompt does not show: a message that just arrived mid-turn,
749 // a teammate's own hold, and in the lead any agent that is paused or stuck.
750 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
751 if (e.props.hasSurvey) return next(e)
752 const arrival = await read($, arrivalAtom)
753 const isShown = arrival !== null && (arrival.readAt === undefined || Date.now() - arrival.readAt < READ_MS)
754 const lines: string[] = []
755 if (role.kind === 'teammate') {
756 const hold = (await read($, holdsAtom)).main
757 if (hold !== undefined) lines.push(heldLine(hold))
758 } else {
759 lines.push(...alertLines(await read($, rowsAtom), Date.now()))
760 }
761 if (!isShown && lines.length === 0) return next(e)
762 const { Box, Text } = $.ui.resolve(e)
763 return (
764 <Box flexDirection="column">
765 {isShown && (
766 // The sender in its team colour, as Claude Code draws a teammate's name.
767 <Text wrap="truncate-end">
768 <Text color={arrival.color === undefined ? undefined : `${arrival.color}_FOR_SUBAGENTS_ONLY`} dimColor={arrival.color === undefined}>{arrival.from}</Text>
769 <Text dimColor>{arrivalLine(arrival.text, arrival.readAt !== undefined)}</Text>
770 </Text>
771 )}
772 {lines.map(line => <Text dimColor wrap="truncate-end">{line}</Text>)}
773 </Box>
774 )
775 })
776}
777hooks/gate.ts 57 lines1import type { Hold } from '../types'
2
3import { holdDenial } from './protocol'
4
5/**
6 * The tools whose `command` a ship hold reads: Bash, and on Windows the
7 * PowerShell tool, which can push, merge and delete just as well.
8 */
9export const SHELL_TOOLS: ReadonlySet<string> = new Set(['Bash', 'PowerShell'])
10
11/**
12 * Shell commands a ship hold always refuses, in Bash or PowerShell (either can
13 * start the other); a configured pattern adds to these.
14 */
15export const DEFAULT_SHIP = [
16 // `git.exe` and a quoted path to it (`& 'C:\...\git.exe' push`) are git too.
17 String.raw`\bgit(?:\.exe)?['"]?\s+(?:\S+\s+)*push\b`,
18 String.raw`\b(?:gh|gh-axi)(?:\.exe)?['"]?\s+pr\s+merge\b`,
19 String.raw`\b(?:gh|gh-axi)(?:\.exe)?['"]?\s+release\s+(?:create|upload|edit)\b`,
20 String.raw`\b(?:npm|pnpm|yarn)(?:\.cmd)?\s+publish\b`,
21 String.raw`\bPublish-(?:Module|Script|PSResource)\b`,
22 String.raw`\b(?:twine\s+upload|cargo\s+publish|gem\s+push|poetry\s+publish)\b`,
23 String.raw`\b(?:kubectl\s+(?:apply|delete|rollout)|helm\s+(?:install|upgrade|uninstall)|terraform\s+(?:apply|destroy))\b`,
24 String.raw`\b(?:deploy|wrangler\s+deploy|vercel\s+--prod|fly\s+deploy)\b`,
25 String.raw`\brm\s+(?:-\S*\s+)*-\S*[rR]\S*\s`,
26 // PowerShell's Remove-Item and its aliases take -Recurse (or any prefix of
27 // it) anywhere on the line, before or after the path.
28 String.raw`\b(?:Remove-Item|rm|ri|del|erase|rd|rmdir)\b[^;|&\n]*\s--?r`,
29 // cmd's recursive deletes, also reachable as `cmd /c` from either shell.
30 String.raw`\b(?:rd|rmdir|del|erase)\b[^;|&\n]*\s/s\b`,
31 String.raw`\[(?:System\.)?IO\.Directory\]::Delete\(`,
32].join('|')
33
34export function shipMatcher(pattern: string): RegExp {
35 if (pattern.trim().length > 0) {
36 try {
37 new RegExp(pattern)
38 return new RegExp(`${DEFAULT_SHIP}|(?:${pattern})`, 'i')
39 } catch {
40 // a bad pattern is ignored; the built-in list still applies
41 }
42 }
43 return new RegExp(DEFAULT_SHIP, 'i')
44}
45
46/** The refusal for a held loop's tool call, or undefined to let it run. */
47export function gateVerdict(
48 hold: Hold | undefined,
49 tool: string,
50 command: string | undefined,
51 ship: RegExp,
52): string | undefined {
53 if (hold === undefined || tool === 'SendMessage') return undefined
54 if (hold.scope === 'all') return holdDenial(hold)
55 return SHELL_TOOLS.has(tool) && command !== undefined && ship.test(command) ? holdDenial(hold) : undefined
56}
57hooks/protocol.ts 218 lines1import type { Hold } from '../types'
2
3// The words the mod puts in front of models: the controller's system-prompt
4// section, the paragraph every spawn's brief gains, and the envelopes of what
5// it delivers. Pure text; the hooks module sends it.
6
7export type Verb =
8 | { kind: 'hold'; scope: 'all' | 'ship'; reason: string }
9 | { kind: 'release'; note: string }
10 | { kind: 'standing'; rule: string }
11 | { kind: 'standing-clear' }
12
13/** Reads a directive off a message's first line; undefined for a plain message. */
14export function parseVerb(text: string): Verb | undefined {
15 const lines = text.trim().split('\n')
16 const first = (lines[0] ?? '').trim()
17 const rest = lines.slice(1).join('\n').trim()
18 const tail = (head: string | undefined) => [head?.trim() ?? '', rest].filter(s => s.length > 0).join('\n')
19
20 const ship = /^hold\s+ship\s*(?::\s*(.*))?$/i.exec(first)
21 if (ship) return { kind: 'hold', scope: 'ship', reason: tail(ship[1]) || 'no reason given' }
22 const hold = /^hold\s*(?::\s*(.*))?$/i.exec(first)
23 if (hold) return { kind: 'hold', scope: 'all', reason: tail(hold[1]) || 'no reason given' }
24 const release = /^release\s*(?::\s*(.*))?$/i.exec(first)
25 if (release) return { kind: 'release', note: tail(release[1]) }
26 if (/^standing\s+clear\s*$/i.test(first)) return { kind: 'standing-clear' }
27 const standing = /^standing\s*:\s*(.*)$/i.exec(first)
28 if (standing) {
29 const rule = tail(standing[1])
30 return rule.length > 0 ? { kind: 'standing', rule } : undefined
31 }
32 return undefined
33}
34
35// SendMessage itself refuses "*" before any hook runs, so a broadcast is
36// addressed to a name no agent carries, which the mod fans out.
37const BROADCAST = new Set(['all', 'everyone', '@all', '*'])
38
39/** Whether a recipient means every live agent of the session. */
40export function isBroadcast(to: string): boolean {
41 return BROADCAST.has(to.trim().toLowerCase())
42}
43
44/** The engine's own team messages that are data, not words: idle notices, shutdown and plan requests. */
45export function isProtocolJson(text: string): boolean {
46 const trimmed = text.trim()
47 if (!trimmed.startsWith('{')) return false
48 try {
49 const parsed: unknown = JSON.parse(trimmed)
50 return typeof parsed === 'object' && parsed !== null && typeof (parsed as { type?: unknown }).type === 'string'
51 } catch {
52 return false
53 }
54}
55
56/**
57 * An engine idle notice's sender and age, for one the lead receives late: the
58 * engine can leave a stopped teammate's last idle notice (its whole final
59 * answer) unread in the inbox, and hands it over at the next spawn, hours on.
60 */
61export function idleNotice(text: string, now: number): { from: string; ageMs: number } | undefined {
62 if (!isProtocolJson(text)) return undefined
63 const parsed = JSON.parse(text.trim()) as { type?: unknown; from?: unknown; timestamp?: unknown }
64 if (parsed.type !== 'idle_notification' || typeof parsed.from !== 'string' || typeof parsed.timestamp !== 'string') return undefined
65 const at = Date.parse(parsed.timestamp)
66 return Number.isNaN(at) ? undefined : { from: parsed.from, ageMs: now - at }
67}
68
69const escapeAttr = (s: string) => s.replace(/[&"<>]/g, c => `&#${c.charCodeAt(0)};`)
70
71/**
72 * A teammate message in the engine's own frame, as the model reads one
73 * delivered between turns. Submitted alone as a prompt, the frame is drawn as
74 * Claude Code draws a teammate's message, the name in `color` (the team's).
75 */
76export function teammateFrame(from: string, text: string, color?: string): string {
77 const tint = color === undefined ? '' : ` color="${escapeAttr(color)}"`
78 return [`<teammate-message teammate_id="${escapeAttr(from)}"${tint}>`, text, '</teammate-message>'].join('\n')
79}
80
81/** A teammate message that arrived while the turn ran, as the turn reads it. */
82export function deliveryText(from: string, text: string): string {
83 const outranks = from === 'team-lead'
84 ? ' It comes from your controller and outranks your original brief where they conflict.'
85 : ''
86 return [
87 teammateFrame(from, text),
88 '<system-reminder>',
89 `${from} sent the message above while you were working. Read it now and address it before you finish your current task.${outranks}`,
90 '</system-reminder>',
91 ].join('\n')
92}
93
94// What a pane teammate's model calls its controller. In its own process
95// "main" names the teammate itself, so each of these goes to team-lead.
96const CONTROLLER_ALIASES = new Set([
97 'main', 'lead', 'leader', 'team-lead', 'teamlead', 'team_lead', 'team lead',
98 'controller', 'orchestrator', 'coordinator', 'parent', 'manager',
99])
100
101/** Whether a teammate's recipient means its controller (and is not already spelled `team-lead`). */
102export function controllerAlias(to: string, team: string): boolean {
103 const bare = to.trim().toLowerCase().replace(/^@/, '')
104 const suffix = `@${team.toLowerCase()}`
105 const name = bare.endsWith(suffix) ? bare.slice(0, -suffix.length) : bare
106 return to !== 'team-lead' && CONTROLLER_ALIASES.has(name)
107}
108
109/** A teammate's whole final answer, sent on when its turn reached no one. */
110export function forwardedAnswer(answer: string): string {
111 return `${answer}\n\n(My final answer for this turn, forwarded whole: I did not send it to you myself.)`
112}
113
114/**
115 * The visible line the person sees when a message is put into a running turn.
116 * The row the model reads is hidden from the transcript view, as every row a
117 * plugin adds for the model is; this notice is the person's copy.
118 */
119export function deliveryNotice(from: string, text: string): string {
120 return `${from} sent:\n${text}`
121}
122
123/** The band's line for a message that arrived mid-turn, after the sender's name: whether it has been read, its first line. */
124export function arrivalLine(text: string, isRead: boolean): string {
125 const first = text.trim().split('\n')[0] ?? ''
126 return ` sent (${isRead ? 'read' : 'unread'}): ${first.length > 160 ? `${first.slice(0, 159)}…` : first}`
127}
128
129/** The band's line in a held teammate's own pane. */
130export function heldLine(hold: Hold): string {
131 return `${hold.by} ${hold.scope === 'ship' ? 'blocked shipping' : 'paused this agent'}: ${hold.reason.split('\n')[0]}`
132}
133
134/**
135 * The prompt that wakes an idle agent for the messages its last turn did not
136 * read: each in the engine's own frame, as the engine would have delivered it
137 * after the turn, so it is drawn as a teammate's message and not a plugin's.
138 */
139export function wakeText(messages: readonly { from: string; text: string; color?: string }[]): string {
140 return messages.map(m => teammateFrame(m.from, m.text, m.color)).join('\n')
141}
142
143/** Standing orders put back after a compaction, or listed in a brief. */
144export function standingBlock(rules: readonly string[], why: 'compacted' | 'spawn'): string {
145 const lead = why === 'compacted'
146 ? 'Standing orders from your controller, still in force (restated because your context was compacted):'
147 : SPAWN_STANDING_HEAD
148 return ['<system-reminder>', lead, ...rules.map(r => `- ${r}`), '</system-reminder>'].join('\n')
149}
150
151const SPAWN_STANDING_HEAD = 'Standing orders from your controller, in force for your whole task:'
152
153/**
154 * The standing orders a brief carries (the block `spawnProtocol` wrote), so a
155 * pane teammate can keep them in force past its own compaction.
156 */
157export function extractStanding(text: string): string[] {
158 const at = text.indexOf(SPAWN_STANDING_HEAD)
159 if (at < 0) return []
160 const rules: string[] = []
161 for (const line of text.slice(at + SPAWN_STANDING_HEAD.length).split('\n').slice(1)) {
162 if (!line.startsWith('- ')) break
163 rules.push(line.slice(2))
164 }
165 return rules
166}
167
168/** What the model reads when the gate refuses a tool call. */
169export function holdDenial(hold: Hold): string {
170 const reason = hold.reason.replace(/[\s.!?;:,]+$/, '')
171 return hold.scope === 'all'
172 ? `On hold by ${hold.by}: ${reason}. Every tool but SendMessage is refused until ${hold.by} sends "release". Tell ${hold.by} with SendMessage where you stopped, then end your turn and wait.`
173 : `On a ship hold by ${hold.by}: ${reason}. Pushing, merging, releasing, publishing, deploying and rm -rf are refused until ${hold.by} sends "release". Other work may continue.`
174}
175
176/** The paragraph a spawned agent's brief gains. */
177export function spawnProtocol(controller: string, isTeammate: boolean, standing: readonly string[]): string {
178 const report = isTeammate
179 ? `Report to ${controller} with SendMessage (to: "${controller}"): progress at milestones, blockers at once, and your final result.`
180 : `Your final answer reaches ${controller} by itself; use SendMessage (to: "${controller}") only for a blocker or a question mid-task.`
181 const lines = [
182 '',
183 '## Messages while you work',
184 `Messages from ${controller} can arrive in the middle of your task, between your steps. Read each one as it appears and act on it before you continue: it outranks this brief where they conflict.`,
185 `- "hold: ..." means stop now. Your tools are refused until "release" arrives; tell ${controller} where you stopped, then end your turn and wait.`,
186 '- "hold ship: ..." means do not push, merge, release, publish, deploy or rm -rf until "release"; other work may continue.',
187 '- "standing: ..." is a rule that stays in force for the rest of your task.',
188 report,
189 ]
190 if (standing.length > 0) lines.push('', standingBlock(standing, 'spawn'))
191 return lines.join('\n')
192}
193
194/** The controller's system-prompt section. */
195export const CONTROLLER_SECTION = [
196 '# Messaging your agents',
197 'SendMessage reaches a running subagent or teammate in the middle of its task: it reads the message at its next step, not when its run ends. Messages your teammates send you reach you mid-turn the same way. No inbox files, watches or nudges are needed.',
198 'Start the FIRST LINE of a SendMessage with a directive and it is enforced in the agent\'s own process:',
199 '- `hold: <reason>`: every tool the agent calls is refused (SendMessage still works) until you send `release`.',
200 '- `hold ship: <reason>`: only shipping actions are refused (git push, PR merge, releases, publish, deploy, rm -rf).',
201 '- `release` or `release: <note>`: lifts a hold.',
202 '- `standing: <rule>`: a rule kept in force; restated to the agent after its context is compacted. Sent to "all", every agent spawned afterwards gets it too. `standing clear` drops them.',
203 'Send to "all" to reach every running or idle agent and teammate at once (SendMessage refuses "*").',
204 'ListAgents also shows each agent\'s live state: what it is doing, for how long, its last activity, any hold, and whether it looks stale. A stale agent is reported to you without asking.',
205].join('\n')
206
207/**
208 * The same teaching as a note in the lead's conversation, for a machine where
209 * Claude Code's security default keeps installed plugins out of the system
210 * prompt (managed settings, Team and Enterprise organizations).
211 */
212export const CONTROLLER_NOTE = `better-agent-messaging is loaded in this session. Claude Code keeps installed plugins out of the system prompt on this machine, so this note stands in for its section.\n\n${CONTROLLER_SECTION}`
213
214/** The prompt or note that tells the controller an agent looks stale. */
215export function staleText(name: string, why: string): string {
216 return `${name} looks stale: ${why}. Check what it waits on, then message it or stop it.`
217}
218hooks/role.ts 54 lines1/**
2 * What this process is in an agent team: the lead (or any session that is no
3 * pane teammate), or a teammate the lead started in a terminal pane of its own.
4 *
5 * A pane teammate is a separate `claude` process launched with
6 * `--agent-name <name> --team-name <team>`; nothing in its environment names
7 * it, so the flags are read off the process tree.
8 */
9export type Role =
10 | { kind: 'lead'; chain: string[] }
11 | { kind: 'teammate'; name: string; team: string; chain: string[] }
12
13/**
14 * Walks up from the shell's parent until it meets a claude process, printing
15 * `pid<TAB>command` per step; only that last process's flags are read, so a
16 * claude started from a teammate's Bash is not mistaken for the teammate.
17 */
18export const ROLE_WALK = [
19 'p=$PPID',
20 'for i in 1 2 3 4 5 6; do',
21 ' c=$(ps -o command= -p "$p" 2>/dev/null)',
22 ' printf "%s\\t%s\\n" "$p" "$c"',
23 ' case "$c" in *claude*) exit 0;; esac',
24 ' p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " ")',
25 ' if [ -z "$p" ] || [ "$p" = 1 ]; then exit 0; fi',
26 'done',
27].join('\n')
28
29/** Windows sets `OS=Windows_NT` in every process, Git Bash's and PowerShell's alike. */
30export const isWindows = (os: string | undefined): boolean => os === 'Windows_NT'
31
32/**
33 * Where `~/.claude` lives, as Claude Code itself finds it: on Windows the
34 * profile folder (`USERPROFILE`), since `HOME` there is often unset, or Git
35 * Bash's `/c/Users/...` spelling, which no Windows path API reads; `HOME`
36 * elsewhere.
37 */
38export function homeDir(env: { os?: string; home?: string; userProfile?: string }): string {
39 const first = isWindows(env.os) ? env.userProfile || env.home : env.home
40 return first ?? ''
41}
42
43export function parseRole(stdout: string): Role {
44 const chain = stdout.split('\n').filter(line => line.length > 0)
45 const claude = chain.at(-1) ?? ''
46 const name = /--agent-name[ =](\S+)/.exec(claude)?.[1]
47 const team = /--team-name[ =](\S+)/.exec(claude)?.[1]
48 const short = chain.map(line => line.slice(0, 240))
49
50 return name !== undefined && team !== undefined
51 ? { kind: 'teammate', name, team, chain: short }
52 : { kind: 'lead', chain: short }
53}
54hooks/table.ts 61 lines1import type { AgentRow } from '../types'
2
3export type Thresholds = { staleMs: number; toolStaleMs: number }
4
5const minutes = (ms: number) => (ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`)
6
7/** Why a working agent looks stuck, or undefined. Idle and finished agents are never stale. */
8export function staleness(row: AgentRow, now: number, t: Thresholds): string | undefined {
9 if (row.status !== 'running') return undefined
10 if (row.tool !== undefined && row.toolSince !== undefined) {
11 const inTool = now - row.toolSince
12 return inTool > t.toolStaleMs ? `in ${row.tool} for ${minutes(inTool)}` : undefined
13 }
14 if (row.lastActivity === undefined) return undefined
15 const quiet = now - row.lastActivity
16 return quiet > t.staleMs ? `no step or tool for ${minutes(quiet)}` : undefined
17}
18
19function describe(row: AgentRow, now: number): string {
20 const doing = row.tool !== undefined && row.toolSince !== undefined
21 ? `in ${row.tool} for ${minutes(now - row.toolSince)}`
22 : row.lastActivity !== undefined ? `last active ${minutes(now - row.lastActivity)} ago` : 'no activity seen'
23 const flags = [
24 row.hold ? `HELD (${row.hold.scope}): ${row.hold.reason}` : undefined,
25 row.standing > 0 ? `${row.standing} standing order${row.standing === 1 ? '' : 's'}` : undefined,
26 row.unseen > 0 ? `${row.unseen} message${row.unseen === 1 ? '' : 's'} not yet read` : undefined,
27 row.stale ? `STALE: ${row.stale}` : undefined,
28 ].filter(f => f !== undefined)
29 return `- ${row.name} (${row.kind}): ${row.status}, ${doing}${flags.length > 0 ? `; ${flags.join('; ')}` : ''}`
30}
31
32/** The rows ListAgents' result gains, for the controller model. */
33export function agentTable(rows: readonly AgentRow[], now: number): string | undefined {
34 if (rows.length === 0) return undefined
35 return ['Live state of your agents (better-agent-messaging):', ...rows.map(r => describe(r, now))].join('\n')
36}
37
38/** At most this many alert lines above the lead's prompt; the rest fold into a count. */
39const MAX_ALERTS = 3
40
41/**
42 * The lines above the lead's prompt: only what the agent list under the prompt
43 * does not show, each led by the agent's name. Nothing while every agent is fine.
44 */
45export function alertLines(rows: readonly AgentRow[], now: number): string[] {
46 const lines: string[] = []
47 for (const row of rows) {
48 if (row.stale !== undefined) lines.push(`${row.name} looks stuck: ${row.stale}`)
49 if (row.hold !== undefined) {
50 const reason = row.hold.reason.split('\n')[0]
51 lines.push(row.hold.scope === 'ship' ? `${row.name} can't ship: ${reason}` : `${row.name} is paused: ${reason}`)
52 }
53 }
54 if (lines.length <= MAX_ALERTS) return lines
55 return [...lines.slice(0, MAX_ALERTS - 1), `and ${lines.length - (MAX_ALERTS - 1)} more paused or stuck: see /agent-status`]
56}
57
58export function rowLine(row: AgentRow, now: number): string {
59 return describe(row, now).slice(2)
60}
61hooks/trace.ts 17 lines1// The trace buffer: one JSON line per intercepted event. The hooks module
2// writes the snapshot (the fs noun has no append, so the file is rewritten
3// whole, writes chained so an older snapshot never lands last).
4const MAX_LINES = 4000
5
6let lines: string[] = []
7
8export function traceLine(ev: string, data: Record<string, unknown> = {}): string {
9 lines.push(JSON.stringify({ t: new Date().toISOString(), ev, ...data }))
10 if (lines.length > MAX_LINES) lines = lines.slice(-MAX_LINES)
11 return lines.join('\n') + '\n'
12}
13
14export function head(text: unknown, n = 200): string | undefined {
15 return typeof text === 'string' ? text.slice(0, n) : undefined
16}
17types/index.d.ts 72 lines1/** A hold the controller put on one agent loop with a `hold:` message. */
2export type Hold = {
3 /** `all`: every tool but SendMessage is refused; `ship`: shipping actions only. */
4 scope: 'all' | 'ship'
5 reason: string
6 /** Who sent it (`team-lead`, or `main` for the session's own subagents). */
7 by: string
8 at: number
9}
10
11/** What a pane teammate's mod writes about itself for the lead to read. */
12export type TeammateStatus = {
13 name: string
14 team: string
15 sessionId: string
16 turn: 'running' | 'idle' | 'ended'
17 tool?: string
18 toolSince?: number
19 lastActivity: number
20 hold?: Hold
21 standing: string[]
22 unseen: number
23 updatedAt: number
24}
25
26/**
27 * The last message put into this process's running turn, shown in the band:
28 * unread until a model request carries it (`readAt`), then briefly as read.
29 */
30export type Arrival = { from: string; text: string; at: number; readAt?: number; color?: string }
31
32/** A team message taken from the engine, and the sender's colour in its team. */
33export type Delivery = { from: string; text: string; color?: string }
34
35/** One agent as the lead shows it: ListAgents context, the /relay pane. */
36export type AgentRow = {
37 key: string
38 name: string
39 kind: 'subagent' | 'teammate'
40 status: string
41 tool?: string
42 toolSince?: number
43 lastActivity?: number
44 hold?: Hold
45 standing: number
46 unseen: number
47 stale?: string
48}
49
50declare module 'claude-code' {
51 interface PluginState {
52 'better-agent-messaging': {
53 /** Holds by loop: an in-process agent's id, or `main` for this process's own loop. */
54 holds: Record<string, Hold>
55 /** Standing orders by loop, and `*` for those every new spawn receives. */
56 standing: Record<string, string[]>
57 /** The lead's view of every agent, refreshed every few seconds. */
58 rows: AgentRow[]
59 /** Whether this process's main turn is running, kept across a reload of the mod. */
60 isTurnRunning: boolean
61 /** The last mid-turn arrival, or null once it has been shown long enough. */
62 arrival: Arrival | null
63 /**
64 * Messages that arrived while a model request was in flight, or after the
65 * turn's last one: whether a later request carries them is known only once
66 * that request's response is in.
67 */
68 held: Delivery[]
69 }
70 }
71}
72