SLOPSHOPPER

better-agent-messaging

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…

newpanebandguardcommandtoast
v1.2.0MITupdated 2026-10-24flyryan/BetterAgentMessaging-CC
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · better-agent-messaging
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ No running or idle subagents or teammates. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agent-status │ ⎿ better-agent-messaging: Agent status is open in a pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
No running or idle subagents or teammates.
README

<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.">

BetterAgentMessaging-CC

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.

Why

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.

Features

  • Mid-task delivery. A working teammate reads a message at its next step. Teammates' reports reach Claude while it is working.
  • Holds. 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 rules. standing: sets a rule that is restated to the agent after context compaction. Sent to all, it also goes to agents started later.
  • Status. A line above the prompt shows unread mid-task messages and any paused or stuck agent. /agent-status lists every agent, and Claude is told when an agent looks stuck.
  • Delivery fixes. Teammate reports addressed to 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.

Install

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.

Usage

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 lineEffect
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 clearDrops 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.

Configuration

Set in /config under the mod's name:

SettingDefaultMeaning
staleMinutes20An agent with no step and no tool for this long is reported as stuck.
toolStaleMinutes45An agent inside one tool call for this long is reported as stuck.
shipPatternemptyA regular expression for more Bash or PowerShell commands hold ship: refuses. The built-in list always applies.
tracefalseWrites every decision the mod makes to ~/.claude/better-agent-messaging/trace/.

Limitations

  • A message is read at the agent's next step. A command that is already running, such as a long build, finishes first. A message that lands while the agent writes its final answer starts its next turn, drawn as Claude Code draws a teammate's message.
  • Background subagents already receive messages at their next step from Claude Code itself; for them the mod adds holds, standing rules and status only.
  • Pane teammates accept directives only from 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.
  • On machines with managed settings, and on Team and Enterprise plans, Claude Code keeps installed plugins out of the system prompt. There the mod teaches Claude through a note in the conversation, which Claude reads from its second request on.

How it works

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.

Development

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

License

MIT © Ryan Duff

Source 7 files
hooks/register.tsx 777 lines
1import { 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}
777
hooks/gate.ts 57 lines
1import 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}
57
hooks/protocol.ts 218 lines
1import 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}
218
hooks/role.ts 54 lines
1/**
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}
54
hooks/table.ts 61 lines
1import 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}
61
hooks/trace.ts 17 lines
1// 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}
17
types/index.d.ts 72 lines
1/** 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