@zgeoff/atc 2.6.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 +11 -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 +23 -2
- package/src/agents/codex-adapter.ts +2 -0
- package/src/agents/gateway-adapter.ts +23 -1
- package/src/agents/grok-adapter.ts +2 -0
- package/src/agents/write-atc-bridge.ts +42 -0
- package/src/agents/write-hook-settings.ts +2 -1
- package/src/cli.ts +51 -0
- package/src/client/daemon-client.ts +13 -1
- package/src/daemon/answer-byte-cap.ts +1 -0
- package/src/daemon/build-headless-env.ts +23 -0
- package/src/daemon/build-session-message-event.ts +29 -0
- package/src/daemon/build-session-report-event.ts +24 -0
- package/src/daemon/daemon-connection.ts +200 -0
- package/src/daemon/daemon.ts +264 -3
- package/src/daemon/hooks.ts +13 -4
- package/src/daemon/mint-message-id.ts +12 -0
- package/src/daemon/parse-report.ts +44 -0
- package/src/daemon/session-runtime.ts +9 -0
- package/src/daemon/start-headless-run.ts +10 -2
- package/src/daemon/start-headless-turn.ts +1 -0
- package/src/daemon/tap-registry.ts +67 -0
- package/src/mcp-server.ts +56 -0
- package/src/protocol/request-param-schemas.ts +12 -0
- package/src/report.ts +59 -0
- package/src/shared/message-id.ts +7 -0
- package/src/shared/report-kinds.ts +1 -0
- package/src/shared/report.ts +18 -2
- package/src/shared/to-message-id.ts +11 -0
- package/src/store/message-owner.ts +9 -0
- package/src/store/message-record.ts +18 -0
- package/src/store/run-migrations.ts +52 -2
- package/src/store/state-store.ts +144 -2
- package/src/tap.ts +120 -0
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 {
|
|
@@ -124,6 +131,10 @@ export interface AgentAdapter {
|
|
|
124
131
|
|
|
125
132
|
// The detector stack's screen tier; null when hooks are authoritative.
|
|
126
133
|
readonly screenDetector: ScreenDetector | null;
|
|
134
|
+
|
|
135
|
+
// Whether a session under this agent can take inbox messages through a tap;
|
|
136
|
+
// false refuses every message as unsupported.
|
|
137
|
+
readonly takesMessages: boolean;
|
|
127
138
|
readonly planSpawn: (opts: SpawnOptions) => SpawnPlan;
|
|
128
139
|
readonly normalizeHook: (e: HookEvent) => AdapterEvent;
|
|
129
140
|
readonly loadName: (
|
|
@@ -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
|
|
@@ -47,14 +48,26 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
47
48
|
|
|
48
49
|
readonly parseTranscriptLine = parseClaudeTranscriptLine;
|
|
49
50
|
|
|
51
|
+
readonly takesMessages = true;
|
|
52
|
+
|
|
50
53
|
private readonly config: Config;
|
|
51
54
|
|
|
52
55
|
// Written on first spawn so constructing the adapter touches no state.
|
|
53
56
|
private settingsFile: string | undefined;
|
|
54
57
|
|
|
55
|
-
|
|
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;
|
|
56
65
|
this.config = config;
|
|
57
|
-
|
|
66
|
+
|
|
67
|
+
this.headlessRunner =
|
|
68
|
+
headlessRunner === null
|
|
69
|
+
? null
|
|
70
|
+
: (opts, hooks) => headlessRunner({ ...opts, pluginDir: this.writeBridge() }, hooks);
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
planSpawn(opts: SpawnOptions): SpawnPlan {
|
|
@@ -66,6 +79,8 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
66
79
|
...this.config.claudeArgs,
|
|
67
80
|
'--settings',
|
|
68
81
|
this.settingsFile,
|
|
82
|
+
'--plugin-dir',
|
|
83
|
+
this.writeBridge(),
|
|
69
84
|
...(opts.resume === true ? ['--resume'] : []),
|
|
70
85
|
...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
|
|
71
86
|
...(opts.prompt === '' ? [] : [opts.prompt]),
|
|
@@ -73,6 +88,12 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
73
88
|
};
|
|
74
89
|
}
|
|
75
90
|
|
|
91
|
+
private writeBridge(): string {
|
|
92
|
+
this.bridgeDir ??= writeATCBridge(this.bridgeTarget);
|
|
93
|
+
|
|
94
|
+
return this.bridgeDir;
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
normalizeHook(e: HookEvent): AdapterEvent {
|
|
77
98
|
const parsed = CLAUDE_HOOK_PAYLOAD_SCHEMA.safeParse(e.payload);
|
|
78
99
|
const payload: ClaudeHookPayload = parsed.success ? parsed.data : {};
|
|
@@ -47,6 +47,8 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
47
47
|
// Codex's hooks are authoritative; no screen heuristics needed.
|
|
48
48
|
readonly screenDetector = null;
|
|
49
49
|
|
|
50
|
+
readonly takesMessages = false;
|
|
51
|
+
|
|
50
52
|
private readonly config: Config;
|
|
51
53
|
|
|
52
54
|
constructor(config: Config) {
|
|
@@ -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
|
/**
|
|
@@ -37,6 +38,8 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
37
38
|
// The gateway runs the Claude CLI, which writes the same transcript.
|
|
38
39
|
readonly parseTranscriptLine = parseClaudeTranscriptLine;
|
|
39
40
|
|
|
41
|
+
readonly takesMessages = true;
|
|
42
|
+
|
|
40
43
|
private readonly gateway: GatewayConfig;
|
|
41
44
|
|
|
42
45
|
private readonly claude: ClaudeAdapter;
|
|
@@ -44,11 +47,18 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
44
47
|
// Written on first spawn so constructing the adapter touches no state.
|
|
45
48
|
private settingsFile: string | undefined;
|
|
46
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
|
+
|
|
47
55
|
constructor(
|
|
48
56
|
gateway: GatewayConfig,
|
|
49
57
|
config: Config,
|
|
50
58
|
headlessRunner: HeadlessRunner | null = null,
|
|
59
|
+
bridgeTarget?: string,
|
|
51
60
|
) {
|
|
61
|
+
this.bridgeTarget = bridgeTarget;
|
|
52
62
|
this.gateway = gateway;
|
|
53
63
|
this.id = gateway.id;
|
|
54
64
|
|
|
@@ -57,7 +67,11 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
57
67
|
this.headlessRunner =
|
|
58
68
|
headlessRunner === null
|
|
59
69
|
? null
|
|
60
|
-
: (opts, hooks) =>
|
|
70
|
+
: (opts, hooks) =>
|
|
71
|
+
headlessRunner(
|
|
72
|
+
{ ...opts, settings: this.writeSettings(), pluginDir: this.writeBridge() },
|
|
73
|
+
hooks,
|
|
74
|
+
);
|
|
61
75
|
}
|
|
62
76
|
|
|
63
77
|
planSpawn(opts: SpawnOptions): SpawnPlan {
|
|
@@ -67,6 +81,8 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
67
81
|
...this.gateway.args,
|
|
68
82
|
'--settings',
|
|
69
83
|
this.writeSettings(),
|
|
84
|
+
'--plugin-dir',
|
|
85
|
+
this.writeBridge(),
|
|
70
86
|
...(opts.resume === true ? ['--resume'] : []),
|
|
71
87
|
...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
|
|
72
88
|
...(opts.prompt === '' ? [] : [opts.prompt]),
|
|
@@ -108,4 +124,10 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
108
124
|
|
|
109
125
|
return this.settingsFile;
|
|
110
126
|
}
|
|
127
|
+
|
|
128
|
+
private writeBridge(): string {
|
|
129
|
+
this.bridgeDir ??= writeATCBridge(this.bridgeTarget);
|
|
130
|
+
|
|
131
|
+
return this.bridgeDir;
|
|
132
|
+
}
|
|
111
133
|
}
|
|
@@ -60,6 +60,8 @@ export class GrokAdapter implements AgentAdapter {
|
|
|
60
60
|
// Grok's hooks are authoritative; no screen heuristics needed.
|
|
61
61
|
readonly screenDetector = null;
|
|
62
62
|
|
|
63
|
+
readonly takesMessages = false;
|
|
64
|
+
|
|
63
65
|
private readonly config: Config;
|
|
64
66
|
|
|
65
67
|
private readonly hookState = new Map<SessionID, GrokSessionHookState>();
|
|
@@ -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
|
@@ -63,6 +63,10 @@ const main = defineCommand({
|
|
|
63
63
|
const restoreBootTimeoutMs =
|
|
64
64
|
Number.isFinite(capOverride) && capOverride >= 0 ? capOverride : 15_000;
|
|
65
65
|
|
|
66
|
+
// How long a started session may go without a tap before a message
|
|
67
|
+
// to it is refused. Tests pin it to 0 to reach the refusal at once.
|
|
68
|
+
const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
|
|
69
|
+
|
|
66
70
|
const claudeAdapter = new claude.ClaudeAdapter(cfg, (runOpts, hooks) =>
|
|
67
71
|
headless.startHeadlessRun({ ...runOpts, claudeBin: cfg.claudeBin }, hooks),
|
|
68
72
|
);
|
|
@@ -89,6 +93,9 @@ const main = defineCommand({
|
|
|
89
93
|
pidPath: config.daemonPidFile,
|
|
90
94
|
hooks: cfg.hooks,
|
|
91
95
|
restoreBootTimeoutMs,
|
|
96
|
+
...(Number.isFinite(graceOverride) && graceOverride >= 0
|
|
97
|
+
? { tapGraceMs: graceOverride }
|
|
98
|
+
: {}),
|
|
92
99
|
...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
|
|
93
100
|
onQuit: () => process.exit(0),
|
|
94
101
|
});
|
|
@@ -152,6 +159,50 @@ const main = defineCommand({
|
|
|
152
159
|
await reporter.runHookReport();
|
|
153
160
|
},
|
|
154
161
|
}),
|
|
162
|
+
tap: () =>
|
|
163
|
+
defineCommand({
|
|
164
|
+
meta: {
|
|
165
|
+
name: 'tap',
|
|
166
|
+
description: "Stream a session's inbox to stdout as NDJSON, acking each message",
|
|
167
|
+
},
|
|
168
|
+
args: {
|
|
169
|
+
session: {
|
|
170
|
+
type: 'string',
|
|
171
|
+
required: true,
|
|
172
|
+
description: 'The atc session id to tap',
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
async run(ctx) {
|
|
176
|
+
const tap = await import('./tap');
|
|
177
|
+
|
|
178
|
+
await tap.runTap(ctx.args.session);
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
report: () =>
|
|
182
|
+
defineCommand({
|
|
183
|
+
meta: {
|
|
184
|
+
name: 'report',
|
|
185
|
+
description:
|
|
186
|
+
'Report a message answer or a note from a wrangled session to the atc socket',
|
|
187
|
+
hidden: true,
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
// No arg is required: a citty usage error exits nonzero, and
|
|
191
|
+
// reporters must always exit 0.
|
|
192
|
+
args: {
|
|
193
|
+
kind: { type: 'positional', required: false, default: '' },
|
|
194
|
+
message: { type: 'string', default: '' },
|
|
195
|
+
label: { type: 'string', default: '' },
|
|
196
|
+
},
|
|
197
|
+
async run(ctx) {
|
|
198
|
+
const reporter = await import('./report');
|
|
199
|
+
|
|
200
|
+
await reporter.runReport(ctx.args.kind, {
|
|
201
|
+
message: ctx.args.message,
|
|
202
|
+
label: ctx.args.label,
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
155
206
|
statusline: () =>
|
|
156
207
|
defineCommand({
|
|
157
208
|
meta: {
|
|
@@ -16,10 +16,14 @@ interface Pending {
|
|
|
16
16
|
export class DaemonClient {
|
|
17
17
|
onEvent: (event: EventMsg) => void = () => {};
|
|
18
18
|
|
|
19
|
+
onClose: () => void = () => {};
|
|
20
|
+
|
|
19
21
|
private queue: OutboundQueue | null = null;
|
|
20
22
|
|
|
21
23
|
private buffer = '';
|
|
22
24
|
|
|
25
|
+
private readonly decoder = new TextDecoder();
|
|
26
|
+
|
|
23
27
|
private nextID = 1;
|
|
24
28
|
|
|
25
29
|
private readonly pending = new Map<number, Pending>();
|
|
@@ -33,13 +37,14 @@ export class DaemonClient {
|
|
|
33
37
|
unix: socketPath,
|
|
34
38
|
socket: {
|
|
35
39
|
data(_s, buf) {
|
|
36
|
-
client.applyChunk(
|
|
40
|
+
client.applyChunk(client.decodeChunk(buf));
|
|
37
41
|
},
|
|
38
42
|
drain() {
|
|
39
43
|
client.queue?.drain();
|
|
40
44
|
},
|
|
41
45
|
close() {
|
|
42
46
|
client.drainPending('connection closed');
|
|
47
|
+
client.onClose();
|
|
43
48
|
},
|
|
44
49
|
error() {},
|
|
45
50
|
},
|
|
@@ -74,6 +79,13 @@ export class DaemonClient {
|
|
|
74
79
|
this.drainPending('client closed');
|
|
75
80
|
}
|
|
76
81
|
|
|
82
|
+
// Decodes with state kept across reads, so a multi-byte character split
|
|
83
|
+
// between two reads decodes whole.
|
|
84
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
|
|
85
|
+
private decodeChunk(buf: Uint8Array): string {
|
|
86
|
+
return this.decoder.decode(buf, { stream: true });
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
private applyChunk(chunk: string): void {
|
|
78
90
|
this.buffer += chunk;
|
|
79
91
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const ANSWER_BYTE_CAP = 65_536;
|
|
@@ -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,29 @@
|
|
|
1
|
+
import { truncateDetail } from '../agents/truncate-detail';
|
|
2
|
+
import { PROTOCOL_V } from '../protocol/protocol';
|
|
3
|
+
import type { EventMsg } from '../protocol/protocol';
|
|
4
|
+
import type { SessionID } from '../shared/session-id';
|
|
5
|
+
import type { MessageRecord } from '../store/message-record';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `SessionMessage` event for one message status change, addressed by
|
|
9
|
+
* the atc session id the message currently belongs to. The event carries
|
|
10
|
+
* short previews of the text and answer, never the full content, so a
|
|
11
|
+
* broadcast stays small; `message.get` returns the full message. Delivery
|
|
12
|
+
* and answer fields appear only once they are set.
|
|
13
|
+
*/
|
|
14
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- every field is readonly; the branded id has no readonly form to wrap it in
|
|
15
|
+
export function buildSessionMessageEvent(sessionID: SessionID, record: MessageRecord): EventMsg {
|
|
16
|
+
return {
|
|
17
|
+
v: PROTOCOL_V,
|
|
18
|
+
ev: 'SessionMessage',
|
|
19
|
+
s: sessionID,
|
|
20
|
+
message: record.id,
|
|
21
|
+
status: record.status,
|
|
22
|
+
from: record.from,
|
|
23
|
+
textPreview: truncateDetail(record.text),
|
|
24
|
+
sentAt: record.sentAt,
|
|
25
|
+
...(record.deliveredAt === undefined ? {} : { deliveredAt: record.deliveredAt }),
|
|
26
|
+
...(record.answeredAt === undefined ? {} : { answeredAt: record.answeredAt }),
|
|
27
|
+
...(record.answer === undefined ? {} : { answerPreview: truncateDetail(record.answer) }),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -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
|
+
}
|