@zgeoff/atc 3.4.1 → 3.5.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 (86) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +4 -0
  3. package/src/agents/build-claude-query-options.ts +1 -0
  4. package/src/agents/build-headless-env.ts +4 -0
  5. package/src/agents/claude-adapter.ts +4 -3
  6. package/src/agents/gateway-adapter.ts +2 -2
  7. package/src/agents/plan-claude-line-input.ts +44 -0
  8. package/src/client/daemon-client.ts +6 -1
  9. package/src/collect-restart-env.ts +6 -3
  10. package/src/daemon/build-scoped-context.ts +2 -0
  11. package/src/daemon/check-session-scope.ts +235 -0
  12. package/src/daemon/daemon-connection.ts +74 -0
  13. package/src/daemon/daemon-context.ts +21 -0
  14. package/src/daemon/daemon.ts +21 -0
  15. package/src/daemon/find-host-branch.ts +28 -0
  16. package/src/daemon/materialize-workspace.ts +6 -1
  17. package/src/daemon/merge-recorded-scope.ts +50 -0
  18. package/src/daemon/published-records.ts +269 -0
  19. package/src/daemon/request-access-classes.ts +1 -0
  20. package/src/daemon/run-host-git.ts +52 -0
  21. package/src/daemon/sessions.ts +185 -53
  22. package/src/daemon/start-headless-turn.ts +3 -0
  23. package/src/federation/id-rules.ts +11 -2
  24. package/src/mcp/mcp-tools.ts +51 -1
  25. package/src/mcp/require-daemon-features.ts +1 -0
  26. package/src/mcp/run-tool.ts +14 -0
  27. package/src/protocol/daemon-features.ts +5 -0
  28. package/src/protocol/parse-declared-scope.ts +158 -0
  29. package/src/protocol/protocol.ts +1 -0
  30. package/src/protocol/published-record.ts +62 -0
  31. package/src/protocol/request-param-schemas.ts +16 -0
  32. package/src/store/parse-published-record.ts +59 -0
  33. package/src/store/run-migrations.ts +23 -0
  34. package/src/store/state-store.ts +32 -0
  35. package/src/test-utils/build-mock-published-record.ts +39 -0
  36. package/src/test-utils/build-stub-brokered-agent-adapter.ts +3 -1
  37. package/src/test-utils/build-stub-daemon-context.ts +2 -0
  38. package/src/test-utils/build-stub-daemon-requests.ts +3 -3
  39. package/src/test-utils/build-stub-harness-relay.ts +2 -2
  40. package/src/test-utils/build-stub-held-gh.ts +5 -7
  41. package/src/test-utils/build-stub-held-inbox-source.ts +2 -2
  42. package/src/test-utils/build-stub-held-zoxide.ts +4 -6
  43. package/src/test-utils/build-stub-host-hold.ts +7 -6
  44. package/src/test-utils/build-stub-launch-admission.ts +20 -0
  45. package/src/test-utils/build-stub-pregranted-broker-host.ts +22 -0
  46. package/src/test-utils/build-stub-terminal.ts +3 -3
  47. package/src/test-utils/create-git-fixture.ts +2 -2
  48. package/src/test-utils/create-stub-claude.ts +3 -1
  49. package/src/test-utils/create-stub-imp-port.ts +46 -37
  50. package/src/test-utils/create-stub-systemd.ts +6 -6
  51. package/src/test-utils/isolate-home.ts +8 -5
  52. package/src/test-utils/read-query-plan.ts +10 -6
  53. package/src/test-utils/register-test-cleanup.ts +10 -10
  54. package/src/test-utils/{run-legacy-daemon.ts → run-stub-legacy-daemon.ts} +1 -1
  55. package/src/test-utils/setup-mcp-home.ts +7 -6
  56. package/src/test-utils/setup-mcp-http.ts +6 -6
  57. package/src/test-utils/setup-temp-dir.ts +4 -10
  58. package/src/test-utils/start-cut-proxy.ts +2 -4
  59. package/src/test-utils/start-daemon-process.ts +13 -13
  60. package/src/test-utils/start-git-http-server.ts +2 -4
  61. package/src/test-utils/start-mcp-stdio.ts +5 -5
  62. package/src/test-utils/start-stub-answering-listener.ts +3 -3
  63. package/src/test-utils/start-stub-closing-listener.ts +3 -3
  64. package/src/test-utils/start-stub-dropping-daemon.ts +2 -3
  65. package/src/test-utils/start-stub-impd-info.ts +3 -7
  66. package/src/test-utils/start-stub-impd.ts +3 -5
  67. package/src/test-utils/start-stub-legacy-daemon.ts +2 -3
  68. package/src/test-utils/start-stub-mcp-server.ts +3 -5
  69. package/src/test-utils/start-stub-recording-listener.ts +3 -3
  70. package/src/test-utils/start-stub-refusing-listener.ts +3 -3
  71. package/src/test-utils/start-stub-reporter-socket.ts +3 -9
  72. package/src/test-utils/start-stub-restore-daemon.ts +2 -7
  73. package/src/test-utils/start-stub-session-bridge.ts +3 -9
  74. package/src/test-utils/start-stub-silent-server.ts +3 -6
  75. package/src/test-utils/start-stub-source-daemon.ts +3 -3
  76. package/src/test-utils/start-stub-stalled-client.ts +3 -3
  77. package/src/test-utils/start-stub-stalled-listener.ts +3 -3
  78. package/src/test-utils/start-stub-tcp-daemon.ts +3 -3
  79. package/src/test-utils/start-stub-telemetry-collector.ts +2 -2
  80. package/src/test-utils/start-stub-unanswering-listener.ts +3 -3
  81. package/src/test-utils/start-stub-unanswering-unix-listener.ts +3 -3
  82. package/src/test-utils/start-test-daemon.ts +62 -18
  83. package/src/test-utils/start-tui-harness.ts +17 -10
  84. package/src/test-utils/subscribe-to-socket-lines.ts +5 -9
  85. package/src/test-utils/{can-bind-addresses.ts → try-bind-addresses.ts} +4 -3
  86. /package/src/test-utils/{run-source-daemon.ts → run-stub-source-daemon.ts} +0 -0
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A terminal that keeps every chunk written to it, in order, and draws
3
- * nothing. `getText` returns everything written. `mark` returns a position
4
- * at the end of what is written so far, and `getTextSince` returns only
3
+ * nothing. `getText` returns everything written. `countChunks` returns how
4
+ * many chunks are written so far, and `getTextSince` returns only
5
5
  * what was written after that position, so a test reads only the output of
6
6
  * the action it checks.
7
7
  */
@@ -13,7 +13,7 @@ export function buildStubTerminal() {
13
13
  chunks.push(chunk);
14
14
  },
15
15
  getText: (): string => chunks.join(''),
16
- mark: (): number => chunks.length,
16
+ countChunks: (): number => chunks.length,
17
17
  getTextSince: (mark: number): string => chunks.slice(mark).join(''),
18
18
  };
19
19
  }
@@ -23,7 +23,7 @@ interface GitFixtureConfig {
23
23
  * The first call in a process builds a template pair under the test home,
24
24
  * and each call copies it, so a fixture costs a copy instead of a run of
25
25
  * git commands. The directory is removed once the current test finishes,
26
- * so it must run inside a test; disposal removes it sooner, and a second
26
+ * so it must run inside a test; `remove` removes it sooner, and a second
27
27
  * removal does nothing.
28
28
  */
29
29
  export async function createGitFixture(config: GitFixtureConfig = {}) {
@@ -56,7 +56,7 @@ export async function createGitFixture(config: GitFixtureConfig = {}) {
56
56
  upstream,
57
57
  work,
58
58
  sha: template.sha,
59
- [Symbol.asyncDispose]: tmp[Symbol.asyncDispose],
59
+ remove: tmp.teardown,
60
60
  };
61
61
  }
62
62
 
@@ -29,7 +29,8 @@ interface StubClaudeConfig {
29
29
  * first stub to find it removes it; `fake-claude-tap` runs `atc tap` into
30
30
  * `$HOME/tap.jsonl`; `fake-claude-events.jsonl` holds more hook payloads to
31
31
  * report, one per line, after the `Notification`; `fake-claude-exit` exits
32
- * once they are reported.
32
+ * once they are reported; `fake-claude-composer-last` runs the composer once
33
+ * they are reported.
33
34
  */
34
35
  export function createStubClaude(dir: string, config: StubClaudeConfig): string {
35
36
  const atc = config.atc.map((part) => `"${part}"`).join(' ');
@@ -69,6 +70,7 @@ if [ -f "$HOME/fake-claude-events.jsonl" ]; then
69
70
  done < "$HOME/fake-claude-events.jsonl"
70
71
  fi
71
72
  if [ -f "$HOME/fake-claude-exit" ]; then exit 0; fi
73
+ if [ -f "$HOME/fake-claude-composer-last" ]; then exec "${process.execPath}" "${config.composer}"; fi
72
74
  while read -r line; do echo "GOT:$line"; done
73
75
  `,
74
76
  );
@@ -43,12 +43,10 @@ const GUEST_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
43
43
 
44
44
  // A hold on the commands whose argv holds its text: entered resolves with
45
45
  // the argv of the first command it holds, and stop lets every command it
46
- // holds run. Disposing the hold stops it, so a test that holds it with
47
- // `using` after its daemon lets the daemon stop even when the test fails.
46
+ // holds run.
48
47
  interface StubCommandHold {
49
48
  readonly entered: Promise<string>;
50
49
  readonly stop: () => void;
51
- readonly [Symbol.dispose]: () => void;
52
50
  }
53
51
 
54
52
  /**
@@ -121,6 +119,9 @@ class StubImpPort implements ImpPort {
121
119
  // Feature reads still to fail as an unreachable impd before they answer.
122
120
  private featureFailures = 0;
123
121
 
122
+ // Feature reads that failed as an unreachable impd.
123
+ private failedFeatureReads = 0;
124
+
124
125
  // Session connections whose opening still throws before it returns, as
125
126
  // an invalid authorization header makes the socket's constructor throw.
126
127
  private openFailures = 0;
@@ -198,6 +199,7 @@ class StubImpPort implements ImpPort {
198
199
 
199
200
  if (this.featureFailures > 0) {
200
201
  this.featureFailures -= 1;
202
+ this.failedFeatureReads += 1;
201
203
 
202
204
  return Promise.reject(new ImpPortError('UNREACHABLE', 'impd did not answer'));
203
205
  }
@@ -471,7 +473,7 @@ class StubImpPort implements ImpPort {
471
473
  }
472
474
 
473
475
  for (const proc of imp.sessions.values()) {
474
- proc.connection?.finish({ kind: 'closed', reason: 'imp destroyed', closeCode: 1000 });
476
+ proc.connection?.stop({ kind: 'closed', reason: 'imp destroyed', closeCode: 1000 });
475
477
  proc.connection = null;
476
478
  proc.exited ??= { code: null };
477
479
 
@@ -500,7 +502,7 @@ class StubImpPort implements ImpPort {
500
502
  sent: 0,
501
503
  finished: false,
502
504
  process: null,
503
- finish: (result) => {
505
+ stop: (result) => {
504
506
  if (connection.finished) {
505
507
  return;
506
508
  }
@@ -519,7 +521,7 @@ class StubImpPort implements ImpPort {
519
521
  // lets it go; a closed gate sends nothing.
520
522
  const sendToImpd = () => {
521
523
  if (gate !== undefined && !tryPassGate(gate)) {
522
- connection.finish({ kind: 'closed', reason: 'closed before sending', closeCode: 1000 });
524
+ connection.stop({ kind: 'closed', reason: 'closed before sending', closeCode: 1000 });
523
525
 
524
526
  return;
525
527
  }
@@ -544,7 +546,7 @@ class StubImpPort implements ImpPort {
544
546
  this.upgradeFailures -= 1;
545
547
 
546
548
  setTimeout(() => {
547
- connection.finish({ kind: 'unreachable', detail: 'the upgrade failed' });
549
+ connection.stop({ kind: 'unreachable', detail: 'the upgrade failed' });
548
550
  }, 0);
549
551
  } else if (this.upgrades === null) {
550
552
  sendToImpd();
@@ -571,7 +573,7 @@ class StubImpPort implements ImpPort {
571
573
  }
572
574
  },
573
575
  close: () => {
574
- connection.finish({ kind: 'closed', reason: 'closed by the client', closeCode: 1000 });
576
+ connection.stop({ kind: 'closed', reason: 'closed by the client', closeCode: 1000 });
575
577
  },
576
578
  };
577
579
  }
@@ -848,7 +850,7 @@ class StubImpPort implements ImpPort {
848
850
  stopConnection(name: string, session: string, closeCode: number): void {
849
851
  const proc = this.getImp(name).sessions.get(session);
850
852
 
851
- proc?.connection?.finish({ kind: 'closed', reason: `code ${closeCode}`, closeCode });
853
+ proc?.connection?.stop({ kind: 'closed', reason: `code ${closeCode}`, closeCode });
852
854
  }
853
855
 
854
856
  /**
@@ -1063,7 +1065,7 @@ class StubImpPort implements ImpPort {
1063
1065
  hold.done.resolve();
1064
1066
  };
1065
1067
 
1066
- return { entered: hold.entered.promise, stop, [Symbol.dispose]: stop };
1068
+ return { entered: hold.entered.promise, stop };
1067
1069
  }
1068
1070
 
1069
1071
  // How many commands wait for their command hold to stop.
@@ -1109,6 +1111,11 @@ class StubImpPort implements ImpPort {
1109
1111
  this.featureFailures = count;
1110
1112
  }
1111
1113
 
1114
+ // How many feature reads failed as an unreachable impd.
1115
+ countFailedFeatureReads(): number {
1116
+ return this.failedFeatureReads;
1117
+ }
1118
+
1112
1119
  /**
1113
1120
  * Refuses the next session request with an impd code and its data, or the
1114
1121
  * message of the refusal when it carries one instead.
@@ -1163,7 +1170,10 @@ class StubImpPort implements ImpPort {
1163
1170
  return proc.end;
1164
1171
  }
1165
1172
 
1166
- [Symbol.dispose](): void {
1173
+ // Kills every process and stops every forward the stand-in holds. It runs
1174
+ // once the current test finishes; calling it sooner runs it then, and a
1175
+ // second call does nothing.
1176
+ readonly stop: () => void = registerTestCleanup(() => {
1167
1177
  this.stopCommandHold();
1168
1178
 
1169
1179
  for (const imp of this.imps.values()) {
@@ -1178,7 +1188,7 @@ class StubImpPort implements ImpPort {
1178
1188
  }
1179
1189
 
1180
1190
  this.imps.clear();
1181
- }
1191
+ });
1182
1192
 
1183
1193
  // impd's refusal of a grant or revoke: the scope and pattern checks run
1184
1194
  // before anything is looked up, then a missing imp or secret.
@@ -1269,13 +1279,13 @@ class StubImpPort implements ImpPort {
1269
1279
 
1270
1280
  for (const proc of imp.sessions.values()) {
1271
1281
  tryKill(proc.pty, 'SIGSTOP');
1272
- proc.connection?.finish({ kind: 'detached', reason: 'lost', offset: proc.end });
1282
+ proc.connection?.stop({ kind: 'detached', reason: 'lost', offset: proc.end });
1273
1283
  }
1274
1284
  }
1275
1285
 
1276
1286
  private bootCold(imp: StubImp, cause: ColdBootCause): void {
1277
1287
  for (const proc of imp.sessions.values()) {
1278
- proc.connection?.finish({ kind: 'detached', reason: 'lost', offset: proc.end });
1288
+ proc.connection?.stop({ kind: 'detached', reason: 'lost', offset: proc.end });
1279
1289
  proc.ended = true;
1280
1290
 
1281
1291
  tryKill(proc.pty, 'SIGCONT');
@@ -1302,7 +1312,7 @@ class StubImpPort implements ImpPort {
1302
1312
  if (this.drops.count > 0) {
1303
1313
  this.drops.count -= 1;
1304
1314
 
1305
- connection.finish({
1315
+ connection.stop({
1306
1316
  kind: 'closed',
1307
1317
  reason: `code ${this.drops.closeCode}`,
1308
1318
  closeCode: this.drops.closeCode,
@@ -1316,7 +1326,7 @@ class StubImpPort implements ImpPort {
1316
1326
  if (failure !== null) {
1317
1327
  this.nextFailure = null;
1318
1328
 
1319
- connection.finish({
1329
+ connection.stop({
1320
1330
  kind: 'failed',
1321
1331
  code: failure.code,
1322
1332
  message: failure.message ?? `impd refused the session (${failure.code})`,
@@ -1329,7 +1339,7 @@ class StubImpPort implements ImpPort {
1329
1339
  const imp = this.imps.get(request.name);
1330
1340
 
1331
1341
  if (imp === undefined) {
1332
- connection.finish({
1342
+ connection.stop({
1333
1343
  kind: 'failed',
1334
1344
  code: 'NOT_FOUND',
1335
1345
  message: `no imp ${request.name}`,
@@ -1340,7 +1350,7 @@ class StubImpPort implements ImpPort {
1340
1350
  }
1341
1351
 
1342
1352
  if (imp.state !== 'running' && request.kind === 'attach' && !request.wake) {
1343
- connection.finish({
1353
+ connection.stop({
1344
1354
  kind: 'failed',
1345
1355
  code: 'INVALID_STATE',
1346
1356
  message: `imp ${imp.name} is ${imp.state}`,
@@ -1360,7 +1370,7 @@ class StubImpPort implements ImpPort {
1360
1370
  const brokerProblem = this.findBrokerProblem(imp, request, running);
1361
1371
 
1362
1372
  if (brokerProblem !== null) {
1363
- connection.finish({
1373
+ connection.stop({
1364
1374
  kind: 'failed',
1365
1375
  code: 'PRECONDITION_FAILED',
1366
1376
  message: `the broker is not ready in imp ${imp.name}`,
@@ -1373,7 +1383,7 @@ class StubImpPort implements ImpPort {
1373
1383
  if (running === undefined && request.kind === 'attach') {
1374
1384
  const previous = imp.previous.get(request.session);
1375
1385
 
1376
- connection.finish({
1386
+ connection.stop({
1377
1387
  kind: 'failed',
1378
1388
  code: 'NO_SESSION',
1379
1389
  message: `no session ${request.session}`,
@@ -1505,7 +1515,7 @@ class StubImpPort implements ImpPort {
1505
1515
  // delivers it.
1506
1516
  if (connection !== null) {
1507
1517
  imp.sessions.delete(request.session);
1508
- connection.finish({ kind: 'exit', code: exitCode, signal: null, offset: proc.end });
1518
+ connection.stop({ kind: 'exit', code: exitCode, signal: null, offset: proc.end });
1509
1519
  }
1510
1520
  });
1511
1521
 
@@ -1534,7 +1544,7 @@ class StubImpPort implements ImpPort {
1534
1544
  firstOffset: bufferStart,
1535
1545
  };
1536
1546
  } else if (resumeFrom.offset > proc.end) {
1537
- connection.finish({
1547
+ connection.stop({
1538
1548
  kind: 'failed',
1539
1549
  code: 'INVALID_RESUME',
1540
1550
  message: 'the resume offset is past the end',
@@ -1557,7 +1567,7 @@ class StubImpPort implements ImpPort {
1557
1567
  prelude = PRELUDE;
1558
1568
  }
1559
1569
 
1560
- proc.connection?.finish({
1570
+ proc.connection?.stop({
1561
1571
  kind: 'detached',
1562
1572
  reason: 'taken_over',
1563
1573
  offset: proc.connection.sent,
@@ -1610,8 +1620,13 @@ class StubImpPort implements ImpPort {
1610
1620
  }
1611
1621
 
1612
1622
  if (proc.exited !== null) {
1613
- imp.sessions.delete(findSessionName(imp, proc));
1614
- connection.finish({ kind: 'exit', code: proc.exited.code, signal: null, offset: proc.end });
1623
+ const name = findSessionName(imp, proc);
1624
+
1625
+ if (name !== undefined) {
1626
+ imp.sessions.delete(name);
1627
+ }
1628
+
1629
+ connection.stop({ kind: 'exit', code: proc.exited.code, signal: null, offset: proc.end });
1615
1630
 
1616
1631
  return;
1617
1632
  }
@@ -1641,16 +1656,10 @@ class StubImpPort implements ImpPort {
1641
1656
  * broker variable, and when it would join a process that started without
1642
1657
  * the broker required. Once the current test finishes, the stand-in kills
1643
1658
  * every process and stops every forward it holds, so it must be created inside
1644
- * a test; disposal does so sooner, and a second disposal does nothing.
1659
+ * a test; `stop` does so sooner, and a second stop does nothing.
1645
1660
  */
1646
1661
  export function createStubImpPort(principal = 'token:atc'): StubImpPort {
1647
- const port = new StubImpPort(principal);
1648
-
1649
- registerTestCleanup(() => {
1650
- port[Symbol.dispose]();
1651
- });
1652
-
1653
- return port;
1662
+ return new StubImpPort(principal);
1654
1663
  }
1655
1664
 
1656
1665
  interface StubLease {
@@ -1694,7 +1703,7 @@ interface StubConnection {
1694
1703
  sent: number;
1695
1704
  finished: boolean;
1696
1705
  process: StubProcess | null;
1697
- readonly finish: (outcome: ImpSessionOutcome) => void;
1706
+ readonly stop: (outcome: ImpSessionOutcome) => void;
1698
1707
  }
1699
1708
 
1700
1709
  interface StubRelay {
@@ -1734,7 +1743,7 @@ function tryEmit(connection: StubConnection, emit: () => void): boolean {
1734
1743
 
1735
1744
  return true;
1736
1745
  } catch (error) {
1737
- connection.finish({
1746
+ connection.stop({
1738
1747
  kind: 'local_error',
1739
1748
  detail: error instanceof Error ? error.message : String(error),
1740
1749
  });
@@ -1743,14 +1752,14 @@ function tryEmit(connection: StubConnection, emit: () => void): boolean {
1743
1752
  }
1744
1753
  }
1745
1754
 
1746
- function findSessionName(imp: StubImp, proc: StubProcess): string {
1755
+ function findSessionName(imp: StubImp, proc: StubProcess): string | undefined {
1747
1756
  for (const [name, held] of imp.sessions) {
1748
1757
  if (held === proc) {
1749
1758
  return name;
1750
1759
  }
1751
1760
  }
1752
1761
 
1753
- return '';
1762
+ return undefined;
1754
1763
  }
1755
1764
 
1756
1765
  function toSignal(name: string): NodeJS.Signals {
@@ -8,7 +8,7 @@ interface StubSystemd {
8
8
  // first on the PATH of every process that could reach a real one.
9
9
  readonly binDir: string;
10
10
 
11
- // The directory `ATC_PROC_ROOT` points at, where `placeInUnit` writes.
11
+ // The directory `ATC_PROC_ROOT` points at, where `writeUnitCgroup` writes.
12
12
  readonly procRoot: string;
13
13
 
14
14
  // The arguments of every `systemctl` call, one line per call.
@@ -22,9 +22,9 @@ interface StubSystemd {
22
22
 
23
23
  // Places the pid in a user service of the given name, as the cgroup of a
24
24
  // daemon run by a user manager reads.
25
- readonly placeInUnit: (pid: number, unit: string) => void;
25
+ readonly writeUnitCgroup: (pid: number, unit: string) => void;
26
26
 
27
- readonly [Symbol.dispose]: () => void;
27
+ readonly remove: () => void;
28
28
  }
29
29
 
30
30
  /**
@@ -36,7 +36,7 @@ interface StubSystemd {
36
36
  * `--` in the background with only the `--setenv` variables, as a transient
37
37
  * unit sees them, and appends its output to the `StandardOutput=append:`
38
38
  * file. Every call is logged. The stand-ins' directory is removed once the
39
- * current test finishes, so it must run inside a test; disposal removes it
39
+ * current test finishes, so it must run inside a test; `remove` removes it
40
40
  * sooner, and a second removal does nothing.
41
41
  */
42
42
  export function createStubSystemd(atcCommand: readonly string[]): StubSystemd {
@@ -118,7 +118,7 @@ exit 0
118
118
  writeMainPID: (pid) => {
119
119
  writeFileSync(join(root, 'mainpid'), String(pid));
120
120
  },
121
- placeInUnit: (pid, unit) => {
121
+ writeUnitCgroup: (pid, unit) => {
122
122
  mkdirSync(join(procRoot, String(pid)), { recursive: true });
123
123
 
124
124
  writeFileSync(
@@ -126,6 +126,6 @@ exit 0
126
126
  `0::/user.slice/user-${process.getuid?.() ?? 0}.slice/user@${process.getuid?.() ?? 0}.service/app.slice/${unit}\n`,
127
127
  );
128
128
  },
129
- [Symbol.dispose]: remove,
129
+ remove,
130
130
  };
131
131
  }
@@ -16,7 +16,7 @@ if (marker === undefined) {
16
16
  stopRun('a bare `bun test` runs against your real home; run `bun run test` instead');
17
17
  }
18
18
 
19
- assertTestHome(marker);
19
+ requireTestHome(marker);
20
20
 
21
21
  afterEach(() => {
22
22
  removeEnvOverrides();
@@ -26,7 +26,7 @@ afterEach(() => {
26
26
  // points under it and no enclosing session's variables remain; a stale or
27
27
  // hand-set marker stops the run before any test imports atc. An inherited
28
28
  // CLAUDE_CONFIG_DIR would move the Claude config folder out of the home.
29
- function assertTestHome(root: string): void {
29
+ function requireTestHome(root: string): void {
30
30
  const expected: Readonly<Record<string, string>> = {
31
31
  HOME: join(root, 'home'),
32
32
  XDG_RUNTIME_DIR: join(root, 'runtime'),
@@ -42,9 +42,12 @@ function assertTestHome(root: string): void {
42
42
  .filter(([name, path]) => normalize(process.env[name] ?? '') !== path)
43
43
  .map(([name]) => name);
44
44
 
45
- const leftover = ['ATC_SESSION_ID', 'ATC_SOCKET', 'CLAUDE_CONFIG_DIR'].filter(
46
- (name) => name in process.env,
47
- );
45
+ const leftover = [
46
+ 'ATC_SESSION_ID',
47
+ 'ATC_SOCKET',
48
+ 'ATC_SESSION_RECORD',
49
+ 'CLAUDE_CONFIG_DIR',
50
+ ].filter((name) => name in process.env);
48
51
 
49
52
  if (mismatched.length > 0 || leftover.length > 0) {
50
53
  stopRun(
@@ -9,14 +9,18 @@ import type { CompiledQuery } from 'kysely';
9
9
  * before returning, so the query never runs and nothing is written.
10
10
  */
11
11
  export function readQueryPlan(dbPath: string, query: CompiledQuery): string {
12
- using db = new Database(dbPath, { readonly: true });
12
+ const db = new Database(dbPath, { readonly: true });
13
13
 
14
14
  // oxlint-disable-next-line no-unsafe-type-assertion -- kysely's sqlite compiler only ever binds bun:sqlite-legal values
15
15
  const bindings = query.parameters as SQLQueryBindings[];
16
16
 
17
- return db
18
- .query<{ detail: string }, SQLQueryBindings[]>(`EXPLAIN QUERY PLAN ${query.sql}`)
19
- .all(...bindings)
20
- .map((row) => row.detail)
21
- .join('\n');
17
+ try {
18
+ return db
19
+ .query<{ detail: string }, SQLQueryBindings[]>(`EXPLAIN QUERY PLAN ${query.sql}`)
20
+ .all(...bindings)
21
+ .map((row) => row.detail)
22
+ .join('\n');
23
+ } finally {
24
+ db.close();
25
+ }
22
26
  }
@@ -7,16 +7,16 @@ let pending: (() => unknown)[] | null = null;
7
7
  /**
8
8
  * Registers the release of a resource a test just acquired, to run once the
9
9
  * current test finishes, and returns that release for the resource's own
10
- * stop and dispose members. Every release a test registers runs from one
11
- * hook, last registered first, as `using` declarations release, so a
12
- * directory made before a process that lives in it is removed only after
13
- * that process stops. A release that throws stops none of the others: the
14
- * hook rethrows once every release has run, as an `AggregateError` when
15
- * more than one threw. The returned release runs its callback once: every
16
- * later call, from the hook or from the test, returns what the first call
17
- * returned. Bun runs the hook after every `afterEach`, so a release works
18
- * from the paths and handles it captured. Throws outside a test, where
19
- * nothing would run the hook.
10
+ * early release, such as a `stop` member. Every release a test registers
11
+ * runs from one hook, last registered first, so a directory made before a
12
+ * process that lives in it is removed only after that process stops. A
13
+ * release that throws stops none of the others: the hook rethrows once
14
+ * every release has run, as an `AggregateError` when more than one threw.
15
+ * The returned release runs its callback once: every later call, from the
16
+ * hook or from the test, returns what the first call returned. Bun runs the
17
+ * hook after every `afterEach`, so a release works from the paths and
18
+ * handles it captured. Throws outside a test, where nothing would run the
19
+ * hook.
20
20
  */
21
21
  export function registerTestCleanup<T>(release: () => T): () => T {
22
22
  let released: { readonly value: T } | null = null;
@@ -20,7 +20,7 @@ function main() {
20
20
  const stateDir = process.argv.at(3);
21
21
 
22
22
  if (socketPath === undefined || stateDir === undefined) {
23
- throw new Error('usage: run-legacy-daemon.ts <socket path> <state dir>');
23
+ throw new Error('usage: run-stub-legacy-daemon.ts <socket path> <state dir>');
24
24
  }
25
25
 
26
26
  startStubLegacyDaemon(socketPath, { protocol: PROTOCOL_V + 1, owner: 'caller' });
@@ -15,12 +15,13 @@ import { waitFor } from './wait-for';
15
15
  * no extra arguments. A server started with this home as its `HOME` and
16
16
  * `XDG_RUNTIME_DIR` boots its daemon here. Each stand-in appends its pid,
17
17
  * which is its process group as a session leader, to `stub-pids` in the
18
- * home. Disposal stops the daemon, when one wrote its pid file here, and
18
+ * home. `teardown` stops the daemon, when one wrote its pid file here, and
19
19
  * waits for it to exit; then it kills the daemon's process group and every
20
20
  * group `stub-pids` records, such as a reporter a stopped session left
21
21
  * behind, waits until each group is empty, and removes the home. That
22
- * disposal runs once the current test finishes, so it must run inside a
23
- * test; disposing sooner runs it then, and a second disposal does nothing.
22
+ * teardown runs once the current test finishes, so it must run inside a
23
+ * test; calling `teardown` sooner runs it then, and a second teardown does
24
+ * nothing.
24
25
  */
25
26
  export function setupMCPHome() {
26
27
  const tmp = setupTempDir('atc-mcp-');
@@ -29,9 +30,9 @@ export function setupMCPHome() {
29
30
  // stand-ins stop before the home that records them is removed.
30
31
  const stack = new AsyncDisposableStack();
31
32
 
32
- const dispose = registerTestCleanup(() => stack.disposeAsync());
33
+ const teardown = registerTestCleanup(() => stack.disposeAsync());
33
34
 
34
- stack.use(tmp);
35
+ stack.defer(tmp.teardown);
35
36
 
36
37
  const home = tmp.dir;
37
38
 
@@ -60,7 +61,7 @@ export function setupMCPHome() {
60
61
  home,
61
62
  claudeBin,
62
63
  grokBin,
63
- [Symbol.asyncDispose]: dispose,
64
+ teardown,
64
65
  };
65
66
  }
66
67
 
@@ -21,13 +21,13 @@ interface MCPHTTPSetupOptions {
21
21
  * collected. The authorization server's database sits where atc keeps it
22
22
  * under `home`, so a CLI run with that home opens the same file. `store` is
23
23
  * the authorization server's database opened a second time the way
24
- * `atc clients` and `atc grants` open it, and `addClient` adds a client
24
+ * `atc clients` and `atc grants` open it, and `createClient` adds a client
25
25
  * through it and returns the client id. `restartDaemon` stops the daemon and
26
26
  * starts a fresh one on the same socket and database, the way an operator
27
27
  * restarts it, with the principals it is given; `countDaemonClients` reads
28
28
  * how many connections the current daemon holds open. Everything it starts
29
29
  * stops, and the directory is removed, once the current test finishes, so it
30
- * must run inside a test; disposal does so sooner, and a second disposal
30
+ * must run inside a test; `teardown` does so sooner, and a second teardown
31
31
  * does nothing.
32
32
  */
33
33
  export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
@@ -37,9 +37,9 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
37
37
  // before the one it depends on, and the directory goes last.
38
38
  const stack = new AsyncDisposableStack();
39
39
 
40
- const dispose = registerTestCleanup(() => stack.disposeAsync());
40
+ const teardown = registerTestCleanup(() => stack.disposeAsync());
41
41
 
42
- stack.use(tmp);
42
+ stack.defer(tmp.teardown);
43
43
 
44
44
  const socketPath = join(tmp.dir, 'daemon.sock');
45
45
  const stateDir = join(tmp.dir, '.local', 'state', 'atc');
@@ -102,7 +102,7 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
102
102
  requests,
103
103
  caller,
104
104
  store,
105
- async addClient(name: string, redirectURIs: readonly string[]): Promise<string> {
105
+ async createClient(name: string, redirectURIs: readonly string[]): Promise<string> {
106
106
  const created = await store.auth.api.createFixedClient({
107
107
  body: { name, redirectURIs: [...redirectURIs] },
108
108
  });
@@ -117,6 +117,6 @@ export async function setupMCPHTTP(options: MCPHTTPSetupOptions = {}) {
117
117
 
118
118
  daemon = await startTestDaemon(principals);
119
119
  },
120
- [Symbol.asyncDispose]: dispose,
120
+ teardown,
121
121
  };
122
122
  }
@@ -5,30 +5,24 @@ import { registerTestCleanup } from './register-test-cleanup';
5
5
 
6
6
  interface TempDir {
7
7
  readonly dir: string;
8
- readonly [Symbol.dispose]: () => void;
9
- readonly [Symbol.asyncDispose]: () => Promise<void>;
8
+ readonly teardown: () => void;
10
9
  }
11
10
 
12
11
  /**
13
12
  * Creates a directory under the system temp root, named by the prefix, and
14
13
  * registers its removal to run once the current test finishes, so it must
15
- * run inside a test. Disposal removes the tree sooner; whichever comes
14
+ * run inside a test. `teardown` removes the tree sooner; whichever comes
16
15
  * second does nothing.
17
16
  */
18
17
  export function setupTempDir(prefix: string): TempDir {
19
18
  const dir = mkdtempSync(join(tmpdir(), prefix));
20
19
 
21
- const remove = registerTestCleanup(() => {
20
+ const teardown = registerTestCleanup(() => {
22
21
  rmSync(dir, { recursive: true, force: true });
23
22
  });
24
23
 
25
24
  return {
26
25
  dir,
27
- [Symbol.dispose]: remove,
28
- [Symbol.asyncDispose]: () => {
29
- remove();
30
-
31
- return Promise.resolve();
32
- },
26
+ teardown,
33
27
  };
34
28
  }
@@ -25,7 +25,6 @@ export interface CutProxy {
25
25
  // How many answers the proxy has swallowed in hold mode.
26
26
  readonly countHeld: () => number;
27
27
  readonly stop: () => void;
28
- readonly [Symbol.dispose]: () => void;
29
28
  }
30
29
 
31
30
  // One proxied connection: the target side once it is connected, what the
@@ -49,8 +48,8 @@ interface ProxyLink {
49
48
  * the method it loses the target's next answer after the request reached
50
49
  * the target: the request runs, and the client never sees its response.
51
50
  * It stops, ending every link, once the current test finishes, so it must
52
- * run inside a test; `stop` or disposal stops it sooner, and a second stop
53
- * does nothing.
51
+ * run inside a test; `stop` stops it sooner, and a second stop does
52
+ * nothing.
54
53
  */
55
54
  export function startCutProxy(options: CutProxyOptions): CutProxy {
56
55
  let cutsLeft = options.cuts;
@@ -139,7 +138,6 @@ export function startCutProxy(options: CutProxyOptions): CutProxy {
139
138
  countRequests: () => requests,
140
139
  countHeld: () => held,
141
140
  stop,
142
- [Symbol.dispose]: stop,
143
141
  };
144
142
  }
145
143