@zgeoff/atc 2.10.3 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -137,9 +137,9 @@ installs in detail.
137
137
  list or revoke grants with `atc grants`. [Remote MCP](./docs/architecture/remote-mcp.md) covers
138
138
  it.
139
139
  - A Claude session takes messages from other tools. `atc_session_message` queues one, and the
140
- session reads it in a new turn, or inside the turn it is running. `atc_message_get` returns the
141
- reply once that turn ends. The [protocol](./docs/architecture/protocol.md#messages) covers the
142
- details.
140
+ session reads it in a new turn, or inside the turn it is running. `atc_message_get` returns that
141
+ turn's final output once the turn ends, and waits for the next status change when given `waitMs`.
142
+ The [protocol](./docs/architecture/protocol.md#messages) covers the details.
143
143
  - `atc events` prints every fleet event as one NDJSON line. The same stream is on a unix socket, and
144
144
  `config.json` hooks run your own commands on events. The [events guide](./docs/guides/events.md)
145
145
  covers all three.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.10.3",
3
+ "version": "2.11.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -117,6 +117,24 @@ export type HeadlessRunner = (
117
117
  hooks: HeadlessRunEvents,
118
118
  ) => { readonly stop: () => void };
119
119
 
120
+ /**
121
+ * How `agents.list` describes an agent. It holds no secret: never an
122
+ * environment value, a credential, a helper command, or a base URL.
123
+ */
124
+ export interface AgentProfile {
125
+ readonly label: string;
126
+
127
+ // The family the agent belongs to, chosen by its adapter; agents.list
128
+ // reports it as given, and any string is a valid kind.
129
+ readonly kind: string;
130
+
131
+ // The binary a spawn runs: a name looked up on PATH, or a path.
132
+ readonly bin: string;
133
+
134
+ // Model names the config sets explicitly, keyed by role; null when it sets none.
135
+ readonly models: Readonly<Record<string, string>> | null;
136
+ }
137
+
120
138
  /**
121
139
  * Everything specific to one agent CLI: how to spawn it, how to read its
122
140
  * hook payloads, where its session names come from, and how to resume a
@@ -137,6 +155,9 @@ export interface AgentAdapter {
137
155
  // Whether a session under this agent can take inbox messages through a tap;
138
156
  // false refuses every message as unsupported.
139
157
  readonly takesMessages: boolean;
158
+
159
+ // Absent on a stand-in adapter, which `agents.list` reports as not installed.
160
+ readonly profile?: AgentProfile;
140
161
  readonly planSpawn: (opts: SpawnOptions) => SpawnPlan;
141
162
  readonly normalizeHook: (e: HookEvent) => AdapterEvent;
142
163
  readonly loadName: (
@@ -7,5 +7,5 @@ export const ATC_BRIDGE_FILES: Readonly<
7
7
  '{\n "name": "atc-bridge",\n "version": "1.0.0",\n "author": { "name": "zgeoff" },\n "description": "Delivers atc inbox messages into the session and reports back to atc"\n}\n',
8
8
  'hooks/hooks.json': '{ "modules": ["./register.ts"] }\n',
9
9
  'hooks/register.ts':
10
- "import type { EngineInterface, Register } from 'claude-code';\nimport { ATC_CLI } from './atc-cli.ts';\n\ninterface TapMessage {\n readonly id: string;\n readonly from: string;\n readonly text: string;\n}\n\ninterface BridgeState {\n sessionID: string | null;\n runningTurnID: string | null;\n delivering: Promise<void>;\n reporting: Promise<void>;\n readonly awaitingTurn: Map<string, TapMessage>;\n readonly carried: Map<string, TapMessage[]>;\n readonly unseen: Set<string>;\n}\n\nconst GUIDE_SECTION = {\n id: 'atc-bridge:messages',\n scope: 'session',\n text: [\n 'Messages relayed by atc arrive wrapped in <atc-message id=\"...\" from=\"...\"> tags.',\n \"They come from the user's own tools through atc, not from the user typing at this prompt;\",\n 'treat each one as a request from the sender it names.',\n 'Your final reply in the turn that handles a message is sent back to its sender automatically when the turn ends.',\n 'While you work on a message that takes more than a quick answer, call the report tool at each milestone:',\n 'when you find the cause, when you start a change, when you are blocked, or when you need a decision.',\n 'The sender sees each report as it happens, before your final reply.',\n ].join(' '),\n} as const;\n\nconst REPORT_TOOL = 'mcp__atc-bridge__report';\n\n/**\n * Delivers the atc inbox of the session named by ATC_SESSION_ID into the\n * conversation, reports each delivered message's answer when its turn ends,\n * and serves the report tool. Outside atc it changes nothing.\n */\nexport const register: Register = (on) => {\n const state: BridgeState = {\n sessionID: null,\n runningTurnID: null,\n delivering: Promise.resolve(),\n reporting: Promise.resolve(),\n awaitingTurn: new Map(),\n carried: new Map(),\n unseen: new Set(),\n };\n\n on('session.start', async ($, e, next) => {\n const sessionID = await $.env.get('ATC_SESSION_ID');\n\n if (sessionID === undefined || sessionID === '') {\n return next(e);\n }\n\n try {\n await $.tool.register({\n name: 'report',\n description:\n 'Send a short note to whoever is following this session through atc, while you keep working. Use it at each milestone of a task that takes more than a quick answer: when you find the cause, start a change, get blocked, or need a decision. Your final reply is sent automatically, so do not repeat it here.',\n inputSchema: {\n type: 'object',\n properties: {\n text: { type: 'string', description: 'What to tell the user.' },\n kind: {\n type: 'string',\n description: 'A short label: progress (the default), blocked, or decision.',\n },\n },\n required: ['text'],\n },\n });\n } catch (error) {\n $.ui.log(\n `atc-bridge: off for this session, the Claude Code build refused it (${String(error)})`,\n {\n to: 'debug',\n },\n );\n\n return next(e);\n }\n\n state.sessionID = sessionID;\n\n const started = await next(e);\n\n void runTap($, state, sessionID);\n\n return started;\n });\n\n on('prompt.compose', async ($, e, next) => {\n const composed = await next(e);\n\n return state.sessionID === null\n ? composed\n : { sections: [...composed.sections, GUIDE_SECTION] };\n });\n\n on('turn.start', async ($, e, next) => {\n const started = await next(e);\n\n if (state.sessionID !== null) {\n updateTurnStarted(state, e.turnId, e.text);\n }\n\n return started;\n });\n\n on('turn.step', async function* updateSeenMessages($, e, next) {\n if (e.agentId === undefined && e.turnId === state.runningTurnID) {\n for (const msg of state.carried.get(e.turnId) ?? []) {\n state.unseen.delete(msg.id);\n }\n }\n\n return yield* next(e);\n });\n\n on('turn.complete', async ($, e, next) => {\n const completed = await next(e);\n\n if (state.sessionID !== null && e.agentId === undefined) {\n updateTurnCompleted($, state, e.turnId, e.answer, e.reason === 'answer');\n }\n\n return completed;\n });\n\n on('tool.call', { tool: REPORT_TOOL }, async ($, e) => {\n const text = typeof e['text'] === 'string' ? e['text'].trim() : '';\n const label = typeof e['kind'] === 'string' ? e['kind'].trim().slice(0, 64) : '';\n\n if (text === '') {\n return { deny: 'report needs non-empty text' };\n }\n\n try {\n await $.process.run(\n [...ATC_CLI, 'report', 'note', '--label', label === '' ? 'progress' : label],\n {\n stdin: text,\n timeoutMs: 5000,\n },\n );\n } catch (error) {\n return { deny: `atc did not take the report: ${String(error)}` };\n }\n\n return { result: 'Sent to the user through atc.' };\n });\n};\n\nasync function runTap($: EngineInterface, state: BridgeState, sessionID: string): Promise<void> {\n const tap = $.process.spawn({ argv: [...ATC_CLI, 'tap', '--session', sessionID] });\n let pending = '';\n\n try {\n for await (const chunk of tap) {\n if (chunk.stream !== 'stdout') {\n continue;\n }\n\n const lines = `${pending}${chunk.text}`.split('\\n');\n\n pending = lines.pop() ?? '';\n\n for (const line of lines) {\n const msg = parseTapLine(line);\n\n if (msg !== null) {\n scheduleDelivery($, state, msg);\n }\n }\n }\n\n $.ui.log('atc-bridge: atc tap ended; this session takes no more atc messages', { to: 'debug' });\n } catch (error) {\n $.ui.log(`atc-bridge: atc tap failed (${String(error)}); this session takes no atc messages`, {\n to: 'debug',\n });\n }\n}\n\nfunction parseTapLine(line: string): TapMessage | null {\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(line);\n } catch {\n return null;\n }\n\n if (\n typeof parsed !== 'object' ||\n parsed === null ||\n !('id' in parsed) ||\n !('from' in parsed) ||\n !('text' in parsed)\n ) {\n return null;\n }\n\n return typeof parsed.id === 'string' &&\n typeof parsed.from === 'string' &&\n typeof parsed.text === 'string'\n ? { id: parsed.id, from: parsed.from, text: parsed.text }\n : null;\n}\n\nfunction scheduleDelivery($: EngineInterface, state: BridgeState, msg: TapMessage): void {\n state.delivering = state.delivering\n .then(() => sendMessage($, state, msg))\n .catch((error: unknown) => {\n $.ui.log(`atc-bridge: delivering ${msg.id} failed (${String(error)})`, { to: 'debug' });\n });\n}\n\nasync function sendMessage($: EngineInterface, state: BridgeState, msg: TapMessage): Promise<void> {\n const turnID = state.runningTurnID;\n\n if (turnID !== null && (await tryUpdateConversation($, msg))) {\n const carried = state.carried.get(turnID);\n\n // The append can resolve after the turn completed; the turn then no\n // longer reports or redelivers anything, so the message goes out as a\n // fresh submit instead.\n if (carried !== undefined && state.runningTurnID === turnID) {\n carried.push(msg);\n state.unseen.add(msg.id);\n\n return;\n }\n }\n\n state.awaitingTurn.set(msg.id, msg);\n\n const submitted = await $.prompt.submit({ text: renderEnvelope(msg) });\n\n if ('drop' in submitted && submitted.drop !== undefined) {\n state.awaitingTurn.delete(msg.id);\n $.ui.log(`atc-bridge: a hook dropped ${msg.id} (${submitted.drop})`, { to: 'debug' });\n }\n}\n\nasync function tryUpdateConversation($: EngineInterface, msg: TapMessage): Promise<boolean> {\n try {\n const appended = await $.session.append({\n message: { type: 'user', content: [{ type: 'text', text: renderEnvelope(msg) }] },\n });\n\n return appended.deny === undefined;\n } catch (error) {\n $.ui.log(`atc-bridge: appending ${msg.id} failed (${String(error)}); submitting it instead`, {\n to: 'debug',\n });\n\n return false;\n }\n}\n\nfunction renderEnvelope(msg: TapMessage): string {\n const body = msg.text.replaceAll('</atc-message>', '&lt;/atc-message&gt;');\n\n return `<atc-message id=\"${encodeAttribute(msg.id)}\" from=\"${encodeAttribute(msg.from)}\">\\n${body}\\n</atc-message>`;\n}\n\nfunction encodeAttribute(value: string): string {\n return value\n .replaceAll('&', '&amp;')\n .replaceAll('\"', '&quot;')\n .replaceAll('<', '&lt;')\n .replaceAll('>', '&gt;');\n}\n\nconst ENVELOPE_ID = /<atc-message id=\"(?<id>[^\"]+)\"/gu;\n\nfunction updateTurnStarted(state: BridgeState, turnID: string, text: string): void {\n const carried: TapMessage[] = [];\n\n state.runningTurnID = turnID;\n\n state.carried.set(turnID, carried);\n\n for (const match of text.matchAll(ENVELOPE_ID)) {\n const msg = state.awaitingTurn.get(match.groups?.['id'] ?? '');\n\n if (msg !== undefined) {\n state.awaitingTurn.delete(msg.id);\n carried.push(msg);\n }\n }\n}\n\nfunction updateTurnCompleted(\n $: EngineInterface,\n state: BridgeState,\n turnID: string,\n answer: string,\n isAnswered: boolean,\n): void {\n const carried = state.carried.get(turnID) ?? [];\n\n state.carried.delete(turnID);\n\n if (state.runningTurnID === turnID) {\n state.runningTurnID = null;\n }\n\n for (const msg of carried) {\n if (state.unseen.delete(msg.id)) {\n scheduleDelivery($, state, msg);\n } else if (isAnswered) {\n scheduleAnsweredReport($, state, msg.id, answer);\n }\n }\n}\n\nfunction scheduleAnsweredReport(\n $: EngineInterface,\n state: BridgeState,\n messageID: string,\n answer: string,\n): void {\n state.reporting = state.reporting\n .then(async () => {\n await $.process.run([...ATC_CLI, 'report', 'answered', '--message', messageID], {\n stdin: answer,\n timeoutMs: 5000,\n });\n })\n .catch((error: unknown) => {\n $.ui.log(`atc-bridge: reporting ${messageID} failed (${String(error)})`, { to: 'debug' });\n });\n}\n",
10
+ "import type { EngineInterface, Register } from 'claude-code';\nimport { ATC_CLI } from './atc-cli.ts';\n\ninterface TapMessage {\n readonly id: string;\n readonly from: string;\n readonly text: string;\n}\n\ninterface BridgeState {\n sessionID: string | null;\n runningTurnID: string | null;\n delivering: Promise<void>;\n reporting: Promise<void>;\n readonly awaitingTurn: Map<string, TapMessage>;\n readonly carried: Map<string, TapMessage[]>;\n readonly unseen: Set<string>;\n}\n\nconst GUIDE_SECTION = {\n id: 'atc-bridge:messages',\n scope: 'session',\n text: [\n 'Messages relayed by atc arrive wrapped in <atc-message id=\"...\" from=\"...\"> tags.',\n \"They come from the user's own tools through atc, not from the user typing at this prompt;\",\n 'treat each one as a request from the sender it names.',\n 'Your final reply in the turn that handles a message is sent back to its sender automatically when the turn ends.',\n 'While you work on a message that takes more than a quick answer, call the report tool at each milestone:',\n 'when you find the cause, when you start a change, when you are blocked, or when you need a decision.',\n 'The sender sees each report as it happens, before your final reply.',\n ].join(' '),\n} as const;\n\nconst REPORT_TOOL = 'mcp__atc-bridge__report';\n\n/**\n * Delivers the atc inbox of the session named by ATC_SESSION_ID into the\n * conversation and serves the report tool. When a turn ends, it reports the\n * turn's final reply, with the turn's id, as the answer to every message the\n * turn carried. Outside atc it changes nothing.\n */\nexport const register: Register = (on) => {\n const state: BridgeState = {\n sessionID: null,\n runningTurnID: null,\n delivering: Promise.resolve(),\n reporting: Promise.resolve(),\n awaitingTurn: new Map(),\n carried: new Map(),\n unseen: new Set(),\n };\n\n on('session.start', async ($, e, next) => {\n const sessionID = await $.env.get('ATC_SESSION_ID');\n\n if (sessionID === undefined || sessionID === '') {\n return next(e);\n }\n\n try {\n await $.tool.register({\n name: 'report',\n description:\n 'Send a short note to whoever is following this session through atc, while you keep working. Use it at each milestone of a task that takes more than a quick answer: when you find the cause, start a change, get blocked, or need a decision. Your final reply is sent automatically, so do not repeat it here.',\n inputSchema: {\n type: 'object',\n properties: {\n text: { type: 'string', description: 'What to tell the user.' },\n kind: {\n type: 'string',\n description: 'A short label: progress (the default), blocked, or decision.',\n },\n },\n required: ['text'],\n },\n });\n } catch (error) {\n $.ui.log(\n `atc-bridge: off for this session, the Claude Code build refused it (${String(error)})`,\n {\n to: 'debug',\n },\n );\n\n return next(e);\n }\n\n state.sessionID = sessionID;\n\n const started = await next(e);\n\n void runTap($, state, sessionID);\n\n return started;\n });\n\n on('prompt.compose', async ($, e, next) => {\n const composed = await next(e);\n\n return state.sessionID === null\n ? composed\n : { sections: [...composed.sections, GUIDE_SECTION] };\n });\n\n on('turn.start', async ($, e, next) => {\n const started = await next(e);\n\n if (state.sessionID !== null) {\n updateTurnStarted(state, e.turnId, e.text);\n }\n\n return started;\n });\n\n on('turn.step', async function* updateSeenMessages($, e, next) {\n if (e.agentId === undefined && e.turnId === state.runningTurnID) {\n for (const msg of state.carried.get(e.turnId) ?? []) {\n state.unseen.delete(msg.id);\n }\n }\n\n return yield* next(e);\n });\n\n on('turn.complete', async ($, e, next) => {\n const completed = await next(e);\n\n if (state.sessionID !== null && e.agentId === undefined) {\n updateTurnCompleted($, state, e.turnId, e.answer, e.reason === 'answer');\n }\n\n return completed;\n });\n\n on('tool.call', { tool: REPORT_TOOL }, async ($, e) => {\n const text = typeof e['text'] === 'string' ? e['text'].trim() : '';\n const label = typeof e['kind'] === 'string' ? e['kind'].trim().slice(0, 64) : '';\n\n if (text === '') {\n return { deny: 'report needs non-empty text' };\n }\n\n try {\n await $.process.run(\n [...ATC_CLI, 'report', 'note', '--label', label === '' ? 'progress' : label],\n {\n stdin: text,\n timeoutMs: 5000,\n },\n );\n } catch (error) {\n return { deny: `atc did not take the report: ${String(error)}` };\n }\n\n return { result: 'Sent to the user through atc.' };\n });\n};\n\nasync function runTap($: EngineInterface, state: BridgeState, sessionID: string): Promise<void> {\n const tap = $.process.spawn({ argv: [...ATC_CLI, 'tap', '--session', sessionID] });\n let pending = '';\n\n try {\n for await (const chunk of tap) {\n if (chunk.stream !== 'stdout') {\n continue;\n }\n\n const lines = `${pending}${chunk.text}`.split('\\n');\n\n pending = lines.pop() ?? '';\n\n for (const line of lines) {\n const msg = parseTapLine(line);\n\n if (msg !== null) {\n scheduleDelivery($, state, msg);\n }\n }\n }\n\n $.ui.log('atc-bridge: atc tap ended; this session takes no more atc messages', { to: 'debug' });\n } catch (error) {\n $.ui.log(`atc-bridge: atc tap failed (${String(error)}); this session takes no atc messages`, {\n to: 'debug',\n });\n }\n}\n\nfunction parseTapLine(line: string): TapMessage | null {\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(line);\n } catch {\n return null;\n }\n\n if (\n typeof parsed !== 'object' ||\n parsed === null ||\n !('id' in parsed) ||\n !('from' in parsed) ||\n !('text' in parsed)\n ) {\n return null;\n }\n\n return typeof parsed.id === 'string' &&\n typeof parsed.from === 'string' &&\n typeof parsed.text === 'string'\n ? { id: parsed.id, from: parsed.from, text: parsed.text }\n : null;\n}\n\nfunction scheduleDelivery($: EngineInterface, state: BridgeState, msg: TapMessage): void {\n state.delivering = state.delivering\n .then(() => sendMessage($, state, msg))\n .catch((error: unknown) => {\n $.ui.log(`atc-bridge: delivering ${msg.id} failed (${String(error)})`, { to: 'debug' });\n });\n}\n\nasync function sendMessage($: EngineInterface, state: BridgeState, msg: TapMessage): Promise<void> {\n const turnID = state.runningTurnID;\n\n if (turnID !== null && (await tryUpdateConversation($, msg))) {\n const carried = state.carried.get(turnID);\n\n // The append can resolve after the turn completed; the turn then no\n // longer reports or redelivers anything, so the message goes out as a\n // fresh submit instead.\n if (carried !== undefined && state.runningTurnID === turnID) {\n carried.push(msg);\n state.unseen.add(msg.id);\n\n return;\n }\n }\n\n state.awaitingTurn.set(msg.id, msg);\n\n const submitted = await $.prompt.submit({ text: renderEnvelope(msg) });\n\n if ('drop' in submitted && submitted.drop !== undefined) {\n state.awaitingTurn.delete(msg.id);\n $.ui.log(`atc-bridge: a hook dropped ${msg.id} (${submitted.drop})`, { to: 'debug' });\n }\n}\n\nasync function tryUpdateConversation($: EngineInterface, msg: TapMessage): Promise<boolean> {\n try {\n const appended = await $.session.append({\n message: { type: 'user', content: [{ type: 'text', text: renderEnvelope(msg) }] },\n });\n\n return appended.deny === undefined;\n } catch (error) {\n $.ui.log(`atc-bridge: appending ${msg.id} failed (${String(error)}); submitting it instead`, {\n to: 'debug',\n });\n\n return false;\n }\n}\n\nfunction renderEnvelope(msg: TapMessage): string {\n const body = msg.text.replaceAll('</atc-message>', '&lt;/atc-message&gt;');\n\n return `<atc-message id=\"${encodeAttribute(msg.id)}\" from=\"${encodeAttribute(msg.from)}\">\\n${body}\\n</atc-message>`;\n}\n\nfunction encodeAttribute(value: string): string {\n return value\n .replaceAll('&', '&amp;')\n .replaceAll('\"', '&quot;')\n .replaceAll('<', '&lt;')\n .replaceAll('>', '&gt;');\n}\n\nconst ENVELOPE_ID = /<atc-message id=\"(?<id>[^\"]+)\"/gu;\n\nfunction updateTurnStarted(state: BridgeState, turnID: string, text: string): void {\n const carried: TapMessage[] = [];\n\n state.runningTurnID = turnID;\n\n state.carried.set(turnID, carried);\n\n for (const match of text.matchAll(ENVELOPE_ID)) {\n const msg = state.awaitingTurn.get(match.groups?.['id'] ?? '');\n\n if (msg !== undefined) {\n state.awaitingTurn.delete(msg.id);\n carried.push(msg);\n }\n }\n}\n\nfunction updateTurnCompleted(\n $: EngineInterface,\n state: BridgeState,\n turnID: string,\n answer: string,\n isAnswered: boolean,\n): void {\n const carried = state.carried.get(turnID) ?? [];\n\n state.carried.delete(turnID);\n\n if (state.runningTurnID === turnID) {\n state.runningTurnID = null;\n }\n\n const answered: string[] = [];\n\n for (const msg of carried) {\n if (state.unseen.delete(msg.id)) {\n scheduleDelivery($, state, msg);\n } else if (isAnswered) {\n answered.push(msg.id);\n }\n }\n\n // One report carries every message the turn answered, so atc records the\n // whole group at once and no member reads as answered before the rest.\n if (answered.length > 0) {\n scheduleAnsweredReport($, state, answered, turnID, answer);\n }\n}\n\nfunction scheduleAnsweredReport(\n $: EngineInterface,\n state: BridgeState,\n messageIDs: readonly string[],\n turnID: string,\n answer: string,\n): void {\n state.reporting = state.reporting\n .then(async () => {\n await $.process.run(\n [...ATC_CLI, 'report', 'answered', '--messages', messageIDs.join(','), '--turn', turnID],\n {\n stdin: answer,\n timeoutMs: 5000,\n },\n );\n })\n .catch((error: unknown) => {\n $.ui.log(`atc-bridge: reporting ${messageIDs.join(', ')} failed (${String(error)})`, {\n to: 'debug',\n });\n });\n}\n",
11
11
  };
@@ -10,6 +10,7 @@ import { toShellArg } from '../shared/to-shell-arg';
10
10
  import type {
11
11
  AdapterEvent,
12
12
  AgentAdapter,
13
+ AgentProfile,
13
14
  HeadlessRunner,
14
15
  NameUpdate,
15
16
  ResumeCheck,
@@ -48,6 +49,8 @@ export class ClaudeAdapter implements AgentAdapter {
48
49
 
49
50
  readonly parseTranscriptLine = parseClaudeTranscriptLine;
50
51
 
52
+ readonly profile: AgentProfile;
53
+
51
54
  readonly takesMessages = true;
52
55
 
53
56
  private readonly config: Config;
@@ -63,6 +66,7 @@ export class ClaudeAdapter implements AgentAdapter {
63
66
  constructor(config: Config, headlessRunner: HeadlessRunner | null = null, bridgeTarget?: string) {
64
67
  this.bridgeTarget = bridgeTarget;
65
68
  this.config = config;
69
+ this.profile = { label: 'Claude', kind: 'claude', bin: config.claudeBin, models: null };
66
70
 
67
71
  this.headlessRunner =
68
72
  headlessRunner === null
@@ -11,6 +11,7 @@ import { toShellArg } from '../shared/to-shell-arg';
11
11
  import type {
12
12
  AdapterEvent,
13
13
  AgentAdapter,
14
+ AgentProfile,
14
15
  NameUpdate,
15
16
  ResumeCheck,
16
17
  SpawnOptions,
@@ -47,12 +48,15 @@ export class CodexAdapter implements AgentAdapter {
47
48
  // Codex's hooks are authoritative; no screen heuristics needed.
48
49
  readonly screenDetector = null;
49
50
 
51
+ readonly profile: AgentProfile;
52
+
50
53
  readonly takesMessages = false;
51
54
 
52
55
  private readonly config: Config;
53
56
 
54
57
  constructor(config: Config) {
55
58
  this.config = config;
59
+ this.profile = { label: 'Codex', kind: 'codex', bin: config.codexBin, models: null };
56
60
  }
57
61
 
58
62
  planSpawn(opts: SpawnOptions): SpawnPlan {
@@ -7,6 +7,7 @@ import type {
7
7
  AdapterEvent,
8
8
  AgentAdapter,
9
9
  AgentID,
10
+ AgentProfile,
10
11
  HeadlessRunner,
11
12
  NameUpdate,
12
13
  ResumeCheck,
@@ -40,6 +41,8 @@ export class GatewayAdapter implements AgentAdapter {
40
41
 
41
42
  readonly takesMessages = true;
42
43
 
44
+ readonly profile: AgentProfile;
45
+
43
46
  private readonly gateway: GatewayConfig;
44
47
 
45
48
  private readonly claude: ClaudeAdapter;
@@ -62,6 +65,13 @@ export class GatewayAdapter implements AgentAdapter {
62
65
  this.gateway = gateway;
63
66
  this.id = gateway.id;
64
67
 
68
+ this.profile = {
69
+ label: gateway.label,
70
+ kind: 'gateway',
71
+ bin: gateway.bin,
72
+ models: pickModels(gateway.env),
73
+ };
74
+
65
75
  this.claude = new ClaudeAdapter(config);
66
76
 
67
77
  this.headlessRunner =
@@ -131,3 +141,24 @@ export class GatewayAdapter implements AgentAdapter {
131
141
  return this.bridgeDir;
132
142
  }
133
143
  }
144
+
145
+ // The model names a gateway's env sets explicitly, keyed by role:
146
+ // `ANTHROPIC_MODEL` is `default`, and `ANTHROPIC_DEFAULT_<TIER>_MODEL` is the
147
+ // tier in lower case. No other env value leaves the adapter.
148
+ const MODEL_KEY = /^ANTHROPIC_(?:DEFAULT_(?<tier>[A-Z0-9]+)_)?MODEL$/u;
149
+
150
+ function pickModels(
151
+ env: Readonly<Record<string, string>>,
152
+ ): Readonly<Record<string, string>> | null {
153
+ const models: Record<string, string> = {};
154
+
155
+ for (const [key, value] of Object.entries(env)) {
156
+ const matched = MODEL_KEY.exec(key);
157
+
158
+ if (matched !== null && value !== '') {
159
+ models[matched.groups?.['tier']?.toLowerCase() ?? 'default'] = value;
160
+ }
161
+ }
162
+
163
+ return Object.keys(models).length === 0 ? null : models;
164
+ }
@@ -12,6 +12,7 @@ import { toShellArg } from '../shared/to-shell-arg';
12
12
  import type {
13
13
  AdapterEvent,
14
14
  AgentAdapter,
15
+ AgentProfile,
15
16
  NameUpdate,
16
17
  ResumeCheck,
17
18
  SpawnOptions,
@@ -60,6 +61,8 @@ export class GrokAdapter implements AgentAdapter {
60
61
  // Grok's hooks are authoritative; no screen heuristics needed.
61
62
  readonly screenDetector = null;
62
63
 
64
+ readonly profile: AgentProfile;
65
+
63
66
  readonly takesMessages = false;
64
67
 
65
68
  private readonly config: Config;
@@ -68,6 +71,7 @@ export class GrokAdapter implements AgentAdapter {
68
71
 
69
72
  constructor(config: Config) {
70
73
  this.config = config;
74
+ this.profile = { label: 'Grok', kind: 'grok', bin: config.grokBin, models: null };
71
75
  }
72
76
 
73
77
  planSpawn(opts: SpawnOptions): SpawnPlan {
package/src/cli.ts CHANGED
@@ -317,13 +317,17 @@ const main = defineCommand({
317
317
  kind: { type: 'positional', required: false, default: '' },
318
318
  message: { type: 'string', default: '' },
319
319
  label: { type: 'string', default: '' },
320
+ messages: { type: 'string', default: '' },
321
+ turn: { type: 'string', default: '' },
320
322
  },
321
323
  async run(ctx) {
322
324
  const reporter = await import('./report');
323
325
 
324
326
  await reporter.runReport(ctx.args.kind, {
325
327
  message: ctx.args.message,
328
+ messages: ctx.args.messages,
326
329
  label: ctx.args.label,
330
+ turn: ctx.args.turn,
327
331
  });
328
332
  },
329
333
  }),
@@ -4,6 +4,8 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { toAgentID } from '../agents/agent-adapter';
6
6
  import type { AgentID } from '../agents/agent-adapter';
7
+ import type { DaemonFeature } from '../protocol/daemon-features';
8
+ import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
7
9
  import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
8
10
  import { findDaemonRecord } from '../shared/find-daemon-record';
9
11
  import { getBuild } from '../shared/get-build';
@@ -18,6 +20,9 @@ export interface DaemonBoot {
18
20
  readonly stale: boolean;
19
21
  readonly lastUsedAgent: AgentID;
20
22
 
23
+ // What the daemon announced it serves; an older daemon announces less.
24
+ readonly features: ReadonlySet<DaemonFeature>;
25
+
21
26
  // The socket the client reached, which differs from the computed one when
22
27
  // the daemon was found through its record in the state directory.
23
28
  readonly socketPath: string;
@@ -49,6 +54,7 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
49
54
  client,
50
55
  stale: hello['daemon'] !== build,
51
56
  lastUsedAgent: toAgentID(hello['lastUsedAgent']),
57
+ features: parseDaemonFeatures(hello),
52
58
  socketPath: opened.socketPath,
53
59
  };
54
60
  } catch (error) {
@@ -0,0 +1,58 @@
1
+ import type { AgentAdapter } from '../agents/agent-adapter';
2
+
3
+ interface AgentCapabilities {
4
+ // Only an installed agent can start a session.
5
+ readonly spawn: boolean;
6
+ readonly readTranscript: boolean;
7
+
8
+ // Whether a session takes inbox messages through a tap.
9
+ readonly message: boolean;
10
+ readonly attach: boolean;
11
+ readonly screen: boolean;
12
+ readonly input: boolean;
13
+ }
14
+
15
+ export interface AgentEntry {
16
+ readonly id: string;
17
+ readonly label: string;
18
+ readonly kind: string;
19
+
20
+ // Whether the agent's binary resolves, on PATH or at its configured path.
21
+ readonly installed: boolean;
22
+ readonly capabilities: AgentCapabilities;
23
+ readonly models: Readonly<Record<string, string>> | null;
24
+ }
25
+
26
+ /**
27
+ * One `agents.list` entry per registered adapter, in registration order. An
28
+ * entry carries only the adapter's profile and what its interface offers,
29
+ * so no environment value, credential, helper command, or base URL reaches
30
+ * it. Every session runs in a PTY, so each agent can be attached, read as a
31
+ * screen, and typed into. A stand-in adapter without a profile is listed
32
+ * under its id as both label and kind.
33
+ */
34
+ export function buildAgentList(
35
+ adapters: readonly AgentAdapter[],
36
+ isInstalled: (bin: string) => boolean,
37
+ ): AgentEntry[] {
38
+ return adapters.map((adapter) => {
39
+ const profile = adapter.profile;
40
+ const installed = profile === undefined ? false : isInstalled(profile.bin);
41
+
42
+ return {
43
+ id: adapter.id,
44
+ label: profile?.label ?? adapter.id,
45
+ kind: profile?.kind ?? adapter.id,
46
+ installed,
47
+ capabilities: {
48
+ spawn: installed,
49
+ readTranscript: adapter.parseTranscriptLine !== undefined,
50
+ message: adapter.takesMessages,
51
+ attach: true,
52
+ screen: true,
53
+ input: true,
54
+ },
55
+ models: profile?.models ?? null,
56
+ };
57
+ });
58
+ }
@@ -1,5 +1,6 @@
1
1
  import { basename } from 'node:path';
2
2
  import type { AgentAdapter, AgentID, SpawnOptions } from '../agents/agent-adapter';
3
+ import { DAEMON_FEATURES } from '../protocol/daemon-features';
3
4
  import { decodeCursor } from '../protocol/decode-cursor';
4
5
  import { encodeCursor } from '../protocol/encode-cursor';
5
6
  import { OutboundQueue } from '../protocol/outbound-queue';
@@ -18,6 +19,7 @@ import type { SessionID } from '../shared/session-id';
18
19
  import type { FleetEntry } from '../store/fleet-entry';
19
20
  import type { MessageRecord } from '../store/message-record';
20
21
  import type { Dims } from './attach-registry';
22
+ import type { AgentEntry } from './build-agent-list';
21
23
  import type { FleetEvent } from './build-fleet-events';
22
24
  import type { TranscriptPage, TranscriptPosition } from './load-transcript-page';
23
25
  import type { AnswerResult } from './permission-registry';
@@ -55,6 +57,7 @@ export interface DaemonContext {
55
57
  readonly build: string;
56
58
  readonly collectSessions: () => SessionDescriptor[];
57
59
  readonly collectSpawnDirs: () => Promise<string[]>;
60
+ readonly collectAgents: () => AgentList;
58
61
  readonly collectFleet: () => Promise<FleetEntry[]>;
59
62
  readonly loadLastUsedAgent: () => Promise<AgentID>;
60
63
  readonly findAdapter: (id: AgentID) => AgentAdapter | null;
@@ -101,13 +104,14 @@ export interface DaemonContext {
101
104
  afterID: number | null,
102
105
  limit: number,
103
106
  waitMs: number,
104
- ) => Promise<FleetEvent[]>;
107
+ sessionID: SessionID | null,
108
+ ) => Promise<EventsPage>;
105
109
  readonly writeSessionMessage: (
106
110
  sessionID: SessionID,
107
111
  from: string,
108
112
  text: string,
109
113
  ) => Promise<MessageRecord | 'missing' | 'dead' | 'unsupported' | 'no_tap'>;
110
- readonly readMessage: (messageID: MessageID) => Promise<MessageView | null>;
114
+ readonly readMessage: (messageID: MessageID, waitMs: number) => Promise<MessageView | null>;
111
115
  readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
112
116
  readonly ackMessage: (
113
117
  client: TapClient,
@@ -122,11 +126,29 @@ export interface OutputClient {
122
126
  readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
123
127
  }
124
128
 
125
- // One message as `message.get` reports it: the session it belongs to now and
126
- // every field of the record.
129
+ // The `agents.list` answer: the host the daemon runs on, and each agent.
130
+ interface AgentList {
131
+ readonly daemon: {
132
+ readonly hostname: string;
133
+ readonly platform: string;
134
+ readonly arch: string;
135
+ readonly build: string;
136
+ };
137
+ readonly agents: readonly AgentEntry[];
138
+ }
139
+
140
+ // One `events.read` answer: the events, and whether more follow them.
141
+ interface EventsPage {
142
+ readonly events: readonly FleetEvent[];
143
+ readonly more: boolean;
144
+ }
145
+
146
+ // One message as `message.get` reports it: the session it belongs to now,
147
+ // every field of the record, and the other messages its turn answered.
127
148
  interface MessageView {
128
149
  readonly session: SessionID;
129
150
  readonly record: MessageRecord;
151
+ readonly answeredWith: readonly MessageID[];
130
152
  }
131
153
 
132
154
  // The slice of a connection a tap subscription needs.
@@ -309,6 +331,11 @@ export class DaemonConnection {
309
331
 
310
332
  return;
311
333
  }
334
+ case 'agents.list': {
335
+ this.sendOk(req.id, { ...this.ctx.collectAgents() });
336
+
337
+ return;
338
+ }
312
339
  case 'dirs.list': {
313
340
  this.sendOk(req.id, { dirs: await this.ctx.collectSpawnDirs() });
314
341
 
@@ -826,14 +853,20 @@ export class DaemonConnection {
826
853
  afterID = decoded.id;
827
854
  }
828
855
 
829
- const events = await this.ctx.readEvents(afterID, parsed.data.limit, parsed.data.waitMs);
856
+ const page = await this.ctx.readEvents(
857
+ afterID,
858
+ parsed.data.limit,
859
+ parsed.data.waitMs,
860
+ parsed.data.session ?? null,
861
+ );
830
862
 
831
- const last = events.at(-1);
863
+ const last = page.events.at(-1);
832
864
 
833
865
  // A cursor always comes back so a client can long-poll from an empty trail.
834
866
  this.sendOk(req.id, {
835
- events,
867
+ events: page.events,
836
868
  cursor: last === undefined ? encodeCursor({ kind: 'events', id: afterID ?? 0 }) : last.cursor,
869
+ more: page.more,
837
870
  });
838
871
  }
839
872
 
@@ -923,7 +956,7 @@ export class DaemonConnection {
923
956
 
924
957
  const messageID = parsed.data.message;
925
958
 
926
- const view = await this.ctx.readMessage(messageID);
959
+ const view = await this.ctx.readMessage(messageID, parsed.data.waitMs);
927
960
 
928
961
  if (view === null) {
929
962
  this.sendErr(req.id, 'bad_args', `no message '${messageID}'`);
@@ -943,6 +976,8 @@ export class DaemonConnection {
943
976
  ...(record.deliveredAt === undefined ? {} : { deliveredAt: record.deliveredAt }),
944
977
  ...(record.answeredAt === undefined ? {} : { answeredAt: record.answeredAt }),
945
978
  ...(record.answer === undefined ? {} : { answer: record.answer }),
979
+ turn: record.turn ?? null,
980
+ answeredWith: view.answeredWith,
946
981
  });
947
982
  }
948
983
 
@@ -1025,6 +1060,7 @@ export class DaemonConnection {
1025
1060
  this.sendOk(id, {
1026
1061
  daemon: this.ctx.build,
1027
1062
  limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
1063
+ features: DAEMON_FEATURES,
1028
1064
  lastUsedAgent: await this.ctx.loadLastUsedAgent(),
1029
1065
  });
1030
1066
  }
@@ -1,18 +1,22 @@
1
1
  import { unlinkSync, writeFileSync } from 'node:fs';
2
+ import { hostname } from 'node:os';
2
3
  import { dirname, join } from 'node:path';
3
4
  import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
4
5
  import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
5
6
  import type { EventMsg } from '../protocol/protocol';
6
7
  import type { HooksConfig } from '../shared/collect-hooks';
7
8
  import { findDaemonRecord } from '../shared/find-daemon-record';
9
+ import type { MessageID } from '../shared/message-id';
8
10
  import type { SessionID } from '../shared/session-id';
9
11
  import { truncateToBytes } from '../shared/truncate-to-bytes';
10
12
  import type { MessageOwner } from '../store/message-owner';
11
- import type { MessageRecord } from '../store/message-record';
13
+ import type { MessageRecord, MessageStatus } from '../store/message-record';
12
14
  import { StateStore } from '../store/state-store';
15
+ import type { EventScope } from '../store/state-store';
13
16
  import type { TrailEntry } from '../store/trail-entry';
14
17
  import { ANSWER_BYTE_CAP } from './answer-byte-cap';
15
18
  import { AttachRegistry } from './attach-registry';
19
+ import { buildAgentList } from './build-agent-list';
16
20
  import { buildFleetEvents } from './build-fleet-events';
17
21
  import { buildMessageTrailEntry } from './build-message-trail-entry';
18
22
  import { buildReportTrailEntry } from './build-report-trail-entry';
@@ -202,6 +206,35 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
202
206
  emitEvent(buildSessionMessageEvent(sessionID, record), findHookScope(sessionID));
203
207
  };
204
208
 
209
+ // A live session's scope also matches the rows it wrote under an earlier
210
+ // atc id, through the agent session id a restore carries on.
211
+ const buildEventScope = (sessionID: SessionID): EventScope => ({
212
+ atcID: sessionID,
213
+ agentSessionID: mgr.sessions.find((x) => x.id === sessionID)?.agentSessionID,
214
+ });
215
+
216
+ // A message belongs to the live session holding its atc id or its agent
217
+ // session id, else to the atc id it was sent to.
218
+ const readMessageView = async (messageID: MessageID) => {
219
+ const record = await store.findMessageByID(messageID);
220
+
221
+ if (record === null) {
222
+ return null;
223
+ }
224
+
225
+ const owner = mgr.sessions.find(
226
+ (x) =>
227
+ x.id === record.atcID ||
228
+ (record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID),
229
+ );
230
+
231
+ return {
232
+ session: owner?.id ?? record.atcID,
233
+ record,
234
+ answeredWith: await store.collectTurnSiblings(record),
235
+ };
236
+ };
237
+
205
238
  registry.onRequested = (req) => {
206
239
  emitEvent(
207
240
  {
@@ -309,15 +342,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
309
342
  const owner = s === undefined ? { atcID: e.atcId } : buildMessageOwner(s);
310
343
 
311
344
  try {
312
- const answered = await store.updateMessageAnswered(
313
- report.message,
345
+ // One statement answers the whole turn, so a reader that sees any
346
+ // member answered already finds every sibling answered beside it.
347
+ const answered = await store.updateMessagesAnswered(
348
+ report.messages,
314
349
  owner,
315
350
  truncateToBytes(report.answer, ANSWER_BYTE_CAP),
316
351
  Date.now(),
352
+ report.turn,
317
353
  );
318
354
 
319
- if (answered !== null) {
320
- await recordMessageStatus(e.atcId, answered);
355
+ for (const record of answered) {
356
+ await recordMessageStatus(e.atcId, record);
321
357
  }
322
358
  } catch {}
323
359
  };
@@ -620,6 +656,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
620
656
  build: opts.build,
621
657
  collectSessions: () => mgr.collectDescriptors(),
622
658
  collectSpawnDirs: () => store.collectSpawnDirs(),
659
+ collectAgents: () => ({
660
+ daemon: {
661
+ hostname: hostname(),
662
+ platform: process.platform,
663
+ arch: process.arch,
664
+ build: opts.build,
665
+ },
666
+ agents: buildAgentList(mgr.collectAdapters(), (bin) => Bun.which(bin) !== null),
667
+ }),
623
668
  collectFleet: () => store.loadFleet(),
624
669
  loadLastUsedAgent: () => store.loadLastUsedAgent(),
625
670
  findAdapter: (kind) => mgr.findAdapter(kind),
@@ -905,23 +950,30 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
905
950
 
906
951
  return { path, page };
907
952
  },
908
- readEvents: async (afterID, limit, waitMs) => {
953
+ readEvents: async (afterID, limit, waitMs, sessionID) => {
909
954
  const deadline = Date.now() + waitMs;
955
+ const scope = sessionID === null ? null : buildEventScope(sessionID);
910
956
 
911
- // A wake for an event the read leaves out (a heartbeat) loops back to
912
- // wait out the rest of the window.
957
+ // A wake for an event the read leaves out (a heartbeat, or another
958
+ // session's event under a session filter) loops back to wait out the
959
+ // rest of the window.
913
960
  for (;;) {
914
961
  const generation = eventSignal.generation;
915
962
 
963
+ // A read from a cursor takes one row past the limit to learn whether
964
+ // more follow; the latest events have nothing after them.
916
965
  const rows =
917
966
  afterID === null
918
- ? await store.collectLatestEvents(limit)
919
- : await store.collectEventsAfter(afterID, limit);
967
+ ? await store.collectLatestEvents(limit, scope)
968
+ : await store.collectEventsAfter(afterID, limit + 1, scope);
920
969
 
921
970
  const remaining = deadline - Date.now();
922
971
 
923
972
  if (rows.length > 0 || remaining <= 0 || eventSignal.disposed) {
924
- return buildFleetEvents(rows, mgr.collectDescriptors());
973
+ return {
974
+ events: buildFleetEvents(rows.slice(0, limit), mgr.collectDescriptors()),
975
+ more: rows.length > limit,
976
+ };
925
977
  }
926
978
 
927
979
  await eventSignal.waitForNext(generation, remaining);
@@ -1005,20 +1057,38 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1005
1057
 
1006
1058
  return 'ok';
1007
1059
  },
1008
- readMessage: async (messageID) => {
1009
- const record = await store.findMessageByID(messageID);
1060
+ readMessage: async (messageID, waitMs) => {
1061
+ const deadline = Date.now() + waitMs;
1062
+ let initialStatus: MessageStatus | null = null;
1010
1063
 
1011
- if (record === null) {
1012
- return null;
1013
- }
1064
+ // Every message status change writes a trail entry and wakes the
1065
+ // signal, so a wake re-reads the message and returns once its status
1066
+ // moved from the status at call time. An answered message has no
1067
+ // further status to wait for.
1068
+ for (;;) {
1069
+ const generation = eventSignal.generation;
1014
1070
 
1015
- const owner = mgr.sessions.find(
1016
- (x) =>
1017
- x.id === record.atcID ||
1018
- (record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID),
1019
- );
1071
+ const view = await readMessageView(messageID);
1072
+
1073
+ if (view === null) {
1074
+ return null;
1075
+ }
1076
+
1077
+ initialStatus ??= view.record.status;
1020
1078
 
1021
- return { session: owner?.id ?? record.atcID, record };
1079
+ const remaining = deadline - Date.now();
1080
+
1081
+ if (
1082
+ view.record.status !== initialStatus ||
1083
+ view.record.status === 'answered' ||
1084
+ remaining <= 0 ||
1085
+ eventSignal.disposed
1086
+ ) {
1087
+ return view;
1088
+ }
1089
+
1090
+ await eventSignal.waitForNext(generation, remaining);
1091
+ }
1022
1092
  },
1023
1093
  ackMessage: async (client, sessionID, messageID) => {
1024
1094
  const s = mgr.sessions.find((x) => x.id === sessionID);