@zgeoff/atc 2.7.0 → 2.8.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 +4 -0
- package/package.json +3 -1
- package/src/agents/agent-adapter.ts +7 -0
- package/src/agents/atc-bridge-files.ts +11 -0
- package/src/agents/build-cli-argv.ts +13 -0
- package/src/agents/build-cli-command.ts +4 -9
- package/src/agents/claude-adapter.ts +21 -2
- package/src/agents/gateway-adapter.ts +21 -1
- package/src/agents/write-atc-bridge.ts +42 -0
- package/src/agents/write-hook-settings.ts +2 -1
- package/src/cli.ts +8 -3
- package/src/daemon/build-headless-env.ts +23 -0
- package/src/daemon/build-session-report-event.ts +24 -0
- package/src/daemon/daemon-connection.ts +7 -0
- package/src/daemon/daemon.ts +19 -0
- package/src/daemon/parse-report.ts +19 -10
- package/src/daemon/start-headless-run.ts +10 -2
- package/src/daemon/start-headless-turn.ts +1 -0
- package/src/report.ts +37 -10
- package/src/shared/report-kinds.ts +1 -3
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.
|
|
3
|
+
"version": "2.8.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",
|
|
@@ -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 'To tell the user something before the turn ends (progress, a blocker, a decision you need), call the report tool.',\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 the user a short note through atc while you keep working: progress, a blocker, or a decision you need from them. Your final reply is sent automatically; use this only for what cannot wait for it.',\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",
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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:
|
|
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
|
-
//
|
|
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,
|
|
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();
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
readonly kind:
|
|
5
|
+
interface AnsweredReport {
|
|
6
|
+
readonly kind: 'answered';
|
|
9
7
|
readonly message: MessageID;
|
|
10
8
|
readonly answer: string;
|
|
11
9
|
}
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
kind:
|
|
15
|
-
|
|
16
|
-
|
|
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:
|
|
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 {
|
|
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:
|
|
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);
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
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
|
|
28
|
+
const stdin = await new Response(Bun.stdin.stream()).text();
|
|
24
29
|
|
|
25
|
-
const
|
|
30
|
+
const payload = buildReportPayload(kind, options, stdin);
|
|
26
31
|
|
|
27
|
-
|
|
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
|
+
}
|