@zgeoff/atc 2.38.0 → 2.40.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.
package/README.md CHANGED
@@ -91,11 +91,12 @@ zoxide is installed. A typed path such as `~/pro` completes like a shell. Inside
91
91
  your statusline gains a fleet segment, so `● 2 need you: auth-bug` is visible without opening the
92
92
  list.
93
93
 
94
- If the daemon dies, press `R` on the home screen and every session respawns from its transcript.
95
- After you upgrade atc, the status bar shows `⟳ update ready`, and `u` restarts the daemon and
96
- restores the fleet when you are ready. The bar shows `⟳ restarting daemon` until the restart
97
- finishes. When the running daemon speaks another protocol version, `atc` asks before it restarts the
98
- daemon, since the restart ends every session the daemon hosts.
94
+ When the daemon restarts, it restores the fleet by itself and every session respawns from its
95
+ transcript; set `restoreFleetOnRestart` to `false` to wait for `R` on the home screen instead. After
96
+ you upgrade atc, the status bar shows `⟳ update ready`, and `u` restarts the daemon and restores the
97
+ fleet when you are ready. The bar shows `⟳ restarting daemon` until the restart finishes. When the
98
+ running daemon speaks another protocol version, `atc` asks before it restarts the daemon, since the
99
+ restart ends every session the daemon hosts.
99
100
 
100
101
  atc runs inside zellij or tmux. Give the pane locked mode so the leader key reaches atc.
101
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.38.0",
3
+ "version": "2.40.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",
@@ -31,7 +31,6 @@ export interface SpawnOverrides {
31
31
  readonly trustClonedWorkspace?: boolean;
32
32
  readonly model?: string;
33
33
  readonly effort?: string;
34
- readonly resumeInterruptedTurns?: boolean;
35
34
  }
36
35
 
37
36
  export interface SpawnPlan {
@@ -86,6 +85,22 @@ export interface GuestSpawnPlan extends SpawnPlan {
86
85
  readonly env?: Readonly<Record<string, string>>;
87
86
  }
88
87
 
88
+ /**
89
+ * The project settings files the agent applies on top of what atc passes
90
+ * it, and the refusal for a file whose content would override or route
91
+ * around a sign-in through impd's broker, or null when it holds nothing
92
+ * that would. `files` are relative to the directory a harness starts in;
93
+ * `rootFiles` are relative to that directory, each directory above it, and
94
+ * the main checkout of the git worktree it lies in, since the agent reads
95
+ * them at a repository's root. `findRefusal` takes the file's absolute path
96
+ * on the host.
97
+ */
98
+ export interface ProjectSettingsCheck {
99
+ readonly files: readonly string[];
100
+ readonly rootFiles: readonly string[];
101
+ readonly findRefusal: (path: string, content: string) => DaemonError | null;
102
+ }
103
+
89
104
  export interface TranscriptToolUse {
90
105
  readonly name: string;
91
106
  readonly input: string;
@@ -269,6 +284,11 @@ export interface AgentAdapter {
269
284
  // The command that exits 0 inside a remote host when the agent there can
270
285
  // sign in without a person. Absent: atc runs no check.
271
286
  readonly planAuthCheck?: () => readonly string[];
287
+
288
+ // The project settings a remote launch behind impd's broker reads in
289
+ // the directory the harness starts in, before it starts. Absent: atc
290
+ // reads none.
291
+ readonly planProjectSettingsCheck?: () => ProjectSettingsCheck;
272
292
  readonly normalizeHook: (e: HookEvent) => AdapterEvent;
273
293
  readonly loadName: (
274
294
  source: string,
@@ -0,0 +1,74 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import { isSubscriptionOverrideVariable } from '../shared/is-subscription-override-variable';
3
+ import { isRecord } from '../shared/report';
4
+ import type { ProjectSettingsCheck } from './agent-adapter';
5
+
6
+ /**
7
+ * The check of a Claude CLI session's project settings before a launch
8
+ * behind impd's broker. The CLI applies a repository's
9
+ * `.claude/settings.json` from the directory it starts in, and its
10
+ * `.claude/settings.local.json` from there and from the repository's root
11
+ * or a worktree's main checkout, once the folder is trusted; a person can
12
+ * accept that trust inside the session. Each file refuses the launch when
13
+ * its `env` block sets a variable that would override the sign-in or route
14
+ * around the broker, or when it sets `apiKeyHelper`. A file that is not a
15
+ * JSON object refuses it too, since what the CLI would take from it is
16
+ * unknown. A refusal holds the setting's name, never its value.
17
+ */
18
+ export function buildClaudeProjectSettingsCheck(agent: string): ProjectSettingsCheck {
19
+ return {
20
+ files: ['.claude/settings.json', LOCAL_SETTINGS_FILE],
21
+ rootFiles: [LOCAL_SETTINGS_FILE],
22
+ findRefusal: (file, content) => {
23
+ const setting = findOverrideSetting(content);
24
+
25
+ if (setting === null) {
26
+ return null;
27
+ }
28
+
29
+ const message =
30
+ setting === UNPARSEABLE
31
+ ? `agent '${agent}' signs in through impd's broker, but ${file} is not a JSON object, so atc cannot check it for settings that would override that sign-in`
32
+ : `agent '${agent}' signs in through impd's broker, but ${file} sets ${setting}, which would override or route around that sign-in`;
33
+
34
+ return new DaemonError('auth_target_unsupported', message, {
35
+ agent,
36
+ problem: 'project_settings_conflict',
37
+ file,
38
+ setting,
39
+ });
40
+ },
41
+ };
42
+ }
43
+
44
+ const LOCAL_SETTINGS_FILE = '.claude/settings.local.json';
45
+ const UNPARSEABLE = '(unparseable)';
46
+
47
+ // The first setting a project settings file holds that would override the
48
+ // sign-in, as `env.<variable>` or `apiKeyHelper`, the unparseable marker
49
+ // for content that is not a JSON object, or null when it holds none.
50
+ function findOverrideSetting(content: string): string | null {
51
+ let parsed: unknown;
52
+
53
+ try {
54
+ parsed = JSON.parse(content);
55
+ } catch {
56
+ return UNPARSEABLE;
57
+ }
58
+
59
+ if (!isRecord(parsed) || Array.isArray(parsed)) {
60
+ return UNPARSEABLE;
61
+ }
62
+
63
+ if (parsed['apiKeyHelper'] !== undefined) {
64
+ return 'apiKeyHelper';
65
+ }
66
+
67
+ const env = parsed['env'];
68
+
69
+ const variable = isRecord(env)
70
+ ? Object.keys(env).find((key) => isSubscriptionOverrideVariable(key))
71
+ : undefined;
72
+
73
+ return variable === undefined ? null : `env.${variable}`;
74
+ }
@@ -23,6 +23,7 @@ import type {
23
23
  GuestSpawnPlan,
24
24
  HeadlessRunner,
25
25
  NameUpdate,
26
+ ProjectSettingsCheck,
26
27
  ResumeCheck,
27
28
  SpawnOptionSpecs,
28
29
  SpawnOptions,
@@ -34,6 +35,7 @@ import { buildClaudeConfigSeed } from './build-claude-config-seed';
34
35
  import { buildClaudeGuestLaunch } from './build-claude-guest-launch';
35
36
  import { buildClaudeMCPConfig } from './build-claude-mcp-config';
36
37
  import { buildClaudeOverrideArgs } from './build-claude-override-args';
38
+ import { buildClaudeProjectSettingsCheck } from './build-claude-project-settings-check';
37
39
  import { buildHookSettings } from './build-hook-settings';
38
40
  import type { HookSettingsProfile } from './build-hook-settings';
39
41
  import { buildRestoreModeArgs } from './build-restore-mode-args';
@@ -226,6 +228,12 @@ export class ClaudeAdapter implements AgentAdapter {
226
228
  return buildClaudeConfigSeed(root);
227
229
  }
228
230
 
231
+ // The repository's own settings files outrank what the session's user
232
+ // settings hold, so a launch behind impd's broker reads them first.
233
+ planProjectSettingsCheck(): ProjectSettingsCheck {
234
+ return buildClaudeProjectSettingsCheck(this.id);
235
+ }
236
+
229
237
  // A settings file of the session's own per binding revision carries the
230
238
  // placeholder, and so does the CLI's environment. The configured
231
239
  // arguments go without a permission mode, so the mode the session's own
@@ -17,6 +17,7 @@ import type {
17
17
  GuestSpawnPlan,
18
18
  HeadlessRunner,
19
19
  NameUpdate,
20
+ ProjectSettingsCheck,
20
21
  ResumeCheck,
21
22
  SpawnOptionSpecs,
22
23
  SpawnOptions,
@@ -26,6 +27,7 @@ import { buildATCBridgeFiles } from './build-atc-bridge-files';
26
27
  import { buildClaudeConfigSeed } from './build-claude-config-seed';
27
28
  import { buildClaudeGuestLaunch } from './build-claude-guest-launch';
28
29
  import { buildClaudeOverrideArgs } from './build-claude-override-args';
30
+ import { buildClaudeProjectSettingsCheck } from './build-claude-project-settings-check';
29
31
  import { buildHookSettings } from './build-hook-settings';
30
32
  import { buildRestoreModeArgs } from './build-restore-mode-args';
31
33
  import { ClaudeAdapter } from './claude-adapter';
@@ -335,6 +337,12 @@ export class GatewayAdapter implements AgentAdapter {
335
337
  ];
336
338
  }
337
339
 
340
+ // The repository's own settings files reach the CLI as well, so a launch
341
+ // behind impd's broker reads them first.
342
+ planProjectSettingsCheck(): ProjectSettingsCheck {
343
+ return buildClaudeProjectSettingsCheck(this.id);
344
+ }
345
+
338
346
  private buildBrokerRefusal(reason: string): DaemonError {
339
347
  return new DaemonError(
340
348
  'auth_target_unsupported',
@@ -3,6 +3,7 @@ import { parseConfig } from './shared/config';
3
3
  import { formatJSONKind } from './shared/format-json-kind';
4
4
  import { formatMixedAgentKeys } from './shared/format-mixed-agent-keys';
5
5
  import { LEGACY_AGENT_KEYS } from './shared/legacy-agent-keys';
6
+ import { REMOVED_CONFIG_KEYS } from './shared/removed-config-keys';
6
7
  import { isRecord } from './shared/report';
7
8
 
8
9
  /**
@@ -17,7 +18,8 @@ type MigratedConfig =
17
18
 
18
19
  /**
19
20
  * Rewrites a config.json that uses the old agent keys so it holds the same
20
- * agents under `agents`. The old keys are removed and `agents` takes the
21
+ * agents under `agents`, and drops every key atc no longer reads. A file
22
+ * that holds `agents` and a removed key is rewritten without the key. The old keys are removed and `agents` takes the
21
23
  * place of the first of them, or the end of the file when it sets none. Each
22
24
  * entry holds only the fields that differ from a default. Values are copied
23
25
  * and never reported, so a note names a key and a reason only.
@@ -31,11 +33,26 @@ export function buildMigratedConfig(raw: unknown): MigratedConfig {
31
33
  }
32
34
 
33
35
  const present = Object.keys(raw).filter((key) => LEGACY_AGENT_KEYS.includes(key));
36
+ const removed = REMOVED_CONFIG_KEYS.filter((key) => Object.hasOwn(raw, key));
37
+
38
+ const removedNotes = removed.map(
39
+ (key) => `atc config migrate: ${key} is dropped: atc no longer reads it`,
40
+ );
34
41
 
35
42
  if (Object.hasOwn(raw, 'agents')) {
36
- return present.length === 0
37
- ? { kind: 'current' }
38
- : { kind: 'unusable', detail: formatMixedAgentKeys(present) };
43
+ if (present.length > 0) {
44
+ return { kind: 'unusable', detail: formatMixedAgentKeys(present) };
45
+ }
46
+
47
+ if (removed.length === 0) {
48
+ return { kind: 'current' };
49
+ }
50
+
51
+ return {
52
+ kind: 'migrated',
53
+ text: `${JSON.stringify(Object.fromEntries(Object.entries(raw).filter(([key]) => !removed.includes(key))), null, 2)}\n`,
54
+ notes: removedNotes,
55
+ };
39
56
  }
40
57
 
41
58
  const config = parseConfig(raw);
@@ -49,6 +66,10 @@ export function buildMigratedConfig(raw: unknown): MigratedConfig {
49
66
  let placed = false;
50
67
 
51
68
  for (const [key, value] of Object.entries(raw)) {
69
+ if (removed.includes(key)) {
70
+ continue;
71
+ }
72
+
52
73
  if (!LEGACY_AGENT_KEYS.includes(key)) {
53
74
  migrated[key] = value;
54
75
  } else if (!placed) {
@@ -64,7 +85,10 @@ export function buildMigratedConfig(raw: unknown): MigratedConfig {
64
85
  return {
65
86
  kind: 'migrated',
66
87
  text: `${JSON.stringify(migrated, null, 2)}\n`,
67
- notes: collectDroppedGateways(raw['gateways'], config.agents, config.agentErrors),
88
+ notes: [
89
+ ...collectDroppedGateways(raw['gateways'], config.agents, config.agentErrors),
90
+ ...removedNotes,
91
+ ],
68
92
  };
69
93
  }
70
94
 
package/src/cli.ts CHANGED
@@ -435,6 +435,12 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
435
435
  );
436
436
  }
437
437
 
438
+ if (cfg.removedKeys.length > 0) {
439
+ console.error(
440
+ `atc daemon: config: config.json sets ${cfg.removedKeys.join(', ')}, which atc no longer reads; run 'atc config migrate' to drop it`,
441
+ );
442
+ }
443
+
438
444
  const sources = sourceOrder.buildSources(
439
445
  builtinSources.collectBuiltinSources({
440
446
  roots: cfg.dirs.roots,
@@ -489,7 +495,7 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
489
495
  ? {}
490
496
  : { listen: { host: listen.host, port: listen.port, tokenFile } }),
491
497
  restoreBootTimeoutMs,
492
- resumeInterruptedTurns: cfg.resumeInterruptedTurns,
498
+ restoreFleetOnRestart: cfg.restoreFleetOnRestart,
493
499
  ...(Number.isFinite(graceOverride) && graceOverride >= 0
494
500
  ? { tapGraceMs: graceOverride }
495
501
  : {}),
@@ -1034,9 +1034,6 @@ export class DaemonConnection {
1034
1034
  ...(data.trustClonedWorkspace === undefined
1035
1035
  ? {}
1036
1036
  : { trustClonedWorkspace: data.trustClonedWorkspace }),
1037
- ...(data.resumeInterruptedTurns === undefined
1038
- ? {}
1039
- : { resumeInterruptedTurns: data.resumeInterruptedTurns }),
1040
1037
  },
1041
1038
  target,
1042
1039
  workspace: data.workspace ?? null,
@@ -162,10 +162,9 @@ export interface DaemonOptions {
162
162
  readonly restoreBootTimeoutMs?: number;
163
163
  readonly tapGraceMs?: number;
164
164
 
165
- // Whether a fleet restore sends a session that was mid-turn when the
166
- // previous daemon stopped one message to carry on, for a session spawned
167
- // without its own choice; off when unset.
168
- readonly resumeInterruptedTurns?: boolean;
165
+ // Whether the daemon restores the stored fleet by itself once it is
166
+ // listening, when that fleet holds sessions; off when unset.
167
+ readonly restoreFleetOnRestart?: boolean;
169
168
 
170
169
  // How long a confirm token from `session.forget` stays usable.
171
170
  readonly forgetConfirmMs?: number;
@@ -291,11 +290,6 @@ const LOCK_WAIT_MS = 2000;
291
290
  * because transports guarantee byte integrity and such a line means a buggy
292
291
  * or hostile peer.
293
292
  */
294
- // The sender and opening of the message a restore sends a session whose
295
- // turn the previous daemon's stop cut off.
296
- const RESUME_SENDER = 'atc';
297
- const RESUME_LEAD = 'atc restarted the daemon';
298
-
299
293
  export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
300
294
  let stopDaemon: (() => Promise<void>) | null = null;
301
295
 
@@ -1327,37 +1321,6 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1327
1321
  return record;
1328
1322
  };
1329
1323
 
1330
- const startedAt = new Date().toISOString();
1331
-
1332
- // Sends a restored session the message that carries on a turn the
1333
- // previous daemon's stop cut off. A resume message still pending from an
1334
- // earlier restart covers this one, so a session never holds two.
1335
- const sendResumeMessage = async (s: Session): Promise<void> => {
1336
- const pending = await store.collectPendingMessages(buildMessageOwner(s));
1337
-
1338
- if (
1339
- pending.some((record) => record.from === RESUME_SENDER && record.text.startsWith(RESUME_LEAD))
1340
- ) {
1341
- return;
1342
- }
1343
-
1344
- const refusal = findMessageRefusal(s.id);
1345
-
1346
- if (refusal !== null) {
1347
- mgr.log(`atc sent session ${s.id} no resume message (${refusal})`);
1348
-
1349
- return;
1350
- }
1351
-
1352
- await writeAcceptedMessage(
1353
- s.id,
1354
- RESUME_SENDER,
1355
- `${RESUME_LEAD} at ${startedAt}; your last turn was interrupted. Check the state of anything you had in flight, then continue.`,
1356
- mintMessageID(),
1357
- () => {},
1358
- );
1359
- };
1360
-
1361
1324
  // A retried send answers with the message's current status, so a caller
1362
1325
  // that lost the first answer learns where its message stands now.
1363
1326
  const loadMessageReplay = async (
@@ -1396,6 +1359,44 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1396
1359
 
1397
1360
  const sources = opts.sources ?? [];
1398
1361
 
1362
+ // The restore in flight, from its first call until its staggered terminal
1363
+ // adoption finishes. A restore requested meanwhile joins it, so the fleet
1364
+ // never registers twice at once.
1365
+ let fleetRestore: Promise<number> | null = null;
1366
+
1367
+ const runFleetRestore = (cols: number, rows: number): Promise<number> => {
1368
+ if (fleetRestore !== null) {
1369
+ return fleetRestore;
1370
+ }
1371
+
1372
+ const restored = Promise.withResolvers<number>();
1373
+
1374
+ fleetRestore = restored.promise;
1375
+
1376
+ void (async () => {
1377
+ try {
1378
+ const result = await restoreFleet({
1379
+ mgr,
1380
+ store,
1381
+ findRuntime,
1382
+ cols,
1383
+ rows,
1384
+ capMs: opts.restoreBootTimeoutMs ?? 0,
1385
+ });
1386
+
1387
+ restored.resolve(result.restored);
1388
+
1389
+ await result.settled;
1390
+ } catch (error) {
1391
+ restored.reject(error);
1392
+ } finally {
1393
+ fleetRestore = null;
1394
+ }
1395
+ })();
1396
+
1397
+ return restored.promise;
1398
+ };
1399
+
1399
1400
  const ctx: DaemonContext = {
1400
1401
  build: opts.build,
1401
1402
  daemonID: store.daemonID,
@@ -1825,17 +1826,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1825
1826
  getEffectiveDims: (sessionID) =>
1826
1827
  attachments.findEffectiveDims(sessionID) ??
1827
1828
  runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
1828
- restoreFleet: (cols, rows) =>
1829
- restoreFleet({
1830
- mgr,
1831
- store,
1832
- findRuntime,
1833
- cols,
1834
- rows,
1835
- capMs: opts.restoreBootTimeoutMs ?? 0,
1836
- resumeInterruptedTurns: opts.resumeInterruptedTurns ?? false,
1837
- sendResumeMessage,
1838
- }),
1829
+ restoreFleet: runFleetRestore,
1839
1830
  readSessionRecord: async (id, access) => {
1840
1831
  const s = mgr.sessions.find((x) => x.id === id);
1841
1832
 
@@ -2275,6 +2266,27 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
2275
2266
  listenPort: tcpListener?.port ?? null,
2276
2267
  });
2277
2268
 
2269
+ // The stored fleet restores once per start, at the size a restored
2270
+ // terminal takes until its first attach resizes it. A failure is logged
2271
+ // and never stops the daemon.
2272
+ const tryRunFleetRestore = async (): Promise<void> => {
2273
+ try {
2274
+ await runFleetRestore(80, 24);
2275
+ } catch (error) {
2276
+ mgr.log(
2277
+ `atc could not restore the fleet (${error instanceof Error ? error.message : String(error)})`,
2278
+ );
2279
+ }
2280
+ };
2281
+
2282
+ if (opts.restoreFleetOnRestart === true) {
2283
+ const stored = await store.loadFleet();
2284
+
2285
+ if (stored.length > 0) {
2286
+ void tryRunFleetRestore();
2287
+ }
2288
+ }
2289
+
2278
2290
  return {
2279
2291
  stop: stopDaemon,
2280
2292
  countClients: () => clients.size,
@@ -16,12 +16,15 @@ export interface RestoreFleetParams {
16
16
  // it has booted before moving on regardless; zero waits on the signal
17
17
  // alone.
18
18
  readonly capMs: number;
19
+ }
20
+
21
+ export interface RestoreFleetResult {
22
+ // How many sessions the restore registered.
23
+ readonly restored: number;
19
24
 
20
- // Whether a session spawned without its own choice gets one message to
21
- // carry on a turn the previous daemon's stop cut off, and how that
22
- // message is sent once the session's terminal is adopted.
23
- readonly resumeInterruptedTurns: boolean;
24
- readonly sendResumeMessage: (s: Session) => Promise<void>;
25
+ // Resolves once the staggered terminal adoption behind the restore has
26
+ // finished, also when it failed, and at once when nothing was queued.
27
+ readonly settled: Promise<void>;
25
28
  }
26
29
 
27
30
  /**
@@ -29,18 +32,15 @@ export interface RestoreFleetParams {
29
32
  * the rest by recency, registers every one as a terminal-less session so
30
33
  * the whole fleet lists at once, then adopts terminals one at a time,
31
34
  * waiting for each session to report it has booted before starting the
32
- * next. A session that was mid-turn when the previous daemon stopped, and
33
- * that resumes interrupted turns, gets one message to carry on once its
34
- * terminal is adopted.
35
+ * next.
35
36
  */
36
- export async function restoreFleet(params: RestoreFleetParams): Promise<number> {
37
+ export async function restoreFleet(params: RestoreFleetParams): Promise<RestoreFleetResult> {
37
38
  const mgr = params.mgr;
38
39
  const store = params.store;
39
40
  const findRuntime = params.findRuntime;
40
41
  const cols = params.cols;
41
42
  const rows = params.rows;
42
43
  const capMs = params.capMs;
43
- const sendResumeMessage = params.sendResumeMessage;
44
44
 
45
45
  const hasLiveSession = (entry: FleetEntry) =>
46
46
  mgr.sessions.some(
@@ -92,33 +92,11 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
92
92
  .filter((r) => r.revive || r.session.state !== 'exited')
93
93
  .map((r) => r.session);
94
94
 
95
- // Only a session this restore registers comes from the previous daemon's
96
- // fleet; a listed one this daemon already ran is revived by hand. The
97
- // trail is read before any terminal is adopted, since a revived agent's
98
- // own events move it on.
99
- const interrupted = await collectInterruptedTurns(
100
- mgr,
101
- store,
102
- registered.filter((r) => !r.revive && r.session.state !== 'exited').map((r) => r.session),
103
- params.resumeInterruptedTurns,
104
- );
105
-
106
95
  // A session whose target refuses to start a terminal stays listed without
107
- // one, and the restore moves on to the next. A resume message that fails
108
- // to send is logged, and the session runs on without it.
96
+ // one, and the restore moves on to the next.
109
97
  const adoptQueued = async (s: Session): Promise<boolean> => {
110
98
  const adopted = await tryAdoptTerminal(mgr, s.id, cols, rows);
111
99
 
112
- if (adopted !== null && interrupted.has(s.id)) {
113
- try {
114
- await sendResumeMessage(s);
115
- } catch (error) {
116
- mgr.log(
117
- `atc could not send session ${s.id} its resume message (${error instanceof Error ? error.message : String(error)})`,
118
- );
119
- }
120
- }
121
-
122
100
  return adopted !== null;
123
101
  };
124
102
 
@@ -159,7 +137,7 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
159
137
  const [first, ...rest] = queued;
160
138
 
161
139
  if (first === undefined) {
162
- return registered.length;
140
+ return { restored: registered.length, settled: Promise.resolve() };
163
141
  }
164
142
 
165
143
  // The first terminal attaches before the restore answers so a caller can
@@ -200,42 +178,17 @@ export async function restoreFleet(params: RestoreFleetParams): Promise<number>
200
178
 
201
179
  const firstBooted = firstAdopted ? first : null;
202
180
 
203
- void adoptRest(firstBooted);
204
-
205
- return registered.length;
206
- }
207
-
208
- // The sessions whose last turn event in the trail is a submitted prompt:
209
- // the previous daemon stopped while their turn ran. A session's own choice
210
- // beats the config, and an agent that takes no atc messages has no path for
211
- // the resume message. Only a harness on the daemon's own machine ends with
212
- // the daemon; one on a host with a lifecycle of its own runs on, so its
213
- // turn was never cut off and a revive attaches to it as it stands.
214
- async function collectInterruptedTurns(
215
- mgr: SessionManager,
216
- store: StateStore,
217
- sessions: readonly Session[],
218
- configured: boolean,
219
- ): Promise<ReadonlySet<SessionID>> {
220
- const interrupted = new Set<SessionID>();
221
-
222
- for (const s of sessions) {
223
- if (
224
- !(s.resumeInterruptedTurns ?? configured) ||
225
- s.attachment !== 'local' ||
226
- mgr.findAdapter(s.agent)?.takesMessages !== true
227
- ) {
228
- continue;
229
- }
230
-
231
- const kind = await store.findLatestTurnKind(s.id, s.agentSessionID);
232
-
233
- if (kind === 'prompt-submitted') {
234
- interrupted.add(s.id);
181
+ const adoptRestLogged = async (): Promise<void> => {
182
+ try {
183
+ await adoptRest(firstBooted);
184
+ } catch (error) {
185
+ mgr.log(
186
+ `atc could not finish restoring the fleet (${error instanceof Error ? error.message : String(error)})`,
187
+ );
235
188
  }
236
- }
189
+ };
237
190
 
238
- return interrupted;
191
+ return { restored: registered.length, settled: adoptRestLogged() };
239
192
  }
240
193
 
241
194
  // A stored entry matches a listed session by its atc session id, or by its
@@ -27,6 +27,7 @@ import { truncateDetail } from '../shared/truncate-detail';
27
27
  import { truncateToBytes } from '../shared/truncate-to-bytes';
28
28
  import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
29
29
  import type { SessionWorkspace } from '../store/workspace-materialization';
30
+ import { REPOSITORY_ENV_VARS } from '../workspace/repository-env-vars';
30
31
  import type { BrokerAuthHost } from './broker-auth-host';
31
32
  import { buildAuthBinding } from './build-auth-binding';
32
33
  import type { AuthBinding } from './build-auth-binding';
@@ -184,10 +185,6 @@ export interface Session {
184
185
  // The epoch of the session's latest harness start or attach, which the
185
186
  // daemon's bridge to that harness is bound to; 0 before the first.
186
187
  bridgeEpoch: number;
187
-
188
- // Whether a fleet restore sends the session one message to carry on a
189
- // turn a daemon restart cut off; absent follows the daemon's config.
190
- resumeInterruptedTurns?: boolean;
191
188
  }
192
189
 
193
190
  // A session's ready workspace and the variables its harnesses go without.
@@ -241,6 +238,31 @@ const RESOLVE_DIR_SCRIPT = `p=$1; s=; while [ ! -d "$p" ]; do s=/\${p##*/}$s; p=
241
238
  const REMOVE_DIR_SCRIPT =
242
239
  'cd -P -- "$1" || exit 3; [ "$(pwd -P; printf x)" = "$2" ] || exit 4; find . -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + || exit 5; cd / && rmdir -- "$1"';
243
240
 
241
+ // Enters a directory as the host resolves it and prints, each followed by
242
+ // a NUL, every path where something, even a dangling symlink, stands: each
243
+ // start file in that directory, then each root file in that directory,
244
+ // every directory above it, and the main checkout of the git worktree it
245
+ // lies in. A `--` argument ends the start files. A directory that does not
246
+ // exist exits with the absent status.
247
+ const FIND_PROJECT_SETTINGS_SCRIPT = `cd -P -- "$1" 2>/dev/null || exit 3; shift; d=$(pwd -P)
248
+ emit() { if [ -e "$1" ] || [ -L "$1" ]; then printf '%s\\0' "$1"; fi; }
249
+ while [ "$#" -gt 0 ] && [ "$1" != -- ]; do emit "\${d%/}/$1"; shift; done; [ "$#" -gt 0 ] && shift
250
+ m=$(git -c safe.directory='*' -c core.fsmonitor=false rev-parse --path-format=absolute --git-common-dir 2>/dev/null) && m=\${m%/.git} || m=
251
+ for n in "$@"; do p=$d; while :; do emit "\${p%/}/$n"; [ "$p" = / ] && break; p=\${p%/*}; [ -n "$p" ] || p=/; done; [ -z "$m" ] || emit "\${m%/}/$n"; done
252
+ exit 0`;
253
+
254
+ // Prints at most one byte more than the largest project settings file atc
255
+ // reads, following a symlink, and fails with the special-file status for
256
+ // anything but a regular file there, such as a FIFO or a device that would
257
+ // never end.
258
+ const READ_SETTINGS_FILE = '[ -f "$1" ] || exit 4; exec head -c 1048577 -- "$1"';
259
+ const ABSENT_DIR_EXIT = 3;
260
+
261
+ // Runs git on the directory it starts in, whatever repository the daemon's
262
+ // environment pins git to.
263
+ const GIT_ENV_ARGV = ['env', ...[...REPOSITORY_ENV_VARS].flatMap((name) => ['-u', name])];
264
+ const MAX_SETTINGS_BYTES = 1_048_576;
265
+
244
266
  // A readied host's harness plan, and the auth attempt that provisioned the
245
267
  // host, if one did.
246
268
  interface HarnessSetup {
@@ -580,9 +602,6 @@ export class SessionManager {
580
602
  attachment: this.hasHostLifecycle(target) ? 'detached' : 'local',
581
603
  hostKey: entry.hostKey ?? entry.sessionID,
582
604
  bridgeEpoch: 0,
583
- ...(entry.resumeInterruptedTurns === undefined
584
- ? {}
585
- : { resumeInterruptedTurns: entry.resumeInterruptedTurns }),
586
605
  };
587
606
 
588
607
  this.sessions.push(session);
@@ -693,6 +712,7 @@ export class SessionManager {
693
712
  ...(s.effort === undefined ? {} : { effort: s.effort }),
694
713
  },
695
714
  authSetup,
715
+ s.cwd,
696
716
  );
697
717
 
698
718
  plan = setup.plan;
@@ -999,6 +1019,10 @@ export class SessionManager {
999
1019
  }
1000
1020
  }
1001
1021
 
1022
+ // A spawn that builds its workspace reads the workspace's project
1023
+ // settings once it is built, not when the host is readied before it.
1024
+ const startDir = materialize === null ? cwd : null;
1025
+
1002
1026
  const setupHost = () =>
1003
1027
  this.setupHarnessOnHost(
1004
1028
  adapter,
@@ -1008,9 +1032,17 @@ export class SessionManager {
1008
1032
  target,
1009
1033
  { prompt, resume, ...overrides },
1010
1034
  authSetup,
1035
+ startDir,
1011
1036
  hostKey === id,
1012
1037
  );
1013
1038
 
1039
+ // A launch behind the broker reads the project settings of the clone it
1040
+ // starts in, once the clone is on the host and before it is trusted.
1041
+ const checkWorkspace =
1042
+ auth === null
1043
+ ? null
1044
+ : (root: string) => this.requireProjectSettings(adapter, provider, hostKey, target, root);
1045
+
1014
1046
  // Takes back trust a spawn accepted in the user's own agent config when
1015
1047
  // the spawn fails before its harness starts.
1016
1048
  const localTrust: { remove: (() => Promise<void>) | null } = { remove: null };
@@ -1061,6 +1093,7 @@ export class SessionManager {
1061
1093
  execution.identity,
1062
1094
  materialize,
1063
1095
  setupHost,
1096
+ checkWorkspace,
1064
1097
  trustWorkspace,
1065
1098
  );
1066
1099
  });
@@ -1139,9 +1172,6 @@ export class SessionManager {
1139
1172
  suspended: false,
1140
1173
  hostKey,
1141
1174
  bridgeEpoch: binding.epoch,
1142
- ...(overrides.resumeInterruptedTurns === undefined
1143
- ? {}
1144
- : { resumeInterruptedTurns: overrides.resumeInterruptedTurns }),
1145
1175
  };
1146
1176
 
1147
1177
  this.attachHarness(session, pty, this.hasHostLifecycle(target));
@@ -1591,7 +1621,9 @@ export class SessionManager {
1591
1621
  // Materializes a spawn's workspace, readying its host once the source
1592
1622
  // resolves, or after the workspace for a directory that runs as it
1593
1623
  // stands, and returns the directory it created, null for one that runs
1594
- // as it stands. A failure once the host is ready takes it back.
1624
+ // as it stands. A clone is checked and then trusted, either as the caller
1625
+ // asks, and a failure there removes it. A failure once the host is ready
1626
+ // takes it back.
1595
1627
  private async materializeOnSpawnHost(
1596
1628
  provider: ExecutionProvider,
1597
1629
  id: SessionID,
@@ -1601,6 +1633,7 @@ export class SessionManager {
1601
1633
  targetIdentity: string,
1602
1634
  materialize: SpawnMaterializer,
1603
1635
  setupHost: () => Promise<HarnessSetup>,
1636
+ checkWorkspace: ((root: string) => Promise<void>) | null,
1604
1637
  trustWorkspace: ((root: string) => Promise<void>) | null,
1605
1638
  ): Promise<{
1606
1639
  readonly setup: HarnessSetup;
@@ -1644,16 +1677,17 @@ export class SessionManager {
1644
1677
 
1645
1678
  readied.setup ??= await setupHost();
1646
1679
 
1647
- if (trustWorkspace !== null) {
1648
- if (materialized === null || readied.root === null) {
1649
- throw new DaemonError(
1650
- 'bad_args',
1651
- 'trustClonedWorkspace requires a successfully cloned workspace',
1652
- );
1653
- }
1680
+ if (trustWorkspace !== null && (materialized === null || readied.root === null)) {
1681
+ throw new DaemonError(
1682
+ 'bad_args',
1683
+ 'trustClonedWorkspace requires a successfully cloned workspace',
1684
+ );
1685
+ }
1654
1686
 
1687
+ if (materialized !== null && readied.root !== null) {
1655
1688
  try {
1656
- await trustWorkspace(readied.root);
1689
+ await checkWorkspace?.(readied.root);
1690
+ await trustWorkspace?.(readied.root);
1657
1691
  } catch (error) {
1658
1692
  let removed = false;
1659
1693
 
@@ -1665,7 +1699,7 @@ export class SessionManager {
1665
1699
 
1666
1700
  if (!removed) {
1667
1701
  throw new EffectRemainsError(
1668
- 'workspace trust setup failed and its clone could not be removed',
1702
+ 'workspace setup failed and its clone could not be removed',
1669
1703
  { cause: error },
1670
1704
  );
1671
1705
  }
@@ -1906,9 +1940,10 @@ export class SessionManager {
1906
1940
  target: string,
1907
1941
  options: SpawnOptions,
1908
1942
  auth: HarnessAuthSetup | null,
1943
+ dir: string,
1909
1944
  ): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
1910
1945
  return this.withHostReadying(hostKey, () =>
1911
- this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth, false),
1946
+ this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth, dir, false),
1912
1947
  );
1913
1948
  }
1914
1949
 
@@ -1938,6 +1973,11 @@ export class SessionManager {
1938
1973
  options: SpawnOptions,
1939
1974
  auth: HarnessAuthSetup | null,
1940
1975
 
1976
+ // The directory the harness starts in, whose project settings a launch
1977
+ // behind the broker reads once the host is ready; null for a spawn
1978
+ // whose workspace is built after this, which reads them once it is.
1979
+ cwd: string | null,
1980
+
1941
1981
  // Whether the host is a spawn's new host of its own, which a failure
1942
1982
  // once it is readied destroys.
1943
1983
  isNewHost: boolean,
@@ -2000,6 +2040,10 @@ export class SessionManager {
2000
2040
 
2001
2041
  try {
2002
2042
  await this.setupGuest(adapter, provider, hostKey, target, dir, plan.files);
2043
+
2044
+ if (auth !== null && cwd !== null) {
2045
+ await this.requireProjectSettings(adapter, provider, hostKey, target, cwd);
2046
+ }
2003
2047
  } catch (error) {
2004
2048
  if (attemptID !== null || isNewHost) {
2005
2049
  await this.destroyFailedSpawnHost(provider, id, hostKey, attemptID);
@@ -2135,6 +2179,84 @@ export class SessionManager {
2135
2179
  }
2136
2180
  }
2137
2181
 
2182
+ // Reads each project settings file the agent applies for a harness behind
2183
+ // the broker, from the directory it starts in as the host resolves it, and
2184
+ // throws the agent's refusal for one that would override its sign-in. An
2185
+ // absent file passes; one the host holds but cannot read, that is not a
2186
+ // regular file, or that is larger than any settings file refuses, since
2187
+ // what the agent would take from it is unknown. A relative directory is
2188
+ // under the host's home.
2189
+ private async requireProjectSettings(
2190
+ adapter: AgentAdapter,
2191
+ provider: ExecutionProvider,
2192
+ hostKey: SessionID,
2193
+ target: string,
2194
+ cwd: string,
2195
+ ): Promise<void> {
2196
+ const check = adapter.planProjectSettingsCheck?.();
2197
+
2198
+ if (check === undefined) {
2199
+ return;
2200
+ }
2201
+
2202
+ // The host resolves the directory as given, since a lexical join would
2203
+ // drop a parent step after a symlink that the host follows.
2204
+ const home = posix.isAbsolute(cwd) ? null : await this.resolveHostHome(provider, hostKey);
2205
+ const dir = home === null ? cwd : `${home}/${cwd}`;
2206
+
2207
+ const found = await provider.runCommand({
2208
+ argv: [
2209
+ ...GIT_ENV_ARGV,
2210
+ 'sh',
2211
+ '-c',
2212
+ FIND_PROJECT_SETTINGS_SCRIPT,
2213
+ 'sh',
2214
+ dir,
2215
+ ...check.files,
2216
+ '--',
2217
+ ...check.rootFiles,
2218
+ ],
2219
+ cwd: '/',
2220
+ host: hostKey,
2221
+ });
2222
+
2223
+ if (found.exitCode === ABSENT_DIR_EXIT) {
2224
+ return;
2225
+ }
2226
+
2227
+ if (found.exitCode !== 0) {
2228
+ throw this.buildUnreadableSettingsRefusal(adapter.id, target, dir);
2229
+ }
2230
+
2231
+ const paths = new Set(found.stdout.split('\0').filter((path) => path !== ''));
2232
+
2233
+ for (const path of paths) {
2234
+ const read = await provider.runCommand({
2235
+ argv: ['sh', '-c', READ_SETTINGS_FILE, 'sh', path],
2236
+ cwd: '/',
2237
+ host: hostKey,
2238
+ });
2239
+
2240
+ if (read.exitCode !== 0 || Buffer.byteLength(read.stdout) > MAX_SETTINGS_BYTES) {
2241
+ throw this.buildUnreadableSettingsRefusal(adapter.id, target, path);
2242
+ }
2243
+
2244
+ const refusal = check.findRefusal(path, read.stdout);
2245
+
2246
+ if (refusal !== null) {
2247
+ throw new DaemonError(refusal.code, refusal.message, { ...refusal.data, target });
2248
+ }
2249
+ }
2250
+ }
2251
+
2252
+ private buildUnreadableSettingsRefusal(agent: string, target: string, path: string): DaemonError {
2253
+ return new DaemonError(
2254
+ 'auth_target_unsupported',
2255
+ `agent '${agent}' signs in through impd's broker on target '${target}', but atc cannot read ${path} there as a settings file to check it for settings that would override that sign-in`,
2256
+ { agent, target, problem: 'project_settings_unreadable', file: path },
2257
+ );
2258
+ }
2259
+
2138
2260
  // A sub-session runs on its parent's host when its resolved target, name
2139
2261
  // and identity both, is the one its parent is bound to and that target's
2140
2262
  // hosts have a lifecycle, so one host serves a top-level session and
@@ -2918,9 +3040,6 @@ export class SessionManager {
2918
3040
  targetIdentity: s.targetIdentity,
2919
3041
  ...(s.desired === 'run' ? {} : { desired: s.desired }),
2920
3042
  ...(s.hostKey === s.id ? {} : { hostKey: s.hostKey }),
2921
- ...(s.resumeInterruptedTurns === undefined
2922
- ? {}
2923
- : { resumeInterruptedTurns: s.resumeInterruptedTurns }),
2924
3043
  });
2925
3044
  }
2926
3045
 
@@ -65,9 +65,6 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
65
65
  trustClonedWorkspace: SPAWN_SCHEMA.shape.trustClonedWorkspace.describe(
66
66
  "Trust the exact verified clone for this launch. An explicit true or false overrides the configured target trustClonedWorkspace default; omitting both keeps trust off. Requires a workspace source and either stock Claude on the local target, which adds trust for the clone root alone to the user's Claude config, or, on an imp target, a brokered Claude gateway or stock Claude signed in through the broker, each with isolated guest config; other launches are refused. Accepts repository configuration and helpers without changing tool permission mode. Existing guest config is preserved.",
67
67
  ),
68
- resumeInterruptedTurns: SPAWN_SCHEMA.shape.resumeInterruptedTurns.describe(
69
- "Whether atc sends the session one message to carry on when a daemon restart cuts off its turn. The message goes out when the fleet is restored, only to a session whose last recorded event was a submitted prompt, and only to an agent that takes atc messages. Omit it to follow the daemon's resumeInterruptedTurns config, which is off by default.",
70
- ),
71
68
  detached: z
72
69
  .boolean()
73
70
  .optional()
@@ -423,7 +420,6 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
423
420
  target: 'spawn.target',
424
421
  workspace: 'spawn.workspace',
425
422
  trustClonedWorkspace: 'spawn.workspace.trust',
426
- resumeInterruptedTurns: 'spawn.resumeInterruptedTurns',
427
423
  },
428
424
  },
429
425
  },
@@ -26,7 +26,6 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
26
26
  'transport.tcp': 'a TCP connection to the daemon',
27
27
  'idempotency.replayOnly': 'a resend that only replays a held idempotency key',
28
28
  'session.auth': 'session.auth.revoke and session.auth.rebind',
29
- 'spawn.resumeInterruptedTurns': "atc_session_spawn's resumeInterruptedTurns",
30
29
  };
31
30
 
32
31
  /**
@@ -58,9 +58,6 @@ export function runTool(
58
58
  ...(args['trustClonedWorkspace'] === undefined
59
59
  ? {}
60
60
  : { trustClonedWorkspace: args['trustClonedWorkspace'] }),
61
- ...(args['resumeInterruptedTurns'] === undefined
62
- ? {}
63
- : { resumeInterruptedTurns: args['resumeInterruptedTurns'] }),
64
61
  cols: 100,
65
62
  rows: 30,
66
63
  };
@@ -81,9 +78,6 @@ export function runTool(
81
78
  const trustFeatures: readonly DaemonFeature[] =
82
79
  args['trustClonedWorkspace'] === undefined ? [] : ['spawn.workspace.trust'];
83
80
 
84
- const resumeFeatures: readonly DaemonFeature[] =
85
- args['resumeInterruptedTurns'] === undefined ? [] : ['spawn.resumeInterruptedTurns'];
86
-
87
81
  // Only a daemon that picks a git workspace's directory takes one
88
82
  // without a cwd.
89
83
  const autoDirFeatures: readonly DaemonFeature[] =
@@ -97,7 +91,6 @@ export function runTool(
97
91
  ...targetFeatures,
98
92
  ...workspaceFeatures,
99
93
  ...trustFeatures,
100
- ...resumeFeatures,
101
94
  ...autoDirFeatures,
102
95
  ];
103
96
 
@@ -85,9 +85,6 @@ export const DAEMON_FEATURES = [
85
85
  // `session.auth.revoke` and `session.auth.rebind` exist, open to the
86
86
  // daemon's owner only.
87
87
  'session.auth',
88
-
89
- // `session.spawn` takes `resumeInterruptedTurns`.
90
- 'spawn.resumeInterruptedTurns',
91
88
  ] as const;
92
89
 
93
90
  export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
@@ -196,12 +196,6 @@ export const REQUEST_PARAM_SCHEMAS = {
196
196
  .boolean({ error: 'session.spawn trustClonedWorkspace must be a boolean' })
197
197
  .optional(),
198
198
 
199
- // Whether a fleet restore after a daemon restart sends the session one
200
- // message to carry on an interrupted turn; absent follows the config.
201
- resumeInterruptedTurns: z
202
- .boolean({ error: 'session.spawn resumeInterruptedTurns must be a boolean' })
203
- .optional(),
204
-
205
199
  // The session the new one is a sub-session of; absent or empty spawns a
206
200
  // top-level session.
207
201
  parent: z.preprocess(
@@ -23,6 +23,7 @@ import { formatJSONKind } from './format-json-kind';
23
23
  import { formatMixedAgentKeys } from './format-mixed-agent-keys';
24
24
  import { LEGACY_AGENT_KEYS } from './legacy-agent-keys';
25
25
  import { pickDefaultAgent } from './pick-default-agent';
26
+ import { REMOVED_CONFIG_KEYS } from './removed-config-keys';
26
27
  import { isRecord } from './report';
27
28
  import { resolveHomeDir } from './resolve-home-dir';
28
29
 
@@ -65,10 +66,12 @@ export interface Config {
65
66
  // The workspace config problems, one line each.
66
67
  workspaceErrors: readonly string[];
67
68
 
68
- // Whether a fleet restore after a daemon restart sends a session that
69
- // was mid-turn one message to carry on; a spawn can override it per
70
- // session.
71
- resumeInterruptedTurns: boolean;
69
+ // Whether the daemon restores the stored fleet by itself after a restart.
70
+ restoreFleetOnRestart: boolean;
71
+
72
+ // The keys the file sets that atc no longer reads, in the order the file
73
+ // holds them. Values are never recorded.
74
+ removedKeys: readonly string[];
72
75
  }
73
76
 
74
77
  /**
@@ -117,7 +120,8 @@ const DEFAULTS: Config = {
117
120
  principals: null,
118
121
  principalErrors: [],
119
122
  workspaceErrors: [],
120
- resumeInterruptedTurns: false,
123
+ restoreFleetOnRestart: true,
124
+ removedKeys: [],
121
125
  };
122
126
 
123
127
  const configDir = join(resolveHomeDir(), '.config', 'atc');
@@ -158,7 +162,7 @@ const CONFIG_SCHEMA = z.object({
158
162
  targets: z.unknown().optional(),
159
163
  defaultTarget: z.unknown().optional(),
160
164
  principals: z.unknown().optional(),
161
- resumeInterruptedTurns: buildOptionalBoolean(),
165
+ restoreFleetOnRestart: buildOptionalBoolean(),
162
166
  });
163
167
 
164
168
  /**
@@ -259,6 +263,7 @@ export function renderDefaultConfig(): string {
259
263
  principals: _principals,
260
264
  principalErrors: _principalErrors,
261
265
  workspaceErrors: _workspaceErrors,
266
+ removedKeys: _removedKeys,
262
267
  workspaces,
263
268
  ...written
264
269
  } = DEFAULTS;
@@ -354,7 +359,8 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
354
359
  principals: principals.principals,
355
360
  principalErrors: principals.errors,
356
361
  workspaceErrors: workspaces.errors,
357
- resumeInterruptedTurns: parsed.data.resumeInterruptedTurns ?? DEFAULTS.resumeInterruptedTurns,
362
+ restoreFleetOnRestart: parsed.data.restoreFleetOnRestart ?? DEFAULTS.restoreFleetOnRestart,
363
+ removedKeys: REMOVED_CONFIG_KEYS.filter((key) => Object.hasOwn(raw, key)),
358
364
  };
359
365
  }
360
366
 
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The config.json keys atc once read and no longer does. A file that still
3
+ * sets one loads with a warning, and `atc config migrate` drops it.
4
+ */
5
+ export const REMOVED_CONFIG_KEYS: readonly string[] = ['resumeInterruptedTurns'];
@@ -67,11 +67,6 @@ export interface FleetEntry {
67
67
  // parent's when the two share one host. A row without one runs on a host
68
68
  // of its own.
69
69
  readonly hostKey?: SessionID;
70
-
71
- // Whether a fleet restore after a daemon restart sends the session one
72
- // message to carry on a turn the restart cut off; a row without one
73
- // follows the daemon's config.
74
- readonly resumeInterruptedTurns?: boolean;
75
70
  }
76
71
 
77
72
  export interface FleetStore {
@@ -34,10 +34,6 @@ interface FleetTable {
34
34
  // its parent's when the two share one host. Null for a row from before
35
35
  // hosts were recorded, which runs on its own.
36
36
  host_key: string | null;
37
-
38
- // Whether a restore sends the session one message to carry on an
39
- // interrupted turn: 1 or 0 as the spawn chose, null to follow the config.
40
- resume_interrupted_turns: number | null;
41
37
  }
42
38
 
43
39
  interface EventsTable {
@@ -38,7 +38,6 @@ import type {
38
38
 
39
39
  // Spelled as the partial index's predicate so SQLite can match them.
40
40
  const TRAIL_FILTER = sql<boolean>`kind IS NOT NULL AND kind != 'heartbeat'`;
41
- const TURN_KINDS = ['prompt-submitted', 'turn-done', 'needs-input', 'ended'];
42
41
 
43
42
  /**
44
43
  * Some sessions' slice of the event trail: rows under their atc ids, plus
@@ -211,7 +210,6 @@ export class StateStore {
211
210
  'workspace.withheld_env',
212
211
  'fleet.desired',
213
212
  'fleet.host_key',
214
- 'fleet.resume_interrupted_turns',
215
213
  ])
216
214
  .execute();
217
215
 
@@ -241,9 +239,6 @@ export class StateStore {
241
239
  ...buildWithheldEnvField(row.withheld_env),
242
240
  ...(row.desired === 'sleep' || row.desired === 'stop' ? { desired: row.desired } : {}),
243
241
  ...(row.host_key === null ? {} : { hostKey: toSessionID(row.host_key) }),
244
- ...(row.resume_interrupted_turns === null
245
- ? {}
246
- : { resumeInterruptedTurns: row.resume_interrupted_turns !== 0 }),
247
242
  });
248
243
  }
249
244
 
@@ -351,10 +346,6 @@ export class StateStore {
351
346
  target_identity: entry.targetIdentity ?? null,
352
347
  desired: entry.desired ?? null,
353
348
  host_key: entry.hostKey ?? null,
354
- resume_interrupted_turns:
355
- entry.resumeInterruptedTurns === undefined
356
- ? null
357
- : Number(entry.resumeInterruptedTurns),
358
349
  })
359
350
  .execute();
360
351
 
@@ -548,31 +539,6 @@ export class StateStore {
548
539
  return row?.ts === null || row?.ts === undefined ? null : Date.parse(row.ts);
549
540
  }
550
541
 
551
- // The kind of the session's latest event that moves a turn: a prompt
552
- // submitted, a turn done, a request for input, or the session's end.
553
- // Null when the trail holds none. Matches the session by either id, as
554
- // the activity read does.
555
- async findLatestTurnKind(
556
- atcID: SessionID,
557
- agentSessionID: AgentSessionID | undefined,
558
- ): Promise<string | null> {
559
- const row = await this.db
560
- .selectFrom('events')
561
- .select('kind')
562
- .where('kind', 'in', TURN_KINDS)
563
- .where((eb) =>
564
- eb.or([
565
- eb('atc_id', '=', atcID),
566
- ...(agentSessionID === undefined ? [] : [eb('session_id', '=', agentSessionID)]),
567
- ]),
568
- )
569
- .orderBy('id', 'desc')
570
- .limit(1)
571
- .executeTakeFirst();
572
-
573
- return row?.kind ?? null;
574
- }
575
-
576
542
  async recordSpawnDir(cwd: string, grant: SpawnTarget): Promise<void> {
577
543
  await this.db
578
544
  .insertInto('spawn_history')