@zgeoff/atc 2.10.4 → 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 (86) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/agents/agent-adapter.ts +72 -0
  4. package/src/agents/atc-bridge-files.ts +1 -1
  5. package/src/agents/build-args-without-flags.ts +25 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/claude-adapter.ts +52 -1
  8. package/src/agents/claude-effort-levels.ts +5 -0
  9. package/src/agents/codex-adapter.ts +46 -1
  10. package/src/agents/find-flag-value.ts +27 -0
  11. package/src/agents/gateway-adapter.ts +75 -1
  12. package/src/agents/grok-adapter.ts +22 -0
  13. package/src/cli.ts +22 -0
  14. package/src/client/boot-daemon.ts +6 -0
  15. package/src/client/daemon-client.ts +14 -2
  16. package/src/client/spawn-picker.ts +27 -4
  17. package/src/daemon/build-agent-list.ts +94 -0
  18. package/src/daemon/build-config-revision.ts +27 -0
  19. package/src/daemon/build-execution-targets.ts +34 -0
  20. package/src/daemon/build-fleet-events.ts +2 -1
  21. package/src/daemon/build-payload-hash.ts +31 -0
  22. package/src/daemon/build-scoped-context.ts +232 -0
  23. package/src/daemon/build-target-access.ts +33 -0
  24. package/src/daemon/build-target-forbidden-error.ts +13 -0
  25. package/src/daemon/build-target-identity.ts +22 -0
  26. package/src/daemon/build-target-list.ts +50 -0
  27. package/src/daemon/daemon-connection.ts +371 -99
  28. package/src/daemon/daemon.ts +537 -90
  29. package/src/daemon/effect-remains-error.ts +13 -0
  30. package/src/daemon/execution-provider.ts +103 -0
  31. package/src/daemon/find-execution-refusal.ts +104 -0
  32. package/src/daemon/idempotency-ledger.ts +164 -0
  33. package/src/daemon/local-pty-provider.ts +83 -0
  34. package/src/daemon/mint-session-id.ts +5 -5
  35. package/src/daemon/parse-report.ts +29 -3
  36. package/src/daemon/parse-spawn-overrides.ts +94 -0
  37. package/src/daemon/permission-registry.ts +14 -4
  38. package/src/daemon/restore-fleet.ts +66 -19
  39. package/src/daemon/session-runtime.ts +10 -0
  40. package/src/daemon/sessions.ts +287 -65
  41. package/src/daemon/start-headless-run.ts +11 -0
  42. package/src/daemon/start-headless-turn.ts +11 -2
  43. package/src/daemon/target-access.ts +36 -0
  44. package/src/mcp/answer-mcp-request.ts +12 -4
  45. package/src/mcp/answer-rpc-request.ts +40 -6
  46. package/src/mcp/build-principal-caller.ts +15 -0
  47. package/src/mcp/build-spawn-descriptions.ts +45 -0
  48. package/src/mcp/build-tool-list.ts +88 -7
  49. package/src/mcp/mcp-tools.ts +245 -24
  50. package/src/mcp/parse-idempotency-key.ts +29 -0
  51. package/src/mcp/reconnecting-caller.ts +77 -15
  52. package/src/mcp/require-daemon-features.ts +40 -0
  53. package/src/mcp/run-tool.ts +139 -35
  54. package/src/mcp/start-mcp-http-server.ts +130 -24
  55. package/src/mcp/types.ts +9 -1
  56. package/src/mcp-http-server.ts +6 -1
  57. package/src/mcp-server.ts +13 -1
  58. package/src/protocol/daemon-error.ts +5 -1
  59. package/src/protocol/daemon-features.ts +47 -0
  60. package/src/protocol/parse-daemon-features.ts +19 -0
  61. package/src/protocol/protocol.ts +44 -11
  62. package/src/protocol/request-param-schemas.ts +78 -12
  63. package/src/report.ts +20 -3
  64. package/src/shared/agent-session-id.ts +1 -1
  65. package/src/shared/collect-principals.ts +51 -0
  66. package/src/shared/collect-targets.ts +144 -0
  67. package/src/shared/config.ts +139 -14
  68. package/src/shared/daemon-id.ts +8 -0
  69. package/src/shared/format-json-kind.ts +21 -0
  70. package/src/shared/sort-json-keys.ts +19 -0
  71. package/src/shared/to-daemon-id.ts +11 -0
  72. package/src/store/fleet-entry.ts +42 -9
  73. package/src/store/idempotency-record.ts +48 -0
  74. package/src/store/message-owner.ts +1 -1
  75. package/src/store/message-record.ts +3 -0
  76. package/src/store/run-migrations.ts +272 -7
  77. package/src/store/state-store.ts +503 -34
  78. package/src/workspace/check-workspace-completeness.ts +90 -0
  79. package/src/workspace/create-workspace-clone.ts +239 -0
  80. package/src/workspace/normalize-git-url.ts +59 -0
  81. package/src/workspace/read-workspace-tar.ts +38 -0
  82. package/src/workspace/resolve-path-source.ts +170 -0
  83. package/src/workspace/run-git.ts +103 -0
  84. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  85. package/src/workspace/workspace-provenance.ts +11 -0
  86. 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
  /**
@@ -134,6 +240,11 @@ export class SessionManager {
134
240
  return this.adapters[id] ?? null;
135
241
  }
136
242
 
243
+ // Every registered adapter, one per id, in registration order.
244
+ collectAdapters(): AgentAdapter[] {
245
+ return Object.values(this.adapters);
246
+ }
247
+
137
248
  // Hands a live terminal session off to a headless run: the terminal dies,
138
249
  // the record lives on as a headless session and keeps its screen history.
139
250
  yankHeadless(id: SessionID): Session | null {
@@ -157,26 +268,42 @@ export class SessionManager {
157
268
  return s;
158
269
  }
159
270
 
160
- // Registers a fleet entry as a session with no terminal yet, so a
161
- // fleet-wide restore can show every incoming session at once; adopting it
162
- // later attaches the terminal. Exited entries come back as killed
163
- // 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.
164
277
  restore(entry: FleetEntry): Session {
165
- 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;
166
287
 
167
288
  // An entry whose agent is no longer registered still gets its row, so a
168
289
  // backend dropped from the config shows as itself instead of vanishing or
169
290
  // reviving under another agent. Adopting a terminal for it is refused.
170
291
  let lastMsg = 'waiting to restore';
171
292
 
172
- 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) {
173
298
  lastMsg = 'killed';
174
299
  } else if (this.findAdapter(entry.agent) === null) {
175
300
  lastMsg = `no adapter for '${entry.agent}'`;
176
301
  }
177
302
 
303
+ const parent = entry.parent ?? null;
304
+
178
305
  const session: Session = {
179
- id: mintSessionID(),
306
+ id: entry.sessionID,
180
307
  name: entry.name,
181
308
  cwd: entry.cwd,
182
309
  kind: 'headless',
@@ -184,17 +311,21 @@ export class SessionManager {
184
311
  state: exited ? 'exited' : 'running',
185
312
  unread: false,
186
313
  lastMsg,
187
- agentSessionID: entry.agentSessionID,
314
+ ...(entry.agentSessionID === undefined ? {} : { agentSessionID: entry.agentSessionID }),
188
315
  agent: entry.agent,
189
316
  pinned: entry.pinned ?? false,
190
317
  lastAttachedAt: entry.lastAttachedAt ?? Date.now(),
191
318
  repoRoot: resolveRepoRoot(entry.cwd),
192
319
  namedBy: 'auto',
193
320
  createdAt: Date.now(),
194
- parent: this.findByAgentSessionID(entry.parent)?.id ?? null,
321
+ parent: parent !== null && this.sessions.some((s) => s.id === parent) ? parent : null,
195
322
  ...(entry.prompt === undefined ? {} : { prompt: entry.prompt }),
196
323
  ...(entry.result === undefined ? {} : { result: entry.result }),
197
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,
198
329
  };
199
330
 
200
331
  this.sessions.push(session);
@@ -204,14 +335,6 @@ export class SessionManager {
204
335
  return session;
205
336
  }
206
337
 
207
- private findByAgentSessionID(agentSessionID: AgentSessionID | undefined): Session | undefined {
208
- if (agentSessionID === undefined) {
209
- return undefined;
210
- }
211
-
212
- return this.sessions.find((s) => s.agentSessionID === agentSessionID);
213
- }
214
-
215
338
  // Adopts a headless session back into a terminal: a fresh PTY resumes the
216
339
  // same agent session id.
217
340
  adoptTerminal(id: SessionID, cols: number, rows: number): Session | null {
@@ -227,14 +350,22 @@ export class SessionManager {
227
350
  return null;
228
351
  }
229
352
 
230
- const plan = adapter.planSpawn({ prompt: '', resume: s.agentSessionID });
353
+ const provider = this.requireExecution(s, 'spawn').provider;
231
354
 
232
- const pty = spawn(plan.bin, plan.args, {
233
- name: 'xterm-256color',
234
- cols,
235
- 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,
236
365
  cwd: s.cwd,
237
366
  env: collectCleanEnv({ ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath }),
367
+ cols,
368
+ rows,
238
369
  });
239
370
 
240
371
  s.pty = pty;
@@ -259,7 +390,7 @@ export class SessionManager {
259
390
  this.emitChange();
260
391
  });
261
392
 
262
- void this.writeFleet();
393
+ void this.tryWriteFleet(s.id);
263
394
  this.onEvent('state', s);
264
395
  this.emitChange();
265
396
 
@@ -296,7 +427,7 @@ export class SessionManager {
296
427
  this.onEvent('state', s);
297
428
  }
298
429
 
299
- void this.writeFleet();
430
+ void this.tryWriteFleet(s.id);
300
431
  this.writeStatus();
301
432
  this.emitChange();
302
433
 
@@ -319,10 +450,7 @@ export class SessionManager {
319
450
  if (result !== undefined) {
320
451
  s.result = truncateToBytes(result, 16_384);
321
452
  s.lastDetail = truncateDetail(result);
322
-
323
- if (s.agentSessionID !== undefined) {
324
- void this.store.updateFleetEntry(s.agentSessionID, { result: s.result });
325
- }
453
+ void this.tryUpdateFleetEntry(s.id, { result: s.result });
326
454
  }
327
455
 
328
456
  this.onEvent('state', s);
@@ -359,7 +487,11 @@ export class SessionManager {
359
487
 
360
488
  // resume: true opens the agent's own session picker; an agent session id
361
489
  // resumes that specific session (fleet restore). parent makes the new
362
- // 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.
363
495
  spawn(
364
496
  cwd: string,
365
497
  name: string,
@@ -370,6 +502,9 @@ export class SessionManager {
370
502
  namedBy: 'user' | 'auto' = 'auto',
371
503
  agent: AgentID = 'claude',
372
504
  parent: SessionID | null = null,
505
+ overrides: SpawnOverrides = {},
506
+ id: SessionID = mintSessionID(),
507
+ target = 'local',
373
508
  ): Session {
374
509
  const adapter = this.findAdapter(agent);
375
510
 
@@ -377,15 +512,21 @@ export class SessionManager {
377
512
  throw new Error(`no adapter for agent '${agent}'`);
378
513
  }
379
514
 
380
- const id = mintSessionID();
381
- const plan = adapter.planSpawn({ prompt, resume });
515
+ const execution = this.requireExecution({ target, targetIdentity: null }, 'spawn');
516
+ const provider = execution.provider;
382
517
 
383
- const pty = spawn(plan.bin, plan.args, {
384
- name: 'xterm-256color',
385
- cols,
386
- 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,
387
526
  cwd,
388
527
  env: collectCleanEnv({ ATC_SESSION_ID: id, ATC_SOCKET: socketPath }),
528
+ cols,
529
+ rows,
389
530
  });
390
531
 
391
532
  let initialMsg = prompt;
@@ -407,11 +548,15 @@ export class SessionManager {
407
548
  agent,
408
549
  pinned: false,
409
550
  lastAttachedAt: Date.now(),
410
- repoRoot: resolveRepoRoot(cwd),
551
+ repoRoot,
411
552
  namedBy,
412
553
  createdAt: Date.now(),
413
554
  parent,
414
555
  ...(prompt === '' ? {} : { prompt }),
556
+ ...(overrides.model === undefined ? {} : { model: overrides.model }),
557
+ ...(overrides.effort === undefined ? {} : { effort: overrides.effort }),
558
+ target,
559
+ targetIdentity: execution.identity,
415
560
  };
416
561
 
417
562
  pty.onData((d) => {
@@ -434,13 +579,34 @@ export class SessionManager {
434
579
  });
435
580
 
436
581
  this.sessions.push(session);
437
- void this.writeFleet();
582
+ void this.tryWriteFleet(session.id);
438
583
  this.writeStatus();
439
584
  this.onEvent('added', session);
440
585
 
441
586
  return session;
442
587
  }
443
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
+
444
610
  collectDescriptors(): SessionDescriptor[] {
445
611
  return this.sessions.map((s) => ({
446
612
  id: s.id,
@@ -461,6 +627,7 @@ export class SessionManager {
461
627
  alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
462
628
  canEject: (this.findAdapter(s.agent)?.headlessRunner ?? null) !== null,
463
629
  ...(s.parent === null ? {} : { parent: s.parent }),
630
+ locator: { daemonID: this.store.daemonID, targetID: s.target },
464
631
  }));
465
632
  }
466
633
 
@@ -579,12 +746,12 @@ export class SessionManager {
579
746
  }
580
747
 
581
748
  // A fleet rewrite marks every session without a PTY as exited, so only a
582
- // new agent session id (which adds or re-keys a row) takes it. Anything
583
- // else touches this session's own row.
749
+ // new agent session id takes it. Anything else touches this session's
750
+ // own row.
584
751
  if (persist) {
585
- void this.writeFleet();
586
- } else if (s.agentSessionID !== undefined) {
587
- void this.store.updateFleetEntry(s.agentSessionID, rowUpdate);
752
+ void this.tryWriteFleet(s.id);
753
+ } else {
754
+ void this.tryUpdateFleetEntry(s.id, rowUpdate);
588
755
  }
589
756
 
590
757
  if (dirty) {
@@ -614,7 +781,7 @@ export class SessionManager {
614
781
  s.namedBy = update.namedBy;
615
782
  }
616
783
 
617
- void this.writeFleet();
784
+ void this.tryWriteFleet(s.id);
618
785
  this.onEvent('renamed', s);
619
786
  this.emitChange();
620
787
  }
@@ -639,7 +806,7 @@ export class SessionManager {
639
806
 
640
807
  s.unread = false;
641
808
  s.lastAttachedAt = Date.now();
642
- void this.writeFleet();
809
+ void this.tryWriteFleet(s.id);
643
810
 
644
811
  // Attaching answers the attention request: a still-pending prompt
645
812
  // re-flags it via the next notification.
@@ -754,42 +921,76 @@ export class SessionManager {
754
921
  // Deliberate kills rewrite the file; unexpected session/atc deaths do not,
755
922
  // so the last known fleet survives for `R` restore. Sessions whose
756
923
  // terminal is gone persist as exited entries, so the killed archive
757
- // 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.
758
926
  async writeFleet(): Promise<void> {
759
927
  const fleet: FleetEntry[] = [];
760
928
 
761
929
  for (const s of this.sessions) {
762
- if (s.agentSessionID === undefined) {
763
- continue;
764
- }
765
-
766
930
  const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
767
931
 
768
- // The link persists by the parent's agent session id, since atc ids
769
- // are minted afresh on restore.
770
- const parent =
771
- s.parent === null
772
- ? undefined
773
- : this.sessions.find((x) => x.id === s.parent)?.agentSessionID;
774
-
775
932
  fleet.push({
933
+ sessionID: s.id,
776
934
  name: s.name,
777
935
  cwd: s.cwd,
778
- agentSessionID: s.agentSessionID,
936
+ ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
779
937
  agent: s.agent,
780
938
  ...(s.pinned ? { pinned: true } : {}),
781
939
  lastAttachedAt: s.lastAttachedAt,
782
940
  ...(live ? {} : { exited: true }),
783
- ...(parent === undefined ? {} : { parent }),
941
+ ...(s.parent === null ? {} : { parent: s.parent }),
784
942
  ...(s.prompt === undefined ? {} : { prompt: s.prompt }),
785
943
  ...(s.result === undefined ? {} : { result: s.result }),
786
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,
787
949
  });
788
950
  }
789
951
 
790
952
  await this.store.writeFleet(fleet);
791
953
  }
792
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
+
793
994
  countStates() {
794
995
  return countSessionStates(this.sessions);
795
996
  }
@@ -887,3 +1088,24 @@ export function sortGroupedSessionViews<
887
1088
 
888
1089
  return [...buckets.values()].flat();
889
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) => {