@zgeoff/atc 2.12.0 → 2.14.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 (75) hide show
  1. package/README.md +12 -11
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +48 -11
  4. package/src/agents/build-atc-bridge-files.ts +13 -0
  5. package/src/agents/build-claude-query-options.ts +72 -0
  6. package/src/agents/build-cli-command.ts +7 -5
  7. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  8. package/src/agents/build-hook-settings.ts +8 -2
  9. package/src/agents/build-restore-mode-args.ts +23 -0
  10. package/src/agents/claude-adapter.ts +64 -17
  11. package/src/agents/codex-adapter.ts +5 -0
  12. package/src/agents/find-claude-permission-mode.ts +24 -0
  13. package/src/agents/gateway-adapter.ts +38 -11
  14. package/src/agents/grok-adapter.ts +5 -0
  15. package/src/agents/make-claude-headless-runner.ts +46 -0
  16. package/src/agents/plan-pasted-line-input.ts +22 -0
  17. package/src/agents/plan-typed-line-input.ts +8 -0
  18. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  19. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
  20. package/src/agents/write-atc-bridge.ts +2 -6
  21. package/src/cli.ts +8 -12
  22. package/src/client/boot-daemon.ts +47 -21
  23. package/src/client/format-protocol-mismatch.ts +37 -0
  24. package/src/client/index.ts +104 -2
  25. package/src/client/ui.ts +8 -0
  26. package/src/daemon/build-execution-targets.ts +49 -10
  27. package/src/daemon/build-imp-provider.ts +68 -0
  28. package/src/daemon/build-report-trail-entry.ts +6 -2
  29. package/src/daemon/build-report-view.ts +41 -0
  30. package/src/daemon/build-scoped-context.ts +9 -1
  31. package/src/daemon/build-session-lifecycle.ts +52 -0
  32. package/src/daemon/build-tar-archive.ts +85 -0
  33. package/src/daemon/daemon-connection.ts +134 -4
  34. package/src/daemon/daemon.ts +306 -42
  35. package/src/daemon/execution-provider.ts +109 -4
  36. package/src/daemon/hooks.ts +5 -17
  37. package/src/daemon/imp-client-port.ts +343 -0
  38. package/src/daemon/imp-harness.ts +618 -0
  39. package/src/daemon/imp-port-error.ts +18 -0
  40. package/src/daemon/imp-port.ts +246 -0
  41. package/src/daemon/imp-provider.ts +601 -0
  42. package/src/daemon/is-binding-current.ts +43 -0
  43. package/src/daemon/local-pty-provider.ts +62 -3
  44. package/src/daemon/materialize-workspace.ts +574 -0
  45. package/src/daemon/parse-hook-line.ts +31 -0
  46. package/src/daemon/pick-session-state.ts +15 -0
  47. package/src/daemon/restore-fleet.ts +41 -23
  48. package/src/daemon/screen-model.ts +7 -0
  49. package/src/daemon/sessions.ts +574 -61
  50. package/src/daemon/start-headless-turn.ts +1 -1
  51. package/src/daemon/start-session-bridge.ts +294 -0
  52. package/src/mcp/mcp-tools.ts +41 -3
  53. package/src/mcp/reconnecting-caller.ts +1 -0
  54. package/src/mcp/require-daemon-features.ts +4 -0
  55. package/src/mcp/run-tool.ts +32 -8
  56. package/src/protocol/daemon-features.ts +14 -0
  57. package/src/protocol/protocol.ts +23 -0
  58. package/src/protocol/request-param-schemas.ts +72 -0
  59. package/src/report.ts +37 -2
  60. package/src/run-bridge-tap.ts +241 -0
  61. package/src/shared/collect-clean-env.ts +9 -1
  62. package/src/shared/open-bridge-socket.ts +86 -0
  63. package/src/shared/send-bridge-request.ts +47 -0
  64. package/src/statusline.ts +34 -3
  65. package/src/store/fleet-entry.ts +19 -0
  66. package/src/store/run-migrations.ts +85 -0
  67. package/src/store/state-store.ts +226 -21
  68. package/src/store/trail-entry.ts +7 -0
  69. package/src/store/workspace-materialization.ts +70 -0
  70. package/src/tap.ts +11 -0
  71. package/src/workspace/check-url-credentials.ts +59 -0
  72. package/src/workspace/create-workspace-clone.ts +13 -1
  73. package/src/workspace/repository-env-vars.ts +22 -0
  74. package/src/workspace/run-git.ts +2 -18
  75. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -0,0 +1,46 @@
1
+ import type { HeadlessRunEvents, HeadlessRunner } from './agent-adapter';
2
+ import type { ClaudeHeadlessRunRequest } from './build-claude-query-options';
3
+
4
+ /**
5
+ * Runs one headless turn through the Claude CLI; the daemon command supplies
6
+ * the Agent SDK implementation, and tests supply a recorder.
7
+ */
8
+ export type ClaudeHeadlessRun = (
9
+ opts: ClaudeHeadlessRunRequest,
10
+ hooks: HeadlessRunEvents,
11
+ ) => { readonly stop: () => void };
12
+
13
+ /**
14
+ * Where and how a Claude CLI headless turn runs: the binary, the permission
15
+ * mode its adapter resolved, the mod folder, and, for a gateway, the
16
+ * settings file that carries its backend. The folder and file are written
17
+ * on first use.
18
+ */
19
+ interface ClaudeHeadlessTarget {
20
+ readonly claudeBin: string;
21
+ readonly permissionMode: string;
22
+ readonly pluginDir: () => string;
23
+ readonly settings?: () => string;
24
+ }
25
+
26
+ /**
27
+ * Turns the Claude CLI's headless run into the neutral runner the daemon
28
+ * calls. Every headless turn runs under the permission mode its adapter
29
+ * resolved for the agent's sessions.
30
+ */
31
+ export function makeClaudeHeadlessRunner(
32
+ run: ClaudeHeadlessRun,
33
+ target: ClaudeHeadlessTarget,
34
+ ): HeadlessRunner {
35
+ return (opts, hooks) =>
36
+ run(
37
+ {
38
+ ...opts,
39
+ claudeBin: target.claudeBin,
40
+ permissionMode: target.permissionMode,
41
+ pluginDir: target.pluginDir(),
42
+ ...(target.settings === undefined ? {} : { settings: target.settings() }),
43
+ },
44
+ hooks,
45
+ );
46
+ }
@@ -0,0 +1,22 @@
1
+ import type { TerminalInputModes } from './agent-adapter';
2
+
3
+ const PASTE_START = '\u001B[200~';
4
+ const PASTE_END = '\u001B[201~';
5
+
6
+ /**
7
+ * Pastes a line into a TUI that tells a paste from typing, then submits it
8
+ * with a carriage return as its own write. Such a TUI takes a newline inside
9
+ * a burst of input as part of the text, so a line typed with its newline
10
+ * stays unsent in the composer. Between bracketed paste markers the whole
11
+ * text is one paste event, newlines included, and the carriage return after
12
+ * the closing marker is a separate Enter key event however the two writes
13
+ * arrive. Paste markers inside the text are dropped, so the text can neither
14
+ * end the paste early nor start one of its own. A TUI that has not turned
15
+ * bracketed paste on gets the text unmarked, and may still read both writes
16
+ * as one burst.
17
+ */
18
+ export function planPastedLineInput(text: string, modes: TerminalInputModes): readonly string[] {
19
+ const unmarked = text.replaceAll(PASTE_START, '').replaceAll(PASTE_END, '');
20
+
21
+ return modes.bracketedPaste ? [`${PASTE_START}${unmarked}${PASTE_END}`, '\r'] : [unmarked, '\r'];
22
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Types a line the way a terminal user does: the text and a newline in one
3
+ * write. A TUI that reads input a line at a time takes the newline as the
4
+ * submit key.
5
+ */
6
+ export function planTypedLineInput(text: string): readonly string[] {
7
+ return [`${text}\n`];
8
+ }
@@ -0,0 +1,13 @@
1
+ import { findClaudePermissionMode } from './find-claude-permission-mode';
2
+
3
+ /**
4
+ * The permission mode a Claude CLI agent's headless turns run in: the mode
5
+ * its configuration sets, else auto, since no human is at a terminal to
6
+ * answer a prompt.
7
+ */
8
+ export function resolveClaudePermissionMode(
9
+ args: readonly string[],
10
+ settings: Readonly<Record<string, unknown>> | undefined,
11
+ ): string {
12
+ return findClaudePermissionMode(args, settings) ?? 'auto';
13
+ }
@@ -1,36 +1,8 @@
1
- import type { AgentSessionID } from '../shared/agent-session-id';
2
- import { socketPath } from '../shared/config';
1
+ import { truncateSummary } from '../daemon/truncate-summary';
3
2
  import { isCompiledBinary } from '../shared/is-compiled-binary';
4
3
  import { isRecord } from '../shared/report';
5
- import type { SessionID } from '../shared/session-id';
6
- import { buildHeadlessEnv } from './build-headless-env';
7
- import { resolveHeadlessExecutable } from './resolve-headless-executable';
8
- import { truncateSummary } from './truncate-summary';
9
-
10
- const PERMISSION_MODES = [
11
- 'default',
12
- 'acceptEdits',
13
- 'bypassPermissions',
14
- 'plan',
15
- 'auto',
16
- 'dontAsk',
17
- ] as const;
18
-
19
- // The effort levels the Agent SDK accepts.
20
- const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
21
-
22
- interface HeadlessRunOptions {
23
- readonly claudeBin: string;
24
- readonly cwd: string;
25
- readonly prompt: string;
26
- readonly resume?: AgentSessionID;
27
- readonly permissionMode?: string;
28
- readonly settings?: string;
29
- readonly sessionID?: SessionID;
30
- readonly pluginDir?: string;
31
- readonly model?: string;
32
- readonly effort?: string;
33
- }
4
+ import { buildClaudeQueryOptions } from './build-claude-query-options';
5
+ import type { ClaudeHeadlessRunRequest } from './build-claude-query-options';
34
6
 
35
7
  interface HeadlessRunHooks {
36
8
  readonly onOutput: (text: string) => void;
@@ -48,16 +20,14 @@ interface HeadlessRunHandle {
48
20
  * done (with the turn's whole result) or needs_you (errors, turn limits,
49
21
  * anything a human must look at).
50
22
  */
51
- export function startHeadlessRun(
52
- opts: HeadlessRunOptions,
23
+ export function startClaudeHeadlessRun(
24
+ opts: ClaudeHeadlessRunRequest,
53
25
  hooks: HeadlessRunHooks,
54
26
  ): HeadlessRunHandle {
55
27
  const controller = new AbortController();
56
28
 
57
29
  void (async () => {
58
30
  try {
59
- const mode = PERMISSION_MODES.find((m) => m === opts.permissionMode);
60
- const effort = EFFORT_LEVELS.find((level) => level === opts.effort);
61
31
  let stderrTail = '';
62
32
 
63
33
  // The SDK is the heaviest module in the daemon's graph, and only a
@@ -68,28 +38,11 @@ export function startHeadlessRun(
68
38
  const stream = sdk.query({
69
39
  prompt: opts.prompt,
70
40
  options: {
71
- cwd: opts.cwd,
72
- env: buildHeadlessEnv({
73
- socketPath,
74
- ...(opts.pluginDir === undefined ? {} : { pluginDir: opts.pluginDir }),
75
- ...(opts.sessionID === undefined ? {} : { sessionID: opts.sessionID }),
76
- }),
41
+ ...buildClaudeQueryOptions(opts, isCompiledBinary()),
77
42
  abortController: controller,
78
43
  stderr: (data: string) => {
79
44
  stderrTail = `${stderrTail}${data}`.slice(-2000);
80
45
  },
81
- ...(opts.resume === undefined ? {} : { resume: opts.resume }),
82
- ...(mode === undefined ? {} : { permissionMode: mode }),
83
-
84
- // The session's own model and effort, so the turn runs as its
85
- // terminal did.
86
- ...(opts.model === undefined ? {} : { model: opts.model }),
87
- ...(effort === undefined ? {} : { effort }),
88
-
89
- // The same generated file the terminal spawn passes, so the turn
90
- // runs against the session's own backend and instrumentation.
91
- ...(opts.settings === undefined ? {} : { extraArgs: { settings: opts.settings } }),
92
- ...resolveHeadlessExecutable(opts.claudeBin, isCompiledBinary()),
93
46
  },
94
47
  });
95
48
 
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { stateDir } from '../shared/config';
4
- import { ATC_BRIDGE_FILES } from './atc-bridge-files';
4
+ import { buildATCBridgeFiles } from './build-atc-bridge-files';
5
5
  import { buildCLIArgv } from './build-cli-argv';
6
6
 
7
7
  /**
@@ -13,7 +13,7 @@ import { buildCLIArgv } from './build-cli-argv';
13
13
  * there.
14
14
  */
15
15
  export function writeATCBridge(dir: string = join(stateDir, 'atc-bridge')): string {
16
- const files = { ...ATC_BRIDGE_FILES, 'hooks/atc-cli.ts': renderATCCLIModule(buildCLIArgv()) };
16
+ const files = buildATCBridgeFiles(buildCLIArgv());
17
17
 
18
18
  for (const [path, content] of Object.entries(files)) {
19
19
  const file = join(dir, path);
@@ -29,10 +29,6 @@ export function writeATCBridge(dir: string = join(stateDir, 'atc-bridge')): stri
29
29
  return dir;
30
30
  }
31
31
 
32
- function renderATCCLIModule(argv: readonly string[]): string {
33
- return `export const ATC_CLI: readonly string[] = ${JSON.stringify(argv)};\n`;
34
- }
35
-
36
32
  function tryReadFileText(file: string): string | null {
37
33
  try {
38
34
  return readFileSync(file, 'utf8');
package/src/cli.ts CHANGED
@@ -159,15 +159,17 @@ const main = defineCommand({
159
159
  const grok = await import('./agents/grok-adapter');
160
160
  const codex = await import('./agents/codex-adapter');
161
161
  const gateway = await import('./agents/gateway-adapter');
162
- const headless = await import('./daemon/start-headless-run');
162
+ const headless = await import('./agents/start-claude-headless-run');
163
163
  const targets = await import('./daemon/build-execution-targets');
164
164
 
165
165
  // Test harnesses shrink the outbound queue to force overflow
166
166
  // deterministically; unset means the production default.
167
167
  const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
168
168
  const cfg = config.loadConfig();
169
+ const built = targets.buildExecutionTargets(cfg.targets);
170
+ const targetErrors = [...cfg.targetErrors, ...built.errors];
169
171
 
170
- for (const error of cfg.targetErrors) {
172
+ for (const error of targetErrors) {
171
173
  const line =
172
174
  error.scope === 'config'
173
175
  ? `${error.path} cannot be used (${error.problem}: ${error.detail}); every spawn is refused, local ones included, until it is fixed`
@@ -192,18 +194,12 @@ const main = defineCommand({
192
194
  // to it is refused. Tests pin it to 0 to reach the refusal at once.
193
195
  const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
194
196
 
195
- const claudeAdapter = new claude.ClaudeAdapter(cfg, (runOpts, hooks) =>
196
- headless.startHeadlessRun({ ...runOpts, claudeBin: cfg.claudeBin }, hooks),
197
- );
198
-
197
+ const claudeAdapter = new claude.ClaudeAdapter(cfg, headless.startClaudeHeadlessRun);
199
198
  const grokAdapter = new grok.GrokAdapter(cfg);
200
199
  const codexAdapter = new codex.CodexAdapter(cfg);
201
200
 
202
201
  const gatewayAdapters = cfg.gateways.map(
203
- (entry) =>
204
- new gateway.GatewayAdapter(entry, cfg, (runOpts, hooks) =>
205
- headless.startHeadlessRun({ ...runOpts, claudeBin: entry.bin }, hooks),
206
- ),
202
+ (entry) => new gateway.GatewayAdapter(entry, cfg, headless.startClaudeHeadlessRun),
207
203
  );
208
204
 
209
205
  let handle: Awaited<ReturnType<typeof daemon.startDaemon>>;
@@ -220,9 +216,9 @@ const main = defineCommand({
220
216
  legacyFleetPath: config.legacyFleetFile,
221
217
  pidPath: config.daemonPidFile,
222
218
  hooks: cfg.hooks,
223
- targets: targets.buildExecutionTargets(cfg.targets),
219
+ targets: built.targets,
224
220
  defaultTarget: cfg.defaultTarget,
225
- targetErrors: cfg.targetErrors,
221
+ targetErrors,
226
222
  principals: cfg.principals,
227
223
  restoreBootTimeoutMs,
228
224
  ...(Number.isFinite(graceOverride) && graceOverride >= 0
@@ -4,15 +4,18 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { toAgentID } from '../agents/agent-adapter';
6
6
  import type { AgentID } from '../agents/agent-adapter';
7
+ import { DaemonError } from '../protocol/daemon-error';
7
8
  import type { DaemonFeature } from '../protocol/daemon-features';
8
9
  import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
10
+ import { PROTOCOL_V } from '../protocol/protocol';
9
11
  import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
10
12
  import { findDaemonRecord } from '../shared/find-daemon-record';
11
13
  import { getBuild } from '../shared/get-build';
12
14
  import { isCompiledBinary } from '../shared/is-compiled-binary';
13
15
  import { makeSingleFlight } from '../shared/make-single-flight';
14
- import { isRecord } from '../shared/report';
15
16
  import { DaemonClient } from './daemon-client';
17
+ import { formatProtocolMismatch } from './format-protocol-mismatch';
18
+ import type { ProtocolMismatch } from './format-protocol-mismatch';
16
19
  import { pickStaleDaemonPID } from './pick-stale-daemon-pid';
17
20
 
18
21
  export interface DaemonBoot {
@@ -28,18 +31,28 @@ export interface DaemonBoot {
28
31
  readonly socketPath: string;
29
32
  }
30
33
 
34
+ export interface DaemonBootOptions {
35
+ // Called when the daemon speaks another protocol version. Resolving true
36
+ // stops that daemon and boots one from this build, which ends every
37
+ // session it hosts; without the callback, or resolving false, the boot
38
+ // rejects and the daemon keeps running.
39
+ readonly onProtocolMismatch?: (mismatch: ProtocolMismatch) => Promise<boolean>;
40
+ }
41
+
31
42
  /**
32
43
  * Opens a handshaken client to the daemon, booting the daemon first when
33
44
  * neither the computed socket nor the one in the daemon's record answers.
34
- * Overlapping calls in one process share a single boot. A daemon from an older build stays in service — killing
35
- * it would kill every hosted session — and is reported as stale so the
36
- * caller can offer a deliberate restart. Only a protocol mismatch, where
37
- * talking would misbehave, forces the restart immediately. The expected
38
- * build is read from disk on every attempt: a long-lived caller holding a
39
- * build string from its own boot would otherwise flag daemons that are
40
- * already current.
45
+ * Overlapping calls in one process share a single boot. A daemon from an
46
+ * older build stays in service, since stopping it would end every hosted
47
+ * session, and is reported as stale so the caller can offer a deliberate
48
+ * restart. A daemon on another protocol version stays in service too: the
49
+ * boot rejects with `protocol_mismatch` and a message holding both builds,
50
+ * both versions, and the way to restart it, unless the caller's
51
+ * `onProtocolMismatch` confirms a restart. The expected build is read from
52
+ * disk on every attempt: a long-lived caller holding a build string from
53
+ * its own boot would otherwise flag daemons that are already current.
41
54
  */
42
- export async function bootDaemonClient(): Promise<DaemonBoot> {
55
+ export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise<DaemonBoot> {
43
56
  for (let attempt = 0; attempt < 2; attempt++) {
44
57
  const build = getBuild();
45
58
 
@@ -60,21 +73,35 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
60
73
  } catch (error) {
61
74
  client.stop();
62
75
 
63
- if (attempt > 0 || !isProtocolMismatch(error)) {
76
+ if (attempt > 0 || !(error instanceof DaemonError) || error.code !== 'protocol_mismatch') {
64
77
  throw error;
65
78
  }
66
79
 
67
- await stopStaleDaemon(opened.socketPath);
80
+ const mismatch: ProtocolMismatch = {
81
+ socketPath: opened.socketPath,
82
+ daemonPID: findDaemonPID(opened.socketPath),
83
+ clientBuild: build,
84
+ clientProtocol: PROTOCOL_V,
85
+ daemonMessage: error.message,
86
+ };
87
+
88
+ // Without a pid there is no daemon this client could stop, so the
89
+ // caller is not asked.
90
+ if (
91
+ mismatch.daemonPID === null ||
92
+ options.onProtocolMismatch === undefined ||
93
+ !(await options.onProtocolMismatch(mismatch))
94
+ ) {
95
+ throw new DaemonError('protocol_mismatch', formatProtocolMismatch(mismatch));
96
+ }
97
+
98
+ await stopDaemon(mismatch.daemonPID);
68
99
  }
69
100
  }
70
101
 
71
102
  throw new Error('the atc daemon could not be restarted');
72
103
  }
73
104
 
74
- function isProtocolMismatch(error: unknown): boolean {
75
- return isRecord(error) && error['code'] === 'protocol_mismatch';
76
- }
77
-
78
105
  interface OpenedDaemon {
79
106
  readonly client: DaemonClient;
80
107
  readonly socketPath: string;
@@ -177,18 +204,17 @@ function isProcessAlive(pid: number): boolean {
177
204
  }
178
205
  }
179
206
 
180
- async function stopStaleDaemon(socketPath: string): Promise<void> {
181
- const pid = pickStaleDaemonPID({
207
+ // The pid of the daemon behind the socket that refused the handshake.
208
+ function findDaemonPID(socketPath: string): number | null {
209
+ return pickStaleDaemonPID({
182
210
  socketPath,
183
211
  record: findDaemonRecord(daemonRecordFile),
184
212
  pidFileSocketPath: daemonSocketPath,
185
213
  pidFilePID: findPidFilePID(),
186
214
  });
215
+ }
187
216
 
188
- if (pid === null) {
189
- return;
190
- }
191
-
217
+ async function stopDaemon(pid: number): Promise<void> {
192
218
  try {
193
219
  process.kill(pid, 'SIGTERM');
194
220
  } catch {
@@ -0,0 +1,37 @@
1
+ // A daemon that refused this client's handshake because it speaks another
2
+ // protocol version.
3
+ export interface ProtocolMismatch {
4
+ readonly socketPath: string;
5
+
6
+ // The daemon's pid from its record or pid file, or null when neither
7
+ // belongs to the socket that refused.
8
+ readonly daemonPID: number | null;
9
+
10
+ readonly clientBuild: string;
11
+ readonly clientProtocol: number;
12
+
13
+ // The daemon's own refusal, which holds its build and protocol version.
14
+ readonly daemonMessage: string;
15
+ }
16
+
17
+ /**
18
+ * Describes a daemon on another protocol version: both builds and both
19
+ * versions, that it was left running, and how to restart it on purpose.
20
+ */
21
+ export function formatProtocolMismatch(mismatch: ProtocolMismatch): string {
22
+ const pid = mismatch.daemonPID === null ? 'pid unknown' : `pid ${mismatch.daemonPID}`;
23
+
24
+ // Without a pid the TUI cannot stop the daemon either, so the restart is
25
+ // left to the user.
26
+ const restart =
27
+ mismatch.daemonPID === null
28
+ ? `To restart it, find its pid with \`ss -xlp | grep ${mismatch.socketPath}\` on Linux or \`lsof -U | grep ${mismatch.socketPath}\` on macOS, stop that process, and run \`atc\` again: every hosted session ends, and \`R\` respawns them from their transcripts.`
29
+ : 'To restart it, run `atc` from the build you want and confirm its restart prompt: every hosted session ends, and the fleet is restored on the new daemon.';
30
+
31
+ return [
32
+ `the atc daemon (${pid}, socket ${mismatch.socketPath}) speaks another protocol than this client, ${mismatch.clientBuild} on protocol v${mismatch.clientProtocol}.`,
33
+ `The daemon answered: ${mismatch.daemonMessage}`,
34
+ 'It was left running, so the sessions it hosts keep running.',
35
+ restart,
36
+ ].join('\n');
37
+ }
@@ -2,6 +2,7 @@ import { match } from 'ts-pattern';
2
2
  import { createActor } from 'xstate';
3
3
  import type { AgentID } from '../agents/agent-adapter';
4
4
  import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '../daemon/sessions';
5
+ import { DaemonError } from '../protocol/daemon-error';
5
6
  import type { EventMsg } from '../protocol/protocol';
6
7
  import { loadConfig } from '../shared/config';
7
8
  import { makeSingleFlight } from '../shared/make-single-flight';
@@ -9,6 +10,7 @@ import { bootDaemonClient } from './boot-daemon';
9
10
  import { buildClientMachine } from './build-client-machine';
10
11
  import { buildLeaderChords } from './build-leader-chords';
11
12
  import { findFuzzyScore, formatDir } from './dirs';
13
+ import type { ProtocolMismatch } from './format-protocol-mismatch';
12
14
  import { KEY, isDown, isUp, planTextEdit } from './keys';
13
15
  import { parseDaemonEvent } from './parse-daemon-event';
14
16
  import { pickTabTarget } from './pick-tab-target';
@@ -20,6 +22,10 @@ import { ansi, cols, drawHelp, drawHome, drawOverlay, drawPicker, drawStatusBar,
20
22
  let overlaySelected = 0;
21
23
  let confirmKill = false;
22
24
 
25
+ // The session whose forget waits on a confirm because it destroys the
26
+ // session's host, or null.
27
+ let confirmDestroyID: string | null = null;
28
+
23
29
  // The client's mirror of the daemon's fleet, kept fresh by events.
24
30
  let fleet: MirrorSession[] = [];
25
31
 
@@ -95,6 +101,36 @@ async function sendQuiet(m: string, p?: Readonly<Record<string, unknown>>) {
95
101
  } catch {}
96
102
  }
97
103
 
104
+ // A kill the daemon refuses because forgetting the session destroys its
105
+ // host asks once more before the forget.
106
+ async function sendKill(sessionID: string) {
107
+ try {
108
+ await client.sendRequest('session.kill', { session: sessionID });
109
+ } catch (error) {
110
+ if (error instanceof DaemonError && error.code === 'confirmation_required') {
111
+ confirmDestroyID = sessionID;
112
+
113
+ if (service.getSnapshot().value === 'overlay') {
114
+ renderOverlay();
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ // Takes the confirm token the daemon offers and hands it straight back:
121
+ // the operator already confirmed in the overlay.
122
+ async function sendForget(sessionID: string) {
123
+ try {
124
+ const offered = await client.sendRequest('session.forget', { session: sessionID });
125
+
126
+ const confirmToken = offered['confirmToken'];
127
+
128
+ if (typeof confirmToken === 'string') {
129
+ await client.sendRequest('session.forget', { session: sessionID, confirmToken });
130
+ }
131
+ } catch {}
132
+ }
133
+
98
134
  async function attach(sessionID: string) {
99
135
  service.send({ type: 'ATTACH', sessionID });
100
136
 
@@ -215,6 +251,7 @@ function renderOverlay() {
215
251
  agentMarks,
216
252
  selected: overlaySelected,
217
253
  confirmKill,
254
+ confirmDestroy: confirmDestroyID !== null,
218
255
  filter: overlayFilter,
219
256
  stale: daemonStale && !daemonRestarting,
220
257
  grouped: overlayGrouped,
@@ -225,6 +262,7 @@ function renderOverlay() {
225
262
 
226
263
  function openOverlay() {
227
264
  confirmKill = false;
265
+ confirmDestroyID = null;
228
266
  overlayFilter = null;
229
267
 
230
268
  const focusedIndex = pickOverlaySessions().findIndex((s) => s.id === focusedID);
@@ -510,7 +548,7 @@ function applyOverlayKey(buf: Buffer) {
510
548
 
511
549
  if (confirmKill) {
512
550
  if (buf[0] === 0x79 /* y */ && sel !== undefined) {
513
- void sendQuiet('session.kill', { session: sel.id });
551
+ void sendKill(sel.id);
514
552
  }
515
553
 
516
554
  confirmKill = false;
@@ -520,6 +558,18 @@ function applyOverlayKey(buf: Buffer) {
520
558
  return;
521
559
  }
522
560
 
561
+ if (confirmDestroyID !== null) {
562
+ if (buf[0] === 0x79 /* y */) {
563
+ void sendForget(confirmDestroyID);
564
+ }
565
+
566
+ confirmDestroyID = null;
567
+
568
+ renderOverlay();
569
+
570
+ return;
571
+ }
572
+
523
573
  if (applyOverlayFilterKey(buf)) {
524
574
  return;
525
575
  }
@@ -732,7 +782,17 @@ const service = createActor(
732
782
  }),
733
783
  );
734
784
 
735
- const boot = await bootDaemonClient();
785
+ // A daemon on another protocol is restarted only when the user confirms
786
+ // it, and the fleet it hosted is then restored on the new one.
787
+ let restartedOnBoot = false;
788
+
789
+ const boot = await bootDaemonClient({
790
+ onProtocolMismatch: async (mismatch) => {
791
+ restartedOnBoot = await waitForRestartConsent(mismatch);
792
+
793
+ return restartedOnBoot;
794
+ },
795
+ });
736
796
 
737
797
  let client = boot.client;
738
798
  let daemonStale = boot.stale;
@@ -741,8 +801,50 @@ let daemonRestarting = false;
741
801
  lastUsedAgent = boot.lastUsedAgent;
742
802
  client.onEvent = applyDaemonEvent;
743
803
 
804
+ if (restartedOnBoot) {
805
+ await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
806
+ }
807
+
744
808
  await refreshMirror();
745
809
 
810
+ /**
811
+ * Asks on the terminal whether to restart a daemon that speaks another
812
+ * protocol, reading a single key. Anything but `y` declines, and so does a
813
+ * terminal that cannot answer.
814
+ */
815
+ async function waitForRestartConsent(mismatch: ProtocolMismatch): Promise<boolean> {
816
+ if (!process.stdin.isTTY) {
817
+ return false;
818
+ }
819
+
820
+ process.stderr.write(
821
+ [
822
+ `atc: the daemon (pid ${mismatch.daemonPID}) speaks another protocol than this client, ${mismatch.clientBuild} on protocol v${mismatch.clientProtocol}.`,
823
+ `The daemon answered: ${mismatch.daemonMessage}`,
824
+ 'Restart it now? Every session it hosts ends, then the fleet is restored on the new daemon. [y/N] ',
825
+ ].join('\n'),
826
+ );
827
+
828
+ process.stdin.setRawMode(true);
829
+ process.stdin.resume();
830
+
831
+ const key = await new Promise<string>((resolve) => {
832
+ process.stdin.once('data', (buf: Buffer) => {
833
+ resolve(buf.toString());
834
+ });
835
+ });
836
+
837
+ process.stdin.setRawMode(false);
838
+ process.stdin.pause();
839
+
840
+ const confirmed = key === 'y' || key === 'Y';
841
+ const echo = confirmed ? 'y\n' : 'n\n';
842
+
843
+ process.stderr.write(echo);
844
+
845
+ return confirmed;
846
+ }
847
+
746
848
  async function refreshMirror() {
747
849
  const list = await client.sendRequest('session.list');
748
850
 
package/src/client/ui.ts CHANGED
@@ -196,6 +196,10 @@ export interface OverlayView {
196
196
  agentMarks: Readonly<Record<AgentID, string>>;
197
197
  selected: number;
198
198
  confirmKill: boolean;
199
+
200
+ // A kill the daemon refused because forgetting the session destroys its
201
+ // host waits on a second confirm.
202
+ confirmDestroy: boolean;
199
203
  filter: string | null;
200
204
  stale: boolean;
201
205
  grouped: boolean;
@@ -271,6 +275,10 @@ export function drawOverlay(view: OverlayView) {
271
275
  hint = formatKillConfirm(selected, view.sessions);
272
276
  }
273
277
 
278
+ if (view.confirmDestroy) {
279
+ hint = 'forget destroys its host and everything on it? y / n';
280
+ }
281
+
274
282
  rowsList.push(dimRow(width, hint), boxBottom(width));
275
283
 
276
284
  drawBox(rowsList);