@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
@@ -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);
@@ -7,6 +7,7 @@ import { collectCleanEnv } from '../shared/collect-clean-env';
7
7
  import { socketPath, statusFile } from '../shared/config';
8
8
  import { resolveRepoRoot } from '../shared/resolve-repo-root';
9
9
  import type { SessionID } from '../shared/session-id';
10
+ import { truncateToBytes } from '../shared/truncate-to-bytes';
10
11
  import type { FleetEntry, FleetStore } from '../store/fleet-entry';
11
12
  import type { HookEvent } from './hooks';
12
13
  import { mintSessionID } from './mint-session-id';
@@ -75,6 +76,16 @@ export interface Session {
75
76
  // The session that spawned this one as a sub-session; null for a
76
77
  // top-level session. One level deep: a sub-session never owns another.
77
78
  parent: SessionID | null;
79
+
80
+ // the prompt the session was spawned with
81
+ prompt?: string;
82
+
83
+ // the agent's final message from its latest finished turn, capped in size
84
+ result?: string;
85
+
86
+ // the transcript file the agent's hooks last reported. Kept apart from the
87
+ // resume check so a restored path never changes whether a revive is allowed.
88
+ transcriptPath?: string;
78
89
  }
79
90
 
80
91
  export class SessionManager {
@@ -180,6 +191,9 @@ export class SessionManager {
180
191
  namedBy: 'auto',
181
192
  createdAt: Date.now(),
182
193
  parent: this.findByAgentSessionID(entry.parent)?.id ?? null,
194
+ ...(entry.prompt === undefined ? {} : { prompt: entry.prompt }),
195
+ ...(entry.result === undefined ? {} : { result: entry.result }),
196
+ ...(entry.transcriptPath === undefined ? {} : { transcriptPath: entry.transcriptPath }),
183
197
  };
184
198
 
185
199
  this.sessions.push(session);
@@ -385,6 +399,7 @@ export class SessionManager {
385
399
  namedBy,
386
400
  createdAt: Date.now(),
387
401
  parent,
402
+ ...(prompt === '' ? {} : { prompt }),
388
403
  };
389
404
 
390
405
  pty.onData((d) => {
@@ -442,9 +457,9 @@ export class SessionManager {
442
457
  return this.sessions.filter((s) => s.parent === id);
443
458
  }
444
459
 
445
- // Returns the normalized event kind so the caller can key lifecycle
446
- // bookkeeping on it, or null when no session or adapter matches.
447
- applyHook(e: HookEvent): AdapterEvent['kind'] | null {
460
+ // Returns the normalized event so the caller can key lifecycle bookkeeping
461
+ // and the event trail on it, or null when no session or adapter matches.
462
+ applyHook(e: HookEvent): AdapterEvent | null {
448
463
  const s = this.sessions.find((x) => x.id === e.atcId);
449
464
 
450
465
  if (!s) {
@@ -462,15 +477,26 @@ export class SessionManager {
462
477
  // Reporters belong to the terminal process; once a session is headless,
463
478
  // late reports from the dying terminal must not clobber its state.
464
479
  if (s.kind === 'headless') {
465
- return ev.kind;
480
+ return ev;
466
481
  }
467
482
 
468
483
  const focused = this.focusedId === s.id;
469
484
  let dirty = false;
485
+ let persist = false;
486
+ const rowUpdate: { result?: string; transcriptPath?: string } = {};
487
+
488
+ if (ev.transcriptSource !== undefined) {
489
+ s.transcriptSource = ev.transcriptSource;
490
+
491
+ if (s.transcriptPath !== ev.transcriptSource) {
492
+ s.transcriptPath = ev.transcriptSource;
493
+ rowUpdate.transcriptPath = ev.transcriptSource;
494
+ }
495
+ }
470
496
 
471
497
  if (ev.agentSessionID !== undefined && s.agentSessionID !== ev.agentSessionID) {
472
498
  s.agentSessionID = ev.agentSessionID;
473
- void this.writeFleet();
499
+ persist = true;
474
500
  dirty = true;
475
501
  }
476
502
 
@@ -478,10 +504,6 @@ export class SessionManager {
478
504
  s.lastDetail = ev.detail;
479
505
  }
480
506
 
481
- if (ev.transcriptSource !== undefined) {
482
- s.transcriptSource = ev.transcriptSource;
483
- }
484
-
485
507
  if (ev.nameSource !== undefined) {
486
508
  void this.refreshName(s, ev.nameSource);
487
509
  }
@@ -507,6 +529,13 @@ export class SessionManager {
507
529
  s.unread = !focused;
508
530
  s.lastMsg = 'turn done';
509
531
  dirty = true;
532
+
533
+ if (ev.result !== undefined) {
534
+ // A runaway final message cannot bloat the fleet row.
535
+ s.result = truncateToBytes(ev.result, 16_384);
536
+ rowUpdate.result = s.result;
537
+ }
538
+
510
539
  break;
511
540
  }
512
541
  case 'prompt-submitted': {
@@ -537,12 +566,21 @@ export class SessionManager {
537
566
  }
538
567
  }
539
568
 
569
+ // A fleet rewrite marks every session without a PTY as exited, so only a
570
+ // new agent session id (which adds or re-keys a row) takes it. Anything
571
+ // else touches this session's own row.
572
+ if (persist) {
573
+ void this.writeFleet();
574
+ } else if (s.agentSessionID !== undefined) {
575
+ void this.store.updateFleetEntry(s.agentSessionID, rowUpdate);
576
+ }
577
+
540
578
  if (dirty) {
541
579
  this.onEvent('state', s);
542
580
  this.emitChange();
543
581
  }
544
582
 
545
- return ev.kind;
583
+ return ev;
546
584
  }
547
585
 
548
586
  private async refreshName(s: Session, source: string) {
@@ -731,6 +769,9 @@ export class SessionManager {
731
769
  lastAttachedAt: s.lastAttachedAt,
732
770
  ...(live ? {} : { exited: true }),
733
771
  ...(parent === undefined ? {} : { parent }),
772
+ ...(s.prompt === undefined ? {} : { prompt: s.prompt }),
773
+ ...(s.result === undefined ? {} : { result: s.result }),
774
+ ...(s.transcriptPath === undefined ? {} : { transcriptPath: s.transcriptPath }),
734
775
  });
735
776
  }
736
777
 
@@ -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
@@ -53,6 +53,53 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
53
53
  { io: 'input' },
54
54
  );
55
55
 
56
+ const SESSION_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
57
+ SESSION_ID_BASE.extend({
58
+ cursor: z
59
+ .string()
60
+ .optional()
61
+ .describe(
62
+ 'The cursor a previous atc_session_read returned; omit to read from the start of the conversation',
63
+ ),
64
+ limit: z
65
+ .number()
66
+ .int()
67
+ .min(1)
68
+ .max(200)
69
+ .optional()
70
+ .describe('Most rows to return; defaults to 50'),
71
+ }).strict(),
72
+ { io: 'input' },
73
+ );
74
+
75
+ const EVENTS_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
76
+ z.strictObject({
77
+ cursor: z
78
+ .string()
79
+ .optional()
80
+ .describe(
81
+ 'The cursor a previous atc_events_read returned; omit to get the most recent events',
82
+ ),
83
+ limit: z
84
+ .number()
85
+ .int()
86
+ .min(1)
87
+ .max(200)
88
+ .optional()
89
+ .describe('Most events to return; defaults to 50'),
90
+ waitMs: z
91
+ .number()
92
+ .int()
93
+ .min(0)
94
+ .max(30_000)
95
+ .optional()
96
+ .describe(
97
+ 'How long to wait for a new event when none is pending, in milliseconds; defaults to 0, capped at 30000. Keep it short.',
98
+ ),
99
+ }),
100
+ { io: 'input' },
101
+ );
102
+
56
103
  const TOOLS: readonly MCPTool[] = [
57
104
  {
58
105
  name: 'atc_session_list',
@@ -122,6 +169,56 @@ const TOOLS: readonly MCPTool[] = [
122
169
  description: 'List directories sessions were previously spawned from, most recent first.',
123
170
  inputSchema: NO_INPUT,
124
171
  },
172
+ {
173
+ name: 'atc_session_get',
174
+ description:
175
+ 'Read one session in a single call: its descriptor (state, unread flag, last activity message), the prompt it was spawned with, when it last reported activity, the prompt or question it is waiting on while it needs you (read-only; answer it with atc_session_input), and the final message of its latest finished turn.',
176
+ inputSchema: SESSION_INPUT,
177
+ },
178
+ {
179
+ name: 'atc_session_read',
180
+ description:
181
+ "Read a session's conversation a page at a time, oldest first: user and assistant messages with tool uses summarised. Pass the returned cursor to continue where you left off; more is true when the page stopped before the end. Claude sessions only; other agents answer unsupported.",
182
+ inputSchema: SESSION_READ_INPUT,
183
+ },
184
+ {
185
+ name: 'atc_events_read',
186
+ description:
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
+ inputSchema: EVENTS_READ_INPUT,
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
+ },
125
222
  ];
126
223
 
127
224
  /**
@@ -138,6 +235,10 @@ export async function runMCPServer(build: string): Promise<void> {
138
235
 
139
236
  let buffer = '';
140
237
 
238
+ // Each request runs on its own, so a long poll never holds up the others;
239
+ // responses carry their request's id.
240
+ const inFlight = new Set<Promise<void>>();
241
+
141
242
  for await (const chunk of Bun.stdin.stream()) {
142
243
  buffer += decoder.decode(chunk, { stream: true });
143
244
 
@@ -150,10 +251,25 @@ export async function runMCPServer(build: string): Promise<void> {
150
251
  continue;
151
252
  }
152
253
 
153
- await applyRPCLine(client, build, line);
254
+ const finished = Promise.withResolvers<void>();
255
+
256
+ inFlight.add(finished.promise);
257
+
258
+ void (async () => {
259
+ try {
260
+ await applyRPCLine(client, build, line);
261
+ } catch {
262
+ // A failed line gets no response, the way a malformed one gets none.
263
+ } finally {
264
+ inFlight.delete(finished.promise);
265
+ finished.resolve();
266
+ }
267
+ })();
154
268
  }
155
269
  }
156
270
 
271
+ await Promise.all(inFlight);
272
+
157
273
  client.stop();
158
274
  }
159
275
 
@@ -317,6 +433,53 @@ async function runTool(
317
433
 
318
434
  return JSON.stringify(ok['dirs'], null, 2);
319
435
  }
436
+ case 'atc_session_get': {
437
+ const ok = await client.sendRequest('session.get', { session: args['session'] });
438
+
439
+ return JSON.stringify(ok, null, 2);
440
+ }
441
+ case 'atc_session_read': {
442
+ const ok = await client.sendRequest('session.read', {
443
+ session: args['session'],
444
+ ...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
445
+ ...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
446
+ });
447
+
448
+ return JSON.stringify(ok, null, 2);
449
+ }
450
+ case 'atc_events_read': {
451
+ const ok = await client.sendRequest('events.read', {
452
+ ...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
453
+ ...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
454
+ ...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
455
+ });
456
+
457
+ return JSON.stringify(ok, null, 2);
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
+ }
320
483
  default: {
321
484
  throw new Error(`unknown tool '${name}'`);
322
485
  }
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import type { Cursor } from './encode-cursor';
3
+
4
+ const WIRE_CURSOR = z.discriminatedUnion('k', [
5
+ z.object({ k: z.literal('ev'), i: z.number().int().nonnegative() }),
6
+ z.object({ k: z.literal('tr'), p: z.string(), o: z.number().int().nonnegative() }),
7
+ ]);
8
+
9
+ export function decodeCursor(raw: string): Cursor | null {
10
+ let json: unknown;
11
+
12
+ try {
13
+ json = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
14
+ } catch {
15
+ return null;
16
+ }
17
+
18
+ const parsed = WIRE_CURSOR.safeParse(json);
19
+
20
+ if (!parsed.success) {
21
+ return null;
22
+ }
23
+
24
+ if (parsed.data.k === 'ev') {
25
+ return { kind: 'events', id: parsed.data.i };
26
+ }
27
+
28
+ return { kind: 'transcript', path: parsed.data.p, offset: parsed.data.o };
29
+ }
@@ -0,0 +1,12 @@
1
+ export type Cursor =
2
+ | { readonly kind: 'events'; readonly id: number }
3
+ | { readonly kind: 'transcript'; readonly path: string; readonly offset: number };
4
+
5
+ export function encodeCursor(cursor: Cursor): string {
6
+ const wire =
7
+ cursor.kind === 'events'
8
+ ? { k: 'ev', i: cursor.id }
9
+ : { k: 'tr', p: cursor.path, o: cursor.offset };
10
+
11
+ return Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url');
12
+ }
@@ -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 =
@@ -87,6 +88,27 @@ export const REQUEST_PARAM_SCHEMAS = {
87
88
  .refine((v) => v.request !== '' && v.decision !== '', {
88
89
  message: 'permission.respond requires a request and a decision',
89
90
  }),
91
+ 'session.get': SESSION_DEFAULTED,
92
+ 'session.read': SESSION_DEFAULTED.extend({
93
+ cursor: buildOptionalCursor(),
94
+ limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
95
+ }),
96
+ 'events.read': z.object({
97
+ cursor: buildOptionalCursor(),
98
+ limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
99
+ waitMs: buildDefaultedNumber(0).transform((v) => Math.min(Math.max(Math.trunc(v), 0), 30_000)),
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' }),
90
112
  } as const;
91
113
 
92
114
  function buildDefaultedString(fallback: string) {
@@ -115,3 +137,11 @@ function buildDefaultedNonEmptyString(fallback: string) {
115
137
  z.string().default(fallback),
116
138
  );
117
139
  }
140
+
141
+ // An empty cursor reads as no cursor, the way a client starting fresh sends it.
142
+ function buildOptionalCursor() {
143
+ return z.preprocess(
144
+ (v) => (typeof v === 'string' && v !== '' ? v : undefined),
145
+ z.string().optional(),
146
+ );
147
+ }
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];
@@ -4,12 +4,28 @@
4
4
  async function sendLine(sock: string, line: string): Promise<void> {
5
5
  const resolvers = Promise.withResolvers<void>();
6
6
 
7
+ const bytes = new TextEncoder().encode(line);
8
+
9
+ let written = 0;
10
+
7
11
  await Bun.connect({
8
12
  unix: sock,
9
13
  socket: {
14
+ // A write returns the bytes the socket accepted and drops the rest, so
15
+ // a line larger than the socket buffer goes out across drain calls.
10
16
  open(s) {
11
- s.write(line);
12
- s.end();
17
+ written += s.write(bytes.subarray(written));
18
+
19
+ if (written >= bytes.length) {
20
+ s.end();
21
+ }
22
+ },
23
+ drain(s) {
24
+ written += s.write(bytes.subarray(written));
25
+
26
+ if (written >= bytes.length) {
27
+ s.end();
28
+ }
13
29
  },
14
30
  close() {
15
31
  resolvers.resolve();
@@ -0,0 +1,11 @@
1
+ import type { MessageID } from './message-id';
2
+
3
+ /**
4
+ * Trusts a string as an atc message id. This is the one point in the
5
+ * codebase where a plain string becomes a `MessageID`, and it adds no
6
+ * runtime check: the caller asserts the string identifies an atc message.
7
+ */
8
+ export function toMessageID(id: string): MessageID {
9
+ // oxlint-disable-next-line no-unsafe-type-assertion -- the one point where a string is trusted as a MessageID
10
+ return id as MessageID;
11
+ }
@@ -0,0 +1,21 @@
1
+ export function truncateToBytes(text: string, maxBytes: number): string {
2
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes) {
3
+ return text;
4
+ }
5
+
6
+ const bytes = Buffer.from(text, 'utf8');
7
+
8
+ // The cut leaves room for the three-byte ellipsis, or drops the ellipsis
9
+ // when the cap is smaller than it, and backs up off any continuation byte
10
+ // so it never lands inside a code point.
11
+ const hasRoomForEllipsis = maxBytes >= 3;
12
+ let end = hasRoomForEllipsis ? maxBytes - 3 : Math.max(maxBytes, 0);
13
+
14
+ while (end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) {
15
+ end -= 1;
16
+ }
17
+
18
+ const head = bytes.subarray(0, end).toString('utf8');
19
+
20
+ return hasRoomForEllipsis ? `${head}…` : head;
21
+ }
@@ -18,11 +18,31 @@ export interface FleetEntry {
18
18
 
19
19
  // The agent session id of the session this one is a sub-session of.
20
20
  readonly parent?: AgentSessionID;
21
+
22
+ // The prompt the session was spawned with.
23
+ readonly prompt?: string;
24
+
25
+ // The agent's final message from the session's latest finished turn.
26
+ readonly result?: string;
27
+
28
+ // The transcript file the agent's hooks last reported for the session.
29
+ readonly transcriptPath?: string;
21
30
  }
22
31
 
23
32
  export interface FleetStore {
24
33
  readonly loadFleet: () => Promise<FleetEntry[]>;
25
34
  readonly writeFleet: (entries: readonly FleetEntry[]) => Promise<void>;
35
+ readonly updateFleetEntry: (
36
+ agentSessionID: AgentSessionID,
37
+ fields: FleetEntryUpdate,
38
+ ) => Promise<void>;
39
+ }
40
+
41
+ // The fields a session rewrites on its own row while it runs, without
42
+ // touching any sibling row.
43
+ export interface FleetEntryUpdate {
44
+ readonly result?: string;
45
+ readonly transcriptPath?: string;
26
46
  }
27
47
 
28
48
  // A stored fleet row's keys. name, cwd, and the resolved agentSessionID are
@@ -0,0 +1,9 @@
1
+ import type { AgentSessionID } from '../shared/agent-session-id';
2
+ import type { SessionID } from '../shared/session-id';
3
+
4
+ // Which session a message belongs to: the atc id it was sent to, or the
5
+ // agent session id that survives a restore's re-minted atc id.
6
+ export interface MessageOwner {
7
+ readonly atcID: SessionID;
8
+ readonly agentSessionID?: AgentSessionID;
9
+ }