@zgeoff/atc 2.39.0 → 2.41.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.39.0",
3
+ "version": "2.41.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 {
@@ -45,12 +44,17 @@ export interface SpawnPlan {
45
44
  * files unpack into. `auth` is given when the harness takes its credential
46
45
  * from impd's broker: the revision of the host's runtime auth binding it
47
46
  * launches under, which keys any settings the agent writes for it, and the
48
- * placeholder variables the harness holds in place of a credential.
47
+ * placeholder variables the harness holds in place of a credential, and
48
+ * the variables its auth profiles set.
49
49
  */
50
50
  export interface GuestPaths {
51
51
  readonly atc: string | null;
52
52
  readonly dir: string;
53
- readonly auth?: { readonly revision: number; readonly env: Readonly<Record<string, string>> };
53
+ readonly auth?: {
54
+ readonly revision: number;
55
+ readonly env: Readonly<Record<string, string>>;
56
+ readonly profileEnv: Readonly<Record<string, string>>;
57
+ };
54
58
  }
55
59
 
56
60
  /**
@@ -290,7 +290,11 @@ export class ClaudeAdapter implements AgentAdapter {
290
290
  const padding = typeof userSettings === 'string' ? findStatuslinePadding(userSettings) : 0;
291
291
 
292
292
  const settings = buildHookSettings(
293
- this.buildSettingsProfile({ ...auth.env, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' }),
293
+ this.buildSettingsProfile({
294
+ ...auth.profileEnv,
295
+ ...auth.env,
296
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
297
+ }),
294
298
  padding,
295
299
  argv,
296
300
  );
@@ -312,7 +316,7 @@ export class ClaudeAdapter implements AgentAdapter {
312
316
  ),
313
317
  ...Object.fromEntries(bridge),
314
318
  },
315
- env: { ...launch.env, ...auth.env },
319
+ env: { ...launch.env, ...auth.profileEnv, ...auth.env },
316
320
  };
317
321
  }
318
322
 
@@ -224,6 +224,7 @@ export class GatewayAdapter implements AgentAdapter {
224
224
  env: {
225
225
  ...this.gateway.env,
226
226
  ANTHROPIC_BASE_URL: this.gateway.baseURL,
227
+ ...guest.auth.profileEnv,
227
228
  ...guest.auth.env,
228
229
  CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
229
230
  },
@@ -253,7 +254,7 @@ export class GatewayAdapter implements AgentAdapter {
253
254
  ...buildClaudeConfigSeed(null),
254
255
  ...Object.fromEntries(bridge),
255
256
  },
256
- env: { ...launch.env, ...guest.auth.env },
257
+ env: { ...launch.env, ...guest.auth.profileEnv, ...guest.auth.env },
257
258
  };
258
259
  }
259
260
 
@@ -352,19 +353,31 @@ export class GatewayAdapter implements AgentAdapter {
352
353
  }
353
354
 
354
355
  // The Claude CLI sends ANTHROPIC_AUTH_TOKEN as a bearer authorization
355
- // header, the one pairing atc binds, so the placeholders must be that
356
- // variable alone, holding the placeholder, for a profile whose rule on
357
- // the base URL's host sets that header. Any other variable would put the
358
- // placeholder in a header the broker never fills.
356
+ // header, the one pairing atc binds, so the placeholders must include that
357
+ // variable, holding the placeholder, for a profile whose rule on the base
358
+ // URL's host sets that header. Any other variable passes through to the
359
+ // session for a tool in it, whose own host a selected profile covers,
360
+ // except a variable the CLI reads as its own credential, which would
361
+ // compete with the bearer variable and put the placeholder in a header
362
+ // the broker never fills, and the variable that names the session's
363
+ // Claude config folder, which atc sets itself.
359
364
  private findPlaceholderRefusal(
360
365
  env: Readonly<Record<string, string>>,
361
366
  profiles: readonly string[],
362
367
  ): DaemonError | null {
363
368
  const keys = Object.keys(env);
364
369
 
365
- if (keys.length !== 1 || keys[0] !== BEARER_VARIABLE) {
370
+ if (!keys.includes(BEARER_VARIABLE)) {
366
371
  return this.buildPlaceholderRefusal(
367
- `needs ${BEARER_VARIABLE} as its only placeholder variable, which the broker fills as a bearer authorization header; it has ${keys.length === 0 ? 'none' : keys.join(', ')}`,
372
+ `needs ${BEARER_VARIABLE} among its placeholder variables, which the broker fills as a bearer authorization header; it has ${keys.length === 0 ? 'none' : keys.join(', ')}`,
373
+ );
374
+ }
375
+
376
+ const competing = keys.find((key) => COMPETING_CREDENTIAL_VARIABLES.has(key));
377
+
378
+ if (competing !== undefined) {
379
+ return this.buildPlaceholderRefusal(
380
+ `cannot use ${competing} as a placeholder variable, since the Claude CLI reads it as its own credential or config folder beside ${BEARER_VARIABLE}`,
368
381
  );
369
382
  }
370
383
 
@@ -444,6 +457,15 @@ export class GatewayAdapter implements AgentAdapter {
444
457
  const BEARER_VARIABLE = 'ANTHROPIC_AUTH_TOKEN';
445
458
  const PLACEHOLDER = 'imp-broker-placeholder';
446
459
 
460
+ // Variables the Claude CLI reads as its own credential beside the bearer
461
+ // variable, plus the one that points it at the session's config folder,
462
+ // whose launch value a placeholder would overwrite.
463
+ const COMPETING_CREDENTIAL_VARIABLES: ReadonlySet<string> = new Set([
464
+ 'ANTHROPIC_API_KEY',
465
+ 'CLAUDE_CODE_OAUTH_TOKEN',
466
+ 'CLAUDE_CONFIG_DIR',
467
+ ]);
468
+
447
469
  // The Claude CLI's mode that asks a person before each action.
448
470
  const MANUAL_PERMISSION_MODE = 'default';
449
471
 
@@ -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
  : {}),
@@ -11,8 +11,9 @@ import { sortJSONKeys } from '../shared/sort-json-keys';
11
11
  * What a session's runtime is bound to through impd's broker: every
12
12
  * profile its gateway's selection reaches, the secrets they need impd to
13
13
  * hold with exactly these rules, the placeholder variables in place of a
14
- * credential, and the endpoint. `hash` covers the secrets and their rules
15
- * alone, so a change to a profile the binding does not reach leaves it as
14
+ * credential, the variables the profiles set, and the endpoint. `hash`
15
+ * covers the secrets and their rules, and the profile variables when there
16
+ * are any, so a change to a profile the binding does not reach leaves it as
16
17
  * it was, and a change to one it reaches gives another hash.
17
18
  */
18
19
  export interface AuthBinding {
@@ -21,6 +22,7 @@ export interface AuthBinding {
21
22
  readonly profiles: readonly string[];
22
23
  readonly secrets: readonly ResolvedAuthSecret[];
23
24
  readonly placeholderEnv: Readonly<Record<string, string>>;
25
+ readonly profileEnv: Readonly<Record<string, string>>;
24
26
  readonly hash: string;
25
27
  }
26
28
 
@@ -29,7 +31,7 @@ type AuthGateway = Pick<GatewayConfig, 'id' | 'baseURL'> & { readonly auth: Gate
29
31
  /**
30
32
  * Plans the binding a gateway's auth asks for against the current auth
31
33
  * profiles, or the problem that refuses it. The hash is the SHA-256 of the
32
- * resolved secrets as canonical JSON, with each list sorted, so the order
34
+ * resolved secrets (and profile variables, when set) as canonical JSON, with each list sorted, so the order
33
35
  * of the selection or of the config never changes it.
34
36
  */
35
37
  export function buildAuthBinding(
@@ -43,6 +45,8 @@ export function buildAuthBinding(
43
45
  }
44
46
 
45
47
  const secrets = resolution.resolved.secrets;
48
+ const profileEnv = resolution.resolved.env;
49
+ const hashed = Object.keys(profileEnv).length === 0 ? { secrets } : { secrets, profileEnv };
46
50
 
47
51
  return {
48
52
  binding: {
@@ -51,8 +55,9 @@ export function buildAuthBinding(
51
55
  profiles: resolution.resolved.profiles,
52
56
  secrets,
53
57
  placeholderEnv: gateway.auth.placeholderEnv,
58
+ profileEnv,
54
59
  hash: createHash('sha256')
55
- .update(JSON.stringify(sortJSONKeys({ secrets })))
60
+ .update(JSON.stringify(sortJSONKeys(hashed)))
56
61
  .digest('hex'),
57
62
  },
58
63
  };
@@ -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
@@ -185,10 +185,6 @@ export interface Session {
185
185
  // The epoch of the session's latest harness start or attach, which the
186
186
  // daemon's bridge to that harness is bound to; 0 before the first.
187
187
  bridgeEpoch: number;
188
-
189
- // Whether a fleet restore sends the session one message to carry on a
190
- // turn a daemon restart cut off; absent follows the daemon's config.
191
- resumeInterruptedTurns?: boolean;
192
188
  }
193
189
 
194
190
  // A session's ready workspace and the variables its harnesses go without.
@@ -606,9 +602,6 @@ export class SessionManager {
606
602
  attachment: this.hasHostLifecycle(target) ? 'detached' : 'local',
607
603
  hostKey: entry.hostKey ?? entry.sessionID,
608
604
  bridgeEpoch: 0,
609
- ...(entry.resumeInterruptedTurns === undefined
610
- ? {}
611
- : { resumeInterruptedTurns: entry.resumeInterruptedTurns }),
612
605
  };
613
606
 
614
607
  this.sessions.push(session);
@@ -1179,9 +1172,6 @@ export class SessionManager {
1179
1172
  suspended: false,
1180
1173
  hostKey,
1181
1174
  bridgeEpoch: binding.epoch,
1182
- ...(overrides.resumeInterruptedTurns === undefined
1183
- ? {}
1184
- : { resumeInterruptedTurns: overrides.resumeInterruptedTurns }),
1185
1175
  };
1186
1176
 
1187
1177
  this.attachHarness(session, pty, this.hasHostLifecycle(target));
@@ -2024,7 +2014,7 @@ export class SessionManager {
2024
2014
  : {
2025
2015
  atc: guest.atc,
2026
2016
  dir,
2027
- auth: await this.planGuestAuth(hostKey, auth.mode, auth.binding.placeholderEnv),
2017
+ auth: await this.planGuestAuth(hostKey, auth.mode, auth.binding),
2028
2018
  };
2029
2019
 
2030
2020
  const plan =
@@ -2110,11 +2100,15 @@ export class SessionManager {
2110
2100
  private async planGuestAuth(
2111
2101
  hostKey: SessionID,
2112
2102
  mode: HarnessAuthSetup['mode'],
2113
- placeholderEnv: Readonly<Record<string, string>>,
2103
+ binding: Pick<AuthBinding, 'placeholderEnv' | 'profileEnv'>,
2114
2104
  ): Promise<NonNullable<GuestPaths['auth']>> {
2115
2105
  const held = mode === 'create' ? null : await this.requireAuthBinder().findBinding(hostKey);
2116
2106
 
2117
- return { revision: held?.revision ?? 1, env: placeholderEnv };
2107
+ return {
2108
+ revision: held?.revision ?? 1,
2109
+ env: binding.placeholderEnv,
2110
+ profileEnv: binding.profileEnv,
2111
+ };
2118
2112
  }
2119
2113
 
2120
2114
  // Creates the binding of a host a spawn provisions, or verifies the one
@@ -3050,9 +3044,6 @@ export class SessionManager {
3050
3044
  targetIdentity: s.targetIdentity,
3051
3045
  ...(s.desired === 'run' ? {} : { desired: s.desired }),
3052
3046
  ...(s.hostKey === s.id ? {} : { hostKey: s.hostKey }),
3053
- ...(s.resumeInterruptedTurns === undefined
3054
- ? {}
3055
- : { resumeInterruptedTurns: s.resumeInterruptedTurns }),
3056
3047
  });
3057
3048
  }
3058
3049
 
@@ -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(
@@ -1,4 +1,5 @@
1
1
  import type { AuthProfile } from './collect-auth-profiles';
2
+ import { collectProfileEnvProblems } from './collect-profile-env-problems';
2
3
  import { isBrokerVariable } from './is-broker-variable';
3
4
  import { isRecord } from './report';
4
5
  import { resolveAuthProfiles } from './resolve-auth-profiles';
@@ -75,6 +76,16 @@ export function checkGatewayAuth(
75
76
  if ('problem' in resolution) {
76
77
  problems.push(resolution.problem.message);
77
78
  } else {
79
+ problems.push(
80
+ ...collectProfileEnvProblems(
81
+ [
82
+ ['env', Object.keys(entry.env)],
83
+ ['settings.env', settingsEnv],
84
+ ],
85
+ resolution.resolved.envOwners,
86
+ ),
87
+ );
88
+
78
89
  const hostProblem = findBaseURLProblem(entry.baseURL ?? '', resolution.resolved.hosts);
79
90
 
80
91
  if (hostProblem !== null) {
@@ -4,8 +4,10 @@ import type { GatewayAuth } from './check-gateway-auth';
4
4
  import type { AuthProfile } from './collect-auth-profiles';
5
5
  import { collectClaudeAuth } from './collect-claude-auth';
6
6
  import type { ClaudeMCPServer } from './collect-claude-auth';
7
+ import { collectProfileEnvProblems } from './collect-profile-env-problems';
7
8
  import { isSubscriptionOverrideVariable } from './is-subscription-override-variable';
8
9
  import { isRecord } from './report';
10
+ import { resolveAuthProfiles } from './resolve-auth-profiles';
9
11
 
10
12
  /**
11
13
  * The agent CLI an entry drives, which decides the adapter behind it.
@@ -305,6 +307,10 @@ function readClaudeAuth(
305
307
 
306
308
  problems.push(...collectSubscriptionProblems(entry));
307
309
 
310
+ if (collected.auth !== null) {
311
+ problems.push(...collectEntryProfileEnvProblems(entry, collected.auth.profiles, authProfiles));
312
+ }
313
+
308
314
  if (collected.auth === null) {
309
315
  return { problems: [...collected.errors, ...problems] };
310
316
  }
@@ -320,6 +326,32 @@ function readClaudeAuth(
320
326
  };
321
327
  }
322
328
 
329
+ // The variables a stock entry sets that its selected profiles also set.
330
+ function collectEntryProfileEnvProblems(
331
+ entry: AgentEntry,
332
+ selected: readonly string[],
333
+ authProfiles: ReadonlyMap<string, AuthProfile>,
334
+ ): string[] {
335
+ const resolution = resolveAuthProfiles(authProfiles, selected);
336
+
337
+ if ('problem' in resolution) {
338
+ return [];
339
+ }
340
+
341
+ const settingsEnv = entry.settings?.['env'];
342
+
343
+ return collectProfileEnvProblems(
344
+ [
345
+ ['env', Object.keys(entry.env)],
346
+ [
347
+ 'settings.env',
348
+ isRecord(settingsEnv) && !Array.isArray(settingsEnv) ? Object.keys(settingsEnv) : [],
349
+ ],
350
+ ],
351
+ resolution.resolved.envOwners,
352
+ );
353
+ }
354
+
323
355
  // What a stock entry with `auth` sets that would override the subscription
324
356
  // sign-in or route the CLI around impd's broker.
325
357
  function collectSubscriptionProblems(entry: AgentEntry): string[] {
@@ -1,3 +1,5 @@
1
+ import { isBrokerVariable } from './is-broker-variable';
2
+ import { isSubscriptionOverrideVariable } from './is-subscription-override-variable';
1
3
  import { isRecord } from './report';
2
4
 
3
5
  /**
@@ -16,6 +18,7 @@ interface CustomAuthProfile {
16
18
  readonly host: string;
17
19
  readonly header: string;
18
20
  readonly scheme: 'bearer';
21
+ readonly env: Readonly<Record<string, string>>;
19
22
  readonly dependencies: readonly string[];
20
23
  }
21
24
 
@@ -23,6 +26,7 @@ interface GitHubAuthProfile {
23
26
  readonly name: string;
24
27
  readonly secret: string;
25
28
  readonly kind: 'github';
29
+ readonly env: Readonly<Record<string, string>>;
26
30
  readonly dependencies: readonly string[];
27
31
  }
28
32
 
@@ -112,7 +116,11 @@ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
112
116
  return `${extra} cannot be set on a github profile, whose hosts and headers impd's github kind fixes`;
113
117
  }
114
118
 
115
- return { name, secret, kind, dependencies: dependencies ?? [] };
119
+ if (entry['env'] !== undefined) {
120
+ return "env cannot be set on a github profile, whose placeholders impd's github kind sets";
121
+ }
122
+
123
+ return { name, secret, kind, env: {}, dependencies: dependencies ?? [] };
116
124
  }
117
125
 
118
126
  if (typeof host !== 'string' || host.length > BROKER_HOST_MAX || !BROKER_HOST.test(host)) {
@@ -131,6 +139,12 @@ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
131
139
  return 'user pairs only with the basic scheme, which atc does not bind';
132
140
  }
133
141
 
142
+ const env = parseProfileEnv(entry['env'], host);
143
+
144
+ if (typeof env === 'string') {
145
+ return env;
146
+ }
147
+
134
148
  return {
135
149
  name,
136
150
  secret,
@@ -138,6 +152,55 @@ function parseAuthProfile(name: string, entry: unknown): AuthProfile | string {
138
152
  host,
139
153
  header,
140
154
  scheme,
155
+ env,
141
156
  dependencies: dependencies ?? [],
142
157
  };
143
158
  }
159
+
160
+ // The value a profile's variable may hold: the placeholder the broker swaps
161
+ // a credential in for, or the profile's own host as an https origin. No
162
+ // other value, so a credential is never written into a profile.
163
+ const ENV_PLACEHOLDER = 'imp-broker-placeholder';
164
+ const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
165
+ const RESERVED_ENV_PREFIXES = ['ANTHROPIC_', 'CLAUDE_', 'ATC_'];
166
+
167
+ const RESERVED_ENV_NAMES: ReadonlySet<string> = new Set(['PATH', 'HOME']);
168
+
169
+ function parseProfileEnv(raw: unknown, host: string): Record<string, string> | string {
170
+ if (raw === undefined) {
171
+ return {};
172
+ }
173
+
174
+ if (!isRecord(raw) || Array.isArray(raw)) {
175
+ return 'env must be an object of variable names';
176
+ }
177
+
178
+ const env: Record<string, string> = {};
179
+
180
+ for (const [key, value] of Object.entries(raw)) {
181
+ if (!ENV_NAME.test(key)) {
182
+ return `env.${key} is not a variable name: use capital letters, digits and underscores`;
183
+ }
184
+
185
+ if (isReservedEnvName(key)) {
186
+ return `env.${key} cannot be set: atc or impd sets or reserves it`;
187
+ }
188
+
189
+ if (value !== ENV_PLACEHOLDER && value !== `https://${host}`) {
190
+ return `env.${key} must be ${ENV_PLACEHOLDER} or https://${host}`;
191
+ }
192
+
193
+ env[key] = value;
194
+ }
195
+
196
+ return env;
197
+ }
198
+
199
+ function isReservedEnvName(key: string): boolean {
200
+ return (
201
+ isBrokerVariable(key) ||
202
+ isSubscriptionOverrideVariable(key) ||
203
+ RESERVED_ENV_NAMES.has(key) ||
204
+ RESERVED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))
205
+ );
206
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Every variable an entry's `env` or `settings.env` sets that one of its
3
+ * selected auth profiles also sets, which would win over or lose to the
4
+ * profile's value by order. `owners` maps each profile variable to the
5
+ * profile that sets it.
6
+ */
7
+ export function collectProfileEnvProblems(
8
+ sources: readonly (readonly [string, readonly string[]])[],
9
+ owners: Readonly<Record<string, string>>,
10
+ ): string[] {
11
+ return sources.flatMap(([source, keys]) =>
12
+ keys
13
+ .filter((key) => owners[key] !== undefined)
14
+ .map((key) => `${source} sets ${key}, which auth profile ${owners[key]} sets`),
15
+ );
16
+ }
@@ -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'];
@@ -23,7 +23,8 @@ export interface ResolvedAuthSecret {
23
23
 
24
24
  /**
25
25
  * A selection with its dependencies expanded: every profile it reaches,
26
- * every host those profiles send a credential to, and the rules grouped by
26
+ * every host those profiles send a credential to, the variables they set
27
+ * in the guest with the profile each came from, and the rules grouped by
27
28
  * secret, each list sorted so two selections of the same profiles resolve
28
29
  * the same.
29
30
  */
@@ -31,6 +32,8 @@ interface ResolvedAuthProfiles {
31
32
  readonly profiles: readonly string[];
32
33
  readonly hosts: readonly string[];
33
34
  readonly secrets: readonly ResolvedAuthSecret[];
35
+ readonly env: Readonly<Record<string, string>>;
36
+ readonly envOwners: Readonly<Record<string, string>>;
34
37
  }
35
38
 
36
39
  /**
@@ -40,7 +43,8 @@ interface ResolvedAuthProfiles {
40
43
  * the config refused.
41
44
  * - `auth_dependency_cycle`: the dependencies loop back on themselves.
42
45
  * - `auth_collision`: two profiles send different credentials or rules to
43
- * one host, which impd could not tell apart.
46
+ * one host, which impd could not tell apart, or set one variable to
47
+ * different values.
44
48
  */
45
49
  export interface AuthProfileProblem {
46
50
  readonly code: 'auth_profile_unknown' | 'auth_dependency_cycle' | 'auth_collision';
@@ -85,6 +89,9 @@ export function resolveAuthProfiles(
85
89
  const byHost = new Map<string, ProfileRule>();
86
90
  const kinds = new Map<string, AuthProfile>();
87
91
 
92
+ const env: Record<string, string> = {};
93
+ const envOwners: Record<string, string> = {};
94
+
88
95
  for (const profile of ordered) {
89
96
  const kindOwner = kinds.get(profile.secret);
90
97
 
@@ -99,6 +106,22 @@ export function resolveAuthProfiles(
99
106
  };
100
107
  }
101
108
 
109
+ for (const [key, value] of Object.entries(profile.env)) {
110
+ const owner = envOwners[key];
111
+
112
+ if (owner === undefined) {
113
+ env[key] = value;
114
+ envOwners[key] = profile.name;
115
+ } else if (env[key] !== value) {
116
+ return {
117
+ problem: {
118
+ code: 'auth_collision',
119
+ message: `profiles ${owner} and ${profile.name} set ${key} to different values`,
120
+ },
121
+ };
122
+ }
123
+ }
124
+
102
125
  for (const rule of getProfileRules(profile)) {
103
126
  const other = byHost.get(rule.host);
104
127
 
@@ -120,6 +143,8 @@ export function resolveAuthProfiles(
120
143
  profiles: ordered.map((profile) => profile.name),
121
144
  hosts: [...byHost.keys()].toSorted(),
122
145
  secrets: buildSecrets([...byHost.values()]),
146
+ env,
147
+ envOwners,
123
148
  },
124
149
  };
125
150
  }
@@ -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')