@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/src/daemon/daemon.ts
CHANGED
|
@@ -1,26 +1,38 @@
|
|
|
1
1
|
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import type { AgentAdapter } from '../agents/agent-adapter';
|
|
2
|
+
import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
|
|
3
3
|
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';
|
|
13
|
+
import { buildFleetEvents } from './build-fleet-events';
|
|
9
14
|
import { buildSessionEvent } from './build-session-event';
|
|
15
|
+
import { buildSessionMessageEvent } from './build-session-message-event';
|
|
10
16
|
import { DaemonConnection } from './daemon-connection';
|
|
11
|
-
import type { DaemonContext, OutputClient } from './daemon-connection';
|
|
17
|
+
import type { DaemonContext, OutputClient, TapClient } from './daemon-connection';
|
|
18
|
+
import { EventSignal } from './event-signal';
|
|
12
19
|
import { startHookServer } from './hooks';
|
|
20
|
+
import type { HookEvent } from './hooks';
|
|
21
|
+
import { loadTranscriptPage } from './load-transcript-page';
|
|
13
22
|
import { makeHookRunner } from './make-hook-runner';
|
|
14
23
|
import type { HookScope } from './make-hook-runner';
|
|
24
|
+
import { mintMessageID } from './mint-message-id';
|
|
25
|
+
import { parseReport } from './parse-report';
|
|
15
26
|
import { PermissionRegistry } from './permission-registry';
|
|
16
27
|
import { restoreFleet } from './restore-fleet';
|
|
17
28
|
import { runEjectHandoff } from './run-eject-handoff';
|
|
18
29
|
import { ScreenModel } from './screen-model';
|
|
19
30
|
import { SessionRuntime } from './session-runtime';
|
|
20
31
|
import { SessionManager } from './sessions';
|
|
21
|
-
import type { SessionDescriptor, SessionState } from './sessions';
|
|
32
|
+
import type { Session, SessionDescriptor, SessionState } from './sessions';
|
|
22
33
|
import { startEventsServer } from './start-events-server';
|
|
23
34
|
import { startHeadlessTurn } from './start-headless-turn';
|
|
35
|
+
import { TapRegistry } from './tap-registry';
|
|
24
36
|
|
|
25
37
|
export interface DaemonOptions {
|
|
26
38
|
readonly socketPath: string;
|
|
@@ -67,6 +79,7 @@ export interface DaemonOptions {
|
|
|
67
79
|
// single revive waits for that signal before moving on regardless, so a
|
|
68
80
|
// session that never reports cannot stall the rest. Zero waits forever.
|
|
69
81
|
readonly restoreBootTimeoutMs?: number;
|
|
82
|
+
readonly tapGraceMs?: number;
|
|
70
83
|
|
|
71
84
|
// Called after a client-requested quit has stopped the daemon; the real
|
|
72
85
|
// entrypoint exits the process, tests leave it unset.
|
|
@@ -77,6 +90,10 @@ export interface DaemonHandle {
|
|
|
77
90
|
readonly stop: () => Promise<void>;
|
|
78
91
|
}
|
|
79
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
|
+
|
|
80
97
|
/**
|
|
81
98
|
* The daemon: owns the sessions, the client-protocol listener, and the
|
|
82
99
|
* reporter listener. Protocol requests are NDJSON lines, one response per
|
|
@@ -130,6 +147,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
130
147
|
};
|
|
131
148
|
|
|
132
149
|
const registry = new PermissionRegistry();
|
|
150
|
+
const eventSignal = new EventSignal();
|
|
133
151
|
|
|
134
152
|
registry.onRequested = (req) => {
|
|
135
153
|
emitEvent(
|
|
@@ -159,6 +177,81 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
159
177
|
const findRuntime = (sessionID: SessionID) => runtimes.get(sessionID);
|
|
160
178
|
|
|
161
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
|
+
};
|
|
162
255
|
|
|
163
256
|
// The screen tier of the detector stack: once a session's output has
|
|
164
257
|
// quiesced, judge the serialized screen and flip running/needs_you.
|
|
@@ -369,6 +462,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
369
462
|
|
|
370
463
|
if (kind === 'removed') {
|
|
371
464
|
attachments.removeSession(s.id);
|
|
465
|
+
|
|
466
|
+
emitInboxClosed(taps.removeSession(s.id), s.id, 'removed');
|
|
372
467
|
runtimes.get(s.id)?.dispose();
|
|
373
468
|
runtimes.delete(s.id);
|
|
374
469
|
}
|
|
@@ -382,13 +477,33 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
382
477
|
}
|
|
383
478
|
};
|
|
384
479
|
|
|
480
|
+
const recordHookEvent = async (e: HookEvent, ev: Readonly<AdapterEvent> | null) => {
|
|
481
|
+
await store.recordEvent(e, ev);
|
|
482
|
+
|
|
483
|
+
eventSignal.emit();
|
|
484
|
+
};
|
|
485
|
+
|
|
385
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;
|
|
494
|
+
const ev = mgr.applyHook(e);
|
|
495
|
+
|
|
386
496
|
if (e.event !== 'Statusline') {
|
|
387
|
-
void
|
|
497
|
+
void recordHookEvent(e, ev);
|
|
388
498
|
}
|
|
389
499
|
|
|
390
|
-
const kind =
|
|
500
|
+
const kind = ev?.kind ?? null;
|
|
391
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
|
+
}
|
|
392
507
|
|
|
393
508
|
if (kind === 'ended') {
|
|
394
509
|
runtime?.pendingEject?.();
|
|
@@ -397,6 +512,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
397
512
|
// A revived session announcing itself is the cue a staggered restore
|
|
398
513
|
// waits on before booting the next one.
|
|
399
514
|
if (kind === 'started') {
|
|
515
|
+
if (runtime !== undefined) {
|
|
516
|
+
runtime.startedAt = Date.now();
|
|
517
|
+
}
|
|
518
|
+
|
|
400
519
|
runtime?.bootWaiter?.();
|
|
401
520
|
const started = mgr.sessions.find((s) => s.id === e.atcId);
|
|
402
521
|
|
|
@@ -522,6 +641,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
522
641
|
|
|
523
642
|
if (runtime !== undefined) {
|
|
524
643
|
runtime.dims = { cols, rows };
|
|
644
|
+
runtime.startedAt = null;
|
|
645
|
+
runtime.tapAttached = false;
|
|
525
646
|
|
|
526
647
|
runtime.screen ??= new ScreenModel(cols, rows);
|
|
527
648
|
}
|
|
@@ -636,6 +757,193 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
636
757
|
runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
|
|
637
758
|
restoreFleet: (cols, rows) =>
|
|
638
759
|
restoreFleet({ mgr, store, findRuntime, cols, rows, capMs: opts.restoreBootTimeoutMs ?? 0 }),
|
|
760
|
+
readSessionRecord: async (id) => {
|
|
761
|
+
const s = mgr.sessions.find((x) => x.id === id);
|
|
762
|
+
|
|
763
|
+
if (s === undefined) {
|
|
764
|
+
return 'missing';
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Every session-derived field is taken before the await: a kill or a
|
|
768
|
+
// hook may change the session meanwhile.
|
|
769
|
+
const session = getDescriptor(mgr, id);
|
|
770
|
+
const prompt = s.prompt ?? null;
|
|
771
|
+
const pending = s.state === 'needs_you' ? { message: s.lastMsg } : null;
|
|
772
|
+
const result = s.result ?? null;
|
|
773
|
+
const createdAt = s.createdAt;
|
|
774
|
+
|
|
775
|
+
const lastEventAt = await store.loadLastActivityAt(s.id, s.agentSessionID);
|
|
776
|
+
|
|
777
|
+
return {
|
|
778
|
+
session,
|
|
779
|
+
prompt,
|
|
780
|
+
lastActivityAt: lastEventAt ?? createdAt,
|
|
781
|
+
pending,
|
|
782
|
+
result,
|
|
783
|
+
};
|
|
784
|
+
},
|
|
785
|
+
loadSessionTranscript: async (id, from, limit) => {
|
|
786
|
+
const s = mgr.sessions.find((x) => x.id === id);
|
|
787
|
+
|
|
788
|
+
if (s === undefined) {
|
|
789
|
+
return 'missing';
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const parseLine = mgr.findAdapter(s.agent)?.parseTranscriptLine;
|
|
793
|
+
|
|
794
|
+
if (parseLine === undefined) {
|
|
795
|
+
return 'unsupported';
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const path = s.transcriptPath ?? '';
|
|
799
|
+
|
|
800
|
+
if (path === '') {
|
|
801
|
+
return { path, page: { rows: [], offset: 0, more: false } };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// A page stays far under the protocol's 1 MiB line cap.
|
|
805
|
+
const page = await loadTranscriptPage({ path, from, limit, maxBytes: 262_144, parseLine });
|
|
806
|
+
|
|
807
|
+
return { path, page };
|
|
808
|
+
},
|
|
809
|
+
readEvents: async (afterID, limit, waitMs) => {
|
|
810
|
+
const deadline = Date.now() + waitMs;
|
|
811
|
+
|
|
812
|
+
// A wake for an event the read leaves out (a heartbeat) loops back to
|
|
813
|
+
// wait out the rest of the window.
|
|
814
|
+
for (;;) {
|
|
815
|
+
const generation = eventSignal.generation;
|
|
816
|
+
|
|
817
|
+
const rows =
|
|
818
|
+
afterID === null
|
|
819
|
+
? await store.collectLatestEvents(limit)
|
|
820
|
+
: await store.collectEventsAfter(afterID, limit);
|
|
821
|
+
|
|
822
|
+
const remaining = deadline - Date.now();
|
|
823
|
+
|
|
824
|
+
if (rows.length > 0 || remaining <= 0 || eventSignal.disposed) {
|
|
825
|
+
return buildFleetEvents(rows, mgr.collectDescriptors());
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
await eventSignal.waitForNext(generation, remaining);
|
|
829
|
+
}
|
|
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
|
+
},
|
|
639
947
|
};
|
|
640
948
|
|
|
641
949
|
try {
|
|
@@ -651,7 +959,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
651
959
|
clients.add(socket.data);
|
|
652
960
|
},
|
|
653
961
|
data(socket, buf) {
|
|
654
|
-
socket.data.applyChunk(
|
|
962
|
+
socket.data.applyChunk(socket.data.decodeChunk(buf));
|
|
655
963
|
},
|
|
656
964
|
drain(socket) {
|
|
657
965
|
socket.data.drain();
|
|
@@ -659,6 +967,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
659
967
|
close(socket) {
|
|
660
968
|
clients.delete(socket.data);
|
|
661
969
|
ctx.detachClient(socket.data);
|
|
970
|
+
taps.detachAll(socket.data);
|
|
662
971
|
},
|
|
663
972
|
error() {},
|
|
664
973
|
},
|
|
@@ -675,6 +984,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
675
984
|
}
|
|
676
985
|
|
|
677
986
|
runtimes.clear();
|
|
987
|
+
eventSignal.dispose();
|
|
678
988
|
|
|
679
989
|
await store.stop();
|
|
680
990
|
|
|
@@ -688,6 +998,23 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
688
998
|
return { stop: stopDaemon };
|
|
689
999
|
}
|
|
690
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
|
+
|
|
691
1018
|
function hasResumableTranscript(mgr: SessionManager, id: SessionID): boolean {
|
|
692
1019
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
693
1020
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wakes long-polling readers when the event trail grows. A reader notes the
|
|
3
|
+
* signal's count before it queries and waits from that count, so an event
|
|
4
|
+
* recorded between the query and the wait is never missed.
|
|
5
|
+
*/
|
|
6
|
+
export class EventSignal {
|
|
7
|
+
generation = 0;
|
|
8
|
+
|
|
9
|
+
disposed = false;
|
|
10
|
+
|
|
11
|
+
private readonly waiters = new Set<() => void>();
|
|
12
|
+
|
|
13
|
+
emit(): void {
|
|
14
|
+
this.generation += 1;
|
|
15
|
+
|
|
16
|
+
this.drainWaiters();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
waitForNext(since: number, timeoutMs: number): Promise<void> {
|
|
20
|
+
if (this.disposed || this.generation !== since || timeoutMs <= 0) {
|
|
21
|
+
return Promise.resolve();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const deferred = Promise.withResolvers<void>();
|
|
25
|
+
|
|
26
|
+
const onWake = () => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
|
|
29
|
+
this.waiters.delete(onWake);
|
|
30
|
+
deferred.resolve();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const timer = setTimeout(onWake, timeoutMs);
|
|
34
|
+
|
|
35
|
+
this.waiters.add(onWake);
|
|
36
|
+
|
|
37
|
+
return deferred.promise;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
dispose(): void {
|
|
41
|
+
this.disposed = true;
|
|
42
|
+
this.generation += 1;
|
|
43
|
+
|
|
44
|
+
this.drainWaiters();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private drainWaiters(): void {
|
|
48
|
+
for (const wake of this.waiters) {
|
|
49
|
+
wake();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.waiters.clear();
|
|
53
|
+
}
|
|
54
|
+
}
|
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,101 @@
|
|
|
1
|
+
import type { TranscriptRow } from '../agents/agent-adapter';
|
|
2
|
+
|
|
3
|
+
export interface TranscriptPosition {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly offset: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TranscriptPage {
|
|
9
|
+
readonly rows: TranscriptRow[];
|
|
10
|
+
readonly offset: number;
|
|
11
|
+
readonly more: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TranscriptPageRequest {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly from: TranscriptPosition | null;
|
|
17
|
+
readonly limit: number;
|
|
18
|
+
readonly maxBytes: number;
|
|
19
|
+
readonly parseLine: (line: string) => TranscriptRow | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function loadTranscriptPage(req: TranscriptPageRequest): Promise<TranscriptPage> {
|
|
23
|
+
const file = Bun.file(req.path);
|
|
24
|
+
|
|
25
|
+
if (!(await file.exists())) {
|
|
26
|
+
return {
|
|
27
|
+
rows: [],
|
|
28
|
+
offset: req.from?.path === req.path ? req.from.offset : 0,
|
|
29
|
+
more: false,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const size = file.size;
|
|
34
|
+
|
|
35
|
+
// One read window; a line longer than this is skipped unparsed.
|
|
36
|
+
const windowBytes = 1_048_576;
|
|
37
|
+
|
|
38
|
+
// A cursor from another file, or past the end of a replaced one, starts over.
|
|
39
|
+
let offset =
|
|
40
|
+
req.from !== null && req.from.path === req.path && req.from.offset <= size
|
|
41
|
+
? req.from.offset
|
|
42
|
+
: 0;
|
|
43
|
+
|
|
44
|
+
const rows: TranscriptRow[] = [];
|
|
45
|
+
|
|
46
|
+
const decoder = new TextDecoder();
|
|
47
|
+
|
|
48
|
+
let bytes = 0;
|
|
49
|
+
let skipping = false;
|
|
50
|
+
|
|
51
|
+
while (offset < size) {
|
|
52
|
+
const chunk = await file.slice(offset, Math.min(offset + windowBytes, size)).bytes();
|
|
53
|
+
|
|
54
|
+
let lineStart = 0;
|
|
55
|
+
let newline = chunk.indexOf(0x0a, lineStart);
|
|
56
|
+
|
|
57
|
+
if (newline === -1) {
|
|
58
|
+
// A trailing partial line is left for the next read. A short read means the
|
|
59
|
+
// file shrank underneath us, so it ends the scan rather than skipping a line.
|
|
60
|
+
if (chunk.length < Math.min(windowBytes, size - offset) || offset + chunk.length >= size) {
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
offset += chunk.length;
|
|
65
|
+
skipping = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
while (newline !== -1) {
|
|
70
|
+
const row = skipping
|
|
71
|
+
? null
|
|
72
|
+
: req.parseLine(decoder.decode(chunk.subarray(lineStart, newline)));
|
|
73
|
+
|
|
74
|
+
skipping = false;
|
|
75
|
+
|
|
76
|
+
if (row !== null) {
|
|
77
|
+
const rowBytes = Buffer.byteLength(JSON.stringify(row));
|
|
78
|
+
|
|
79
|
+
if (rows.length > 0 && bytes + rowBytes > req.maxBytes) {
|
|
80
|
+
return { rows, offset: offset + lineStart, more: true };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
rows.push(row);
|
|
84
|
+
|
|
85
|
+
bytes += rowBytes;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
lineStart = newline + 1;
|
|
89
|
+
|
|
90
|
+
if (rows.length >= req.limit) {
|
|
91
|
+
return { rows, offset: offset + lineStart, more: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
newline = chunk.indexOf(0x0a, lineStart);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
offset += lineStart;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { rows, offset, more: false };
|
|
101
|
+
}
|
|
@@ -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
|
+
}
|