@zgeoff/atc 2.33.0 → 2.35.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.
@@ -1,6 +1,8 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { DaemonError } from '../protocol/daemon-error';
3
+ import type { DaemonFeature } from '../protocol/daemon-features';
3
4
  import type { AgentID } from '../shared/agent-id';
5
+ import { buildWorkspaceDestination } from '../shared/build-workspace-destination';
4
6
  import type { WorkspacesConfig } from '../shared/collect-workspaces-config';
5
7
  import { collectZoxideDirs } from '../shared/collect-zoxide-dirs';
6
8
  import { loadConfig } from '../shared/config';
@@ -8,7 +10,6 @@ import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
8
10
  import { isGitURL } from '../shared/is-git-url';
9
11
  import { isRecord } from '../shared/report';
10
12
  import { resolveHomeDir } from '../shared/resolve-home-dir';
11
- import { buildWorkspaceDestination } from './build-workspace-destination';
12
13
  import { collectAgentPicks } from './collect-agent-picks';
13
14
  import type { AgentPick } from './collect-agent-picks';
14
15
  import { collectPathCompletions } from './collect-path-completions';
@@ -48,6 +49,7 @@ export interface SpawnPickerDeps<TMirror> {
48
49
  readonly attach: (sessionID: string) => Promise<void>;
49
50
  readonly toMirrorSession: (value: unknown) => TMirror | null;
50
51
  readonly upsertMirror: (session: Readonly<TMirror>) => void;
52
+ readonly hasDaemonFeature: (feature: DaemonFeature) => boolean;
51
53
  }
52
54
 
53
55
  // One source `agents.list` returned, in the order the daemon offers them.
@@ -100,6 +102,9 @@ interface ChosenRef {
100
102
  readonly sha: string;
101
103
  }
102
104
 
105
+ // The root the daemon picks a destination under on a remote target without
106
+ // a configured one, as the confirm screen shows it.
107
+ const AUTO_ROOT = '~/.local/share/atc/workspaces';
103
108
  const FULL_SHA_PATTERN = /^(?:[\da-f]{40}|[\da-f]{64})$/u;
104
109
  const SHORT_SHA_PATTERN = /^[\da-f]{7,63}$/u;
105
110
 
@@ -236,6 +241,11 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
236
241
  // The repository the destination was built or typed for.
237
242
  private destinationRepoURL: string | null = null;
238
243
 
244
+ // The directory the daemon picks on the target when the confirm screen
245
+ // has no destination it can build, under the target user's home as the
246
+ // screen shows it, or null when the spawn gives its own.
247
+ private autoDestination: string | null = null;
248
+
239
249
  private name = '';
240
250
 
241
251
  private resume = false;
@@ -291,6 +301,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
291
301
  this.destination = '';
292
302
  this.destinationEdited = false;
293
303
  this.destinationRepoURL = null;
304
+ this.autoDestination = null;
294
305
 
295
306
  // A last-used agent that is no longer installed is not in the menu, so
296
307
  // the selection falls to the first one that is.
@@ -462,7 +473,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
462
473
  items: [],
463
474
  selected: -1,
464
475
  input: this.input,
465
- placeholder: formatDirName(this.dir),
476
+ placeholder: formatDirName(this.autoDestination ?? this.dir),
466
477
  hint: this.refusal ?? `session name for ${where} · ⏎ accept · esc back`,
467
478
  });
468
479
  } else {
@@ -607,7 +618,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
607
618
  ],
608
619
  selected: -1,
609
620
  input: this.input,
610
- placeholder: 'destination on the target',
621
+ placeholder:
622
+ this.autoDestination === null
623
+ ? 'destination on the target'
624
+ : 'picked on the target · or type an absolute path',
611
625
  hint: this.refusal ?? 'destination on the target · ⏎ continue · esc back',
612
626
  });
613
627
  }
@@ -1584,6 +1598,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1584
1598
  private openConfirmStep() {
1585
1599
  this.step = 'confirm';
1586
1600
  this.destinationAttempt = 1;
1601
+ this.autoDestination = null;
1587
1602
 
1588
1603
  if (this.destination === '' && this.repo !== null && this.ref !== null) {
1589
1604
  const target = this.findEffectiveTarget();
@@ -1602,6 +1617,13 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1602
1617
  ref: this.ref.ref,
1603
1618
  sha: this.ref.sha,
1604
1619
  });
1620
+ } else if (target !== null && !target.inPlace && this.canPickOnTarget(target.id)) {
1621
+ this.autoDestination = buildWorkspaceDestination({
1622
+ root: this.workspaces.targetRoots.get(target.id) ?? this.workspaces.root ?? AUTO_ROOT,
1623
+ url: this.repo.url,
1624
+ ref: this.ref.ref,
1625
+ sha: this.ref.sha,
1626
+ });
1605
1627
  } else {
1606
1628
  this.refusal = root.message;
1607
1629
  }
@@ -1610,10 +1632,30 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1610
1632
  this.input = this.buildDestination();
1611
1633
  }
1612
1634
 
1635
+ // Whether the daemon picks the destination on a remote target: it must
1636
+ // take a git workspace without a directory, and the target's configured
1637
+ // root, if any, must be one it resolves there, under the home.
1638
+ private canPickOnTarget(target: string): boolean {
1639
+ const configured = this.workspaces.targetRoots.get(target) ?? this.workspaces.root;
1640
+
1641
+ return (
1642
+ this.deps.hasDaemonFeature('spawn.workspace.autoDir') &&
1643
+ (configured === null || configured === '~' || configured.startsWith('~/'))
1644
+ );
1645
+ }
1646
+
1613
1647
  // Takes the confirm screen's destination: absolute on its target, with a
1614
1648
  // leading `~` expanded only on the daemon's own machine. False, with the
1615
1649
  // reason shown, when the target cannot take it.
1616
1650
  private applyDestination(typed: string): boolean {
1651
+ // An empty destination takes the one the daemon picks on the target.
1652
+ if (typed === '' && this.autoDestination !== null) {
1653
+ this.destinationRepoURL = this.repo?.url ?? null;
1654
+ this.dir = '';
1655
+
1656
+ return true;
1657
+ }
1658
+
1617
1659
  const inPlace = this.findEffectiveTarget()?.inPlace ?? true;
1618
1660
 
1619
1661
  const dir =
@@ -1635,6 +1677,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1635
1677
  this.destinationEdited = true;
1636
1678
  }
1637
1679
 
1680
+ this.autoDestination = null;
1638
1681
  this.destinationRepoURL = this.repo?.url ?? null;
1639
1682
  this.dir = this.buildDestination();
1640
1683
 
@@ -1649,7 +1692,11 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1649
1692
  }
1650
1693
 
1651
1694
  private formatDestination(): string {
1652
- return `${this.findEffectiveTarget()?.id ?? 'local'}:${this.buildDestination()}`;
1695
+ const target = this.findEffectiveTarget()?.id ?? 'local';
1696
+
1697
+ return this.autoDestination === null
1698
+ ? `${target}:${this.buildDestination()}`
1699
+ : `${target}:${this.autoDestination}`;
1653
1700
  }
1654
1701
 
1655
1702
  // The target the spawn runs on: the chosen one, else the daemon's
@@ -1792,7 +1839,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1792
1839
 
1793
1840
  private async spawn(prompt: string) {
1794
1841
  const params = {
1795
- cwd: this.dir,
1842
+ ...(this.isGitFlow() && this.autoDestination !== null ? {} : { cwd: this.dir }),
1796
1843
  name: this.name,
1797
1844
  prompt,
1798
1845
  cols: cols(),
@@ -1885,7 +1932,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1885
1932
  return;
1886
1933
  }
1887
1934
 
1888
- if (step === 'destination') {
1935
+ if (step === 'destination' && this.autoDestination !== null) {
1936
+ // The daemon already tried the numbered directories beside its pick.
1937
+ this.step = 'confirm';
1938
+ this.input = '';
1939
+ this.refusal = `${reason} · esc back`;
1940
+ } else if (step === 'destination') {
1889
1941
  this.destinationAttempt += 1;
1890
1942
  this.step = 'confirm';
1891
1943
  this.input = this.buildDestination();
@@ -0,0 +1,70 @@
1
+ import { posix } from 'node:path';
2
+ import { DaemonError } from '../protocol/daemon-error';
3
+ import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
4
+ import { buildWorkspaceDestination } from '../shared/build-workspace-destination';
5
+
6
+ type GitWorkspaceSource = Extract<SpawnWorkspaceSource, { kind: 'git' }>;
7
+
8
+ interface DefaultDirTarget {
9
+ // The workspaces root the config sets for the target, else the global
10
+ // one, else null for the default.
11
+ readonly root: string | null;
12
+
13
+ // Whether the target runs on another machine, whose home only that
14
+ // machine knows.
15
+ readonly remote: boolean;
16
+
17
+ // The home directory of the daemon's own user.
18
+ readonly home: string;
19
+ }
20
+
21
+ // Where checkouts land without a configured root, under the target user's
22
+ // home.
23
+ const DEFAULT_ROOT = '~/.local/share/atc/workspaces';
24
+
25
+ /**
26
+ * The directory a git workspace lands in when the spawn gives none:
27
+ * `<root>/<repo>-<ref>-<short sha>`, with the root the config sets, else
28
+ * `~/.local/share/atc/workspaces`. A root under `~` resolves on the
29
+ * target: against the daemon user's home on the daemon's own machine, and
30
+ * on a remote target as a path relative to the target user's home, which
31
+ * the host resolves, since only that host knows its home. Any other root
32
+ * must be absolute.
33
+ */
34
+ export function buildDefaultWorkspaceDir(
35
+ source: GitWorkspaceSource,
36
+ target: DefaultDirTarget,
37
+ ): string {
38
+ const configured = target.root ?? DEFAULT_ROOT;
39
+
40
+ const parts = {
41
+ url: source.url,
42
+ ref: source.ref ?? null,
43
+ sha: source.sha ?? null,
44
+ };
45
+
46
+ if (configured.startsWith('/')) {
47
+ return buildWorkspaceDestination({ root: posix.normalize(configured), ...parts });
48
+ }
49
+
50
+ if (configured !== '~' && !configured.startsWith('~/')) {
51
+ throw new DaemonError(
52
+ 'bad_args',
53
+ `workspaces root '${configured}' must be an absolute path or start with ~/`,
54
+ );
55
+ }
56
+
57
+ const normalized = posix.normalize(`./${configured.slice(1)}`);
58
+ const underHome = normalized === './' || normalized === '.' ? '' : normalized;
59
+
60
+ if (underHome === '..' || underHome.startsWith('../')) {
61
+ throw new DaemonError(
62
+ 'bad_args',
63
+ `workspaces root '${configured}' must stay under the home directory`,
64
+ );
65
+ }
66
+
67
+ return target.remote
68
+ ? buildWorkspaceDestination({ root: underHome, ...parts })
69
+ : buildWorkspaceDestination({ root: posix.join(target.home, underHome), ...parts });
70
+ }
@@ -12,8 +12,9 @@ const BLOCK = 512;
12
12
 
13
13
  /**
14
14
  * Packs files into a POSIX ustar archive that `tar -x` unpacks, each with
15
- * its parent directories created on the way. A path longer than ustar
16
- * holds is refused.
15
+ * its parent directories created on the way. A path longer than 100 bytes
16
+ * splits at a slash into ustar's prefix and name fields, and a path that
17
+ * fits no such split is refused.
17
18
  */
18
19
  // oxlint-disable-next-line prefer-readonly-parameter-types -- file bytes have no readonly form
19
20
  export function buildTarArchive(files: readonly ArchiveFile[]): Uint8Array {
@@ -50,15 +51,11 @@ export function buildTarArchive(files: readonly ArchiveFile[]): Uint8Array {
50
51
  function buildHeader(path: string, size: number, mode: number): Uint8Array {
51
52
  const encoder = new TextEncoder();
52
53
 
53
- const name = encoder.encode(path);
54
-
55
- if (name.length > 100) {
56
- throw new Error(`archive path is longer than 100 bytes: ${path}`);
57
- }
54
+ const split = splitArchivePath(path);
58
55
 
59
56
  const header = new Uint8Array(BLOCK);
60
57
 
61
- header.set(name, 0);
58
+ header.set(encoder.encode(split.name), 0);
62
59
  header.set(encoder.encode(formatOctal(mode, 8)), 100);
63
60
  header.set(encoder.encode(formatOctal(0, 8)), 108);
64
61
  header.set(encoder.encode(formatOctal(0, 8)), 116);
@@ -71,6 +68,7 @@ function buildHeader(path: string, size: number, mode: number): Uint8Array {
71
68
  header[156] = '0'.codePointAt(0) ?? 0;
72
69
 
73
70
  header.set(encoder.encode('ustar\u000000'), 257);
71
+ header.set(encoder.encode(split.prefix), 345);
74
72
 
75
73
  const checksum = header.reduce((total, byte) => total + byte, 0);
76
74
 
@@ -79,6 +77,33 @@ function buildHeader(path: string, size: number, mode: number): Uint8Array {
79
77
  return header;
80
78
  }
81
79
 
80
+ const NAME_BYTES = 100;
81
+ const PREFIX_BYTES = 155;
82
+
83
+ // The ustar fields a path fills: the whole path as the name when it fits,
84
+ // otherwise the part before the last slash that leaves a name short enough,
85
+ // with that slash dropped, as the prefix.
86
+ function splitArchivePath(path: string): { readonly prefix: string; readonly name: string } {
87
+ if (countBytes(path) <= NAME_BYTES) {
88
+ return { prefix: '', name: path };
89
+ }
90
+
91
+ for (let at = path.indexOf('/'); at !== -1; at = path.indexOf('/', at + 1)) {
92
+ const prefix = path.slice(0, at);
93
+ const name = path.slice(at + 1);
94
+
95
+ if (countBytes(prefix) <= PREFIX_BYTES && countBytes(name) <= NAME_BYTES && name !== '') {
96
+ return { prefix, name };
97
+ }
98
+ }
99
+
100
+ throw new Error(`archive path does not fit a ustar header: ${path}`);
101
+ }
102
+
103
+ function countBytes(text: string): number {
104
+ return new TextEncoder().encode(text).length;
105
+ }
106
+
82
107
  // An octal number padded to fill its field, ending in a NUL.
83
108
  function formatOctal(value: number, width: number): string {
84
109
  return `${value.toString(8).padStart(width - 1, '0')}\u0000`;
@@ -926,6 +926,13 @@ export class DaemonConnection {
926
926
  }
927
927
 
928
928
  const data = parsed.data;
929
+ const cwd = data.cwd;
930
+
931
+ if (cwd === undefined && data.workspace?.kind !== 'git') {
932
+ this.sendErr(req.id, 'bad_args', 'session.spawn requires a cwd');
933
+
934
+ return;
935
+ }
929
936
 
930
937
  // Every refusal is thrown from the plan, which runs only once a key is
931
938
  // claimed: a retry of a held key answers from the key without checking
@@ -972,16 +979,28 @@ export class DaemonConnection {
972
979
  ctx.requireWorkspaceTarget(target);
973
980
  }
974
981
 
982
+ // A git workspace without a cwd lands where the daemon picks on the
983
+ // target.
984
+ const dir =
985
+ cwd === undefined && data.workspace?.kind === 'git'
986
+ ? ctx.buildDefaultWorkspaceDir(target, data.workspace)
987
+ : cwd;
988
+
989
+ if (dir === undefined) {
990
+ throw new DaemonError('bad_args', 'session.spawn requires a cwd');
991
+ }
992
+
975
993
  // A workspace is materialized at cwd on the target, which only an
976
- // absolute path names.
977
- if (data.workspace !== undefined && !isAbsolute(data.cwd)) {
994
+ // absolute path names; a directory the daemon picks on a remote
995
+ // target is relative to the home there, which the host resolves.
996
+ if (data.workspace !== undefined && cwd !== undefined && !isAbsolute(cwd)) {
978
997
  throw new DaemonError('bad_args', 'a spawn with a workspace requires an absolute cwd');
979
998
  }
980
999
 
981
1000
  // A workspace is created, filled, and removed at the one directory
982
1001
  // the host resolves cwd to, which a dot segment or a control
983
1002
  // character would make differ from the path as written.
984
- if (data.workspace !== undefined && !isPlainWorkspaceDir(data.cwd)) {
1003
+ if (data.workspace !== undefined && !isPlainWorkspaceDir(dir)) {
985
1004
  throw new DaemonError(
986
1005
  'bad_args',
987
1006
  'a spawn with a workspace requires a cwd without . or .. segments or control characters',
@@ -1001,8 +1020,8 @@ export class DaemonConnection {
1001
1020
  }
1002
1021
 
1003
1022
  return {
1004
- cwd: data.cwd,
1005
- name: data.name === '' ? basename(data.cwd) : data.name,
1023
+ cwd: dir,
1024
+ name: data.name === '' ? basename(dir) : data.name,
1006
1025
  prompt: data.prompt,
1007
1026
  cols: data.cols,
1008
1027
  rows: data.rows,
@@ -1015,9 +1034,13 @@ export class DaemonConnection {
1015
1034
  ...(data.trustClonedWorkspace === undefined
1016
1035
  ? {}
1017
1036
  : { trustClonedWorkspace: data.trustClonedWorkspace }),
1037
+ ...(data.resumeInterruptedTurns === undefined
1038
+ ? {}
1039
+ : { resumeInterruptedTurns: data.resumeInterruptedTurns }),
1018
1040
  },
1019
1041
  target,
1020
1042
  workspace: data.workspace ?? null,
1043
+ autoDir: cwd === undefined,
1021
1044
  };
1022
1045
  };
1023
1046
 
@@ -39,6 +39,11 @@ export interface SpawnParams {
39
39
  // Where the session's working directory comes from; null runs the
40
40
  // session in cwd as it stands.
41
41
  readonly workspace: SpawnWorkspaceSource | null;
42
+
43
+ // Whether the daemon picked cwd for a git workspace the spawn gave no
44
+ // directory for, so a held directory moves the spawn to a numbered one
45
+ // beside it.
46
+ readonly autoDir: boolean;
42
47
  }
43
48
 
44
49
  interface SessionRecord {
@@ -180,6 +185,14 @@ export interface DaemonContext {
180
185
  // one whose provider cannot both transfer an archive and run a command.
181
186
  readonly requireWorkspaceTarget: (target: string) => void;
182
187
 
188
+ // The directory a git workspace lands in on a target when the spawn
189
+ // gives none: absolute on the daemon's own machine, and relative to the
190
+ // target user's home on a remote target.
191
+ readonly buildDefaultWorkspaceDir: (
192
+ target: string,
193
+ source: Extract<SpawnWorkspaceSource, { kind: 'git' }>,
194
+ ) => string;
195
+
183
196
  // Throws the refusal for an agent that takes its credential from impd's
184
197
  // broker on a target whose provider reaches no broker.
185
198
  readonly requireAgentTarget: (agent: AgentID, target: string) => void;
@@ -18,6 +18,7 @@ import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
18
18
  import { findDaemonRecord } from '../shared/find-daemon-record';
19
19
  import type { MessageID } from '../shared/message-id';
20
20
  import { isRecord } from '../shared/report';
21
+ import { resolveHomeDir } from '../shared/resolve-home-dir';
21
22
  import type { SessionID } from '../shared/session-id';
22
23
  import { toMessageID } from '../shared/to-message-id';
23
24
  import { truncateToBytes } from '../shared/truncate-to-bytes';
@@ -34,6 +35,7 @@ import { ANSWER_BYTE_CAP } from './answer-byte-cap';
34
35
  import { AttachRegistry } from './attach-registry';
35
36
  import { buildAgentList } from './build-agent-list';
36
37
  import { buildConfigRevision } from './build-config-revision';
38
+ import { buildDefaultWorkspaceDir } from './build-default-workspace-dir';
37
39
  import { buildExecutionTargets } from './build-execution-targets';
38
40
  import type { ExecutionTarget } from './build-execution-targets';
39
41
  import { buildFleetEvents } from './build-fleet-events';
@@ -155,6 +157,11 @@ export interface DaemonOptions {
155
157
  readonly restoreBootTimeoutMs?: number;
156
158
  readonly tapGraceMs?: number;
157
159
 
160
+ // Whether a fleet restore sends a session that was mid-turn when the
161
+ // previous daemon stopped one message to carry on, for a session spawned
162
+ // without its own choice; off when unset.
163
+ readonly resumeInterruptedTurns?: boolean;
164
+
158
165
  // How long a confirm token from `session.forget` stays usable.
159
166
  readonly forgetConfirmMs?: number;
160
167
 
@@ -178,6 +185,14 @@ export interface DaemonOptions {
178
185
  // or the invalid list the config holds, which refuses every git
179
186
  // operation; https and ssh when unset.
180
187
  readonly gitTransports?: readonly string[] | InvalidGitTransports;
188
+
189
+ // The root a git workspace without a directory lands under on any target
190
+ // without its own, and each target's own root, by target id; the default
191
+ // root under the target user's home when unset.
192
+ readonly workspaceRoots?: Readonly<{
193
+ root: string | null;
194
+ targetRoots: ReadonlyMap<string, string>;
195
+ }>;
181
196
  }
182
197
 
183
198
  // The TCP listener's address and the file holding the tokens a handshake
@@ -271,6 +286,11 @@ const LOCK_WAIT_MS = 2000;
271
286
  * because transports guarantee byte integrity and such a line means a buggy
272
287
  * or hostile peer.
273
288
  */
289
+ // The sender and opening of the message a restore sends a session whose
290
+ // turn the previous daemon's stop cut off.
291
+ const RESUME_SENDER = 'atc';
292
+ const RESUME_LEAD = 'atc restarted the daemon';
293
+
274
294
  export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
275
295
  let stopDaemon: (() => Promise<void>) | null = null;
276
296
 
@@ -962,7 +982,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
962
982
  ? null
963
983
  : async (
964
984
  host: Readonly<{
965
- readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
985
+ readyHost: (
986
+ attempt: number,
987
+ ) => Promise<{ readonly host: SessionID; readonly dir: string }>;
966
988
  removeClaim: (dir: string) => Promise<boolean>;
967
989
  }>,
968
990
  targetIdentity: string,
@@ -1013,7 +1035,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1013
1035
  id: SessionID,
1014
1036
  source: SpawnWorkspaceSource,
1015
1037
  host: Readonly<{
1016
- readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
1038
+ readyHost: (attempt: number) => Promise<{ readonly host: SessionID; readonly dir: string }>;
1017
1039
  removeClaim: (dir: string) => Promise<boolean>;
1018
1040
  }>,
1019
1041
  targetIdentity: string,
@@ -1028,6 +1050,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1028
1050
  dir: p.cwd,
1029
1051
  source,
1030
1052
  inPlace: bound.provider.kind === 'local-pty',
1053
+ autoDir: p.autoDir,
1031
1054
  },
1032
1055
  {
1033
1056
  requireProvider: (capability) => mgr.requireExecution(binding, capability).provider,
@@ -1049,7 +1072,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1049
1072
  materialize:
1050
1073
  | ((
1051
1074
  host: Readonly<{
1052
- readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
1075
+ readyHost: (
1076
+ attempt: number,
1077
+ ) => Promise<{ readonly host: SessionID; readonly dir: string }>;
1053
1078
  removeClaim: (dir: string) => Promise<boolean>;
1054
1079
  }>,
1055
1080
  targetIdentity: string,
@@ -1075,6 +1100,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1075
1100
  p.target,
1076
1101
  materialize,
1077
1102
  requireInReach,
1103
+ p.autoDir,
1078
1104
  );
1079
1105
 
1080
1106
  const runtime = runtimes.get(s.id);
@@ -1083,7 +1109,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1083
1109
  runtime.pendingLastUsed = true;
1084
1110
  }
1085
1111
 
1086
- void store.recordSpawnDir(p.cwd, { target: s.target, targetIdentity: s.targetIdentity });
1112
+ void store.recordSpawnDir(s.cwd, { target: s.target, targetIdentity: s.targetIdentity });
1087
1113
 
1088
1114
  return getDescriptor(mgr, s.id);
1089
1115
  };
@@ -1292,6 +1318,37 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1292
1318
  return record;
1293
1319
  };
1294
1320
 
1321
+ const startedAt = new Date().toISOString();
1322
+
1323
+ // Sends a restored session the message that carries on a turn the
1324
+ // previous daemon's stop cut off. A resume message still pending from an
1325
+ // earlier restart covers this one, so a session never holds two.
1326
+ const sendResumeMessage = async (s: Session): Promise<void> => {
1327
+ const pending = await store.collectPendingMessages(buildMessageOwner(s));
1328
+
1329
+ if (
1330
+ pending.some((record) => record.from === RESUME_SENDER && record.text.startsWith(RESUME_LEAD))
1331
+ ) {
1332
+ return;
1333
+ }
1334
+
1335
+ const refusal = findMessageRefusal(s.id);
1336
+
1337
+ if (refusal !== null) {
1338
+ mgr.log(`atc sent session ${s.id} no resume message (${refusal})`);
1339
+
1340
+ return;
1341
+ }
1342
+
1343
+ await writeAcceptedMessage(
1344
+ s.id,
1345
+ RESUME_SENDER,
1346
+ `${RESUME_LEAD} at ${startedAt}; your last turn was interrupted. Check the state of anything you had in flight, then continue.`,
1347
+ mintMessageID(),
1348
+ () => {},
1349
+ );
1350
+ };
1351
+
1295
1352
  // A retried send answers with the message's current status, so a caller
1296
1353
  // that lost the first answer learns where its message stands now.
1297
1354
  const loadMessageReplay = async (
@@ -1405,6 +1462,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1405
1462
  mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
1406
1463
  mgr.requireExecution({ target, targetIdentity: null }, 'run');
1407
1464
  },
1465
+ buildDefaultWorkspaceDir: (target, source) =>
1466
+ buildDefaultWorkspaceDir(source, {
1467
+ root: opts.workspaceRoots?.targetRoots.get(target) ?? opts.workspaceRoots?.root ?? null,
1468
+ remote: mgr.requireExecution({ target, targetIdentity: null }, 'run').provider.remote,
1469
+ home: resolveHomeDir(),
1470
+ }),
1408
1471
  requireAgentTarget: (agent, target) => {
1409
1472
  mgr.requireAgentTarget(agent, target);
1410
1473
  },
@@ -1754,7 +1817,16 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1754
1817
  attachments.findEffectiveDims(sessionID) ??
1755
1818
  runtimes.get(sessionID)?.dims ?? { cols: 80, rows: 24 },
1756
1819
  restoreFleet: (cols, rows) =>
1757
- restoreFleet({ mgr, store, findRuntime, cols, rows, capMs: opts.restoreBootTimeoutMs ?? 0 }),
1820
+ restoreFleet({
1821
+ mgr,
1822
+ store,
1823
+ findRuntime,
1824
+ cols,
1825
+ rows,
1826
+ capMs: opts.restoreBootTimeoutMs ?? 0,
1827
+ resumeInterruptedTurns: opts.resumeInterruptedTurns ?? false,
1828
+ sendResumeMessage,
1829
+ }),
1758
1830
  readSessionRecord: async (id, access) => {
1759
1831
  const s = mgr.sessions.find((x) => x.id === id);
1760
1832