@zgeoff/atc 2.14.0 → 2.15.1

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 (71) hide show
  1. package/package.json +2 -1
  2. package/src/agents/agent-adapter.ts +3 -38
  3. package/src/agents/build-hook-settings.ts +1 -1
  4. package/src/agents/claude-adapter.ts +3 -3
  5. package/src/agents/codex-adapter.ts +3 -3
  6. package/src/agents/gateway-adapter.ts +3 -3
  7. package/src/agents/grok-adapter.ts +3 -3
  8. package/src/agents/start-claude-headless-run.ts +1 -1
  9. package/src/client/boot-daemon.ts +2 -2
  10. package/src/client/collect-agent-picks.ts +1 -1
  11. package/src/client/daemon-client.ts +7 -23
  12. package/src/client/format-overlay-agent-mark.ts +1 -1
  13. package/src/client/index.ts +4 -2
  14. package/src/client/pick-tab-target.ts +2 -2
  15. package/src/client/spawn-picker.ts +1 -1
  16. package/src/client/to-mirror-session.ts +3 -3
  17. package/src/client/ui.ts +3 -3
  18. package/src/daemon/build-imp-provider.ts +70 -23
  19. package/src/daemon/build-message-trail-entry.ts +1 -1
  20. package/src/daemon/build-report-trail-entry.ts +1 -1
  21. package/src/daemon/build-scoped-context.ts +1 -1
  22. package/src/daemon/build-session-message-event.ts +1 -1
  23. package/src/daemon/daemon-connection.ts +9 -253
  24. package/src/daemon/daemon-context.ts +240 -0
  25. package/src/daemon/daemon.ts +9 -5
  26. package/src/daemon/execution-provider.ts +12 -0
  27. package/src/daemon/hooks.ts +6 -24
  28. package/src/daemon/idempotency-ledger.ts +78 -18
  29. package/src/daemon/imp-client-port.ts +83 -27
  30. package/src/daemon/imp-harness.ts +41 -0
  31. package/src/daemon/imp-provider.ts +5 -11
  32. package/src/daemon/local-pty-provider.ts +45 -0
  33. package/src/daemon/parse-hook-line.ts +1 -1
  34. package/src/daemon/read-imp-token.ts +30 -0
  35. package/src/daemon/sessions.ts +134 -107
  36. package/src/daemon/start-headless-turn.ts +3 -3
  37. package/src/daemon/start-session-bridge.ts +2 -2
  38. package/src/mcp/collect-clients.ts +1 -1
  39. package/src/mcp/collect-grants.ts +1 -1
  40. package/src/mcp/find-client-name.ts +1 -1
  41. package/src/mcp/mcp-auth-schema.ts +37 -0
  42. package/src/mcp/open-mcp-auth.ts +2 -2
  43. package/src/mcp/reconnecting-caller.ts +15 -6
  44. package/src/mcp/remove-client.ts +1 -1
  45. package/src/mcp/revoke-grant.ts +1 -1
  46. package/src/mcp/types.ts +0 -38
  47. package/src/mcp-http-server.ts +2 -1
  48. package/src/mcp-server.ts +3 -14
  49. package/src/protocol/adapter-event.ts +25 -0
  50. package/src/protocol/count-session-states.ts +16 -0
  51. package/src/protocol/daemon-channel.ts +17 -0
  52. package/src/protocol/hook-event.ts +11 -0
  53. package/src/protocol/line-decoder.ts +34 -0
  54. package/src/{shared → protocol}/open-bridge-socket.ts +4 -7
  55. package/src/protocol/pinned-group-key.ts +2 -0
  56. package/src/protocol/session-state.ts +5 -0
  57. package/src/protocol/sort-grouped-session-views.ts +29 -0
  58. package/src/protocol/sort-session-views.ts +41 -0
  59. package/src/protocol/sortable-session-view.ts +13 -0
  60. package/src/report.ts +1 -1
  61. package/src/run-bridge-tap.ts +2 -2
  62. package/src/shared/agent-id.ts +6 -0
  63. package/src/shared/collect-gateways.ts +1 -1
  64. package/src/shared/to-agent-id.ts +11 -0
  65. package/src/statusline.ts +1 -1
  66. package/src/store/fleet-entry.ts +6 -3
  67. package/src/store/state-store.ts +80 -19
  68. /package/src/{shared → protocol}/send-bridge-request.ts +0 -0
  69. /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
  70. /package/src/{agents → shared}/truncate-detail.ts +0 -0
  71. /package/src/{daemon → shared}/truncate-summary.ts +0 -0
@@ -0,0 +1,17 @@
1
+ /**
2
+ * One open protocol connection to a daemon, as a caller that does not own
3
+ * the transport drives it: the handshake, correlated requests, a callback
4
+ * for when the connection ends, and closing it.
5
+ */
6
+ export interface DaemonChannel {
7
+ onClose: () => void;
8
+ readonly sendHello: (build: string) => Promise<Readonly<Record<string, unknown>>>;
9
+
10
+ // A request with a principal acts as that principal.
11
+ readonly sendRequest: (
12
+ m: string,
13
+ p?: Readonly<Record<string, unknown>>,
14
+ as?: string,
15
+ ) => Promise<Readonly<Record<string, unknown>>>;
16
+ readonly stop: () => void;
17
+ }
@@ -0,0 +1,11 @@
1
+ import type { SessionID } from '../shared/session-id';
2
+
3
+ /**
4
+ * One line a hook reporter sends: the atc session it reports on, the agent's
5
+ * hook event name, and the hook's payload as the agent gave it.
6
+ */
7
+ export interface HookEvent {
8
+ atcId: SessionID;
9
+ event: string;
10
+ payload: Record<string, unknown>;
11
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Splits one stream of newline-framed text into whole lines. Bytes decode
3
+ * as UTF-8 with state kept across reads, so a multi-byte character split
4
+ * between two reads decodes whole. The unterminated tail waits for the read
5
+ * that ends it, and a line holding only whitespace is dropped. A carriage
6
+ * return before the newline stays on the line.
7
+ */
8
+ export class LineDecoder {
9
+ private readonly decoder = new TextDecoder();
10
+
11
+ private pending = '';
12
+
13
+ get pendingLength(): number {
14
+ return this.pending.length;
15
+ }
16
+
17
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
18
+ splitChunk(bytes: Uint8Array): string[] {
19
+ return this.splitText(this.decodeText(bytes));
20
+ }
21
+
22
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
23
+ decodeText(bytes: Uint8Array): string {
24
+ return this.decoder.decode(bytes, { stream: true });
25
+ }
26
+
27
+ splitText(text: string): string[] {
28
+ const lines = `${this.pending}${text}`.split('\n');
29
+
30
+ this.pending = lines.pop() ?? '';
31
+
32
+ return lines.filter((line) => line.trim() !== '');
33
+ }
34
+ }
@@ -1,3 +1,5 @@
1
+ import { LineDecoder } from './line-decoder';
2
+
1
3
  /**
2
4
  * One line-framed connection to a session bridge, from inside a remote
3
5
  * session: each line the bridge writes arrives whole, and each line written
@@ -20,10 +22,9 @@ export async function openBridgeSocket(
20
22
  onLine: (line: Readonly<Record<string, unknown>>) => void,
21
23
  ): Promise<BridgeSocket> {
22
24
  const encoder = new TextEncoder();
23
- const decoder = new TextDecoder();
25
+ const decoder = new LineDecoder();
24
26
 
25
27
  const closed = Promise.withResolvers<void>();
26
- let pending = '';
27
28
 
28
29
  let unsent = new Uint8Array(0);
29
30
 
@@ -31,11 +32,7 @@ export async function openBridgeSocket(
31
32
  unix: path,
32
33
  socket: {
33
34
  data(_socket, buf) {
34
- const lines = `${pending}${decoder.decode(buf, { stream: true })}`.split('\n');
35
-
36
- pending = lines.pop() ?? '';
37
-
38
- for (const line of lines) {
35
+ for (const line of decoder.splitChunk(buf)) {
39
36
  const parsed = parseLine(line);
40
37
 
41
38
  if (parsed !== null) {
@@ -0,0 +1,2 @@
1
+ // Never a filesystem path, so a repository can't collide with it.
2
+ export const PINNED_GROUP_KEY = ' pinned';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Where a session stands for the person watching it: busy, waiting on them,
3
+ * finished with a turn, or ended.
4
+ */
5
+ export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
@@ -0,0 +1,29 @@
1
+ import { PINNED_GROUP_KEY } from './pinned-group-key';
2
+ import { sortSessionViews } from './sort-session-views';
3
+ import type { SortableSessionView } from './sortable-session-view';
4
+
5
+ // Overlay display order for the grouped view: the flat sort with each
6
+ // repository's sessions pulled together at the position of its best-ranked
7
+ // member, so the renderer's adjacency-based headers appear once per group.
8
+ // Pinned sessions form their own leading group. A sub-session keys by its
9
+ // parent, so a set never splits across groups.
10
+ export function sortGroupedSessionViews<
11
+ T extends SortableSessionView & { readonly repoRoot: string },
12
+ >(list: readonly T[]): T[] {
13
+ const byID = new Map(list.map((s) => [s.id, s]));
14
+ const buckets = new Map<string, T[]>();
15
+
16
+ for (const s of sortSessionViews(list)) {
17
+ const owner = (s.parent === null ? undefined : byID.get(s.parent)) ?? s;
18
+ const key = owner.pinned ? PINNED_GROUP_KEY : owner.repoRoot;
19
+ const bucket = buckets.get(key);
20
+
21
+ if (bucket === undefined) {
22
+ buckets.set(key, [s]);
23
+ } else {
24
+ bucket.push(s);
25
+ }
26
+ }
27
+
28
+ return [...buckets.values()].flat();
29
+ }
@@ -0,0 +1,41 @@
1
+ import type { SessionState } from './session-state';
2
+ import type { SortableSessionView } from './sortable-session-view';
3
+
4
+ // Overlay order: pinned sessions first in most-recently-attached order, then
5
+ // everyone else by urgency — who needs you, finished turns, busy, dead —
6
+ // with most-recently-attached breaking ties inside each state. A
7
+ // sub-session sits directly under its parent, ranked among its siblings
8
+ // alone, so its attention never moves the parent's row; a sub-session whose
9
+ // parent is not listed ranks as a top-level row.
10
+ export function sortSessionViews<T extends SortableSessionView>(list: readonly T[]): T[] {
11
+ const rank: Record<SessionState, number> = {
12
+ needs_you: 0,
13
+ done: 1,
14
+ running: 2,
15
+ exited: 3,
16
+ };
17
+
18
+ const ranked = [...list].toSorted((a, b) => {
19
+ if (a.pinned !== b.pinned) {
20
+ return a.pinned ? -1 : 1;
21
+ }
22
+
23
+ const recency = b.lastAttachedAt - a.lastAttachedAt || b.createdAt - a.createdAt;
24
+
25
+ return a.pinned ? recency : rank[a.state] - rank[b.state] || recency;
26
+ });
27
+
28
+ const listed = new Set(ranked.map((s) => s.id));
29
+
30
+ const sorted: T[] = [];
31
+
32
+ for (const s of ranked) {
33
+ if (s.parent !== null && listed.has(s.parent)) {
34
+ continue;
35
+ }
36
+
37
+ sorted.push(s, ...ranked.filter((child) => child.parent === s.id));
38
+ }
39
+
40
+ return sorted;
41
+ }
@@ -0,0 +1,13 @@
1
+ import type { SessionState } from './session-state';
2
+
3
+ /**
4
+ * The fields of a session row that the overlay orders by.
5
+ */
6
+ export interface SortableSessionView {
7
+ readonly id: string;
8
+ readonly parent: string | null;
9
+ readonly state: SessionState;
10
+ readonly pinned: boolean;
11
+ readonly lastAttachedAt: number;
12
+ readonly createdAt: number;
13
+ }
package/src/report.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { sendBridgeRequest } from './protocol/send-bridge-request';
4
5
  import { sendReport } from './shared/report';
5
6
  import { REPORT_KINDS } from './shared/report-kinds';
6
- import { sendBridgeRequest } from './shared/send-bridge-request';
7
7
 
8
8
  interface ReportOptions {
9
9
  readonly message: string;
@@ -1,8 +1,8 @@
1
1
  import { readFileSync, readdirSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { z } from 'zod';
4
- import { openBridgeSocket } from './shared/open-bridge-socket';
5
- import type { BridgeSocket } from './shared/open-bridge-socket';
4
+ import { openBridgeSocket } from './protocol/open-bridge-socket';
5
+ import type { BridgeSocket } from './protocol/open-bridge-socket';
6
6
 
7
7
  const INBOX_MESSAGE_SCHEMA = z.looseObject({
8
8
  ev: z.literal('InboxMessage'),
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Which agent a session runs under: the key the adapter registry is looked
3
+ * up by. Every agent CLI supplies one, and so does every configured backend
4
+ * that drives a CLI it does not own, so two ids can share one kind.
5
+ */
6
+ export type AgentID = string;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { AgentID } from '../agents/agent-adapter';
2
+ import type { AgentID } from './agent-id';
3
3
  import { buildOptionalStringArray } from './build-optional-string-array';
4
4
  import { isRecord } from './report';
5
5
 
@@ -0,0 +1,11 @@
1
+ import type { AgentID } from './agent-id';
2
+
3
+ /**
4
+ * Missing and empty values become Claude so a fleet written before the agent
5
+ * column still restores as Claude. Any other string is returned as it stands,
6
+ * registered or not: an id whose adapter is gone must reach the caller intact
7
+ * so the session can be shown and refused, never quietly run as Claude.
8
+ */
9
+ export function toAgentID(raw: unknown): AgentID {
10
+ return typeof raw === 'string' && raw !== '' ? raw : 'claude';
11
+ }
package/src/statusline.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { sendBridgeRequest } from './protocol/send-bridge-request';
4
5
  import { statusFile } from './shared/config';
5
6
  import { isRecord, sendReport } from './shared/report';
6
- import { sendBridgeRequest } from './shared/send-bridge-request';
7
7
 
8
8
  /**
9
9
  * Runs as the statusLine command injected into wrangled sessions. Chains the
@@ -1,12 +1,12 @@
1
1
  import { z } from 'zod';
2
- import { toAgentID } from '../agents/agent-adapter';
3
- import type { AgentID } from '../agents/agent-adapter';
2
+ import type { AgentID } from '../shared/agent-id';
4
3
  import type { AgentSessionID } from '../shared/agent-session-id';
5
4
  import { buildOptionalBoolean } from '../shared/build-optional-boolean';
6
5
  import { buildOptionalString } from '../shared/build-optional-string';
7
6
  import type { DaemonID } from '../shared/daemon-id';
8
7
  import { isRecord } from '../shared/report';
9
8
  import type { SessionID } from '../shared/session-id';
9
+ import { toAgentID } from '../shared/to-agent-id';
10
10
  import { toAgentSessionID } from '../shared/to-agent-session-id';
11
11
  import type { SessionWorkspace } from './workspace-materialization';
12
12
 
@@ -72,7 +72,10 @@ export interface FleetEntry {
72
72
  export interface FleetStore {
73
73
  readonly daemonID: DaemonID;
74
74
  readonly loadFleet: () => Promise<FleetEntry[]>;
75
- readonly writeFleet: (entries: readonly FleetEntry[]) => Promise<void>;
75
+ readonly writeFleet: (
76
+ entries: readonly FleetEntry[],
77
+ removed?: readonly SessionID[],
78
+ ) => Promise<void>;
76
79
  readonly updateFleetEntry: (sessionID: SessionID, fields: FleetEntryUpdate) => Promise<void>;
77
80
  }
78
81
 
@@ -3,19 +3,20 @@ import { randomUUID } from 'node:crypto';
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
4
  import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, sql } from 'kysely';
5
5
  import type { Expression, ExpressionBuilder, SqlBool, Transaction } from 'kysely';
6
- import { toAgentID } from '../agents/agent-adapter';
7
- import type { AdapterEvent, AgentID } from '../agents/agent-adapter';
8
- import type { HookEvent } from '../daemon/hooks';
6
+ import type { AdapterEvent } from '../protocol/adapter-event';
9
7
  import { DaemonError } from '../protocol/daemon-error';
8
+ import type { HookEvent } from '../protocol/hook-event';
9
+ import type { AgentID } from '../shared/agent-id';
10
10
  import type { AgentSessionID } from '../shared/agent-session-id';
11
+ import { BunSqliteDriver } from '../shared/bun-sqlite-driver';
11
12
  import type { DaemonID } from '../shared/daemon-id';
12
13
  import type { MessageID } from '../shared/message-id';
13
14
  import type { SessionID } from '../shared/session-id';
15
+ import { toAgentID } from '../shared/to-agent-id';
14
16
  import { toAgentSessionID } from '../shared/to-agent-session-id';
15
17
  import { toDaemonID } from '../shared/to-daemon-id';
16
18
  import { toMessageID } from '../shared/to-message-id';
17
19
  import { toSessionID } from '../shared/to-session-id';
18
- import { BunSqliteDriver } from './bun-sqlite-driver';
19
20
  import { parseFleetEntry } from './fleet-entry';
20
21
  import type { FleetEntry, FleetEntryUpdate, LegacyFleetEntry } from './fleet-entry';
21
22
  import type { EffectTarget, IdempotencyClaim, IdempotencyRecord } from './idempotency-record';
@@ -241,12 +242,22 @@ export class StateStore {
241
242
  return recency;
242
243
  }
243
244
 
244
- // Replaces this daemon's rows wholesale and leaves every other daemon's
245
- // rows as they stand. A session another daemon owns, or one whose stored
246
- // ownership epoch has moved past this daemon's, rejects the whole write
247
- // with stale_epoch.
248
- async writeFleet(entries: readonly FleetEntry[]): Promise<void> {
245
+ // Rewrites this daemon's rows for the given entries and drops its rows
246
+ // for the removed sessions, plus any row of its own that an entry
247
+ // replaces by holding the same agent session id. Every other row stays as
248
+ // it stands, so a row the daemon never loaded survives a write. A session
249
+ // another daemon owns, or one whose stored ownership epoch has moved past
250
+ // this daemon's, rejects the whole write with stale_epoch.
251
+ async writeFleet(
252
+ entries: readonly FleetEntry[],
253
+ removed: readonly SessionID[] = [],
254
+ ): Promise<void> {
249
255
  const kept = buildFleetWithoutReplacedRows(entries);
256
+ const sessionIDs = [...entries.map((entry) => entry.sessionID), ...removed];
257
+
258
+ const agentSessionIDs = entries.flatMap((entry) =>
259
+ entry.agentSessionID === undefined ? [] : [entry.agentSessionID],
260
+ );
250
261
 
251
262
  await this.db.transaction().execute(async (trx) => {
252
263
  await requireCurrentOwnership(
@@ -255,17 +266,35 @@ export class StateStore {
255
266
  entries.map((entry) => entry.sessionID),
256
267
  );
257
268
 
258
- await trx
259
- .deleteFrom('fleet')
260
- .where('session_id', 'in', (eb) =>
261
- eb
262
- .selectFrom('session_owner')
263
- .select('session_id')
264
- .where('daemon_id', '=', this.daemonID),
265
- )
266
- .execute();
269
+ const replaced =
270
+ agentSessionIDs.length === 0
271
+ ? []
272
+ : await trx
273
+ .selectFrom('fleet')
274
+ .select(['session_id', 'agent_session_id'])
275
+ .where('agent_session_id', 'in', agentSessionIDs)
276
+ .execute();
267
277
 
268
- await trx.deleteFrom('session_owner').where('daemon_id', '=', this.daemonID).execute();
278
+ const dropped = [...new Set([...sessionIDs, ...replaced.map((row) => row.session_id)])];
279
+
280
+ if (dropped.length > 0) {
281
+ await trx
282
+ .deleteFrom('fleet')
283
+ .where('session_id', 'in', dropped)
284
+ .where('session_id', 'in', (eb) =>
285
+ eb
286
+ .selectFrom('session_owner')
287
+ .select('session_id')
288
+ .where('daemon_id', '=', this.daemonID),
289
+ )
290
+ .execute();
291
+
292
+ await trx
293
+ .deleteFrom('session_owner')
294
+ .where('daemon_id', '=', this.daemonID)
295
+ .where('session_id', 'in', dropped)
296
+ .execute();
297
+ }
269
298
 
270
299
  const updatedAt = Date.now();
271
300
 
@@ -304,6 +333,38 @@ export class StateStore {
304
333
  })
305
334
  .execute();
306
335
  }
336
+
337
+ // A stored row this write leaves in place follows its replaced parent
338
+ // to the session that replaced it, or to that session's own parent
339
+ // when the replacement is itself a sub-session.
340
+ const survivors = new Map(
341
+ kept.flatMap((entry) =>
342
+ entry.agentSessionID === undefined ? [] : [[entry.agentSessionID, entry] as const],
343
+ ),
344
+ );
345
+
346
+ for (const row of replaced) {
347
+ const survivor =
348
+ row.agent_session_id === null
349
+ ? undefined
350
+ : survivors.get(toAgentSessionID(row.agent_session_id));
351
+
352
+ if (survivor === undefined || survivor.sessionID === row.session_id) {
353
+ continue;
354
+ }
355
+
356
+ await trx
357
+ .updateTable('fleet')
358
+ .set({ parent_session_id: survivor.parent ?? survivor.sessionID })
359
+ .where('parent_session_id', '=', row.session_id)
360
+ .where('session_id', 'in', (eb) =>
361
+ eb
362
+ .selectFrom('session_owner')
363
+ .select('session_id')
364
+ .where('daemon_id', '=', this.daemonID),
365
+ )
366
+ .execute();
367
+ }
307
368
  });
308
369
  }
309
370
 
File without changes
File without changes
File without changes
File without changes