@zgeoff/atc 2.5.1 → 2.7.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/package.json +1 -1
- package/src/agents/agent-adapter.ts +26 -0
- package/src/agents/claude-adapter.ts +6 -1
- package/src/agents/codex-adapter.ts +3 -1
- package/src/agents/gateway-adapter.ts +6 -0
- package/src/agents/grok-adapter.ts +3 -1
- package/src/agents/parse-claude-transcript-line.ts +107 -0
- package/src/cli.ts +46 -0
- package/src/client/daemon-client.ts +13 -1
- package/src/daemon/answer-byte-cap.ts +1 -0
- package/src/daemon/build-fleet-events.ts +35 -0
- package/src/daemon/build-session-message-event.ts +29 -0
- package/src/daemon/daemon-connection.ts +337 -0
- package/src/daemon/daemon.ts +333 -6
- package/src/daemon/event-signal.ts +54 -0
- package/src/daemon/hooks.ts +13 -4
- package/src/daemon/load-transcript-page.ts +101 -0
- package/src/daemon/mint-message-id.ts +12 -0
- package/src/daemon/parse-report.ts +35 -0
- package/src/daemon/session-runtime.ts +9 -0
- package/src/daemon/sessions.ts +51 -10
- package/src/daemon/tap-registry.ts +67 -0
- package/src/mcp-server.ts +164 -1
- package/src/protocol/decode-cursor.ts +29 -0
- package/src/protocol/encode-cursor.ts +12 -0
- package/src/protocol/request-param-schemas.ts +30 -0
- package/src/report.ts +32 -0
- package/src/shared/message-id.ts +7 -0
- package/src/shared/report-kinds.ts +3 -0
- package/src/shared/report.ts +18 -2
- package/src/shared/to-message-id.ts +11 -0
- package/src/shared/truncate-to-bytes.ts +21 -0
- package/src/store/fleet-entry.ts +20 -0
- package/src/store/message-owner.ts +9 -0
- package/src/store/message-record.ts +18 -0
- package/src/store/run-migrations.ts +94 -3
- package/src/store/state-store.ts +272 -4
- package/src/tap.ts +120 -0
package/package.json
CHANGED
|
@@ -46,6 +46,23 @@ export interface AdapterEvent {
|
|
|
46
46
|
// Claude resume-existence path. Distinct from nameSource: a naming
|
|
47
47
|
// handle is not a resume gate.
|
|
48
48
|
transcriptSource?: string;
|
|
49
|
+
|
|
50
|
+
// The agent's whole final message for a finished turn; detail holds a bounded preview of it.
|
|
51
|
+
result?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TranscriptToolUse {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
readonly input: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TranscriptRow {
|
|
60
|
+
readonly role: 'user' | 'assistant';
|
|
61
|
+
readonly text: string;
|
|
62
|
+
readonly tools: readonly TranscriptToolUse[];
|
|
63
|
+
|
|
64
|
+
// Epoch ms from the line's timestamp.
|
|
65
|
+
readonly at: number | null;
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
export interface NameUpdate {
|
|
@@ -107,6 +124,10 @@ export interface AgentAdapter {
|
|
|
107
124
|
|
|
108
125
|
// The detector stack's screen tier; null when hooks are authoritative.
|
|
109
126
|
readonly screenDetector: ScreenDetector | null;
|
|
127
|
+
|
|
128
|
+
// Whether a session under this agent can take inbox messages through a tap;
|
|
129
|
+
// false refuses every message as unsupported.
|
|
130
|
+
readonly takesMessages: boolean;
|
|
110
131
|
readonly planSpawn: (opts: SpawnOptions) => SpawnPlan;
|
|
111
132
|
readonly normalizeHook: (e: HookEvent) => AdapterEvent;
|
|
112
133
|
readonly loadName: (
|
|
@@ -118,4 +139,9 @@ export interface AgentAdapter {
|
|
|
118
139
|
cwd: string,
|
|
119
140
|
agentSessionID: AgentSessionID | undefined,
|
|
120
141
|
) => string | null;
|
|
142
|
+
|
|
143
|
+
// Turns one line of the agent's transcript file into a conversation row,
|
|
144
|
+
// null for a line that is not one. Absent: atc cannot read this agent's
|
|
145
|
+
// transcript.
|
|
146
|
+
readonly parseTranscriptLine?: (line: string) => TranscriptRow | null;
|
|
121
147
|
}
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
SpawnOptions,
|
|
17
17
|
SpawnPlan,
|
|
18
18
|
} from './agent-adapter';
|
|
19
|
+
import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
|
|
19
20
|
import { truncateDetail } from './truncate-detail';
|
|
20
21
|
import { writeHookSettings } from './write-hook-settings';
|
|
21
22
|
|
|
@@ -44,6 +45,10 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
44
45
|
// Claude's hooks are authoritative; no screen heuristics needed.
|
|
45
46
|
readonly screenDetector = null;
|
|
46
47
|
|
|
48
|
+
readonly parseTranscriptLine = parseClaudeTranscriptLine;
|
|
49
|
+
|
|
50
|
+
readonly takesMessages = true;
|
|
51
|
+
|
|
47
52
|
private readonly config: Config;
|
|
48
53
|
|
|
49
54
|
// Written on first spawn so constructing the adapter touches no state.
|
|
@@ -110,7 +115,7 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
110
115
|
...named,
|
|
111
116
|
kind: 'turn-done',
|
|
112
117
|
...(lastMessage !== undefined && lastMessage !== ''
|
|
113
|
-
? { detail: truncateDetail(lastMessage) }
|
|
118
|
+
? { detail: truncateDetail(lastMessage), result: lastMessage }
|
|
114
119
|
: {}),
|
|
115
120
|
};
|
|
116
121
|
}
|
|
@@ -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) {
|
|
@@ -117,7 +119,7 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
117
119
|
...named,
|
|
118
120
|
kind: 'turn-done',
|
|
119
121
|
...(lastMessage !== undefined && lastMessage !== ''
|
|
120
|
-
? { detail: truncateDetail(lastMessage) }
|
|
122
|
+
? { detail: truncateDetail(lastMessage), result: lastMessage }
|
|
121
123
|
: {}),
|
|
122
124
|
};
|
|
123
125
|
}
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
SpawnPlan,
|
|
15
15
|
} from './agent-adapter';
|
|
16
16
|
import { ClaudeAdapter } from './claude-adapter';
|
|
17
|
+
import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
|
|
17
18
|
import { writeHookSettings } from './write-hook-settings';
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -33,6 +34,11 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
33
34
|
// The CLI's hooks are authoritative; no screen heuristics needed.
|
|
34
35
|
readonly screenDetector = null;
|
|
35
36
|
|
|
37
|
+
// The gateway runs the Claude CLI, which writes the same transcript.
|
|
38
|
+
readonly parseTranscriptLine = parseClaudeTranscriptLine;
|
|
39
|
+
|
|
40
|
+
readonly takesMessages = true;
|
|
41
|
+
|
|
36
42
|
private readonly gateway: GatewayConfig;
|
|
37
43
|
|
|
38
44
|
private readonly claude: ClaudeAdapter;
|
|
@@ -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>();
|
|
@@ -269,7 +271,7 @@ function buildTurnDoneEvent(named: Readonly<AdapterEvent>, payload: GrokHookPayl
|
|
|
269
271
|
...named,
|
|
270
272
|
kind: 'turn-done',
|
|
271
273
|
...(lastMessage !== undefined && lastMessage !== ''
|
|
272
|
-
? { detail: truncateDetail(lastMessage) }
|
|
274
|
+
? { detail: truncateDetail(lastMessage), result: lastMessage }
|
|
273
275
|
: {}),
|
|
274
276
|
};
|
|
275
277
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { isRecord } from '../shared/report';
|
|
2
|
+
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
3
|
+
import type { TranscriptRow, TranscriptToolUse } from './agent-adapter';
|
|
4
|
+
|
|
5
|
+
// One row must stay far under the protocol's line cap.
|
|
6
|
+
const MAX_ROW_TEXT_BYTES = 16_384;
|
|
7
|
+
|
|
8
|
+
export function parseClaudeTranscriptLine(line: string): TranscriptRow | null {
|
|
9
|
+
let parsed: unknown;
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
parsed = JSON.parse(line);
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!isRecord(parsed)) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const type = parsed['type'];
|
|
22
|
+
|
|
23
|
+
if (type !== 'user' && type !== 'assistant') {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (parsed['isSidechain'] === true || parsed['isMeta'] === true) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const message = parsed['message'];
|
|
32
|
+
|
|
33
|
+
if (!isRecord(message)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const content = message['content'];
|
|
38
|
+
const texts: string[] = [];
|
|
39
|
+
const tools: TranscriptToolUse[] = [];
|
|
40
|
+
|
|
41
|
+
if (typeof content === 'string') {
|
|
42
|
+
texts.push(content);
|
|
43
|
+
} else if (Array.isArray(content)) {
|
|
44
|
+
for (const block of content as unknown[]) {
|
|
45
|
+
if (!isRecord(block)) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const text = block['text'];
|
|
50
|
+
const name = block['name'];
|
|
51
|
+
|
|
52
|
+
if (block['type'] === 'text' && typeof text === 'string') {
|
|
53
|
+
texts.push(text);
|
|
54
|
+
} else if (block['type'] === 'tool_use' && typeof name === 'string') {
|
|
55
|
+
tools.push({ name, input: formatToolInput(block['input']) });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const text = texts.join('\n');
|
|
63
|
+
|
|
64
|
+
if (text === '' && tools.length === 0) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const timestamp = parsed['timestamp'];
|
|
69
|
+
const parsedAt = typeof timestamp === 'string' ? Date.parse(timestamp) : Number.NaN;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
role: type,
|
|
73
|
+
text: truncateToBytes(text, MAX_ROW_TEXT_BYTES),
|
|
74
|
+
tools,
|
|
75
|
+
at: Number.isNaN(parsedAt) ? null : parsedAt,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const TOOL_INPUT_KEYS = [
|
|
80
|
+
'command',
|
|
81
|
+
'file_path',
|
|
82
|
+
'path',
|
|
83
|
+
'pattern',
|
|
84
|
+
'url',
|
|
85
|
+
'query',
|
|
86
|
+
'description',
|
|
87
|
+
'prompt',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
function formatToolInput(input: unknown): string {
|
|
91
|
+
if (!isRecord(input)) {
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let summary = JSON.stringify(input);
|
|
96
|
+
|
|
97
|
+
for (const key of TOOL_INPUT_KEYS) {
|
|
98
|
+
const value = input[key];
|
|
99
|
+
|
|
100
|
+
if (typeof value === 'string') {
|
|
101
|
+
summary = value;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return summary.length <= 200 ? summary : `${summary.slice(0, 199)}…`;
|
|
107
|
+
}
|
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,45 @@ 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: 'Report a message event from a wrangled session to the atc socket',
|
|
186
|
+
hidden: true,
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
// Neither arg is required: a citty usage error exits nonzero, and
|
|
190
|
+
// reporters must always exit 0.
|
|
191
|
+
args: {
|
|
192
|
+
kind: { type: 'positional', required: false, default: '' },
|
|
193
|
+
message: { type: 'string', default: '' },
|
|
194
|
+
},
|
|
195
|
+
async run(ctx) {
|
|
196
|
+
const reporter = await import('./report');
|
|
197
|
+
|
|
198
|
+
await reporter.runReport(ctx.args.kind, ctx.args.message);
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
155
201
|
statusline: () =>
|
|
156
202
|
defineCommand({
|
|
157
203
|
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,35 @@
|
|
|
1
|
+
import { encodeCursor } from '../protocol/encode-cursor';
|
|
2
|
+
import type { StoredEvent } from '../store/state-store';
|
|
3
|
+
import type { SessionDescriptor } from './sessions';
|
|
4
|
+
|
|
5
|
+
export interface FleetEvent {
|
|
6
|
+
readonly cursor: string;
|
|
7
|
+
readonly at: number;
|
|
8
|
+
readonly session: string;
|
|
9
|
+
readonly name: string | null;
|
|
10
|
+
readonly kind: string;
|
|
11
|
+
readonly detail: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildFleetEvents(
|
|
15
|
+
rows: readonly StoredEvent[],
|
|
16
|
+
sessions: readonly SessionDescriptor[],
|
|
17
|
+
): FleetEvent[] {
|
|
18
|
+
return rows.map((row) => {
|
|
19
|
+
// A restore re-mints atc ids, so the agent session id is the stable link.
|
|
20
|
+
const live =
|
|
21
|
+
(row.agentSessionID === null
|
|
22
|
+
? undefined
|
|
23
|
+
: sessions.find((s) => s.agentSessionID === row.agentSessionID)) ??
|
|
24
|
+
sessions.find((s) => s.id === row.atcID);
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
cursor: encodeCursor({ kind: 'events', id: row.id }),
|
|
28
|
+
at: row.at,
|
|
29
|
+
session: live?.id ?? row.atcID,
|
|
30
|
+
name: live?.name ?? null,
|
|
31
|
+
kind: row.kind,
|
|
32
|
+
detail: row.detail,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -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
|
+
}
|