@zgeoff/atc 2.33.0 → 2.34.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.33.0",
3
+ "version": "2.34.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
package/src/cli.ts CHANGED
@@ -452,6 +452,7 @@ async function runDaemon(listenArg: string | null, tokenFile: string | null): Pr
452
452
  principals: cfg.principals,
453
453
  sources: sources.sources,
454
454
  gitTransports: cfg.workspaces.gitTransports,
455
+ workspaceRoots: { root: cfg.workspaces.root, targetRoots: cfg.workspaces.targetRoots },
455
456
  ...(listen === null || tokenFile === null
456
457
  ? {}
457
458
  : { listen: { host: listen.host, port: listen.port, tokenFile } }),
@@ -2,6 +2,7 @@ import { match } from 'ts-pattern';
2
2
  import { createActor } from 'xstate';
3
3
  import { countSessionStates } from '../protocol/count-session-states';
4
4
  import { DaemonError } from '../protocol/daemon-error';
5
+ import type { DaemonFeature } from '../protocol/daemon-features';
5
6
  import type { EventMsg } from '../protocol/protocol';
6
7
  import { sortGroupedSessionViews } from '../protocol/sort-grouped-session-views';
7
8
  import { sortSessionViews } from '../protocol/sort-session-views';
@@ -339,12 +340,17 @@ const picker = new SpawnPicker<MirrorSession>({
339
340
  attach,
340
341
  toMirrorSession,
341
342
  upsertMirror,
343
+ hasDaemonFeature,
342
344
  });
343
345
 
344
346
  function openPicker(resume: boolean) {
345
347
  picker.open(resume);
346
348
  }
347
349
 
350
+ function hasDaemonFeature(feature: DaemonFeature): boolean {
351
+ return daemonFeatures.has(feature);
352
+ }
353
+
348
354
  function sendRequest(m: string, p?: Readonly<Record<string, unknown>>) {
349
355
  return client.sendRequest(m, p);
350
356
  }
@@ -832,6 +838,7 @@ const boot = await bootDaemonClient({
832
838
 
833
839
  let client = boot.client;
834
840
  let daemonStale = boot.stale;
841
+ let daemonFeatures = boot.features;
835
842
  let daemonRestarting = false;
836
843
 
837
844
  lastUsedAgent = boot.lastUsedAgent;
@@ -950,6 +957,7 @@ async function restartDaemon() {
950
957
 
951
958
  client = next.client;
952
959
  daemonStale = next.stale;
960
+ daemonFeatures = next.features;
953
961
  lastUsedAgent = next.lastUsedAgent;
954
962
  client.onEvent = applyDaemonEvent;
955
963
 
@@ -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
+ }
@@ -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,
@@ -1018,6 +1037,7 @@ export class DaemonConnection {
1018
1037
  },
1019
1038
  target,
1020
1039
  workspace: data.workspace ?? null,
1040
+ autoDir: cwd === undefined,
1021
1041
  };
1022
1042
  };
1023
1043
 
@@ -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';
@@ -178,6 +180,14 @@ export interface DaemonOptions {
178
180
  // or the invalid list the config holds, which refuses every git
179
181
  // operation; https and ssh when unset.
180
182
  readonly gitTransports?: readonly string[] | InvalidGitTransports;
183
+
184
+ // The root a git workspace without a directory lands under on any target
185
+ // without its own, and each target's own root, by target id; the default
186
+ // root under the target user's home when unset.
187
+ readonly workspaceRoots?: Readonly<{
188
+ root: string | null;
189
+ targetRoots: ReadonlyMap<string, string>;
190
+ }>;
181
191
  }
182
192
 
183
193
  // The TCP listener's address and the file holding the tokens a handshake
@@ -962,7 +972,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
962
972
  ? null
963
973
  : async (
964
974
  host: Readonly<{
965
- readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
975
+ readyHost: (
976
+ attempt: number,
977
+ ) => Promise<{ readonly host: SessionID; readonly dir: string }>;
966
978
  removeClaim: (dir: string) => Promise<boolean>;
967
979
  }>,
968
980
  targetIdentity: string,
@@ -1013,7 +1025,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1013
1025
  id: SessionID,
1014
1026
  source: SpawnWorkspaceSource,
1015
1027
  host: Readonly<{
1016
- readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
1028
+ readyHost: (attempt: number) => Promise<{ readonly host: SessionID; readonly dir: string }>;
1017
1029
  removeClaim: (dir: string) => Promise<boolean>;
1018
1030
  }>,
1019
1031
  targetIdentity: string,
@@ -1028,6 +1040,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1028
1040
  dir: p.cwd,
1029
1041
  source,
1030
1042
  inPlace: bound.provider.kind === 'local-pty',
1043
+ autoDir: p.autoDir,
1031
1044
  },
1032
1045
  {
1033
1046
  requireProvider: (capability) => mgr.requireExecution(binding, capability).provider,
@@ -1049,7 +1062,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1049
1062
  materialize:
1050
1063
  | ((
1051
1064
  host: Readonly<{
1052
- readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
1065
+ readyHost: (
1066
+ attempt: number,
1067
+ ) => Promise<{ readonly host: SessionID; readonly dir: string }>;
1053
1068
  removeClaim: (dir: string) => Promise<boolean>;
1054
1069
  }>,
1055
1070
  targetIdentity: string,
@@ -1075,6 +1090,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1075
1090
  p.target,
1076
1091
  materialize,
1077
1092
  requireInReach,
1093
+ p.autoDir,
1078
1094
  );
1079
1095
 
1080
1096
  const runtime = runtimes.get(s.id);
@@ -1083,7 +1099,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1083
1099
  runtime.pendingLastUsed = true;
1084
1100
  }
1085
1101
 
1086
- void store.recordSpawnDir(p.cwd, { target: s.target, targetIdentity: s.targetIdentity });
1102
+ void store.recordSpawnDir(s.cwd, { target: s.target, targetIdentity: s.targetIdentity });
1087
1103
 
1088
1104
  return getDescriptor(mgr, s.id);
1089
1105
  };
@@ -1405,6 +1421,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1405
1421
  mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
1406
1422
  mgr.requireExecution({ target, targetIdentity: null }, 'run');
1407
1423
  },
1424
+ buildDefaultWorkspaceDir: (target, source) =>
1425
+ buildDefaultWorkspaceDir(source, {
1426
+ root: opts.workspaceRoots?.targetRoots.get(target) ?? opts.workspaceRoots?.root ?? null,
1427
+ remote: mgr.requireExecution({ target, targetIdentity: null }, 'run').provider.remote,
1428
+ home: resolveHomeDir(),
1429
+ }),
1408
1430
  requireAgentTarget: (agent, target) => {
1409
1431
  mgr.requireAgentTarget(agent, target);
1410
1432
  },
@@ -32,6 +32,11 @@ interface MaterializeRequest {
32
32
  // Whether the target is the daemon's own host, where a directory outside
33
33
  // any git work tree runs in place instead of being materialized.
34
34
  readonly inPlace: boolean;
35
+
36
+ // Whether the daemon picked the directory, so one that exists or that
37
+ // another workspace holds moves the claim to the next attempt's
38
+ // directory instead of refusing the spawn.
39
+ readonly autoDir?: boolean;
35
40
  }
36
41
 
37
42
  // The host a workspace lands on, and the directory there it is built in.
@@ -48,8 +53,9 @@ interface MaterializeDeps {
48
53
  readonly log: (line: string) => void;
49
54
 
50
55
  // Readies the host on the target the workspace lands on and resolves to
51
- // it, once the source has resolved.
52
- readonly readyHost: () => Promise<Landing>;
56
+ // it, once the source has resolved, with the directory of an attempt: the
57
+ // first is the request's directory, and attempt n appends `-n` to it.
58
+ readonly readyHost: (attempt: number) => Promise<Landing>;
53
59
 
54
60
  // Removes the directory this call claimed after a failure, and resolves
55
61
  // to whether it did; one it leaves holds what another session needs, or
@@ -254,12 +260,13 @@ async function runMaterialization(
254
260
  const repoURL = secret === null ? pinned.repoURL : toRedacted(pinned.repoURL, secret);
255
261
  const ref = secret === null || pinned.ref === null ? pinned.ref : toRedacted(pinned.ref, secret);
256
262
 
257
- const landing = await deps.readyHost();
258
-
259
- updateProgress({ landing });
263
+ const landing = await claimLanding(request, deps, updateProgress);
260
264
 
261
- await claimTargetDir(request, deps, landing, updateProgress);
262
- await recordPhase(request, deps, updateProgress, 'cloning', { repoURL, ref });
265
+ await recordPhase(request, deps, updateProgress, 'cloning', {
266
+ repoURL,
267
+ ref,
268
+ ...(request.autoDir === true ? { dir: landing.dir } : {}),
269
+ });
263
270
 
264
271
  const clone = await createCleanClone(pinned, join(staging, 'clone'), transports);
265
272
 
@@ -401,6 +408,45 @@ async function requireNoURLCredentials(url: string, cwd: string): Promise<void>
401
408
  }
402
409
  }
403
410
 
411
+ // How many directories a spawn whose directory the daemon picked tries
412
+ // before it refuses.
413
+ const AUTO_DIR_ATTEMPTS = 100;
414
+
415
+ /**
416
+ * Readies the host and claims the directory the workspace lands in. A
417
+ * directory the daemon picked moves to the next attempt while the one
418
+ * before exists or another workspace holds it, so repeated and concurrent
419
+ * spawns of one repository land side by side; a directory the caller gave
420
+ * is claimed once.
421
+ */
422
+ async function claimLanding(
423
+ request: MaterializeRequest,
424
+ deps: MaterializeDeps,
425
+ updateProgress: ProgressTracker,
426
+ ): Promise<Landing> {
427
+ const attempts = request.autoDir === true ? AUTO_DIR_ATTEMPTS : 1;
428
+
429
+ for (let attempt = 1; ; attempt += 1) {
430
+ try {
431
+ const landing = await deps.readyHost(attempt);
432
+
433
+ updateProgress({ landing });
434
+
435
+ await claimTargetDir(request, deps, landing, updateProgress);
436
+
437
+ return landing;
438
+ } catch (error) {
439
+ const held =
440
+ error instanceof DaemonError &&
441
+ (error.code === 'workspace_exists' || error.code === 'workspace_overlap');
442
+
443
+ if (!held || attempt >= attempts) {
444
+ throw error;
445
+ }
446
+ }
447
+ }
448
+ }
449
+
404
450
  /**
405
451
  * Creates the target directory as the claim on it: `mkdir` without `-p`
406
452
  * fails when the directory exists, so a materialization never unpacks over
@@ -412,6 +458,9 @@ async function claimTargetDir(
412
458
  landing: Landing,
413
459
  updateProgress: ProgressTracker,
414
460
  ): Promise<void> {
461
+ // A directory the daemon picked is reported as it landed.
462
+ const shown = request.autoDir === true ? landing.dir : request.dir;
463
+
415
464
  const parent = await deps.requireProvider('run').runCommand({
416
465
  argv: ['mkdir', '-p', '--', dirname(landing.dir)],
417
466
  cwd: '/',
@@ -421,8 +470,8 @@ async function claimTargetDir(
421
470
  if (parent.exitCode !== 0) {
422
471
  throw new DaemonError(
423
472
  'transfer_failed',
424
- `cannot create ${dirname(request.dir)} on target '${request.target}': ${parent.stderr.trim()}`,
425
- { phase: 'resolving', dir: request.dir },
473
+ `cannot create ${dirname(shown)} on target '${request.target}': ${parent.stderr.trim()}`,
474
+ { phase: 'resolving', dir: shown },
426
475
  );
427
476
  }
428
477
 
@@ -430,23 +479,46 @@ async function claimTargetDir(
430
479
  .requireProvider('run')
431
480
  .runCommand({ argv: ['mkdir', '--', landing.dir], cwd: '/', host: landing.host });
432
481
 
482
+ // A directory that is not there failed for another reason, such as a
483
+ // parent the command cannot write, which no other attempt would fix.
484
+ if (claim.exitCode !== 0 && !(await isTargetPathPresent(deps, landing))) {
485
+ throw new DaemonError(
486
+ 'transfer_failed',
487
+ `cannot create ${shown} on target '${request.target}': ${claim.stderr.trim()}`,
488
+ { phase: 'resolving', dir: shown },
489
+ );
490
+ }
491
+
433
492
  if (claim.exitCode !== 0) {
434
493
  throw new DaemonError(
435
494
  'workspace_exists',
436
- `${request.dir} already exists on target '${request.target}'; a workspace is materialized only into a directory that does not exist`,
437
- { phase: 'resolving', dir: request.dir },
495
+ `${shown} already exists on target '${request.target}'; a workspace is materialized only into a directory that does not exist`,
496
+ { phase: 'resolving', dir: shown },
438
497
  );
439
498
  }
440
499
 
441
500
  updateProgress({ claimed: true });
442
501
  }
443
502
 
503
+ // Whether anything, a dangling symlink included, stands at the landing
504
+ // directory's path on its host.
505
+ async function isTargetPathPresent(deps: MaterializeDeps, landing: Landing): Promise<boolean> {
506
+ const probe = await deps.requireProvider('run').runCommand({
507
+ argv: ['sh', '-c', '[ -e "$1" ] || [ -L "$1" ]', 'sh', landing.dir],
508
+ cwd: '/',
509
+ host: landing.host,
510
+ });
511
+
512
+ return probe.exitCode === 0;
513
+ }
514
+
444
515
  async function recordPhase(
445
516
  request: MaterializeRequest,
446
517
  deps: MaterializeDeps,
447
518
  updateProgress: ProgressTracker,
448
519
  phase: MaterializationPhase,
449
520
  fields: Readonly<{
521
+ dir?: string;
450
522
  repoURL?: string;
451
523
  sha?: string;
452
524
  ref?: string | null;
@@ -194,14 +194,18 @@ interface MaterializedSpawn {
194
194
  // Builds a spawn's workspace on a target bound to an identity, calling
195
195
  // readyHost for the host it lands on once its source resolves and asking
196
196
  // canRemoveClaim before a failure removes the directory it claimed, or
197
- // resolves to null for a directory that runs as it stands.
197
+ // resolves to null for a directory that runs as it stands. readyHost takes
198
+ // the attempt the directory is for: the first is the spawn's directory, and
199
+ // attempt n is that directory with `-n` appended.
198
200
  type SpawnMaterializer = (
199
201
  host: SpawnHostAccess,
200
202
  targetIdentity: string,
201
203
  ) => Promise<MaterializedSpawn | null>;
202
204
 
203
205
  interface SpawnHostAccess {
204
- readonly readyHost: () => Promise<{ readonly host: SessionID; readonly dir: string }>;
206
+ readonly readyHost: (
207
+ attempt: number,
208
+ ) => Promise<{ readonly host: SessionID; readonly dir: string }>;
205
209
  readonly removeClaim: (dir: string) => Promise<boolean>;
206
210
  }
207
211
 
@@ -210,7 +214,7 @@ interface SpawnHostAccess {
210
214
  interface WorkspaceReservation {
211
215
  readonly hostKey: SessionID;
212
216
  readonly target: string;
213
- readonly dir: string;
217
+ dir: string;
214
218
  resolved: string | null;
215
219
 
216
220
  // A workspace spawn builds its directory and may remove it on a failure;
@@ -905,6 +909,7 @@ export class SessionManager {
905
909
  target = 'local',
906
910
  materialize: SpawnMaterializer | null = null,
907
911
  requireInReach: () => void = () => {},
912
+ autoDir = false,
908
913
  ): Promise<Session> {
909
914
  const adapter = this.findAdapter(agent);
910
915
 
@@ -967,9 +972,14 @@ export class SessionManager {
967
972
  throw refusal;
968
973
  }
969
974
 
975
+ // A directory the daemon picked is checked on the host once per
976
+ // attempt, so a held one moves the spawn to the next attempt instead of
977
+ // refusing it.
970
978
  if (hostKey !== id) {
971
979
  if (materialize === null) {
972
980
  this.claimPlainDir(id, hostKey, target, cwd);
981
+ } else if (autoDir) {
982
+ this.reservations.set(id, { hostKey, target, dir: cwd, resolved: null, kind: 'workspace' });
973
983
  } else {
974
984
  this.claimWorkspace(id, hostKey, target, cwd);
975
985
  }
@@ -1041,6 +1051,9 @@ export class SessionManager {
1041
1051
  );
1042
1052
  });
1043
1053
 
1054
+ // A spawn whose directory the daemon picked runs in the one it claimed,
1055
+ // as the host resolves it.
1056
+ const dir = autoDir && prepared.root !== null ? prepared.root : cwd;
1044
1057
  const setup = prepared.setup;
1045
1058
  const materialized = prepared.materialized;
1046
1059
  const readied: SpawnReadied = { attemptID: setup.attemptID, root: prepared.root };
@@ -1059,7 +1072,7 @@ export class SessionManager {
1059
1072
  host: hostKey,
1060
1073
  bin: plan.bin,
1061
1074
  args: plan.args,
1062
- cwd,
1075
+ cwd: dir,
1063
1076
  env: { ...plan.env, ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
1064
1077
  withheldEnv: materialized?.withheldEnv ?? [],
1065
1078
  cols,
@@ -1084,8 +1097,8 @@ export class SessionManager {
1084
1097
 
1085
1098
  const session: Session = {
1086
1099
  id,
1087
- name,
1088
- cwd,
1100
+ name: namedBy === 'auto' && dir !== cwd ? posix.basename(dir) : name,
1101
+ cwd: dir,
1089
1102
  kind: 'pty',
1090
1103
  pty,
1091
1104
  state: 'running',
@@ -1095,7 +1108,7 @@ export class SessionManager {
1095
1108
  agent,
1096
1109
  pinned: false,
1097
1110
  lastAttachedAt: Date.now(),
1098
- repoRoot,
1111
+ repoRoot: dir === cwd ? repoRoot : dir,
1099
1112
  namedBy,
1100
1113
  createdAt: Date.now(),
1101
1114
  parent,
@@ -1306,6 +1319,7 @@ export class SessionManager {
1306
1319
  if (reservation !== undefined) {
1307
1320
  this.requireSeparateWorkspace(id, hostKey, target, dir, [dir, physical], listed.dirs);
1308
1321
 
1322
+ reservation.dir = dir;
1309
1323
  reservation.resolved = physical;
1310
1324
  }
1311
1325
 
@@ -1535,6 +1549,28 @@ export class SessionManager {
1535
1549
  return existing === '/' ? rest || '/' : `${existing}${rest}`;
1536
1550
  }
1537
1551
 
1552
+ // The home directory of the user commands run as on a host, with every
1553
+ // symlink in it resolved.
1554
+ private async resolveHostHome(provider: ExecutionProvider, hostKey: SessionID): Promise<string> {
1555
+ const result = await provider.runCommand({
1556
+ argv: ['sh', '-c', 'cd && pwd -P'],
1557
+ cwd: '/',
1558
+ host: hostKey,
1559
+ });
1560
+
1561
+ const home = result.stdout.replace(/\n$/u, '');
1562
+
1563
+ if (result.exitCode !== 0 || !posix.isAbsolute(home)) {
1564
+ throw new DaemonError(
1565
+ 'host_unavailable',
1566
+ `the host of session ${hostKey} cannot resolve its home directory: ${result.stderr.trim()}`,
1567
+ { phase: 'resolving' },
1568
+ );
1569
+ }
1570
+
1571
+ return home;
1572
+ }
1573
+
1538
1574
  // Materializes a spawn's workspace, readying its host once the source
1539
1575
  // resolves, or after the workspace for a directory that runs as it
1540
1576
  // stands, and returns the directory it created, null for one that runs
@@ -1559,15 +1595,26 @@ export class SessionManager {
1559
1595
  root: null,
1560
1596
  };
1561
1597
 
1598
+ const hostHome: { dir: string | null } = { dir: null };
1599
+
1562
1600
  try {
1563
1601
  const materialized = await materialize(
1564
1602
  {
1565
- readyHost: async () => {
1566
- readied.setup = await setupHost();
1603
+ readyHost: async (attempt) => {
1604
+ readied.setup ??= await setupHost();
1605
+
1606
+ // A directory the daemon picked on a remote target is relative
1607
+ // to the home there, which only the readied host can resolve.
1608
+ hostHome.dir ??= posix.isAbsolute(dir)
1609
+ ? ''
1610
+ : await this.resolveHostHome(provider, hostKey);
1611
+
1612
+ const base = posix.isAbsolute(dir) ? dir : posix.join(hostHome.dir, dir);
1613
+ const candidate = attempt === 1 ? base : `${base}-${attempt}`;
1567
1614
 
1568
1615
  const landing = this.hasHostLifecycle(target)
1569
- ? await this.claimHostDir(provider, id, hostKey, target, dir)
1570
- : await this.resolveHostDir(provider, null, dir);
1616
+ ? await this.claimHostDir(provider, id, hostKey, target, candidate)
1617
+ : await this.resolveHostDir(provider, null, candidate);
1571
1618
 
1572
1619
  readied.root = landing;
1573
1620
 
@@ -44,7 +44,9 @@ const DIRS_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
44
44
  const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
45
45
  z.strictObject({
46
46
  daemon: DAEMON_FIELD,
47
- cwd: SPAWN_SCHEMA.shape.cwd.describe('Absolute path of the working directory'),
47
+ cwd: SPAWN_SCHEMA.shape.cwd.describe(
48
+ "Absolute path of the working directory. Required, except with a git workspace: omit it there and atc picks a new directory under the target user's home, ~/.local/share/atc/workspaces/<repo>-<ref>-<short sha> unless the config sets another root, adding -2, -3, and so on when that directory exists. The session's cwd in the result holds the path it landed in.",
49
+ ),
48
50
  name: SPAWN_SCHEMA.shape.name.describe('Session name; defaults to the directory basename'),
49
51
  prompt: SPAWN_SCHEMA.shape.prompt.describe('First message for the session'),
50
52
  agent: SPAWN_SCHEMA.shape.agent.describe(SPAWN_AGENT_DESCRIPTION),
@@ -58,7 +60,7 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
58
60
  'Execution target for the new session, one of the target ids in atc_agents_list. Omit it to run on the default target (spawnDefaults.target). An unknown or unavailable target is refused; atc never runs the session on another target instead.',
59
61
  ),
60
62
  workspace: SPAWN_SCHEMA.shape.workspace.describe(
61
- "Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host, leaving its uncommitted and untracked changes behind with a warning, or refusing them when allowDirty is 'refuse'; {kind:'git', url, ref or sha, credentialRef?} checks out a branch, tag, or full commit of a repository, with credentialRef {kind:'env', name} naming the atc daemon's environment variable that holds its token. A directory outside git runs in place only on a target on the atc host itself (provider local-pty), with cwd equal to its path. Submodules and Git LFS are refused, and so is a URL that carries a credential.",
63
+ "Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet, or for a git source without cwd into a directory atc picks: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host, leaving its uncommitted and untracked changes behind with a warning, or refusing them when allowDirty is 'refuse'; {kind:'git', url, ref or sha, credentialRef?} checks out a branch, tag, or full commit of a repository, with credentialRef {kind:'env', name} naming the atc daemon's environment variable that holds its token. A directory outside git runs in place only on a target on the atc host itself (provider local-pty), with cwd equal to its path. Submodules and Git LFS are refused, and so is a URL that carries a credential.",
62
64
  ),
63
65
  trustClonedWorkspace: SPAWN_SCHEMA.shape.trustClonedWorkspace.describe(
64
66
  "Trust the exact verified clone for this launch. An explicit true or false overrides the configured target trustClonedWorkspace default; omitting both keeps trust off. Requires a workspace source and either stock Claude on the local target, which adds trust for the clone root alone to the user's Claude config, or, on an imp target, a brokered Claude gateway or stock Claude signed in through the broker, each with isolated guest config; other launches are refused. Accepts repository configuration and helpers without changing tool permission mode. Existing guest config is preserved.",
@@ -20,6 +20,7 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
20
20
  'request.principal': 'the target limits of a remote MCP client',
21
21
  'spawn.workspace': "atc_session_spawn's workspace",
22
22
  'spawn.workspace.trust': "atc_session_spawn's trustClonedWorkspace",
23
+ 'spawn.workspace.autoDir': "atc_session_spawn's git workspace without a cwd",
23
24
  sources: 'sources.list and sources.interpret',
24
25
  'git.probe': 'git.probe',
25
26
  'transport.tcp': 'a TCP connection to the daemon',
@@ -46,7 +46,7 @@ export function runTool(
46
46
 
47
47
  const params = {
48
48
  ...(typeof args['daemon'] === 'string' ? { daemon: args['daemon'] } : {}),
49
- cwd: args['cwd'],
49
+ ...(args['cwd'] === undefined ? {} : { cwd: args['cwd'] }),
50
50
  ...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
51
51
  ...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
52
52
  ...(rawAgent === undefined ? {} : { agent: rawAgent }),
@@ -78,12 +78,20 @@ export function runTool(
78
78
  const trustFeatures: readonly DaemonFeature[] =
79
79
  args['trustClonedWorkspace'] === undefined ? [] : ['spawn.workspace.trust'];
80
80
 
81
+ // Only a daemon that picks a git workspace's directory takes one
82
+ // without a cwd.
83
+ const autoDirFeatures: readonly DaemonFeature[] =
84
+ args['cwd'] === undefined && args['workspace'] !== undefined
85
+ ? ['spawn.workspace.autoDir']
86
+ : [];
87
+
81
88
  const required = [
82
89
  ...optionFeatures,
83
90
  ...keyFeatures,
84
91
  ...targetFeatures,
85
92
  ...workspaceFeatures,
86
93
  ...trustFeatures,
94
+ ...autoDirFeatures,
87
95
  ];
88
96
 
89
97
  const ok =
@@ -50,6 +50,11 @@ export const DAEMON_FEATURES = [
50
50
  // `session.spawn` takes an explicit trust decision for a cloned imp workspace.
51
51
  'spawn.workspace.trust',
52
52
 
53
+ // `session.spawn` with a git workspace takes no `cwd`, and the daemon
54
+ // picks a directory under the target user's home that no other
55
+ // workspace holds.
56
+ 'spawn.workspace.autoDir',
57
+
53
58
  // `session.forget` exists, and a kill of a session asleep on a target that
54
59
  // can destroy its host answers `confirmation_required`.
55
60
  'session.forget',
@@ -151,7 +151,13 @@ export const REQUEST_PARAM_SCHEMAS = {
151
151
  rows: buildTerminalSize(24),
152
152
  }),
153
153
  'session.spawn': z.object({
154
- cwd: z.string({ error: 'session.spawn requires a cwd' }).min(1, 'session.spawn requires a cwd'),
154
+ // The working directory on the target. A spawn with a git workspace may
155
+ // leave it out, and the daemon then picks a directory under the target
156
+ // user's home; every other spawn requires it.
157
+ cwd: z
158
+ .string({ error: 'session.spawn requires a cwd' })
159
+ .min(1, 'session.spawn requires a cwd')
160
+ .optional(),
155
161
  name: buildDefaultedString(''),
156
162
  prompt: buildDefaultedString(''),
157
163
  cols: buildTerminalSize(80),
@@ -1,5 +1,6 @@
1
1
  interface DestinationParts {
2
- // The root the checkout lands under, absolute on its target.
2
+ // The root the checkout lands under: absolute on its target, or relative
3
+ // to the target user's home, where an empty root is the home itself.
3
4
  readonly root: string;
4
5
 
5
6
  // The repository URL the checkout comes from, as the daemon resolved it.
@@ -7,15 +8,18 @@ interface DestinationParts {
7
8
 
8
9
  // The branch or tag the commit was chosen by, or null for a bare commit.
9
10
  readonly ref: string | null;
10
- readonly sha: string;
11
+
12
+ // The commit checked out, or null when only the ref picks it.
13
+ readonly sha: string | null;
11
14
  }
12
15
 
13
16
  /**
14
17
  * The default directory a git workspace lands in: `<repo>-<ref>-<short
15
- * sha>` under the root, with the ref left out for a bare commit. The repo
16
- * is the URL's last path segment without `.git`, and each part keeps only
17
- * `[A-Za-z0-9._-]`, with `/` turned into `-`, so the name is one path
18
- * segment on any target.
18
+ * sha>` under the root, with the ref left out for a bare commit and the
19
+ * short sha for a ref alone. The repo is the URL's last path segment
20
+ * without `.git`, and each part keeps only `[A-Za-z0-9._-]`, with `/`
21
+ * turned into `-`, so the name is one path segment on any target; a name
22
+ * with nothing left is `workspace`.
19
23
  */
20
24
  export function buildWorkspaceDestination(parts: DestinationParts): string {
21
25
  const repo =
@@ -25,12 +29,19 @@ export function buildWorkspaceDestination(parts: DestinationParts): string {
25
29
  .at(-1)
26
30
  ?.replace(/\.git$/u, '') ?? '';
27
31
 
28
- const name = [repo, ...(parts.ref === null ? [] : [parts.ref]), parts.sha.slice(0, 7)]
32
+ const name = [
33
+ repo,
34
+ ...(parts.ref === null ? [] : [parts.ref]),
35
+ ...(parts.sha === null ? [] : [parts.sha.slice(0, 7)]),
36
+ ]
29
37
  .map((part) => toSafeSegment(part))
30
38
  .filter((part) => part !== '')
31
39
  .join('-');
32
40
 
33
- return `${parts.root.replace(/\/+$/u, '')}/${name}`;
41
+ const segment = name === '' ? 'workspace' : name;
42
+ const root = parts.root.replace(/\/+$/u, '');
43
+
44
+ return parts.root === '' ? segment : `${root}/${segment}`;
34
45
  }
35
46
 
36
47
  function toSafeSegment(part: string): string {
@@ -939,6 +939,7 @@ export class StateStore {
939
939
  .updateTable('workspace_materialization')
940
940
  .set({
941
941
  phase: fields.phase,
942
+ ...(fields.dir === undefined ? {} : { dir: fields.dir }),
942
943
  ...(fields.repoURL === undefined ? {} : { repo_url: fields.repoURL }),
943
944
  ...(fields.sha === undefined ? {} : { sha: fields.sha }),
944
945
  ...(fields.ref === undefined ? {} : { ref: fields.ref }),
@@ -50,6 +50,10 @@ export interface WorkspaceMaterialization {
50
50
  // The fields a materialization rewrites as it moves through its phases.
51
51
  export interface MaterializationUpdate {
52
52
  readonly phase: MaterializationPhase;
53
+
54
+ // The directory the workspace landed in, once a directory the daemon
55
+ // picked is claimed.
56
+ readonly dir?: string;
53
57
  readonly repoURL?: string;
54
58
  readonly sha?: string;
55
59
  readonly ref?: string | null;