@zgeoff/atc 2.5.1 → 2.6.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.
@@ -0,0 +1,101 @@
1
+ import type { TranscriptRow } from '../agents/agent-adapter';
2
+
3
+ export interface TranscriptPosition {
4
+ readonly path: string;
5
+ readonly offset: number;
6
+ }
7
+
8
+ export interface TranscriptPage {
9
+ readonly rows: TranscriptRow[];
10
+ readonly offset: number;
11
+ readonly more: boolean;
12
+ }
13
+
14
+ interface TranscriptPageRequest {
15
+ readonly path: string;
16
+ readonly from: TranscriptPosition | null;
17
+ readonly limit: number;
18
+ readonly maxBytes: number;
19
+ readonly parseLine: (line: string) => TranscriptRow | null;
20
+ }
21
+
22
+ export async function loadTranscriptPage(req: TranscriptPageRequest): Promise<TranscriptPage> {
23
+ const file = Bun.file(req.path);
24
+
25
+ if (!(await file.exists())) {
26
+ return {
27
+ rows: [],
28
+ offset: req.from?.path === req.path ? req.from.offset : 0,
29
+ more: false,
30
+ };
31
+ }
32
+
33
+ const size = file.size;
34
+
35
+ // One read window; a line longer than this is skipped unparsed.
36
+ const windowBytes = 1_048_576;
37
+
38
+ // A cursor from another file, or past the end of a replaced one, starts over.
39
+ let offset =
40
+ req.from !== null && req.from.path === req.path && req.from.offset <= size
41
+ ? req.from.offset
42
+ : 0;
43
+
44
+ const rows: TranscriptRow[] = [];
45
+
46
+ const decoder = new TextDecoder();
47
+
48
+ let bytes = 0;
49
+ let skipping = false;
50
+
51
+ while (offset < size) {
52
+ const chunk = await file.slice(offset, Math.min(offset + windowBytes, size)).bytes();
53
+
54
+ let lineStart = 0;
55
+ let newline = chunk.indexOf(0x0a, lineStart);
56
+
57
+ if (newline === -1) {
58
+ // A trailing partial line is left for the next read. A short read means the
59
+ // file shrank underneath us, so it ends the scan rather than skipping a line.
60
+ if (chunk.length < Math.min(windowBytes, size - offset) || offset + chunk.length >= size) {
61
+ break;
62
+ }
63
+
64
+ offset += chunk.length;
65
+ skipping = true;
66
+ continue;
67
+ }
68
+
69
+ while (newline !== -1) {
70
+ const row = skipping
71
+ ? null
72
+ : req.parseLine(decoder.decode(chunk.subarray(lineStart, newline)));
73
+
74
+ skipping = false;
75
+
76
+ if (row !== null) {
77
+ const rowBytes = Buffer.byteLength(JSON.stringify(row));
78
+
79
+ if (rows.length > 0 && bytes + rowBytes > req.maxBytes) {
80
+ return { rows, offset: offset + lineStart, more: true };
81
+ }
82
+
83
+ rows.push(row);
84
+
85
+ bytes += rowBytes;
86
+ }
87
+
88
+ lineStart = newline + 1;
89
+
90
+ if (rows.length >= req.limit) {
91
+ return { rows, offset: offset + lineStart, more: true };
92
+ }
93
+
94
+ newline = chunk.indexOf(0x0a, lineStart);
95
+ }
96
+
97
+ offset += lineStart;
98
+ }
99
+
100
+ return { rows, offset, more: false };
101
+ }
@@ -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
 
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,24 @@ 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
+ },
125
190
  ];
126
191
 
127
192
  /**
@@ -138,6 +203,10 @@ export async function runMCPServer(build: string): Promise<void> {
138
203
 
139
204
  let buffer = '';
140
205
 
206
+ // Each request runs on its own, so a long poll never holds up the others;
207
+ // responses carry their request's id.
208
+ const inFlight = new Set<Promise<void>>();
209
+
141
210
  for await (const chunk of Bun.stdin.stream()) {
142
211
  buffer += decoder.decode(chunk, { stream: true });
143
212
 
@@ -150,10 +219,25 @@ export async function runMCPServer(build: string): Promise<void> {
150
219
  continue;
151
220
  }
152
221
 
153
- await applyRPCLine(client, build, line);
222
+ const finished = Promise.withResolvers<void>();
223
+
224
+ inFlight.add(finished.promise);
225
+
226
+ void (async () => {
227
+ try {
228
+ await applyRPCLine(client, build, line);
229
+ } catch {
230
+ // A failed line gets no response, the way a malformed one gets none.
231
+ } finally {
232
+ inFlight.delete(finished.promise);
233
+ finished.resolve();
234
+ }
235
+ })();
154
236
  }
155
237
  }
156
238
 
239
+ await Promise.all(inFlight);
240
+
157
241
  client.stop();
158
242
  }
159
243
 
@@ -317,6 +401,29 @@ async function runTool(
317
401
 
318
402
  return JSON.stringify(ok['dirs'], null, 2);
319
403
  }
404
+ case 'atc_session_get': {
405
+ const ok = await client.sendRequest('session.get', { session: args['session'] });
406
+
407
+ return JSON.stringify(ok, null, 2);
408
+ }
409
+ case 'atc_session_read': {
410
+ const ok = await client.sendRequest('session.read', {
411
+ session: args['session'],
412
+ ...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
413
+ ...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
414
+ });
415
+
416
+ return JSON.stringify(ok, null, 2);
417
+ }
418
+ case 'atc_events_read': {
419
+ const ok = await client.sendRequest('events.read', {
420
+ ...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
421
+ ...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
422
+ ...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
423
+ });
424
+
425
+ return JSON.stringify(ok, null, 2);
426
+ }
320
427
  default: {
321
428
  throw new Error(`unknown tool '${name}'`);
322
429
  }
@@ -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
+ }
@@ -87,6 +87,16 @@ export const REQUEST_PARAM_SCHEMAS = {
87
87
  .refine((v) => v.request !== '' && v.decision !== '', {
88
88
  message: 'permission.respond requires a request and a decision',
89
89
  }),
90
+ 'session.get': SESSION_DEFAULTED,
91
+ 'session.read': SESSION_DEFAULTED.extend({
92
+ cursor: buildOptionalCursor(),
93
+ limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
94
+ }),
95
+ 'events.read': z.object({
96
+ cursor: buildOptionalCursor(),
97
+ limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
98
+ waitMs: buildDefaultedNumber(0).transform((v) => Math.min(Math.max(Math.trunc(v), 0), 30_000)),
99
+ }),
90
100
  } as const;
91
101
 
92
102
  function buildDefaultedString(fallback: string) {
@@ -115,3 +125,11 @@ function buildDefaultedNonEmptyString(fallback: string) {
115
125
  z.string().default(fallback),
116
126
  );
117
127
  }
128
+
129
+ // An empty cursor reads as no cursor, the way a client starting fresh sends it.
130
+ function buildOptionalCursor() {
131
+ return z.preprocess(
132
+ (v) => (typeof v === 'string' && v !== '' ? v : undefined),
133
+ z.string().optional(),
134
+ );
135
+ }
@@ -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
@@ -12,6 +12,9 @@ interface FleetTable {
12
12
  agent: string;
13
13
  exited: number;
14
14
  parent: string | null;
15
+ prompt: string | null;
16
+ result: string | null;
17
+ transcript_path: string | null;
15
18
  }
16
19
 
17
20
  interface EventsTable {
@@ -21,6 +24,8 @@ interface EventsTable {
21
24
  event: string;
22
25
  message: string | null;
23
26
  session_id: string | null;
27
+ kind: string | null;
28
+ detail: string | null;
24
29
  }
25
30
 
26
31
  interface SpawnHistoryTable {
@@ -46,9 +51,9 @@ export interface StateStoreSchema {
46
51
 
47
52
  // Every shape the fleet table has shipped with: the oldest carries only
48
53
  // agent_session_id under its Claude-era name plus name and cwd, and each
49
- // later step adds one column the daemon grew to depend on. events,
50
- // spawn_history, and prefs have carried one shape since they were added, so
51
- // they need only their initial creation.
54
+ // later step adds one column the daemon grew to depend on. events later gains
55
+ // columns of its own, while spawn_history and prefs have carried one shape
56
+ // since they were added.
52
57
  const MIGRATIONS: Record<string, Migration> = {
53
58
  '001_create_initial_schema': {
54
59
  async up(db: Kysely<StateStoreSchema>) {
@@ -125,6 +130,42 @@ const MIGRATIONS: Record<string, Migration> = {
125
130
  await db.schema.alterTable('fleet').addColumn('parent', 'text').execute();
126
131
  },
127
132
  },
133
+ '008_add_fleet_prompt_result_transcript': {
134
+ async up(db: Kysely<StateStoreSchema>) {
135
+ await db.schema.alterTable('fleet').addColumn('prompt', 'text').execute();
136
+ await db.schema.alterTable('fleet').addColumn('result', 'text').execute();
137
+ await db.schema.alterTable('fleet').addColumn('transcript_path', 'text').execute();
138
+ },
139
+ },
140
+ '009_add_events_kind_detail': {
141
+ async up(db: Kysely<StateStoreSchema>) {
142
+ await db.schema.alterTable('events').addColumn('kind', 'text').execute();
143
+ await db.schema.alterTable('events').addColumn('detail', 'text').execute();
144
+ },
145
+ },
146
+ '010_add_events_trail_indexes': {
147
+ async up(db: Kysely<StateStoreSchema>) {
148
+ await db.schema
149
+ .createIndex('events_atc_id_ts')
150
+ .ifNotExists()
151
+ .on('events')
152
+ .columns(['atc_id', 'ts'])
153
+ .execute();
154
+
155
+ await db.schema
156
+ .createIndex('events_session_id_ts')
157
+ .ifNotExists()
158
+ .on('events')
159
+ .columns(['session_id', 'ts'])
160
+ .execute();
161
+
162
+ // Partial, so the trail reads walk only the rows they return. The
163
+ // predicate text must match the reads' filter for SQLite to pick it.
164
+ await sql`CREATE INDEX IF NOT EXISTS events_trail ON events (id) WHERE kind IS NOT NULL AND kind != 'heartbeat'`.execute(
165
+ db,
166
+ );
167
+ },
168
+ },
128
169
  };
129
170
 
130
171
  const PROVIDER: MigrationProvider = {