@zgeoff/atc 2.15.0 → 2.15.2

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 (67) 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 +2 -1
  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-fleet-events.ts +10 -4
  19. package/src/daemon/build-grant-from-fleet-entry.ts +14 -0
  20. package/src/daemon/build-message-trail-entry.ts +1 -1
  21. package/src/daemon/build-report-trail-entry.ts +1 -1
  22. package/src/daemon/build-report-view.ts +10 -4
  23. package/src/daemon/build-scoped-context.ts +64 -77
  24. package/src/daemon/build-session-message-event.ts +1 -1
  25. package/src/daemon/daemon-connection.ts +206 -288
  26. package/src/daemon/daemon-context.ts +291 -0
  27. package/src/daemon/daemon.ts +250 -94
  28. package/src/daemon/drain-inbox.ts +85 -0
  29. package/src/daemon/execution-provider.ts +12 -0
  30. package/src/daemon/hooks.ts +1 -7
  31. package/src/daemon/idempotency-ledger.ts +78 -18
  32. package/src/daemon/imp-harness.ts +41 -0
  33. package/src/daemon/is-tree-in-reach.ts +32 -0
  34. package/src/daemon/local-pty-provider.ts +45 -0
  35. package/src/daemon/parse-hook-line.ts +1 -1
  36. package/src/daemon/sessions.ts +175 -123
  37. package/src/daemon/start-headless-turn.ts +3 -3
  38. package/src/daemon/start-session-bridge.ts +2 -2
  39. package/src/daemon/tap-registry.ts +43 -5
  40. package/src/mcp/collect-clients.ts +1 -1
  41. package/src/mcp/collect-grants.ts +1 -1
  42. package/src/mcp/find-client-name.ts +1 -1
  43. package/src/mcp/mcp-auth-schema.ts +37 -0
  44. package/src/mcp/open-mcp-auth.ts +2 -2
  45. package/src/mcp/reconnecting-caller.ts +15 -6
  46. package/src/mcp/remove-client.ts +1 -1
  47. package/src/mcp/revoke-grant.ts +1 -1
  48. package/src/mcp/types.ts +0 -38
  49. package/src/mcp-http-server.ts +2 -1
  50. package/src/protocol/adapter-event.ts +25 -0
  51. package/src/protocol/count-session-states.ts +16 -0
  52. package/src/protocol/daemon-channel.ts +17 -0
  53. package/src/protocol/hook-event.ts +11 -0
  54. package/src/protocol/pinned-group-key.ts +2 -0
  55. package/src/protocol/session-state.ts +5 -0
  56. package/src/protocol/sort-grouped-session-views.ts +29 -0
  57. package/src/protocol/sort-session-views.ts +41 -0
  58. package/src/protocol/sortable-session-view.ts +13 -0
  59. package/src/shared/agent-id.ts +6 -0
  60. package/src/shared/collect-gateways.ts +1 -1
  61. package/src/shared/to-agent-id.ts +11 -0
  62. package/src/store/fleet-entry.ts +2 -2
  63. package/src/store/run-migrations.ts +55 -4
  64. package/src/store/state-store.ts +49 -13
  65. /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
  66. /package/src/{agents → shared}/truncate-detail.ts +0 -0
  67. /package/src/{daemon → shared}/truncate-summary.ts +0 -0
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { DaemonClient } from '../client/daemon-client';
2
+ import type { DaemonChannel } from '../protocol/daemon-channel';
3
3
  import type { DaemonFeature } from '../protocol/daemon-features';
4
4
  import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
5
5
  import { requireDaemonFeatures } from './require-daemon-features';
@@ -30,7 +30,7 @@ const KEYED_METHODS: ReadonlyMap<string, DaemonFeature> = new Map<string, Daemon
30
30
 
31
31
  // One handshaken connection and the features its daemon announced.
32
32
  interface DaemonConnection {
33
- readonly client: DaemonClient;
33
+ readonly client: DaemonChannel;
34
34
  readonly features: ReadonlySet<DaemonFeature>;
35
35
  }
36
36
 
@@ -48,13 +48,22 @@ export class ReconnectingCaller implements FleetCaller {
48
48
 
49
49
  private readonly build: string;
50
50
 
51
+ private readonly openChannel: (socketPath: string) => Promise<DaemonChannel>;
52
+
51
53
  private client: Promise<DaemonConnection> | null = null;
52
54
 
53
- private readonly closed = new WeakSet<DaemonClient>();
55
+ private readonly closed = new WeakSet<DaemonChannel>();
54
56
 
55
- constructor(socketPath: string, build: string) {
57
+ // The last argument connects to the daemon at a socket path; each channel
58
+ // it returns is handshaken here before a request rides it.
59
+ constructor(
60
+ socketPath: string,
61
+ build: string,
62
+ openChannel: (socketPath: string) => Promise<DaemonChannel>,
63
+ ) {
56
64
  this.socketPath = socketPath;
57
65
  this.build = build;
66
+ this.openChannel = openChannel;
58
67
  }
59
68
 
60
69
  // The required features are checked against each connection right before
@@ -141,10 +150,10 @@ export class ReconnectingCaller implements FleetCaller {
141
150
  }
142
151
  };
143
152
 
144
- let client: DaemonClient;
153
+ let client: DaemonChannel;
145
154
 
146
155
  try {
147
- client = await DaemonClient.open(this.socketPath);
156
+ client = await this.openChannel(this.socketPath);
148
157
  } catch (error) {
149
158
  resetClient();
150
159
  throw error;
@@ -1,5 +1,5 @@
1
1
  import type { Kysely } from 'kysely';
2
- import type { MCPAuthSchema } from './types';
2
+ import type { MCPAuthSchema } from './mcp-auth-schema';
3
3
 
4
4
  /**
5
5
  * Removes a client and everything it holds: its access and refresh tokens,
@@ -1,5 +1,5 @@
1
1
  import type { Kysely } from 'kysely';
2
- import type { MCPAuthSchema } from './types';
2
+ import type { MCPAuthSchema } from './mcp-auth-schema';
3
3
 
4
4
  /**
5
5
  * Revokes a grant: its access and refresh tokens go, along with the
package/src/mcp/types.ts CHANGED
@@ -25,44 +25,6 @@ export interface ToolContext {
25
25
  readonly sender: { readonly kind: 'fixed' | 'default'; readonly name: string };
26
26
  }
27
27
 
28
- /**
29
- * The columns of the authorization server's database that atc reads and
30
- * writes itself. better-auth owns the tables and their migrations; dates are
31
- * ISO 8601 strings and string lists are JSON arrays.
32
- */
33
- export interface MCPAuthSchema {
34
- readonly oauthClient: {
35
- readonly id: string;
36
- readonly clientId: string;
37
- readonly name: string | null;
38
- readonly redirectUris: string;
39
- readonly createdAt: string | null;
40
- };
41
- readonly oauthAccessToken: {
42
- readonly id: string;
43
- readonly token: string;
44
- readonly clientId: string;
45
- readonly sessionId: string | null;
46
- readonly authorizationCodeId: string | null;
47
- };
48
- readonly oauthRefreshToken: {
49
- readonly id: string;
50
- readonly clientId: string;
51
- readonly sessionId: string | null;
52
- readonly authorizationCodeId: string | null;
53
- readonly scopes: string;
54
- readonly createdAt: string;
55
- readonly expiresAt: string;
56
- readonly revoked: string | null;
57
- };
58
- readonly oauthConsent: { readonly id: string; readonly clientId: string };
59
- readonly oauthResource: { readonly identifier: string };
60
- readonly session: { readonly id: string; readonly expiresAt: string };
61
-
62
- // When each grant last reached /mcp, keyed by the grant's authorization code id.
63
- readonly atc_grant_use: { readonly grant_id: string; readonly last_used_at: string };
64
- }
65
-
66
28
  /**
67
29
  * The authorization server's store: the better-auth instance and the
68
30
  * database under it.
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync } from 'node:fs';
2
2
  import { bootDaemonClient } from './client/boot-daemon';
3
+ import { DaemonClient } from './client/daemon-client';
3
4
  import { collectClients } from './mcp/collect-clients';
4
5
  import { openMCPAuth } from './mcp/open-mcp-auth';
5
6
  import { ReconnectingCaller } from './mcp/reconnecting-caller';
@@ -27,7 +28,7 @@ export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Prom
27
28
 
28
29
  mkdirSync(stateDir, { recursive: true });
29
30
 
30
- const caller = new ReconnectingCaller(boot.socketPath, build);
31
+ const caller = new ReconnectingCaller(boot.socketPath, build, (path) => DaemonClient.open(path));
31
32
 
32
33
  const server = await startMCPHTTPServer({
33
34
  caller,
@@ -0,0 +1,25 @@
1
+ import type { AgentSessionID } from '../shared/agent-session-id';
2
+
3
+ /**
4
+ * What an adapter makes of one hook event: the agent-neutral kind and the
5
+ * fields the daemon and the store act on.
6
+ */
7
+ export interface AdapterEvent {
8
+ kind: 'started' | 'needs-input' | 'turn-done' | 'prompt-submitted' | 'ended' | 'heartbeat';
9
+ agentSessionID?: AgentSessionID;
10
+ message?: string;
11
+
12
+ // Fuller activity text than message: what the agent last said or was
13
+ // asked, for briefing. Bounded by the adapter.
14
+ detail?: string;
15
+
16
+ // Opaque handle the adapter can later pull a session name from.
17
+ nameSource?: string;
18
+
19
+ // Claude resume-existence path. Distinct from nameSource: a naming
20
+ // handle is not a resume gate.
21
+ transcriptSource?: string;
22
+
23
+ // The agent's whole final message for a finished turn; detail holds a bounded preview of it.
24
+ result?: string;
25
+ }
@@ -0,0 +1,16 @@
1
+ import type { SessionState } from './session-state';
2
+
3
+ /**
4
+ * How many of the sessions are in each state.
5
+ */
6
+ export function countSessionStates(
7
+ list: readonly { readonly state: SessionState }[],
8
+ ): Record<SessionState, number> {
9
+ const c = { needs_you: 0, running: 0, done: 0, exited: 0 };
10
+
11
+ for (const s of list) {
12
+ c[s.state]++;
13
+ }
14
+
15
+ return c;
16
+ }
@@ -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,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
+ }
@@ -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
+ }
@@ -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
 
@@ -56,6 +56,8 @@ interface EventsTable {
56
56
 
57
57
  interface SpawnHistoryTable {
58
58
  cwd: string;
59
+ target: string;
60
+ target_identity: string;
59
61
  last_spawn: number;
60
62
  }
61
63
 
@@ -152,8 +154,9 @@ export interface StateStoreSchema {
152
154
  // agent_session_id under its Claude-era name plus name and cwd, and each
153
155
  // later step adds one column the daemon grew to depend on, until the
154
156
  // rebuild that keys the table by the atc session id. events later gains
155
- // columns of its own, as does messages, while spawn_history and prefs have
156
- // carried one shape since they were added.
157
+ // columns of its own, as does messages, spawn_history is rebuilt to key each
158
+ // directory by the target it was spawned on, and prefs has carried one shape
159
+ // since it was added.
157
160
  const MIGRATIONS: Record<string, Migration> = {
158
161
  '001_create_initial_schema': {
159
162
  async up(db: Kysely<StateStoreSchema>) {
@@ -446,6 +449,29 @@ const MIGRATIONS: Record<string, Migration> = {
446
449
  await db.schema.alterTable('events').addColumn('report_text', 'text').execute();
447
450
  },
448
451
  },
452
+ '024_rebuild_spawn_history_keyed_by_target': {
453
+ async up(db: Kysely<StateStoreSchema>) {
454
+ // The ledger records this step only after it returns, so a crash in
455
+ // between leaves a rebuilt table behind a ledger that still lacks it.
456
+ const columns = await sql<ColumnInfoRow>`PRAGMA table_info(spawn_history)`.execute(db);
457
+
458
+ if (columns.rows.some((column) => column.name === 'target')) {
459
+ return;
460
+ }
461
+
462
+ await sql`BEGIN IMMEDIATE`.execute(db);
463
+
464
+ try {
465
+ await updateSpawnHistoryKeyToTarget(db);
466
+
467
+ await sql`COMMIT`.execute(db);
468
+ } catch (error) {
469
+ await sql`ROLLBACK`.execute(db);
470
+
471
+ throw error;
472
+ }
473
+ },
474
+ },
449
475
  };
450
476
 
451
477
  const PROVIDER: MigrationProvider = {
@@ -457,8 +483,9 @@ const PROVIDER: MigrationProvider = {
457
483
  * `Migrator`, one additive step at a time. A database from before the
458
484
  * ladder existed is recognized at whichever shape it stopped at by a
459
485
  * baselining pass that records the steps its columns already satisfy, so
460
- * only what is genuinely missing runs. One step rebuilds the fleet table
461
- * under a new key and carries every row across; no step drops a row.
486
+ * only what is genuinely missing runs. Two steps rebuild a table under a
487
+ * new key, the fleet and the spawn history, and carry every row across; no
488
+ * step drops a row.
462
489
  */
463
490
  export async function runMigrations(db: Kysely<StateStoreSchema>): Promise<void> {
464
491
  // A baselined step can land out of order relative to a step that turned
@@ -652,6 +679,30 @@ interface ColumnInfoRow {
652
679
  type: string;
653
680
  }
654
681
 
682
+ // The identity of the local target with no options. Every spawn from before
683
+ // spawns recorded their target ran there.
684
+ const LEGACY_SPAWN_TARGET_IDENTITY = 'local-pty:44136fa355b3678a';
685
+
686
+ async function updateSpawnHistoryKeyToTarget(db: Kysely<StateStoreSchema>): Promise<void> {
687
+ await sql`
688
+ CREATE TABLE spawn_history_rebuilt (
689
+ cwd TEXT NOT NULL,
690
+ target TEXT NOT NULL,
691
+ target_identity TEXT NOT NULL,
692
+ last_spawn INTEGER NOT NULL,
693
+ PRIMARY KEY (cwd, target, target_identity)
694
+ )
695
+ `.execute(db);
696
+
697
+ await sql`
698
+ INSERT INTO spawn_history_rebuilt (cwd, target, target_identity, last_spawn)
699
+ SELECT cwd, 'local', ${LEGACY_SPAWN_TARGET_IDENTITY}, last_spawn FROM spawn_history
700
+ `.execute(db);
701
+
702
+ await sql`DROP TABLE spawn_history`.execute(db);
703
+ await sql`ALTER TABLE spawn_history_rebuilt RENAME TO spawn_history`.execute(db);
704
+ }
705
+
655
706
  async function collectFleetColumns(db: Kysely<StateStoreSchema>): Promise<ReadonlySet<string>> {
656
707
  const result = await sql<ColumnInfoRow>`PRAGMA table_info(fleet)`.execute(db);
657
708
 
@@ -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';
@@ -63,6 +64,27 @@ export interface StoredEvent {
63
64
  readonly label?: string;
64
65
  }
65
66
 
67
+ // The target a spawn ran on: its name and the identity the spawned session
68
+ // was bound to there.
69
+ interface SpawnTarget {
70
+ readonly target: string;
71
+ readonly targetIdentity: string;
72
+ }
73
+
74
+ // A directory a spawn ran in and the target it ran on.
75
+ export interface SpawnDir {
76
+ readonly cwd: string;
77
+ readonly grant: SpawnTarget;
78
+ }
79
+
80
+ /**
81
+ * Another message one turn answered, and the atc id it was sent to.
82
+ */
83
+ export interface TurnSibling {
84
+ readonly id: MessageID;
85
+ readonly atcID: SessionID;
86
+ }
87
+
66
88
  /**
67
89
  * One report as the trail holds it. A report recorded before the trail kept
68
90
  * whole texts has only its preview, so its text is that preview and
@@ -512,24 +534,38 @@ export class StateStore {
512
534
  return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
513
535
  }
514
536
 
515
- async recordSpawnDir(cwd: string): Promise<void> {
537
+ async recordSpawnDir(cwd: string, grant: SpawnTarget): Promise<void> {
516
538
  await this.db
517
539
  .insertInto('spawn_history')
518
- .values({ cwd, last_spawn: Date.now() })
540
+ .values({
541
+ cwd,
542
+ target: grant.target,
543
+ target_identity: grant.targetIdentity,
544
+ last_spawn: Date.now(),
545
+ })
519
546
  .onConflict((oc) =>
520
- oc.column('cwd').doUpdateSet((eb) => ({ last_spawn: eb.ref('excluded.last_spawn') })),
547
+ oc
548
+ .columns(['cwd', 'target', 'target_identity'])
549
+ .doUpdateSet((eb) => ({ last_spawn: eb.ref('excluded.last_spawn') })),
521
550
  )
522
551
  .execute();
523
552
  }
524
553
 
525
- async collectSpawnDirs(): Promise<string[]> {
554
+ /**
555
+ * Each directory a spawn ran in, with the target it ran on, most recent
556
+ * first. A directory spawned on several targets has one entry for each.
557
+ */
558
+ async collectSpawnDirs(): Promise<SpawnDir[]> {
526
559
  const rows = await this.db
527
560
  .selectFrom('spawn_history')
528
- .select('cwd')
561
+ .select(['cwd', 'target', 'target_identity'])
529
562
  .orderBy('last_spawn', 'desc')
530
563
  .execute();
531
564
 
532
- return rows.map((row) => row.cwd);
565
+ return rows.map((row) => ({
566
+ cwd: row.cwd,
567
+ grant: { target: row.target, targetIdentity: row.target_identity },
568
+ }));
533
569
  }
534
570
 
535
571
  async loadLastUsedAgent(): Promise<AgentID> {
@@ -606,7 +642,7 @@ export class StateStore {
606
642
 
607
643
  // The other messages of the same session the given message's turn
608
644
  // answered, oldest first; none when the message has no turn.
609
- async collectTurnSiblings(record: MessageRecord): Promise<MessageID[]> {
645
+ async collectTurnSiblings(record: MessageRecord): Promise<TurnSibling[]> {
610
646
  if (record.turn === undefined) {
611
647
  return [];
612
648
  }
@@ -618,7 +654,7 @@ export class StateStore {
618
654
 
619
655
  const rows = await this.db
620
656
  .selectFrom('messages')
621
- .select('id')
657
+ .select(['id', 'atc_id'])
622
658
  .where('turn_id', '=', record.turn)
623
659
  .where('id', '!=', record.id)
624
660
  .where((eb) => buildOwnerFilter(eb, owner))
@@ -626,7 +662,7 @@ export class StateStore {
626
662
  .orderBy(sql`rowid`, 'asc')
627
663
  .execute();
628
664
 
629
- return rows.map((row) => toMessageID(row.id));
665
+ return rows.map((row) => ({ id: toMessageID(row.id), atcID: toSessionID(row.atc_id) }));
630
666
  }
631
667
 
632
668
  async updateMessageDelivered(
File without changes
File without changes
File without changes