@zgeoff/atc 2.8.2 → 2.10.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 (62) hide show
  1. package/README.md +5 -0
  2. package/package.json +6 -3
  3. package/src/agents/agent-adapter.ts +3 -1
  4. package/src/cli.ts +114 -4
  5. package/src/clients.ts +82 -0
  6. package/src/collect-redirect-uris.ts +16 -0
  7. package/src/daemon/build-fleet-events.ts +9 -0
  8. package/src/daemon/build-message-trail-entry.ts +54 -0
  9. package/src/daemon/build-report-trail-entry.ts +25 -0
  10. package/src/daemon/build-session-message-event.ts +0 -1
  11. package/src/daemon/daemon.ts +72 -11
  12. package/src/daemon/sessions.ts +13 -1
  13. package/src/daemon/start-headless-run.ts +3 -8
  14. package/src/daemon/start-headless-turn.ts +15 -3
  15. package/src/daemon/truncate-summary.ts +9 -0
  16. package/src/grants.ts +51 -0
  17. package/src/mcp/answer-authorize-request.ts +98 -0
  18. package/src/mcp/answer-consent-request.ts +166 -0
  19. package/src/mcp/answer-login-request.ts +108 -0
  20. package/src/mcp/answer-mcp-request.ts +161 -0
  21. package/src/mcp/answer-rpc-request.ts +137 -0
  22. package/src/mcp/approval-state.ts +155 -0
  23. package/src/mcp/build-consent-binding.ts +13 -0
  24. package/src/mcp/build-owner-plugin.ts +122 -0
  25. package/src/mcp/build-page-response.ts +29 -0
  26. package/src/mcp/build-tool-list.ts +21 -0
  27. package/src/mcp/collect-clients.ts +31 -0
  28. package/src/mcp/collect-grants.ts +55 -0
  29. package/src/mcp/collect-json-strings.ts +17 -0
  30. package/src/mcp/derive-token-hash.ts +9 -0
  31. package/src/mcp/find-client-name.ts +19 -0
  32. package/src/mcp/find-owner-session-id.ts +18 -0
  33. package/src/mcp/is-allowed-redirect-uri.ts +21 -0
  34. package/src/mcp/is-loopback-host.ts +13 -0
  35. package/src/mcp/is-supported-protocol-version.ts +13 -0
  36. package/src/mcp/mcp-tools.ts +278 -0
  37. package/src/mcp/mint-approval-code.ts +13 -0
  38. package/src/mcp/normalize-approval-code.ts +8 -0
  39. package/src/mcp/normalize-public-url.ts +33 -0
  40. package/src/mcp/open-mcp-auth.ts +145 -0
  41. package/src/mcp/pick-error-message.ts +22 -0
  42. package/src/mcp/pick-protocol-version.ts +9 -0
  43. package/src/mcp/reconnecting-caller.ts +121 -0
  44. package/src/mcp/remove-client.ts +36 -0
  45. package/src/mcp/render-consent-page.ts +65 -0
  46. package/src/mcp/render-login-page.ts +47 -0
  47. package/src/mcp/revoke-grant.ts +28 -0
  48. package/src/mcp/run-tool.ts +143 -0
  49. package/src/mcp/start-mcp-http-server.ts +330 -0
  50. package/src/mcp/to-html-text.ts +12 -0
  51. package/src/mcp/types.ts +78 -0
  52. package/src/mcp/verify-oauth-query.ts +42 -0
  53. package/src/mcp-http-server.ts +75 -0
  54. package/src/mcp-server.ts +22 -450
  55. package/src/parse-port.ts +18 -0
  56. package/src/shared/collect-mcp-http-config.ts +46 -0
  57. package/src/shared/config.ts +3 -1
  58. package/src/shared/grant-scope.ts +8 -0
  59. package/src/shared/load-mcp-http-config.ts +20 -0
  60. package/src/shared/normalize-client-name.ts +28 -0
  61. package/src/store/state-store.ts +65 -4
  62. package/src/store/trail-entry.ts +27 -0
package/README.md CHANGED
@@ -130,6 +130,11 @@ installs in detail.
130
130
  A Claude or Codex session that is already running loads the server only after it restarts. A
131
131
  running Grok session loads it when you press `r` in its `/mcps` list.
132
132
 
133
+ - `atc mcp --http` serves the same tools over HTTP behind OAuth, so a hosted assistant such as
134
+ ChatGPT or Claude.ai can reach your sessions with the scopes you approve. Add each assistant with
135
+ `atc clients add`, approve each connection with the code printed in the server's terminal, and
136
+ list or revoke grants with `atc grants`. [Remote MCP](./docs/architecture/remote-mcp.md) covers
137
+ it.
133
138
  - A Claude session takes messages from other tools. `atc_session_message` queues one, and the
134
139
  session reads it in a new turn, or inside the turn it is running. `atc_message_get` returns the
135
140
  reply once that turn ends. The [protocol](./docs/architecture/protocol.md#messages) covers the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.8.2",
3
+ "version": "2.10.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",
@@ -37,20 +37,23 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@anthropic-ai/claude-agent-sdk": "0.3.222",
40
+ "@better-auth/mcp": "1.7.6",
41
+ "@better-auth/oauth-provider": "1.7.6",
40
42
  "@xterm/addon-serialize": "0.14.0",
41
43
  "@xterm/headless": "6.0.0",
44
+ "better-auth": "1.7.6",
42
45
  "bun-pty": "0.4.10",
43
46
  "citty": "0.2.2",
44
47
  "kysely": "0.29.5",
45
48
  "ts-pattern": "5.9.0",
46
49
  "xstate": "5.32.5",
47
- "zod": "4.4.3"
50
+ "zod": "4.6.5"
48
51
  },
49
52
  "devDependencies": {
50
53
  "@commitlint/cli": "21.2.0",
51
54
  "@stylistic/eslint-plugin": "5.10.0",
52
55
  "@tsconfig/strictest": "2.0.8",
53
- "@types/bun": "1.3.14",
56
+ "@types/bun": "1.4.2",
54
57
  "@zgeoff/bun-test-extended": "0.0.4",
55
58
  "@zgeoff/format-codemod": "0.0.8",
56
59
  "@zgeoff/oxlint-config": "0.0.6",
@@ -106,7 +106,9 @@ interface HeadlessRunRequest {
106
106
 
107
107
  interface HeadlessRunEvents {
108
108
  readonly onOutput: (text: string) => void;
109
- readonly onDone: (summary: string) => void;
109
+
110
+ // The turn's whole final message.
111
+ readonly onDone: (result: string) => void;
110
112
  readonly onNeedsYou: (msg: string) => void;
111
113
  }
112
114
 
package/src/cli.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  // `statusline` are the commands injected into wrangled sessions.
3
3
  import { defineCommand, runMain } from 'citty';
4
4
  import pkg from '../package.json';
5
+ import { collectRedirectURIs } from './collect-redirect-uris';
6
+ import { parsePort } from './parse-port';
5
7
  import { getBuild } from './shared/get-build';
6
8
 
7
9
  const main = defineCommand({
@@ -27,12 +29,120 @@ const main = defineCommand({
27
29
  defineCommand({
28
30
  meta: {
29
31
  name: 'mcp',
30
- description: 'Run an MCP server over stdio exposing the fleet as tools',
32
+ description:
33
+ 'Run an MCP server exposing the fleet as tools: over stdio, or with --http behind OAuth',
31
34
  },
32
- async run() {
33
- const server = await import('./mcp-server');
35
+ args: {
36
+ http: {
37
+ type: 'boolean',
38
+ default: false,
39
+ description: 'Serve MCP over HTTP behind OAuth',
40
+ },
41
+ host: {
42
+ type: 'string',
43
+ description: 'Address to bind with --http (default 127.0.0.1)',
44
+ },
45
+ port: { type: 'string', description: 'Port to listen on with --http (default 8414)' },
46
+ 'public-url': {
47
+ type: 'string',
48
+ description:
49
+ 'Origin clients reach the --http server at, such as https://mcp.example.com',
50
+ },
51
+ },
52
+ async run(ctx) {
53
+ if (!ctx.args.http) {
54
+ const server = await import('./mcp-server');
55
+
56
+ await server.runMCPServer(getBuild());
57
+
58
+ return;
59
+ }
60
+
61
+ const port = ctx.args.port === undefined ? null : parsePort(ctx.args.port);
62
+
63
+ if (port !== null && !port.ok) {
64
+ console.error(`atc mcp --http: ${port.message}`);
65
+ process.exit(1);
66
+ }
67
+
68
+ const http = await import('./mcp-http-server');
69
+
70
+ await http.runMCPHTTPServer(getBuild(), {
71
+ host: ctx.args.host ?? null,
72
+ port: port === null ? null : port.port,
73
+ publicURL: ctx.args['public-url'] ?? null,
74
+ });
75
+ },
76
+ }),
77
+ clients: () =>
78
+ defineCommand({
79
+ meta: {
80
+ name: 'clients',
81
+ description: 'List, add, or remove the clients that may connect to atc mcp --http',
82
+ },
83
+ default: 'list',
84
+ subCommands: {
85
+ list: () =>
86
+ defineCommand({
87
+ meta: { name: 'list', description: 'List the clients', hidden: true },
88
+ async run() {
89
+ const clients = await import('./clients');
90
+
91
+ await clients.runClients({ kind: 'list' });
92
+ },
93
+ }),
94
+ add: () =>
95
+ defineCommand({
96
+ meta: { name: 'add', description: 'Add a client and print its client ID' },
97
+ args: {
98
+ name: { type: 'positional', required: true, description: 'The client name' },
99
+ 'redirect-uri': {
100
+ type: 'string',
101
+ required: true,
102
+ description: 'A redirect URI the client returns to; repeat for more',
103
+ },
104
+ },
105
+ async run(ctx) {
106
+ const clients = await import('./clients');
107
+
108
+ await clients.runClients({
109
+ kind: 'add',
110
+ name: ctx.args.name,
111
+ redirectURIs: collectRedirectURIs(ctx.rawArgs),
112
+ });
113
+ },
114
+ }),
115
+ remove: () =>
116
+ defineCommand({
117
+ meta: {
118
+ name: 'remove',
119
+ description: 'Remove a client and revoke every grant it holds',
120
+ },
121
+ args: {
122
+ id: { type: 'positional', required: true, description: 'The client ID' },
123
+ },
124
+ async run(ctx) {
125
+ const clients = await import('./clients');
126
+
127
+ await clients.runClients({ kind: 'remove', clientID: ctx.args.id });
128
+ },
129
+ }),
130
+ },
131
+ }),
132
+ grants: () =>
133
+ defineCommand({
134
+ meta: {
135
+ name: 'grants',
136
+ description:
137
+ 'List the grants atc mcp --http clients hold, or revoke one with --revoke <id>',
138
+ },
139
+ args: {
140
+ revoke: { type: 'string', description: 'The ID of a grant to revoke' },
141
+ },
142
+ async run(ctx) {
143
+ const grants = await import('./grants');
34
144
 
35
- await server.runMCPServer(getBuild());
145
+ await grants.runGrants(ctx.args.revoke ?? null);
36
146
  },
37
147
  }),
38
148
  daemon: () =>
package/src/clients.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { collectClients } from './mcp/collect-clients';
3
+ import { isAllowedRedirectURI } from './mcp/is-allowed-redirect-uri';
4
+ import { openMCPAuth } from './mcp/open-mcp-auth';
5
+ import { removeClient } from './mcp/remove-client';
6
+ import { mcpAuthDBFile, stateDir } from './shared/config';
7
+ import { normalizeClientName } from './shared/normalize-client-name';
8
+
9
+ type ClientsAction =
10
+ | { readonly kind: 'list' }
11
+ | { readonly kind: 'add'; readonly name: string; readonly redirectURIs: readonly string[] }
12
+ | { readonly kind: 'remove'; readonly clientID: string };
13
+
14
+ /**
15
+ * Runs `atc clients`: lists the clients that may connect to `atc mcp --http`,
16
+ * adds one, or removes one along with every token and consent it holds. It
17
+ * opens the authorization server's database directly, so it works whether or
18
+ * not the server is running.
19
+ */
20
+ export async function runClients(action: ClientsAction): Promise<void> {
21
+ if (action.kind === 'add') {
22
+ const refused = action.redirectURIs.find((uri) => !isAllowedRedirectURI(uri));
23
+
24
+ if (action.redirectURIs.length === 0 || refused !== undefined) {
25
+ const message =
26
+ refused === undefined
27
+ ? 'atc clients add: give at least one --redirect-uri'
28
+ : `atc clients add: '${refused}' is not a redirect URI atc accepts; use https, or http on a loopback host, with no fragment`;
29
+
30
+ console.error(message);
31
+ process.exit(1);
32
+ }
33
+ }
34
+
35
+ mkdirSync(stateDir, { recursive: true });
36
+
37
+ const store = await openMCPAuth({ dbPath: mcpAuthDBFile, origin: null });
38
+
39
+ try {
40
+ if (action.kind === 'add') {
41
+ const name = normalizeClientName(action.name);
42
+
43
+ const created = await store.auth.api.createFixedClient({
44
+ body: { name, redirectURIs: [...action.redirectURIs] },
45
+ });
46
+
47
+ console.log(`Added ${name}. Its client ID is ${created.clientID}`);
48
+
49
+ return;
50
+ }
51
+
52
+ if (action.kind === 'remove') {
53
+ const removed = await removeClient(store.db, action.clientID);
54
+
55
+ if (!removed) {
56
+ console.error(`atc clients remove: no client has the ID '${action.clientID}'`);
57
+
58
+ process.exitCode = 1;
59
+
60
+ return;
61
+ }
62
+
63
+ console.log(`Removed client ${action.clientID} and revoked every grant it held`);
64
+
65
+ return;
66
+ }
67
+
68
+ const clients = await collectClients(store.db);
69
+
70
+ if (clients.length === 0) {
71
+ console.log('No clients. Add one with: atc clients add <name> --redirect-uri <uri>');
72
+
73
+ return;
74
+ }
75
+
76
+ for (const client of clients) {
77
+ console.log(`${client.clientID} ${client.name} ${client.redirectURIs.join(' ')}`);
78
+ }
79
+ } finally {
80
+ await store.close();
81
+ }
82
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Every `--redirect-uri` value in a command line, in order, whether given as
3
+ * `--redirect-uri <uri>` or `--redirect-uri=<uri>`. The flag can repeat, and
4
+ * the argument parser keeps only its last value.
5
+ */
6
+ export function collectRedirectURIs(rawArgs: readonly string[]): readonly string[] {
7
+ return rawArgs.flatMap((arg, index) => {
8
+ if (arg.startsWith('--redirect-uri=')) {
9
+ return [arg.slice('--redirect-uri='.length)];
10
+ }
11
+
12
+ const next = rawArgs[index + 1];
13
+
14
+ return arg === '--redirect-uri' && next !== undefined ? [next] : [];
15
+ });
16
+ }
@@ -1,4 +1,5 @@
1
1
  import { encodeCursor } from '../protocol/encode-cursor';
2
+ import type { MessageID } from '../shared/message-id';
2
3
  import type { StoredEvent } from '../store/state-store';
3
4
  import type { SessionDescriptor } from './sessions';
4
5
 
@@ -9,6 +10,12 @@ export interface FleetEvent {
9
10
  readonly name: string | null;
10
11
  readonly kind: string;
11
12
  readonly detail: string | null;
13
+
14
+ // The message id on a message status event, for message.get.
15
+ readonly message?: MessageID;
16
+
17
+ // The report label on a report event.
18
+ readonly label?: string;
12
19
  }
13
20
 
14
21
  export function buildFleetEvents(
@@ -30,6 +37,8 @@ export function buildFleetEvents(
30
37
  name: live?.name ?? null,
31
38
  kind: row.kind,
32
39
  detail: row.detail,
40
+ ...(row.message === undefined ? {} : { message: row.message }),
41
+ ...(row.label === undefined ? {} : { label: row.label }),
33
42
  };
34
43
  });
35
44
  }
@@ -0,0 +1,54 @@
1
+ import { truncateDetail } from '../agents/truncate-detail';
2
+ import type { AgentSessionID } from '../shared/agent-session-id';
3
+ import type { SessionID } from '../shared/session-id';
4
+ import type { MessageRecord, MessageStatus } from '../store/message-record';
5
+ import type { TrailEntry } from '../store/trail-entry';
6
+
7
+ /**
8
+ * The trail entry for one message status change, addressed like its SessionMessage
9
+ * broadcast. It is stamped with the status's own time and the owning session's agent
10
+ * session id, falling back to the one the message carries, and the detail previews
11
+ * the answer once there is one, else the text.
12
+ */
13
+ export function buildMessageTrailEntry(
14
+ sessionID: SessionID,
15
+ agentSessionID: AgentSessionID | undefined,
16
+ record: MessageRecord,
17
+ ): TrailEntry {
18
+ const previewText = record.status === 'answered' ? (record.answer ?? record.text) : record.text;
19
+
20
+ return {
21
+ at: pickStatusTime(record),
22
+ atcID: sessionID,
23
+ agentSessionID: agentSessionID ?? record.agentSessionID ?? null,
24
+ kind: pickMessageKind(record.status),
25
+ message: record.id,
26
+ detail: truncateDetail(previewText),
27
+ };
28
+ }
29
+
30
+ function pickStatusTime(record: MessageRecord): number {
31
+ if (record.status === 'answered') {
32
+ return record.answeredAt ?? record.sentAt;
33
+ }
34
+
35
+ if (record.status === 'delivered') {
36
+ return record.deliveredAt ?? record.sentAt;
37
+ }
38
+
39
+ return record.sentAt;
40
+ }
41
+
42
+ function pickMessageKind(
43
+ status: MessageStatus,
44
+ ): 'message-accepted' | 'message-delivered' | 'message-answered' {
45
+ if (status === 'answered') {
46
+ return 'message-answered';
47
+ }
48
+
49
+ if (status === 'delivered') {
50
+ return 'message-delivered';
51
+ }
52
+
53
+ return 'message-accepted';
54
+ }
@@ -0,0 +1,25 @@
1
+ import { truncateDetail } from '../agents/truncate-detail';
2
+ import type { AgentSessionID } from '../shared/agent-session-id';
3
+ import type { SessionID } from '../shared/session-id';
4
+ import type { TrailEntry } from '../store/trail-entry';
5
+ import type { NoteReport } from './parse-report';
6
+
7
+ /**
8
+ * The trail entry for one note a session reported, carrying its label and a
9
+ * preview of its text.
10
+ */
11
+ export function buildReportTrailEntry(
12
+ sessionID: SessionID,
13
+ agentSessionID: AgentSessionID | undefined,
14
+ report: Readonly<NoteReport>,
15
+ reportedAt: number,
16
+ ): TrailEntry {
17
+ return {
18
+ at: reportedAt,
19
+ atcID: sessionID,
20
+ agentSessionID: agentSessionID ?? null,
21
+ kind: 'report',
22
+ label: report.label,
23
+ detail: truncateDetail(report.text),
24
+ };
25
+ }
@@ -11,7 +11,6 @@ import type { MessageRecord } from '../store/message-record';
11
11
  * broadcast stays small; `message.get` returns the full message. Delivery
12
12
  * and answer fields appear only once they are set.
13
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
14
  export function buildSessionMessageEvent(sessionID: SessionID, record: MessageRecord): EventMsg {
16
15
  return {
17
16
  v: PROTOCOL_V,
@@ -8,9 +8,12 @@ import { truncateToBytes } from '../shared/truncate-to-bytes';
8
8
  import type { MessageOwner } from '../store/message-owner';
9
9
  import type { MessageRecord } from '../store/message-record';
10
10
  import { StateStore } from '../store/state-store';
11
+ import type { TrailEntry } from '../store/trail-entry';
11
12
  import { ANSWER_BYTE_CAP } from './answer-byte-cap';
12
13
  import { AttachRegistry } from './attach-registry';
13
14
  import { buildFleetEvents } from './build-fleet-events';
15
+ import { buildMessageTrailEntry } from './build-message-trail-entry';
16
+ import { buildReportTrailEntry } from './build-report-trail-entry';
14
17
  import { buildSessionEvent } from './build-session-event';
15
18
  import { buildSessionMessageEvent } from './build-session-message-event';
16
19
  import { buildSessionReportEvent } from './build-session-report-event';
@@ -89,6 +92,9 @@ export interface DaemonOptions {
89
92
 
90
93
  export interface DaemonHandle {
91
94
  readonly stop: () => Promise<void>;
95
+
96
+ // How many client-protocol connections are open right now.
97
+ readonly countClients: () => number;
92
98
  }
93
99
 
94
100
  // How long a started Claude session may go without a tap before a message to
@@ -150,6 +156,27 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
150
156
  const registry = new PermissionRegistry();
151
157
  const eventSignal = new EventSignal();
152
158
 
159
+ // A trail write that fails never fails the message request or report behind it.
160
+ const recordTrailEntry = async (entry: TrailEntry) => {
161
+ try {
162
+ await store.recordTrailEntry(entry);
163
+ } catch {
164
+ return;
165
+ }
166
+
167
+ eventSignal.emit();
168
+ };
169
+
170
+ // Notes a message status change in the trail before broadcasting it, so a
171
+ // client reading the trail on the broadcast finds the change already there.
172
+ const recordMessageStatus = async (sessionID: SessionID, record: MessageRecord) => {
173
+ const s = mgr.sessions.find((x) => x.id === sessionID);
174
+
175
+ await recordTrailEntry(buildMessageTrailEntry(sessionID, s?.agentSessionID, record));
176
+
177
+ emitEvent(buildSessionMessageEvent(sessionID, record), findHookScope(sessionID));
178
+ };
179
+
153
180
  registry.onRequested = (req) => {
154
181
  emitEvent(
155
182
  {
@@ -187,7 +214,6 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
187
214
  // Hands one pending message to the session's tap, once, and reports whether
188
215
  // it did. The event goes to the tap connection alone: it never reaches
189
216
  // other clients, the events socket, or hooks.
190
- // oxlint-disable-next-line prefer-readonly-parameter-types -- every field is readonly; the branded id has no readonly form to wrap it in
191
217
  const sendInboxMessage = (sessionID: SessionID, record: MessageRecord): boolean => {
192
218
  const tap = taps.claimDelivery(sessionID, record.id);
193
219
 
@@ -242,8 +268,13 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
242
268
 
243
269
  if (sender !== undefined) {
244
270
  const capped = { ...report, text: truncateToBytes(report.text, ANSWER_BYTE_CAP) };
271
+ const reportedAt = Date.now();
272
+
273
+ await recordTrailEntry(
274
+ buildReportTrailEntry(sender.id, sender.agentSessionID, capped, reportedAt),
275
+ );
245
276
 
246
- emitEvent(buildSessionReportEvent(sender.id, capped, Date.now()), findHookScope(sender.id));
277
+ emitEvent(buildSessionReportEvent(sender.id, capped, reportedAt), findHookScope(sender.id));
247
278
  }
248
279
 
249
280
  return;
@@ -261,7 +292,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
261
292
  );
262
293
 
263
294
  if (answered !== null) {
264
- emitEvent(buildSessionMessageEvent(e.atcId, answered), findHookScope(e.atcId));
295
+ await recordMessageStatus(e.atcId, answered);
265
296
  }
266
297
  } catch {}
267
298
  };
@@ -496,6 +527,21 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
496
527
  eventSignal.emit();
497
528
  };
498
529
 
530
+ // A headless turn has no terminal hooks of its own, so the daemon writes
531
+ // its start and end to the trail under a synthetic event name.
532
+ const recordHeadlessTurnEvent = (sessionID: SessionID, ev: Readonly<AdapterEvent>) => {
533
+ const s = mgr.sessions.find((x) => x.id === sessionID);
534
+
535
+ void recordHookEvent(
536
+ {
537
+ atcId: sessionID,
538
+ event: 'HeadlessTurn',
539
+ payload: s?.agentSessionID === undefined ? {} : { session_id: s.agentSessionID },
540
+ },
541
+ ev,
542
+ );
543
+ };
544
+
499
545
  const reporter = startHookServer((e) => {
500
546
  if (e.event === 'Report') {
501
547
  void applyReport(e);
@@ -503,11 +549,16 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
503
549
  return;
504
550
  }
505
551
 
506
- const previousAgentSessionID = mgr.sessions.find((s) => s.id === e.atcId)?.agentSessionID;
552
+ const before = mgr.sessions.find((s) => s.id === e.atcId);
553
+ const previousAgentSessionID = before?.agentSessionID;
507
554
  const ev = mgr.applyHook(e);
508
555
 
556
+ // A headless session's trail comes from its runs alone: hook reports from
557
+ // its dying terminal, or from a run's own CLI, stay out of it.
558
+ const trailEvent = before?.kind === 'headless' ? null : ev;
559
+
509
560
  if (e.event !== 'Statusline') {
510
- void recordHookEvent(e, ev);
561
+ void recordHookEvent(e, trailEvent);
511
562
  }
512
563
 
513
564
  const kind = ev?.kind ?? null;
@@ -516,6 +567,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
516
567
 
517
568
  if (currentAgentSessionID !== undefined && currentAgentSessionID !== previousAgentSessionID) {
518
569
  void store.updateMessageOwner(e.atcId, previousAgentSessionID, currentAgentSessionID);
570
+ void store.updateTrailOwner(e.atcId, currentAgentSessionID);
519
571
  }
520
572
 
521
573
  if (kind === 'ended') {
@@ -633,7 +685,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
633
685
  prompt,
634
686
  settleMs: opts.ejectSettleMs ?? 4000,
635
687
  runtime,
636
- startHeadlessTurn: (sid, p) => startHeadlessTurn(mgr, findRuntime, sid, p),
688
+ startHeadlessTurn: (sid, p) =>
689
+ startHeadlessTurn(mgr, findRuntime, sid, p, recordHeadlessTurnEvent),
637
690
  });
638
691
 
639
692
  return 'ok';
@@ -743,7 +796,15 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
743
796
  return 'busy';
744
797
  }
745
798
 
746
- return startHeadlessTurn(mgr, findRuntime, sessionID, data.trimEnd()) ? 'ok' : 'dead';
799
+ return startHeadlessTurn(
800
+ mgr,
801
+ findRuntime,
802
+ sessionID,
803
+ data.trimEnd(),
804
+ recordHeadlessTurnEvent,
805
+ )
806
+ ? 'ok'
807
+ : 'dead';
747
808
  }
748
809
 
749
810
  if (s.pty === null) {
@@ -886,12 +947,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
886
947
 
887
948
  try {
888
949
  await store.writeMessage(record);
950
+
951
+ await recordMessageStatus(sessionID, record);
889
952
  } finally {
890
953
  written.resolve();
891
954
  }
892
955
 
893
- emitEvent(buildSessionMessageEvent(sessionID, record), findHookScope(sessionID));
894
-
895
956
  await drainInbox(sessionID);
896
957
 
897
958
  return record;
@@ -948,7 +1009,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
948
1009
  void drainInbox(sessionID);
949
1010
 
950
1011
  if (delivered !== null) {
951
- emitEvent(buildSessionMessageEvent(sessionID, delivered), findHookScope(sessionID));
1012
+ await recordMessageStatus(sessionID, delivered);
952
1013
 
953
1014
  return delivered;
954
1015
  }
@@ -1014,7 +1075,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1014
1075
  }
1015
1076
  };
1016
1077
 
1017
- return { stop: stopDaemon };
1078
+ return { stop: stopDaemon, countClients: () => clients.size };
1018
1079
  }
1019
1080
 
1020
1081
  // Tells a tap its subscription is over so the `atc tap` process behind it
@@ -2,6 +2,7 @@ import { writeFileSync } from 'node:fs';
2
2
  import { spawn } from 'bun-pty';
3
3
  import type { IPty } from 'bun-pty';
4
4
  import type { AdapterEvent, AgentAdapter, AgentID } from '../agents/agent-adapter';
5
+ import { truncateDetail } from '../agents/truncate-detail';
5
6
  import type { AgentSessionID } from '../shared/agent-session-id';
6
7
  import { collectCleanEnv } from '../shared/collect-clean-env';
7
8
  import { socketPath, statusFile } from '../shared/config';
@@ -302,7 +303,9 @@ export class SessionManager {
302
303
  return true;
303
304
  }
304
305
 
305
- updateSurfaceState(id: SessionID, state: SessionState, msg: string) {
306
+ // A result, when given, becomes the session's latest result the way a
307
+ // terminal turn's final message does.
308
+ updateSurfaceState(id: SessionID, state: SessionState, msg: string, result?: string) {
306
309
  const s = this.sessions.find((x) => x.id === id);
307
310
 
308
311
  if (!s || s.kind !== 'headless') {
@@ -313,6 +316,15 @@ export class SessionManager {
313
316
  s.lastMsg = msg;
314
317
  s.unread = this.focusedId !== s.id;
315
318
 
319
+ if (result !== undefined) {
320
+ s.result = truncateToBytes(result, 16_384);
321
+ s.lastDetail = truncateDetail(result);
322
+
323
+ if (s.agentSessionID !== undefined) {
324
+ void this.store.updateFleetEntry(s.agentSessionID, { result: s.result });
325
+ }
326
+ }
327
+
316
328
  this.onEvent('state', s);
317
329
  this.emitChange();
318
330
  }
@@ -6,6 +6,7 @@ import { isRecord } from '../shared/report';
6
6
  import type { SessionID } from '../shared/session-id';
7
7
  import { buildHeadlessEnv } from './build-headless-env';
8
8
  import { resolveHeadlessExecutable } from './resolve-headless-executable';
9
+ import { truncateSummary } from './truncate-summary';
9
10
 
10
11
  const PERMISSION_MODES = [
11
12
  'default',
@@ -40,7 +41,7 @@ interface HeadlessRunHandle {
40
41
  /**
41
42
  * Runs one headless Agent SDK turn over a session, rendering its structured
42
43
  * messages as plain lines into the session's output pipe. The run ends in
43
- * done (with the result as the summary) or needs_you (errors, turn limits,
44
+ * done (with the turn's whole result) or needs_you (errors, turn limits,
44
45
  * anything a human must look at).
45
46
  */
46
47
  export function startHeadlessRun(
@@ -89,7 +90,7 @@ export function startHeadlessRun(
89
90
  }
90
91
 
91
92
  if (message.subtype === 'success') {
92
- hooks.onDone(truncateSummary(message.result));
93
+ hooks.onDone(message.result);
93
94
  continue;
94
95
  }
95
96
 
@@ -170,9 +171,3 @@ export function renderSdkMessage(message: RenderableMessage): string | null {
170
171
 
171
172
  return null;
172
173
  }
173
-
174
- function truncateSummary(text: string): string {
175
- const flat = text.replaceAll('\n', ' ');
176
-
177
- return flat.length <= 200 ? flat : `${flat.slice(0, 199)}…`;
178
- }