@zgeoff/atc 2.10.4 → 2.12.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.
Files changed (86) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/agents/agent-adapter.ts +72 -0
  4. package/src/agents/atc-bridge-files.ts +1 -1
  5. package/src/agents/build-args-without-flags.ts +25 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/claude-adapter.ts +52 -1
  8. package/src/agents/claude-effort-levels.ts +5 -0
  9. package/src/agents/codex-adapter.ts +46 -1
  10. package/src/agents/find-flag-value.ts +27 -0
  11. package/src/agents/gateway-adapter.ts +75 -1
  12. package/src/agents/grok-adapter.ts +22 -0
  13. package/src/cli.ts +22 -0
  14. package/src/client/boot-daemon.ts +6 -0
  15. package/src/client/daemon-client.ts +14 -2
  16. package/src/client/spawn-picker.ts +27 -4
  17. package/src/daemon/build-agent-list.ts +94 -0
  18. package/src/daemon/build-config-revision.ts +27 -0
  19. package/src/daemon/build-execution-targets.ts +34 -0
  20. package/src/daemon/build-fleet-events.ts +2 -1
  21. package/src/daemon/build-payload-hash.ts +31 -0
  22. package/src/daemon/build-scoped-context.ts +232 -0
  23. package/src/daemon/build-target-access.ts +33 -0
  24. package/src/daemon/build-target-forbidden-error.ts +13 -0
  25. package/src/daemon/build-target-identity.ts +22 -0
  26. package/src/daemon/build-target-list.ts +50 -0
  27. package/src/daemon/daemon-connection.ts +371 -99
  28. package/src/daemon/daemon.ts +537 -90
  29. package/src/daemon/effect-remains-error.ts +13 -0
  30. package/src/daemon/execution-provider.ts +103 -0
  31. package/src/daemon/find-execution-refusal.ts +104 -0
  32. package/src/daemon/idempotency-ledger.ts +164 -0
  33. package/src/daemon/local-pty-provider.ts +83 -0
  34. package/src/daemon/mint-session-id.ts +5 -5
  35. package/src/daemon/parse-report.ts +29 -3
  36. package/src/daemon/parse-spawn-overrides.ts +94 -0
  37. package/src/daemon/permission-registry.ts +14 -4
  38. package/src/daemon/restore-fleet.ts +66 -19
  39. package/src/daemon/session-runtime.ts +10 -0
  40. package/src/daemon/sessions.ts +287 -65
  41. package/src/daemon/start-headless-run.ts +11 -0
  42. package/src/daemon/start-headless-turn.ts +11 -2
  43. package/src/daemon/target-access.ts +36 -0
  44. package/src/mcp/answer-mcp-request.ts +12 -4
  45. package/src/mcp/answer-rpc-request.ts +40 -6
  46. package/src/mcp/build-principal-caller.ts +15 -0
  47. package/src/mcp/build-spawn-descriptions.ts +45 -0
  48. package/src/mcp/build-tool-list.ts +88 -7
  49. package/src/mcp/mcp-tools.ts +245 -24
  50. package/src/mcp/parse-idempotency-key.ts +29 -0
  51. package/src/mcp/reconnecting-caller.ts +77 -15
  52. package/src/mcp/require-daemon-features.ts +40 -0
  53. package/src/mcp/run-tool.ts +139 -35
  54. package/src/mcp/start-mcp-http-server.ts +130 -24
  55. package/src/mcp/types.ts +9 -1
  56. package/src/mcp-http-server.ts +6 -1
  57. package/src/mcp-server.ts +13 -1
  58. package/src/protocol/daemon-error.ts +5 -1
  59. package/src/protocol/daemon-features.ts +47 -0
  60. package/src/protocol/parse-daemon-features.ts +19 -0
  61. package/src/protocol/protocol.ts +44 -11
  62. package/src/protocol/request-param-schemas.ts +78 -12
  63. package/src/report.ts +20 -3
  64. package/src/shared/agent-session-id.ts +1 -1
  65. package/src/shared/collect-principals.ts +51 -0
  66. package/src/shared/collect-targets.ts +144 -0
  67. package/src/shared/config.ts +139 -14
  68. package/src/shared/daemon-id.ts +8 -0
  69. package/src/shared/format-json-kind.ts +21 -0
  70. package/src/shared/sort-json-keys.ts +19 -0
  71. package/src/shared/to-daemon-id.ts +11 -0
  72. package/src/store/fleet-entry.ts +42 -9
  73. package/src/store/idempotency-record.ts +48 -0
  74. package/src/store/message-owner.ts +1 -1
  75. package/src/store/message-record.ts +3 -0
  76. package/src/store/run-migrations.ts +272 -7
  77. package/src/store/state-store.ts +503 -34
  78. package/src/workspace/check-workspace-completeness.ts +90 -0
  79. package/src/workspace/create-workspace-clone.ts +239 -0
  80. package/src/workspace/normalize-git-url.ts +59 -0
  81. package/src/workspace/read-workspace-tar.ts +38 -0
  82. package/src/workspace/resolve-path-source.ts +170 -0
  83. package/src/workspace/run-git.ts +103 -0
  84. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  85. package/src/workspace/workspace-provenance.ts +11 -0
  86. package/src/workspace/workspace-source.ts +8 -0
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.4",
3
+ "version": "2.12.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",
@@ -25,6 +25,20 @@ export interface SpawnOptions {
25
25
  // true opens the agent's own session picker; an agent session id resumes
26
26
  // that specific session.
27
27
  readonly resume: boolean | AgentSessionID;
28
+
29
+ // The model and effort the session runs with; an absent one leaves the
30
+ // agent's configured default in place.
31
+ readonly model?: string;
32
+ readonly effort?: string;
33
+ }
34
+
35
+ /**
36
+ * The per-session model and effort a spawn asks for, already checked
37
+ * against what the agent advertises.
38
+ */
39
+ export interface SpawnOverrides {
40
+ readonly model?: string;
41
+ readonly effort?: string;
28
42
  }
29
43
 
30
44
  export interface SpawnPlan {
@@ -102,6 +116,10 @@ interface HeadlessRunRequest {
102
116
 
103
117
  // Folder of the atc-bridge mod the run's CLI loads, supplied by the adapter.
104
118
  readonly pluginDir?: string;
119
+
120
+ // The session's own model and effort, so a headless turn keeps them.
121
+ readonly model?: string;
122
+ readonly effort?: string;
105
123
  }
106
124
 
107
125
  interface HeadlessRunEvents {
@@ -117,6 +135,57 @@ export type HeadlessRunner = (
117
135
  hooks: HeadlessRunEvents,
118
136
  ) => { readonly stop: () => void };
119
137
 
138
+ /**
139
+ * How `agents.list` describes an agent. It holds no secret: never an
140
+ * environment value, a credential, a helper command, or a base URL.
141
+ */
142
+ export interface AgentProfile {
143
+ readonly label: string;
144
+
145
+ // The family the agent belongs to, chosen by its adapter; agents.list
146
+ // reports it as given, and any string is a valid kind.
147
+ readonly kind: string;
148
+
149
+ // The binary a spawn runs: a name looked up on PATH, or a path.
150
+ readonly bin: string;
151
+
152
+ // Model names the config sets explicitly, keyed by role; null when it sets none.
153
+ readonly models: Readonly<Record<string, string>> | null;
154
+
155
+ // The per-session overrides a spawn can pass this agent's CLI.
156
+ readonly spawnOptions: SpawnOptionSpecs;
157
+ }
158
+
159
+ /**
160
+ * A value worth offering for a spawn option, with the provider model it
161
+ * maps to when the config holds that mapping.
162
+ */
163
+ interface SpawnOptionExample {
164
+ readonly value: string;
165
+ readonly resolvesTo: string | null;
166
+ }
167
+
168
+ /**
169
+ * Whether the agent's CLI takes one spawn option, and which values it does.
170
+ * `values` holds the closed set a value must come from; null takes any
171
+ * well-formed value. `backendEffect` is `applied` when the CLI applies the
172
+ * value itself and `unverified` when the backend behind the CLI may ignore
173
+ * it; null for an option the agent does not take.
174
+ */
175
+ export interface SpawnOptionSpec {
176
+ readonly supported: boolean;
177
+ readonly values: readonly string[] | null;
178
+ readonly examples: readonly SpawnOptionExample[];
179
+ readonly default: string | null;
180
+ readonly backendEffect: 'applied' | 'unverified' | null;
181
+ readonly note: string | null;
182
+ }
183
+
184
+ export interface SpawnOptionSpecs {
185
+ readonly model: SpawnOptionSpec;
186
+ readonly effort: SpawnOptionSpec;
187
+ }
188
+
120
189
  /**
121
190
  * Everything specific to one agent CLI: how to spawn it, how to read its
122
191
  * hook payloads, where its session names come from, and how to resume a
@@ -137,6 +206,9 @@ export interface AgentAdapter {
137
206
  // Whether a session under this agent can take inbox messages through a tap;
138
207
  // false refuses every message as unsupported.
139
208
  readonly takesMessages: boolean;
209
+
210
+ // Absent on a stand-in adapter, which `agents.list` reports as not installed.
211
+ readonly profile?: AgentProfile;
140
212
  readonly planSpawn: (opts: SpawnOptions) => SpawnPlan;
141
213
  readonly normalizeHook: (e: HookEvent) => AdapterEvent;
142
214
  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
  };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * An argument list with every occurrence of the named flags removed, each
3
+ * with its value: the flag followed by its value, or `--flag=value`. Every
4
+ * other argument keeps its place.
5
+ */
6
+ export function buildArgsWithoutFlags(args: readonly string[], names: readonly string[]): string[] {
7
+ const kept: string[] = [];
8
+
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const arg = args[index] ?? '';
11
+
12
+ if (names.includes(arg)) {
13
+ index += 1;
14
+ continue;
15
+ }
16
+
17
+ if (names.some((name) => name.startsWith('--') && arg.startsWith(`${name}=`))) {
18
+ continue;
19
+ }
20
+
21
+ kept.push(arg);
22
+ }
23
+
24
+ return kept;
25
+ }
@@ -0,0 +1,26 @@
1
+ import type { SpawnOverrides } from './agent-adapter';
2
+ import { buildArgsWithoutFlags } from './build-args-without-flags';
3
+
4
+ /**
5
+ * The configured leading arguments for a Claude CLI spawn with the session's
6
+ * model and effort applied. An override replaces any `--model` or `--effort`
7
+ * the configured arguments carry, value included, so the session never
8
+ * depends on which occurrence the CLI honours. Without overrides the
9
+ * configured arguments pass through unchanged. Each value travels as its own
10
+ * argument, never through a shell.
11
+ */
12
+ export function buildClaudeOverrideArgs(
13
+ configured: readonly string[],
14
+ overrides: SpawnOverrides,
15
+ ): string[] {
16
+ const replaced = [
17
+ ...(overrides.model === undefined ? [] : ['--model']),
18
+ ...(overrides.effort === undefined ? [] : ['--effort']),
19
+ ];
20
+
21
+ return [
22
+ ...buildArgsWithoutFlags(configured, replaced),
23
+ ...(overrides.model === undefined ? [] : ['--model', overrides.model]),
24
+ ...(overrides.effort === undefined ? [] : ['--effort', overrides.effort]),
25
+ ];
26
+ }
@@ -10,12 +10,17 @@ 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,
17
+ SpawnOptionSpecs,
16
18
  SpawnOptions,
17
19
  SpawnPlan,
18
20
  } from './agent-adapter';
21
+ import { buildClaudeOverrideArgs } from './build-claude-override-args';
22
+ import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
23
+ import { findFlagValue } from './find-flag-value';
19
24
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
20
25
  import { truncateDetail } from './truncate-detail';
21
26
  import { writeATCBridge } from './write-atc-bridge';
@@ -48,6 +53,8 @@ export class ClaudeAdapter implements AgentAdapter {
48
53
 
49
54
  readonly parseTranscriptLine = parseClaudeTranscriptLine;
50
55
 
56
+ readonly profile: AgentProfile;
57
+
51
58
  readonly takesMessages = true;
52
59
 
53
60
  private readonly config: Config;
@@ -64,6 +71,14 @@ export class ClaudeAdapter implements AgentAdapter {
64
71
  this.bridgeTarget = bridgeTarget;
65
72
  this.config = config;
66
73
 
74
+ this.profile = {
75
+ label: 'Claude',
76
+ kind: 'claude',
77
+ bin: config.claudeBin,
78
+ models: null,
79
+ spawnOptions: buildClaudeSpawnOptions(config.claudeArgs),
80
+ };
81
+
67
82
  this.headlessRunner =
68
83
  headlessRunner === null
69
84
  ? null
@@ -76,7 +91,7 @@ export class ClaudeAdapter implements AgentAdapter {
76
91
  return {
77
92
  bin: this.config.claudeBin,
78
93
  args: [
79
- ...this.config.claudeArgs,
94
+ ...buildClaudeOverrideArgs(this.config.claudeArgs, opts),
80
95
  '--settings',
81
96
  this.settingsFile,
82
97
  '--plugin-dir',
@@ -226,3 +241,39 @@ export class ClaudeAdapter implements AgentAdapter {
226
241
  return `cd ${toShellArg(cwd)} && ${resume}`;
227
242
  }
228
243
  }
244
+
245
+ // The aliases Claude Code documents for `--model`, each resolving to a model
246
+ // the account picks. A full model name is accepted as well.
247
+ const CLAUDE_MODEL_ALIASES = [
248
+ 'best',
249
+ 'fable',
250
+ 'opus',
251
+ 'sonnet',
252
+ 'haiku',
253
+ 'opus[1m]',
254
+ 'sonnet[1m]',
255
+ 'opusplan',
256
+ ];
257
+
258
+ // What a Claude spawn can override. Each default is the value the configured
259
+ // arguments pass, or null when the CLI picks its own.
260
+ function buildClaudeSpawnOptions(claudeArgs: readonly string[]): SpawnOptionSpecs {
261
+ return {
262
+ model: {
263
+ supported: true,
264
+ values: null,
265
+ examples: CLAUDE_MODEL_ALIASES.map((value) => ({ value, resolvesTo: null })),
266
+ default: findFlagValue(claudeArgs, ['--model']),
267
+ backendEffect: 'applied',
268
+ note: 'An alias or a full model name, passed as --model.',
269
+ },
270
+ effort: {
271
+ supported: true,
272
+ values: CLAUDE_EFFORT_LEVELS,
273
+ examples: [],
274
+ default: findFlagValue(claudeArgs, ['--effort']),
275
+ backendEffect: 'applied',
276
+ note: 'Passed as --effort. Which levels a session honours depends on its model.',
277
+ },
278
+ };
279
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The effort levels Claude Code's `--effort` flag accepts. Which of them a
3
+ * session honours depends on its model.
4
+ */
5
+ export const CLAUDE_EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max'];
@@ -11,11 +11,15 @@ 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,
17
+ SpawnOptionSpecs,
16
18
  SpawnOptions,
17
19
  SpawnPlan,
18
20
  } from './agent-adapter';
21
+ import { buildArgsWithoutFlags } from './build-args-without-flags';
22
+ import { findFlagValue } from './find-flag-value';
19
23
  import { resolveAgentHome } from './resolve-agent-home';
20
24
  import { truncateDetail } from './truncate-detail';
21
25
 
@@ -32,6 +36,9 @@ const CODEX_HOOK_PAYLOAD_SCHEMA = z.object({
32
36
 
33
37
  type CodexHookPayload = z.infer<typeof CODEX_HOOK_PAYLOAD_SCHEMA>;
34
38
 
39
+ // The spellings of the Codex CLI's model flag.
40
+ const CODEX_MODEL_FLAGS = ['-m', '--model'];
41
+
35
42
  /**
36
43
  * The Codex CLI adapter: spawn arguments, hook payload mapping, resume
37
44
  * semantics, and session_index.jsonl name-pulling. Hooks are a user-installed
@@ -47,19 +54,33 @@ export class CodexAdapter implements AgentAdapter {
47
54
  // Codex's hooks are authoritative; no screen heuristics needed.
48
55
  readonly screenDetector = null;
49
56
 
57
+ readonly profile: AgentProfile;
58
+
50
59
  readonly takesMessages = false;
51
60
 
52
61
  private readonly config: Config;
53
62
 
54
63
  constructor(config: Config) {
55
64
  this.config = config;
65
+
66
+ this.profile = {
67
+ label: 'Codex',
68
+ kind: 'codex',
69
+ bin: config.codexBin,
70
+ models: null,
71
+ spawnOptions: buildCodexSpawnOptions(config.codexArgs),
72
+ };
56
73
  }
57
74
 
58
75
  planSpawn(opts: SpawnOptions): SpawnPlan {
59
76
  return {
60
77
  bin: this.config.codexBin,
61
78
  args: [
62
- ...this.config.codexArgs,
79
+ // A model override replaces any model flag the configured arguments
80
+ // carry, and travels as its own argument.
81
+ ...(opts.model === undefined
82
+ ? this.config.codexArgs
83
+ : [...buildArgsWithoutFlags(this.config.codexArgs, CODEX_MODEL_FLAGS), '-m', opts.model]),
63
84
 
64
85
  // Bare resume opens Codex's own session picker; an id resumes that
65
86
  // session directly. Both accept a prompt afterwards.
@@ -189,3 +210,27 @@ export class CodexAdapter implements AgentAdapter {
189
210
  return `cd ${toShellArg(cwd)} && ${resume}`;
190
211
  }
191
212
  }
213
+
214
+ // What a Codex spawn can override. Codex documents its reasoning effort as
215
+ // whatever the selected model advertises, with no closed list of levels, so
216
+ // atc takes no effort for it.
217
+ function buildCodexSpawnOptions(codexArgs: readonly string[]): SpawnOptionSpecs {
218
+ return {
219
+ model: {
220
+ supported: true,
221
+ values: null,
222
+ examples: [],
223
+ default: findFlagValue(codexArgs, CODEX_MODEL_FLAGS),
224
+ backendEffect: 'applied',
225
+ note: 'A model name, passed as -m.',
226
+ },
227
+ effort: {
228
+ supported: false,
229
+ values: null,
230
+ examples: [],
231
+ default: null,
232
+ backendEffect: null,
233
+ note: 'Codex documents its reasoning effort levels as depending on the model, with no closed list, so atc does not pass one.',
234
+ },
235
+ };
236
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The value a CLI flag takes in an argument list, written either as the flag
3
+ * followed by its value or as `--flag=value`. The last occurrence wins, as it
4
+ * does for the CLIs atc runs; null when the list never sets the flag.
5
+ */
6
+ export function findFlagValue(args: readonly string[], names: readonly string[]): string | null {
7
+ let value: string | null = null;
8
+
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const arg = args[index] ?? '';
11
+ const next = args[index + 1];
12
+
13
+ if (names.includes(arg) && next !== undefined) {
14
+ value = next;
15
+ index += 1;
16
+ continue;
17
+ }
18
+
19
+ const inline = names.find((name) => name.startsWith('--') && arg.startsWith(`${name}=`));
20
+
21
+ if (inline !== undefined) {
22
+ value = arg.slice(inline.length + 1);
23
+ }
24
+ }
25
+
26
+ return value;
27
+ }
@@ -7,13 +7,18 @@ import type {
7
7
  AdapterEvent,
8
8
  AgentAdapter,
9
9
  AgentID,
10
+ AgentProfile,
10
11
  HeadlessRunner,
11
12
  NameUpdate,
12
13
  ResumeCheck,
14
+ SpawnOptionSpecs,
13
15
  SpawnOptions,
14
16
  SpawnPlan,
15
17
  } from './agent-adapter';
18
+ import { buildClaudeOverrideArgs } from './build-claude-override-args';
16
19
  import { ClaudeAdapter } from './claude-adapter';
20
+ import { CLAUDE_EFFORT_LEVELS } from './claude-effort-levels';
21
+ import { findFlagValue } from './find-flag-value';
17
22
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
18
23
  import { writeATCBridge } from './write-atc-bridge';
19
24
  import { writeHookSettings } from './write-hook-settings';
@@ -40,6 +45,8 @@ export class GatewayAdapter implements AgentAdapter {
40
45
 
41
46
  readonly takesMessages = true;
42
47
 
48
+ readonly profile: AgentProfile;
49
+
43
50
  private readonly gateway: GatewayConfig;
44
51
 
45
52
  private readonly claude: ClaudeAdapter;
@@ -62,6 +69,16 @@ export class GatewayAdapter implements AgentAdapter {
62
69
  this.gateway = gateway;
63
70
  this.id = gateway.id;
64
71
 
72
+ const models = pickModels(gateway.env);
73
+
74
+ this.profile = {
75
+ label: gateway.label,
76
+ kind: 'gateway',
77
+ bin: gateway.bin,
78
+ models,
79
+ spawnOptions: buildGatewaySpawnOptions(gateway.args, models),
80
+ };
81
+
65
82
  this.claude = new ClaudeAdapter(config);
66
83
 
67
84
  this.headlessRunner =
@@ -78,7 +95,7 @@ export class GatewayAdapter implements AgentAdapter {
78
95
  return {
79
96
  bin: this.gateway.bin,
80
97
  args: [
81
- ...this.gateway.args,
98
+ ...buildClaudeOverrideArgs(this.gateway.args, opts),
82
99
  '--settings',
83
100
  this.writeSettings(),
84
101
  '--plugin-dir',
@@ -131,3 +148,60 @@ export class GatewayAdapter implements AgentAdapter {
131
148
  return this.bridgeDir;
132
149
  }
133
150
  }
151
+
152
+ // The model names a gateway's env sets explicitly, keyed by role:
153
+ // `ANTHROPIC_MODEL` is `default`, and `ANTHROPIC_DEFAULT_<TIER>_MODEL` is the
154
+ // tier in lower case. No other env value leaves the adapter.
155
+ const MODEL_KEY = /^ANTHROPIC_(?:DEFAULT_(?<tier>[A-Z0-9]+)_)?MODEL$/u;
156
+
157
+ function pickModels(
158
+ env: Readonly<Record<string, string>>,
159
+ ): Readonly<Record<string, string>> | null {
160
+ const models: Record<string, string> = {};
161
+
162
+ for (const [key, value] of Object.entries(env)) {
163
+ const matched = MODEL_KEY.exec(key);
164
+
165
+ if (matched !== null && value !== '') {
166
+ models[matched.groups?.['tier']?.toLowerCase() ?? 'default'] = value;
167
+ }
168
+ }
169
+
170
+ return Object.keys(models).length === 0 ? null : models;
171
+ }
172
+
173
+ // The Claude tiers a gateway's env can map to a provider model.
174
+ const GATEWAY_TIERS = ['opus', 'sonnet', 'haiku'];
175
+
176
+ // What a gateway spawn can override. The model examples are the tier aliases
177
+ // the gateway's env maps, each with the provider model it reaches. Effort
178
+ // goes to the CLI unchanged, and whether the provider acts on it is unknown.
179
+ function buildGatewaySpawnOptions(
180
+ args: readonly string[],
181
+ models: Readonly<Record<string, string>> | null,
182
+ ): SpawnOptionSpecs {
183
+ const examples = GATEWAY_TIERS.flatMap((tier) => {
184
+ const mapped = models?.[tier];
185
+
186
+ return mapped === undefined ? [] : [{ value: tier, resolvesTo: mapped }];
187
+ });
188
+
189
+ return {
190
+ model: {
191
+ supported: true,
192
+ values: null,
193
+ examples,
194
+ default: findFlagValue(args, ['--model']) ?? models?.['default'] ?? null,
195
+ backendEffect: 'applied',
196
+ note: "A tier alias the gateway's env maps, or a model name the provider accepts, passed as --model.",
197
+ },
198
+ effort: {
199
+ supported: true,
200
+ values: CLAUDE_EFFORT_LEVELS,
201
+ examples: [],
202
+ default: findFlagValue(args, ['--effort']),
203
+ backendEffect: 'unverified',
204
+ note: "Passed as --effort; the gateway's provider may ignore it.",
205
+ },
206
+ };
207
+ }
@@ -12,8 +12,10 @@ 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,
18
+ SpawnOptionSpec,
17
19
  SpawnOptions,
18
20
  SpawnPlan,
19
21
  } from './agent-adapter';
@@ -47,6 +49,16 @@ const GROK_HOOK_PAYLOAD_SCHEMA = z.object({
47
49
 
48
50
  type GrokHookPayload = z.infer<typeof GROK_HOOK_PAYLOAD_SCHEMA>;
49
51
 
52
+ // atc passes Grok neither a model nor an effort; the CLI's own config picks both.
53
+ const GROK_UNSUPPORTED_OPTION: SpawnOptionSpec = {
54
+ supported: false,
55
+ values: null,
56
+ examples: [],
57
+ default: null,
58
+ backendEffect: null,
59
+ note: 'atc does not pass this option to Grok.',
60
+ };
61
+
50
62
  /**
51
63
  * The Grok Build adapter: spawn arguments, hook payload mapping,
52
64
  * resume semantics, and summary.json name-pulling.
@@ -60,6 +72,8 @@ export class GrokAdapter implements AgentAdapter {
60
72
  // Grok's hooks are authoritative; no screen heuristics needed.
61
73
  readonly screenDetector = null;
62
74
 
75
+ readonly profile: AgentProfile;
76
+
63
77
  readonly takesMessages = false;
64
78
 
65
79
  private readonly config: Config;
@@ -68,6 +82,14 @@ export class GrokAdapter implements AgentAdapter {
68
82
 
69
83
  constructor(config: Config) {
70
84
  this.config = config;
85
+
86
+ this.profile = {
87
+ label: 'Grok',
88
+ kind: 'grok',
89
+ bin: config.grokBin,
90
+ models: null,
91
+ spawnOptions: { model: GROK_UNSUPPORTED_OPTION, effort: GROK_UNSUPPORTED_OPTION },
92
+ };
71
93
  }
72
94
 
73
95
  planSpawn(opts: SpawnOptions): SpawnPlan {
package/src/cli.ts CHANGED
@@ -160,12 +160,26 @@ const main = defineCommand({
160
160
  const codex = await import('./agents/codex-adapter');
161
161
  const gateway = await import('./agents/gateway-adapter');
162
162
  const headless = await import('./daemon/start-headless-run');
163
+ const targets = await import('./daemon/build-execution-targets');
163
164
 
164
165
  // Test harnesses shrink the outbound queue to force overflow
165
166
  // deterministically; unset means the production default.
166
167
  const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
167
168
  const cfg = config.loadConfig();
168
169
 
170
+ for (const error of cfg.targetErrors) {
171
+ const line =
172
+ error.scope === 'config'
173
+ ? `${error.path} cannot be used (${error.problem}: ${error.detail}); every spawn is refused, local ones included, until it is fixed`
174
+ : error.problem;
175
+
176
+ console.error(`atc daemon: config: ${line}`);
177
+ }
178
+
179
+ for (const problem of cfg.principalErrors) {
180
+ console.error(`atc daemon: config: ${problem}`);
181
+ }
182
+
169
183
  // Cap on how long a fleet restore waits for one revived session to
170
184
  // report it has booted before moving to the next. Tests pin it to
171
185
  // keep timing deterministic; unset means the production default.
@@ -206,6 +220,10 @@ const main = defineCommand({
206
220
  legacyFleetPath: config.legacyFleetFile,
207
221
  pidPath: config.daemonPidFile,
208
222
  hooks: cfg.hooks,
223
+ targets: targets.buildExecutionTargets(cfg.targets),
224
+ defaultTarget: cfg.defaultTarget,
225
+ targetErrors: cfg.targetErrors,
226
+ principals: cfg.principals,
209
227
  restoreBootTimeoutMs,
210
228
  ...(Number.isFinite(graceOverride) && graceOverride >= 0
211
229
  ? { tapGraceMs: graceOverride }
@@ -317,13 +335,17 @@ const main = defineCommand({
317
335
  kind: { type: 'positional', required: false, default: '' },
318
336
  message: { type: 'string', default: '' },
319
337
  label: { type: 'string', default: '' },
338
+ messages: { type: 'string', default: '' },
339
+ turn: { type: 'string', default: '' },
320
340
  },
321
341
  async run(ctx) {
322
342
  const reporter = await import('./report');
323
343
 
324
344
  await reporter.runReport(ctx.args.kind, {
325
345
  message: ctx.args.message,
346
+ messages: ctx.args.messages,
326
347
  label: ctx.args.label,
348
+ turn: ctx.args.turn,
327
349
  });
328
350
  },
329
351
  }),