@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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +26 -0
  3. package/src/agents/claude-adapter.ts +6 -1
  4. package/src/agents/codex-adapter.ts +3 -1
  5. package/src/agents/gateway-adapter.ts +6 -0
  6. package/src/agents/grok-adapter.ts +3 -1
  7. package/src/agents/parse-claude-transcript-line.ts +107 -0
  8. package/src/cli.ts +46 -0
  9. package/src/client/daemon-client.ts +13 -1
  10. package/src/daemon/answer-byte-cap.ts +1 -0
  11. package/src/daemon/build-fleet-events.ts +35 -0
  12. package/src/daemon/build-session-message-event.ts +29 -0
  13. package/src/daemon/daemon-connection.ts +337 -0
  14. package/src/daemon/daemon.ts +333 -6
  15. package/src/daemon/event-signal.ts +54 -0
  16. package/src/daemon/hooks.ts +13 -4
  17. package/src/daemon/load-transcript-page.ts +101 -0
  18. package/src/daemon/mint-message-id.ts +12 -0
  19. package/src/daemon/parse-report.ts +35 -0
  20. package/src/daemon/session-runtime.ts +9 -0
  21. package/src/daemon/sessions.ts +51 -10
  22. package/src/daemon/tap-registry.ts +67 -0
  23. package/src/mcp-server.ts +164 -1
  24. package/src/protocol/decode-cursor.ts +29 -0
  25. package/src/protocol/encode-cursor.ts +12 -0
  26. package/src/protocol/request-param-schemas.ts +30 -0
  27. package/src/report.ts +32 -0
  28. package/src/shared/message-id.ts +7 -0
  29. package/src/shared/report-kinds.ts +3 -0
  30. package/src/shared/report.ts +18 -2
  31. package/src/shared/to-message-id.ts +11 -0
  32. package/src/shared/truncate-to-bytes.ts +21 -0
  33. package/src/store/fleet-entry.ts +20 -0
  34. package/src/store/message-owner.ts +9 -0
  35. package/src/store/message-record.ts +18 -0
  36. package/src/store/run-migrations.ts +94 -3
  37. package/src/store/state-store.ts +272 -4
  38. package/src/tap.ts +120 -0
@@ -1,5 +1,7 @@
1
1
  import { basename } from 'node:path';
2
2
  import type { AgentAdapter, AgentID, SpawnOptions } from '../agents/agent-adapter';
3
+ import { decodeCursor } from '../protocol/decode-cursor';
4
+ import { encodeCursor } from '../protocol/encode-cursor';
3
5
  import { OutboundQueue } from '../protocol/outbound-queue';
4
6
  import type { SocketWriter } from '../protocol/outbound-queue';
5
7
  import { parseRequestParams } from '../protocol/parse-request-params';
@@ -11,9 +13,13 @@ import {
11
13
  encodeMessage,
12
14
  } from '../protocol/protocol';
13
15
  import type { ErrorCode, EventMsg, RequestMsg } from '../protocol/protocol';
16
+ import type { MessageID } from '../shared/message-id';
14
17
  import type { SessionID } from '../shared/session-id';
15
18
  import type { FleetEntry } from '../store/fleet-entry';
19
+ import type { MessageRecord } from '../store/message-record';
16
20
  import type { Dims } from './attach-registry';
21
+ import type { FleetEvent } from './build-fleet-events';
22
+ import type { TranscriptPage, TranscriptPosition } from './load-transcript-page';
17
23
  import type { AnswerResult } from './permission-registry';
18
24
  import type { ScreenText } from './screen-model';
19
25
  import type { SessionDescriptor } from './sessions';
@@ -30,6 +36,21 @@ interface SpawnParams {
30
36
  readonly parent: SessionID | null;
31
37
  }
32
38
 
39
+ interface SessionRecord {
40
+ readonly session: SessionDescriptor;
41
+ readonly prompt: string | null;
42
+ readonly lastActivityAt: number;
43
+ readonly pending: { readonly message: string } | null;
44
+ readonly result: string | null;
45
+ }
46
+
47
+ // A transcript page with the file it came from, so a cursor into a replaced
48
+ // transcript is distinguishable from one into a grown transcript.
49
+ interface SessionTranscriptRead {
50
+ readonly path: string;
51
+ readonly page: TranscriptPage;
52
+ }
53
+
33
54
  export interface DaemonContext {
34
55
  readonly build: string;
35
56
  readonly collectSessions: () => SessionDescriptor[];
@@ -70,6 +91,29 @@ export interface DaemonContext {
70
91
  readonly resyncClient: (sessionID: SessionID, client: OutputClient) => Promise<void>;
71
92
  readonly queueBytes?: number;
72
93
  readonly getEffectiveDims: (sessionID: SessionID) => Dims;
94
+ readonly readSessionRecord: (id: SessionID) => Promise<SessionRecord | 'missing'>;
95
+ readonly loadSessionTranscript: (
96
+ id: SessionID,
97
+ from: TranscriptPosition | null,
98
+ limit: number,
99
+ ) => Promise<SessionTranscriptRead | 'missing' | 'unsupported'>;
100
+ readonly readEvents: (
101
+ afterID: number | null,
102
+ limit: number,
103
+ waitMs: number,
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'>;
73
117
  }
74
118
 
75
119
  // The slice of a connection the attach bookkeeping needs: identity plus the
@@ -78,6 +122,18 @@ export interface OutputClient {
78
122
  readonly sendOutput: (sessionID: SessionID, event: EventMsg, byteLength: number) => void;
79
123
  }
80
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
+
81
137
  interface PeerSocket extends SocketWriter {
82
138
  readonly end: () => void;
83
139
  }
@@ -91,6 +147,8 @@ export class DaemonConnection {
91
147
 
92
148
  private buffer = '';
93
149
 
150
+ private readonly decoder = new TextDecoder();
151
+
94
152
  private helloed = false;
95
153
 
96
154
  // The handshake answer is written before any other response on this
@@ -135,6 +193,13 @@ export class DaemonConnection {
135
193
  }
136
194
  }
137
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
+
138
203
  applyChunk(chunk: string): void {
139
204
  const buffered = this.buffer + chunk;
140
205
 
@@ -433,6 +498,26 @@ export class DaemonConnection {
433
498
 
434
499
  return;
435
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
+ }
436
521
  case 'fleet.restore': {
437
522
  const parsed = parseRequestParams('fleet.restore', req.p);
438
523
 
@@ -448,6 +533,21 @@ export class DaemonConnection {
448
533
 
449
534
  return;
450
535
  }
536
+ case 'session.get': {
537
+ await this.applySessionGet(req);
538
+
539
+ return;
540
+ }
541
+ case 'session.read': {
542
+ await this.applySessionRead(req);
543
+
544
+ return;
545
+ }
546
+ case 'events.read': {
547
+ await this.applyEventsRead(req);
548
+
549
+ return;
550
+ }
451
551
  default: {
452
552
  this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
453
553
  }
@@ -631,6 +731,243 @@ export class DaemonConnection {
631
731
  }
632
732
  }
633
733
 
734
+ private async applySessionGet(req: RequestMsg): Promise<void> {
735
+ const parsed = parseRequestParams('session.get', req.p);
736
+
737
+ if (!parsed.ok) {
738
+ this.sendErr(req.id, 'bad_args', parsed.message);
739
+
740
+ return;
741
+ }
742
+
743
+ const id = parsed.data.session;
744
+
745
+ const record = await this.ctx.readSessionRecord(id);
746
+
747
+ if (record === 'missing') {
748
+ this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
749
+
750
+ return;
751
+ }
752
+
753
+ this.sendOk(req.id, { ...record });
754
+ }
755
+
756
+ private async applySessionRead(req: RequestMsg): Promise<void> {
757
+ const parsed = parseRequestParams('session.read', req.p);
758
+
759
+ if (!parsed.ok) {
760
+ this.sendErr(req.id, 'bad_args', parsed.message);
761
+
762
+ return;
763
+ }
764
+
765
+ const id = parsed.data.session;
766
+ let from: TranscriptPosition | null = null;
767
+
768
+ if (parsed.data.cursor !== undefined) {
769
+ const decoded = decodeCursor(parsed.data.cursor);
770
+
771
+ if (decoded === null || decoded.kind !== 'transcript') {
772
+ this.sendErr(req.id, 'bad_args', `'${parsed.data.cursor}' is not a session.read cursor`);
773
+
774
+ return;
775
+ }
776
+
777
+ from = { path: decoded.path, offset: decoded.offset };
778
+ }
779
+
780
+ const read = await this.ctx.loadSessionTranscript(id, from, parsed.data.limit);
781
+
782
+ if (read === 'missing') {
783
+ this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
784
+ } else if (read === 'unsupported') {
785
+ this.sendErr(
786
+ req.id,
787
+ 'unsupported',
788
+ `session '${id}' runs an agent whose transcript atc cannot read`,
789
+ );
790
+ } else {
791
+ this.sendOk(req.id, {
792
+ rows: read.page.rows,
793
+ cursor: encodeCursor({ kind: 'transcript', path: read.path, offset: read.page.offset }),
794
+ more: read.page.more,
795
+ });
796
+ }
797
+ }
798
+
799
+ private async applyEventsRead(req: RequestMsg): Promise<void> {
800
+ const parsed = parseRequestParams('events.read', req.p);
801
+
802
+ if (!parsed.ok) {
803
+ this.sendErr(req.id, 'bad_args', parsed.message);
804
+
805
+ return;
806
+ }
807
+
808
+ let afterID: number | null = null;
809
+
810
+ if (parsed.data.cursor !== undefined) {
811
+ const decoded = decodeCursor(parsed.data.cursor);
812
+
813
+ if (decoded === null || decoded.kind !== 'events') {
814
+ this.sendErr(req.id, 'bad_args', `'${parsed.data.cursor}' is not an events.read cursor`);
815
+
816
+ return;
817
+ }
818
+
819
+ afterID = decoded.id;
820
+ }
821
+
822
+ const events = await this.ctx.readEvents(afterID, parsed.data.limit, parsed.data.waitMs);
823
+
824
+ const last = events.at(-1);
825
+
826
+ // A cursor always comes back so a client can long-poll from an empty trail.
827
+ this.sendOk(req.id, {
828
+ events,
829
+ cursor: last === undefined ? encodeCursor({ kind: 'events', id: afterID ?? 0 }) : last.cursor,
830
+ });
831
+ }
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
+
634
971
  private async applySessionVerb(
635
972
  req: RequestMsg,
636
973
  method: 'session.kill' | 'session.ack',