@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
|
@@ -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',
|