@zgeoff/atc 2.6.0 → 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 +4 -0
- package/src/agents/claude-adapter.ts +2 -0
- package/src/agents/codex-adapter.ts +2 -0
- package/src/agents/gateway-adapter.ts +2 -0
- package/src/agents/grok-adapter.ts +2 -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-session-message-event.ts +29 -0
- package/src/daemon/daemon-connection.ts +193 -0
- package/src/daemon/daemon.ts +245 -3
- package/src/daemon/hooks.ts +13 -4
- 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/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 +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/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/package.json
CHANGED
|
@@ -124,6 +124,10 @@ export interface AgentAdapter {
|
|
|
124
124
|
|
|
125
125
|
// The detector stack's screen tier; null when hooks are authoritative.
|
|
126
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;
|
|
127
131
|
readonly planSpawn: (opts: SpawnOptions) => SpawnPlan;
|
|
128
132
|
readonly normalizeHook: (e: HookEvent) => AdapterEvent;
|
|
129
133
|
readonly loadName: (
|
|
@@ -47,6 +47,8 @@ export class ClaudeAdapter implements AgentAdapter {
|
|
|
47
47
|
|
|
48
48
|
readonly parseTranscriptLine = parseClaudeTranscriptLine;
|
|
49
49
|
|
|
50
|
+
readonly takesMessages = true;
|
|
51
|
+
|
|
50
52
|
private readonly config: Config;
|
|
51
53
|
|
|
52
54
|
// Written on first spawn so constructing the adapter touches no state.
|
|
@@ -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) {
|
|
@@ -37,6 +37,8 @@ export class GatewayAdapter implements AgentAdapter {
|
|
|
37
37
|
// The gateway runs the Claude CLI, which writes the same transcript.
|
|
38
38
|
readonly parseTranscriptLine = parseClaudeTranscriptLine;
|
|
39
39
|
|
|
40
|
+
readonly takesMessages = true;
|
|
41
|
+
|
|
40
42
|
private readonly gateway: GatewayConfig;
|
|
41
43
|
|
|
42
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>();
|
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,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
|
+
}
|
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
encodeMessage,
|
|
14
14
|
} from '../protocol/protocol';
|
|
15
15
|
import type { ErrorCode, EventMsg, RequestMsg } from '../protocol/protocol';
|
|
16
|
+
import type { MessageID } from '../shared/message-id';
|
|
16
17
|
import type { SessionID } from '../shared/session-id';
|
|
17
18
|
import type { FleetEntry } from '../store/fleet-entry';
|
|
19
|
+
import type { MessageRecord } from '../store/message-record';
|
|
18
20
|
import type { Dims } from './attach-registry';
|
|
19
21
|
import type { FleetEvent } from './build-fleet-events';
|
|
20
22
|
import type { TranscriptPage, TranscriptPosition } from './load-transcript-page';
|
|
@@ -100,6 +102,18 @@ export interface DaemonContext {
|
|
|
100
102
|
limit: number,
|
|
101
103
|
waitMs: number,
|
|
102
104
|
) => Promise<FleetEvent[]>;
|
|
105
|
+
readonly writeSessionMessage: (
|
|
106
|
+
sessionID: SessionID,
|
|
107
|
+
from: string,
|
|
108
|
+
text: string,
|
|
109
|
+
) => Promise<MessageRecord | 'missing' | 'dead' | 'unsupported' | 'no_tap'>;
|
|
110
|
+
readonly readMessage: (messageID: MessageID) => Promise<MessageView | null>;
|
|
111
|
+
readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
|
|
112
|
+
readonly ackMessage: (
|
|
113
|
+
client: TapClient,
|
|
114
|
+
sessionID: SessionID,
|
|
115
|
+
messageID: MessageID,
|
|
116
|
+
) => Promise<MessageRecord | 'not_tapping' | 'unknown'>;
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
// The slice of a connection the attach bookkeeping needs: identity plus the
|
|
@@ -108,6 +122,18 @@ export interface OutputClient {
|
|
|
108
122
|
readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
|
|
109
123
|
}
|
|
110
124
|
|
|
125
|
+
// One message as `message.get` reports it: the session it belongs to now and
|
|
126
|
+
// every field of the record.
|
|
127
|
+
interface MessageView {
|
|
128
|
+
readonly session: SessionID;
|
|
129
|
+
readonly record: MessageRecord;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The slice of a connection a tap subscription needs.
|
|
133
|
+
export interface TapClient {
|
|
134
|
+
readonly sendEvent: (event: EventMsg) => void;
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
interface PeerSocket extends SocketWriter {
|
|
112
138
|
readonly end: () => void;
|
|
113
139
|
}
|
|
@@ -121,6 +147,8 @@ export class DaemonConnection {
|
|
|
121
147
|
|
|
122
148
|
private buffer = '';
|
|
123
149
|
|
|
150
|
+
private readonly decoder = new TextDecoder();
|
|
151
|
+
|
|
124
152
|
private helloed = false;
|
|
125
153
|
|
|
126
154
|
// The handshake answer is written before any other response on this
|
|
@@ -165,6 +193,13 @@ export class DaemonConnection {
|
|
|
165
193
|
}
|
|
166
194
|
}
|
|
167
195
|
|
|
196
|
+
// Decodes with state kept across reads, so a multi-byte character split
|
|
197
|
+
// between two reads decodes whole.
|
|
198
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
|
|
199
|
+
decodeChunk(buf: Uint8Array): string {
|
|
200
|
+
return this.decoder.decode(buf, { stream: true });
|
|
201
|
+
}
|
|
202
|
+
|
|
168
203
|
applyChunk(chunk: string): void {
|
|
169
204
|
const buffered = this.buffer + chunk;
|
|
170
205
|
|
|
@@ -463,6 +498,26 @@ export class DaemonConnection {
|
|
|
463
498
|
|
|
464
499
|
return;
|
|
465
500
|
}
|
|
501
|
+
case 'session.message': {
|
|
502
|
+
await this.applySessionMessage(req);
|
|
503
|
+
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
case 'session.tap': {
|
|
507
|
+
this.applyTap(req);
|
|
508
|
+
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
case 'message.get': {
|
|
512
|
+
await this.applyMessageGet(req);
|
|
513
|
+
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
case 'message.ack': {
|
|
517
|
+
await this.applyMessageAck(req);
|
|
518
|
+
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
466
521
|
case 'fleet.restore': {
|
|
467
522
|
const parsed = parseRequestParams('fleet.restore', req.p);
|
|
468
523
|
|
|
@@ -775,6 +830,144 @@ export class DaemonConnection {
|
|
|
775
830
|
});
|
|
776
831
|
}
|
|
777
832
|
|
|
833
|
+
private async applySessionMessage(req: RequestMsg): Promise<void> {
|
|
834
|
+
const parsed = parseRequestParams('session.message', req.p);
|
|
835
|
+
|
|
836
|
+
if (!parsed.ok) {
|
|
837
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
838
|
+
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const sessionID = parsed.data.session;
|
|
843
|
+
|
|
844
|
+
const result = await this.ctx.writeSessionMessage(
|
|
845
|
+
sessionID,
|
|
846
|
+
parsed.data.from,
|
|
847
|
+
parsed.data.text,
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
if (result === 'missing') {
|
|
851
|
+
this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
|
|
852
|
+
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (result === 'dead') {
|
|
857
|
+
this.sendErr(req.id, 'session_dead', `session '${sessionID}' has no live process`);
|
|
858
|
+
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (result === 'unsupported') {
|
|
863
|
+
this.sendErr(
|
|
864
|
+
req.id,
|
|
865
|
+
'unsupported',
|
|
866
|
+
`session '${sessionID}' cannot take messages: its agent has no message tap`,
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (result === 'no_tap') {
|
|
873
|
+
this.sendErr(req.id, 'unsupported', `session '${sessionID}' never attached a message tap`);
|
|
874
|
+
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
this.sendOk(req.id, { message: result.id, status: result.status });
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
private applyTap(req: RequestMsg): void {
|
|
882
|
+
const parsed = parseRequestParams('session.tap', req.p);
|
|
883
|
+
|
|
884
|
+
if (!parsed.ok) {
|
|
885
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
886
|
+
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const sessionID = parsed.data.session;
|
|
891
|
+
const result = this.ctx.attachTap(this, sessionID);
|
|
892
|
+
|
|
893
|
+
if (result === 'missing') {
|
|
894
|
+
this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
|
|
895
|
+
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (result === 'unsupported') {
|
|
900
|
+
this.sendErr(req.id, 'unsupported', `session '${sessionID}' cannot take messages`);
|
|
901
|
+
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
this.sendOk(req.id, {});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
private async applyMessageGet(req: RequestMsg): Promise<void> {
|
|
909
|
+
const parsed = parseRequestParams('message.get', req.p);
|
|
910
|
+
|
|
911
|
+
if (!parsed.ok) {
|
|
912
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
913
|
+
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const messageID = parsed.data.message;
|
|
918
|
+
|
|
919
|
+
const view = await this.ctx.readMessage(messageID);
|
|
920
|
+
|
|
921
|
+
if (view === null) {
|
|
922
|
+
this.sendErr(req.id, 'bad_args', `no message '${messageID}'`);
|
|
923
|
+
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const record = view.record;
|
|
928
|
+
|
|
929
|
+
this.sendOk(req.id, {
|
|
930
|
+
message: record.id,
|
|
931
|
+
session: view.session,
|
|
932
|
+
from: record.from,
|
|
933
|
+
text: record.text,
|
|
934
|
+
status: record.status,
|
|
935
|
+
sentAt: record.sentAt,
|
|
936
|
+
...(record.deliveredAt === undefined ? {} : { deliveredAt: record.deliveredAt }),
|
|
937
|
+
...(record.answeredAt === undefined ? {} : { answeredAt: record.answeredAt }),
|
|
938
|
+
...(record.answer === undefined ? {} : { answer: record.answer }),
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
private async applyMessageAck(req: RequestMsg): Promise<void> {
|
|
943
|
+
const parsed = parseRequestParams('message.ack', req.p);
|
|
944
|
+
|
|
945
|
+
if (!parsed.ok) {
|
|
946
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
947
|
+
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const sessionID = parsed.data.session;
|
|
952
|
+
const messageID = parsed.data.message;
|
|
953
|
+
|
|
954
|
+
const result = await this.ctx.ackMessage(this, sessionID, messageID);
|
|
955
|
+
|
|
956
|
+
if (result === 'not_tapping') {
|
|
957
|
+
this.sendErr(req.id, 'bad_args', `this connection is not tapping session '${sessionID}'`);
|
|
958
|
+
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (result === 'unknown') {
|
|
963
|
+
this.sendErr(req.id, 'bad_args', `no message '${messageID}' for session '${sessionID}'`);
|
|
964
|
+
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
this.sendOk(req.id, { message: result.id, status: result.status });
|
|
969
|
+
}
|
|
970
|
+
|
|
778
971
|
private async applySessionVerb(
|
|
779
972
|
req: RequestMsg,
|
|
780
973
|
method: 'session.kill' | 'session.ack',
|