@zgeoff/atc 2.6.0 → 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.
@@ -4,27 +4,35 @@ 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';
11
16
  import { DaemonConnection } from './daemon-connection';
12
- import type { DaemonContext, OutputClient } from './daemon-connection';
17
+ import type { DaemonContext, OutputClient, TapClient } from './daemon-connection';
13
18
  import { EventSignal } from './event-signal';
14
19
  import { startHookServer } from './hooks';
15
20
  import type { HookEvent } from './hooks';
16
21
  import { loadTranscriptPage } from './load-transcript-page';
17
22
  import { makeHookRunner } from './make-hook-runner';
18
23
  import type { HookScope } from './make-hook-runner';
24
+ import { mintMessageID } from './mint-message-id';
25
+ import { parseReport } from './parse-report';
19
26
  import { PermissionRegistry } from './permission-registry';
20
27
  import { restoreFleet } from './restore-fleet';
21
28
  import { runEjectHandoff } from './run-eject-handoff';
22
29
  import { ScreenModel } from './screen-model';
23
30
  import { SessionRuntime } from './session-runtime';
24
31
  import { SessionManager } from './sessions';
25
- import type { SessionDescriptor, SessionState } from './sessions';
32
+ import type { Session, SessionDescriptor, SessionState } from './sessions';
26
33
  import { startEventsServer } from './start-events-server';
27
34
  import { startHeadlessTurn } from './start-headless-turn';
35
+ import { TapRegistry } from './tap-registry';
28
36
 
29
37
  export interface DaemonOptions {
30
38
  readonly socketPath: string;
@@ -71,6 +79,7 @@ export interface DaemonOptions {
71
79
  // single revive waits for that signal before moving on regardless, so a
72
80
  // session that never reports cannot stall the rest. Zero waits forever.
73
81
  readonly restoreBootTimeoutMs?: number;
82
+ readonly tapGraceMs?: number;
74
83
 
75
84
  // Called after a client-requested quit has stopped the daemon; the real
76
85
  // entrypoint exits the process, tests leave it unset.
@@ -81,6 +90,10 @@ export interface DaemonHandle {
81
90
  readonly stop: () => Promise<void>;
82
91
  }
83
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
+
84
97
  /**
85
98
  * The daemon: owns the sessions, the client-protocol listener, and the
86
99
  * reporter listener. Protocol requests are NDJSON lines, one response per
@@ -164,6 +177,81 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
164
177
  const findRuntime = (sessionID: SessionID) => runtimes.get(sessionID);
165
178
 
166
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
+ };
167
255
 
168
256
  // The screen tier of the detector stack: once a session's output has
169
257
  // quiesced, judge the serialized screen and flip running/needs_you.
@@ -374,6 +462,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
374
462
 
375
463
  if (kind === 'removed') {
376
464
  attachments.removeSession(s.id);
465
+
466
+ emitInboxClosed(taps.removeSession(s.id), s.id, 'removed');
377
467
  runtimes.get(s.id)?.dispose();
378
468
  runtimes.delete(s.id);
379
469
  }
@@ -394,6 +484,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
394
484
  };
395
485
 
396
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;
397
494
  const ev = mgr.applyHook(e);
398
495
 
399
496
  if (e.event !== 'Statusline') {
@@ -402,6 +499,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
402
499
 
403
500
  const kind = ev?.kind ?? null;
404
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
+ }
405
507
 
406
508
  if (kind === 'ended') {
407
509
  runtime?.pendingEject?.();
@@ -410,6 +512,10 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
410
512
  // A revived session announcing itself is the cue a staggered restore
411
513
  // waits on before booting the next one.
412
514
  if (kind === 'started') {
515
+ if (runtime !== undefined) {
516
+ runtime.startedAt = Date.now();
517
+ }
518
+
413
519
  runtime?.bootWaiter?.();
414
520
  const started = mgr.sessions.find((s) => s.id === e.atcId);
415
521
 
@@ -535,6 +641,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
535
641
 
536
642
  if (runtime !== undefined) {
537
643
  runtime.dims = { cols, rows };
644
+ runtime.startedAt = null;
645
+ runtime.tapAttached = false;
538
646
 
539
647
  runtime.screen ??= new ScreenModel(cols, rows);
540
648
  }
@@ -720,6 +828,122 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
720
828
  await eventSignal.waitForNext(generation, remaining);
721
829
  }
722
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
+ },
723
947
  };
724
948
 
725
949
  try {
@@ -735,7 +959,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
735
959
  clients.add(socket.data);
736
960
  },
737
961
  data(socket, buf) {
738
- socket.data.applyChunk(buf.toString());
962
+ socket.data.applyChunk(socket.data.decodeChunk(buf));
739
963
  },
740
964
  drain(socket) {
741
965
  socket.data.drain();
@@ -743,6 +967,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
743
967
  close(socket) {
744
968
  clients.delete(socket.data);
745
969
  ctx.detachClient(socket.data);
970
+ taps.detachAll(socket.data);
746
971
  },
747
972
  error() {},
748
973
  },
@@ -773,6 +998,23 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
773
998
  return { stop: stopDaemon };
774
999
  }
775
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
+
776
1018
  function hasResumableTranscript(mgr: SessionManager, id: SessionID): boolean {
777
1019
  const s = mgr.sessions.find((x) => x.id === id);
778
1020
 
@@ -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
+ }
@@ -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
+ }
@@ -37,6 +37,15 @@ export class SessionRuntime {
37
37
  // clears it, and a spawn that never reports leaves it stranded harmlessly.
38
38
  pendingLastUsed = false;
39
39
 
40
+ // When the agent reported SessionStart since its terminal last booted, or
41
+ // null before it has. A message queues for a grace window after that, and
42
+ // beyond it only if a tap has attached since the boot.
43
+ startedAt: number | null = null;
44
+
45
+ // Whether a tap has attached since the terminal last booted. A tap that
46
+ // later dropped may be restarting, so it keeps the inbox open.
47
+ tapAttached = false;
48
+
40
49
  dispose(): void {
41
50
  clearTimeout(this.resizeTimer);
42
51
  clearTimeout(this.detectTimer);
@@ -0,0 +1,67 @@
1
+ import type { MessageID } from '../shared/message-id';
2
+ import type { SessionID } from '../shared/session-id';
3
+
4
+ interface TapEntry<TClient> {
5
+ readonly client: TClient;
6
+ readonly claimed: Set<MessageID>;
7
+ }
8
+
9
+ /**
10
+ * Which connection taps each session's inbox; one tap per session, the
11
+ * latest wins; each message is handed to a tap once.
12
+ */
13
+ export class TapRegistry<TClient> {
14
+ private readonly bySession = new Map<SessionID, TapEntry<TClient>>();
15
+
16
+ /**
17
+ * Makes the client the session's tap and returns the client it displaced,
18
+ * or null when there was none or it is the same connection.
19
+ */
20
+ attach(sessionID: SessionID, client: TClient): TClient | null {
21
+ const previous = this.bySession.get(sessionID)?.client ?? null;
22
+
23
+ this.bySession.set(sessionID, { client, claimed: new Set() });
24
+
25
+ return previous === client ? null : previous;
26
+ }
27
+
28
+ detachAll(client: TClient): void {
29
+ for (const [sessionID, entry] of this.bySession) {
30
+ if (entry.client === client) {
31
+ this.bySession.delete(sessionID);
32
+ }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Forgets the session's tap and returns its client, or null when the
38
+ * session had none.
39
+ */
40
+ removeSession(sessionID: SessionID): TClient | null {
41
+ const previous = this.bySession.get(sessionID)?.client ?? null;
42
+
43
+ this.bySession.delete(sessionID);
44
+
45
+ return previous;
46
+ }
47
+
48
+ hasTap(sessionID: SessionID): boolean {
49
+ return this.bySession.has(sessionID);
50
+ }
51
+
52
+ isTap(sessionID: SessionID, client: TClient): boolean {
53
+ return this.bySession.get(sessionID)?.client === client;
54
+ }
55
+
56
+ claimDelivery(sessionID: SessionID, messageID: MessageID): TClient | null {
57
+ const entry = this.bySession.get(sessionID);
58
+
59
+ if (entry === undefined || entry.claimed.has(messageID)) {
60
+ return null;
61
+ }
62
+
63
+ entry.claimed.add(messageID);
64
+
65
+ return entry.client;
66
+ }
67
+ }
package/src/mcp-server.ts CHANGED
@@ -187,6 +187,38 @@ const TOOLS: readonly MCPTool[] = [
187
187
  'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended) since a cursor, oldest first, each with the session id and name. Without a cursor it returns the most recent events. Pass the returned cursor next time. waitMs holds the call open until an event arrives.',
188
188
  inputSchema: EVENTS_READ_INPUT,
189
189
  },
190
+ {
191
+ name: 'atc_session_message',
192
+ description:
193
+ "Send a session a message and get its id back; poll atc_message_get with the id for its status and answer. The message waits in the session inbox until the session takes it, and its status moves accepted, delivered, answered. A message is refused as unsupported when the session's agent has no message tap (Grok, Codex), or when a Claude session reported SessionStart more than 15 seconds ago and no tap has attached since. It is refused as session_dead when the session has no live process and as no_such_session for an unknown id. Otherwise it queues, including while a session restores or after its tap dropped. The message is never typed into the terminal.",
194
+ inputSchema: {
195
+ type: 'object',
196
+ properties: {
197
+ session: { type: 'string', description: 'The atc session id, from atc_session_list' },
198
+ text: { type: 'string', description: 'The message text' },
199
+ from: {
200
+ type: 'string',
201
+ description:
202
+ 'Who the message is from; defaults to the calling session id, or mcp outside a session',
203
+ },
204
+ },
205
+ required: ['session', 'text'],
206
+ additionalProperties: false,
207
+ },
208
+ },
209
+ {
210
+ name: 'atc_message_get',
211
+ description:
212
+ 'Read one message sent with atc_session_message: its id, session, from, text, status (accepted, delivered, or answered), the answer once answered, and the sentAt, deliveredAt, and answeredAt timestamps. Poll it until the status is answered.',
213
+ inputSchema: {
214
+ type: 'object',
215
+ properties: {
216
+ message: { type: 'string', description: 'The message id atc_session_message returned' },
217
+ },
218
+ required: ['message'],
219
+ additionalProperties: false,
220
+ },
221
+ },
190
222
  ];
191
223
 
192
224
  /**
@@ -424,6 +456,30 @@ async function runTool(
424
456
 
425
457
  return JSON.stringify(ok, null, 2);
426
458
  }
459
+ case 'atc_session_message': {
460
+ const caller = process.env['ATC_SESSION_ID'];
461
+ const given = args['from'];
462
+ let from = 'mcp';
463
+
464
+ if (typeof given === 'string' && given !== '') {
465
+ from = given;
466
+ } else if (caller !== undefined && caller !== '') {
467
+ from = caller;
468
+ }
469
+
470
+ const ok = await client.sendRequest('session.message', {
471
+ session: args['session'],
472
+ text: args['text'],
473
+ from,
474
+ });
475
+
476
+ return JSON.stringify(ok, null, 2);
477
+ }
478
+ case 'atc_message_get': {
479
+ const ok = await client.sendRequest('message.get', { message: args['message'] });
480
+
481
+ return JSON.stringify(ok, null, 2);
482
+ }
427
483
  default: {
428
484
  throw new Error(`unknown tool '${name}'`);
429
485
  }
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { buildOptionalBoolean } from '../shared/build-optional-boolean';
3
3
  import { buildOptionalString } from '../shared/build-optional-string';
4
4
  import { toAgentSessionID } from '../shared/to-agent-session-id';
5
+ import { toMessageID } from '../shared/to-message-id';
5
6
  import { toSessionID } from '../shared/to-session-id';
6
7
 
7
8
  const EJECT_DEFAULT_PROMPT =
@@ -97,6 +98,17 @@ export const REQUEST_PARAM_SCHEMAS = {
97
98
  limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
98
99
  waitMs: buildDefaultedNumber(0).transform((v) => Math.min(Math.max(Math.trunc(v), 0), 30_000)),
99
100
  }),
101
+ 'session.message': SESSION_DEFAULTED.extend({
102
+ from: buildDefaultedNonEmptyString('unknown'),
103
+ text: buildDefaultedString(''),
104
+ }).refine((v) => v.text !== '', { message: 'session.message requires text' }),
105
+ 'session.tap': SESSION_DEFAULTED,
106
+ 'message.get': z
107
+ .object({ message: buildDefaultedString('').transform(toMessageID) })
108
+ .refine((v) => v.message !== '', { message: 'message.get requires a message' }),
109
+ 'message.ack': SESSION_DEFAULTED.extend({
110
+ message: buildDefaultedString('').transform(toMessageID),
111
+ }).refine((v) => v.message !== '', { message: 'message.ack requires a message' }),
100
112
  } as const;
101
113
 
102
114
  function buildDefaultedString(fallback: string) {
package/src/report.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { sendReport } from './shared/report';
2
+ import { REPORT_KINDS } from './shared/report-kinds';
3
+
4
+ /**
5
+ * Runs inside wrangled sessions: reads the final text from stdin verbatim
6
+ * and forwards it to the atc socket as a Report envelope of the given kind
7
+ * for the given message. Always exits 0 so it never blocks the session it
8
+ * reports on.
9
+ */
10
+ export async function runReport(kind: string, message: string): Promise<void> {
11
+ try {
12
+ const sock = process.env['ATC_SOCKET'];
13
+ const atcId = process.env['ATC_SESSION_ID'];
14
+
15
+ if (
16
+ sock !== undefined &&
17
+ sock !== '' &&
18
+ atcId !== undefined &&
19
+ atcId !== '' &&
20
+ REPORT_KINDS.some((known) => known === kind) &&
21
+ message !== ''
22
+ ) {
23
+ const answer = await new Response(Bun.stdin.stream()).text();
24
+
25
+ const line = `${JSON.stringify({ atcId, event: 'Report', payload: { kind, message, answer } })}\n`;
26
+
27
+ await sendReport(sock, line, 2000);
28
+ }
29
+ } catch {}
30
+
31
+ process.exit(0);
32
+ }
@@ -0,0 +1,7 @@
1
+ import type { Tagged } from 'type-fest';
2
+
3
+ /**
4
+ * An atc-minted message id; keys the messages table and every message
5
+ * request and event.
6
+ */
7
+ export type MessageID = Tagged<string, 'MessageID'>;
@@ -0,0 +1,3 @@
1
+ export const REPORT_KINDS = ['answered'] as const;
2
+
3
+ export type ReportKind = (typeof REPORT_KINDS)[number];