@zgeoff/atc 2.11.0 → 2.13.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 (119) hide show
  1. package/README.md +10 -10
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +98 -10
  4. package/src/agents/build-args-without-flags.ts +25 -0
  5. package/src/agents/build-atc-bridge-files.ts +13 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/build-claude-query-options.ts +72 -0
  8. package/src/agents/build-cli-command.ts +7 -5
  9. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  10. package/src/agents/build-hook-settings.ts +8 -2
  11. package/src/agents/build-restore-mode-args.ts +23 -0
  12. package/src/agents/claude-adapter.ts +112 -18
  13. package/src/agents/claude-effort-levels.ts +5 -0
  14. package/src/agents/codex-adapter.ts +48 -2
  15. package/src/agents/find-claude-permission-mode.ts +24 -0
  16. package/src/agents/find-flag-value.ts +27 -0
  17. package/src/agents/gateway-adapter.ts +83 -13
  18. package/src/agents/grok-adapter.ts +24 -1
  19. package/src/agents/make-claude-headless-runner.ts +46 -0
  20. package/src/agents/plan-pasted-line-input.ts +22 -0
  21. package/src/agents/plan-typed-line-input.ts +8 -0
  22. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  23. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
  24. package/src/agents/write-atc-bridge.ts +2 -6
  25. package/src/cli.ts +23 -9
  26. package/src/client/daemon-client.ts +14 -2
  27. package/src/client/index.ts +50 -1
  28. package/src/client/spawn-picker.ts +27 -4
  29. package/src/client/ui.ts +8 -0
  30. package/src/daemon/build-agent-list.ts +37 -1
  31. package/src/daemon/build-config-revision.ts +27 -0
  32. package/src/daemon/build-execution-targets.ts +73 -0
  33. package/src/daemon/build-fleet-events.ts +2 -1
  34. package/src/daemon/build-imp-provider.ts +68 -0
  35. package/src/daemon/build-payload-hash.ts +31 -0
  36. package/src/daemon/build-report-trail-entry.ts +4 -2
  37. package/src/daemon/build-scoped-context.ts +235 -0
  38. package/src/daemon/build-session-lifecycle.ts +52 -0
  39. package/src/daemon/build-tar-archive.ts +85 -0
  40. package/src/daemon/build-target-access.ts +33 -0
  41. package/src/daemon/build-target-forbidden-error.ts +13 -0
  42. package/src/daemon/build-target-identity.ts +22 -0
  43. package/src/daemon/build-target-list.ts +50 -0
  44. package/src/daemon/daemon-connection.ts +425 -96
  45. package/src/daemon/daemon.ts +730 -96
  46. package/src/daemon/effect-remains-error.ts +13 -0
  47. package/src/daemon/execution-provider.ts +208 -0
  48. package/src/daemon/find-execution-refusal.ts +104 -0
  49. package/src/daemon/hooks.ts +5 -17
  50. package/src/daemon/idempotency-ledger.ts +164 -0
  51. package/src/daemon/imp-client-port.ts +343 -0
  52. package/src/daemon/imp-harness.ts +618 -0
  53. package/src/daemon/imp-port-error.ts +18 -0
  54. package/src/daemon/imp-port.ts +246 -0
  55. package/src/daemon/imp-provider.ts +601 -0
  56. package/src/daemon/is-binding-current.ts +43 -0
  57. package/src/daemon/local-pty-provider.ts +142 -0
  58. package/src/daemon/materialize-workspace.ts +571 -0
  59. package/src/daemon/mint-session-id.ts +5 -5
  60. package/src/daemon/parse-hook-line.ts +31 -0
  61. package/src/daemon/parse-spawn-overrides.ts +94 -0
  62. package/src/daemon/permission-registry.ts +14 -4
  63. package/src/daemon/pick-session-state.ts +15 -0
  64. package/src/daemon/restore-fleet.ts +103 -38
  65. package/src/daemon/screen-model.ts +7 -0
  66. package/src/daemon/session-runtime.ts +10 -0
  67. package/src/daemon/sessions.ts +835 -105
  68. package/src/daemon/start-headless-turn.ts +12 -3
  69. package/src/daemon/start-session-bridge.ts +294 -0
  70. package/src/daemon/target-access.ts +36 -0
  71. package/src/mcp/answer-mcp-request.ts +12 -4
  72. package/src/mcp/answer-rpc-request.ts +28 -1
  73. package/src/mcp/build-principal-caller.ts +15 -0
  74. package/src/mcp/build-spawn-descriptions.ts +45 -0
  75. package/src/mcp/build-tool-list.ts +32 -4
  76. package/src/mcp/mcp-tools.ts +107 -10
  77. package/src/mcp/parse-idempotency-key.ts +29 -0
  78. package/src/mcp/reconnecting-caller.ts +39 -7
  79. package/src/mcp/require-daemon-features.ts +10 -0
  80. package/src/mcp/run-tool.ts +87 -16
  81. package/src/mcp/types.ts +2 -0
  82. package/src/protocol/daemon-error.ts +5 -1
  83. package/src/protocol/daemon-features.ts +35 -0
  84. package/src/protocol/protocol.ts +67 -11
  85. package/src/protocol/request-param-schemas.ts +126 -10
  86. package/src/report.ts +37 -2
  87. package/src/run-bridge-tap.ts +241 -0
  88. package/src/shared/agent-session-id.ts +1 -1
  89. package/src/shared/collect-clean-env.ts +9 -1
  90. package/src/shared/collect-principals.ts +51 -0
  91. package/src/shared/collect-targets.ts +144 -0
  92. package/src/shared/config.ts +139 -14
  93. package/src/shared/daemon-id.ts +8 -0
  94. package/src/shared/format-json-kind.ts +21 -0
  95. package/src/shared/open-bridge-socket.ts +86 -0
  96. package/src/shared/send-bridge-request.ts +47 -0
  97. package/src/shared/sort-json-keys.ts +19 -0
  98. package/src/shared/to-daemon-id.ts +11 -0
  99. package/src/statusline.ts +34 -3
  100. package/src/store/fleet-entry.ts +61 -9
  101. package/src/store/idempotency-record.ts +48 -0
  102. package/src/store/message-owner.ts +1 -1
  103. package/src/store/run-migrations.ts +330 -5
  104. package/src/store/state-store.ts +607 -44
  105. package/src/store/trail-entry.ts +4 -0
  106. package/src/store/workspace-materialization.ts +70 -0
  107. package/src/tap.ts +11 -0
  108. package/src/workspace/check-url-credentials.ts +59 -0
  109. package/src/workspace/check-workspace-completeness.ts +90 -0
  110. package/src/workspace/create-workspace-clone.ts +251 -0
  111. package/src/workspace/normalize-git-url.ts +59 -0
  112. package/src/workspace/read-workspace-tar.ts +38 -0
  113. package/src/workspace/repository-env-vars.ts +22 -0
  114. package/src/workspace/resolve-path-source.ts +170 -0
  115. package/src/workspace/run-git.ts +87 -0
  116. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  117. package/src/workspace/workspace-provenance.ts +11 -0
  118. package/src/workspace/workspace-source.ts +8 -0
  119. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -1,24 +1,48 @@
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 {
3
+ AdapterEvent,
4
+ AgentAdapter,
5
+ AgentID,
6
+ SpawnOptions,
7
+ SpawnOverrides,
8
+ SpawnPlan,
9
+ } from '../agents/agent-adapter';
5
10
  import { truncateDetail } from '../agents/truncate-detail';
11
+ import { DaemonError } from '../protocol/daemon-error';
12
+ import type { ErrorCode } from '../protocol/protocol';
6
13
  import type { AgentSessionID } from '../shared/agent-session-id';
7
- import { collectCleanEnv } from '../shared/collect-clean-env';
14
+ import type { TargetConfigError } from '../shared/collect-targets';
8
15
  import { socketPath, statusFile } from '../shared/config';
16
+ import type { DaemonID } from '../shared/daemon-id';
9
17
  import { resolveRepoRoot } from '../shared/resolve-repo-root';
10
18
  import type { SessionID } from '../shared/session-id';
11
19
  import { truncateToBytes } from '../shared/truncate-to-bytes';
12
- import type { FleetEntry, FleetStore } from '../store/fleet-entry';
20
+ import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
21
+ import type { SessionWorkspace } from '../store/workspace-materialization';
22
+ import type { ExecutionTarget } from './build-execution-targets';
23
+ import { buildSessionLifecycle } from './build-session-lifecycle';
24
+ import type { SessionLifecycle } from './build-session-lifecycle';
25
+ import { buildTarArchive } from './build-tar-archive';
26
+ import { buildTargetIdentity } from './build-target-identity';
27
+ import type {
28
+ ExecutionCapability,
29
+ ExecutionProvider,
30
+ HarnessHandle,
31
+ HarnessRelay,
32
+ } from './execution-provider';
33
+ import { findExecutionRefusal } from './find-execution-refusal';
13
34
  import type { HookEvent } from './hooks';
35
+ import type { BridgeBinding } from './is-binding-current';
36
+ import { LocalPTYProvider } from './local-pty-provider';
14
37
  import { mintSessionID } from './mint-session-id';
38
+ import { pickSessionState } from './pick-session-state';
15
39
 
16
40
  export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
17
41
 
18
42
  export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
19
43
 
20
- // The over-the-wire view of a session: everything but the PTY handle, plus
21
- // the surface kind.
44
+ // The over-the-wire view of a session: everything but the harness handle,
45
+ // plus the surface kind.
22
46
  export interface SessionDescriptor {
23
47
  readonly id: SessionID;
24
48
  readonly name: string;
@@ -44,6 +68,23 @@ export interface SessionDescriptor {
44
68
  // The session this one is a sub-session of, when it has one: it lists
45
69
  // under that session and takes its pin from it.
46
70
  readonly parent?: SessionID;
71
+
72
+ // Where the session runs: the daemon hosting it, and the execution target
73
+ // on that daemon's host.
74
+ readonly locator: SessionLocator;
75
+
76
+ // What the session's working directory was materialized from, for a
77
+ // session spawned with a workspace source.
78
+ readonly workspace?: SessionWorkspace;
79
+
80
+ // What the operator asked for, the host, the harness, and the daemon's
81
+ // connection to it; the state derives from these and the attention.
82
+ readonly lifecycle: SessionLifecycle;
83
+ }
84
+
85
+ interface SessionLocator {
86
+ readonly daemonID: DaemonID;
87
+ readonly targetID: string;
47
88
  }
48
89
 
49
90
  export interface Session {
@@ -51,7 +92,10 @@ export interface Session {
51
92
  name: string;
52
93
  cwd: string;
53
94
  kind: 'pty' | 'headless';
54
- pty: IPty | null;
95
+
96
+ // The running harness its execution provider handed back, or null when
97
+ // the session has no terminal.
98
+ pty: HarnessHandle | null;
55
99
  state: SessionState;
56
100
  unread: boolean;
57
101
  lastMsg: string;
@@ -87,6 +131,61 @@ export interface Session {
87
131
  // the transcript file the agent's hooks last reported. Kept apart from the
88
132
  // resume check so a restored path never changes whether a revive is allowed.
89
133
  transcriptPath?: string;
134
+
135
+ // the model and effort the session was spawned with; absent leaves the
136
+ // agent's configured default, and every revive passes them again.
137
+ model?: string;
138
+ effort?: string;
139
+
140
+ // the execution target the session runs on, and the identity it had
141
+ // when the session started there; every revive runs there too, and only
142
+ // while the target keeps that identity.
143
+ target: string;
144
+ targetIdentity: string;
145
+
146
+ // what the working directory was materialized from, when the spawn
147
+ // carried a workspace source
148
+ workspace?: SessionWorkspace;
149
+
150
+ // the environment variable names every harness the session starts goes
151
+ // without: the materialization's credential and askpass context
152
+ withheldEnv: readonly string[];
153
+
154
+ // What the operator asked of the harness, the last state seen of its
155
+ // host, and the daemon's connection to its output.
156
+ desired: SessionLifecycle['desired'];
157
+ vm: SessionLifecycle['vm'];
158
+ attachment: SessionLifecycle['attachment'];
159
+
160
+ // Whether the harness is kept inside a sleeping host, for a revive to
161
+ // find as it was.
162
+ suspended: boolean;
163
+
164
+ // The session whose host the harness runs on: its own id, or its
165
+ // parent's when the parent runs on the same target, so one host serves a
166
+ // top-level session and the sub-sessions beside it.
167
+ hostKey: SessionID;
168
+
169
+ // The epoch of the session's latest harness start or attach, which the
170
+ // daemon's bridge to that harness is bound to; 0 before the first.
171
+ bridgeEpoch: number;
172
+ }
173
+
174
+ // A session's ready workspace and the variables its harnesses go without.
175
+ interface MaterializedSpawn {
176
+ readonly workspace: SessionWorkspace;
177
+ readonly withheldEnv: readonly string[];
178
+ }
179
+
180
+ // The identity of the implicit `local` target, which a fleet row without a
181
+ // stored identity ran on.
182
+ const LOCAL_TARGET_IDENTITY = buildTargetIdentity('local-pty', {});
183
+
184
+ // The target a session runs on and the identity it is bound to there; null
185
+ // binds a new session to the target as it stands now.
186
+ interface TargetBinding {
187
+ readonly target: string;
188
+ readonly targetIdentity: string | null;
90
189
  }
91
190
 
92
191
  export class SessionManager {
@@ -98,8 +197,22 @@ export class SessionManager {
98
197
 
99
198
  onChange: () => void = () => {};
100
199
 
200
+ // Called as a harness starts, before its first output can arrive, with
201
+ // the terminal size it starts at.
202
+ onBoot: (s: Session, cols: number, rows: number) => void = () => {};
203
+
204
+ // Takes each connection a harness's processes open through a provider's
205
+ // own relay, with the binding of the harness start that opened it.
206
+ onRelay: (binding: BridgeBinding, relay: HarnessRelay) => void = () => {};
207
+
101
208
  onEvent: (kind: SessionEventKind, s: Session) => void = () => {};
102
209
 
210
+ // Where a background failure is reported, one line at a time: stderr,
211
+ // like the daemon's other background failures.
212
+ log: (line: string) => void = (line) => {
213
+ console.error(line);
214
+ };
215
+
103
216
  // Whether any registered adapter has a screen detector, decided once at
104
217
  // construction since the registry never changes afterward. Lets a hot path
105
218
  // that only cares about this answer skip walking live sessions to find it.
@@ -111,11 +224,33 @@ export class SessionManager {
111
224
 
112
225
  private readonly statusPath: string;
113
226
 
227
+ private readonly targets: ReadonlyMap<string, ExecutionTarget>;
228
+
229
+ private readonly targetErrors: readonly TargetConfigError[];
230
+
231
+ // Sessions whose revive waits on their host waking, so a second revive
232
+ // of the same session does not start a second harness.
233
+ private readonly adopting = new Set<SessionID>();
234
+
235
+ // The epoch the next harness start or attach takes, unique across every
236
+ // session this manager holds.
237
+ private nextBridgeEpoch = 1;
238
+
114
239
  constructor(
115
240
  fallback: AgentAdapter,
116
241
  store: FleetStore,
117
242
  statusPath: string | undefined = statusFile,
118
243
  adapters: readonly AgentAdapter[] = [],
244
+ targets: readonly ExecutionTarget[] = [
245
+ {
246
+ id: 'local',
247
+ kind: 'local-pty',
248
+ options: {},
249
+ identity: LOCAL_TARGET_IDENTITY,
250
+ provider: new LocalPTYProvider(),
251
+ },
252
+ ],
253
+ targetErrors: readonly TargetConfigError[] = [],
119
254
  ) {
120
255
  // Each adapter names the id it answers to, so a registry key can never
121
256
  // disagree with the adapter behind it. A later one wins the id.
@@ -123,6 +258,51 @@ export class SessionManager {
123
258
  this.hasScreenDetector = Object.values(this.adapters).some((a) => a.screenDetector !== null);
124
259
  this.store = store;
125
260
  this.statusPath = statusPath ?? statusFile;
261
+
262
+ this.targets = new Map(targets.map((t) => [t.id, t]));
263
+
264
+ this.targetErrors = targetErrors;
265
+ }
266
+
267
+ /**
268
+ * The provider a binding's work runs on, after every target check: throws
269
+ * the refusal for a target the config leaves unusable, one that is gone,
270
+ * one whose identity is not the binding's, one with no provider here, and
271
+ * a provider that lacks the capability. Returns the identity a new session
272
+ * binds to as well.
273
+ */
274
+ requireExecution(
275
+ binding: Readonly<TargetBinding>,
276
+ capability: ExecutionCapability,
277
+ ): { readonly provider: ExecutionProvider; readonly identity: string } {
278
+ const refusal = this.findExecutionRefusal(binding, capability);
279
+ const target = this.targets.get(binding.target);
280
+
281
+ if (refusal !== null) {
282
+ throw refusal;
283
+ }
284
+
285
+ if (target === undefined || target.provider === null) {
286
+ throw new Error(`execution target '${binding.target}' passed its checks without a provider`);
287
+ }
288
+
289
+ return { provider: target.provider, identity: target.identity };
290
+ }
291
+
292
+ // The refusal for running work of a capability on a binding's target, or
293
+ // null when the target serves it. A null target is a spawn that names
294
+ // none when the config gives no default, which is always refused.
295
+ findExecutionRefusal(
296
+ binding: Readonly<{ target: string | null; targetIdentity: string | null }>,
297
+ capability: ExecutionCapability,
298
+ ): DaemonError | null {
299
+ return findExecutionRefusal(this.targets, this.targetErrors, binding, capability);
300
+ }
301
+
302
+ // The provider a session's harness runs on, or null when its target is
303
+ // gone from the config or has no provider here.
304
+ findProvider(s: Session): ExecutionProvider | null {
305
+ return this.targets.get(s.target)?.provider ?? null;
126
306
  }
127
307
 
128
308
  /**
@@ -162,26 +342,62 @@ export class SessionManager {
162
342
  return s;
163
343
  }
164
344
 
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.
345
+ // A sibling's revive failing leaves that sibling asleep; the session
346
+ // whose revive woke the host is already running, so the failure is
347
+ // logged rather than thrown.
348
+ private async tryAdoptSibling(
349
+ sibling: Session,
350
+ s: Session,
351
+ cols: number,
352
+ rows: number,
353
+ ): Promise<void> {
354
+ try {
355
+ await this.adoptTerminal(sibling.id, cols, rows);
356
+ } catch (error) {
357
+ this.log(
358
+ `atc could not revive session ${sibling.id} beside ${s.id} (${error instanceof Error ? error.message : String(error)})`,
359
+ );
360
+ }
361
+ }
362
+
363
+ // Registers a fleet entry as a session with no terminal yet, under the
364
+ // atc session id its row holds, so a fleet-wide restore can show every
365
+ // incoming session at once; adopting it later attaches the terminal.
366
+ // Exited entries come back as killed sessions: still listed and revivable,
367
+ // never auto-adopted. An entry without an agent session id has nothing to
368
+ // resume, so it comes back exited too.
169
369
  restore(entry: FleetEntry): Session {
170
- const exited = entry.exited === true;
370
+ const target = entry.target ?? 'local';
371
+ const targetIdentity = entry.targetIdentity ?? LOCAL_TARGET_IDENTITY;
372
+ const refusal = this.findExecutionRefusal({ target, targetIdentity }, 'spawn');
373
+ const targetRefusal = refusal === null ? null : formatTargetRefusal(refusal.code, target);
374
+
375
+ // A session whose target this daemon cannot use comes back exited, so
376
+ // nothing runs it anywhere else: neither a terminal nor a headless turn.
377
+ const exited =
378
+ entry.exited === true || entry.agentSessionID === undefined || targetRefusal !== null;
171
379
 
172
380
  // An entry whose agent is no longer registered still gets its row, so a
173
381
  // backend dropped from the config shows as itself instead of vanishing or
174
382
  // reviving under another agent. Adopting a terminal for it is refused.
175
383
  let lastMsg = 'waiting to restore';
176
384
 
177
- if (exited) {
385
+ if (exited && entry.desired === 'sleep') {
386
+ lastMsg = 'asleep';
387
+ } else if (entry.exited !== true && entry.agentSessionID === undefined) {
388
+ lastMsg = 'nothing to resume';
389
+ } else if (entry.exited !== true && targetRefusal !== null) {
390
+ lastMsg = targetRefusal;
391
+ } else if (exited) {
178
392
  lastMsg = 'killed';
179
393
  } else if (this.findAdapter(entry.agent) === null) {
180
394
  lastMsg = `no adapter for '${entry.agent}'`;
181
395
  }
182
396
 
397
+ const parent = entry.parent ?? null;
398
+
183
399
  const session: Session = {
184
- id: mintSessionID(),
400
+ id: entry.sessionID,
185
401
  name: entry.name,
186
402
  cwd: entry.cwd,
187
403
  kind: 'headless',
@@ -189,17 +405,29 @@ export class SessionManager {
189
405
  state: exited ? 'exited' : 'running',
190
406
  unread: false,
191
407
  lastMsg,
192
- agentSessionID: entry.agentSessionID,
408
+ ...(entry.agentSessionID === undefined ? {} : { agentSessionID: entry.agentSessionID }),
193
409
  agent: entry.agent,
194
410
  pinned: entry.pinned ?? false,
195
411
  lastAttachedAt: entry.lastAttachedAt ?? Date.now(),
196
412
  repoRoot: resolveRepoRoot(entry.cwd),
197
413
  namedBy: 'auto',
198
414
  createdAt: Date.now(),
199
- parent: this.findByAgentSessionID(entry.parent)?.id ?? null,
415
+ parent: parent !== null && this.sessions.some((s) => s.id === parent) ? parent : null,
200
416
  ...(entry.prompt === undefined ? {} : { prompt: entry.prompt }),
201
417
  ...(entry.result === undefined ? {} : { result: entry.result }),
202
418
  ...(entry.transcriptPath === undefined ? {} : { transcriptPath: entry.transcriptPath }),
419
+ ...(entry.model === undefined ? {} : { model: entry.model }),
420
+ ...(entry.effort === undefined ? {} : { effort: entry.effort }),
421
+ target,
422
+ targetIdentity,
423
+ ...(entry.workspace === undefined ? {} : { workspace: entry.workspace }),
424
+ withheldEnv: entry.withheldEnv ?? [],
425
+ desired: entry.desired ?? 'run',
426
+ suspended: exited && entry.desired === 'sleep',
427
+ vm: this.pickRestoredVM(target, entry.desired),
428
+ attachment: this.hasHostLifecycle(target) ? 'detached' : 'local',
429
+ hostKey: entry.hostKey ?? entry.sessionID,
430
+ bridgeEpoch: 0,
203
431
  };
204
432
 
205
433
  this.sessions.push(session);
@@ -209,20 +437,37 @@ export class SessionManager {
209
437
  return session;
210
438
  }
211
439
 
212
- private findByAgentSessionID(agentSessionID: AgentSessionID | undefined): Session | undefined {
213
- if (agentSessionID === undefined) {
214
- return undefined;
440
+ // Whether a target's host has a lifecycle of its own that the daemon
441
+ // follows: one that can sleep or be destroyed. The daemon's own machine
442
+ // has none.
443
+ private hasHostLifecycle(target: string): boolean {
444
+ const capabilities = this.targets.get(target)?.provider?.capabilities;
445
+
446
+ return capabilities !== undefined && (capabilities.suspend || capabilities.destroy);
447
+ }
448
+
449
+ // The host state a restored session lists with: asleep when the operator
450
+ // left it asleep, unknown for any other remote host until the daemon
451
+ // reaches it, and none on the daemon's own machine.
452
+ private pickRestoredVM(
453
+ target: string,
454
+ desired: 'sleep' | 'stop' | undefined,
455
+ ): SessionLifecycle['vm'] {
456
+ if (!this.hasHostLifecycle(target)) {
457
+ return 'none';
215
458
  }
216
459
 
217
- return this.sessions.find((s) => s.agentSessionID === agentSessionID);
460
+ return desired === 'sleep' ? 'asleep' : 'unknown';
218
461
  }
219
462
 
220
463
  // Adopts a headless session back into a terminal: a fresh PTY resumes the
221
- // same agent session id.
222
- adoptTerminal(id: SessionID, cols: number, rows: number): Session | null {
464
+ // same agent session id. On a remote host the host wakes first, and a
465
+ // harness still running inside it is attached rather than started again;
466
+ // every other session left asleep on that host comes back with it.
467
+ async adoptTerminal(id: SessionID, cols: number, rows: number): Promise<Session | null> {
223
468
  const s = this.sessions.find((x) => x.id === id);
224
469
 
225
- if (!s || s.pty !== null || s.agentSessionID === undefined) {
470
+ if (!s || s.pty !== null || s.agentSessionID === undefined || this.adopting.has(id)) {
226
471
  return null;
227
472
  }
228
473
 
@@ -232,43 +477,117 @@ export class SessionManager {
232
477
  return null;
233
478
  }
234
479
 
235
- const plan = adapter.planSpawn({ prompt: '', resume: s.agentSessionID });
480
+ const provider = this.requireExecution(s, 'spawn').provider;
481
+
482
+ this.adopting.add(id);
236
483
 
237
- const pty = spawn(plan.bin, plan.args, {
238
- name: 'xterm-256color',
484
+ let plan: SpawnPlan;
485
+
486
+ try {
487
+ plan = await this.setupHarness(adapter, provider, s.id, s.hostKey, s.target, {
488
+ prompt: '',
489
+ resume: s.agentSessionID,
490
+ ...(s.model === undefined ? {} : { model: s.model }),
491
+ ...(s.effort === undefined ? {} : { effort: s.effort }),
492
+ });
493
+ } finally {
494
+ this.adopting.delete(id);
495
+ }
496
+
497
+ // A kill or a second adopt can land while the host wakes.
498
+ if (s.pty !== null || !this.sessions.includes(s)) {
499
+ return null;
500
+ }
501
+
502
+ const binding = this.mintBridgeBinding(s.id, s.target, s.targetIdentity, s.hostKey);
503
+
504
+ const pty = provider.spawnHarness({
505
+ session: s.id,
506
+ host: s.hostKey,
507
+ bin: plan.bin,
508
+ args: plan.args,
509
+ cwd: s.cwd,
510
+ env: { ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath },
511
+ withheldEnv: s.withheldEnv,
239
512
  cols,
240
513
  rows,
241
- cwd: s.cwd,
242
- env: collectCleanEnv({ ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath }),
514
+ onRelay: (relay) => {
515
+ this.onRelay(binding, relay);
516
+ },
243
517
  });
244
518
 
519
+ s.bridgeEpoch = binding.epoch;
245
520
  s.pty = pty;
246
521
  s.kind = 'pty';
247
522
  s.state = 'running';
248
523
  s.lastMsg = 'revived';
524
+ s.desired = 'run';
525
+ s.suspended = false;
526
+
527
+ this.attachHarness(s, pty, this.hasHostLifecycle(s.target));
528
+ this.onBoot(s, cols, rows);
529
+ void this.tryWriteFleet(s.id);
530
+ this.onEvent('state', s);
531
+ this.emitChange();
532
+
533
+ for (const asleep of this.sessions) {
534
+ if (asleep.hostKey === s.hostKey && asleep.target === s.target && asleep.suspended) {
535
+ await this.tryAdoptSibling(asleep, s, cols, rows);
536
+ }
537
+ }
538
+
539
+ return s;
540
+ }
541
+
542
+ // Follows a harness's output and its end. An exit, or a host that lost the
543
+ // process, leaves the session exited; a host that went to sleep with the
544
+ // process inside leaves it suspended, for a revive to find. A session
545
+ // mid-handoff keeps its headless state, since the terminal dying is
546
+ // expected there.
547
+ private attachHarness(s: Session, pty: HarnessHandle, hostLifecycle: boolean): void {
548
+ s.vm = hostLifecycle ? 'awake' : 'none';
549
+ s.attachment = hostLifecycle ? 'attached' : 'local';
249
550
 
250
551
  pty.onData((d) => {
251
552
  this.onOutput(s, d);
252
553
  });
253
554
 
254
- pty.onExit(() => {
555
+ pty.onAttachment?.((attachment) => {
556
+ if (s.pty !== pty || s.attachment === attachment) {
557
+ return;
558
+ }
559
+
560
+ s.attachment = attachment;
561
+
562
+ this.onEvent('state', s);
563
+ this.emitChange();
564
+ });
565
+
566
+ pty.onExit((exit) => {
567
+ if (s.pty !== pty) {
568
+ return;
569
+ }
570
+
255
571
  s.pty = null;
256
572
 
573
+ if (hostLifecycle) {
574
+ s.attachment = 'detached';
575
+ }
576
+
257
577
  if (s.kind === 'pty' && s.state !== 'exited') {
258
578
  s.state = 'exited';
259
579
  s.unread = this.focusedId !== s.id;
260
- s.lastMsg = 'process exited';
580
+ s.lastMsg = pickExitMessage(exit);
581
+ }
582
+
583
+ if (exit.reason === 'suspended') {
584
+ s.vm = 'asleep';
585
+ s.suspended = true;
261
586
  }
262
587
 
263
588
  this.onEvent('state', s);
264
589
  this.emitChange();
265
590
  });
266
-
267
- void this.writeFleet();
268
- this.onEvent('state', s);
269
- this.emitChange();
270
-
271
- return s;
272
591
  }
273
592
 
274
593
  /**
@@ -301,7 +620,7 @@ export class SessionManager {
301
620
  this.onEvent('state', s);
302
621
  }
303
622
 
304
- void this.writeFleet();
623
+ void this.tryWriteFleet(s.id);
305
624
  this.writeStatus();
306
625
  this.emitChange();
307
626
 
@@ -324,10 +643,7 @@ export class SessionManager {
324
643
  if (result !== undefined) {
325
644
  s.result = truncateToBytes(result, 16_384);
326
645
  s.lastDetail = truncateDetail(result);
327
-
328
- if (s.agentSessionID !== undefined) {
329
- void this.store.updateFleetEntry(s.agentSessionID, { result: s.result });
330
- }
646
+ void this.tryUpdateFleetEntry(s.id, { result: s.result });
331
647
  }
332
648
 
333
649
  this.onEvent('state', s);
@@ -364,8 +680,14 @@ export class SessionManager {
364
680
 
365
681
  // resume: true opens the agent's own session picker; an agent session id
366
682
  // resumes that specific session (fleet restore). parent makes the new
367
- // session a sub-session of that one.
368
- spawn(
683
+ // session a sub-session of that one. overrides hold the model and effort
684
+ // the new process runs with, and the session keeps them for every revive.
685
+ // id is minted here unless the caller minted it ahead of the spawn. target
686
+ // is the execution target the harness runs on; one this daemon cannot use
687
+ // refuses the spawn before anything starts. materialized holds what cwd
688
+ // was materialized from, when it was, and the variables the session's
689
+ // harnesses go without.
690
+ async spawn(
369
691
  cwd: string,
370
692
  name: string,
371
693
  prompt: string,
@@ -375,22 +697,47 @@ export class SessionManager {
375
697
  namedBy: 'user' | 'auto' = 'auto',
376
698
  agent: AgentID = 'claude',
377
699
  parent: SessionID | null = null,
378
- ): Session {
700
+ overrides: SpawnOverrides = {},
701
+ id: SessionID = mintSessionID(),
702
+ target = 'local',
703
+ materialized: MaterializedSpawn | null = null,
704
+ ): Promise<Session> {
379
705
  const adapter = this.findAdapter(agent);
380
706
 
381
707
  if (adapter === null) {
382
708
  throw new Error(`no adapter for agent '${agent}'`);
383
709
  }
384
710
 
385
- const id = mintSessionID();
386
- const plan = adapter.planSpawn({ prompt, resume });
711
+ const execution = this.requireExecution({ target, targetIdentity: null }, 'spawn');
712
+ const provider = execution.provider;
713
+
714
+ // The repository root resolves before the process starts: resolving it
715
+ // can throw, and a spawn that throws must leave nothing running. A
716
+ // remote directory is not on the daemon's machine, so it is its own root.
717
+ const repoRoot = provider.remote ? cwd : resolveRepoRoot(cwd);
718
+ const hostKey = this.pickHostKey(id, parent, target, execution.identity);
719
+
720
+ const plan = await this.setupHarness(adapter, provider, id, hostKey, target, {
721
+ prompt,
722
+ resume,
723
+ ...overrides,
724
+ });
725
+
726
+ const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
387
727
 
388
- const pty = spawn(plan.bin, plan.args, {
389
- name: 'xterm-256color',
728
+ const pty = provider.spawnHarness({
729
+ session: id,
730
+ host: hostKey,
731
+ bin: plan.bin,
732
+ args: plan.args,
733
+ cwd,
734
+ env: { ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
735
+ withheldEnv: materialized?.withheldEnv ?? [],
390
736
  cols,
391
737
  rows,
392
- cwd,
393
- env: collectCleanEnv({ ATC_SESSION_ID: id, ATC_SOCKET: socketPath }),
738
+ onRelay: (relay) => {
739
+ this.onRelay(binding, relay);
740
+ },
394
741
  });
395
742
 
396
743
  let initialMsg = prompt;
@@ -412,38 +759,153 @@ export class SessionManager {
412
759
  agent,
413
760
  pinned: false,
414
761
  lastAttachedAt: Date.now(),
415
- repoRoot: resolveRepoRoot(cwd),
762
+ repoRoot,
416
763
  namedBy,
417
764
  createdAt: Date.now(),
418
765
  parent,
419
766
  ...(prompt === '' ? {} : { prompt }),
767
+ ...(overrides.model === undefined ? {} : { model: overrides.model }),
768
+ ...(overrides.effort === undefined ? {} : { effort: overrides.effort }),
769
+ target,
770
+ targetIdentity: execution.identity,
771
+ ...(materialized === null ? {} : { workspace: materialized.workspace }),
772
+ withheldEnv: materialized?.withheldEnv ?? [],
773
+ desired: 'run',
774
+ vm: 'none',
775
+ attachment: 'local',
776
+ suspended: false,
777
+ hostKey,
778
+ bridgeEpoch: binding.epoch,
420
779
  };
421
780
 
422
- pty.onData((d) => {
423
- this.onOutput(session, d);
781
+ this.attachHarness(session, pty, this.hasHostLifecycle(target));
782
+ this.sessions.push(session);
783
+ void this.tryWriteFleet(session.id);
784
+ this.writeStatus();
785
+ this.onEvent('added', session);
786
+ this.onBoot(session, cols, rows);
787
+
788
+ return session;
789
+ }
790
+
791
+ // The binding a harness start or attach opens its bridge under, at a
792
+ // fresh epoch.
793
+ private mintBridgeBinding(
794
+ sessionID: SessionID,
795
+ target: string,
796
+ targetIdentity: string,
797
+ hostKey: SessionID,
798
+ ): BridgeBinding {
799
+ const epoch = this.nextBridgeEpoch;
800
+
801
+ this.nextBridgeEpoch += 1;
802
+
803
+ return { sessionID, target, targetIdentity, hostKey, epoch };
804
+ }
805
+
806
+ // Readies the host a harness is about to start on and plans the harness.
807
+ // On a remote host the agent plans a guest spawn, whose files unpack into
808
+ // the session's own guest folder, and the agent's sign-in check runs
809
+ // there first. Every refusal comes before the harness starts.
810
+ private async setupHarness(
811
+ adapter: AgentAdapter,
812
+ provider: ExecutionProvider,
813
+ id: SessionID,
814
+ hostKey: SessionID,
815
+ target: string,
816
+ options: SpawnOptions,
817
+ ): Promise<SpawnPlan> {
818
+ if (!provider.remote) {
819
+ await provider.prepareHost({ host: hostKey, daemonID: this.store.daemonID });
820
+
821
+ return adapter.planSpawn(options);
822
+ }
823
+
824
+ const guest = provider.guest ?? { dir: '/tmp/atc', atc: null };
825
+ const dir = `${guest.dir}/sessions/${id}`;
826
+
827
+ const plan =
828
+ adapter.planGuestSpawn === undefined
829
+ ? { ...adapter.planSpawn(options), files: {} }
830
+ : adapter.planGuestSpawn(options, { atc: guest.atc, dir });
831
+
832
+ if (plan === null) {
833
+ throw buildGuestRefusal(provider.kind, adapter.id, target, guest.atc === null);
834
+ }
835
+
836
+ await provider.prepareHost({
837
+ host: hostKey,
838
+ daemonID: this.store.daemonID,
839
+ installATC: adapter.planGuestSpawn !== undefined,
424
840
  });
425
841
 
426
- pty.onExit(() => {
427
- session.pty = null;
842
+ const check = adapter.planAuthCheck?.();
428
843
 
429
- // A session mid-handoff keeps its headless state; the terminal dying
430
- // is expected there, not an exit.
431
- if (session.kind === 'pty' && session.state !== 'exited') {
432
- session.state = 'exited';
433
- session.unread = this.focusedId !== session.id;
434
- session.lastMsg = 'process exited';
844
+ if (check !== undefined) {
845
+ const result = await provider.runCommand({ argv: check, cwd: '/', host: hostKey });
846
+
847
+ if (result.exitCode !== 0) {
848
+ throw new DaemonError(
849
+ 'auth_not_configured',
850
+ `agent '${adapter.id}' is not signed in on target '${target}'; sign it in inside the host's image`,
851
+ { agent: adapter.id, target },
852
+ );
435
853
  }
854
+ }
436
855
 
437
- this.onEvent('state', session);
438
- this.emitChange();
439
- });
856
+ const files = Object.entries(plan.files).map(([path, content]) => ({ path, content }));
440
857
 
441
- this.sessions.push(session);
442
- void this.writeFleet();
443
- this.writeStatus();
444
- this.onEvent('added', session);
858
+ if (files.length > 0) {
859
+ await provider.transferArchive(buildTarArchive(files), dir, hostKey);
860
+ }
445
861
 
446
- return session;
862
+ return plan;
863
+ }
864
+
865
+ // A sub-session runs on its parent's host when its resolved target, name
866
+ // and identity both, is the one its parent is bound to and that target's
867
+ // hosts have a lifecycle, so one host serves a top-level session and
868
+ // every sub-session beside it there; any other session has a host of its
869
+ // own.
870
+ private pickHostKey(
871
+ id: SessionID,
872
+ parent: SessionID | null,
873
+ target: string,
874
+ identity: string,
875
+ ): SessionID {
876
+ const owner = parent === null ? undefined : this.sessions.find((s) => s.id === parent);
877
+
878
+ if (
879
+ owner === undefined ||
880
+ owner.target !== target ||
881
+ owner.targetIdentity !== identity ||
882
+ !this.hasHostLifecycle(target)
883
+ ) {
884
+ return id;
885
+ }
886
+
887
+ return owner.hostKey;
888
+ }
889
+
890
+ // Takes back a spawn that failed after its process started: the process
891
+ // dies and the session leaves the list and the fleet, so the spawn leaves
892
+ // nothing behind. It resolves only once the fleet without the session is
893
+ // durable, and throws when the kill or that write fails. A session that
894
+ // never registered is left alone.
895
+ async removeFailedSpawn(id: SessionID): Promise<void> {
896
+ const s = this.sessions.find((x) => x.id === id);
897
+
898
+ if (s === undefined) {
899
+ return;
900
+ }
901
+
902
+ s.pty?.kill();
903
+ s.pty = null;
904
+
905
+ this.remove(s);
906
+ this.emitChange();
907
+
908
+ await this.writeFleet();
447
909
  }
448
910
 
449
911
  collectDescriptors(): SessionDescriptor[] {
@@ -451,7 +913,7 @@ export class SessionManager {
451
913
  id: s.id,
452
914
  name: s.name,
453
915
  cwd: s.cwd,
454
- state: s.state,
916
+ state: pickSessionState(buildLifecycle(s), s.state),
455
917
  unread: s.unread,
456
918
  lastMsg: s.lastMsg,
457
919
  ...(s.lastDetail === undefined ? {} : { lastDetail: s.lastDetail }),
@@ -466,6 +928,9 @@ export class SessionManager {
466
928
  alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
467
929
  canEject: (this.findAdapter(s.agent)?.headlessRunner ?? null) !== null,
468
930
  ...(s.parent === null ? {} : { parent: s.parent }),
931
+ locator: { daemonID: this.store.daemonID, targetID: s.target },
932
+ ...(s.workspace === undefined ? {} : { workspace: s.workspace }),
933
+ lifecycle: buildLifecycle(s),
469
934
  }));
470
935
  }
471
936
 
@@ -584,12 +1049,12 @@ export class SessionManager {
584
1049
  }
585
1050
 
586
1051
  // 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.
1052
+ // new agent session id takes it. Anything else touches this session's
1053
+ // own row.
589
1054
  if (persist) {
590
- void this.writeFleet();
591
- } else if (s.agentSessionID !== undefined) {
592
- void this.store.updateFleetEntry(s.agentSessionID, rowUpdate);
1055
+ void this.tryWriteFleet(s.id);
1056
+ } else {
1057
+ void this.tryUpdateFleetEntry(s.id, rowUpdate);
593
1058
  }
594
1059
 
595
1060
  if (dirty) {
@@ -619,7 +1084,7 @@ export class SessionManager {
619
1084
  s.namedBy = update.namedBy;
620
1085
  }
621
1086
 
622
- void this.writeFleet();
1087
+ void this.tryWriteFleet(s.id);
623
1088
  this.onEvent('renamed', s);
624
1089
  this.emitChange();
625
1090
  }
@@ -644,7 +1109,7 @@ export class SessionManager {
644
1109
 
645
1110
  s.unread = false;
646
1111
  s.lastAttachedAt = Date.now();
647
- void this.writeFleet();
1112
+ void this.tryWriteFleet(s.id);
648
1113
 
649
1114
  // Attaching answers the attention request: a still-pending prompt
650
1115
  // re-flags it via the next notification.
@@ -673,7 +1138,12 @@ export class SessionManager {
673
1138
  // archived, so the write it depends on must land before that response
674
1139
  // goes out. A kill acts on the whole set: killing a session kills its
675
1140
  // live sub-sessions with it, and forgetting a dead one forgets its dead
676
- // sub-sessions and promotes any live ones to top level.
1141
+ // sub-sessions and promotes any live ones to top level. A live session
1142
+ // that owns a host that can sleep puts the host to sleep instead, with
1143
+ // every harness on it kept inside, and fails whole when the host stays
1144
+ // awake. A dead session on a target that can destroy its host is not
1145
+ // forgotten by a kill: forgetting it destroys the host, which takes a
1146
+ // confirmed forget.
677
1147
  async kill(id: SessionID): Promise<void> {
678
1148
  const s = this.sessions.find((x) => x.id === id);
679
1149
 
@@ -682,28 +1152,181 @@ export class SessionManager {
682
1152
  }
683
1153
 
684
1154
  if (s.pty) {
1155
+ await this.stopHarness(s);
1156
+
685
1157
  for (const child of this.collectChildren(id)) {
686
- this.killTerminal(child);
1158
+ await this.tryStopHarness(child);
687
1159
  }
688
-
689
- this.killTerminal(s);
690
1160
  } else {
691
- for (const child of this.collectChildren(id)) {
692
- if (child.pty === null && !(child.kind === 'headless' && child.state !== 'exited')) {
693
- this.remove(child);
694
- } else {
695
- child.parent = null;
1161
+ if (this.findProvider(s)?.capabilities.destroy === true) {
1162
+ throw new DaemonError(
1163
+ 'confirmation_required',
1164
+ `forgetting session ${id} destroys its host on target '${s.target}'; confirm it with session.forget`,
1165
+ { session: id },
1166
+ );
1167
+ }
1168
+
1169
+ this.updateForgottenChildren(id);
1170
+ this.remove(s);
1171
+ }
1172
+
1173
+ await this.writeFleet();
696
1174
 
697
- this.onEvent('state', child);
1175
+ this.emitChange();
1176
+ }
1177
+
1178
+ /**
1179
+ * Forgets a session for good, whether it runs or not, and returns whether
1180
+ * its host was destroyed. A session that owns a host its target can
1181
+ * destroy destroys the host, and every session on that host goes with it;
1182
+ * a session on its parent's host ends its own harness alone, and one
1183
+ * kept asleep in that host is refused until the host wakes. Its dead
1184
+ * sub-sessions on other hosts go with it, unless their own target can
1185
+ * destroy their host, and its live ones become top-level. A failed destroy
1186
+ * throws before anything is forgotten.
1187
+ */
1188
+ async forget(id: SessionID): Promise<boolean> {
1189
+ const s = this.sessions.find((x) => x.id === id);
1190
+
1191
+ if (s === undefined) {
1192
+ return false;
1193
+ }
1194
+
1195
+ const provider = this.findProvider(s);
1196
+ const destroys = provider !== null && provider.capabilities.destroy && s.hostKey === s.id;
1197
+
1198
+ // A harness kept inside a sleeping host it does not own still has a
1199
+ // process there, which the daemon can neither reach nor end while the
1200
+ // host sleeps. Its record keeps that process owned until the host wakes
1201
+ // or its owner's forget destroys the host.
1202
+ if (!destroys && s.pty === null && s.vm === 'asleep') {
1203
+ throw new DaemonError(
1204
+ 'unsupported_operation',
1205
+ `session ${id} sleeps inside the host of session ${s.hostKey}; revive it or forget session ${s.hostKey} first`,
1206
+ { provider: provider?.kind ?? null, problem: 'host_asleep', host: s.hostKey },
1207
+ );
1208
+ }
1209
+
1210
+ if (destroys) {
1211
+ await provider.destroyHost(s.hostKey);
1212
+
1213
+ for (const onHost of this.sessions) {
1214
+ if (onHost.hostKey === s.hostKey && onHost.target === s.target && onHost.id !== s.id) {
1215
+ onHost.pty?.detach();
1216
+ onHost.pty = null;
1217
+
1218
+ this.remove(onHost);
698
1219
  }
699
1220
  }
700
1221
 
701
- this.remove(s);
1222
+ s.pty?.detach();
1223
+ s.pty = null;
1224
+ } else {
1225
+ this.killTerminal(s);
702
1226
  }
703
1227
 
1228
+ this.updateForgottenChildren(id);
1229
+ this.remove(s);
1230
+
704
1231
  await this.writeFleet();
705
1232
 
706
1233
  this.emitChange();
1234
+
1235
+ return destroys;
1236
+ }
1237
+
1238
+ // A forgotten parent's dead sub-sessions go with it, except one whose own
1239
+ // target can destroy its host: forgetting that one destroys the host, which
1240
+ // takes its own confirmed forget. Every sub-session that stays becomes
1241
+ // top-level.
1242
+ private updateForgottenChildren(id: SessionID): void {
1243
+ for (const child of this.collectChildren(id)) {
1244
+ const live = child.pty !== null || (child.kind === 'headless' && child.state !== 'exited');
1245
+
1246
+ if (live || this.findProvider(child)?.capabilities.destroy === true) {
1247
+ child.parent = null;
1248
+
1249
+ this.onEvent('state', child);
1250
+ } else {
1251
+ this.remove(child);
1252
+ }
1253
+ }
1254
+ }
1255
+
1256
+ /**
1257
+ * The capability a kill of a live session needs on its target: `suspend`
1258
+ * for a session that owns a host that can sleep, `kill` for any other.
1259
+ */
1260
+ pickKillCapability(s: Session): ExecutionCapability {
1261
+ return this.canSuspendHost(s) ? 'suspend' : 'kill';
1262
+ }
1263
+
1264
+ private canSuspendHost(s: Session): boolean {
1265
+ return s.hostKey === s.id && this.findProvider(s)?.capabilities.suspend === true;
1266
+ }
1267
+
1268
+ // Puts the session's host to sleep when it owns one that can sleep, and
1269
+ // ends its harness otherwise. A refused sleep throws before anything
1270
+ // changes.
1271
+ private async stopHarness(s: Session): Promise<void> {
1272
+ const provider = this.findProvider(s);
1273
+
1274
+ if (provider === null || !this.canSuspendHost(s)) {
1275
+ this.killTerminal(s);
1276
+
1277
+ return;
1278
+ }
1279
+
1280
+ await provider.suspendHost(s.hostKey);
1281
+
1282
+ for (const onHost of this.sessions) {
1283
+ if (onHost.hostKey === s.hostKey && onHost.target === s.target) {
1284
+ this.updateSuspended(onHost);
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ // A sub-session whose host stays awake is ended instead, so a kill
1290
+ // never leaves part of its set running.
1291
+ private async tryStopHarness(s: Session): Promise<void> {
1292
+ if (s.pty === null) {
1293
+ this.killTerminal(s);
1294
+
1295
+ return;
1296
+ }
1297
+
1298
+ try {
1299
+ await this.stopHarness(s);
1300
+ } catch (error) {
1301
+ this.log(
1302
+ `atc could not put the host of session ${s.id} to sleep (${error instanceof Error ? error.message : String(error)}); ending its harness instead`,
1303
+ );
1304
+
1305
+ this.killTerminal(s);
1306
+ }
1307
+ }
1308
+
1309
+ // A harness inside a host that went to sleep: the daemon lets go of it,
1310
+ // and the process stays inside the host for a revive to find.
1311
+ private updateSuspended(s: Session): void {
1312
+ s.vm = 'asleep';
1313
+
1314
+ if (s.pty === null) {
1315
+ this.onEvent('state', s);
1316
+
1317
+ return;
1318
+ }
1319
+
1320
+ s.pty.detach();
1321
+
1322
+ s.pty = null;
1323
+ s.state = 'exited';
1324
+ s.lastMsg = 'asleep';
1325
+ s.desired = 'sleep';
1326
+ s.attachment = 'detached';
1327
+ s.suspended = true;
1328
+
1329
+ this.onEvent('state', s);
707
1330
  }
708
1331
 
709
1332
  // Ends a live session, terminal or headless, leaving a dead entry; a
@@ -719,6 +1342,7 @@ export class SessionManager {
719
1342
 
720
1343
  s.state = 'exited';
721
1344
  s.lastMsg = 'killed';
1345
+ s.desired = 'stop';
722
1346
 
723
1347
  this.onEvent('state', s);
724
1348
  }
@@ -733,9 +1357,12 @@ export class SessionManager {
733
1357
  this.onEvent('removed', s);
734
1358
  }
735
1359
 
736
- killAll() {
1360
+ // Lets go of every harness as the daemon stops: one on the daemon's own
1361
+ // machine ends with it, and one on a remote host runs on for the next
1362
+ // daemon to attach.
1363
+ detachAll() {
737
1364
  for (const s of this.sessions) {
738
- s.pty?.kill();
1365
+ s.pty?.detach();
739
1366
  }
740
1367
  }
741
1368
 
@@ -759,42 +1386,78 @@ export class SessionManager {
759
1386
  // Deliberate kills rewrite the file; unexpected session/atc deaths do not,
760
1387
  // so the last known fleet survives for `R` restore. Sessions whose
761
1388
  // terminal is gone persist as exited entries, so the killed archive
762
- // survives a daemon restart too.
1389
+ // survives a daemon restart too. A session the agent has not yet given
1390
+ // its own session id persists as well, under its atc session id alone.
763
1391
  async writeFleet(): Promise<void> {
764
1392
  const fleet: FleetEntry[] = [];
765
1393
 
766
1394
  for (const s of this.sessions) {
767
- if (s.agentSessionID === undefined) {
768
- continue;
769
- }
770
-
771
1395
  const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
772
1396
 
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
1397
  fleet.push({
1398
+ sessionID: s.id,
781
1399
  name: s.name,
782
1400
  cwd: s.cwd,
783
- agentSessionID: s.agentSessionID,
1401
+ ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
784
1402
  agent: s.agent,
785
1403
  ...(s.pinned ? { pinned: true } : {}),
786
1404
  lastAttachedAt: s.lastAttachedAt,
787
1405
  ...(live ? {} : { exited: true }),
788
- ...(parent === undefined ? {} : { parent }),
1406
+ ...(s.parent === null ? {} : { parent: s.parent }),
789
1407
  ...(s.prompt === undefined ? {} : { prompt: s.prompt }),
790
1408
  ...(s.result === undefined ? {} : { result: s.result }),
791
1409
  ...(s.transcriptPath === undefined ? {} : { transcriptPath: s.transcriptPath }),
1410
+ ...(s.model === undefined ? {} : { model: s.model }),
1411
+ ...(s.effort === undefined ? {} : { effort: s.effort }),
1412
+ target: s.target,
1413
+ targetIdentity: s.targetIdentity,
1414
+ ...(s.desired === 'run' ? {} : { desired: s.desired }),
1415
+ ...(s.hostKey === s.id ? {} : { hostKey: s.hostKey }),
792
1416
  });
793
1417
  }
794
1418
 
795
1419
  await this.store.writeFleet(fleet);
796
1420
  }
797
1421
 
1422
+ // A write nobody awaits that fails leaves the stored rows as they were,
1423
+ // and must not take the daemon down unhandled. A stale ownership epoch is
1424
+ // expected and surfaces on the next write a request awaits; any other
1425
+ // failure is logged with the session whose change it was writing.
1426
+ private async tryWriteFleet(sessionID: SessionID): Promise<boolean> {
1427
+ try {
1428
+ await this.writeFleet();
1429
+ } catch (error) {
1430
+ this.logStoreFailure(sessionID, error);
1431
+
1432
+ return false;
1433
+ }
1434
+
1435
+ return true;
1436
+ }
1437
+
1438
+ private async tryUpdateFleetEntry(id: SessionID, fields: FleetEntryUpdate): Promise<boolean> {
1439
+ try {
1440
+ await this.store.updateFleetEntry(id, fields);
1441
+ } catch (error) {
1442
+ this.logStoreFailure(id, error);
1443
+
1444
+ return false;
1445
+ }
1446
+
1447
+ return true;
1448
+ }
1449
+
1450
+ private logStoreFailure(sessionID: SessionID, error: unknown): void {
1451
+ if (error instanceof DaemonError && error.code === 'stale_epoch') {
1452
+ return;
1453
+ }
1454
+
1455
+ const code = error instanceof DaemonError ? error.code : 'internal';
1456
+ const message = error instanceof Error ? error.message : String(error);
1457
+
1458
+ this.log(`atc fleet write for session ${sessionID} failed (${code}): ${message}`);
1459
+ }
1460
+
798
1461
  countStates() {
799
1462
  return countSessionStates(this.sessions);
800
1463
  }
@@ -892,3 +1555,70 @@ export function sortGroupedSessionViews<
892
1555
 
893
1556
  return [...buckets.values()].flat();
894
1557
  }
1558
+
1559
+ function buildLifecycle(s: Session): SessionLifecycle {
1560
+ return buildSessionLifecycle({
1561
+ desired: s.desired,
1562
+ vm: s.vm,
1563
+ attachment: s.attachment,
1564
+ suspended: s.suspended,
1565
+ hasHarness: s.pty !== null,
1566
+ kind: s.kind,
1567
+ state: s.state,
1568
+ });
1569
+ }
1570
+
1571
+ // An agent that plans no remote spawn: for want of an atc inside the host
1572
+ // when the host has none, or because the agent never runs remotely.
1573
+ function buildGuestRefusal(
1574
+ provider: string,
1575
+ agent: string,
1576
+ target: string,
1577
+ hasNoATC: boolean,
1578
+ ): DaemonError {
1579
+ return hasNoATC
1580
+ ? new DaemonError(
1581
+ 'unsupported_operation',
1582
+ `agent '${agent}' cannot run on target '${target}': its host has no atc to report through; run a compiled atc daemon on Linux, or set the target's guestATC to an atc installed in its image`,
1583
+ { provider, agent, problem: 'no_guest_atc' },
1584
+ )
1585
+ : new DaemonError(
1586
+ 'unsupported_operation',
1587
+ `agent '${agent}' cannot run on a remote target such as '${target}'`,
1588
+ { provider, agent, problem: 'remote_unsupported' },
1589
+ );
1590
+ }
1591
+
1592
+ // Why a harness stopped, as a session's last message.
1593
+ function pickExitMessage(exit: Readonly<{ reason?: string; detail?: string }>): string {
1594
+ if (exit.reason === 'suspended') {
1595
+ return 'asleep';
1596
+ }
1597
+
1598
+ if (exit.reason === 'ended') {
1599
+ return exit.detail ?? 'host lost the process';
1600
+ }
1601
+
1602
+ return 'process exited';
1603
+ }
1604
+
1605
+ // A target refusal as a session's last message, short enough for a list row.
1606
+ function formatTargetRefusal(code: ErrorCode, target: string): string {
1607
+ if (code === 'target_config_invalid') {
1608
+ return `target '${target}' misconfigured`;
1609
+ }
1610
+
1611
+ if (code === 'unknown_target') {
1612
+ return `no target '${target}'`;
1613
+ }
1614
+
1615
+ if (code === 'target_changed') {
1616
+ return `target '${target}' changed`;
1617
+ }
1618
+
1619
+ if (code === 'target_unavailable') {
1620
+ return `target '${target}' unavailable`;
1621
+ }
1622
+
1623
+ return `target '${target}' cannot start a terminal`;
1624
+ }