@zgeoff/atc 3.1.1 → 3.3.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 (78) hide show
  1. package/README.md +6 -0
  2. package/package.json +4 -3
  3. package/src/build-own-daemon-process.ts +16 -0
  4. package/src/build-restart-failure.ts +24 -0
  5. package/src/cli.ts +96 -0
  6. package/src/client/boot-daemon.ts +5 -32
  7. package/src/collect-restart-env.ts +18 -0
  8. package/src/collect-restart-plan.ts +157 -0
  9. package/src/daemon/build-scoped-context.ts +4 -2
  10. package/src/daemon/daemon-connection.ts +4 -1
  11. package/src/daemon/daemon-context.ts +12 -1
  12. package/src/daemon/daemon.ts +26 -4
  13. package/src/daemon/local-pty-provider.ts +83 -10
  14. package/src/daemon/read-native-env-keys.ts +74 -0
  15. package/src/daemon-process.ts +12 -0
  16. package/src/find-restart-unit.ts +45 -0
  17. package/src/format-restart-preflight.ts +74 -0
  18. package/src/format-restart-report.ts +45 -0
  19. package/src/is-daemon-command-line.ts +33 -0
  20. package/src/is-daemon-process.ts +33 -0
  21. package/src/mcp/require-daemon-features.ts +1 -0
  22. package/src/mcp/run-tool.ts +70 -15
  23. package/src/parse-daemon-flags.ts +33 -0
  24. package/src/parse-mismatch-build.ts +19 -0
  25. package/src/parse-restart-result.ts +65 -0
  26. package/src/parse-systemd-unit.ts +25 -0
  27. package/src/protocol/daemon-features.ts +5 -0
  28. package/src/protocol/protocol.ts +2 -0
  29. package/src/protocol/request-param-schemas.ts +6 -0
  30. package/src/read-daemon-process.ts +50 -0
  31. package/src/read-fleet-snapshot.ts +37 -0
  32. package/src/read-restart-result.ts +17 -0
  33. package/src/read-stored-fleet-file.ts +74 -0
  34. package/src/read-stored-rows.ts +36 -0
  35. package/src/remove-restart-result.ts +11 -0
  36. package/src/restart-daemon.ts +149 -0
  37. package/src/restart-plan.ts +33 -0
  38. package/src/run-daemon-restart-worker.ts +77 -0
  39. package/src/run-daemon-restart.ts +228 -0
  40. package/src/run-systemctl.ts +33 -0
  41. package/src/shared/build-atc-command.ts +13 -0
  42. package/src/shared/config.ts +5 -0
  43. package/src/shared/find-pid-file-pid.ts +15 -0
  44. package/src/shared/get-build.ts +4 -3
  45. package/src/shared/is-process-alive.ts +21 -0
  46. package/src/shared/spawn-atc-detached.ts +34 -0
  47. package/src/start-replacement-daemon.ts +31 -0
  48. package/src/stop-daemon-process.ts +46 -0
  49. package/src/store/state-store.ts +2 -2
  50. package/src/test-utils/can-bind-addresses.ts +19 -0
  51. package/src/test-utils/create-migrated-state-db.ts +38 -0
  52. package/src/test-utils/env-originals.ts +6 -0
  53. package/src/test-utils/fixture-dir-provider.ts +131 -0
  54. package/src/test-utils/fixture-imp-port.ts +1692 -0
  55. package/src/test-utils/get-agent-entry.ts +17 -0
  56. package/src/test-utils/get-gateway-config.ts +18 -0
  57. package/src/test-utils/isolate-home.ts +60 -0
  58. package/src/test-utils/read-json-record.ts +20 -0
  59. package/src/test-utils/remove-env-overrides.ts +18 -0
  60. package/src/test-utils/run-legacy-daemon.ts +38 -0
  61. package/src/test-utils/run-listener-daemon.ts +51 -0
  62. package/src/test-utils/run-mcp-authorization.ts +118 -0
  63. package/src/test-utils/run-source-daemon.ts +110 -0
  64. package/src/test-utils/setup-fake-systemd.ts +125 -0
  65. package/src/test-utils/setup-mcp-http.ts +126 -0
  66. package/src/test-utils/setup-temp-dir.ts +33 -0
  67. package/src/test-utils/spawn-named-session.ts +27 -0
  68. package/src/test-utils/start-cut-proxy.ts +175 -0
  69. package/src/test-utils/start-git-http-server.ts +102 -0
  70. package/src/test-utils/start-legacy-daemon.ts +111 -0
  71. package/src/test-utils/subscribe-to-socket-lines.ts +60 -0
  72. package/src/test-utils/update-env.ts +19 -0
  73. package/src/test-utils/wait-for.ts +38 -0
  74. package/src/verify-restored-fleet.ts +155 -0
  75. package/src/wait-for-replacement.ts +112 -0
  76. package/src/write-restart-result.ts +17 -0
  77. /package/src/{daemon → shared}/claim-daemon-lock.ts +0 -0
  78. /package/src/{daemon → shared}/load-listener-tokens.ts +0 -0
package/README.md CHANGED
@@ -98,6 +98,12 @@ fleet when you are ready. The bar shows `⟳ restarting daemon` until the restar
98
98
  running daemon speaks another protocol version, `atc` asks before it restarts the daemon, since the
99
99
  restart ends every session the daemon hosts.
100
100
 
101
+ `atc daemon restart` does the same from a script or from inside a session: it stops the daemon,
102
+ starts one in its place, restores the fleet, and exits 0 only when every stored row came back.
103
+ `atc daemon restart --dry-run` prints what the restart would stop and which sessions are mid-turn.
104
+ [Daemon architecture](./docs/architecture/daemon.md#restarting-the-daemon) covers the handoff and
105
+ the exit codes.
106
+
101
107
  atc runs inside zellij or tmux. Give the pane locked mode so the leader key reaches atc.
102
108
 
103
109
  ## Grok and Codex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "3.1.1",
3
+ "version": "3.3.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -28,8 +28,9 @@
28
28
  "deadcode": "knip",
29
29
  "lint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error",
30
30
  "lint:fix": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error --fix",
31
- "test": "bash scripts/with-test-home.sh bun test",
32
- "test:binary": "bash scripts/with-test-home.sh bun test --timeout 20000 test/daemon-e2e.test.ts",
31
+ "test": "bash scripts/with-test-home.sh bun test --parallel --timings=bun-test-timings.json",
32
+ "test:timings": "bash scripts/with-test-home.sh bun test --parallel --timings=bun-test-timings.json --update-timings",
33
+ "test:binary": "bash scripts/with-test-home.sh bun test --timeout 20000 e2e/daemon-e2e.test.ts",
33
34
  "test:gateway-binary": "bash scripts/with-test-home.sh bun test --timeout 20000 src/gateway.test.ts",
34
35
  "test:atc-bridge": "bash scripts/with-test-home.sh bash scripts/test-atc-bridge.sh",
35
36
  "test:isolation": "bash scripts/check-test-isolation.sh",
@@ -0,0 +1,16 @@
1
+ import { collectRestartEnv } from './collect-restart-env';
2
+ import type { DaemonProcess } from './daemon-process';
3
+
4
+ /**
5
+ * The start a daemon gets when no running one supplies it: this process's
6
+ * environment without the session variables, no working directory, and no
7
+ * listener flags.
8
+ */
9
+ export function buildOwnDaemonProcess(): DaemonProcess {
10
+ return {
11
+ env: collectRestartEnv(process.env),
12
+ cwd: null,
13
+ flags: { listen: null, tokenFile: null },
14
+ fromProc: false,
15
+ };
16
+ }
@@ -0,0 +1,24 @@
1
+ import type { RestartResult } from './parse-restart-result';
2
+
3
+ /**
4
+ * The result of a restart that stopped with `error` before it could count
5
+ * the fleet. `interrupted` lists the sessions the stop already ended.
6
+ */
7
+ export function buildRestartFailure(
8
+ runID: string,
9
+ error: string,
10
+ interrupted: RestartResult['interrupted'] = [],
11
+ ): RestartResult {
12
+ return {
13
+ runID,
14
+ code: 1,
15
+ pid: null,
16
+ build: null,
17
+ listenPort: null,
18
+ restored: 0,
19
+ total: 0,
20
+ failed: [],
21
+ interrupted,
22
+ error,
23
+ };
24
+ }
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // atc CLI entry: no subcommand opens the client TUI; `hook-report` and
2
2
  // `statusline` are the commands injected into wrangled sessions.
3
+ import { resolve } from 'node:path';
3
4
  import { defineCommand, runMain } from 'citty';
4
5
  import pkg from '../package.json';
5
6
  import { collectRedirectURIs } from './collect-redirect-uris';
@@ -252,6 +253,64 @@ const main = defineCommand({
252
253
  await id.runDaemonID(getBuild());
253
254
  },
254
255
  }),
256
+ restart: () =>
257
+ defineCommand({
258
+ meta: {
259
+ name: 'restart',
260
+ description: 'Restart the running daemon and restore its fleet',
261
+ },
262
+ args: {
263
+ ...DAEMON_ARGS,
264
+ timeout: {
265
+ type: 'string',
266
+ description: 'Seconds to wait for the restored fleet to come alive',
267
+ },
268
+ 'dry-run': {
269
+ type: 'boolean',
270
+ default: false,
271
+ description: 'Print what the restart would do and stop',
272
+ },
273
+ },
274
+ async run(ctx) {
275
+ const options = parseRestartArgs(ctx.args);
276
+
277
+ const restart = await import('./run-daemon-restart');
278
+
279
+ const code = await restart.runDaemonRestart({
280
+ ...options,
281
+ dryRun: ctx.args['dry-run'],
282
+ });
283
+
284
+ process.exit(code);
285
+ },
286
+ }),
287
+ 'restart-worker': () =>
288
+ defineCommand({
289
+ meta: {
290
+ name: 'restart-worker',
291
+ description: 'Run one daemon restart for atc daemon restart',
292
+ hidden: true,
293
+ },
294
+ args: {
295
+ ...DAEMON_ARGS,
296
+ runID: { type: 'positional', required: true, description: 'The run id' },
297
+ session: { type: 'string', description: 'The session that asked for the restart' },
298
+ timeout: { type: 'string', description: 'Seconds to wait for the restored fleet' },
299
+ },
300
+ async run(ctx) {
301
+ const options = parseRestartArgs(ctx.args);
302
+
303
+ const worker = await import('./run-daemon-restart-worker');
304
+
305
+ const code = await worker.runDaemonRestartWorker({
306
+ ...options,
307
+ runID: ctx.args.runID,
308
+ callerSession: ctx.args.session ?? null,
309
+ });
310
+
311
+ process.exit(code);
312
+ },
313
+ }),
255
314
  },
256
315
  }),
257
316
  events: () =>
@@ -379,6 +438,43 @@ const main = defineCommand({
379
438
  },
380
439
  });
381
440
 
441
+ interface RestartArgs {
442
+ readonly listen?: string | undefined;
443
+ readonly 'token-file'?: string | undefined;
444
+ readonly timeout?: string | undefined;
445
+ }
446
+
447
+ interface RestartFlags {
448
+ readonly listen: string | null;
449
+ readonly tokenFile: string | null;
450
+ readonly timeoutSeconds: number | null;
451
+ }
452
+
453
+ // Reads the flags the restart commands share, and stops the command on one
454
+ // the replacement daemon would refuse after the old one is already gone.
455
+ function parseRestartArgs(args: RestartArgs): RestartFlags {
456
+ const listen = args.listen ?? null;
457
+ const parsedListen = listen === null ? null : parseListenAddress(listen);
458
+
459
+ if (parsedListen !== null && !parsedListen.ok) {
460
+ console.error(`atc daemon restart: ${parsedListen.message}`);
461
+ process.exit(1);
462
+ }
463
+
464
+ const timeout = args.timeout === undefined ? null : Number(args.timeout);
465
+
466
+ if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
467
+ console.error('atc daemon restart: --timeout takes a number of seconds above 0');
468
+ process.exit(1);
469
+ }
470
+
471
+ // An explicit token file is read where the caller runs, not in the old
472
+ // daemon's directory, so a relative path is fixed here before the handoff.
473
+ const tokenFile = args['token-file'] === undefined ? null : resolve(args['token-file']);
474
+
475
+ return { listen, tokenFile, timeoutSeconds: timeout };
476
+ }
477
+
382
478
  // Runs the daemon in the foreground until SIGTERM, with a TCP listener when
383
479
  // --listen and --token-file are given.
384
480
  async function runDaemon(listenArg: string | null, tokenFile: string | null): Promise<void> {
@@ -1,7 +1,4 @@
1
- import { spawn as spawnChild } from 'node:child_process';
2
1
  import type { ChildProcess } from 'node:child_process';
3
- import { readFileSync } from 'node:fs';
4
- import { join } from 'node:path';
5
2
  import { DaemonError } from '../protocol/daemon-error';
6
3
  import type { DaemonFeature } from '../protocol/daemon-features';
7
4
  import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
@@ -9,9 +6,11 @@ import { PROTOCOL_V } from '../protocol/protocol';
9
6
  import type { AgentID } from '../shared/agent-id';
10
7
  import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
11
8
  import { findDaemonRecord } from '../shared/find-daemon-record';
9
+ import { findPidFilePID } from '../shared/find-pid-file-pid';
12
10
  import { getBuild } from '../shared/get-build';
13
- import { isCompiledBinary } from '../shared/is-compiled-binary';
11
+ import { isProcessAlive } from '../shared/is-process-alive';
14
12
  import { makeSingleFlight } from '../shared/make-single-flight';
13
+ import { spawnATCDetached } from '../shared/spawn-atc-detached';
15
14
  import { toAgentID } from '../shared/to-agent-id';
16
15
  import { DaemonClient } from './daemon-client';
17
16
  import { formatProtocolMismatch } from './format-protocol-mismatch';
@@ -275,23 +274,13 @@ function formatWaitFailure(timeoutMs: number): string {
275
274
  );
276
275
  }
277
276
 
278
- function isProcessAlive(pid: number): boolean {
279
- try {
280
- process.kill(pid, 0);
281
-
282
- return true;
283
- } catch {
284
- return false;
285
- }
286
- }
287
-
288
277
  // The pid of the daemon behind the socket that refused the handshake.
289
278
  function findDaemonPID(socketPath: string): number | null {
290
279
  return pickStaleDaemonPID({
291
280
  socketPath,
292
281
  record: findDaemonRecord(daemonRecordFile),
293
282
  pidFileSocketPath: daemonSocketPath,
294
- pidFilePID: findPidFilePID(),
283
+ pidFilePID: findPidFilePID(daemonPidFile),
295
284
  });
296
285
  }
297
286
 
@@ -315,22 +304,6 @@ async function stopDaemon(pid: number): Promise<void> {
315
304
  }
316
305
  }
317
306
 
318
- // The pid file beside the sockets this environment computes.
319
- function findPidFilePID(): number | null {
320
- try {
321
- const pid = Number(readFileSync(daemonPidFile, 'utf8'));
322
-
323
- return Number.isInteger(pid) && pid > 1 ? pid : null;
324
- } catch {
325
- return null;
326
- }
327
- }
328
-
329
307
  function spawnDaemonDetached(): ChildProcess {
330
- const args = isCompiledBinary() ? ['daemon'] : [join(import.meta.dir, '..', 'cli.ts'), 'daemon'];
331
- const child = spawnChild(process.execPath, args, { detached: true, stdio: 'ignore' });
332
-
333
- child.unref();
334
-
335
- return child;
308
+ return spawnATCDetached(['daemon']);
336
309
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Copies an environment without the variables that name the session or
3
+ * socket of whoever runs the restart, so the daemon it starts never mistakes
4
+ * itself for a hosted session. Entries with no value drop out.
5
+ */
6
+ export function collectRestartEnv(
7
+ env: Readonly<Record<string, string | undefined>>,
8
+ ): Record<string, string> {
9
+ const copy: Record<string, string> = {};
10
+
11
+ for (const [key, value] of Object.entries(env)) {
12
+ if (value !== undefined && key !== 'ATC_SESSION_ID' && key !== 'ATC_SOCKET') {
13
+ copy[key] = value;
14
+ }
15
+ }
16
+
17
+ return copy;
18
+ }
@@ -0,0 +1,157 @@
1
+ import { DaemonClient } from './client/daemon-client';
2
+ import { pickStaleDaemonPID } from './client/pick-stale-daemon-pid';
3
+ import { findRestartUnit } from './find-restart-unit';
4
+ import { isDaemonProcess } from './is-daemon-process';
5
+ import { DaemonError } from './protocol/daemon-error';
6
+ import { PROTOCOL_V } from './protocol/protocol';
7
+ import type { DaemonAnswer, PlanSession, ReplacementPlan, RestartPlan } from './restart-plan';
8
+ import { runSystemctl } from './run-systemctl';
9
+ import { buildATCCommand } from './shared/build-atc-command';
10
+ import { daemonPidFile, daemonRecordFile, daemonSocketPath, stateDir } from './shared/config';
11
+ import { findDaemonRecord } from './shared/find-daemon-record';
12
+ import { findPidFilePID } from './shared/find-pid-file-pid';
13
+ import { getBuild } from './shared/get-build';
14
+ import { isProcessAlive } from './shared/is-process-alive';
15
+ import { isRecord } from './shared/report';
16
+
17
+ // How long the handshake and the session list may take together.
18
+ const PROBE_DEADLINE_MS = 3000;
19
+
20
+ interface Probe {
21
+ readonly socketPath: string;
22
+ readonly answer: DaemonAnswer;
23
+ readonly sessions: readonly PlanSession[] | null;
24
+ }
25
+
26
+ /**
27
+ * Finds the daemon a restart would stop and the replacement it would start,
28
+ * without changing either. The daemon answers on the socket this
29
+ * environment computes or the one its record in the state directory holds,
30
+ * and a handshake that its protocol refuses still identifies it.
31
+ */
32
+ export async function collectRestartPlan(callerSession: string | null): Promise<RestartPlan> {
33
+ const record = findDaemonRecord(daemonRecordFile);
34
+
35
+ const paths = new Set([daemonSocketPath, ...(record === null ? [] : [record.socketPath])]);
36
+
37
+ let probe: Probe | null = null;
38
+
39
+ for (const path of paths) {
40
+ probe = await tryProbe(path);
41
+
42
+ if (probe !== null) {
43
+ break;
44
+ }
45
+ }
46
+
47
+ const pid =
48
+ probe === null
49
+ ? pickLivePID(record?.pid ?? null, findPidFilePID(daemonPidFile))
50
+ : (pickStaleDaemonPID({
51
+ socketPath: probe.socketPath,
52
+ record,
53
+ pidFileSocketPath: daemonSocketPath,
54
+ pidFilePID: findPidFilePID(daemonPidFile),
55
+ }) ?? pickLivePID(record?.pid ?? null, null));
56
+
57
+ return {
58
+ pid,
59
+ socketPath: probe?.socketPath ?? null,
60
+ answer: probe?.answer ?? null,
61
+ sessions: probe?.sessions ?? null,
62
+ callerSession,
63
+ replacement: await planReplacement(pid),
64
+ };
65
+ }
66
+
67
+ function pickLivePID(...candidates: readonly (number | null)[]): number | null {
68
+ return (
69
+ candidates.find(
70
+ (pid) => pid !== null && isProcessAlive(pid) && isDaemonProcess(pid, stateDir),
71
+ ) ?? null
72
+ );
73
+ }
74
+
75
+ async function planReplacement(pid: number | null): Promise<ReplacementPlan> {
76
+ const unit = pid === null ? null : await findRestartUnit(pid);
77
+
78
+ if (unit === null) {
79
+ return { kind: 'plain', build: getBuild(), command: buildATCCommand(['daemon']).join(' ') };
80
+ }
81
+
82
+ const shown = await runSystemctl(['show', '-p', 'ExecStart', '--value', unit]);
83
+
84
+ const execPath = /path=(?<path>[^\s;]+)/.exec(shown.stdout)?.groups?.['path'];
85
+
86
+ return {
87
+ kind: 'unit',
88
+ unit,
89
+ execStart: execPath ?? (shown.code === 0 && shown.stdout !== '' ? shown.stdout : null),
90
+ };
91
+ }
92
+
93
+ async function tryProbe(socketPath: string): Promise<Probe | null> {
94
+ let client: DaemonClient;
95
+
96
+ try {
97
+ client = await DaemonClient.open(socketPath);
98
+ } catch {
99
+ return null;
100
+ }
101
+
102
+ // A daemon that accepts the socket and never answers must not hang the
103
+ // restart: closing the client rejects every request still waiting.
104
+ const expiry = setTimeout(() => {
105
+ client.stop();
106
+ }, PROBE_DEADLINE_MS);
107
+
108
+ try {
109
+ const hello = await client.sendHello(getBuild());
110
+
111
+ const build = hello['daemon'];
112
+
113
+ return {
114
+ socketPath,
115
+ answer: {
116
+ kind: 'ok',
117
+ build: typeof build === 'string' ? build : 'unknown',
118
+ protocol: PROTOCOL_V,
119
+ },
120
+ sessions: await tryListSessions(client),
121
+ };
122
+ } catch (error) {
123
+ if (error instanceof DaemonError && error.code === 'protocol_mismatch') {
124
+ return { socketPath, answer: { kind: 'refused', message: error.message }, sessions: null };
125
+ }
126
+
127
+ return null;
128
+ } finally {
129
+ clearTimeout(expiry);
130
+
131
+ client.stop();
132
+ }
133
+ }
134
+
135
+ async function tryListSessions(
136
+ client: Pick<DaemonClient, 'sendRequest'>,
137
+ ): Promise<PlanSession[] | null> {
138
+ try {
139
+ const listed = await client.sendRequest('session.list');
140
+
141
+ const sessions = listed['sessions'];
142
+
143
+ if (!Array.isArray(sessions)) {
144
+ return null;
145
+ }
146
+
147
+ return sessions
148
+ .filter((entry) => isRecord(entry))
149
+ .map((entry) => ({
150
+ id: String(entry['id']),
151
+ name: String(entry['name']),
152
+ state: String(entry['state']),
153
+ }));
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
@@ -137,8 +137,10 @@ export function buildScopedContext(
137
137
 
138
138
  // A session out of reach answers before any confirm token is handed
139
139
  // out or taken, so its host is never touched.
140
- forgetSession: (id, confirmToken) =>
141
- canSee(id) ? ctx.forgetSession(id, confirmToken) : Promise.resolve('missing' as const),
140
+ forgetSession: (id, confirmToken, refuse) =>
141
+ canSee(id)
142
+ ? ctx.forgetSession(id, confirmToken, refuse)
143
+ : Promise.resolve('missing' as const),
142
144
  revokeSessionAuth: (id) => (canSee(id) ? ctx.revokeSessionAuth(id) : Promise.resolve(false)),
143
145
  updateSessionAuth: (id) => (canSee(id) ? ctx.updateSessionAuth(id) : Promise.resolve(null)),
144
146
  updateSession: (id, name, pinned) => canSee(id) && ctx.updateSession(id, name, pinned),
@@ -650,7 +650,10 @@ export class DaemonConnection {
650
650
 
651
651
  const id = parsed.data.session;
652
652
 
653
- const forgotten = await ctx.forgetSession(id, parsed.data.confirmToken);
653
+ const forgotten = await ctx.forgetSession(id, parsed.data.confirmToken, {
654
+ pinned: parsed.data.refusePinned === true,
655
+ live: parsed.data.refuseLive === true,
656
+ });
654
657
 
655
658
  if (forgotten === 'missing') {
656
659
  this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
@@ -63,6 +63,15 @@ type ForgetResult =
63
63
  | { readonly confirmToken: string; readonly expiresAt: number }
64
64
  | { readonly forgotten: true; readonly destroyed: boolean };
65
65
 
66
+ /**
67
+ * The states a forget refuses to act on: a pinned session, or a sub-session
68
+ * of a pinned one, and a live session.
69
+ */
70
+ interface ForgetRefusals {
71
+ readonly pinned: boolean;
72
+ readonly live: boolean;
73
+ }
74
+
66
75
  // A transcript page with the file it came from, so a cursor into a replaced
67
76
  // transcript is distinguishable from one into a grown transcript.
68
77
  interface SessionTranscriptRead {
@@ -223,10 +232,12 @@ export interface DaemonContext {
223
232
  readonly killSession: (id: SessionID) => Promise<boolean>;
224
233
 
225
234
  // Forgets a session, or answers with the token a forget that destroys a
226
- // host must carry. Throws the refusal for a token it does not take.
235
+ // host must carry. Throws the refusal for a token it does not take, and
236
+ // for a pinned or live session when the caller asks it to refuse one.
227
237
  readonly forgetSession: (
228
238
  id: SessionID,
229
239
  confirmToken: string | undefined,
240
+ refuse: ForgetRefusals,
230
241
  ) => Promise<ForgetResult | 'missing'>;
231
242
 
232
243
  // Withdraws the grants of the runtime auth binding on a session's host,
@@ -12,11 +12,13 @@ import type { EventMsg } from '../protocol/protocol';
12
12
  import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
13
13
  import type { SessionState } from '../protocol/session-state';
14
14
  import type { AgentID } from '../shared/agent-id';
15
+ import { claimDaemonLock } from '../shared/claim-daemon-lock';
15
16
  import type { HooksConfig } from '../shared/collect-hooks';
16
17
  import type { TargetConfigError } from '../shared/collect-targets';
17
18
  import type { InvalidGitTransports } from '../shared/collect-workspaces-config';
18
19
  import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
19
20
  import { findDaemonRecord } from '../shared/find-daemon-record';
21
+ import { loadListenerTokens } from '../shared/load-listener-tokens';
20
22
  import type { MessageID } from '../shared/message-id';
21
23
  import { isRecord } from '../shared/report';
22
24
  import { resolveHomeDir } from '../shared/resolve-home-dir';
@@ -50,7 +52,6 @@ import { buildSessionReportEvent } from './build-session-report-event';
50
52
  import { buildTargetAccess } from './build-target-access';
51
53
  import { buildTargetForbiddenError } from './build-target-forbidden-error';
52
54
  import { buildTargetList } from './build-target-list';
53
- import { claimDaemonLock } from './claim-daemon-lock';
54
55
  import { createNonBlockingLog } from './create-non-blocking-log';
55
56
  import type { NonBlockingLog } from './create-non-blocking-log';
56
57
  import { DaemonConnection } from './daemon-connection';
@@ -70,7 +71,6 @@ import { IdempotencyLedger } from './idempotency-ledger';
70
71
  import { isAllowedListenHost } from './is-allowed-listen-host';
71
72
  import { isOwnHookEvent } from './is-own-hook-event';
72
73
  import { isTreeInReach } from './is-tree-in-reach';
73
- import { loadListenerTokens } from './load-listener-tokens';
74
74
  import { loadTranscriptPage } from './load-transcript-page';
75
75
  import { makeHookRunner } from './make-hook-runner';
76
76
  import type { HookScope } from './make-hook-runner';
@@ -1112,7 +1112,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1112
1112
  runtime.pendingLastUsed = true;
1113
1113
  }
1114
1114
 
1115
- void store.recordSpawnDir(s.cwd, { target: s.target, targetIdentity: s.targetIdentity });
1115
+ void store.recordSpawnDir(
1116
+ s.cwd,
1117
+ { target: s.target, targetIdentity: s.targetIdentity },
1118
+ Date.now(),
1119
+ );
1116
1120
 
1117
1121
  return getDescriptor(mgr, s.id);
1118
1122
  };
@@ -1573,13 +1577,31 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1573
1577
  // A forget on a target that cannot destroy its host forgets at once. On
1574
1578
  // one that can, a forget without a token checks the target and answers
1575
1579
  // with a token, and the forget that carries the token destroys the host.
1576
- forgetSession: async (id, confirmToken) => {
1580
+ // A refusal the caller asks for reads the session in the same step that
1581
+ // starts the forget, so a pin or revive that lands first is seen.
1582
+ forgetSession: async (id, confirmToken, refuse) => {
1577
1583
  const s = mgr.sessions.find((x) => x.id === id);
1578
1584
 
1579
1585
  if (s === undefined) {
1580
1586
  return 'missing';
1581
1587
  }
1582
1588
 
1589
+ if (refuse.pinned && (s.pinned || mgr.sessions.some((x) => x.id === s.parent && x.pinned))) {
1590
+ throw new DaemonError(
1591
+ 'session_pinned',
1592
+ `session ${id} is pinned, or is a sub-session of a pinned session; unpin it before forgetting it`,
1593
+ { session: id },
1594
+ );
1595
+ }
1596
+
1597
+ if (refuse.live && (s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'))) {
1598
+ throw new DaemonError(
1599
+ 'session_live',
1600
+ `session ${id} is live; stop it before forgetting it`,
1601
+ { session: id },
1602
+ );
1603
+ }
1604
+
1583
1605
  if (mgr.findProvider(s)?.capabilities.destroy === true) {
1584
1606
  mgr.requireExecution(s, 'destroy');
1585
1607