@zgeoff/atc 2.11.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +51 -0
  3. package/src/agents/build-args-without-flags.ts +25 -0
  4. package/src/agents/build-claude-override-args.ts +26 -0
  5. package/src/agents/claude-adapter.ts +49 -2
  6. package/src/agents/claude-effort-levels.ts +5 -0
  7. package/src/agents/codex-adapter.ts +43 -2
  8. package/src/agents/find-flag-value.ts +27 -0
  9. package/src/agents/gateway-adapter.ts +45 -2
  10. package/src/agents/grok-adapter.ts +19 -1
  11. package/src/cli.ts +18 -0
  12. package/src/client/daemon-client.ts +14 -2
  13. package/src/client/spawn-picker.ts +27 -4
  14. package/src/daemon/build-agent-list.ts +37 -1
  15. package/src/daemon/build-config-revision.ts +27 -0
  16. package/src/daemon/build-execution-targets.ts +34 -0
  17. package/src/daemon/build-fleet-events.ts +2 -1
  18. package/src/daemon/build-payload-hash.ts +31 -0
  19. package/src/daemon/build-scoped-context.ts +232 -0
  20. package/src/daemon/build-target-access.ts +33 -0
  21. package/src/daemon/build-target-forbidden-error.ts +13 -0
  22. package/src/daemon/build-target-identity.ts +22 -0
  23. package/src/daemon/build-target-list.ts +50 -0
  24. package/src/daemon/daemon-connection.ts +331 -95
  25. package/src/daemon/daemon.ts +452 -75
  26. package/src/daemon/effect-remains-error.ts +13 -0
  27. package/src/daemon/execution-provider.ts +103 -0
  28. package/src/daemon/find-execution-refusal.ts +104 -0
  29. package/src/daemon/idempotency-ledger.ts +164 -0
  30. package/src/daemon/local-pty-provider.ts +83 -0
  31. package/src/daemon/mint-session-id.ts +5 -5
  32. package/src/daemon/parse-spawn-overrides.ts +94 -0
  33. package/src/daemon/permission-registry.ts +14 -4
  34. package/src/daemon/restore-fleet.ts +66 -19
  35. package/src/daemon/session-runtime.ts +10 -0
  36. package/src/daemon/sessions.ts +282 -65
  37. package/src/daemon/start-headless-run.ts +11 -0
  38. package/src/daemon/start-headless-turn.ts +11 -2
  39. package/src/daemon/target-access.ts +36 -0
  40. package/src/mcp/answer-mcp-request.ts +12 -4
  41. package/src/mcp/answer-rpc-request.ts +28 -1
  42. package/src/mcp/build-principal-caller.ts +15 -0
  43. package/src/mcp/build-spawn-descriptions.ts +45 -0
  44. package/src/mcp/build-tool-list.ts +32 -4
  45. package/src/mcp/mcp-tools.ts +98 -8
  46. package/src/mcp/parse-idempotency-key.ts +29 -0
  47. package/src/mcp/reconnecting-caller.ts +39 -7
  48. package/src/mcp/require-daemon-features.ts +7 -0
  49. package/src/mcp/run-tool.ts +63 -11
  50. package/src/mcp/types.ts +2 -0
  51. package/src/protocol/daemon-error.ts +5 -1
  52. package/src/protocol/daemon-features.ts +24 -0
  53. package/src/protocol/protocol.ts +44 -11
  54. package/src/protocol/request-param-schemas.ts +60 -10
  55. package/src/shared/agent-session-id.ts +1 -1
  56. package/src/shared/collect-principals.ts +51 -0
  57. package/src/shared/collect-targets.ts +144 -0
  58. package/src/shared/config.ts +139 -14
  59. package/src/shared/daemon-id.ts +8 -0
  60. package/src/shared/format-json-kind.ts +21 -0
  61. package/src/shared/sort-json-keys.ts +19 -0
  62. package/src/shared/to-daemon-id.ts +11 -0
  63. package/src/store/fleet-entry.ts +42 -9
  64. package/src/store/idempotency-record.ts +48 -0
  65. package/src/store/message-owner.ts +1 -1
  66. package/src/store/run-migrations.ts +254 -5
  67. package/src/store/state-store.ts +433 -31
  68. package/src/workspace/check-workspace-completeness.ts +90 -0
  69. package/src/workspace/create-workspace-clone.ts +239 -0
  70. package/src/workspace/normalize-git-url.ts +59 -0
  71. package/src/workspace/read-workspace-tar.ts +38 -0
  72. package/src/workspace/resolve-path-source.ts +170 -0
  73. package/src/workspace/run-git.ts +103 -0
  74. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  75. package/src/workspace/workspace-provenance.ts +11 -0
  76. package/src/workspace/workspace-source.ts +8 -0
@@ -1,24 +1,31 @@
1
1
  import { writeFileSync } from 'node:fs';
2
- import { spawn } from 'bun-pty';
3
- import type { IPty } from 'bun-pty';
4
- import type { AdapterEvent, AgentAdapter, AgentID } from '../agents/agent-adapter';
2
+ import type { AdapterEvent, AgentAdapter, AgentID, SpawnOverrides } from '../agents/agent-adapter';
5
3
  import { truncateDetail } from '../agents/truncate-detail';
4
+ import { DaemonError } from '../protocol/daemon-error';
5
+ import type { ErrorCode } from '../protocol/protocol';
6
6
  import type { AgentSessionID } from '../shared/agent-session-id';
7
7
  import { collectCleanEnv } from '../shared/collect-clean-env';
8
+ import type { TargetConfigError } from '../shared/collect-targets';
8
9
  import { socketPath, statusFile } from '../shared/config';
10
+ import type { DaemonID } from '../shared/daemon-id';
9
11
  import { resolveRepoRoot } from '../shared/resolve-repo-root';
10
12
  import type { SessionID } from '../shared/session-id';
11
13
  import { truncateToBytes } from '../shared/truncate-to-bytes';
12
- import type { FleetEntry, FleetStore } from '../store/fleet-entry';
14
+ import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
15
+ import type { ExecutionTarget } from './build-execution-targets';
16
+ import { buildTargetIdentity } from './build-target-identity';
17
+ import type { ExecutionCapability, ExecutionProvider, HarnessHandle } from './execution-provider';
18
+ import { findExecutionRefusal } from './find-execution-refusal';
13
19
  import type { HookEvent } from './hooks';
20
+ import { LocalPTYProvider } from './local-pty-provider';
14
21
  import { mintSessionID } from './mint-session-id';
15
22
 
16
23
  export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
17
24
 
18
25
  export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
19
26
 
20
- // The over-the-wire view of a session: everything but the PTY handle, plus
21
- // the surface kind.
27
+ // The over-the-wire view of a session: everything but the harness handle,
28
+ // plus the surface kind.
22
29
  export interface SessionDescriptor {
23
30
  readonly id: SessionID;
24
31
  readonly name: string;
@@ -44,6 +51,15 @@ export interface SessionDescriptor {
44
51
  // The session this one is a sub-session of, when it has one: it lists
45
52
  // under that session and takes its pin from it.
46
53
  readonly parent?: SessionID;
54
+
55
+ // Where the session runs: the daemon hosting it, and the execution target
56
+ // on that daemon's host.
57
+ readonly locator: SessionLocator;
58
+ }
59
+
60
+ interface SessionLocator {
61
+ readonly daemonID: DaemonID;
62
+ readonly targetID: string;
47
63
  }
48
64
 
49
65
  export interface Session {
@@ -51,7 +67,10 @@ export interface Session {
51
67
  name: string;
52
68
  cwd: string;
53
69
  kind: 'pty' | 'headless';
54
- pty: IPty | null;
70
+
71
+ // The running harness its execution provider handed back, or null when
72
+ // the session has no terminal.
73
+ pty: HarnessHandle | null;
55
74
  state: SessionState;
56
75
  unread: boolean;
57
76
  lastMsg: string;
@@ -87,6 +106,28 @@ export interface Session {
87
106
  // the transcript file the agent's hooks last reported. Kept apart from the
88
107
  // resume check so a restored path never changes whether a revive is allowed.
89
108
  transcriptPath?: string;
109
+
110
+ // the model and effort the session was spawned with; absent leaves the
111
+ // agent's configured default, and every revive passes them again.
112
+ model?: string;
113
+ effort?: string;
114
+
115
+ // the execution target the session runs on, and the identity it had
116
+ // when the session started there; every revive runs there too, and only
117
+ // while the target keeps that identity.
118
+ target: string;
119
+ targetIdentity: string;
120
+ }
121
+
122
+ // The identity of the implicit `local` target, which a fleet row without a
123
+ // stored identity ran on.
124
+ const LOCAL_TARGET_IDENTITY = buildTargetIdentity('local-pty', {});
125
+
126
+ // The target a session runs on and the identity it is bound to there; null
127
+ // binds a new session to the target as it stands now.
128
+ interface TargetBinding {
129
+ readonly target: string;
130
+ readonly targetIdentity: string | null;
90
131
  }
91
132
 
92
133
  export class SessionManager {
@@ -100,6 +141,12 @@ export class SessionManager {
100
141
 
101
142
  onEvent: (kind: SessionEventKind, s: Session) => void = () => {};
102
143
 
144
+ // Where a background failure is reported, one line at a time: stderr,
145
+ // like the daemon's other background failures.
146
+ log: (line: string) => void = (line) => {
147
+ console.error(line);
148
+ };
149
+
103
150
  // Whether any registered adapter has a screen detector, decided once at
104
151
  // construction since the registry never changes afterward. Lets a hot path
105
152
  // that only cares about this answer skip walking live sessions to find it.
@@ -111,11 +158,25 @@ export class SessionManager {
111
158
 
112
159
  private readonly statusPath: string;
113
160
 
161
+ private readonly targets: ReadonlyMap<string, ExecutionTarget>;
162
+
163
+ private readonly targetErrors: readonly TargetConfigError[];
164
+
114
165
  constructor(
115
166
  fallback: AgentAdapter,
116
167
  store: FleetStore,
117
168
  statusPath: string | undefined = statusFile,
118
169
  adapters: readonly AgentAdapter[] = [],
170
+ targets: readonly ExecutionTarget[] = [
171
+ {
172
+ id: 'local',
173
+ kind: 'local-pty',
174
+ options: {},
175
+ identity: LOCAL_TARGET_IDENTITY,
176
+ provider: new LocalPTYProvider(),
177
+ },
178
+ ],
179
+ targetErrors: readonly TargetConfigError[] = [],
119
180
  ) {
120
181
  // Each adapter names the id it answers to, so a registry key can never
121
182
  // disagree with the adapter behind it. A later one wins the id.
@@ -123,6 +184,51 @@ export class SessionManager {
123
184
  this.hasScreenDetector = Object.values(this.adapters).some((a) => a.screenDetector !== null);
124
185
  this.store = store;
125
186
  this.statusPath = statusPath ?? statusFile;
187
+
188
+ this.targets = new Map(targets.map((t) => [t.id, t]));
189
+
190
+ this.targetErrors = targetErrors;
191
+ }
192
+
193
+ /**
194
+ * The provider a binding's work runs on, after every target check: throws
195
+ * the refusal for a target the config leaves unusable, one that is gone,
196
+ * one whose identity is not the binding's, one with no provider here, and
197
+ * a provider that lacks the capability. Returns the identity a new session
198
+ * binds to as well.
199
+ */
200
+ requireExecution(
201
+ binding: Readonly<TargetBinding>,
202
+ capability: ExecutionCapability,
203
+ ): { readonly provider: ExecutionProvider; readonly identity: string } {
204
+ const refusal = this.findExecutionRefusal(binding, capability);
205
+ const target = this.targets.get(binding.target);
206
+
207
+ if (refusal !== null) {
208
+ throw refusal;
209
+ }
210
+
211
+ if (target === undefined || target.provider === null) {
212
+ throw new Error(`execution target '${binding.target}' passed its checks without a provider`);
213
+ }
214
+
215
+ return { provider: target.provider, identity: target.identity };
216
+ }
217
+
218
+ // The refusal for running work of a capability on a binding's target, or
219
+ // null when the target serves it. A null target is a spawn that names
220
+ // none when the config gives no default, which is always refused.
221
+ findExecutionRefusal(
222
+ binding: Readonly<{ target: string | null; targetIdentity: string | null }>,
223
+ capability: ExecutionCapability,
224
+ ): DaemonError | null {
225
+ return findExecutionRefusal(this.targets, this.targetErrors, binding, capability);
226
+ }
227
+
228
+ // The provider a session's harness runs on, or null when its target is
229
+ // gone from the config or has no provider here.
230
+ findProvider(s: Session): ExecutionProvider | null {
231
+ return this.targets.get(s.target)?.provider ?? null;
126
232
  }
127
233
 
128
234
  /**
@@ -162,26 +268,42 @@ export class SessionManager {
162
268
  return s;
163
269
  }
164
270
 
165
- // Registers a fleet entry as a session with no terminal yet, so a
166
- // fleet-wide restore can show every incoming session at once; adopting it
167
- // later attaches the terminal. Exited entries come back as killed
168
- // sessions: still listed and revivable, never auto-adopted.
271
+ // Registers a fleet entry as a session with no terminal yet, under the
272
+ // atc session id its row holds, so a fleet-wide restore can show every
273
+ // incoming session at once; adopting it later attaches the terminal.
274
+ // Exited entries come back as killed sessions: still listed and revivable,
275
+ // never auto-adopted. An entry without an agent session id has nothing to
276
+ // resume, so it comes back exited too.
169
277
  restore(entry: FleetEntry): Session {
170
- const exited = entry.exited === true;
278
+ const target = entry.target ?? 'local';
279
+ const targetIdentity = entry.targetIdentity ?? LOCAL_TARGET_IDENTITY;
280
+ const refusal = this.findExecutionRefusal({ target, targetIdentity }, 'spawn');
281
+ const targetRefusal = refusal === null ? null : formatTargetRefusal(refusal.code, target);
282
+
283
+ // A session whose target this daemon cannot use comes back exited, so
284
+ // nothing runs it anywhere else: neither a terminal nor a headless turn.
285
+ const exited =
286
+ entry.exited === true || entry.agentSessionID === undefined || targetRefusal !== null;
171
287
 
172
288
  // An entry whose agent is no longer registered still gets its row, so a
173
289
  // backend dropped from the config shows as itself instead of vanishing or
174
290
  // reviving under another agent. Adopting a terminal for it is refused.
175
291
  let lastMsg = 'waiting to restore';
176
292
 
177
- if (exited) {
293
+ if (entry.exited !== true && entry.agentSessionID === undefined) {
294
+ lastMsg = 'nothing to resume';
295
+ } else if (entry.exited !== true && targetRefusal !== null) {
296
+ lastMsg = targetRefusal;
297
+ } else if (exited) {
178
298
  lastMsg = 'killed';
179
299
  } else if (this.findAdapter(entry.agent) === null) {
180
300
  lastMsg = `no adapter for '${entry.agent}'`;
181
301
  }
182
302
 
303
+ const parent = entry.parent ?? null;
304
+
183
305
  const session: Session = {
184
- id: mintSessionID(),
306
+ id: entry.sessionID,
185
307
  name: entry.name,
186
308
  cwd: entry.cwd,
187
309
  kind: 'headless',
@@ -189,17 +311,21 @@ export class SessionManager {
189
311
  state: exited ? 'exited' : 'running',
190
312
  unread: false,
191
313
  lastMsg,
192
- agentSessionID: entry.agentSessionID,
314
+ ...(entry.agentSessionID === undefined ? {} : { agentSessionID: entry.agentSessionID }),
193
315
  agent: entry.agent,
194
316
  pinned: entry.pinned ?? false,
195
317
  lastAttachedAt: entry.lastAttachedAt ?? Date.now(),
196
318
  repoRoot: resolveRepoRoot(entry.cwd),
197
319
  namedBy: 'auto',
198
320
  createdAt: Date.now(),
199
- parent: this.findByAgentSessionID(entry.parent)?.id ?? null,
321
+ parent: parent !== null && this.sessions.some((s) => s.id === parent) ? parent : null,
200
322
  ...(entry.prompt === undefined ? {} : { prompt: entry.prompt }),
201
323
  ...(entry.result === undefined ? {} : { result: entry.result }),
202
324
  ...(entry.transcriptPath === undefined ? {} : { transcriptPath: entry.transcriptPath }),
325
+ ...(entry.model === undefined ? {} : { model: entry.model }),
326
+ ...(entry.effort === undefined ? {} : { effort: entry.effort }),
327
+ target,
328
+ targetIdentity,
203
329
  };
204
330
 
205
331
  this.sessions.push(session);
@@ -209,14 +335,6 @@ export class SessionManager {
209
335
  return session;
210
336
  }
211
337
 
212
- private findByAgentSessionID(agentSessionID: AgentSessionID | undefined): Session | undefined {
213
- if (agentSessionID === undefined) {
214
- return undefined;
215
- }
216
-
217
- return this.sessions.find((s) => s.agentSessionID === agentSessionID);
218
- }
219
-
220
338
  // Adopts a headless session back into a terminal: a fresh PTY resumes the
221
339
  // same agent session id.
222
340
  adoptTerminal(id: SessionID, cols: number, rows: number): Session | null {
@@ -232,14 +350,22 @@ export class SessionManager {
232
350
  return null;
233
351
  }
234
352
 
235
- const plan = adapter.planSpawn({ prompt: '', resume: s.agentSessionID });
353
+ const provider = this.requireExecution(s, 'spawn').provider;
236
354
 
237
- const pty = spawn(plan.bin, plan.args, {
238
- name: 'xterm-256color',
239
- cols,
240
- rows,
355
+ const plan = adapter.planSpawn({
356
+ prompt: '',
357
+ resume: s.agentSessionID,
358
+ ...(s.model === undefined ? {} : { model: s.model }),
359
+ ...(s.effort === undefined ? {} : { effort: s.effort }),
360
+ });
361
+
362
+ const pty = provider.spawnHarness({
363
+ bin: plan.bin,
364
+ args: plan.args,
241
365
  cwd: s.cwd,
242
366
  env: collectCleanEnv({ ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath }),
367
+ cols,
368
+ rows,
243
369
  });
244
370
 
245
371
  s.pty = pty;
@@ -264,7 +390,7 @@ export class SessionManager {
264
390
  this.emitChange();
265
391
  });
266
392
 
267
- void this.writeFleet();
393
+ void this.tryWriteFleet(s.id);
268
394
  this.onEvent('state', s);
269
395
  this.emitChange();
270
396
 
@@ -301,7 +427,7 @@ export class SessionManager {
301
427
  this.onEvent('state', s);
302
428
  }
303
429
 
304
- void this.writeFleet();
430
+ void this.tryWriteFleet(s.id);
305
431
  this.writeStatus();
306
432
  this.emitChange();
307
433
 
@@ -324,10 +450,7 @@ export class SessionManager {
324
450
  if (result !== undefined) {
325
451
  s.result = truncateToBytes(result, 16_384);
326
452
  s.lastDetail = truncateDetail(result);
327
-
328
- if (s.agentSessionID !== undefined) {
329
- void this.store.updateFleetEntry(s.agentSessionID, { result: s.result });
330
- }
453
+ void this.tryUpdateFleetEntry(s.id, { result: s.result });
331
454
  }
332
455
 
333
456
  this.onEvent('state', s);
@@ -364,7 +487,11 @@ export class SessionManager {
364
487
 
365
488
  // resume: true opens the agent's own session picker; an agent session id
366
489
  // resumes that specific session (fleet restore). parent makes the new
367
- // session a sub-session of that one.
490
+ // session a sub-session of that one. overrides hold the model and effort
491
+ // the new process runs with, and the session keeps them for every revive.
492
+ // id is minted here unless the caller minted it ahead of the spawn. target
493
+ // is the execution target the harness runs on; one this daemon cannot use
494
+ // refuses the spawn before anything starts.
368
495
  spawn(
369
496
  cwd: string,
370
497
  name: string,
@@ -375,6 +502,9 @@ export class SessionManager {
375
502
  namedBy: 'user' | 'auto' = 'auto',
376
503
  agent: AgentID = 'claude',
377
504
  parent: SessionID | null = null,
505
+ overrides: SpawnOverrides = {},
506
+ id: SessionID = mintSessionID(),
507
+ target = 'local',
378
508
  ): Session {
379
509
  const adapter = this.findAdapter(agent);
380
510
 
@@ -382,15 +512,21 @@ export class SessionManager {
382
512
  throw new Error(`no adapter for agent '${agent}'`);
383
513
  }
384
514
 
385
- const id = mintSessionID();
386
- const plan = adapter.planSpawn({ prompt, resume });
515
+ const execution = this.requireExecution({ target, targetIdentity: null }, 'spawn');
516
+ const provider = execution.provider;
387
517
 
388
- const pty = spawn(plan.bin, plan.args, {
389
- name: 'xterm-256color',
390
- cols,
391
- rows,
518
+ // The repository root resolves before the process starts: resolving it
519
+ // can throw, and a spawn that throws must leave nothing running.
520
+ const repoRoot = resolveRepoRoot(cwd);
521
+ const plan = adapter.planSpawn({ prompt, resume, ...overrides });
522
+
523
+ const pty = provider.spawnHarness({
524
+ bin: plan.bin,
525
+ args: plan.args,
392
526
  cwd,
393
527
  env: collectCleanEnv({ ATC_SESSION_ID: id, ATC_SOCKET: socketPath }),
528
+ cols,
529
+ rows,
394
530
  });
395
531
 
396
532
  let initialMsg = prompt;
@@ -412,11 +548,15 @@ export class SessionManager {
412
548
  agent,
413
549
  pinned: false,
414
550
  lastAttachedAt: Date.now(),
415
- repoRoot: resolveRepoRoot(cwd),
551
+ repoRoot,
416
552
  namedBy,
417
553
  createdAt: Date.now(),
418
554
  parent,
419
555
  ...(prompt === '' ? {} : { prompt }),
556
+ ...(overrides.model === undefined ? {} : { model: overrides.model }),
557
+ ...(overrides.effort === undefined ? {} : { effort: overrides.effort }),
558
+ target,
559
+ targetIdentity: execution.identity,
420
560
  };
421
561
 
422
562
  pty.onData((d) => {
@@ -439,13 +579,34 @@ export class SessionManager {
439
579
  });
440
580
 
441
581
  this.sessions.push(session);
442
- void this.writeFleet();
582
+ void this.tryWriteFleet(session.id);
443
583
  this.writeStatus();
444
584
  this.onEvent('added', session);
445
585
 
446
586
  return session;
447
587
  }
448
588
 
589
+ // Takes back a spawn that failed after its process started: the process
590
+ // dies and the session leaves the list and the fleet, so the spawn leaves
591
+ // nothing behind. It resolves only once the fleet without the session is
592
+ // durable, and throws when the kill or that write fails. A session that
593
+ // never registered is left alone.
594
+ async removeFailedSpawn(id: SessionID): Promise<void> {
595
+ const s = this.sessions.find((x) => x.id === id);
596
+
597
+ if (s === undefined) {
598
+ return;
599
+ }
600
+
601
+ s.pty?.kill();
602
+ s.pty = null;
603
+
604
+ this.remove(s);
605
+ this.emitChange();
606
+
607
+ await this.writeFleet();
608
+ }
609
+
449
610
  collectDescriptors(): SessionDescriptor[] {
450
611
  return this.sessions.map((s) => ({
451
612
  id: s.id,
@@ -466,6 +627,7 @@ export class SessionManager {
466
627
  alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
467
628
  canEject: (this.findAdapter(s.agent)?.headlessRunner ?? null) !== null,
468
629
  ...(s.parent === null ? {} : { parent: s.parent }),
630
+ locator: { daemonID: this.store.daemonID, targetID: s.target },
469
631
  }));
470
632
  }
471
633
 
@@ -584,12 +746,12 @@ export class SessionManager {
584
746
  }
585
747
 
586
748
  // A fleet rewrite marks every session without a PTY as exited, so only a
587
- // new agent session id (which adds or re-keys a row) takes it. Anything
588
- // else touches this session's own row.
749
+ // new agent session id takes it. Anything else touches this session's
750
+ // own row.
589
751
  if (persist) {
590
- void this.writeFleet();
591
- } else if (s.agentSessionID !== undefined) {
592
- void this.store.updateFleetEntry(s.agentSessionID, rowUpdate);
752
+ void this.tryWriteFleet(s.id);
753
+ } else {
754
+ void this.tryUpdateFleetEntry(s.id, rowUpdate);
593
755
  }
594
756
 
595
757
  if (dirty) {
@@ -619,7 +781,7 @@ export class SessionManager {
619
781
  s.namedBy = update.namedBy;
620
782
  }
621
783
 
622
- void this.writeFleet();
784
+ void this.tryWriteFleet(s.id);
623
785
  this.onEvent('renamed', s);
624
786
  this.emitChange();
625
787
  }
@@ -644,7 +806,7 @@ export class SessionManager {
644
806
 
645
807
  s.unread = false;
646
808
  s.lastAttachedAt = Date.now();
647
- void this.writeFleet();
809
+ void this.tryWriteFleet(s.id);
648
810
 
649
811
  // Attaching answers the attention request: a still-pending prompt
650
812
  // re-flags it via the next notification.
@@ -759,42 +921,76 @@ export class SessionManager {
759
921
  // Deliberate kills rewrite the file; unexpected session/atc deaths do not,
760
922
  // so the last known fleet survives for `R` restore. Sessions whose
761
923
  // terminal is gone persist as exited entries, so the killed archive
762
- // survives a daemon restart too.
924
+ // survives a daemon restart too. A session the agent has not yet given
925
+ // its own session id persists as well, under its atc session id alone.
763
926
  async writeFleet(): Promise<void> {
764
927
  const fleet: FleetEntry[] = [];
765
928
 
766
929
  for (const s of this.sessions) {
767
- if (s.agentSessionID === undefined) {
768
- continue;
769
- }
770
-
771
930
  const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
772
931
 
773
- // The link persists by the parent's agent session id, since atc ids
774
- // are minted afresh on restore.
775
- const parent =
776
- s.parent === null
777
- ? undefined
778
- : this.sessions.find((x) => x.id === s.parent)?.agentSessionID;
779
-
780
932
  fleet.push({
933
+ sessionID: s.id,
781
934
  name: s.name,
782
935
  cwd: s.cwd,
783
- agentSessionID: s.agentSessionID,
936
+ ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
784
937
  agent: s.agent,
785
938
  ...(s.pinned ? { pinned: true } : {}),
786
939
  lastAttachedAt: s.lastAttachedAt,
787
940
  ...(live ? {} : { exited: true }),
788
- ...(parent === undefined ? {} : { parent }),
941
+ ...(s.parent === null ? {} : { parent: s.parent }),
789
942
  ...(s.prompt === undefined ? {} : { prompt: s.prompt }),
790
943
  ...(s.result === undefined ? {} : { result: s.result }),
791
944
  ...(s.transcriptPath === undefined ? {} : { transcriptPath: s.transcriptPath }),
945
+ ...(s.model === undefined ? {} : { model: s.model }),
946
+ ...(s.effort === undefined ? {} : { effort: s.effort }),
947
+ target: s.target,
948
+ targetIdentity: s.targetIdentity,
792
949
  });
793
950
  }
794
951
 
795
952
  await this.store.writeFleet(fleet);
796
953
  }
797
954
 
955
+ // A write nobody awaits that fails leaves the stored rows as they were,
956
+ // and must not take the daemon down unhandled. A stale ownership epoch is
957
+ // expected and surfaces on the next write a request awaits; any other
958
+ // failure is logged with the session whose change it was writing.
959
+ private async tryWriteFleet(sessionID: SessionID): Promise<boolean> {
960
+ try {
961
+ await this.writeFleet();
962
+ } catch (error) {
963
+ this.logStoreFailure(sessionID, error);
964
+
965
+ return false;
966
+ }
967
+
968
+ return true;
969
+ }
970
+
971
+ private async tryUpdateFleetEntry(id: SessionID, fields: FleetEntryUpdate): Promise<boolean> {
972
+ try {
973
+ await this.store.updateFleetEntry(id, fields);
974
+ } catch (error) {
975
+ this.logStoreFailure(id, error);
976
+
977
+ return false;
978
+ }
979
+
980
+ return true;
981
+ }
982
+
983
+ private logStoreFailure(sessionID: SessionID, error: unknown): void {
984
+ if (error instanceof DaemonError && error.code === 'stale_epoch') {
985
+ return;
986
+ }
987
+
988
+ const code = error instanceof DaemonError ? error.code : 'internal';
989
+ const message = error instanceof Error ? error.message : String(error);
990
+
991
+ this.log(`atc fleet write for session ${sessionID} failed (${code}): ${message}`);
992
+ }
993
+
798
994
  countStates() {
799
995
  return countSessionStates(this.sessions);
800
996
  }
@@ -892,3 +1088,24 @@ export function sortGroupedSessionViews<
892
1088
 
893
1089
  return [...buckets.values()].flat();
894
1090
  }
1091
+
1092
+ // A target refusal as a session's last message, short enough for a list row.
1093
+ function formatTargetRefusal(code: ErrorCode, target: string): string {
1094
+ if (code === 'target_config_invalid') {
1095
+ return `target '${target}' misconfigured`;
1096
+ }
1097
+
1098
+ if (code === 'unknown_target') {
1099
+ return `no target '${target}'`;
1100
+ }
1101
+
1102
+ if (code === 'target_changed') {
1103
+ return `target '${target}' changed`;
1104
+ }
1105
+
1106
+ if (code === 'target_unavailable') {
1107
+ return `target '${target}' unavailable`;
1108
+ }
1109
+
1110
+ return `target '${target}' cannot start a terminal`;
1111
+ }
@@ -16,6 +16,9 @@ const PERMISSION_MODES = [
16
16
  'dontAsk',
17
17
  ] as const;
18
18
 
19
+ // The effort levels the Agent SDK accepts.
20
+ const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
21
+
19
22
  interface HeadlessRunOptions {
20
23
  readonly claudeBin: string;
21
24
  readonly cwd: string;
@@ -25,6 +28,8 @@ interface HeadlessRunOptions {
25
28
  readonly settings?: string;
26
29
  readonly sessionID?: SessionID;
27
30
  readonly pluginDir?: string;
31
+ readonly model?: string;
32
+ readonly effort?: string;
28
33
  }
29
34
 
30
35
  interface HeadlessRunHooks {
@@ -52,6 +57,7 @@ export function startHeadlessRun(
52
57
  void (async () => {
53
58
  try {
54
59
  const mode = PERMISSION_MODES.find((m) => m === opts.permissionMode);
60
+ const effort = EFFORT_LEVELS.find((level) => level === opts.effort);
55
61
  let stderrTail = '';
56
62
 
57
63
  // The SDK is the heaviest module in the daemon's graph, and only a
@@ -75,6 +81,11 @@ export function startHeadlessRun(
75
81
  ...(opts.resume === undefined ? {} : { resume: opts.resume }),
76
82
  ...(mode === undefined ? {} : { permissionMode: mode }),
77
83
 
84
+ // The session's own model and effort, so the turn runs as its
85
+ // terminal did.
86
+ ...(opts.model === undefined ? {} : { model: opts.model }),
87
+ ...(effort === undefined ? {} : { effort }),
88
+
78
89
  // The same generated file the terminal spawn passes, so the turn
79
90
  // runs against the session's own backend and instrumentation.
80
91
  ...(opts.settings === undefined ? {} : { extraArgs: { settings: opts.settings } }),
@@ -10,8 +10,9 @@ import { truncateSummary } from './truncate-summary';
10
10
  * runner, wires the run's output and completion into the session manager,
11
11
  * passes the turn's start and end to the event trail the way a terminal
12
12
  * turn's hooks do, and records the live handle on the session's runtime. Returns false
13
- * without starting anything when the session is unknown, its agent has no
14
- * headless runner, or a turn is already running.
13
+ * without starting anything when the session is unknown or exited, its
14
+ * agent has no headless runner, its target does not serve headless turns,
15
+ * or a turn is already running.
15
16
  */
16
17
  export function startHeadlessTurn(
17
18
  mgr: SessionManager,
@@ -28,6 +29,12 @@ export function startHeadlessTurn(
28
29
  return false;
29
30
  }
30
31
 
32
+ // The runner runs on the daemon's own host, so a session whose target
33
+ // does not serve headless turns never reaches it.
34
+ if (s.state === 'exited' || mgr.findExecutionRefusal(s, 'headless') !== null) {
35
+ return false;
36
+ }
37
+
31
38
  mgr.updateSurfaceState(sessionID, 'running', 'headless turn running');
32
39
 
33
40
  recordTurnEvent(sessionID, { kind: 'prompt-submitted', detail: truncateDetail(prompt) });
@@ -39,6 +46,8 @@ export function startHeadlessTurn(
39
46
  ...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
40
47
  permissionMode: 'auto',
41
48
  sessionID,
49
+ ...(s.model === undefined ? {} : { model: s.model }),
50
+ ...(s.effort === undefined ? {} : { effort: s.effort }),
42
51
  },
43
52
  {
44
53
  onOutput: (text) => {