@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -46,6 +46,23 @@ export interface AdapterEvent {
46
46
  // Claude resume-existence path. Distinct from nameSource: a naming
47
47
  // handle is not a resume gate.
48
48
  transcriptSource?: string;
49
+
50
+ // The agent's whole final message for a finished turn; detail holds a bounded preview of it.
51
+ result?: string;
52
+ }
53
+
54
+ export interface TranscriptToolUse {
55
+ readonly name: string;
56
+ readonly input: string;
57
+ }
58
+
59
+ export interface TranscriptRow {
60
+ readonly role: 'user' | 'assistant';
61
+ readonly text: string;
62
+ readonly tools: readonly TranscriptToolUse[];
63
+
64
+ // Epoch ms from the line's timestamp.
65
+ readonly at: number | null;
49
66
  }
50
67
 
51
68
  export interface NameUpdate {
@@ -118,4 +135,9 @@ export interface AgentAdapter {
118
135
  cwd: string,
119
136
  agentSessionID: AgentSessionID | undefined,
120
137
  ) => string | null;
138
+
139
+ // Turns one line of the agent's transcript file into a conversation row,
140
+ // null for a line that is not one. Absent: atc cannot read this agent's
141
+ // transcript.
142
+ readonly parseTranscriptLine?: (line: string) => TranscriptRow | null;
121
143
  }
@@ -16,6 +16,7 @@ import type {
16
16
  SpawnOptions,
17
17
  SpawnPlan,
18
18
  } from './agent-adapter';
19
+ import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
19
20
  import { truncateDetail } from './truncate-detail';
20
21
  import { writeHookSettings } from './write-hook-settings';
21
22
 
@@ -44,6 +45,8 @@ export class ClaudeAdapter implements AgentAdapter {
44
45
  // Claude's hooks are authoritative; no screen heuristics needed.
45
46
  readonly screenDetector = null;
46
47
 
48
+ readonly parseTranscriptLine = parseClaudeTranscriptLine;
49
+
47
50
  private readonly config: Config;
48
51
 
49
52
  // Written on first spawn so constructing the adapter touches no state.
@@ -110,7 +113,7 @@ export class ClaudeAdapter implements AgentAdapter {
110
113
  ...named,
111
114
  kind: 'turn-done',
112
115
  ...(lastMessage !== undefined && lastMessage !== ''
113
- ? { detail: truncateDetail(lastMessage) }
116
+ ? { detail: truncateDetail(lastMessage), result: lastMessage }
114
117
  : {}),
115
118
  };
116
119
  }
@@ -117,7 +117,7 @@ export class CodexAdapter implements AgentAdapter {
117
117
  ...named,
118
118
  kind: 'turn-done',
119
119
  ...(lastMessage !== undefined && lastMessage !== ''
120
- ? { detail: truncateDetail(lastMessage) }
120
+ ? { detail: truncateDetail(lastMessage), result: lastMessage }
121
121
  : {}),
122
122
  };
123
123
  }
@@ -14,6 +14,7 @@ import type {
14
14
  SpawnPlan,
15
15
  } from './agent-adapter';
16
16
  import { ClaudeAdapter } from './claude-adapter';
17
+ import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
17
18
  import { writeHookSettings } from './write-hook-settings';
18
19
 
19
20
  /**
@@ -33,6 +34,9 @@ export class GatewayAdapter implements AgentAdapter {
33
34
  // The CLI's hooks are authoritative; no screen heuristics needed.
34
35
  readonly screenDetector = null;
35
36
 
37
+ // The gateway runs the Claude CLI, which writes the same transcript.
38
+ readonly parseTranscriptLine = parseClaudeTranscriptLine;
39
+
36
40
  private readonly gateway: GatewayConfig;
37
41
 
38
42
  private readonly claude: ClaudeAdapter;
@@ -269,7 +269,7 @@ function buildTurnDoneEvent(named: Readonly<AdapterEvent>, payload: GrokHookPayl
269
269
  ...named,
270
270
  kind: 'turn-done',
271
271
  ...(lastMessage !== undefined && lastMessage !== ''
272
- ? { detail: truncateDetail(lastMessage) }
272
+ ? { detail: truncateDetail(lastMessage), result: lastMessage }
273
273
  : {}),
274
274
  };
275
275
  }
@@ -0,0 +1,107 @@
1
+ import { isRecord } from '../shared/report';
2
+ import { truncateToBytes } from '../shared/truncate-to-bytes';
3
+ import type { TranscriptRow, TranscriptToolUse } from './agent-adapter';
4
+
5
+ // One row must stay far under the protocol's line cap.
6
+ const MAX_ROW_TEXT_BYTES = 16_384;
7
+
8
+ export function parseClaudeTranscriptLine(line: string): TranscriptRow | null {
9
+ let parsed: unknown;
10
+
11
+ try {
12
+ parsed = JSON.parse(line);
13
+ } catch {
14
+ return null;
15
+ }
16
+
17
+ if (!isRecord(parsed)) {
18
+ return null;
19
+ }
20
+
21
+ const type = parsed['type'];
22
+
23
+ if (type !== 'user' && type !== 'assistant') {
24
+ return null;
25
+ }
26
+
27
+ if (parsed['isSidechain'] === true || parsed['isMeta'] === true) {
28
+ return null;
29
+ }
30
+
31
+ const message = parsed['message'];
32
+
33
+ if (!isRecord(message)) {
34
+ return null;
35
+ }
36
+
37
+ const content = message['content'];
38
+ const texts: string[] = [];
39
+ const tools: TranscriptToolUse[] = [];
40
+
41
+ if (typeof content === 'string') {
42
+ texts.push(content);
43
+ } else if (Array.isArray(content)) {
44
+ for (const block of content as unknown[]) {
45
+ if (!isRecord(block)) {
46
+ continue;
47
+ }
48
+
49
+ const text = block['text'];
50
+ const name = block['name'];
51
+
52
+ if (block['type'] === 'text' && typeof text === 'string') {
53
+ texts.push(text);
54
+ } else if (block['type'] === 'tool_use' && typeof name === 'string') {
55
+ tools.push({ name, input: formatToolInput(block['input']) });
56
+ }
57
+ }
58
+ } else {
59
+ return null;
60
+ }
61
+
62
+ const text = texts.join('\n');
63
+
64
+ if (text === '' && tools.length === 0) {
65
+ return null;
66
+ }
67
+
68
+ const timestamp = parsed['timestamp'];
69
+ const parsedAt = typeof timestamp === 'string' ? Date.parse(timestamp) : Number.NaN;
70
+
71
+ return {
72
+ role: type,
73
+ text: truncateToBytes(text, MAX_ROW_TEXT_BYTES),
74
+ tools,
75
+ at: Number.isNaN(parsedAt) ? null : parsedAt,
76
+ };
77
+ }
78
+
79
+ const TOOL_INPUT_KEYS = [
80
+ 'command',
81
+ 'file_path',
82
+ 'path',
83
+ 'pattern',
84
+ 'url',
85
+ 'query',
86
+ 'description',
87
+ 'prompt',
88
+ ];
89
+
90
+ function formatToolInput(input: unknown): string {
91
+ if (!isRecord(input)) {
92
+ return '';
93
+ }
94
+
95
+ let summary = JSON.stringify(input);
96
+
97
+ for (const key of TOOL_INPUT_KEYS) {
98
+ const value = input[key];
99
+
100
+ if (typeof value === 'string') {
101
+ summary = value;
102
+ break;
103
+ }
104
+ }
105
+
106
+ return summary.length <= 200 ? summary : `${summary.slice(0, 199)}…`;
107
+ }
@@ -0,0 +1,35 @@
1
+ import { encodeCursor } from '../protocol/encode-cursor';
2
+ import type { StoredEvent } from '../store/state-store';
3
+ import type { SessionDescriptor } from './sessions';
4
+
5
+ export interface FleetEvent {
6
+ readonly cursor: string;
7
+ readonly at: number;
8
+ readonly session: string;
9
+ readonly name: string | null;
10
+ readonly kind: string;
11
+ readonly detail: string | null;
12
+ }
13
+
14
+ export function buildFleetEvents(
15
+ rows: readonly StoredEvent[],
16
+ sessions: readonly SessionDescriptor[],
17
+ ): FleetEvent[] {
18
+ return rows.map((row) => {
19
+ // A restore re-mints atc ids, so the agent session id is the stable link.
20
+ const live =
21
+ (row.agentSessionID === null
22
+ ? undefined
23
+ : sessions.find((s) => s.agentSessionID === row.agentSessionID)) ??
24
+ sessions.find((s) => s.id === row.atcID);
25
+
26
+ return {
27
+ cursor: encodeCursor({ kind: 'events', id: row.id }),
28
+ at: row.at,
29
+ session: live?.id ?? row.atcID,
30
+ name: live?.name ?? null,
31
+ kind: row.kind,
32
+ detail: row.detail,
33
+ };
34
+ });
35
+ }
@@ -1,5 +1,7 @@
1
1
  import { basename } from 'node:path';
2
2
  import type { AgentAdapter, AgentID, SpawnOptions } from '../agents/agent-adapter';
3
+ import { decodeCursor } from '../protocol/decode-cursor';
4
+ import { encodeCursor } from '../protocol/encode-cursor';
3
5
  import { OutboundQueue } from '../protocol/outbound-queue';
4
6
  import type { SocketWriter } from '../protocol/outbound-queue';
5
7
  import { parseRequestParams } from '../protocol/parse-request-params';
@@ -14,6 +16,8 @@ import type { ErrorCode, EventMsg, RequestMsg } from '../protocol/protocol';
14
16
  import type { SessionID } from '../shared/session-id';
15
17
  import type { FleetEntry } from '../store/fleet-entry';
16
18
  import type { Dims } from './attach-registry';
19
+ import type { FleetEvent } from './build-fleet-events';
20
+ import type { TranscriptPage, TranscriptPosition } from './load-transcript-page';
17
21
  import type { AnswerResult } from './permission-registry';
18
22
  import type { ScreenText } from './screen-model';
19
23
  import type { SessionDescriptor } from './sessions';
@@ -30,6 +34,21 @@ interface SpawnParams {
30
34
  readonly parent: SessionID | null;
31
35
  }
32
36
 
37
+ interface SessionRecord {
38
+ readonly session: SessionDescriptor;
39
+ readonly prompt: string | null;
40
+ readonly lastActivityAt: number;
41
+ readonly pending: { readonly message: string } | null;
42
+ readonly result: string | null;
43
+ }
44
+
45
+ // A transcript page with the file it came from, so a cursor into a replaced
46
+ // transcript is distinguishable from one into a grown transcript.
47
+ interface SessionTranscriptRead {
48
+ readonly path: string;
49
+ readonly page: TranscriptPage;
50
+ }
51
+
33
52
  export interface DaemonContext {
34
53
  readonly build: string;
35
54
  readonly collectSessions: () => SessionDescriptor[];
@@ -70,6 +89,17 @@ export interface DaemonContext {
70
89
  readonly resyncClient: (sessionID: SessionID, client: OutputClient) => Promise<void>;
71
90
  readonly queueBytes?: number;
72
91
  readonly getEffectiveDims: (sessionID: SessionID) => Dims;
92
+ readonly readSessionRecord: (id: SessionID) => Promise<SessionRecord | 'missing'>;
93
+ readonly loadSessionTranscript: (
94
+ id: SessionID,
95
+ from: TranscriptPosition | null,
96
+ limit: number,
97
+ ) => Promise<SessionTranscriptRead | 'missing' | 'unsupported'>;
98
+ readonly readEvents: (
99
+ afterID: number | null,
100
+ limit: number,
101
+ waitMs: number,
102
+ ) => Promise<FleetEvent[]>;
73
103
  }
74
104
 
75
105
  // The slice of a connection the attach bookkeeping needs: identity plus the
@@ -448,6 +478,21 @@ export class DaemonConnection {
448
478
 
449
479
  return;
450
480
  }
481
+ case 'session.get': {
482
+ await this.applySessionGet(req);
483
+
484
+ return;
485
+ }
486
+ case 'session.read': {
487
+ await this.applySessionRead(req);
488
+
489
+ return;
490
+ }
491
+ case 'events.read': {
492
+ await this.applyEventsRead(req);
493
+
494
+ return;
495
+ }
451
496
  default: {
452
497
  this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
453
498
  }
@@ -631,6 +676,105 @@ export class DaemonConnection {
631
676
  }
632
677
  }
633
678
 
679
+ private async applySessionGet(req: RequestMsg): Promise<void> {
680
+ const parsed = parseRequestParams('session.get', req.p);
681
+
682
+ if (!parsed.ok) {
683
+ this.sendErr(req.id, 'bad_args', parsed.message);
684
+
685
+ return;
686
+ }
687
+
688
+ const id = parsed.data.session;
689
+
690
+ const record = await this.ctx.readSessionRecord(id);
691
+
692
+ if (record === 'missing') {
693
+ this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
694
+
695
+ return;
696
+ }
697
+
698
+ this.sendOk(req.id, { ...record });
699
+ }
700
+
701
+ private async applySessionRead(req: RequestMsg): Promise<void> {
702
+ const parsed = parseRequestParams('session.read', req.p);
703
+
704
+ if (!parsed.ok) {
705
+ this.sendErr(req.id, 'bad_args', parsed.message);
706
+
707
+ return;
708
+ }
709
+
710
+ const id = parsed.data.session;
711
+ let from: TranscriptPosition | null = null;
712
+
713
+ if (parsed.data.cursor !== undefined) {
714
+ const decoded = decodeCursor(parsed.data.cursor);
715
+
716
+ if (decoded === null || decoded.kind !== 'transcript') {
717
+ this.sendErr(req.id, 'bad_args', `'${parsed.data.cursor}' is not a session.read cursor`);
718
+
719
+ return;
720
+ }
721
+
722
+ from = { path: decoded.path, offset: decoded.offset };
723
+ }
724
+
725
+ const read = await this.ctx.loadSessionTranscript(id, from, parsed.data.limit);
726
+
727
+ if (read === 'missing') {
728
+ this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
729
+ } else if (read === 'unsupported') {
730
+ this.sendErr(
731
+ req.id,
732
+ 'unsupported',
733
+ `session '${id}' runs an agent whose transcript atc cannot read`,
734
+ );
735
+ } else {
736
+ this.sendOk(req.id, {
737
+ rows: read.page.rows,
738
+ cursor: encodeCursor({ kind: 'transcript', path: read.path, offset: read.page.offset }),
739
+ more: read.page.more,
740
+ });
741
+ }
742
+ }
743
+
744
+ private async applyEventsRead(req: RequestMsg): Promise<void> {
745
+ const parsed = parseRequestParams('events.read', req.p);
746
+
747
+ if (!parsed.ok) {
748
+ this.sendErr(req.id, 'bad_args', parsed.message);
749
+
750
+ return;
751
+ }
752
+
753
+ let afterID: number | null = null;
754
+
755
+ if (parsed.data.cursor !== undefined) {
756
+ const decoded = decodeCursor(parsed.data.cursor);
757
+
758
+ if (decoded === null || decoded.kind !== 'events') {
759
+ this.sendErr(req.id, 'bad_args', `'${parsed.data.cursor}' is not an events.read cursor`);
760
+
761
+ return;
762
+ }
763
+
764
+ afterID = decoded.id;
765
+ }
766
+
767
+ const events = await this.ctx.readEvents(afterID, parsed.data.limit, parsed.data.waitMs);
768
+
769
+ const last = events.at(-1);
770
+
771
+ // A cursor always comes back so a client can long-poll from an empty trail.
772
+ this.sendOk(req.id, {
773
+ events,
774
+ cursor: last === undefined ? encodeCursor({ kind: 'events', id: afterID ?? 0 }) : last.cursor,
775
+ });
776
+ }
777
+
634
778
  private async applySessionVerb(
635
779
  req: RequestMsg,
636
780
  method: 'session.kill' | 'session.ack',
@@ -1,15 +1,19 @@
1
1
  import { unlinkSync, writeFileSync } from 'node:fs';
2
- import type { AgentAdapter } from '../agents/agent-adapter';
2
+ import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
3
3
  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
7
  import { StateStore } from '../store/state-store';
8
8
  import { AttachRegistry } from './attach-registry';
9
+ import { buildFleetEvents } from './build-fleet-events';
9
10
  import { buildSessionEvent } from './build-session-event';
10
11
  import { DaemonConnection } from './daemon-connection';
11
12
  import type { DaemonContext, OutputClient } from './daemon-connection';
13
+ import { EventSignal } from './event-signal';
12
14
  import { startHookServer } from './hooks';
15
+ import type { HookEvent } from './hooks';
16
+ import { loadTranscriptPage } from './load-transcript-page';
13
17
  import { makeHookRunner } from './make-hook-runner';
14
18
  import type { HookScope } from './make-hook-runner';
15
19
  import { PermissionRegistry } from './permission-registry';
@@ -130,6 +134,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
130
134
  };
131
135
 
132
136
  const registry = new PermissionRegistry();
137
+ const eventSignal = new EventSignal();
133
138
 
134
139
  registry.onRequested = (req) => {
135
140
  emitEvent(
@@ -382,12 +387,20 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
382
387
  }
383
388
  };
384
389
 
390
+ const recordHookEvent = async (e: HookEvent, ev: Readonly<AdapterEvent> | null) => {
391
+ await store.recordEvent(e, ev);
392
+
393
+ eventSignal.emit();
394
+ };
395
+
385
396
  const reporter = startHookServer((e) => {
397
+ const ev = mgr.applyHook(e);
398
+
386
399
  if (e.event !== 'Statusline') {
387
- void store.recordEvent(e);
400
+ void recordHookEvent(e, ev);
388
401
  }
389
402
 
390
- const kind = mgr.applyHook(e);
403
+ const kind = ev?.kind ?? null;
391
404
  const runtime = runtimes.get(e.atcId);
392
405
 
393
406
  if (kind === 'ended') {
@@ -636,6 +649,77 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
636
649
  runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
637
650
  restoreFleet: (cols, rows) =>
638
651
  restoreFleet({ mgr, store, findRuntime, cols, rows, capMs: opts.restoreBootTimeoutMs ?? 0 }),
652
+ readSessionRecord: async (id) => {
653
+ const s = mgr.sessions.find((x) => x.id === id);
654
+
655
+ if (s === undefined) {
656
+ return 'missing';
657
+ }
658
+
659
+ // Every session-derived field is taken before the await: a kill or a
660
+ // hook may change the session meanwhile.
661
+ const session = getDescriptor(mgr, id);
662
+ const prompt = s.prompt ?? null;
663
+ const pending = s.state === 'needs_you' ? { message: s.lastMsg } : null;
664
+ const result = s.result ?? null;
665
+ const createdAt = s.createdAt;
666
+
667
+ const lastEventAt = await store.loadLastActivityAt(s.id, s.agentSessionID);
668
+
669
+ return {
670
+ session,
671
+ prompt,
672
+ lastActivityAt: lastEventAt ?? createdAt,
673
+ pending,
674
+ result,
675
+ };
676
+ },
677
+ loadSessionTranscript: async (id, from, limit) => {
678
+ const s = mgr.sessions.find((x) => x.id === id);
679
+
680
+ if (s === undefined) {
681
+ return 'missing';
682
+ }
683
+
684
+ const parseLine = mgr.findAdapter(s.agent)?.parseTranscriptLine;
685
+
686
+ if (parseLine === undefined) {
687
+ return 'unsupported';
688
+ }
689
+
690
+ const path = s.transcriptPath ?? '';
691
+
692
+ if (path === '') {
693
+ return { path, page: { rows: [], offset: 0, more: false } };
694
+ }
695
+
696
+ // A page stays far under the protocol's 1 MiB line cap.
697
+ const page = await loadTranscriptPage({ path, from, limit, maxBytes: 262_144, parseLine });
698
+
699
+ return { path, page };
700
+ },
701
+ readEvents: async (afterID, limit, waitMs) => {
702
+ const deadline = Date.now() + waitMs;
703
+
704
+ // A wake for an event the read leaves out (a heartbeat) loops back to
705
+ // wait out the rest of the window.
706
+ for (;;) {
707
+ const generation = eventSignal.generation;
708
+
709
+ const rows =
710
+ afterID === null
711
+ ? await store.collectLatestEvents(limit)
712
+ : await store.collectEventsAfter(afterID, limit);
713
+
714
+ const remaining = deadline - Date.now();
715
+
716
+ if (rows.length > 0 || remaining <= 0 || eventSignal.disposed) {
717
+ return buildFleetEvents(rows, mgr.collectDescriptors());
718
+ }
719
+
720
+ await eventSignal.waitForNext(generation, remaining);
721
+ }
722
+ },
639
723
  };
640
724
 
641
725
  try {
@@ -675,6 +759,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
675
759
  }
676
760
 
677
761
  runtimes.clear();
762
+ eventSignal.dispose();
678
763
 
679
764
  await store.stop();
680
765
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Wakes long-polling readers when the event trail grows. A reader notes the
3
+ * signal's count before it queries and waits from that count, so an event
4
+ * recorded between the query and the wait is never missed.
5
+ */
6
+ export class EventSignal {
7
+ generation = 0;
8
+
9
+ disposed = false;
10
+
11
+ private readonly waiters = new Set<() => void>();
12
+
13
+ emit(): void {
14
+ this.generation += 1;
15
+
16
+ this.drainWaiters();
17
+ }
18
+
19
+ waitForNext(since: number, timeoutMs: number): Promise<void> {
20
+ if (this.disposed || this.generation !== since || timeoutMs <= 0) {
21
+ return Promise.resolve();
22
+ }
23
+
24
+ const deferred = Promise.withResolvers<void>();
25
+
26
+ const onWake = () => {
27
+ clearTimeout(timer);
28
+
29
+ this.waiters.delete(onWake);
30
+ deferred.resolve();
31
+ };
32
+
33
+ const timer = setTimeout(onWake, timeoutMs);
34
+
35
+ this.waiters.add(onWake);
36
+
37
+ return deferred.promise;
38
+ }
39
+
40
+ dispose(): void {
41
+ this.disposed = true;
42
+ this.generation += 1;
43
+
44
+ this.drainWaiters();
45
+ }
46
+
47
+ private drainWaiters(): void {
48
+ for (const wake of this.waiters) {
49
+ wake();
50
+ }
51
+
52
+ this.waiters.clear();
53
+ }
54
+ }