@zgeoff/atc 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +4 -0
  2. package/package.json +3 -1
  3. package/src/agents/agent-adapter.ts +11 -0
  4. package/src/agents/atc-bridge-files.ts +11 -0
  5. package/src/agents/build-cli-argv.ts +13 -0
  6. package/src/agents/build-cli-command.ts +4 -9
  7. package/src/agents/claude-adapter.ts +23 -2
  8. package/src/agents/codex-adapter.ts +2 -0
  9. package/src/agents/gateway-adapter.ts +23 -1
  10. package/src/agents/grok-adapter.ts +2 -0
  11. package/src/agents/write-atc-bridge.ts +42 -0
  12. package/src/agents/write-hook-settings.ts +2 -1
  13. package/src/cli.ts +51 -0
  14. package/src/client/daemon-client.ts +13 -1
  15. package/src/daemon/answer-byte-cap.ts +1 -0
  16. package/src/daemon/build-headless-env.ts +23 -0
  17. package/src/daemon/build-session-message-event.ts +29 -0
  18. package/src/daemon/build-session-report-event.ts +24 -0
  19. package/src/daemon/daemon-connection.ts +200 -0
  20. package/src/daemon/daemon.ts +264 -3
  21. package/src/daemon/hooks.ts +13 -4
  22. package/src/daemon/mint-message-id.ts +12 -0
  23. package/src/daemon/parse-report.ts +44 -0
  24. package/src/daemon/session-runtime.ts +9 -0
  25. package/src/daemon/start-headless-run.ts +10 -2
  26. package/src/daemon/start-headless-turn.ts +1 -0
  27. package/src/daemon/tap-registry.ts +67 -0
  28. package/src/mcp-server.ts +56 -0
  29. package/src/protocol/request-param-schemas.ts +12 -0
  30. package/src/report.ts +59 -0
  31. package/src/shared/message-id.ts +7 -0
  32. package/src/shared/report-kinds.ts +1 -0
  33. package/src/shared/report.ts +18 -2
  34. package/src/shared/to-message-id.ts +11 -0
  35. package/src/store/message-owner.ts +9 -0
  36. package/src/store/message-record.ts +18 -0
  37. package/src/store/run-migrations.ts +52 -2
  38. package/src/store/state-store.ts +144 -2
  39. package/src/tap.ts +120 -0
@@ -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
@@ -137,6 +165,13 @@ export class DaemonConnection {
137
165
  this.queue = new OutboundQueue(peer, ctx.queueBytes);
138
166
  }
139
167
 
168
+ /**
169
+ * Ends the connection. Safe to call more than once.
170
+ */
171
+ dispose(): void {
172
+ this.peer.end();
173
+ }
174
+
140
175
  sendEvent(event: EventMsg): void {
141
176
  if (this.helloed && !this.queue.send(encodeMessage(event))) {
142
177
  this.peer.end();
@@ -165,6 +200,13 @@ export class DaemonConnection {
165
200
  }
166
201
  }
167
202
 
203
+ // Decodes with state kept across reads, so a multi-byte character split
204
+ // between two reads decodes whole.
205
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
206
+ decodeChunk(buf: Uint8Array): string {
207
+ return this.decoder.decode(buf, { stream: true });
208
+ }
209
+
168
210
  applyChunk(chunk: string): void {
169
211
  const buffered = this.buffer + chunk;
170
212
 
@@ -463,6 +505,26 @@ export class DaemonConnection {
463
505
 
464
506
  return;
465
507
  }
508
+ case 'session.message': {
509
+ await this.applySessionMessage(req);
510
+
511
+ return;
512
+ }
513
+ case 'session.tap': {
514
+ this.applyTap(req);
515
+
516
+ return;
517
+ }
518
+ case 'message.get': {
519
+ await this.applyMessageGet(req);
520
+
521
+ return;
522
+ }
523
+ case 'message.ack': {
524
+ await this.applyMessageAck(req);
525
+
526
+ return;
527
+ }
466
528
  case 'fleet.restore': {
467
529
  const parsed = parseRequestParams('fleet.restore', req.p);
468
530
 
@@ -775,6 +837,144 @@ export class DaemonConnection {
775
837
  });
776
838
  }
777
839
 
840
+ private async applySessionMessage(req: RequestMsg): Promise<void> {
841
+ const parsed = parseRequestParams('session.message', req.p);
842
+
843
+ if (!parsed.ok) {
844
+ this.sendErr(req.id, 'bad_args', parsed.message);
845
+
846
+ return;
847
+ }
848
+
849
+ const sessionID = parsed.data.session;
850
+
851
+ const result = await this.ctx.writeSessionMessage(
852
+ sessionID,
853
+ parsed.data.from,
854
+ parsed.data.text,
855
+ );
856
+
857
+ if (result === 'missing') {
858
+ this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
859
+
860
+ return;
861
+ }
862
+
863
+ if (result === 'dead') {
864
+ this.sendErr(req.id, 'session_dead', `session '${sessionID}' has no live process`);
865
+
866
+ return;
867
+ }
868
+
869
+ if (result === 'unsupported') {
870
+ this.sendErr(
871
+ req.id,
872
+ 'unsupported',
873
+ `session '${sessionID}' cannot take messages: its agent has no message tap`,
874
+ );
875
+
876
+ return;
877
+ }
878
+
879
+ if (result === 'no_tap') {
880
+ this.sendErr(req.id, 'unsupported', `session '${sessionID}' never attached a message tap`);
881
+
882
+ return;
883
+ }
884
+
885
+ this.sendOk(req.id, { message: result.id, status: result.status });
886
+ }
887
+
888
+ private applyTap(req: RequestMsg): void {
889
+ const parsed = parseRequestParams('session.tap', req.p);
890
+
891
+ if (!parsed.ok) {
892
+ this.sendErr(req.id, 'bad_args', parsed.message);
893
+
894
+ return;
895
+ }
896
+
897
+ const sessionID = parsed.data.session;
898
+ const result = this.ctx.attachTap(this, sessionID);
899
+
900
+ if (result === 'missing') {
901
+ this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
902
+
903
+ return;
904
+ }
905
+
906
+ if (result === 'unsupported') {
907
+ this.sendErr(req.id, 'unsupported', `session '${sessionID}' cannot take messages`);
908
+
909
+ return;
910
+ }
911
+
912
+ this.sendOk(req.id, {});
913
+ }
914
+
915
+ private async applyMessageGet(req: RequestMsg): Promise<void> {
916
+ const parsed = parseRequestParams('message.get', req.p);
917
+
918
+ if (!parsed.ok) {
919
+ this.sendErr(req.id, 'bad_args', parsed.message);
920
+
921
+ return;
922
+ }
923
+
924
+ const messageID = parsed.data.message;
925
+
926
+ const view = await this.ctx.readMessage(messageID);
927
+
928
+ if (view === null) {
929
+ this.sendErr(req.id, 'bad_args', `no message '${messageID}'`);
930
+
931
+ return;
932
+ }
933
+
934
+ const record = view.record;
935
+
936
+ this.sendOk(req.id, {
937
+ message: record.id,
938
+ session: view.session,
939
+ from: record.from,
940
+ text: record.text,
941
+ status: record.status,
942
+ sentAt: record.sentAt,
943
+ ...(record.deliveredAt === undefined ? {} : { deliveredAt: record.deliveredAt }),
944
+ ...(record.answeredAt === undefined ? {} : { answeredAt: record.answeredAt }),
945
+ ...(record.answer === undefined ? {} : { answer: record.answer }),
946
+ });
947
+ }
948
+
949
+ private async applyMessageAck(req: RequestMsg): Promise<void> {
950
+ const parsed = parseRequestParams('message.ack', req.p);
951
+
952
+ if (!parsed.ok) {
953
+ this.sendErr(req.id, 'bad_args', parsed.message);
954
+
955
+ return;
956
+ }
957
+
958
+ const sessionID = parsed.data.session;
959
+ const messageID = parsed.data.message;
960
+
961
+ const result = await this.ctx.ackMessage(this, sessionID, messageID);
962
+
963
+ if (result === 'not_tapping') {
964
+ this.sendErr(req.id, 'bad_args', `this connection is not tapping session '${sessionID}'`);
965
+
966
+ return;
967
+ }
968
+
969
+ if (result === 'unknown') {
970
+ this.sendErr(req.id, 'bad_args', `no message '${messageID}' for session '${sessionID}'`);
971
+
972
+ return;
973
+ }
974
+
975
+ this.sendOk(req.id, { message: result.id, status: result.status });
976
+ }
977
+
778
978
  private async applySessionVerb(
779
979
  req: RequestMsg,
780
980
  method: 'session.kill' | 'session.ack',
@@ -4,27 +4,36 @@ 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';
16
+ import { buildSessionReportEvent } from './build-session-report-event';
11
17
  import { DaemonConnection } from './daemon-connection';
12
- import type { DaemonContext, OutputClient } from './daemon-connection';
18
+ import type { DaemonContext, OutputClient, TapClient } from './daemon-connection';
13
19
  import { EventSignal } from './event-signal';
14
20
  import { startHookServer } from './hooks';
15
21
  import type { HookEvent } from './hooks';
16
22
  import { loadTranscriptPage } from './load-transcript-page';
17
23
  import { makeHookRunner } from './make-hook-runner';
18
24
  import type { HookScope } from './make-hook-runner';
25
+ import { mintMessageID } from './mint-message-id';
26
+ import { parseReport } from './parse-report';
19
27
  import { PermissionRegistry } from './permission-registry';
20
28
  import { restoreFleet } from './restore-fleet';
21
29
  import { runEjectHandoff } from './run-eject-handoff';
22
30
  import { ScreenModel } from './screen-model';
23
31
  import { SessionRuntime } from './session-runtime';
24
32
  import { SessionManager } from './sessions';
25
- import type { SessionDescriptor, SessionState } from './sessions';
33
+ import type { Session, SessionDescriptor, SessionState } from './sessions';
26
34
  import { startEventsServer } from './start-events-server';
27
35
  import { startHeadlessTurn } from './start-headless-turn';
36
+ import { TapRegistry } from './tap-registry';
28
37
 
29
38
  export interface DaemonOptions {
30
39
  readonly socketPath: string;
@@ -71,6 +80,7 @@ export interface DaemonOptions {
71
80
  // single revive waits for that signal before moving on regardless, so a
72
81
  // session that never reports cannot stall the rest. Zero waits forever.
73
82
  readonly restoreBootTimeoutMs?: number;
83
+ readonly tapGraceMs?: number;
74
84
 
75
85
  // Called after a client-requested quit has stopped the daemon; the real
76
86
  // entrypoint exits the process, tests leave it unset.
@@ -81,6 +91,10 @@ export interface DaemonHandle {
81
91
  readonly stop: () => Promise<void>;
82
92
  }
83
93
 
94
+ // How long a started Claude session may go without a tap before a message to
95
+ // it is refused.
96
+ const TAP_GRACE_MS = 15_000;
97
+
84
98
  /**
85
99
  * The daemon: owns the sessions, the client-protocol listener, and the
86
100
  * reporter listener. Protocol requests are NDJSON lines, one response per
@@ -164,6 +178,93 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
164
178
  const findRuntime = (sessionID: SessionID) => runtimes.get(sessionID);
165
179
 
166
180
  const attachments = new AttachRegistry<OutputClient>();
181
+ const taps = new TapRegistry<TapClient>();
182
+
183
+ // Writes accepted messages one at a time so the store's insertion order is
184
+ // the order of their sent times, which the inbox drains by.
185
+ let lastMessageWrite: Promise<void> = Promise.resolve();
186
+
187
+ // Hands one pending message to the session's tap, once, and reports whether
188
+ // it did. The event goes to the tap connection alone: it never reaches
189
+ // other clients, the events socket, or hooks.
190
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- every field is readonly; the branded id has no readonly form to wrap it in
191
+ const sendInboxMessage = (sessionID: SessionID, record: MessageRecord): boolean => {
192
+ const tap = taps.claimDelivery(sessionID, record.id);
193
+
194
+ if (tap === null) {
195
+ return false;
196
+ }
197
+
198
+ tap.sendEvent({
199
+ v: PROTOCOL_V,
200
+ ev: 'InboxMessage',
201
+ s: sessionID,
202
+ message: record.id,
203
+ from: record.from,
204
+ text: record.text,
205
+ sentAt: record.sentAt,
206
+ });
207
+
208
+ return true;
209
+ };
210
+
211
+ // Reads the backlog from the store before sending anything, so a tap's
212
+ // ok response is always queued ahead of its first message. It sends one
213
+ // unclaimed message per call and the tap's ack calls it again, so the
214
+ // backlog never outgrows the connection's outbound queue.
215
+ const drainInbox = async (sessionID: SessionID) => {
216
+ const s = mgr.sessions.find((x) => x.id === sessionID);
217
+
218
+ if (s === undefined) {
219
+ return;
220
+ }
221
+
222
+ try {
223
+ const pending = await store.collectPendingMessages(buildMessageOwner(s));
224
+
225
+ for (const record of pending) {
226
+ if (sendInboxMessage(sessionID, record)) {
227
+ return;
228
+ }
229
+ }
230
+ } catch {}
231
+ };
232
+
233
+ const applyReport = async (e: HookEvent) => {
234
+ const report = parseReport(e.payload);
235
+
236
+ if (report === null) {
237
+ return;
238
+ }
239
+
240
+ if (report.kind === 'note') {
241
+ const sender = mgr.sessions.find((x) => x.id === e.atcId);
242
+
243
+ if (sender !== undefined) {
244
+ const capped = { ...report, text: truncateToBytes(report.text, ANSWER_BYTE_CAP) };
245
+
246
+ emitEvent(buildSessionReportEvent(sender.id, capped, Date.now()), findHookScope(sender.id));
247
+ }
248
+
249
+ return;
250
+ }
251
+
252
+ const s = mgr.sessions.find((x) => x.id === e.atcId);
253
+ const owner = s === undefined ? { atcID: e.atcId } : buildMessageOwner(s);
254
+
255
+ try {
256
+ const answered = await store.updateMessageAnswered(
257
+ report.message,
258
+ owner,
259
+ truncateToBytes(report.answer, ANSWER_BYTE_CAP),
260
+ Date.now(),
261
+ );
262
+
263
+ if (answered !== null) {
264
+ emitEvent(buildSessionMessageEvent(e.atcId, answered), findHookScope(e.atcId));
265
+ }
266
+ } catch {}
267
+ };
167
268
 
168
269
  // The screen tier of the detector stack: once a session's output has
169
270
  // quiesced, judge the serialized screen and flip running/needs_you.
@@ -374,6 +475,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
374
475
 
375
476
  if (kind === 'removed') {
376
477
  attachments.removeSession(s.id);
478
+
479
+ emitInboxClosed(taps.removeSession(s.id), s.id, 'removed');
377
480
  runtimes.get(s.id)?.dispose();
378
481
  runtimes.delete(s.id);
379
482
  }
@@ -394,6 +497,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
394
497
  };
395
498
 
396
499
  const reporter = startHookServer((e) => {
500
+ if (e.event === 'Report') {
501
+ void applyReport(e);
502
+
503
+ return;
504
+ }
505
+
506
+ const previousAgentSessionID = mgr.sessions.find((s) => s.id === e.atcId)?.agentSessionID;
397
507
  const ev = mgr.applyHook(e);
398
508
 
399
509
  if (e.event !== 'Statusline') {
@@ -402,6 +512,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
402
512
 
403
513
  const kind = ev?.kind ?? null;
404
514
  const runtime = runtimes.get(e.atcId);
515
+ const currentAgentSessionID = mgr.sessions.find((s) => s.id === e.atcId)?.agentSessionID;
516
+
517
+ if (currentAgentSessionID !== undefined && currentAgentSessionID !== previousAgentSessionID) {
518
+ void store.updateMessageOwner(e.atcId, previousAgentSessionID, currentAgentSessionID);
519
+ }
405
520
 
406
521
  if (kind === 'ended') {
407
522
  runtime?.pendingEject?.();
@@ -410,6 +525,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
410
525
  // A revived session announcing itself is the cue a staggered restore
411
526
  // waits on before booting the next one.
412
527
  if (kind === 'started') {
528
+ if (runtime !== undefined) {
529
+ runtime.startedAt = Date.now();
530
+ }
531
+
413
532
  runtime?.bootWaiter?.();
414
533
  const started = mgr.sessions.find((s) => s.id === e.atcId);
415
534
 
@@ -535,6 +654,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
535
654
 
536
655
  if (runtime !== undefined) {
537
656
  runtime.dims = { cols, rows };
657
+ runtime.startedAt = null;
658
+ runtime.tapAttached = false;
538
659
 
539
660
  runtime.screen ??= new ScreenModel(cols, rows);
540
661
  }
@@ -720,6 +841,122 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
720
841
  await eventSignal.waitForNext(generation, remaining);
721
842
  }
722
843
  },
844
+ writeSessionMessage: async (sessionID, from, text) => {
845
+ const s = mgr.sessions.find((x) => x.id === sessionID);
846
+
847
+ if (s === undefined) {
848
+ return 'missing';
849
+ }
850
+
851
+ if (!(s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'))) {
852
+ return 'dead';
853
+ }
854
+
855
+ if (mgr.findAdapter(s.agent)?.takesMessages !== true) {
856
+ return 'unsupported';
857
+ }
858
+
859
+ const runtime = runtimes.get(sessionID);
860
+
861
+ if (
862
+ runtime !== undefined &&
863
+ runtime.startedAt !== null &&
864
+ !runtime.tapAttached &&
865
+ Date.now() - runtime.startedAt >= (opts.tapGraceMs ?? TAP_GRACE_MS)
866
+ ) {
867
+ return 'no_tap';
868
+ }
869
+
870
+ const previousWrite = lastMessageWrite;
871
+ const written = Promise.withResolvers<void>();
872
+
873
+ lastMessageWrite = written.promise;
874
+
875
+ await previousWrite;
876
+
877
+ const record: MessageRecord = {
878
+ id: mintMessageID(),
879
+ atcID: sessionID,
880
+ ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
881
+ from,
882
+ text,
883
+ status: 'accepted',
884
+ sentAt: Date.now(),
885
+ };
886
+
887
+ try {
888
+ await store.writeMessage(record);
889
+ } finally {
890
+ written.resolve();
891
+ }
892
+
893
+ emitEvent(buildSessionMessageEvent(sessionID, record), findHookScope(sessionID));
894
+
895
+ await drainInbox(sessionID);
896
+
897
+ return record;
898
+ },
899
+ attachTap: (client, sessionID) => {
900
+ const s = mgr.sessions.find((x) => x.id === sessionID);
901
+
902
+ if (s === undefined) {
903
+ return 'missing';
904
+ }
905
+
906
+ if (mgr.findAdapter(s.agent)?.takesMessages !== true) {
907
+ return 'unsupported';
908
+ }
909
+
910
+ emitInboxClosed(taps.attach(sessionID, client), sessionID, 'replaced');
911
+
912
+ const runtime = runtimes.get(sessionID);
913
+
914
+ if (runtime !== undefined) {
915
+ runtime.tapAttached = true;
916
+ }
917
+
918
+ void drainInbox(sessionID);
919
+
920
+ return 'ok';
921
+ },
922
+ readMessage: async (messageID) => {
923
+ const record = await store.findMessageByID(messageID);
924
+
925
+ if (record === null) {
926
+ return null;
927
+ }
928
+
929
+ const owner = mgr.sessions.find(
930
+ (x) =>
931
+ x.id === record.atcID ||
932
+ (record.agentSessionID !== undefined && x.agentSessionID === record.agentSessionID),
933
+ );
934
+
935
+ return { session: owner?.id ?? record.atcID, record };
936
+ },
937
+ ackMessage: async (client, sessionID, messageID) => {
938
+ const s = mgr.sessions.find((x) => x.id === sessionID);
939
+
940
+ if (s === undefined || !taps.isTap(sessionID, client)) {
941
+ return 'not_tapping';
942
+ }
943
+
944
+ const owner = buildMessageOwner(s);
945
+
946
+ const delivered = await store.updateMessageDelivered(messageID, owner, Date.now());
947
+
948
+ void drainInbox(sessionID);
949
+
950
+ if (delivered !== null) {
951
+ emitEvent(buildSessionMessageEvent(sessionID, delivered), findHookScope(sessionID));
952
+
953
+ return delivered;
954
+ }
955
+
956
+ const current = await store.findMessage(messageID, owner);
957
+
958
+ return current ?? 'unknown';
959
+ },
723
960
  };
724
961
 
725
962
  try {
@@ -735,7 +972,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
735
972
  clients.add(socket.data);
736
973
  },
737
974
  data(socket, buf) {
738
- socket.data.applyChunk(buf.toString());
975
+ socket.data.applyChunk(socket.data.decodeChunk(buf));
739
976
  },
740
977
  drain(socket) {
741
978
  socket.data.drain();
@@ -743,12 +980,19 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
743
980
  close(socket) {
744
981
  clients.delete(socket.data);
745
982
  ctx.detachClient(socket.data);
983
+ taps.detachAll(socket.data);
746
984
  },
747
985
  error() {},
748
986
  },
749
987
  });
750
988
 
751
989
  stopDaemon = async () => {
990
+ // Ends each client itself so every peer sees the close: a stopped
991
+ // listener does not reliably end the connections it already accepted.
992
+ for (const client of clients) {
993
+ client.dispose();
994
+ }
995
+
752
996
  server.stop(true);
753
997
  eventsServer?.stop();
754
998
  reporter.stop(true);
@@ -773,6 +1017,23 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
773
1017
  return { stop: stopDaemon };
774
1018
  }
775
1019
 
1020
+ // Tells a tap its subscription is over so the `atc tap` process behind it
1021
+ // exits instead of idling on a session it no longer serves.
1022
+ function emitInboxClosed(
1023
+ tap: TapClient | null,
1024
+ sessionID: SessionID,
1025
+ reason: 'replaced' | 'removed',
1026
+ ): void {
1027
+ tap?.sendEvent({ v: PROTOCOL_V, ev: 'InboxClosed', s: sessionID, reason });
1028
+ }
1029
+
1030
+ function buildMessageOwner(s: Session): MessageOwner {
1031
+ return {
1032
+ atcID: s.id,
1033
+ ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
1034
+ };
1035
+ }
1036
+
776
1037
  function hasResumableTranscript(mgr: SessionManager, id: SessionID): boolean {
777
1038
  const s = mgr.sessions.find((x) => x.id === id);
778
1039
 
@@ -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<string>({
26
+ return Bun.listen<HookConnection>({
19
27
  unix: path,
20
28
  socket: {
21
29
  data(socket, buf) {
22
- const buffered = (socket.data ?? '') + buf.toString();
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
- socket.data = lines.pop() ?? '';
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
+ }