@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/src/daemon/daemon.ts
CHANGED
|
@@ -4,27 +4,35 @@ import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
|
|
|
4
4
|
import type { EventMsg } from '../protocol/protocol';
|
|
5
5
|
import type { HooksConfig } from '../shared/collect-hooks';
|
|
6
6
|
import type { SessionID } from '../shared/session-id';
|
|
7
|
+
import { truncateToBytes } from '../shared/truncate-to-bytes';
|
|
8
|
+
import type { MessageOwner } from '../store/message-owner';
|
|
9
|
+
import type { MessageRecord } from '../store/message-record';
|
|
7
10
|
import { StateStore } from '../store/state-store';
|
|
11
|
+
import { ANSWER_BYTE_CAP } from './answer-byte-cap';
|
|
8
12
|
import { AttachRegistry } from './attach-registry';
|
|
9
13
|
import { buildFleetEvents } from './build-fleet-events';
|
|
10
14
|
import { buildSessionEvent } from './build-session-event';
|
|
15
|
+
import { buildSessionMessageEvent } from './build-session-message-event';
|
|
11
16
|
import { DaemonConnection } from './daemon-connection';
|
|
12
|
-
import type { DaemonContext, OutputClient } from './daemon-connection';
|
|
17
|
+
import type { DaemonContext, OutputClient, TapClient } from './daemon-connection';
|
|
13
18
|
import { EventSignal } from './event-signal';
|
|
14
19
|
import { startHookServer } from './hooks';
|
|
15
20
|
import type { HookEvent } from './hooks';
|
|
16
21
|
import { loadTranscriptPage } from './load-transcript-page';
|
|
17
22
|
import { makeHookRunner } from './make-hook-runner';
|
|
18
23
|
import type { HookScope } from './make-hook-runner';
|
|
24
|
+
import { mintMessageID } from './mint-message-id';
|
|
25
|
+
import { parseReport } from './parse-report';
|
|
19
26
|
import { PermissionRegistry } from './permission-registry';
|
|
20
27
|
import { restoreFleet } from './restore-fleet';
|
|
21
28
|
import { runEjectHandoff } from './run-eject-handoff';
|
|
22
29
|
import { ScreenModel } from './screen-model';
|
|
23
30
|
import { SessionRuntime } from './session-runtime';
|
|
24
31
|
import { SessionManager } from './sessions';
|
|
25
|
-
import type { SessionDescriptor, SessionState } from './sessions';
|
|
32
|
+
import type { Session, SessionDescriptor, SessionState } from './sessions';
|
|
26
33
|
import { startEventsServer } from './start-events-server';
|
|
27
34
|
import { startHeadlessTurn } from './start-headless-turn';
|
|
35
|
+
import { TapRegistry } from './tap-registry';
|
|
28
36
|
|
|
29
37
|
export interface DaemonOptions {
|
|
30
38
|
readonly socketPath: string;
|
|
@@ -71,6 +79,7 @@ export interface DaemonOptions {
|
|
|
71
79
|
// single revive waits for that signal before moving on regardless, so a
|
|
72
80
|
// session that never reports cannot stall the rest. Zero waits forever.
|
|
73
81
|
readonly restoreBootTimeoutMs?: number;
|
|
82
|
+
readonly tapGraceMs?: number;
|
|
74
83
|
|
|
75
84
|
// Called after a client-requested quit has stopped the daemon; the real
|
|
76
85
|
// entrypoint exits the process, tests leave it unset.
|
|
@@ -81,6 +90,10 @@ export interface DaemonHandle {
|
|
|
81
90
|
readonly stop: () => Promise<void>;
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
// How long a started Claude session may go without a tap before a message to
|
|
94
|
+
// it is refused.
|
|
95
|
+
const TAP_GRACE_MS = 15_000;
|
|
96
|
+
|
|
84
97
|
/**
|
|
85
98
|
* The daemon: owns the sessions, the client-protocol listener, and the
|
|
86
99
|
* reporter listener. Protocol requests are NDJSON lines, one response per
|
|
@@ -164,6 +177,81 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
164
177
|
const findRuntime = (sessionID: SessionID) => runtimes.get(sessionID);
|
|
165
178
|
|
|
166
179
|
const attachments = new AttachRegistry<OutputClient>();
|
|
180
|
+
const taps = new TapRegistry<TapClient>();
|
|
181
|
+
|
|
182
|
+
// Writes accepted messages one at a time so the store's insertion order is
|
|
183
|
+
// the order of their sent times, which the inbox drains by.
|
|
184
|
+
let lastMessageWrite: Promise<void> = Promise.resolve();
|
|
185
|
+
|
|
186
|
+
// Hands one pending message to the session's tap, once, and reports whether
|
|
187
|
+
// it did. The event goes to the tap connection alone: it never reaches
|
|
188
|
+
// other clients, the events socket, or hooks.
|
|
189
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- every field is readonly; the branded id has no readonly form to wrap it in
|
|
190
|
+
const sendInboxMessage = (sessionID: SessionID, record: MessageRecord): boolean => {
|
|
191
|
+
const tap = taps.claimDelivery(sessionID, record.id);
|
|
192
|
+
|
|
193
|
+
if (tap === null) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
tap.sendEvent({
|
|
198
|
+
v: PROTOCOL_V,
|
|
199
|
+
ev: 'InboxMessage',
|
|
200
|
+
s: sessionID,
|
|
201
|
+
message: record.id,
|
|
202
|
+
from: record.from,
|
|
203
|
+
text: record.text,
|
|
204
|
+
sentAt: record.sentAt,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return true;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Reads the backlog from the store before sending anything, so a tap's
|
|
211
|
+
// ok response is always queued ahead of its first message. It sends one
|
|
212
|
+
// unclaimed message per call and the tap's ack calls it again, so the
|
|
213
|
+
// backlog never outgrows the connection's outbound queue.
|
|
214
|
+
const drainInbox = async (sessionID: SessionID) => {
|
|
215
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
216
|
+
|
|
217
|
+
if (s === undefined) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const pending = await store.collectPendingMessages(buildMessageOwner(s));
|
|
223
|
+
|
|
224
|
+
for (const record of pending) {
|
|
225
|
+
if (sendInboxMessage(sessionID, record)) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} catch {}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const applyReport = async (e: HookEvent) => {
|
|
233
|
+
const report = parseReport(e.payload);
|
|
234
|
+
|
|
235
|
+
if (report === null) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const s = mgr.sessions.find((x) => x.id === e.atcId);
|
|
240
|
+
const owner = s === undefined ? { atcID: e.atcId } : buildMessageOwner(s);
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const answered = await store.updateMessageAnswered(
|
|
244
|
+
report.message,
|
|
245
|
+
owner,
|
|
246
|
+
truncateToBytes(report.answer, ANSWER_BYTE_CAP),
|
|
247
|
+
Date.now(),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
if (answered !== null) {
|
|
251
|
+
emitEvent(buildSessionMessageEvent(e.atcId, answered), findHookScope(e.atcId));
|
|
252
|
+
}
|
|
253
|
+
} catch {}
|
|
254
|
+
};
|
|
167
255
|
|
|
168
256
|
// The screen tier of the detector stack: once a session's output has
|
|
169
257
|
// quiesced, judge the serialized screen and flip running/needs_you.
|
|
@@ -374,6 +462,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
374
462
|
|
|
375
463
|
if (kind === 'removed') {
|
|
376
464
|
attachments.removeSession(s.id);
|
|
465
|
+
|
|
466
|
+
emitInboxClosed(taps.removeSession(s.id), s.id, 'removed');
|
|
377
467
|
runtimes.get(s.id)?.dispose();
|
|
378
468
|
runtimes.delete(s.id);
|
|
379
469
|
}
|
|
@@ -394,6 +484,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
394
484
|
};
|
|
395
485
|
|
|
396
486
|
const reporter = startHookServer((e) => {
|
|
487
|
+
if (e.event === 'Report') {
|
|
488
|
+
void applyReport(e);
|
|
489
|
+
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const previousAgentSessionID = mgr.sessions.find((s) => s.id === e.atcId)?.agentSessionID;
|
|
397
494
|
const ev = mgr.applyHook(e);
|
|
398
495
|
|
|
399
496
|
if (e.event !== 'Statusline') {
|
|
@@ -402,6 +499,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
402
499
|
|
|
403
500
|
const kind = ev?.kind ?? null;
|
|
404
501
|
const runtime = runtimes.get(e.atcId);
|
|
502
|
+
const currentAgentSessionID = mgr.sessions.find((s) => s.id === e.atcId)?.agentSessionID;
|
|
503
|
+
|
|
504
|
+
if (currentAgentSessionID !== undefined && currentAgentSessionID !== previousAgentSessionID) {
|
|
505
|
+
void store.updateMessageOwner(e.atcId, previousAgentSessionID, currentAgentSessionID);
|
|
506
|
+
}
|
|
405
507
|
|
|
406
508
|
if (kind === 'ended') {
|
|
407
509
|
runtime?.pendingEject?.();
|
|
@@ -410,6 +512,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
410
512
|
// A revived session announcing itself is the cue a staggered restore
|
|
411
513
|
// waits on before booting the next one.
|
|
412
514
|
if (kind === 'started') {
|
|
515
|
+
if (runtime !== undefined) {
|
|
516
|
+
runtime.startedAt = Date.now();
|
|
517
|
+
}
|
|
518
|
+
|
|
413
519
|
runtime?.bootWaiter?.();
|
|
414
520
|
const started = mgr.sessions.find((s) => s.id === e.atcId);
|
|
415
521
|
|
|
@@ -535,6 +641,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
535
641
|
|
|
536
642
|
if (runtime !== undefined) {
|
|
537
643
|
runtime.dims = { cols, rows };
|
|
644
|
+
runtime.startedAt = null;
|
|
645
|
+
runtime.tapAttached = false;
|
|
538
646
|
|
|
539
647
|
runtime.screen ??= new ScreenModel(cols, rows);
|
|
540
648
|
}
|
|
@@ -720,6 +828,122 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
720
828
|
await eventSignal.waitForNext(generation, remaining);
|
|
721
829
|
}
|
|
722
830
|
},
|
|
831
|
+
writeSessionMessage: async (sessionID, from, text) => {
|
|
832
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
833
|
+
|
|
834
|
+
if (s === undefined) {
|
|
835
|
+
return 'missing';
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (!(s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'))) {
|
|
839
|
+
return 'dead';
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (mgr.findAdapter(s.agent)?.takesMessages !== true) {
|
|
843
|
+
return 'unsupported';
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const runtime = runtimes.get(sessionID);
|
|
847
|
+
|
|
848
|
+
if (
|
|
849
|
+
runtime !== undefined &&
|
|
850
|
+
runtime.startedAt !== null &&
|
|
851
|
+
!runtime.tapAttached &&
|
|
852
|
+
Date.now() - runtime.startedAt >= (opts.tapGraceMs ?? TAP_GRACE_MS)
|
|
853
|
+
) {
|
|
854
|
+
return 'no_tap';
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const previousWrite = lastMessageWrite;
|
|
858
|
+
const written = Promise.withResolvers<void>();
|
|
859
|
+
|
|
860
|
+
lastMessageWrite = written.promise;
|
|
861
|
+
|
|
862
|
+
await previousWrite;
|
|
863
|
+
|
|
864
|
+
const record: MessageRecord = {
|
|
865
|
+
id: mintMessageID(),
|
|
866
|
+
atcID: sessionID,
|
|
867
|
+
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
868
|
+
from,
|
|
869
|
+
text,
|
|
870
|
+
status: 'accepted',
|
|
871
|
+
sentAt: Date.now(),
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
try {
|
|
875
|
+
await store.writeMessage(record);
|
|
876
|
+
} finally {
|
|
877
|
+
written.resolve();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
emitEvent(buildSessionMessageEvent(sessionID, record), findHookScope(sessionID));
|
|
881
|
+
|
|
882
|
+
await drainInbox(sessionID);
|
|
883
|
+
|
|
884
|
+
return record;
|
|
885
|
+
},
|
|
886
|
+
attachTap: (client, sessionID) => {
|
|
887
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
888
|
+
|
|
889
|
+
if (s === undefined) {
|
|
890
|
+
return 'missing';
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (mgr.findAdapter(s.agent)?.takesMessages !== true) {
|
|
894
|
+
return 'unsupported';
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
emitInboxClosed(taps.attach(sessionID, client), sessionID, 'replaced');
|
|
898
|
+
|
|
899
|
+
const runtime = runtimes.get(sessionID);
|
|
900
|
+
|
|
901
|
+
if (runtime !== undefined) {
|
|
902
|
+
runtime.tapAttached = true;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
void drainInbox(sessionID);
|
|
906
|
+
|
|
907
|
+
return 'ok';
|
|
908
|
+
},
|
|
909
|
+
readMessage: async (messageID) => {
|
|
910
|
+
const record = await store.findMessageByID(messageID);
|
|
911
|
+
|
|
912
|
+
if (record === null) {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const owner = mgr.sessions.find(
|
|
917
|
+
(x) =>
|
|
918
|
+
x.id === record.atcID ||
|
|
919
|
+
(record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID),
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
return { session: owner?.id ?? record.atcID, record };
|
|
923
|
+
},
|
|
924
|
+
ackMessage: async (client, sessionID, messageID) => {
|
|
925
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
926
|
+
|
|
927
|
+
if (s === undefined || !taps.isTap(sessionID, client)) {
|
|
928
|
+
return 'not_tapping';
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const owner = buildMessageOwner(s);
|
|
932
|
+
|
|
933
|
+
const delivered = await store.updateMessageDelivered(messageID, owner, Date.now());
|
|
934
|
+
|
|
935
|
+
void drainInbox(sessionID);
|
|
936
|
+
|
|
937
|
+
if (delivered !== null) {
|
|
938
|
+
emitEvent(buildSessionMessageEvent(sessionID, delivered), findHookScope(sessionID));
|
|
939
|
+
|
|
940
|
+
return delivered;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const current = await store.findMessage(messageID, owner);
|
|
944
|
+
|
|
945
|
+
return current ?? 'unknown';
|
|
946
|
+
},
|
|
723
947
|
};
|
|
724
948
|
|
|
725
949
|
try {
|
|
@@ -735,7 +959,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
735
959
|
clients.add(socket.data);
|
|
736
960
|
},
|
|
737
961
|
data(socket, buf) {
|
|
738
|
-
socket.data.applyChunk(
|
|
962
|
+
socket.data.applyChunk(socket.data.decodeChunk(buf));
|
|
739
963
|
},
|
|
740
964
|
drain(socket) {
|
|
741
965
|
socket.data.drain();
|
|
@@ -743,6 +967,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
743
967
|
close(socket) {
|
|
744
968
|
clients.delete(socket.data);
|
|
745
969
|
ctx.detachClient(socket.data);
|
|
970
|
+
taps.detachAll(socket.data);
|
|
746
971
|
},
|
|
747
972
|
error() {},
|
|
748
973
|
},
|
|
@@ -773,6 +998,23 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
773
998
|
return { stop: stopDaemon };
|
|
774
999
|
}
|
|
775
1000
|
|
|
1001
|
+
// Tells a tap its subscription is over so the `atc tap` process behind it
|
|
1002
|
+
// exits instead of idling on a session it no longer serves.
|
|
1003
|
+
function emitInboxClosed(
|
|
1004
|
+
tap: TapClient | null,
|
|
1005
|
+
sessionID: SessionID,
|
|
1006
|
+
reason: 'replaced' | 'removed',
|
|
1007
|
+
): void {
|
|
1008
|
+
tap?.sendEvent({ v: PROTOCOL_V, ev: 'InboxClosed', s: sessionID, reason });
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function buildMessageOwner(s: Session): MessageOwner {
|
|
1012
|
+
return {
|
|
1013
|
+
atcID: s.id,
|
|
1014
|
+
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
776
1018
|
function hasResumableTranscript(mgr: SessionManager, id: SessionID): boolean {
|
|
777
1019
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
778
1020
|
|
package/src/daemon/hooks.ts
CHANGED
|
@@ -10,19 +10,28 @@ export interface HookEvent {
|
|
|
10
10
|
payload: Record<string, unknown>;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// Per-connection read state: the unterminated tail of the line in progress,
|
|
14
|
+
// and a streaming decoder so a multi-byte character split across two reads
|
|
15
|
+
// decodes whole.
|
|
16
|
+
interface HookConnection {
|
|
17
|
+
pending: string;
|
|
18
|
+
readonly decoder: TextDecoder;
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
export function startHookServer(onEvent: (e: HookEvent) => void, path: string = socketPath) {
|
|
14
22
|
try {
|
|
15
23
|
unlinkSync(path);
|
|
16
24
|
} catch {}
|
|
17
25
|
|
|
18
|
-
return Bun.listen<
|
|
26
|
+
return Bun.listen<HookConnection>({
|
|
19
27
|
unix: path,
|
|
20
28
|
socket: {
|
|
21
29
|
data(socket, buf) {
|
|
22
|
-
const
|
|
30
|
+
const conn = socket.data;
|
|
31
|
+
const buffered = conn.pending + conn.decoder.decode(buf, { stream: true });
|
|
23
32
|
const lines = buffered.split('\n');
|
|
24
33
|
|
|
25
|
-
|
|
34
|
+
conn.pending = lines.pop() ?? '';
|
|
26
35
|
|
|
27
36
|
for (const line of lines) {
|
|
28
37
|
if (line.trim() === '') {
|
|
@@ -48,7 +57,7 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
|
|
|
48
57
|
}
|
|
49
58
|
},
|
|
50
59
|
open(socket) {
|
|
51
|
-
socket.data = '';
|
|
60
|
+
socket.data = { pending: '', decoder: new TextDecoder() };
|
|
52
61
|
},
|
|
53
62
|
error() {},
|
|
54
63
|
},
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { MessageID } from '../shared/message-id';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A fresh atc message id: a random uuid behind an `m-` prefix. The id is a
|
|
6
|
+
* persisted primary key, so it is random rather than counted and stays
|
|
7
|
+
* unique across daemon restarts.
|
|
8
|
+
*/
|
|
9
|
+
export function mintMessageID(): MessageID {
|
|
10
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- this is where atc mints a message id
|
|
11
|
+
return `m-${randomUUID()}` as MessageID;
|
|
12
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
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
|
+
import { toMessageID } from '../shared/to-message-id';
|
|
6
|
+
|
|
7
|
+
export interface Report {
|
|
8
|
+
readonly kind: ReportKind;
|
|
9
|
+
readonly message: MessageID;
|
|
10
|
+
readonly answer: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const REPORT_SCHEMA = z.object({
|
|
14
|
+
kind: z.enum(REPORT_KINDS),
|
|
15
|
+
message: z.string().min(1),
|
|
16
|
+
answer: z.string(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parses a Report envelope's payload into the report its kind discriminates,
|
|
21
|
+
* or null when it matches no known kind.
|
|
22
|
+
*/
|
|
23
|
+
export function parseReport(payload: Readonly<Record<string, unknown>>): Report | null {
|
|
24
|
+
const parsed = REPORT_SCHEMA.safeParse(payload);
|
|
25
|
+
|
|
26
|
+
if (!parsed.success) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
kind: parsed.data.kind,
|
|
32
|
+
message: toMessageID(parsed.data.message),
|
|
33
|
+
answer: parsed.data.answer,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -37,6 +37,15 @@ export class SessionRuntime {
|
|
|
37
37
|
// clears it, and a spawn that never reports leaves it stranded harmlessly.
|
|
38
38
|
pendingLastUsed = false;
|
|
39
39
|
|
|
40
|
+
// When the agent reported SessionStart since its terminal last booted, or
|
|
41
|
+
// null before it has. A message queues for a grace window after that, and
|
|
42
|
+
// beyond it only if a tap has attached since the boot.
|
|
43
|
+
startedAt: number | null = null;
|
|
44
|
+
|
|
45
|
+
// Whether a tap has attached since the terminal last booted. A tap that
|
|
46
|
+
// later dropped may be restarting, so it keeps the inbox open.
|
|
47
|
+
tapAttached = false;
|
|
48
|
+
|
|
40
49
|
dispose(): void {
|
|
41
50
|
clearTimeout(this.resizeTimer);
|
|
42
51
|
clearTimeout(this.detectTimer);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { MessageID } from '../shared/message-id';
|
|
2
|
+
import type { SessionID } from '../shared/session-id';
|
|
3
|
+
|
|
4
|
+
interface TapEntry<TClient> {
|
|
5
|
+
readonly client: TClient;
|
|
6
|
+
readonly claimed: Set<MessageID>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Which connection taps each session's inbox; one tap per session, the
|
|
11
|
+
* latest wins; each message is handed to a tap once.
|
|
12
|
+
*/
|
|
13
|
+
export class TapRegistry<TClient> {
|
|
14
|
+
private readonly bySession = new Map<SessionID, TapEntry<TClient>>();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Makes the client the session's tap and returns the client it displaced,
|
|
18
|
+
* or null when there was none or it is the same connection.
|
|
19
|
+
*/
|
|
20
|
+
attach(sessionID: SessionID, client: TClient): TClient | null {
|
|
21
|
+
const previous = this.bySession.get(sessionID)?.client ?? null;
|
|
22
|
+
|
|
23
|
+
this.bySession.set(sessionID, { client, claimed: new Set() });
|
|
24
|
+
|
|
25
|
+
return previous === client ? null : previous;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
detachAll(client: TClient): void {
|
|
29
|
+
for (const [sessionID, entry] of this.bySession) {
|
|
30
|
+
if (entry.client === client) {
|
|
31
|
+
this.bySession.delete(sessionID);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Forgets the session's tap and returns its client, or null when the
|
|
38
|
+
* session had none.
|
|
39
|
+
*/
|
|
40
|
+
removeSession(sessionID: SessionID): TClient | null {
|
|
41
|
+
const previous = this.bySession.get(sessionID)?.client ?? null;
|
|
42
|
+
|
|
43
|
+
this.bySession.delete(sessionID);
|
|
44
|
+
|
|
45
|
+
return previous;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
hasTap(sessionID: SessionID): boolean {
|
|
49
|
+
return this.bySession.has(sessionID);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
isTap(sessionID: SessionID, client: TClient): boolean {
|
|
53
|
+
return this.bySession.get(sessionID)?.client === client;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
claimDelivery(sessionID: SessionID, messageID: MessageID): TClient | null {
|
|
57
|
+
const entry = this.bySession.get(sessionID);
|
|
58
|
+
|
|
59
|
+
if (entry === undefined || entry.claimed.has(messageID)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
entry.claimed.add(messageID);
|
|
64
|
+
|
|
65
|
+
return entry.client;
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/mcp-server.ts
CHANGED
|
@@ -187,6 +187,38 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
187
187
|
'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended) since a cursor, oldest first, each with the session id and name. Without a cursor it returns the most recent events. Pass the returned cursor next time. waitMs holds the call open until an event arrives.',
|
|
188
188
|
inputSchema: EVENTS_READ_INPUT,
|
|
189
189
|
},
|
|
190
|
+
{
|
|
191
|
+
name: 'atc_session_message',
|
|
192
|
+
description:
|
|
193
|
+
"Send a session a message and get its id back; poll atc_message_get with the id for its status and answer. The message waits in the session inbox until the session takes it, and its status moves accepted, delivered, answered. A message is refused as unsupported when the session's agent has no message tap (Grok, Codex), or when a Claude session reported SessionStart more than 15 seconds ago and no tap has attached since. It is refused as session_dead when the session has no live process and as no_such_session for an unknown id. Otherwise it queues, including while a session restores or after its tap dropped. The message is never typed into the terminal.",
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: 'object',
|
|
196
|
+
properties: {
|
|
197
|
+
session: { type: 'string', description: 'The atc session id, from atc_session_list' },
|
|
198
|
+
text: { type: 'string', description: 'The message text' },
|
|
199
|
+
from: {
|
|
200
|
+
type: 'string',
|
|
201
|
+
description:
|
|
202
|
+
'Who the message is from; defaults to the calling session id, or mcp outside a session',
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
required: ['session', 'text'],
|
|
206
|
+
additionalProperties: false,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: 'atc_message_get',
|
|
211
|
+
description:
|
|
212
|
+
'Read one message sent with atc_session_message: its id, session, from, text, status (accepted, delivered, or answered), the answer once answered, and the sentAt, deliveredAt, and answeredAt timestamps. Poll it until the status is answered.',
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
message: { type: 'string', description: 'The message id atc_session_message returned' },
|
|
217
|
+
},
|
|
218
|
+
required: ['message'],
|
|
219
|
+
additionalProperties: false,
|
|
220
|
+
},
|
|
221
|
+
},
|
|
190
222
|
];
|
|
191
223
|
|
|
192
224
|
/**
|
|
@@ -424,6 +456,30 @@ async function runTool(
|
|
|
424
456
|
|
|
425
457
|
return JSON.stringify(ok, null, 2);
|
|
426
458
|
}
|
|
459
|
+
case 'atc_session_message': {
|
|
460
|
+
const caller = process.env['ATC_SESSION_ID'];
|
|
461
|
+
const given = args['from'];
|
|
462
|
+
let from = 'mcp';
|
|
463
|
+
|
|
464
|
+
if (typeof given === 'string' && given !== '') {
|
|
465
|
+
from = given;
|
|
466
|
+
} else if (caller !== undefined && caller !== '') {
|
|
467
|
+
from = caller;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const ok = await client.sendRequest('session.message', {
|
|
471
|
+
session: args['session'],
|
|
472
|
+
text: args['text'],
|
|
473
|
+
from,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
return JSON.stringify(ok, null, 2);
|
|
477
|
+
}
|
|
478
|
+
case 'atc_message_get': {
|
|
479
|
+
const ok = await client.sendRequest('message.get', { message: args['message'] });
|
|
480
|
+
|
|
481
|
+
return JSON.stringify(ok, null, 2);
|
|
482
|
+
}
|
|
427
483
|
default: {
|
|
428
484
|
throw new Error(`unknown tool '${name}'`);
|
|
429
485
|
}
|
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { buildOptionalBoolean } from '../shared/build-optional-boolean';
|
|
3
3
|
import { buildOptionalString } from '../shared/build-optional-string';
|
|
4
4
|
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
5
|
+
import { toMessageID } from '../shared/to-message-id';
|
|
5
6
|
import { toSessionID } from '../shared/to-session-id';
|
|
6
7
|
|
|
7
8
|
const EJECT_DEFAULT_PROMPT =
|
|
@@ -97,6 +98,17 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
97
98
|
limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
|
|
98
99
|
waitMs: buildDefaultedNumber(0).transform((v) => Math.min(Math.max(Math.trunc(v), 0), 30_000)),
|
|
99
100
|
}),
|
|
101
|
+
'session.message': SESSION_DEFAULTED.extend({
|
|
102
|
+
from: buildDefaultedNonEmptyString('unknown'),
|
|
103
|
+
text: buildDefaultedString(''),
|
|
104
|
+
}).refine((v) => v.text !== '', { message: 'session.message requires text' }),
|
|
105
|
+
'session.tap': SESSION_DEFAULTED,
|
|
106
|
+
'message.get': z
|
|
107
|
+
.object({ message: buildDefaultedString('').transform(toMessageID) })
|
|
108
|
+
.refine((v) => v.message !== '', { message: 'message.get requires a message' }),
|
|
109
|
+
'message.ack': SESSION_DEFAULTED.extend({
|
|
110
|
+
message: buildDefaultedString('').transform(toMessageID),
|
|
111
|
+
}).refine((v) => v.message !== '', { message: 'message.ack requires a message' }),
|
|
100
112
|
} as const;
|
|
101
113
|
|
|
102
114
|
function buildDefaultedString(fallback: string) {
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { sendReport } from './shared/report';
|
|
2
|
+
import { REPORT_KINDS } from './shared/report-kinds';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runs inside wrangled sessions: reads the final text from stdin verbatim
|
|
6
|
+
* and forwards it to the atc socket as a Report envelope of the given kind
|
|
7
|
+
* for the given message. Always exits 0 so it never blocks the session it
|
|
8
|
+
* reports on.
|
|
9
|
+
*/
|
|
10
|
+
export async function runReport(kind: string, message: string): Promise<void> {
|
|
11
|
+
try {
|
|
12
|
+
const sock = process.env['ATC_SOCKET'];
|
|
13
|
+
const atcId = process.env['ATC_SESSION_ID'];
|
|
14
|
+
|
|
15
|
+
if (
|
|
16
|
+
sock !== undefined &&
|
|
17
|
+
sock !== '' &&
|
|
18
|
+
atcId !== undefined &&
|
|
19
|
+
atcId !== '' &&
|
|
20
|
+
REPORT_KINDS.some((known) => known === kind) &&
|
|
21
|
+
message !== ''
|
|
22
|
+
) {
|
|
23
|
+
const answer = await new Response(Bun.stdin.stream()).text();
|
|
24
|
+
|
|
25
|
+
const line = `${JSON.stringify({ atcId, event: 'Report', payload: { kind, message, answer } })}\n`;
|
|
26
|
+
|
|
27
|
+
await sendReport(sock, line, 2000);
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|