@zgeoff/atc 3.3.0 → 3.4.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 (179) hide show
  1. package/package.json +4 -2
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-cli-argv.ts +2 -4
  4. package/src/agents/claude-adapter.ts +13 -3
  5. package/src/agents/codex-adapter.ts +11 -2
  6. package/src/agents/gateway-adapter.ts +20 -8
  7. package/src/agents/grok-adapter.ts +16 -13
  8. package/src/agents/load-claude-config-bundle.ts +6 -6
  9. package/src/agents/print-codex-hook-file.ts +7 -3
  10. package/src/agents/print-grok-hook-file.ts +8 -3
  11. package/src/agents/resolve-agent-home.ts +7 -3
  12. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  13. package/src/agents/update-claude-project-trust.ts +41 -30
  14. package/src/agents/with-claude-config-lock.ts +13 -1
  15. package/src/agents/write-atc-bridge.ts +10 -7
  16. package/src/agents/write-hook-settings.ts +13 -7
  17. package/src/client/boot-daemon.ts +48 -15
  18. package/src/client/dirs.ts +4 -2
  19. package/src/client/index.ts +17 -1
  20. package/src/client/log-client-event.ts +20 -0
  21. package/src/client/spawn-picker.ts +200 -121
  22. package/src/client/ui.ts +7 -7
  23. package/src/clients.ts +41 -11
  24. package/src/daemon/daemon.ts +143 -33
  25. package/src/daemon/event-signal.ts +19 -3
  26. package/src/daemon/make-hook-runner.ts +82 -22
  27. package/src/daemon/permission-registry.ts +11 -6
  28. package/src/daemon/restore-fleet.ts +62 -17
  29. package/src/daemon/run-eject-handoff.ts +20 -2
  30. package/src/daemon/sessions.ts +6 -0
  31. package/src/daemon/start-tcp-listener.ts +12 -10
  32. package/src/federation/daemon-caller.ts +25 -4
  33. package/src/federation/daemon-pool.ts +3 -1
  34. package/src/federation/open-gateway-caller.ts +6 -1
  35. package/src/grants.ts +36 -11
  36. package/src/hook-report.ts +17 -3
  37. package/src/mcp/read-report-texts.ts +11 -7
  38. package/src/mcp/start-mcp-http-server.ts +5 -1
  39. package/src/mcp-http-server.ts +85 -32
  40. package/src/report.ts +21 -3
  41. package/src/run-bridge-tap.ts +36 -7
  42. package/src/run-config-migrate.ts +35 -11
  43. package/src/run-gateway.ts +4 -2
  44. package/src/shared/build-atc-command.ts +7 -3
  45. package/src/shared/bun-sqlite-driver.ts +10 -0
  46. package/src/shared/claim-daemon-lock.ts +10 -4
  47. package/src/shared/collect-dir-roots.ts +6 -6
  48. package/src/shared/collect-hooks.ts +7 -6
  49. package/src/shared/config.ts +19 -9
  50. package/src/shared/system-clock.ts +24 -0
  51. package/src/sources/github/collect-github-repos.ts +1 -1
  52. package/src/sources/github/read-git-protocol.ts +1 -1
  53. package/src/sources/github/run-gh.ts +15 -9
  54. package/src/store/state-store.ts +57 -7
  55. package/src/tap.ts +44 -13
  56. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  57. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  58. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  59. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  60. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  61. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  62. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  63. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  64. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  65. package/src/test-utils/build-mock-message-record.ts +22 -0
  66. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  67. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  68. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  69. package/src/test-utils/build-mock-session.ts +43 -0
  70. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  71. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  72. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  73. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  74. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  75. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  76. package/src/test-utils/build-stub-client-screens.ts +33 -0
  77. package/src/test-utils/build-stub-clock.ts +67 -0
  78. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  79. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  80. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  81. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  82. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  83. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  84. package/src/test-utils/build-stub-gh.ts +50 -0
  85. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  86. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  87. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  88. package/src/test-utils/build-stub-held-gh.ts +19 -0
  89. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  90. package/src/test-utils/build-stub-held-provider.ts +58 -0
  91. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  92. package/src/test-utils/build-stub-host-hold.ts +31 -0
  93. package/src/test-utils/build-stub-host-provider.ts +26 -0
  94. package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
  95. package/src/test-utils/build-stub-log.ts +15 -0
  96. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  97. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  98. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  99. package/src/test-utils/build-stub-picker-host.ts +36 -0
  100. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  101. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  102. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  103. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  104. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  105. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  106. package/src/test-utils/build-stub-targets.ts +58 -0
  107. package/src/test-utils/build-stub-terminal.ts +19 -0
  108. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  109. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  110. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  111. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  112. package/src/test-utils/build-target-options-from-config.ts +37 -0
  113. package/src/test-utils/collect-process-tree.ts +75 -0
  114. package/src/test-utils/create-git-fixture.ts +120 -0
  115. package/src/test-utils/create-migrated-state-db.ts +12 -10
  116. package/src/test-utils/create-stub-bin.ts +19 -0
  117. package/src/test-utils/create-stub-claude.ts +75 -0
  118. package/src/test-utils/create-stub-codex.ts +31 -0
  119. package/src/test-utils/create-stub-composer.ts +65 -0
  120. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  121. package/src/test-utils/create-stub-grok.ts +44 -0
  122. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  123. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  124. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  125. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
  126. package/src/test-utils/get-gateway-config.ts +3 -3
  127. package/src/test-utils/get-only-effect-ref.ts +21 -0
  128. package/src/test-utils/get-only-imp-name.ts +19 -0
  129. package/src/test-utils/get-records.ts +20 -0
  130. package/src/test-utils/get-string.ts +14 -0
  131. package/src/test-utils/keys.ts +26 -0
  132. package/src/test-utils/merge-deep.ts +36 -0
  133. package/src/test-utils/mock-overrides.ts +13 -0
  134. package/src/test-utils/open-repo-step.ts +24 -0
  135. package/src/test-utils/read-query-plan.ts +22 -0
  136. package/src/test-utils/resolve-atc-command.ts +13 -0
  137. package/src/test-utils/resolve-gateway-command.ts +13 -0
  138. package/src/test-utils/run-atc.ts +56 -0
  139. package/src/test-utils/run-legacy-daemon.ts +27 -5
  140. package/src/test-utils/run-listener-daemon.ts +5 -10
  141. package/src/test-utils/run-source-daemon.ts +2 -2
  142. package/src/test-utils/seed-faker.ts +7 -0
  143. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  144. package/src/test-utils/send-mcp-request.ts +25 -0
  145. package/src/test-utils/setup-mcp-home.ts +99 -0
  146. package/src/test-utils/setup-mcp-http.ts +19 -31
  147. package/src/test-utils/spawn-claude-session.ts +34 -0
  148. package/src/test-utils/spawn-grok-session.ts +39 -0
  149. package/src/test-utils/start-cut-proxy.ts +25 -8
  150. package/src/test-utils/start-daemon-process.ts +192 -0
  151. package/src/test-utils/start-git-http-server.ts +21 -3
  152. package/src/test-utils/start-mcp-stdio.ts +195 -0
  153. package/src/test-utils/start-output-capture.ts +20 -0
  154. package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
  155. package/src/test-utils/start-stub-impd-info.ts +32 -0
  156. package/src/test-utils/start-stub-impd.ts +138 -0
  157. package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
  158. package/src/test-utils/start-stub-mcp-server.ts +50 -0
  159. package/src/test-utils/start-stub-refusing-listener.ts +38 -0
  160. package/src/test-utils/start-stub-reporter-socket.ts +77 -0
  161. package/src/test-utils/start-stub-restore-daemon.ts +58 -0
  162. package/src/test-utils/start-stub-session-bridge.ts +98 -0
  163. package/src/test-utils/start-stub-silent-server.ts +33 -0
  164. package/src/test-utils/start-stub-source-daemon.ts +39 -0
  165. package/src/test-utils/start-stub-stalled-listener.ts +40 -0
  166. package/src/test-utils/start-stub-tap.ts +38 -0
  167. package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
  168. package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
  169. package/src/test-utils/start-test-daemon.ts +215 -0
  170. package/src/test-utils/start-tui-harness.ts +284 -0
  171. package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
  172. package/src/test-utils/try-send-request.ts +29 -0
  173. package/src/test-utils/wait-for-event.ts +41 -0
  174. package/src/test-utils/wait-for.ts +15 -3
  175. package/src/verify-restored-fleet.ts +62 -13
  176. package/src/workspace/check-repository-access.ts +4 -0
  177. package/src/workspace/collect-remote-refs.ts +4 -2
  178. package/src/workspace/run-git.ts +6 -0
  179. package/src/test-utils/start-legacy-daemon.ts +0 -111
@@ -0,0 +1,58 @@
1
+ import type { ExecutionProvider, HarnessSpec, HostRequest } from '../daemon/execution-provider';
2
+ import { LocalPTYProvider } from '../daemon/local-pty-provider';
3
+
4
+ /**
5
+ * An execution provider that runs on this machine as the local
6
+ * pseudo-terminal provider does, except that each host preparation holds
7
+ * until the test calls `release` with that host, so a test can act while a
8
+ * harness is still starting. `prepares` holds the host of each preparation
9
+ * in the order it began, and `harnesses` each harness spec in the order it
10
+ * started.
11
+ */
12
+ export function buildStubHeldProvider() {
13
+ const local = new LocalPTYProvider();
14
+ const gates = new Map<string, PromiseWithResolvers<void>>();
15
+
16
+ const prepares: string[] = [];
17
+ const harnesses: HarnessSpec[] = [];
18
+
19
+ const getGate = (host: string): PromiseWithResolvers<void> => {
20
+ const gate = gates.get(host) ?? Promise.withResolvers<void>();
21
+
22
+ gates.set(host, gate);
23
+
24
+ return gate;
25
+ };
26
+
27
+ const provider: ExecutionProvider = {
28
+ kind: local.kind,
29
+ remote: local.remote,
30
+ capabilities: local.capabilities,
31
+ prepareHost: async (request: HostRequest) => {
32
+ prepares.push(request.host);
33
+
34
+ await getGate(request.host).promise;
35
+
36
+ await local.prepareHost();
37
+ },
38
+ spawnHarness: (spec) => {
39
+ harnesses.push(spec);
40
+
41
+ return local.spawnHarness(spec);
42
+ },
43
+ transferArchive: local.transferArchive,
44
+ runCommand: local.runCommand,
45
+ suspendHost: local.suspendHost,
46
+ destroyHost: local.destroyHost,
47
+ dispose: local.dispose,
48
+ };
49
+
50
+ return {
51
+ provider,
52
+ prepares,
53
+ harnesses,
54
+ release: (host: string) => {
55
+ getGate(host).resolve();
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The script of a stand-in `zoxide` that lists no directories, and waits
3
+ * for the test first: while `$HOME/zoxide-hold` exists, it touches
4
+ * `$HOME/zoxide-held` and waits for the file to go.
5
+ */
6
+ export function buildStubHeldZoxide(): string {
7
+ return `#!/bin/sh
8
+ if [ -f "$HOME/zoxide-hold" ]; then
9
+ touch "$HOME/zoxide-held"
10
+ while [ -f "$HOME/zoxide-hold" ]; do sleep 0.05; done
11
+ fi
12
+ `;
13
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A host operation a test can hold open, for a host preparation or sleep
3
+ * that must stay in flight while the test changes something. Unarmed, the
4
+ * operation resolves at once, so the arrange steps before the hold pass
5
+ * through. Once armed, the next call resolves `entered` and every call
6
+ * waits until `release`; after the release, calls resolve at once again.
7
+ */
8
+ export function buildStubHostHold() {
9
+ const entered = Promise.withResolvers<void>();
10
+ const released = Promise.withResolvers<void>();
11
+ let armed = false;
12
+
13
+ return {
14
+ hold: (): Promise<void> => {
15
+ if (!armed) {
16
+ return Promise.resolve();
17
+ }
18
+
19
+ entered.resolve();
20
+
21
+ return released.promise;
22
+ },
23
+ arm: () => {
24
+ armed = true;
25
+ },
26
+ entered: entered.promise,
27
+ release: () => {
28
+ released.resolve();
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,26 @@
1
+ import type { ExecutionProvider } from '../daemon/execution-provider';
2
+ import { LocalPTYProvider } from '../daemon/local-pty-provider';
3
+
4
+ /**
5
+ * An execution provider of kind `imp-like` whose hosts can be put to sleep
6
+ * and destroyed, as an imp's can, while every harness, archive transfer, and
7
+ * command runs on this machine through the local terminal provider.
8
+ * Readying, suspending, and destroying a host each do nothing and settle at
9
+ * once.
10
+ */
11
+ export function buildStubHostProvider(): ExecutionProvider {
12
+ const local = new LocalPTYProvider();
13
+
14
+ return {
15
+ kind: 'imp-like',
16
+ remote: false,
17
+ capabilities: { ...local.capabilities, suspend: true, destroy: true },
18
+ prepareHost: () => Promise.resolve(),
19
+ spawnHarness: local.spawnHarness,
20
+ transferArchive: local.transferArchive,
21
+ runCommand: local.runCommand,
22
+ suspendHost: () => Promise.resolve(),
23
+ destroyHost: () => Promise.resolve(),
24
+ dispose: local.dispose,
25
+ };
26
+ }
@@ -36,39 +36,24 @@ const RING_BYTES = 262_144;
36
36
  // The mode bytes a fresh attach sends ahead of a ring that has wrapped.
37
37
  const PRELUDE = '\u001B[0m';
38
38
 
39
- // The PATH every fixture process runs with: a guest's own, never the
39
+ // The PATH every stub process runs with: a guest's own, never the
40
40
  // daemon's.
41
41
  const GUEST_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
42
42
 
43
- /**
44
- * An in-process stand-in for impd behind the imp port. Each imp is a set of
45
- * real `bun-pty` processes on this machine; a sleeping imp stops them with
46
- * SIGSTOP and a wake continues them, so a memory wake keeps each process
47
- * and its generation. Every session keeps an exact 262144-byte ring with
48
- * offsets, a fresh attach skips to the next line and sends a mode prelude,
49
- * and impd's refusals carry its codes and data: `LEASED`, `LEASE_NOT_HELD`,
50
- * `NO_SESSION`, `INVALID_STATE`, `INVALID_RESUME`, `NOT_FOUND`, `CONFLICT`,
51
- * and `FORBIDDEN`. Leases belong to principals; the port acts as
52
- * `principal`, and a test adds other owners' leases, cold boots, and
53
- * dropped sockets through the controls. Grants follow impd 0.27: the
54
- * caller's identity must reach the imp and, under imp patterns, list the
55
- * secret as grantable; a grant is idempotent, one secret per host, and a
56
- * destroyed imp or a rebound or removed secret takes its grants with it. A
57
- * start or an attach that requires the broker is refused with
58
- * `PRECONDITION_FAILED` and reason `broker_not_ready`, and runs nothing,
59
- * while the broker fails or the imp holds no grant, when the start sets a
60
- * broker variable, and when it would join a process that started without
61
- * the broker required.
62
- */
63
43
  // A hold on the commands whose argv holds its text: entered resolves with
64
44
  // the argv of the first command it holds, and stop lets every command it
65
- // holds run.
66
- interface FixtureCommandHold {
45
+ // holds run. Disposing the hold stops it, so a test that holds it with
46
+ // `using` after its daemon lets the daemon stop even when the test fails.
47
+ interface StubCommandHold {
67
48
  readonly entered: Promise<string>;
68
49
  readonly stop: () => void;
50
+ readonly [Symbol.dispose]: () => void;
69
51
  }
70
52
 
71
- export class FixtureImpPort implements ImpPort {
53
+ /**
54
+ * The impd stand-in the factory below builds, one per call.
55
+ */
56
+ class StubImpPort implements ImpPort {
72
57
  // Every port call, in order, as `<call> <imp> [<detail>]`.
73
58
  readonly calls: string[] = [];
74
59
 
@@ -150,6 +135,11 @@ export class FixtureImpPort implements ImpPort {
150
135
  // The lease acquisitions wait for this hold to end, while one is held.
151
136
  private leaseHold: PromiseWithResolvers<void> | null = null;
152
137
 
138
+ // How many lease acquisitions and releases wait on their holds.
139
+ private heldLeases = 0;
140
+
141
+ private heldReleases = 0;
142
+
153
143
  // The directory a relative or missing working directory resolves
154
144
  // against inside every imp, as a guest's home does; null for the test's
155
145
  // own working directory.
@@ -183,9 +173,12 @@ export class FixtureImpPort implements ImpPort {
183
173
  // what the daemon writes still reaches the guest.
184
174
  private droppingGuestBytes = false;
185
175
 
176
+ // How many guest bytes the drop has thrown away.
177
+ private droppedGuestBytes = 0;
178
+
186
179
  private readonly principal: string;
187
180
 
188
- private readonly imps = new Map<string, FixtureImp>();
181
+ private readonly imps = new Map<string, StubImp>();
189
182
 
190
183
  private readonly forwards = new Set<{ stop: () => void; stopRelays: () => void }>();
191
184
 
@@ -308,7 +301,7 @@ export class FixtureImpPort implements ImpPort {
308
301
  );
309
302
  }
310
303
 
311
- const imp: FixtureImp = {
304
+ const imp: StubImp = {
312
305
  id: randomUUID(),
313
306
  name: spec.name,
314
307
  state: 'stopped',
@@ -335,8 +328,12 @@ export class FixtureImpPort implements ImpPort {
335
328
  }
336
329
 
337
330
  private async waitForLease(name: string, label: string, ttlSeconds: number): Promise<ImpLease> {
331
+ this.heldLeases += 1;
332
+
338
333
  await this.leaseHold?.promise;
339
334
 
335
+ this.heldLeases -= 1;
336
+
340
337
  return this.applyLease(name, label, ttlSeconds);
341
338
  }
342
339
 
@@ -401,8 +398,12 @@ export class FixtureImpPort implements ImpPort {
401
398
  }
402
399
 
403
400
  private async waitForRelease(name: string, label: string): Promise<boolean> {
401
+ this.heldReleases += 1;
402
+
404
403
  await this.releaseHold?.promise;
405
404
 
405
+ this.heldReleases -= 1;
406
+
406
407
  return this.applyRelease(name, label);
407
408
  }
408
409
 
@@ -486,7 +487,7 @@ export class FixtureImpPort implements ImpPort {
486
487
 
487
488
  const outcome = Promise.withResolvers<ImpSessionOutcome>();
488
489
 
489
- const connection: FixtureConnection = {
490
+ const connection: StubConnection = {
490
491
  handlers,
491
492
  sent: 0,
492
493
  finished: false,
@@ -625,11 +626,11 @@ export class FixtureImpPort implements ImpPort {
625
626
  ): ImpReverseForward {
626
627
  this.calls.push(`reverse ${name} ${guestPath}`);
627
628
 
628
- const server = Bun.listen<FixtureRelay>({
629
+ const server = Bun.listen<StubRelay>({
629
630
  unix: guestPath,
630
631
  socket: {
631
632
  open: (socket) => {
632
- const relay: FixtureRelay = {
633
+ const relay: StubRelay = {
633
634
  dataListeners: [],
634
635
  closeListeners: [],
635
636
  unsent: new Uint8Array(0),
@@ -675,6 +676,8 @@ export class FixtureImpPort implements ImpPort {
675
676
  },
676
677
  data: (socket, buf) => {
677
678
  if (this.droppingGuestBytes) {
679
+ this.droppedGuestBytes += buf.length;
680
+
678
681
  return;
679
682
  }
680
683
 
@@ -884,6 +887,11 @@ export class FixtureImpPort implements ImpPort {
884
887
  this.droppingGuestBytes = false;
885
888
  }
886
889
 
890
+ // How many bytes guests wrote that the drop threw away.
891
+ countDroppedGuestBytes(): number {
892
+ return this.droppedGuestBytes;
893
+ }
894
+
887
895
  /**
888
896
  * Holds every session answer back, as a slow network does, until the
889
897
  * hold stops.
@@ -892,6 +900,11 @@ export class FixtureImpPort implements ImpPort {
892
900
  this.held ??= [];
893
901
  }
894
902
 
903
+ // How many session answers wait for the answer hold to stop.
904
+ countHeldAnswers(): number {
905
+ return this.held?.length ?? 0;
906
+ }
907
+
895
908
  /**
896
909
  * Sends every held answer, in order, and answers at once again.
897
910
  */
@@ -970,6 +983,11 @@ export class FixtureImpPort implements ImpPort {
970
983
  this.leaseHold ??= Promise.withResolvers<void>();
971
984
  }
972
985
 
986
+ // How many lease acquisitions wait for the lease hold to stop.
987
+ countHeldLeases(): number {
988
+ return this.heldLeases;
989
+ }
990
+
973
991
  /**
974
992
  * Lets every held lease acquisition go through, and the next at once.
975
993
  */
@@ -985,6 +1003,11 @@ export class FixtureImpPort implements ImpPort {
985
1003
  this.releaseHold ??= Promise.withResolvers<void>();
986
1004
  }
987
1005
 
1006
+ // How many lease releases wait for the release hold to stop.
1007
+ countHeldReleases(): number {
1008
+ return this.heldReleases;
1009
+ }
1010
+
988
1011
  /**
989
1012
  * Lets every held lease release go through, and the next at once.
990
1013
  */
@@ -1007,7 +1030,7 @@ export class FixtureImpPort implements ImpPort {
1007
1030
  * stops. Throws while another hold is active, so no hold replaces one a
1008
1031
  * command still waits on.
1009
1032
  */
1010
- startCommandHold(match: string): FixtureCommandHold {
1033
+ startCommandHold(match: string): StubCommandHold {
1011
1034
  if (this.commandHold !== null) {
1012
1035
  throw new Error(`a hold on ${this.commandHold.match} is still active`);
1013
1036
  }
@@ -1020,16 +1043,15 @@ export class FixtureImpPort implements ImpPort {
1020
1043
 
1021
1044
  this.commandHold = hold;
1022
1045
 
1023
- return {
1024
- entered: hold.entered.promise,
1025
- stop: () => {
1026
- if (this.commandHold === hold) {
1027
- this.commandHold = null;
1028
- }
1046
+ const stop = () => {
1047
+ if (this.commandHold === hold) {
1048
+ this.commandHold = null;
1049
+ }
1029
1050
 
1030
- hold.done.resolve();
1031
- },
1051
+ hold.done.resolve();
1032
1052
  };
1053
+
1054
+ return { entered: hold.entered.promise, stop, [Symbol.dispose]: stop };
1033
1055
  }
1034
1056
 
1035
1057
  // Lets every command the active hold holds run, and the next at once.
@@ -1083,7 +1105,7 @@ export class FixtureImpPort implements ImpPort {
1083
1105
  return this.imps.get(name)?.state ?? null;
1084
1106
  }
1085
1107
 
1086
- // The names of the imps the fixture holds.
1108
+ // The names of the imps the stub holds.
1087
1109
  collectImpNames(): string[] {
1088
1110
  return [...this.imps.keys()];
1089
1111
  }
@@ -1104,6 +1126,15 @@ export class FixtureImpPort implements ImpPort {
1104
1126
  return proc.generation;
1105
1127
  }
1106
1128
 
1129
+ /**
1130
+ * The exit a session's process left that no connection has received, or
1131
+ * null while the process runs, once an attach delivered the exit, and for
1132
+ * a session the imp does not hold.
1133
+ */
1134
+ findUndeliveredExit(name: string, session: string): { readonly code: number | null } | null {
1135
+ return this.imps.get(name)?.sessions.get(session)?.exited ?? null;
1136
+ }
1137
+
1107
1138
  // The offset after the last byte a session's running generation wrote.
1108
1139
  getEnd(name: string, session: string): number {
1109
1140
  const proc = this.getImp(name).sessions.get(session);
@@ -1174,7 +1205,7 @@ export class FixtureImpPort implements ImpPort {
1174
1205
  }
1175
1206
  }
1176
1207
 
1177
- private getImp(name: string): FixtureImp {
1208
+ private getImp(name: string): StubImp {
1178
1209
  const imp = this.imps.get(name);
1179
1210
 
1180
1211
  if (imp === undefined) {
@@ -1184,7 +1215,7 @@ export class FixtureImpPort implements ImpPort {
1184
1215
  return imp;
1185
1216
  }
1186
1217
 
1187
- private buildView(imp: FixtureImp): ImpView {
1218
+ private buildView(imp: StubImp): ImpView {
1188
1219
  const live = [...imp.leases.values()].filter((lease) => lease.until > Date.now());
1189
1220
  const own = live.filter((lease) => lease.principal === this.principal);
1190
1221
  const others = live.filter((lease) => lease.principal !== this.principal);
@@ -1200,7 +1231,7 @@ export class FixtureImpPort implements ImpPort {
1200
1231
 
1201
1232
  // A sleeping imp wakes from memory with every process as it was; a
1202
1233
  // stopped one boots cold.
1203
- private updateAwake(imp: FixtureImp): void {
1234
+ private updateAwake(imp: StubImp): void {
1204
1235
  if (imp.state === 'sleeping') {
1205
1236
  imp.state = 'running';
1206
1237
 
@@ -1212,7 +1243,7 @@ export class FixtureImpPort implements ImpPort {
1212
1243
  }
1213
1244
  }
1214
1245
 
1215
- private updateAsleep(imp: FixtureImp): void {
1246
+ private updateAsleep(imp: StubImp): void {
1216
1247
  if (imp.state !== 'running') {
1217
1248
  return;
1218
1249
  }
@@ -1225,7 +1256,7 @@ export class FixtureImpPort implements ImpPort {
1225
1256
  }
1226
1257
  }
1227
1258
 
1228
- private bootCold(imp: FixtureImp, cause: ColdBootCause): void {
1259
+ private bootCold(imp: StubImp, cause: ColdBootCause): void {
1229
1260
  for (const proc of imp.sessions.values()) {
1230
1261
  proc.connection?.finish({ kind: 'detached', reason: 'lost', offset: proc.end });
1231
1262
  proc.ended = true;
@@ -1246,7 +1277,7 @@ export class FixtureImpPort implements ImpPort {
1246
1277
  ].slice(0, 4);
1247
1278
  }
1248
1279
 
1249
- private answerSession(request: ImpSessionRequest, connection: FixtureConnection): void {
1280
+ private answerSession(request: ImpSessionRequest, connection: StubConnection): void {
1250
1281
  if (connection.finished) {
1251
1282
  return;
1252
1283
  }
@@ -1353,9 +1384,9 @@ export class FixtureImpPort implements ImpPort {
1353
1384
  // start sets, or a running process that started without the broker
1354
1385
  // required, fails the requirement as impd's would.
1355
1386
  private findBrokerProblem(
1356
- imp: FixtureImp,
1387
+ imp: StubImp,
1357
1388
  request: ImpSessionRequest,
1358
- running: FixtureProcess | undefined,
1389
+ running: StubProcess | undefined,
1359
1390
  ): string | null {
1360
1391
  if (request.require?.includes('broker') !== true) {
1361
1392
  return null;
@@ -1393,7 +1424,7 @@ export class FixtureImpPort implements ImpPort {
1393
1424
  return posix.resolve(this.homeDir, cwd ?? '.');
1394
1425
  }
1395
1426
 
1396
- private startProcess(imp: FixtureImp, request: ImpSessionRequest): FixtureProcess | null {
1427
+ private startProcess(imp: StubImp, request: ImpSessionRequest): StubProcess | null {
1397
1428
  if (request.kind !== 'start') {
1398
1429
  return null;
1399
1430
  }
@@ -1406,7 +1437,7 @@ export class FixtureImpPort implements ImpPort {
1406
1437
  env: { ...request.env },
1407
1438
  });
1408
1439
 
1409
- const proc: FixtureProcess = {
1440
+ const proc: StubProcess = {
1410
1441
  pty,
1411
1442
  generation: randomBytes(16).toString('hex'),
1412
1443
  ring: new Uint8Array(0),
@@ -1465,10 +1496,10 @@ export class FixtureImpPort implements ImpPort {
1465
1496
  }
1466
1497
 
1467
1498
  private attachProcess(
1468
- imp: FixtureImp,
1469
- proc: FixtureProcess,
1499
+ imp: StubImp,
1500
+ proc: StubProcess,
1470
1501
  request: ImpSessionRequest,
1471
- connection: FixtureConnection,
1502
+ connection: StubConnection,
1472
1503
  created: boolean,
1473
1504
  ): void {
1474
1505
  const bufferStart = proc.end - proc.ring.length;
@@ -1572,27 +1603,52 @@ export class FixtureImpPort implements ImpPort {
1572
1603
  }
1573
1604
  }
1574
1605
 
1575
- interface FixtureLease {
1606
+ /**
1607
+ * Builds an in-process stand-in for impd behind the imp port, calling impd
1608
+ * as the principal, `token:atc` when absent. Each imp is a set of
1609
+ * real `bun-pty` processes on this machine; a sleeping imp stops them with
1610
+ * SIGSTOP and a wake continues them, so a memory wake keeps each process
1611
+ * and its generation. Every session keeps an exact 262144-byte ring with
1612
+ * offsets, a fresh attach skips to the next line and sends a mode prelude,
1613
+ * and impd's refusals carry its codes and data: `LEASED`, `LEASE_NOT_HELD`,
1614
+ * `NO_SESSION`, `INVALID_STATE`, `INVALID_RESUME`, `NOT_FOUND`, `CONFLICT`,
1615
+ * and `FORBIDDEN`. Leases belong to principals; the port acts as
1616
+ * `principal`, and a test adds other owners' leases, cold boots, and
1617
+ * dropped sockets through the controls. Grants follow impd 0.27: the
1618
+ * caller's identity must reach the imp and, under imp patterns, list the
1619
+ * secret as grantable; a grant is idempotent, one secret per host, and a
1620
+ * destroyed imp or a rebound or removed secret takes its grants with it. A
1621
+ * start or an attach that requires the broker is refused with
1622
+ * `PRECONDITION_FAILED` and reason `broker_not_ready`, and runs nothing,
1623
+ * while the broker fails or the imp holds no grant, when the start sets a
1624
+ * broker variable, and when it would join a process that started without
1625
+ * the broker required.
1626
+ */
1627
+ export function buildStubImpPort(principal = 'token:atc'): StubImpPort {
1628
+ return new StubImpPort(principal);
1629
+ }
1630
+
1631
+ interface StubLease {
1576
1632
  readonly principal: string;
1577
1633
  readonly label: string;
1578
1634
  readonly until: number;
1579
1635
  }
1580
1636
 
1581
- interface FixtureImp {
1637
+ interface StubImp {
1582
1638
  readonly id: string;
1583
1639
  readonly name: string;
1584
1640
  state: ImpState;
1585
1641
  bootId: string;
1586
1642
  coldBoots: ColdBoot[];
1587
- readonly leases: Map<string, FixtureLease>;
1588
- readonly sessions: Map<string, FixtureProcess>;
1643
+ readonly leases: Map<string, StubLease>;
1644
+ readonly sessions: Map<string, StubProcess>;
1589
1645
  readonly previous: Map<string, PreviousGeneration>;
1590
1646
 
1591
1647
  // The secrets granted to the imp.
1592
1648
  readonly grants: Set<string>;
1593
1649
  }
1594
1650
 
1595
- interface FixtureProcess {
1651
+ interface StubProcess {
1596
1652
  readonly pty: IPty;
1597
1653
  readonly generation: string;
1598
1654
  ring: Uint8Array;
@@ -1601,22 +1657,22 @@ interface FixtureProcess {
1601
1657
 
1602
1658
  // Ended by a cold boot: its exit is never delivered.
1603
1659
  ended: boolean;
1604
- connection: FixtureConnection | null;
1660
+ connection: StubConnection | null;
1605
1661
 
1606
1662
  // Whether its start required the broker, which an attach that requires
1607
1663
  // it needs.
1608
1664
  readonly requireBroker: boolean;
1609
1665
  }
1610
1666
 
1611
- interface FixtureConnection {
1667
+ interface StubConnection {
1612
1668
  readonly handlers: ImpSessionHandlers;
1613
1669
  sent: number;
1614
1670
  finished: boolean;
1615
- process: FixtureProcess | null;
1671
+ process: StubProcess | null;
1616
1672
  readonly finish: (outcome: ImpSessionOutcome) => void;
1617
1673
  }
1618
1674
 
1619
- interface FixtureRelay {
1675
+ interface StubRelay {
1620
1676
  // oxlint-disable-next-line prefer-readonly-parameter-types -- relayed bytes have no readonly form
1621
1677
  readonly dataListeners: ((data: Uint8Array) => void)[];
1622
1678
  readonly closeListeners: (() => void)[];
@@ -1647,7 +1703,7 @@ function mergeTail(ring: Uint8Array, data: Uint8Array, limit: number): Uint8Arra
1647
1703
 
1648
1704
  // Calls a connection's handler as the imp client does: a handler that
1649
1705
  // throws ends the connection with a local error and closes it.
1650
- function tryEmit(connection: FixtureConnection, emit: () => void): boolean {
1706
+ function tryEmit(connection: StubConnection, emit: () => void): boolean {
1651
1707
  try {
1652
1708
  emit();
1653
1709
 
@@ -1662,7 +1718,7 @@ function tryEmit(connection: FixtureConnection, emit: () => void): boolean {
1662
1718
  }
1663
1719
  }
1664
1720
 
1665
- function findSessionName(imp: FixtureImp, proc: FixtureProcess): string {
1721
+ function findSessionName(imp: StubImp, proc: StubProcess): string {
1666
1722
  for (const [name, held] of imp.sessions) {
1667
1723
  if (held === proc) {
1668
1724
  return name;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A log sink that records each line it is given, in order, in place of the
3
+ * daemon's stderr log: `log` takes a line and `lines` holds every line taken
4
+ * so far.
5
+ */
6
+ export function buildStubLog() {
7
+ const lines: string[] = [];
8
+
9
+ return {
10
+ lines,
11
+ log: (line: string): void => {
12
+ lines.push(line);
13
+ },
14
+ };
15
+ }
@@ -0,0 +1,26 @@
1
+ import { join } from 'node:path';
2
+
3
+ const CLI_PATH = join(import.meta.dir, '..', 'cli.ts');
4
+
5
+ /**
6
+ * The script of a stand-in `claude` for the stdio MCP suites. It appends its
7
+ * pid to `$HOME/stub-pids`, prints `FAKE_CLAUDE_UP args: ` and its
8
+ * arguments, and ends by echoing its input back until that input closes.
9
+ * Unless the file
10
+ * `$HOME/fake-claude-hold-start` exists, it first reports `SessionStart`
11
+ * through the real reporter with session id `fake-1` and transcript
12
+ * `$HOME/fake-transcript.jsonl`, and when `$HOME/fake-claude-note` exists it
13
+ * files that file's text as a report labelled `decision`.
14
+ */
15
+ export function buildStubMCPClaude(): string {
16
+ const cli = `"${process.execPath}" "${CLI_PATH}"`;
17
+
18
+ return `#!/usr/bin/env bash
19
+ echo $$ >> "$HOME/stub-pids"
20
+ echo "FAKE_CLAUDE_UP args: $@"
21
+ if [ -f "$HOME/fake-claude-hold-start" ]; then exec cat; fi
22
+ printf '{"hook_event_name":"SessionStart","session_id":"fake-1","transcript_path":"'"$HOME"'/fake-transcript.jsonl"}' | ${cli} hook-report
23
+ if [ -f "$HOME/fake-claude-note" ]; then ${cli} report note --label decision < "$HOME/fake-claude-note"; fi
24
+ exec cat
25
+ `;
26
+ }
@@ -0,0 +1,19 @@
1
+ import { join } from 'node:path';
2
+
3
+ const CLI_PATH = join(import.meta.dir, '..', 'cli.ts');
4
+
5
+ /**
6
+ * The script of a stand-in `grok` for the stdio MCP suites. It appends its
7
+ * pid to `$HOME/stub-pids`, prints `FAKE_GROK_UP args: ` and its arguments,
8
+ * reports `session_start` through the real reporter with session id
9
+ * `fake-grok-1` and its working directory, then echoes its input back until
10
+ * that input closes.
11
+ */
12
+ export function buildStubMCPGrok(): string {
13
+ return `#!/usr/bin/env bash
14
+ echo $$ >> "$HOME/stub-pids"
15
+ echo "FAKE_GROK_UP args: $@"
16
+ printf '{"hookEventName":"session_start","sessionId":"fake-grok-1","cwd":"%s"}' "$PWD" | "${process.execPath}" "${CLI_PATH}" hook-report
17
+ exec cat
18
+ `;
19
+ }