@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.5.1",
3
+ "version": "2.7.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 {
@@ -107,6 +124,10 @@ export interface AgentAdapter {
107
124
 
108
125
  // The detector stack's screen tier; null when hooks are authoritative.
109
126
  readonly screenDetector: ScreenDetector | null;
127
+
128
+ // Whether a session under this agent can take inbox messages through a tap;
129
+ // false refuses every message as unsupported.
130
+ readonly takesMessages: boolean;
110
131
  readonly planSpawn: (opts: SpawnOptions) => SpawnPlan;
111
132
  readonly normalizeHook: (e: HookEvent) => AdapterEvent;
112
133
  readonly loadName: (
@@ -118,4 +139,9 @@ export interface AgentAdapter {
118
139
  cwd: string,
119
140
  agentSessionID: AgentSessionID | undefined,
120
141
  ) => string | null;
142
+
143
+ // Turns one line of the agent's transcript file into a conversation row,
144
+ // null for a line that is not one. Absent: atc cannot read this agent's
145
+ // transcript.
146
+ readonly parseTranscriptLine?: (line: string) => TranscriptRow | null;
121
147
  }
@@ -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,10 @@ 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
+
50
+ readonly takesMessages = true;
51
+
47
52
  private readonly config: Config;
48
53
 
49
54
  // Written on first spawn so constructing the adapter touches no state.
@@ -110,7 +115,7 @@ export class ClaudeAdapter implements AgentAdapter {
110
115
  ...named,
111
116
  kind: 'turn-done',
112
117
  ...(lastMessage !== undefined && lastMessage !== ''
113
- ? { detail: truncateDetail(lastMessage) }
118
+ ? { detail: truncateDetail(lastMessage), result: lastMessage }
114
119
  : {}),
115
120
  };
116
121
  }
@@ -47,6 +47,8 @@ export class CodexAdapter implements AgentAdapter {
47
47
  // Codex's hooks are authoritative; no screen heuristics needed.
48
48
  readonly screenDetector = null;
49
49
 
50
+ readonly takesMessages = false;
51
+
50
52
  private readonly config: Config;
51
53
 
52
54
  constructor(config: Config) {
@@ -117,7 +119,7 @@ export class CodexAdapter implements AgentAdapter {
117
119
  ...named,
118
120
  kind: 'turn-done',
119
121
  ...(lastMessage !== undefined && lastMessage !== ''
120
- ? { detail: truncateDetail(lastMessage) }
122
+ ? { detail: truncateDetail(lastMessage), result: lastMessage }
121
123
  : {}),
122
124
  };
123
125
  }
@@ -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,11 @@ 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
+
40
+ readonly takesMessages = true;
41
+
36
42
  private readonly gateway: GatewayConfig;
37
43
 
38
44
  private readonly claude: ClaudeAdapter;
@@ -60,6 +60,8 @@ export class GrokAdapter implements AgentAdapter {
60
60
  // Grok's hooks are authoritative; no screen heuristics needed.
61
61
  readonly screenDetector = null;
62
62
 
63
+ readonly takesMessages = false;
64
+
63
65
  private readonly config: Config;
64
66
 
65
67
  private readonly hookState = new Map<SessionID, GrokSessionHookState>();
@@ -269,7 +271,7 @@ function buildTurnDoneEvent(named: Readonly<AdapterEvent>, payload: GrokHookPayl
269
271
  ...named,
270
272
  kind: 'turn-done',
271
273
  ...(lastMessage !== undefined && lastMessage !== ''
272
- ? { detail: truncateDetail(lastMessage) }
274
+ ? { detail: truncateDetail(lastMessage), result: lastMessage }
273
275
  : {}),
274
276
  };
275
277
  }
@@ -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
+ }
package/src/cli.ts CHANGED
@@ -63,6 +63,10 @@ const main = defineCommand({
63
63
  const restoreBootTimeoutMs =
64
64
  Number.isFinite(capOverride) && capOverride >= 0 ? capOverride : 15_000;
65
65
 
66
+ // How long a started session may go without a tap before a message
67
+ // to it is refused. Tests pin it to 0 to reach the refusal at once.
68
+ const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
69
+
66
70
  const claudeAdapter = new claude.ClaudeAdapter(cfg, (runOpts, hooks) =>
67
71
  headless.startHeadlessRun({ ...runOpts, claudeBin: cfg.claudeBin }, hooks),
68
72
  );
@@ -89,6 +93,9 @@ const main = defineCommand({
89
93
  pidPath: config.daemonPidFile,
90
94
  hooks: cfg.hooks,
91
95
  restoreBootTimeoutMs,
96
+ ...(Number.isFinite(graceOverride) && graceOverride >= 0
97
+ ? { tapGraceMs: graceOverride }
98
+ : {}),
92
99
  ...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
93
100
  onQuit: () => process.exit(0),
94
101
  });
@@ -152,6 +159,45 @@ const main = defineCommand({
152
159
  await reporter.runHookReport();
153
160
  },
154
161
  }),
162
+ tap: () =>
163
+ defineCommand({
164
+ meta: {
165
+ name: 'tap',
166
+ description: "Stream a session's inbox to stdout as NDJSON, acking each message",
167
+ },
168
+ args: {
169
+ session: {
170
+ type: 'string',
171
+ required: true,
172
+ description: 'The atc session id to tap',
173
+ },
174
+ },
175
+ async run(ctx) {
176
+ const tap = await import('./tap');
177
+
178
+ await tap.runTap(ctx.args.session);
179
+ },
180
+ }),
181
+ report: () =>
182
+ defineCommand({
183
+ meta: {
184
+ name: 'report',
185
+ description: 'Report a message event from a wrangled session to the atc socket',
186
+ hidden: true,
187
+ },
188
+
189
+ // Neither arg is required: a citty usage error exits nonzero, and
190
+ // reporters must always exit 0.
191
+ args: {
192
+ kind: { type: 'positional', required: false, default: '' },
193
+ message: { type: 'string', default: '' },
194
+ },
195
+ async run(ctx) {
196
+ const reporter = await import('./report');
197
+
198
+ await reporter.runReport(ctx.args.kind, ctx.args.message);
199
+ },
200
+ }),
155
201
  statusline: () =>
156
202
  defineCommand({
157
203
  meta: {
@@ -16,10 +16,14 @@ interface Pending {
16
16
  export class DaemonClient {
17
17
  onEvent: (event: EventMsg) => void = () => {};
18
18
 
19
+ onClose: () => void = () => {};
20
+
19
21
  private queue: OutboundQueue | null = null;
20
22
 
21
23
  private buffer = '';
22
24
 
25
+ private readonly decoder = new TextDecoder();
26
+
23
27
  private nextID = 1;
24
28
 
25
29
  private readonly pending = new Map<number, Pending>();
@@ -33,13 +37,14 @@ export class DaemonClient {
33
37
  unix: socketPath,
34
38
  socket: {
35
39
  data(_s, buf) {
36
- client.applyChunk(buf.toString());
40
+ client.applyChunk(client.decodeChunk(buf));
37
41
  },
38
42
  drain() {
39
43
  client.queue?.drain();
40
44
  },
41
45
  close() {
42
46
  client.drainPending('connection closed');
47
+ client.onClose();
43
48
  },
44
49
  error() {},
45
50
  },
@@ -74,6 +79,13 @@ export class DaemonClient {
74
79
  this.drainPending('client closed');
75
80
  }
76
81
 
82
+ // Decodes with state kept across reads, so a multi-byte character split
83
+ // between two reads decodes whole.
84
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
85
+ private decodeChunk(buf: Uint8Array): string {
86
+ return this.decoder.decode(buf, { stream: true });
87
+ }
88
+
77
89
  private applyChunk(chunk: string): void {
78
90
  this.buffer += chunk;
79
91
 
@@ -0,0 +1 @@
1
+ export const ANSWER_BYTE_CAP = 65_536;
@@ -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
+ }
@@ -0,0 +1,29 @@
1
+ import { truncateDetail } from '../agents/truncate-detail';
2
+ import { PROTOCOL_V } from '../protocol/protocol';
3
+ import type { EventMsg } from '../protocol/protocol';
4
+ import type { SessionID } from '../shared/session-id';
5
+ import type { MessageRecord } from '../store/message-record';
6
+
7
+ /**
8
+ * The `SessionMessage` event for one message status change, addressed by
9
+ * the atc session id the message currently belongs to. The event carries
10
+ * short previews of the text and answer, never the full content, so a
11
+ * broadcast stays small; `message.get` returns the full message. Delivery
12
+ * and answer fields appear only once they are set.
13
+ */
14
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- every field is readonly; the branded id has no readonly form to wrap it in
15
+ export function buildSessionMessageEvent(sessionID: SessionID, record: MessageRecord): EventMsg {
16
+ return {
17
+ v: PROTOCOL_V,
18
+ ev: 'SessionMessage',
19
+ s: sessionID,
20
+ message: record.id,
21
+ status: record.status,
22
+ from: record.from,
23
+ textPreview: truncateDetail(record.text),
24
+ sentAt: record.sentAt,
25
+ ...(record.deliveredAt === undefined ? {} : { deliveredAt: record.deliveredAt }),
26
+ ...(record.answeredAt === undefined ? {} : { answeredAt: record.answeredAt }),
27
+ ...(record.answer === undefined ? {} : { answerPreview: truncateDetail(record.answer) }),
28
+ };
29
+ }