@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 +3 -3
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +21 -0
- package/src/agents/atc-bridge-files.ts +1 -1
- package/src/agents/claude-adapter.ts +4 -0
- package/src/agents/codex-adapter.ts +4 -0
- package/src/agents/gateway-adapter.ts +31 -0
- package/src/agents/grok-adapter.ts +4 -0
- package/src/cli.ts +4 -0
- package/src/client/boot-daemon.ts +6 -0
- package/src/daemon/build-agent-list.ts +58 -0
- package/src/daemon/daemon-connection.ts +44 -8
- package/src/daemon/daemon.ts +92 -22
- package/src/daemon/parse-report.ts +29 -3
- package/src/daemon/sessions.ts +5 -0
- package/src/mcp/answer-rpc-request.ts +13 -6
- package/src/mcp/build-tool-list.ts +60 -7
- package/src/mcp/mcp-tools.ts +151 -20
- package/src/mcp/reconnecting-caller.ts +42 -12
- package/src/mcp/require-daemon-features.ts +33 -0
- package/src/mcp/run-tool.ts +75 -23
- package/src/mcp/start-mcp-http-server.ts +130 -24
- package/src/mcp/types.ts +7 -1
- package/src/mcp-http-server.ts +6 -1
- package/src/mcp-server.ts +13 -1
- package/src/protocol/daemon-features.ts +23 -0
- package/src/protocol/parse-daemon-features.ts +19 -0
- package/src/protocol/request-param-schemas.ts +18 -2
- package/src/report.ts +20 -3
- package/src/store/message-record.ts +3 -0
- package/src/store/run-migrations.ts +18 -2
- package/src/store/state-store.ts +77 -10
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
|
|
141
|
-
|
|
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
|
@@ -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>', '</atc-message>');\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('&', '&')\n .replaceAll('\"', '"')\n .replaceAll('<', '<')\n .replaceAll('>', '>');\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>', '</atc-message>');\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('&', '&')\n .replaceAll('\"', '"')\n .replaceAll('<', '<')\n .replaceAll('>', '>');\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
|
-
|
|
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
|
-
//
|
|
126
|
-
|
|
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
|
|
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
|
}
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
320
|
-
await recordMessageStatus(e.atcId,
|
|
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
|
|
912
|
-
//
|
|
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
|
|
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
|
|
1060
|
+
readMessage: async (messageID, waitMs) => {
|
|
1061
|
+
const deadline = Date.now() + waitMs;
|
|
1062
|
+
let initialStatus: MessageStatus | null = null;
|
|
1010
1063
|
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
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);
|