@zgeoff/atc 2.16.0 → 2.17.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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +24 -1
  3. package/src/client/collect-target-picks.ts +53 -0
  4. package/src/client/dirs.ts +1 -1
  5. package/src/client/spawn-picker.ts +132 -6
  6. package/src/client/ui.ts +6 -1
  7. package/src/daemon/daemon-connection.ts +129 -0
  8. package/src/daemon/daemon-context.ts +23 -0
  9. package/src/daemon/daemon.ts +29 -0
  10. package/src/daemon/materialize-workspace.ts +42 -18
  11. package/src/daemon/require-git-transports.ts +23 -0
  12. package/src/mcp/require-daemon-features.ts +2 -0
  13. package/src/protocol/daemon-features.ts +8 -0
  14. package/src/protocol/protocol.ts +2 -0
  15. package/src/protocol/request-param-schemas.ts +67 -15
  16. package/src/shared/collect-workspaces-config.ts +105 -0
  17. package/src/shared/config.ts +14 -0
  18. package/src/shared/default-git-transports.ts +5 -0
  19. package/src/shared/is-git-url.ts +11 -0
  20. package/src/sources/build-sources.ts +40 -0
  21. package/src/sources/collect-builtin-sources.ts +41 -0
  22. package/src/sources/dirs/build-dirs-source.ts +70 -0
  23. package/src/sources/git/build-git-source.ts +25 -0
  24. package/src/sources/github/build-github-source.ts +113 -0
  25. package/src/sources/github/collect-github-repos.ts +165 -0
  26. package/src/sources/github/find-github-alternate-url.ts +20 -0
  27. package/src/sources/github/read-git-protocol.ts +11 -0
  28. package/src/sources/github/run-gh.ts +66 -0
  29. package/src/sources/types.ts +70 -0
  30. package/src/workspace/check-git-transport.ts +61 -0
  31. package/src/workspace/check-repository-access.ts +108 -0
  32. package/src/workspace/collect-remote-refs.ts +154 -0
  33. package/src/workspace/create-git-askpass.ts +84 -0
  34. package/src/workspace/create-workspace-clone.ts +29 -76
  35. package/src/workspace/expand-git-shorthand.ts +13 -0
  36. package/src/workspace/find-remote-ref.ts +34 -0
  37. package/src/workspace/normalize-git-url.ts +2 -7
  38. package/src/workspace/resolve-git-url.ts +46 -0
  39. package/src/workspace/resolve-path-source.ts +11 -4
  40. package/src/workspace/run-git.ts +52 -4
  41. package/src/workspace/workspace-source.ts +10 -2
  42. /package/src/{client → shared}/collect-root-dirs.ts +0 -0
  43. /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.16.0",
3
+ "version": "2.17.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
@@ -161,6 +161,10 @@ const main = defineCommand({
161
161
  const gateway = await import('./agents/gateway-adapter');
162
162
  const headless = await import('./agents/start-claude-headless-run');
163
163
  const targets = await import('./daemon/build-execution-targets');
164
+ const sourceOrder = await import('./sources/build-sources');
165
+ const builtinSources = await import('./sources/collect-builtin-sources');
166
+ const zoxide = await import('./shared/collect-zoxide-dirs');
167
+ const home = await import('./shared/resolve-home-dir');
164
168
 
165
169
  // Test harnesses shrink the outbound queue to force overflow
166
170
  // deterministically; unset means the production default.
@@ -178,10 +182,27 @@ const main = defineCommand({
178
182
  console.error(`atc daemon: config: ${line}`);
179
183
  }
180
184
 
181
- for (const problem of cfg.principalErrors) {
185
+ for (const problem of [...cfg.principalErrors, ...cfg.workspaceErrors]) {
182
186
  console.error(`atc daemon: config: ${problem}`);
183
187
  }
184
188
 
189
+ const sources = sourceOrder.buildSources(
190
+ builtinSources.collectBuiltinSources({
191
+ roots: cfg.dirs.roots,
192
+ githubOwner: cfg.workspaces.githubOwner,
193
+ ghBin: 'gh',
194
+ homeDir: home.resolveHomeDir(),
195
+ collectZoxideDirs: zoxide.collectZoxideDirs,
196
+ }),
197
+ cfg.workspaces.sources,
198
+ );
199
+
200
+ for (const id of sources.missing) {
201
+ console.error(
202
+ `atc daemon: config: workspaces.sources holds '${id}', which this daemon cannot offer; the picker leaves it out`,
203
+ );
204
+ }
205
+
185
206
  // Cap on how long a fleet restore waits for one revived session to
186
207
  // report it has booted before moving to the next. Tests pin it to
187
208
  // keep timing deterministic; unset means the production default.
@@ -220,6 +241,8 @@ const main = defineCommand({
220
241
  defaultTarget: cfg.defaultTarget,
221
242
  targetErrors,
222
243
  principals: cfg.principals,
244
+ sources: sources.sources,
245
+ gitTransports: cfg.workspaces.gitTransports,
223
246
  restoreBootTimeoutMs,
224
247
  ...(Number.isFinite(graceOverride) && graceOverride >= 0
225
248
  ? { tapGraceMs: graceOverride }
@@ -0,0 +1,53 @@
1
+ import { isRecord } from '../shared/report';
2
+
3
+ export interface TargetPick {
4
+ readonly id: string;
5
+ readonly provider: string;
6
+ readonly available: boolean;
7
+ readonly isDefault: boolean;
8
+
9
+ // Whether a workspace can be materialized there: the daemon has the
10
+ // provider, and it can both transfer an archive and run a command.
11
+ readonly takesWorkspace: boolean;
12
+
13
+ // Whether a session there runs on the daemon's own machine, where a
14
+ // local directory runs in place.
15
+ readonly inPlace: boolean;
16
+ }
17
+
18
+ /**
19
+ * The execution targets an `agents.list` answer holds, in config order. An
20
+ * answer from a daemon without targets, or a malformed entry, yields none.
21
+ */
22
+ export function collectTargetPicks(answer: Readonly<Record<string, unknown>>): TargetPick[] {
23
+ const targets = answer['targets'];
24
+
25
+ if (!Array.isArray(targets)) {
26
+ return [];
27
+ }
28
+
29
+ return targets.flatMap((entry: unknown): TargetPick[] => {
30
+ if (
31
+ !isRecord(entry) ||
32
+ typeof entry['id'] !== 'string' ||
33
+ typeof entry['provider'] !== 'string'
34
+ ) {
35
+ return [];
36
+ }
37
+
38
+ const capabilities = isRecord(entry['capabilities']) ? entry['capabilities'] : {};
39
+ const available = entry['available'] === true;
40
+
41
+ return [
42
+ {
43
+ id: entry['id'],
44
+ provider: entry['provider'],
45
+ available,
46
+ isDefault: entry['default'] === true,
47
+ takesWorkspace:
48
+ available && capabilities['transfer'] === true && capabilities['run'] === true,
49
+ inPlace: entry['provider'] === 'local-pty',
50
+ },
51
+ ];
52
+ });
53
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename } from 'node:path';
4
- import { collectRootDirs } from './collect-root-dirs';
4
+ import { collectRootDirs } from '../shared/collect-root-dirs';
5
5
 
6
6
  /**
7
7
  * Every source the picker lists, merged in the order the reader expects
@@ -1,17 +1,19 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { DaemonError } from '../protocol/daemon-error';
3
3
  import type { AgentID } from '../shared/agent-id';
4
+ import { collectZoxideDirs } from '../shared/collect-zoxide-dirs';
4
5
  import { loadConfig } from '../shared/config';
5
6
  import { collectAgentPicks } from './collect-agent-picks';
6
7
  import type { AgentPick } from './collect-agent-picks';
7
8
  import { collectPathCompletions } from './collect-path-completions';
8
- import { collectZoxideDirs } from './collect-zoxide-dirs';
9
+ import { collectTargetPicks } from './collect-target-picks';
10
+ import type { TargetPick } from './collect-target-picks';
9
11
  import { collectDirs, formatDir, formatDirName, pickMatches } from './dirs';
10
12
  import { planTextEdit } from './keys';
11
13
  import { resolvePathInput } from './resolve-path-input';
12
14
  import { ansi, cols, drawPicker } from './ui';
13
15
 
14
- type PickerStep = 'agent' | 'dir' | 'name' | 'prompt';
16
+ type PickerStep = 'agent' | 'dir' | 'target' | 'name' | 'prompt';
15
17
 
16
18
  // What the flow borrows from the client that owns the screen, the daemon
17
19
  // connection and the fleet mirror.
@@ -31,8 +33,9 @@ export interface SpawnPickerDeps<TMirror> {
31
33
  }
32
34
 
33
35
  /**
34
- * The modal flow behind n and r: agent, then directory, then name, then an
35
- * optional first prompt. Every path out of it either attaches the new
36
+ * The modal flow behind n and r: agent, then directory, then execution
37
+ * target when the daemon has more than one, then name, then an optional
38
+ * first prompt. Every path out of it either attaches the new
36
39
  * session or returns the client to the screen it came from. A spawn the
37
40
  * daemon refuses is no path out: the flow stays open and shows why.
38
41
  */
@@ -56,6 +59,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
56
59
 
57
60
  private dir = '';
58
61
 
62
+ // The daemon's execution targets, read when the directory step opens,
63
+ // and the one chosen; null spawns on the daemon's default target.
64
+ private targets: TargetPick[] = [];
65
+
66
+ private target: TargetPick | null = null;
67
+
59
68
  private name = '';
60
69
 
61
70
  private resume = false;
@@ -72,6 +81,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
72
81
 
73
82
  open(resume = false) {
74
83
  this.resume = resume;
84
+ this.target = null;
75
85
 
76
86
  const config = loadConfig();
77
87
 
@@ -99,7 +109,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
99
109
 
100
110
  const edit = planTextEdit(buf, this.input, {
101
111
  isLeaderKey: this.deps.isLeaderKey,
102
- moves: this.step === 'agent' || this.step === 'dir',
112
+ moves: this.step === 'agent' || this.step === 'dir' || this.step === 'target',
103
113
  });
104
114
 
105
115
  switch (edit.kind) {
@@ -131,6 +141,11 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
131
141
  return;
132
142
  }
133
143
  case 'input': {
144
+ // The target step takes no text: its list is short and fixed.
145
+ if (this.step === 'target') {
146
+ return;
147
+ }
148
+
134
149
  this.input = edit.value;
135
150
 
136
151
  this.render();
@@ -170,6 +185,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
170
185
  input: this.input,
171
186
  hint: 'type to filter, or a path (/ ~ .) · ↑↓ move · ⏎ select · esc cancel',
172
187
  });
188
+ } else if (this.step === 'target') {
189
+ this.selected = Math.min(this.selected, this.targets.length - 1);
190
+
191
+ drawPicker({
192
+ title: `${verb}: target`,
193
+ items: this.targets.map((t) => formatTargetPick(t)),
194
+ selected: this.selected,
195
+ input: '',
196
+ hint: this.refusal ?? `where ${formatDir(this.dir)} runs · ↑↓ move · ⏎ select · esc back`,
197
+ dimmed: new Set(
198
+ this.targets.flatMap((t, i) =>
199
+ t.takesWorkspace || (t.available && t.inPlace) ? [] : [i],
200
+ ),
201
+ ),
202
+ });
173
203
  } else if (this.step === 'name') {
174
204
  drawPicker({
175
205
  title: `${verb}: name`,
@@ -209,8 +239,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
209
239
  );
210
240
 
211
241
  this.step = 'agent';
212
- } else if (this.step === 'name') {
242
+ } else if (this.step === 'target') {
213
243
  this.step = 'dir';
244
+ } else if (this.step === 'name') {
245
+ this.openTargetOrDir('dir');
214
246
  } else {
215
247
  this.step = 'name';
216
248
  }
@@ -246,6 +278,27 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
246
278
 
247
279
  this.dir = chosen;
248
280
  this.input = '';
281
+
282
+ this.openTargetOrDir('name');
283
+ } else if (this.step === 'target') {
284
+ const pick = this.targets[this.selected];
285
+
286
+ if (pick === undefined) {
287
+ return;
288
+ }
289
+
290
+ const refusal = findTargetRefusal(pick);
291
+
292
+ if (refusal !== null) {
293
+ this.refusal = refusal;
294
+
295
+ this.render();
296
+
297
+ return;
298
+ }
299
+
300
+ this.target = pick;
301
+ this.input = '';
249
302
  this.step = 'name';
250
303
  } else if (this.step === 'name') {
251
304
  this.name = this.input.trim();
@@ -280,9 +333,44 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
280
333
  : collectPathCompletions(this.input, process.cwd(), homedir());
281
334
  }
282
335
 
336
+ // Opens the target step when the daemon has more than one target, with
337
+ // the chosen or default target selected, and the fallback step otherwise.
338
+ private openTargetOrDir(fallback: 'dir' | 'name') {
339
+ if (this.targets.length < 2) {
340
+ // The one target is the spawn's, default or not, so the spawn names it
341
+ // and its provider decides whether a directory runs in place.
342
+ this.target = this.targets[0] ?? null;
343
+ this.step = fallback;
344
+
345
+ return;
346
+ }
347
+
348
+ const preferred = this.target?.id;
349
+
350
+ this.selected = Math.max(
351
+ 0,
352
+ this.targets.findIndex((t) => (preferred === undefined ? t.isDefault : t.id === preferred)),
353
+ );
354
+
355
+ this.step = 'target';
356
+ }
357
+
283
358
  private async openDirStep() {
284
359
  let recent: string[] = [];
285
360
 
361
+ try {
362
+ const listed = await this.deps.sendRequest('agents.list');
363
+
364
+ const picks = collectTargetPicks(listed);
365
+
366
+ // An adopt resumes a session from its history on this host, which a
367
+ // fresh checkout elsewhere does not hold, so it is offered only the
368
+ // targets that run here.
369
+ this.targets = this.resume ? picks.filter((t) => t.available && t.inPlace) : picks;
370
+ } catch {
371
+ this.targets = [];
372
+ }
373
+
286
374
  try {
287
375
  const answer = await this.deps.sendRequest('dirs.list');
288
376
 
@@ -318,6 +406,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
318
406
  rows: this.deps.ptyRows(),
319
407
  ...(this.resume ? { resume: true } : {}),
320
408
  agent: this.agent,
409
+ ...this.buildTargetParams(),
321
410
  });
322
411
 
323
412
  const spawned = this.deps.toMirrorSession(ok['session']);
@@ -347,4 +436,41 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
347
436
 
348
437
  this.deps.toBase();
349
438
  }
439
+
440
+ // A chosen target goes with the spawn. A local directory runs in place on
441
+ // a target on the daemon's own machine, and is materialized at the same
442
+ // path from its pushed HEAD on any other.
443
+ private buildTargetParams(): Readonly<Record<string, unknown>> {
444
+ if (this.target === null) {
445
+ return {};
446
+ }
447
+
448
+ return this.target.inPlace
449
+ ? { target: this.target.id }
450
+ : { target: this.target.id, workspace: { kind: 'path', path: this.dir } };
451
+ }
452
+ }
453
+
454
+ function formatTargetPick(pick: TargetPick): string {
455
+ const notes = [
456
+ pick.provider,
457
+ ...(pick.isDefault ? ['default'] : []),
458
+ ...(pick.available ? [] : ['unavailable']),
459
+ ...(pick.available && !pick.takesWorkspace ? ['no workspace'] : []),
460
+ ];
461
+
462
+ return `${pick.id} ${notes.join(' · ')}`;
463
+ }
464
+
465
+ // Why a local directory cannot run on a target, or null when it can.
466
+ function findTargetRefusal(pick: TargetPick): string | null {
467
+ if (!pick.available) {
468
+ return `target '${pick.id}' is unavailable on this daemon · esc back`;
469
+ }
470
+
471
+ if (!pick.inPlace && !pick.takesWorkspace) {
472
+ return `target '${pick.id}' cannot take a workspace · esc back`;
473
+ }
474
+
475
+ return null;
350
476
  }
package/src/client/ui.ts CHANGED
@@ -387,6 +387,9 @@ export interface PickerView {
387
387
  input: string;
388
388
  placeholder?: string;
389
389
  hint: string;
390
+
391
+ // The indexes of items drawn dim: listed, but not a fit for the flow.
392
+ dimmed?: ReadonlySet<number>;
390
393
  }
391
394
 
392
395
  export function drawPicker(view: PickerView) {
@@ -397,7 +400,9 @@ export function drawPicker(view: PickerView) {
397
400
  for (const [i, item] of shown.entries()) {
398
401
  const sel = i === view.selected;
399
402
  const t = truncate(item, width - 4);
400
- const styled = sel ? `${ESC}[7m${t.padEnd(width - 4)}${ESC}[0m` : t;
403
+ const dim = view.dimmed?.has(i) === true;
404
+ const inverse = sel ? `${ESC}[7m${t.padEnd(width - 4)}${ESC}[0m` : t;
405
+ const styled = dim ? `${ESC}[90m${inverse}${ESC}[0m` : inverse;
401
406
  const plainLen = sel ? width - 4 : t.length;
402
407
 
403
408
  rowsList.push(boxRow(width, styled, plainLen));
@@ -19,6 +19,7 @@ import type { AgentID } from '../shared/agent-id';
19
19
  import { isRecord } from '../shared/report';
20
20
  import type { SessionID } from '../shared/session-id';
21
21
  import { toSessionID } from '../shared/to-session-id';
22
+ import type { SourceProvider, SourceRequest } from '../sources/types';
22
23
  import { buildPayloadHash } from './build-payload-hash';
23
24
  import { buildScopedContext } from './build-scoped-context';
24
25
  import { buildTargetForbiddenError } from './build-target-forbidden-error';
@@ -43,6 +44,18 @@ interface ViewUpdate {
43
44
  readonly withdrawn: readonly SessionID[];
44
45
  }
45
46
 
47
+ // What a source request holds: the source's id and the target it is for.
48
+ interface SourceParams {
49
+ readonly source: string;
50
+ readonly target?: string | undefined;
51
+ }
52
+
53
+ // The source a request asks for, and the request the source serves.
54
+ interface ResolvedSourceRequest {
55
+ readonly source: SourceProvider;
56
+ readonly request: SourceRequest;
57
+ }
58
+
46
59
  interface PeerSocket extends SocketWriter {
47
60
  readonly end: () => void;
48
61
  }
@@ -409,6 +422,21 @@ export class DaemonConnection {
409
422
 
410
423
  return;
411
424
  }
425
+ case 'sources.list': {
426
+ await this.applySourcesList(req, ctx);
427
+
428
+ return;
429
+ }
430
+ case 'sources.interpret': {
431
+ await this.applySourcesInterpret(req, ctx);
432
+
433
+ return;
434
+ }
435
+ case 'git.probe': {
436
+ await this.applyGitProbe(req, ctx);
437
+
438
+ return;
439
+ }
412
440
  case 'session.spawn': {
413
441
  await this.applySpawn(req, ctx);
414
442
 
@@ -794,6 +822,107 @@ export class DaemonConnection {
794
822
  this.sendOk(req.id, spawned);
795
823
  }
796
824
 
825
+ // Lists one source's candidates for a spawn to the request's target.
826
+ private async applySourcesList(req: RequestMsg, ctx: DaemonContext): Promise<void> {
827
+ const parsed = parseRequestParams('sources.list', req.p);
828
+
829
+ if (!parsed.ok) {
830
+ this.sendErr(req.id, 'bad_args', parsed.message);
831
+
832
+ return;
833
+ }
834
+
835
+ const resolved = this.resolveSourceRequest(parsed.data, ctx);
836
+ const source = resolved.source;
837
+
838
+ const listed = await source.list(
839
+ {
840
+ ...(parsed.data.scope === undefined ? {} : { scope: parsed.data.scope }),
841
+ ...(parsed.data.text === undefined ? {} : { text: parsed.data.text }),
842
+ },
843
+ resolved.request,
844
+ );
845
+
846
+ this.sendOk(req.id, { source: source.id, scope: listed.scope, candidates: listed.candidates });
847
+ }
848
+
849
+ // Reads typed input through one source for a spawn to the request's
850
+ // target.
851
+ private async applySourcesInterpret(req: RequestMsg, ctx: DaemonContext): Promise<void> {
852
+ const parsed = parseRequestParams('sources.interpret', req.p);
853
+
854
+ if (!parsed.ok) {
855
+ this.sendErr(req.id, 'bad_args', parsed.message);
856
+
857
+ return;
858
+ }
859
+
860
+ const resolved = this.resolveSourceRequest(parsed.data, ctx);
861
+
862
+ const interpretation = await resolved.source.interpret(parsed.data.input, resolved.request);
863
+
864
+ this.sendOk(req.id, { ...interpretation });
865
+ }
866
+
867
+ // The source a request asks for and the request it serves, so a principal
868
+ // reaches a source only for a target it may spawn on, and a git source
869
+ // only for a target it may spawn a workspace on.
870
+ private resolveSourceRequest(params: SourceParams, ctx: DaemonContext): ResolvedSourceRequest {
871
+ const source = ctx.findSource(params.source);
872
+
873
+ if (source === null) {
874
+ throw new DaemonError('unsupported', `this daemon offers no source '${params.source}'`);
875
+ }
876
+
877
+ const target = ctx.resolveSpawnTarget(params.target);
878
+
879
+ if (source.kind === 'git') {
880
+ this.ctx.requireWorkspaceTarget(target);
881
+ }
882
+
883
+ // The spawn history is read through the request's own view, so a
884
+ // principal lists only directories spawned on targets it may use.
885
+ return { source, request: { target, collectSpawnDirs: () => ctx.collectSpawnDirs(null) } };
886
+ }
887
+
888
+ // Probes a git workspace source for a spawn to the request's target, so
889
+ // a principal probes only for a target it may spawn a workspace on. A
890
+ // refusal holds the repository's other URLs that the offered sources know.
891
+ private async applyGitProbe(req: RequestMsg, ctx: DaemonContext): Promise<void> {
892
+ const parsed = parseRequestParams('git.probe', req.p);
893
+
894
+ if (!parsed.ok) {
895
+ this.sendErr(req.id, 'bad_args', parsed.message);
896
+
897
+ return;
898
+ }
899
+
900
+ const data = parsed.data;
901
+
902
+ this.ctx.requireWorkspaceTarget(ctx.resolveSpawnTarget(data.target));
903
+
904
+ const access = await ctx.checkRepositoryAccess({
905
+ url: data.url,
906
+ ref: data.ref,
907
+ sha: data.sha,
908
+ credential: data.credentialRef,
909
+ });
910
+
911
+ if (!access.ok) {
912
+ const alternates = ctx.collectAlternateGitURLs(data.url);
913
+ const detail = alternates.length === 0 ? undefined : { alternates };
914
+
915
+ throw new DaemonError(access.code, access.message, detail);
916
+ }
917
+
918
+ this.sendOk(req.id, {
919
+ url: access.url,
920
+ head: access.head,
921
+ refs: access.refs,
922
+ resolved: access.resolved,
923
+ });
924
+ }
925
+
797
926
  private applyAttach(req: RequestMsg, ctx: DaemonContext): void {
798
927
  const parsed = parseRequestParams('session.attach', req.p);
799
928
 
@@ -6,9 +6,11 @@ import type { TargetConfigError } from '../shared/collect-targets';
6
6
  import type { DaemonID } from '../shared/daemon-id';
7
7
  import type { MessageID } from '../shared/message-id';
8
8
  import type { SessionID } from '../shared/session-id';
9
+ import type { SourceKind, SourceProvider } from '../sources/types';
9
10
  import type { FleetEntry } from '../store/fleet-entry';
10
11
  import type { MessageRecord } from '../store/message-record';
11
12
  import type { TurnSibling } from '../store/state-store';
13
+ import type { checkRepositoryAccess } from '../workspace/check-repository-access';
12
14
  import type { Dims } from './attach-registry';
13
15
  import type { AgentEntry } from './build-agent-list';
14
16
  import type { FleetEvent } from './build-fleet-events';
@@ -79,6 +81,13 @@ interface AgentList {
79
81
  readonly spawnDefaults: { readonly agent: AgentID; readonly target: string | null };
80
82
  readonly configRevision: string;
81
83
  readonly targetErrors: readonly TargetConfigError[];
84
+
85
+ // The sources the spawn picker offers, in order.
86
+ readonly sources: readonly {
87
+ readonly id: string;
88
+ readonly label: string;
89
+ readonly kind: SourceKind;
90
+ }[];
82
91
  }
83
92
 
84
93
  // The slice of a connection the attach bookkeeping needs: identity plus the
@@ -163,6 +172,20 @@ export interface DaemonContext {
163
172
  // one whose provider cannot both transfer an archive and run a command.
164
173
  readonly requireWorkspaceTarget: (target: string) => void;
165
174
 
175
+ // The source the daemon offers the spawn picker under an id, or null when
176
+ // it offers none under it.
177
+ readonly findSource: (id: string) => SourceProvider | null;
178
+
179
+ // The other URLs the offered sources know for a git URL the daemon's host
180
+ // could not read, each once.
181
+ readonly collectAlternateGitURLs: (url: string) => readonly string[];
182
+
183
+ // Checks that the daemon's host can read a git workspace source, and
184
+ // resolves its ref, the way a workspace spawn from it would.
185
+ readonly checkRepositoryAccess: (
186
+ request: Omit<Parameters<typeof checkRepositoryAccess>[0], 'transports'>,
187
+ ) => ReturnType<typeof checkRepositoryAccess>;
188
+
166
189
  // Runs the plan, which throws the refusal for a spawn it refuses, then
167
190
  // spawns. Answers with the `session.spawn` ok payload, which a keyed
168
191
  // retry replays as the first spawn answered it while the access, when
@@ -13,12 +13,15 @@ import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
13
13
  import type { SessionState } from '../protocol/session-state';
14
14
  import type { HooksConfig } from '../shared/collect-hooks';
15
15
  import type { TargetConfigError } from '../shared/collect-targets';
16
+ import type { InvalidGitTransports } from '../shared/collect-workspaces-config';
17
+ import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
16
18
  import { findDaemonRecord } from '../shared/find-daemon-record';
17
19
  import type { MessageID } from '../shared/message-id';
18
20
  import { isRecord } from '../shared/report';
19
21
  import type { SessionID } from '../shared/session-id';
20
22
  import { toMessageID } from '../shared/to-message-id';
21
23
  import { truncateToBytes } from '../shared/truncate-to-bytes';
24
+ import type { SourceProvider } from '../sources/types';
22
25
  import type { IdempotencyRecord } from '../store/idempotency-record';
23
26
  import type { MessageOwner } from '../store/message-owner';
24
27
  import type { MessageRecord, MessageStatus } from '../store/message-record';
@@ -26,6 +29,7 @@ import { StateStore } from '../store/state-store';
26
29
  import type { EventScope } from '../store/state-store';
27
30
  import type { TrailEntry } from '../store/trail-entry';
28
31
  import type { SessionWorkspace } from '../store/workspace-materialization';
32
+ import { checkRepositoryAccess } from '../workspace/check-repository-access';
29
33
  import { ANSWER_BYTE_CAP } from './answer-byte-cap';
30
34
  import { AttachRegistry } from './attach-registry';
31
35
  import { buildAgentList } from './build-agent-list';
@@ -68,6 +72,7 @@ import { mintMessageID } from './mint-message-id';
68
72
  import { mintSessionID } from './mint-session-id';
69
73
  import { parseReport } from './parse-report';
70
74
  import { PermissionRegistry } from './permission-registry';
75
+ import { requireGitTransports } from './require-git-transports';
71
76
  import { restoreFleet } from './restore-fleet';
72
77
  import { runEjectHandoff } from './run-eject-handoff';
73
78
  import { ScreenModel } from './screen-model';
@@ -153,6 +158,15 @@ export interface DaemonOptions {
153
158
  // Where background failures are reported, one line at a time; stderr
154
159
  // when unset.
155
160
  readonly log?: (line: string) => void;
161
+
162
+ // The sources the spawn picker offers, in order, each built with the
163
+ // services it uses; none when unset.
164
+ readonly sources?: readonly SourceProvider[];
165
+
166
+ // The transports a git workspace source may use and git may fetch over,
167
+ // or the invalid list the config holds, which refuses every git
168
+ // operation; https and ssh when unset.
169
+ readonly gitTransports?: readonly string[] | InvalidGitTransports;
156
170
  }
157
171
 
158
172
  export interface DaemonHandle {
@@ -236,6 +250,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
236
250
 
237
251
  const targetErrors = opts.targetErrors ?? [];
238
252
  const principals = opts.principals ?? null;
253
+ const gitTransports = opts.gitTransports ?? DEFAULT_GIT_TRANSPORTS;
239
254
 
240
255
  const targetsByID = new Map(targets.map((target) => [target.id, target]));
241
256
 
@@ -911,6 +926,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
911
926
  mgr.log(line);
912
927
  },
913
928
  stagingRoot: tmpdir(),
929
+ gitTransports,
914
930
  },
915
931
  );
916
932
  };
@@ -1192,6 +1208,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1192
1208
  }
1193
1209
  };
1194
1210
 
1211
+ const sources = opts.sources ?? [];
1212
+
1195
1213
  const ctx: DaemonContext = {
1196
1214
  build: opts.build,
1197
1215
  daemonID: store.daemonID,
@@ -1215,6 +1233,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1215
1233
  spawnDefaults: { agent: 'claude', target: defaultTarget },
1216
1234
  configRevision,
1217
1235
  targetErrors,
1236
+ sources: sources.map((source) => ({
1237
+ id: source.id,
1238
+ label: source.label,
1239
+ kind: source.kind,
1240
+ })),
1218
1241
  }),
1219
1242
  collectFleet: () => store.loadFleet(),
1220
1243
  loadLastUsedAgent: () => store.loadLastUsedAgent(),
@@ -1256,6 +1279,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1256
1279
  mgr.requireExecution({ target, targetIdentity: null }, 'transfer');
1257
1280
  mgr.requireExecution({ target, targetIdentity: null }, 'run');
1258
1281
  },
1282
+ findSource: (id) => sources.find((source) => source.id === id) ?? null,
1283
+ collectAlternateGitURLs: (url) => [
1284
+ ...new Set(sources.flatMap((source) => source.findAlternateURLs?.(url) ?? [])),
1285
+ ],
1286
+ checkRepositoryAccess: (request) =>
1287
+ checkRepositoryAccess({ ...request, transports: requireGitTransports(gitTransports) }),
1259
1288
  spawnSession: (plan, keyed, access) => {
1260
1289
  // Under an access, a spawn under a parent whose tree leaves the access
1261
1290
  // before the harness starts is refused as a spawn under an unknown