@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.
- package/README.md +4 -0
- package/package.json +3 -1
- package/src/agents/agent-adapter.ts +11 -0
- package/src/agents/atc-bridge-files.ts +11 -0
- package/src/agents/build-cli-argv.ts +13 -0
- package/src/agents/build-cli-command.ts +4 -9
- package/src/agents/claude-adapter.ts +23 -2
- package/src/agents/codex-adapter.ts +2 -0
- package/src/agents/gateway-adapter.ts +23 -1
- package/src/agents/grok-adapter.ts +2 -0
- package/src/agents/write-atc-bridge.ts +42 -0
- package/src/agents/write-hook-settings.ts +2 -1
- package/src/cli.ts +51 -0
- package/src/client/daemon-client.ts +13 -1
- package/src/daemon/answer-byte-cap.ts +1 -0
- package/src/daemon/build-headless-env.ts +23 -0
- package/src/daemon/build-session-message-event.ts +29 -0
- package/src/daemon/build-session-report-event.ts +24 -0
- package/src/daemon/daemon-connection.ts +200 -0
- package/src/daemon/daemon.ts +264 -3
- package/src/daemon/hooks.ts +13 -4
- package/src/daemon/mint-message-id.ts +12 -0
- package/src/daemon/parse-report.ts +44 -0
- package/src/daemon/session-runtime.ts +9 -0
- package/src/daemon/start-headless-run.ts +10 -2
- package/src/daemon/start-headless-turn.ts +1 -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 +59 -0
- package/src/shared/message-id.ts +7 -0
- package/src/shared/report-kinds.ts +1 -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
|
@@ -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',
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
+
}
|