@zgeoff/atc 2.12.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 (71) hide show
  1. package/README.md +10 -10
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +48 -11
  4. package/src/agents/build-atc-bridge-files.ts +13 -0
  5. package/src/agents/build-claude-query-options.ts +72 -0
  6. package/src/agents/build-cli-command.ts +7 -5
  7. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  8. package/src/agents/build-hook-settings.ts +8 -2
  9. package/src/agents/build-restore-mode-args.ts +23 -0
  10. package/src/agents/claude-adapter.ts +64 -17
  11. package/src/agents/codex-adapter.ts +5 -0
  12. package/src/agents/find-claude-permission-mode.ts +24 -0
  13. package/src/agents/gateway-adapter.ts +38 -11
  14. package/src/agents/grok-adapter.ts +5 -0
  15. package/src/agents/make-claude-headless-runner.ts +46 -0
  16. package/src/agents/plan-pasted-line-input.ts +22 -0
  17. package/src/agents/plan-typed-line-input.ts +8 -0
  18. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  19. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
  20. package/src/agents/write-atc-bridge.ts +2 -6
  21. package/src/cli.ts +8 -12
  22. package/src/client/index.ts +50 -1
  23. package/src/client/ui.ts +8 -0
  24. package/src/daemon/build-execution-targets.ts +49 -10
  25. package/src/daemon/build-imp-provider.ts +68 -0
  26. package/src/daemon/build-report-trail-entry.ts +4 -2
  27. package/src/daemon/build-scoped-context.ts +4 -1
  28. package/src/daemon/build-session-lifecycle.ts +52 -0
  29. package/src/daemon/build-tar-archive.ts +85 -0
  30. package/src/daemon/daemon-connection.ts +97 -4
  31. package/src/daemon/daemon.ts +298 -41
  32. package/src/daemon/execution-provider.ts +109 -4
  33. package/src/daemon/hooks.ts +5 -17
  34. package/src/daemon/imp-client-port.ts +343 -0
  35. package/src/daemon/imp-harness.ts +618 -0
  36. package/src/daemon/imp-port-error.ts +18 -0
  37. package/src/daemon/imp-port.ts +246 -0
  38. package/src/daemon/imp-provider.ts +601 -0
  39. package/src/daemon/is-binding-current.ts +43 -0
  40. package/src/daemon/local-pty-provider.ts +62 -3
  41. package/src/daemon/materialize-workspace.ts +571 -0
  42. package/src/daemon/parse-hook-line.ts +31 -0
  43. package/src/daemon/pick-session-state.ts +15 -0
  44. package/src/daemon/restore-fleet.ts +41 -23
  45. package/src/daemon/screen-model.ts +7 -0
  46. package/src/daemon/sessions.ts +574 -61
  47. package/src/daemon/start-headless-turn.ts +1 -1
  48. package/src/daemon/start-session-bridge.ts +294 -0
  49. package/src/mcp/mcp-tools.ts +9 -2
  50. package/src/mcp/require-daemon-features.ts +3 -0
  51. package/src/mcp/run-tool.ts +27 -8
  52. package/src/protocol/daemon-features.ts +11 -0
  53. package/src/protocol/protocol.ts +23 -0
  54. package/src/protocol/request-param-schemas.ts +66 -0
  55. package/src/report.ts +37 -2
  56. package/src/run-bridge-tap.ts +241 -0
  57. package/src/shared/collect-clean-env.ts +9 -1
  58. package/src/shared/open-bridge-socket.ts +86 -0
  59. package/src/shared/send-bridge-request.ts +47 -0
  60. package/src/statusline.ts +34 -3
  61. package/src/store/fleet-entry.ts +19 -0
  62. package/src/store/run-migrations.ts +76 -0
  63. package/src/store/state-store.ts +182 -21
  64. package/src/store/trail-entry.ts +4 -0
  65. package/src/store/workspace-materialization.ts +70 -0
  66. package/src/tap.ts +11 -0
  67. package/src/workspace/check-url-credentials.ts +59 -0
  68. package/src/workspace/create-workspace-clone.ts +13 -1
  69. package/src/workspace/repository-env-vars.ts +22 -0
  70. package/src/workspace/run-git.ts +2 -18
  71. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -1,10 +1,16 @@
1
1
  import { writeFileSync } from 'node:fs';
2
- import type { AdapterEvent, AgentAdapter, AgentID, SpawnOverrides } from '../agents/agent-adapter';
2
+ import type {
3
+ AdapterEvent,
4
+ AgentAdapter,
5
+ AgentID,
6
+ SpawnOptions,
7
+ SpawnOverrides,
8
+ SpawnPlan,
9
+ } from '../agents/agent-adapter';
3
10
  import { truncateDetail } from '../agents/truncate-detail';
4
11
  import { DaemonError } from '../protocol/daemon-error';
5
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';
8
14
  import type { TargetConfigError } from '../shared/collect-targets';
9
15
  import { socketPath, statusFile } from '../shared/config';
10
16
  import type { DaemonID } from '../shared/daemon-id';
@@ -12,13 +18,24 @@ import { resolveRepoRoot } from '../shared/resolve-repo-root';
12
18
  import type { SessionID } from '../shared/session-id';
13
19
  import { truncateToBytes } from '../shared/truncate-to-bytes';
14
20
  import type { FleetEntry, FleetEntryUpdate, FleetStore } from '../store/fleet-entry';
21
+ import type { SessionWorkspace } from '../store/workspace-materialization';
15
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';
16
26
  import { buildTargetIdentity } from './build-target-identity';
17
- import type { ExecutionCapability, ExecutionProvider, HarnessHandle } from './execution-provider';
27
+ import type {
28
+ ExecutionCapability,
29
+ ExecutionProvider,
30
+ HarnessHandle,
31
+ HarnessRelay,
32
+ } from './execution-provider';
18
33
  import { findExecutionRefusal } from './find-execution-refusal';
19
34
  import type { HookEvent } from './hooks';
35
+ import type { BridgeBinding } from './is-binding-current';
20
36
  import { LocalPTYProvider } from './local-pty-provider';
21
37
  import { mintSessionID } from './mint-session-id';
38
+ import { pickSessionState } from './pick-session-state';
22
39
 
23
40
  export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
24
41
 
@@ -55,6 +72,14 @@ export interface SessionDescriptor {
55
72
  // Where the session runs: the daemon hosting it, and the execution target
56
73
  // on that daemon's host.
57
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;
58
83
  }
59
84
 
60
85
  interface SessionLocator {
@@ -117,6 +142,39 @@ export interface Session {
117
142
  // while the target keeps that identity.
118
143
  target: string;
119
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[];
120
178
  }
121
179
 
122
180
  // The identity of the implicit `local` target, which a fleet row without a
@@ -139,6 +197,14 @@ export class SessionManager {
139
197
 
140
198
  onChange: () => void = () => {};
141
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
+
142
208
  onEvent: (kind: SessionEventKind, s: Session) => void = () => {};
143
209
 
144
210
  // Where a background failure is reported, one line at a time: stderr,
@@ -162,6 +228,14 @@ export class SessionManager {
162
228
 
163
229
  private readonly targetErrors: readonly TargetConfigError[];
164
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
+
165
239
  constructor(
166
240
  fallback: AgentAdapter,
167
241
  store: FleetStore,
@@ -268,6 +342,24 @@ export class SessionManager {
268
342
  return s;
269
343
  }
270
344
 
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
+
271
363
  // Registers a fleet entry as a session with no terminal yet, under the
272
364
  // atc session id its row holds, so a fleet-wide restore can show every
273
365
  // incoming session at once; adopting it later attaches the terminal.
@@ -290,7 +382,9 @@ export class SessionManager {
290
382
  // reviving under another agent. Adopting a terminal for it is refused.
291
383
  let lastMsg = 'waiting to restore';
292
384
 
293
- if (entry.exited !== true && entry.agentSessionID === undefined) {
385
+ if (exited && entry.desired === 'sleep') {
386
+ lastMsg = 'asleep';
387
+ } else if (entry.exited !== true && entry.agentSessionID === undefined) {
294
388
  lastMsg = 'nothing to resume';
295
389
  } else if (entry.exited !== true && targetRefusal !== null) {
296
390
  lastMsg = targetRefusal;
@@ -326,6 +420,14 @@ export class SessionManager {
326
420
  ...(entry.effort === undefined ? {} : { effort: entry.effort }),
327
421
  target,
328
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,
329
431
  };
330
432
 
331
433
  this.sessions.push(session);
@@ -335,12 +437,37 @@ export class SessionManager {
335
437
  return session;
336
438
  }
337
439
 
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';
458
+ }
459
+
460
+ return desired === 'sleep' ? 'asleep' : 'unknown';
461
+ }
462
+
338
463
  // Adopts a headless session back into a terminal: a fresh PTY resumes the
339
- // same agent session id.
340
- 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> {
341
468
  const s = this.sessions.find((x) => x.id === id);
342
469
 
343
- if (!s || s.pty !== null || s.agentSessionID === undefined) {
470
+ if (!s || s.pty !== null || s.agentSessionID === undefined || this.adopting.has(id)) {
344
471
  return null;
345
472
  }
346
473
 
@@ -352,49 +479,115 @@ export class SessionManager {
352
479
 
353
480
  const provider = this.requireExecution(s, 'spawn').provider;
354
481
 
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
- });
482
+ this.adopting.add(id);
483
+
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);
361
503
 
362
504
  const pty = provider.spawnHarness({
505
+ session: s.id,
506
+ host: s.hostKey,
363
507
  bin: plan.bin,
364
508
  args: plan.args,
365
509
  cwd: s.cwd,
366
- env: collectCleanEnv({ ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath }),
510
+ env: { ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath },
511
+ withheldEnv: s.withheldEnv,
367
512
  cols,
368
513
  rows,
514
+ onRelay: (relay) => {
515
+ this.onRelay(binding, relay);
516
+ },
369
517
  });
370
518
 
519
+ s.bridgeEpoch = binding.epoch;
371
520
  s.pty = pty;
372
521
  s.kind = 'pty';
373
522
  s.state = 'running';
374
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';
375
550
 
376
551
  pty.onData((d) => {
377
552
  this.onOutput(s, d);
378
553
  });
379
554
 
380
- 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
+
381
571
  s.pty = null;
382
572
 
573
+ if (hostLifecycle) {
574
+ s.attachment = 'detached';
575
+ }
576
+
383
577
  if (s.kind === 'pty' && s.state !== 'exited') {
384
578
  s.state = 'exited';
385
579
  s.unread = this.focusedId !== s.id;
386
- s.lastMsg = 'process exited';
580
+ s.lastMsg = pickExitMessage(exit);
581
+ }
582
+
583
+ if (exit.reason === 'suspended') {
584
+ s.vm = 'asleep';
585
+ s.suspended = true;
387
586
  }
388
587
 
389
588
  this.onEvent('state', s);
390
589
  this.emitChange();
391
590
  });
392
-
393
- void this.tryWriteFleet(s.id);
394
- this.onEvent('state', s);
395
- this.emitChange();
396
-
397
- return s;
398
591
  }
399
592
 
400
593
  /**
@@ -491,8 +684,10 @@ export class SessionManager {
491
684
  // the new process runs with, and the session keeps them for every revive.
492
685
  // id is minted here unless the caller minted it ahead of the spawn. target
493
686
  // is the execution target the harness runs on; one this daemon cannot use
494
- // refuses the spawn before anything starts.
495
- spawn(
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(
496
691
  cwd: string,
497
692
  name: string,
498
693
  prompt: string,
@@ -505,7 +700,8 @@ export class SessionManager {
505
700
  overrides: SpawnOverrides = {},
506
701
  id: SessionID = mintSessionID(),
507
702
  target = 'local',
508
- ): Session {
703
+ materialized: MaterializedSpawn | null = null,
704
+ ): Promise<Session> {
509
705
  const adapter = this.findAdapter(agent);
510
706
 
511
707
  if (adapter === null) {
@@ -516,17 +712,32 @@ export class SessionManager {
516
712
  const provider = execution.provider;
517
713
 
518
714
  // 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 });
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);
522
727
 
523
728
  const pty = provider.spawnHarness({
729
+ session: id,
730
+ host: hostKey,
524
731
  bin: plan.bin,
525
732
  args: plan.args,
526
733
  cwd,
527
- env: collectCleanEnv({ ATC_SESSION_ID: id, ATC_SOCKET: socketPath }),
734
+ env: { ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
735
+ withheldEnv: materialized?.withheldEnv ?? [],
528
736
  cols,
529
737
  rows,
738
+ onRelay: (relay) => {
739
+ this.onRelay(binding, relay);
740
+ },
530
741
  });
531
742
 
532
743
  let initialMsg = prompt;
@@ -557,33 +768,123 @@ export class SessionManager {
557
768
  ...(overrides.effort === undefined ? {} : { effort: overrides.effort }),
558
769
  target,
559
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,
560
779
  };
561
780
 
562
- pty.onData((d) => {
563
- 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,
564
840
  });
565
841
 
566
- pty.onExit(() => {
567
- session.pty = null;
842
+ const check = adapter.planAuthCheck?.();
568
843
 
569
- // A session mid-handoff keeps its headless state; the terminal dying
570
- // is expected there, not an exit.
571
- if (session.kind === 'pty' && session.state !== 'exited') {
572
- session.state = 'exited';
573
- session.unread = this.focusedId !== session.id;
574
- 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
+ );
575
853
  }
854
+ }
576
855
 
577
- this.onEvent('state', session);
578
- this.emitChange();
579
- });
856
+ const files = Object.entries(plan.files).map(([path, content]) => ({ path, content }));
580
857
 
581
- this.sessions.push(session);
582
- void this.tryWriteFleet(session.id);
583
- this.writeStatus();
584
- this.onEvent('added', session);
858
+ if (files.length > 0) {
859
+ await provider.transferArchive(buildTarArchive(files), dir, hostKey);
860
+ }
585
861
 
586
- 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;
587
888
  }
588
889
 
589
890
  // Takes back a spawn that failed after its process started: the process
@@ -612,7 +913,7 @@ export class SessionManager {
612
913
  id: s.id,
613
914
  name: s.name,
614
915
  cwd: s.cwd,
615
- state: s.state,
916
+ state: pickSessionState(buildLifecycle(s), s.state),
616
917
  unread: s.unread,
617
918
  lastMsg: s.lastMsg,
618
919
  ...(s.lastDetail === undefined ? {} : { lastDetail: s.lastDetail }),
@@ -628,6 +929,8 @@ export class SessionManager {
628
929
  canEject: (this.findAdapter(s.agent)?.headlessRunner ?? null) !== null,
629
930
  ...(s.parent === null ? {} : { parent: s.parent }),
630
931
  locator: { daemonID: this.store.daemonID, targetID: s.target },
932
+ ...(s.workspace === undefined ? {} : { workspace: s.workspace }),
933
+ lifecycle: buildLifecycle(s),
631
934
  }));
632
935
  }
633
936
 
@@ -835,7 +1138,12 @@ export class SessionManager {
835
1138
  // archived, so the write it depends on must land before that response
836
1139
  // goes out. A kill acts on the whole set: killing a session kills its
837
1140
  // live sub-sessions with it, and forgetting a dead one forgets its dead
838
- // 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.
839
1147
  async kill(id: SessionID): Promise<void> {
840
1148
  const s = this.sessions.find((x) => x.id === id);
841
1149
 
@@ -844,28 +1152,181 @@ export class SessionManager {
844
1152
  }
845
1153
 
846
1154
  if (s.pty) {
1155
+ await this.stopHarness(s);
1156
+
847
1157
  for (const child of this.collectChildren(id)) {
848
- this.killTerminal(child);
1158
+ await this.tryStopHarness(child);
849
1159
  }
850
-
851
- this.killTerminal(s);
852
1160
  } else {
853
- for (const child of this.collectChildren(id)) {
854
- if (child.pty === null && !(child.kind === 'headless' && child.state !== 'exited')) {
855
- this.remove(child);
856
- } else {
857
- 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();
1174
+
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);
858
1190
 
859
- this.onEvent('state', child);
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);
860
1219
  }
861
1220
  }
862
1221
 
863
- this.remove(s);
1222
+ s.pty?.detach();
1223
+ s.pty = null;
1224
+ } else {
1225
+ this.killTerminal(s);
864
1226
  }
865
1227
 
1228
+ this.updateForgottenChildren(id);
1229
+ this.remove(s);
1230
+
866
1231
  await this.writeFleet();
867
1232
 
868
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);
869
1330
  }
870
1331
 
871
1332
  // Ends a live session, terminal or headless, leaving a dead entry; a
@@ -881,6 +1342,7 @@ export class SessionManager {
881
1342
 
882
1343
  s.state = 'exited';
883
1344
  s.lastMsg = 'killed';
1345
+ s.desired = 'stop';
884
1346
 
885
1347
  this.onEvent('state', s);
886
1348
  }
@@ -895,9 +1357,12 @@ export class SessionManager {
895
1357
  this.onEvent('removed', s);
896
1358
  }
897
1359
 
898
- 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() {
899
1364
  for (const s of this.sessions) {
900
- s.pty?.kill();
1365
+ s.pty?.detach();
901
1366
  }
902
1367
  }
903
1368
 
@@ -946,6 +1411,8 @@ export class SessionManager {
946
1411
  ...(s.effort === undefined ? {} : { effort: s.effort }),
947
1412
  target: s.target,
948
1413
  targetIdentity: s.targetIdentity,
1414
+ ...(s.desired === 'run' ? {} : { desired: s.desired }),
1415
+ ...(s.hostKey === s.id ? {} : { hostKey: s.hostKey }),
949
1416
  });
950
1417
  }
951
1418
 
@@ -1089,6 +1556,52 @@ export function sortGroupedSessionViews<
1089
1556
  return [...buckets.values()].flat();
1090
1557
  }
1091
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
+
1092
1605
  // A target refusal as a session's last message, short enough for a list row.
1093
1606
  function formatTargetRefusal(code: ErrorCode, target: string): string {
1094
1607
  if (code === 'target_config_invalid') {