@zgeoff/atc 2.7.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,6 +120,10 @@ installs in detail.
120
120
  - `atc mcp` exposes the fleet as MCP tools, so an agent can spawn, drive, and read other agents. A
121
121
  session spawned this way lists under the session that spawned it and is killed with it. Register
122
122
  it with `claude mcp add --scope user atc -- atc mcp`.
123
+ - A Claude session takes messages from other tools. `atc_session_message` queues one, and the
124
+ session reads it in a new turn, or inside the turn it is running. `atc_message_get` returns the
125
+ reply once that turn ends. The [protocol](./docs/architecture/protocol.md#messages) covers the
126
+ details.
123
127
  - `atc events` prints every fleet event as one NDJSON line. The same stream is on a unix socket, and
124
128
  `config.json` hooks run your own commands on events. The [events guide](./docs/guides/events.md)
125
129
  covers all three.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.7.0",
3
+ "version": "2.8.1",
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",
@@ -21,6 +21,7 @@
21
21
  "scripts": {
22
22
  "start": "bun src/cli.ts",
23
23
  "build:agents": "bash scripts/build-agents-md.sh agents/shared.md agents/project.md AGENTS.md",
24
+ "build:atc-bridge": "bun scripts/build-atc-bridge.ts && oxfmt src/agents/atc-bridge-files.ts && format-codemod --quiet src/agents/atc-bridge-files.ts",
24
25
  "build:binaries": "bash scripts/build-binaries.sh",
25
26
  "audit": "bun audit",
26
27
  "deadcode": "knip",
@@ -28,6 +29,7 @@
28
29
  "lint:fix": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error --fix",
29
30
  "test": "bun test",
30
31
  "test:binary": "bun test --timeout 20000 test/daemon-e2e.test.ts",
32
+ "test:atc-bridge": "bash scripts/test-atc-bridge.sh",
31
33
  "typecheck": "tsc -p tsconfig.json --noEmit",
32
34
  "format": "oxfmt . && format-codemod --quiet .",
33
35
  "format:check": "oxfmt --check . && format-codemod --check --quiet .",
@@ -1,5 +1,6 @@
1
1
  import type { HookEvent } from '../daemon/hooks';
2
2
  import type { AgentSessionID } from '../shared/agent-session-id';
3
+ import type { SessionID } from '../shared/session-id';
3
4
 
4
5
  /**
5
6
  * Which agent a session runs under: the key the adapter registry is looked
@@ -95,6 +96,12 @@ interface HeadlessRunRequest {
95
96
  // Settings file the run's CLI is started with, so a headless turn reaches
96
97
  // the same backend the session's terminal did.
97
98
  readonly settings?: string;
99
+
100
+ // The atc session the run belongs to; the mod reads it to tap the inbox.
101
+ readonly sessionID?: SessionID;
102
+
103
+ // Folder of the atc-bridge mod the run's CLI loads, supplied by the adapter.
104
+ readonly pluginDir?: string;
98
105
  }
99
106
 
100
107
  interface HeadlessRunEvents {
@@ -0,0 +1,11 @@
1
+ // Generated by `bun run build:atc-bridge` from mods/atc-bridge; edit the mod, then rerun it.
2
+ // oxlint-disable no-template-curly-in-string -- the strings are the mod source, template literals included
3
+ export const ATC_BRIDGE_FILES: Readonly<
4
+ Record<'.claude-plugin/plugin.json' | 'hooks/hooks.json' | 'hooks/register.ts', string>
5
+ > = {
6
+ '.claude-plugin/plugin.json':
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
+ 'hooks/hooks.json': '{ "modules": ["./register.ts"] }\n',
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",
11
+ };
@@ -0,0 +1,13 @@
1
+ import { join } from 'node:path';
2
+ import { isCompiledBinary } from '../shared/is-compiled-binary';
3
+
4
+ /**
5
+ * The argv that runs the atc CLI from inside a wrangled session: under bun
6
+ * the CLI entry path follows the runtime; a compiled binary is itself the
7
+ * entry.
8
+ */
9
+ export function buildCLIArgv(): string[] {
10
+ return isCompiledBinary()
11
+ ? [process.execPath]
12
+ : [process.execPath, join(import.meta.dir, '..', 'cli.ts')];
13
+ }
@@ -1,5 +1,4 @@
1
- import { join } from 'node:path';
2
- import { isCompiledBinary } from '../shared/is-compiled-binary';
1
+ import { buildCLIArgv } from './build-cli-argv';
3
2
 
4
3
  /**
5
4
  * Command line that wrangled sessions invoke for atc subcommands: under bun
@@ -7,11 +6,7 @@ import { isCompiledBinary } from '../shared/is-compiled-binary';
7
6
  * the entry.
8
7
  */
9
8
  export function buildCLICommand(subcommand: string): string {
10
- const exec = process.execPath;
11
-
12
- if (isCompiledBinary()) {
13
- return `"${exec}" ${subcommand}`;
14
- }
15
-
16
- return `"${exec}" "${join(import.meta.dir, '..', 'cli.ts')}" ${subcommand}`;
9
+ return `${buildCLIArgv()
10
+ .map((part) => `"${part}"`)
11
+ .join(' ')} ${subcommand}`;
17
12
  }
@@ -18,6 +18,7 @@ import type {
18
18
  } from './agent-adapter';
19
19
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
20
20
  import { truncateDetail } from './truncate-detail';
21
+ import { writeATCBridge } from './write-atc-bridge';
21
22
  import { writeHookSettings } from './write-hook-settings';
22
23
 
23
24
  // Claude's hook payload keys, snake_case. An absent or wrong-typed field
@@ -54,9 +55,19 @@ export class ClaudeAdapter implements AgentAdapter {
54
55
  // Written on first spawn so constructing the adapter touches no state.
55
56
  private settingsFile: string | undefined;
56
57
 
57
- constructor(config: Config, headlessRunner: HeadlessRunner | null = null) {
58
+ // Written on first spawn so constructing the adapter touches no state.
59
+ private bridgeDir: string | undefined;
60
+
61
+ private readonly bridgeTarget: string | undefined;
62
+
63
+ constructor(config: Config, headlessRunner: HeadlessRunner | null = null, bridgeTarget?: string) {
64
+ this.bridgeTarget = bridgeTarget;
58
65
  this.config = config;
59
- this.headlessRunner = headlessRunner;
66
+
67
+ this.headlessRunner =
68
+ headlessRunner === null
69
+ ? null
70
+ : (opts, hooks) => headlessRunner({ ...opts, pluginDir: this.writeBridge() }, hooks);
60
71
  }
61
72
 
62
73
  planSpawn(opts: SpawnOptions): SpawnPlan {
@@ -68,6 +79,8 @@ export class ClaudeAdapter implements AgentAdapter {
68
79
  ...this.config.claudeArgs,
69
80
  '--settings',
70
81
  this.settingsFile,
82
+ '--plugin-dir',
83
+ this.writeBridge(),
71
84
  ...(opts.resume === true ? ['--resume'] : []),
72
85
  ...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
73
86
  ...(opts.prompt === '' ? [] : [opts.prompt]),
@@ -75,6 +88,12 @@ export class ClaudeAdapter implements AgentAdapter {
75
88
  };
76
89
  }
77
90
 
91
+ private writeBridge(): string {
92
+ this.bridgeDir ??= writeATCBridge(this.bridgeTarget);
93
+
94
+ return this.bridgeDir;
95
+ }
96
+
78
97
  normalizeHook(e: HookEvent): AdapterEvent {
79
98
  const parsed = CLAUDE_HOOK_PAYLOAD_SCHEMA.safeParse(e.payload);
80
99
  const payload: ClaudeHookPayload = parsed.success ? parsed.data : {};
@@ -15,6 +15,7 @@ import type {
15
15
  } from './agent-adapter';
16
16
  import { ClaudeAdapter } from './claude-adapter';
17
17
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
18
+ import { writeATCBridge } from './write-atc-bridge';
18
19
  import { writeHookSettings } from './write-hook-settings';
19
20
 
20
21
  /**
@@ -46,11 +47,18 @@ export class GatewayAdapter implements AgentAdapter {
46
47
  // Written on first spawn so constructing the adapter touches no state.
47
48
  private settingsFile: string | undefined;
48
49
 
50
+ // Written on first spawn so constructing the adapter touches no state.
51
+ private bridgeDir: string | undefined;
52
+
53
+ private readonly bridgeTarget: string | undefined;
54
+
49
55
  constructor(
50
56
  gateway: GatewayConfig,
51
57
  config: Config,
52
58
  headlessRunner: HeadlessRunner | null = null,
59
+ bridgeTarget?: string,
53
60
  ) {
61
+ this.bridgeTarget = bridgeTarget;
54
62
  this.gateway = gateway;
55
63
  this.id = gateway.id;
56
64
 
@@ -59,7 +67,11 @@ export class GatewayAdapter implements AgentAdapter {
59
67
  this.headlessRunner =
60
68
  headlessRunner === null
61
69
  ? null
62
- : (opts, hooks) => headlessRunner({ ...opts, settings: this.writeSettings() }, hooks);
70
+ : (opts, hooks) =>
71
+ headlessRunner(
72
+ { ...opts, settings: this.writeSettings(), pluginDir: this.writeBridge() },
73
+ hooks,
74
+ );
63
75
  }
64
76
 
65
77
  planSpawn(opts: SpawnOptions): SpawnPlan {
@@ -69,6 +81,8 @@ export class GatewayAdapter implements AgentAdapter {
69
81
  ...this.gateway.args,
70
82
  '--settings',
71
83
  this.writeSettings(),
84
+ '--plugin-dir',
85
+ this.writeBridge(),
72
86
  ...(opts.resume === true ? ['--resume'] : []),
73
87
  ...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
74
88
  ...(opts.prompt === '' ? [] : [opts.prompt]),
@@ -110,4 +124,10 @@ export class GatewayAdapter implements AgentAdapter {
110
124
 
111
125
  return this.settingsFile;
112
126
  }
127
+
128
+ private writeBridge(): string {
129
+ this.bridgeDir ??= writeATCBridge(this.bridgeTarget);
130
+
131
+ return this.bridgeDir;
132
+ }
113
133
  }
@@ -0,0 +1,42 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { stateDir } from '../shared/config';
4
+ import { ATC_BRIDGE_FILES } from './atc-bridge-files';
5
+ import { buildCLIArgv } from './build-cli-argv';
6
+
7
+ /**
8
+ * Writes the atc-bridge mod that every Claude session loads with
9
+ * `--plugin-dir`, with the atc command it runs rendered for this install,
10
+ * and returns its folder. A file whose content already matches is left
11
+ * untouched, since any write reloads the mod in every running session, and
12
+ * the folder is never cleared, since Claude Code keeps its own type files
13
+ * there.
14
+ */
15
+ export function writeATCBridge(dir: string = join(stateDir, 'atc-bridge')): string {
16
+ const files = { ...ATC_BRIDGE_FILES, 'hooks/atc-cli.ts': renderATCCLIModule(buildCLIArgv()) };
17
+
18
+ for (const [path, content] of Object.entries(files)) {
19
+ const file = join(dir, path);
20
+
21
+ if (tryReadFileText(file) === content) {
22
+ continue;
23
+ }
24
+
25
+ mkdirSync(dirname(file), { recursive: true });
26
+ writeFileSync(file, content);
27
+ }
28
+
29
+ return dir;
30
+ }
31
+
32
+ function renderATCCLIModule(argv: readonly string[]): string {
33
+ return `export const ATC_CLI: readonly string[] = ${JSON.stringify(argv)};\n`;
34
+ }
35
+
36
+ function tryReadFileText(file: string): string | null {
37
+ try {
38
+ return readFileSync(file, 'utf8');
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { stateDir } from '../shared/config';
@@ -14,6 +14,7 @@ export function writeHookSettings(profile: HookSettingsProfile): string {
14
14
  const file = join(stateDir, `hook-settings-${profile.id}.json`);
15
15
  const settings = buildHookSettings(profile, readStatuslinePadding());
16
16
 
17
+ mkdirSync(stateDir, { recursive: true });
17
18
  writeFileSync(file, JSON.stringify(settings, null, 2));
18
19
 
19
20
  return file;
package/src/cli.ts CHANGED
@@ -182,20 +182,25 @@ const main = defineCommand({
182
182
  defineCommand({
183
183
  meta: {
184
184
  name: 'report',
185
- description: 'Report a message event from a wrangled session to the atc socket',
185
+ description:
186
+ 'Report a message answer or a note from a wrangled session to the atc socket',
186
187
  hidden: true,
187
188
  },
188
189
 
189
- // Neither arg is required: a citty usage error exits nonzero, and
190
+ // No arg is required: a citty usage error exits nonzero, and
190
191
  // reporters must always exit 0.
191
192
  args: {
192
193
  kind: { type: 'positional', required: false, default: '' },
193
194
  message: { type: 'string', default: '' },
195
+ label: { type: 'string', default: '' },
194
196
  },
195
197
  async run(ctx) {
196
198
  const reporter = await import('./report');
197
199
 
198
- await reporter.runReport(ctx.args.kind, ctx.args.message);
200
+ await reporter.runReport(ctx.args.kind, {
201
+ message: ctx.args.message,
202
+ label: ctx.args.label,
203
+ });
199
204
  },
200
205
  }),
201
206
  statusline: () =>
@@ -0,0 +1,23 @@
1
+ import { collectCleanEnv } from '../shared/collect-clean-env';
2
+ import type { SessionID } from '../shared/session-id';
3
+
4
+ interface HeadlessEnvParams {
5
+ readonly pluginDir?: string;
6
+ readonly sessionID?: SessionID;
7
+ readonly socketPath: string;
8
+ }
9
+
10
+ /**
11
+ * The clean process environment for a headless run, plus what the atc-bridge
12
+ * mod needs to find its session: the mod folder to load and the session and
13
+ * reporter socket it taps. A run without a session id carries none of them,
14
+ * since the mod stays off outside atc.
15
+ */
16
+ export function buildHeadlessEnv(params: HeadlessEnvParams): Record<string, string> {
17
+ return collectCleanEnv({
18
+ ...(params.pluginDir === undefined ? {} : { CLAUDE_CODE_PLUGIN_DIRS: params.pluginDir }),
19
+ ...(params.sessionID === undefined
20
+ ? {}
21
+ : { ATC_SESSION_ID: params.sessionID, ATC_SOCKET: params.socketPath }),
22
+ });
23
+ }
@@ -0,0 +1,24 @@
1
+ import { PROTOCOL_V } from '../protocol/protocol';
2
+ import type { EventMsg } from '../protocol/protocol';
3
+ import type { SessionID } from '../shared/session-id';
4
+ import type { NoteReport } from './parse-report';
5
+
6
+ /**
7
+ * The `SessionReport` event for a note an agent sent mid-turn, addressed by
8
+ * the atc session that sent it. The note's label travels as the event's
9
+ * `kind`.
10
+ */
11
+ export function buildSessionReportEvent(
12
+ sessionID: SessionID,
13
+ report: NoteReport,
14
+ reportedAt: number,
15
+ ): EventMsg {
16
+ return {
17
+ v: PROTOCOL_V,
18
+ ev: 'SessionReport',
19
+ s: sessionID,
20
+ kind: report.label,
21
+ text: report.text,
22
+ reportedAt,
23
+ };
24
+ }
@@ -165,6 +165,13 @@ export class DaemonConnection {
165
165
  this.queue = new OutboundQueue(peer, ctx.queueBytes);
166
166
  }
167
167
 
168
+ /**
169
+ * Ends the connection. Safe to call more than once.
170
+ */
171
+ dispose(): void {
172
+ this.peer.end();
173
+ }
174
+
168
175
  sendEvent(event: EventMsg): void {
169
176
  if (this.helloed && !this.queue.send(encodeMessage(event))) {
170
177
  this.peer.end();
@@ -13,6 +13,7 @@ import { AttachRegistry } from './attach-registry';
13
13
  import { buildFleetEvents } from './build-fleet-events';
14
14
  import { buildSessionEvent } from './build-session-event';
15
15
  import { buildSessionMessageEvent } from './build-session-message-event';
16
+ import { buildSessionReportEvent } from './build-session-report-event';
16
17
  import { DaemonConnection } from './daemon-connection';
17
18
  import type { DaemonContext, OutputClient, TapClient } from './daemon-connection';
18
19
  import { EventSignal } from './event-signal';
@@ -236,6 +237,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
236
237
  return;
237
238
  }
238
239
 
240
+ if (report.kind === 'note') {
241
+ const sender = mgr.sessions.find((x) => x.id === e.atcId);
242
+
243
+ if (sender !== undefined) {
244
+ const capped = { ...report, text: truncateToBytes(report.text, ANSWER_BYTE_CAP) };
245
+
246
+ emitEvent(buildSessionReportEvent(sender.id, capped, Date.now()), findHookScope(sender.id));
247
+ }
248
+
249
+ return;
250
+ }
251
+
239
252
  const s = mgr.sessions.find((x) => x.id === e.atcId);
240
253
  const owner = s === undefined ? { atcID: e.atcId } : buildMessageOwner(s);
241
254
 
@@ -974,6 +987,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
974
987
  });
975
988
 
976
989
  stopDaemon = async () => {
990
+ // Ends each client itself so every peer sees the close: a stopped
991
+ // listener does not reliably end the connections it already accepted.
992
+ for (const client of clients) {
993
+ client.dispose();
994
+ }
995
+
977
996
  server.stop(true);
978
997
  eventsServer?.stop();
979
998
  reporter.stop(true);
@@ -1,20 +1,25 @@
1
1
  import { z } from 'zod';
2
2
  import type { MessageID } from '../shared/message-id';
3
- import { REPORT_KINDS } from '../shared/report-kinds';
4
- import type { ReportKind } from '../shared/report-kinds';
5
3
  import { toMessageID } from '../shared/to-message-id';
6
4
 
7
- export interface Report {
8
- readonly kind: ReportKind;
5
+ interface AnsweredReport {
6
+ readonly kind: 'answered';
9
7
  readonly message: MessageID;
10
8
  readonly answer: string;
11
9
  }
12
10
 
13
- const REPORT_SCHEMA = z.object({
14
- kind: z.enum(REPORT_KINDS),
15
- message: z.string().min(1),
16
- answer: z.string(),
17
- });
11
+ export interface NoteReport {
12
+ readonly kind: 'note';
13
+ readonly label: string;
14
+ readonly text: string;
15
+ }
16
+
17
+ export type Report = AnsweredReport | NoteReport;
18
+
19
+ const REPORT_SCHEMA = z.discriminatedUnion('kind', [
20
+ z.object({ kind: z.literal('answered'), message: z.string().min(1), answer: z.string() }),
21
+ z.object({ kind: z.literal('note'), label: z.string().min(1).max(64), text: z.string().min(1) }),
22
+ ]);
18
23
 
19
24
  /**
20
25
  * Parses a Report envelope's payload into the report its kind discriminates,
@@ -27,8 +32,12 @@ export function parseReport(payload: Readonly<Record<string, unknown>>): Report
27
32
  return null;
28
33
  }
29
34
 
35
+ if (parsed.data.kind === 'note') {
36
+ return { kind: 'note', label: parsed.data.label, text: parsed.data.text };
37
+ }
38
+
30
39
  return {
31
- kind: parsed.data.kind,
40
+ kind: 'answered',
32
41
  message: toMessageID(parsed.data.message),
33
42
  answer: parsed.data.answer,
34
43
  };
@@ -1,8 +1,10 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
2
  import type { AgentSessionID } from '../shared/agent-session-id';
3
- import { collectCleanEnv } from '../shared/collect-clean-env';
3
+ import { socketPath } from '../shared/config';
4
4
  import { isCompiledBinary } from '../shared/is-compiled-binary';
5
5
  import { isRecord } from '../shared/report';
6
+ import type { SessionID } from '../shared/session-id';
7
+ import { buildHeadlessEnv } from './build-headless-env';
6
8
  import { resolveHeadlessExecutable } from './resolve-headless-executable';
7
9
 
8
10
  const PERMISSION_MODES = [
@@ -21,6 +23,8 @@ interface HeadlessRunOptions {
21
23
  readonly resume?: AgentSessionID;
22
24
  readonly permissionMode?: string;
23
25
  readonly settings?: string;
26
+ readonly sessionID?: SessionID;
27
+ readonly pluginDir?: string;
24
28
  }
25
29
 
26
30
  interface HeadlessRunHooks {
@@ -54,7 +58,11 @@ export function startHeadlessRun(
54
58
  prompt: opts.prompt,
55
59
  options: {
56
60
  cwd: opts.cwd,
57
- env: collectCleanEnv(),
61
+ env: buildHeadlessEnv({
62
+ socketPath,
63
+ ...(opts.pluginDir === undefined ? {} : { pluginDir: opts.pluginDir }),
64
+ ...(opts.sessionID === undefined ? {} : { sessionID: opts.sessionID }),
65
+ }),
58
66
  abortController: controller,
59
67
  stderr: (data: string) => {
60
68
  stderrTail = `${stderrTail}${data}`.slice(-2000);
@@ -31,6 +31,7 @@ export function startHeadlessTurn(
31
31
  prompt,
32
32
  ...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
33
33
  permissionMode: 'auto',
34
+ sessionID,
34
35
  },
35
36
  {
36
37
  onOutput: (text) => {
package/src/mcp-server.ts CHANGED
@@ -190,7 +190,7 @@ const TOOLS: readonly MCPTool[] = [
190
190
  {
191
191
  name: 'atc_session_message',
192
192
  description:
193
- "Send a session a message and get its id back; poll atc_message_get with the id for its status and answer. The message waits in the session inbox until the session takes it, and its status moves accepted, delivered, answered. A message is refused as unsupported when the session's agent has no message tap (Grok, Codex), or when a Claude session reported SessionStart more than 15 seconds ago and no tap has attached since. It is refused as session_dead when the session has no live process and as no_such_session for an unknown id. Otherwise it queues, including while a session restores or after its tap dropped. The message is never typed into the terminal.",
193
+ "Send a session a message and get its id back. Follow up by polling atc_message_get with the id until its status is answered, which returns the session's final reply; don't read the session's screen or transcript to check on it. The message waits in the session inbox until the session takes it, and its status moves accepted, delivered, answered. A message is refused as unsupported when the session's agent has no message tap (Grok, Codex), or when a Claude session reported SessionStart more than 15 seconds ago and no tap has attached since. It is refused as session_dead when the session has no live process and as no_such_session for an unknown id. Otherwise it queues, including while a session restores or after its tap dropped. The message is never typed into the terminal.",
194
194
  inputSchema: {
195
195
  type: 'object',
196
196
  properties: {
package/src/report.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  import { sendReport } from './shared/report';
2
2
  import { REPORT_KINDS } from './shared/report-kinds';
3
3
 
4
+ interface ReportOptions {
5
+ readonly message: string;
6
+ readonly label: string;
7
+ }
8
+
4
9
  /**
5
- * Runs inside wrangled sessions: reads the final text from stdin verbatim
6
- * and forwards it to the atc socket as a Report envelope of the given kind
7
- * for the given message. Always exits 0 so it never blocks the session it
8
- * reports on.
10
+ * Runs inside wrangled sessions: reads stdin verbatim and forwards it to the
11
+ * atc socket as a Report envelope of the given kind. An `answered` report
12
+ * carries stdin as the final text for the given message; a `note` carries
13
+ * it as text for the user under the given label, `progress` when none is
14
+ * given. Always exits 0 so it never blocks the session it reports on.
9
15
  */
10
- export async function runReport(kind: string, message: string): Promise<void> {
16
+ export async function runReport(kind: string, options: ReportOptions): Promise<void> {
11
17
  try {
12
18
  const sock = process.env['ATC_SOCKET'];
13
19
  const atcId = process.env['ATC_SESSION_ID'];
@@ -17,16 +23,37 @@ export async function runReport(kind: string, message: string): Promise<void> {
17
23
  sock !== '' &&
18
24
  atcId !== undefined &&
19
25
  atcId !== '' &&
20
- REPORT_KINDS.some((known) => known === kind) &&
21
- message !== ''
26
+ REPORT_KINDS.some((known) => known === kind)
22
27
  ) {
23
- const answer = await new Response(Bun.stdin.stream()).text();
28
+ const stdin = await new Response(Bun.stdin.stream()).text();
24
29
 
25
- const line = `${JSON.stringify({ atcId, event: 'Report', payload: { kind, message, answer } })}\n`;
30
+ const payload = buildReportPayload(kind, options, stdin);
26
31
 
27
- await sendReport(sock, line, 2000);
32
+ if (payload !== null) {
33
+ const line = `${JSON.stringify({ atcId, event: 'Report', payload })}\n`;
34
+
35
+ await sendReport(sock, line, 2000);
36
+ }
28
37
  }
29
38
  } catch {}
30
39
 
31
40
  process.exit(0);
32
41
  }
42
+
43
+ function buildReportPayload(
44
+ kind: string,
45
+ options: ReportOptions,
46
+ stdin: string,
47
+ ): Record<string, string> | null {
48
+ if (kind === 'answered') {
49
+ return options.message === '' ? null : { kind, message: options.message, answer: stdin };
50
+ }
51
+
52
+ if (kind === 'note') {
53
+ return stdin.trim() === ''
54
+ ? null
55
+ : { kind, label: options.label === '' ? 'progress' : options.label, text: stdin };
56
+ }
57
+
58
+ return null;
59
+ }
@@ -1,3 +1 @@
1
- export const REPORT_KINDS = ['answered'] as const;
2
-
3
- export type ReportKind = (typeof REPORT_KINDS)[number];
1
+ export const REPORT_KINDS = ['answered', 'note'] as const;