@zgeoff/atc 2.17.0 → 2.20.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,19 +1,27 @@
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 type { WorkspacesConfig } from '../shared/collect-workspaces-config';
4
5
  import { collectZoxideDirs } from '../shared/collect-zoxide-dirs';
5
6
  import { loadConfig } from '../shared/config';
7
+ import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
8
+ import { isGitURL } from '../shared/is-git-url';
9
+ import { isRecord } from '../shared/report';
10
+ import { resolveHomeDir } from '../shared/resolve-home-dir';
11
+ import { buildWorkspaceDestination } from './build-workspace-destination';
6
12
  import { collectAgentPicks } from './collect-agent-picks';
7
13
  import type { AgentPick } from './collect-agent-picks';
8
14
  import { collectPathCompletions } from './collect-path-completions';
9
15
  import { collectTargetPicks } from './collect-target-picks';
10
16
  import type { TargetPick } from './collect-target-picks';
11
17
  import { collectDirs, formatDir, formatDirName, pickMatches } from './dirs';
12
- import { planTextEdit } from './keys';
18
+ import { KEY, planTextEdit } from './keys';
19
+ import { pickRefusalStep } from './pick-refusal-step';
13
20
  import { resolvePathInput } from './resolve-path-input';
21
+ import { resolveWorkspaceRoot } from './resolve-workspace-root';
14
22
  import { ansi, cols, drawPicker } from './ui';
15
23
 
16
- type PickerStep = 'agent' | 'dir' | 'target' | 'name' | 'prompt';
24
+ type PickerStep = 'agent' | 'dir' | 'source' | 'ref' | 'target' | 'confirm' | 'name' | 'prompt';
17
25
 
18
26
  // What the flow borrows from the client that owns the screen, the daemon
19
27
  // connection and the fleet mirror.
@@ -32,18 +40,103 @@ export interface SpawnPickerDeps<TMirror> {
32
40
  readonly upsertMirror: (session: Readonly<TMirror>) => void;
33
41
  }
34
42
 
43
+ // One source `agents.list` returned, in the order the daemon offers them.
44
+ interface AnnouncedSource {
45
+ readonly id: string;
46
+ readonly label: string;
47
+ readonly kind: 'git' | 'path';
48
+ }
49
+
50
+ // What a candidate or a reading of typed input resolves to.
51
+ type SourcePick =
52
+ | { readonly kind: 'path'; readonly dir: string }
53
+ | { readonly kind: 'git'; readonly url: string };
54
+
55
+ // One candidate `sources.list` returned.
56
+ interface ListedCandidate {
57
+ readonly label: string;
58
+ readonly detail: string | null;
59
+ readonly pick: SourcePick;
60
+ }
61
+
62
+ // What `sources.interpret` read typed input as.
63
+ type Interpretation =
64
+ | { readonly kind: 'browse'; readonly scope: string }
65
+ | SourcePick
66
+ | { readonly kind: 'none' };
67
+
68
+ // One branch or tag `git.probe` returned.
69
+ interface ProbedRef {
70
+ readonly name: string;
71
+ readonly kind: 'branch' | 'tag';
72
+ readonly sha: string;
73
+ }
74
+
75
+ // The repository the probe proved the daemon's host can read.
76
+ interface ProbedRepo {
77
+ // What the user chose, shown as the source step's input on the way back
78
+ // to it.
79
+ readonly label: string;
80
+
81
+ // The URL the clone fetches, as the daemon resolved it.
82
+ readonly url: string;
83
+ readonly head: string | null;
84
+ readonly refs: readonly ProbedRef[];
85
+ }
86
+
87
+ // The commit the spawn checks out, and the branch or tag it came from.
88
+ interface ChosenRef {
89
+ readonly ref: string | null;
90
+ readonly sha: string;
91
+ }
92
+
93
+ const FULL_SHA_PATTERN = /^(?:[\da-f]{40}|[\da-f]{64})$/u;
94
+ const SHORT_SHA_PATTERN = /^[\da-f]{7,63}$/u;
95
+
96
+ // A request the step waits on, and the number that tells its answer from a
97
+ // cancelled one's.
98
+ interface PendingRequest {
99
+ readonly label: string;
100
+ readonly seq: number;
101
+ readonly kind: 'interpret' | 'probe' | 'spawn';
102
+ }
103
+
104
+ // What a git source step does once it opens: nothing, probe a URL a
105
+ // reading of typed input already resolved, or list the scope a reading
106
+ // asked for, once the step's target is known.
107
+ type SourceStepAction =
108
+ | { readonly kind: 'none' }
109
+ | { readonly kind: 'probe'; readonly url: string }
110
+ | { readonly kind: 'browse'; readonly scope: string };
111
+
35
112
  /**
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
39
- * session or returns the client to the screen it came from. A spawn the
40
- * daemon refuses is no path out: the flow stays open and shows why.
113
+ * The modal flow behind n and r: agent, then source, then name, then an
114
+ * optional first prompt. The sources are the ones the daemon offers, in
115
+ * its order, and Tab cycles them; a daemon that offers none gets the local
116
+ * directory flow alone. A source of directories is followed by the
117
+ * execution target when the daemon has more than one. A source of git
118
+ * repositories opens the target first, then its candidates, the ref, and a
119
+ * confirm screen with the destination the checkout lands in, so every
120
+ * request about the repository is made for the target the spawn runs on.
121
+ * Typed input a source does not match against its candidates is read by
122
+ * the source, then by the others in order. Every path out of the flow
123
+ * either attaches the new session or returns the client to the screen it
124
+ * came from. A spawn the daemon refuses is no path out: the flow returns to
125
+ * the step that can fix the cause and shows why. Esc cancels a request in
126
+ * flight and drops its late answer.
41
127
  */
42
128
  export class SpawnPicker<TMirror extends { readonly id: string }> {
43
129
  private readonly deps: SpawnPickerDeps<TMirror>;
44
130
 
45
131
  private step: PickerStep = 'agent';
46
132
 
133
+ // The sources the daemon offers, or null when it offers none, and the
134
+ // one the flow is on; -1 is the local directory flow of a daemon that
135
+ // offers none.
136
+ private sources: readonly AnnouncedSource[] | null = null;
137
+
138
+ private sourceIndex = -1;
139
+
47
140
  private input = '';
48
141
 
49
142
  private selected = 0;
@@ -52,42 +145,122 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
52
145
  // edit or a fresh install lands without a client restart.
53
146
  private picks: AgentPick[] = [];
54
147
 
148
+ // The directories the directory step lists, and the label each shows.
55
149
  private dirs: string[] = [];
56
150
 
57
- // The configured roots, read with the agents each time the menu opens.
151
+ private dirLabels: ReadonlyMap<string, string> = new Map();
152
+
153
+ // The configured roots and workspace settings, read with the agents each
154
+ // time the menu opens.
58
155
  private roots: readonly string[] = [];
59
156
 
157
+ private workspaces: WorkspacesConfig = {
158
+ githubOwner: null,
159
+ sources: null,
160
+ gitTransports: DEFAULT_GIT_TRANSPORTS,
161
+ root: null,
162
+ targetRoots: new Map(),
163
+ };
164
+
60
165
  private dir = '';
61
166
 
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.
167
+ // The daemon's execution targets, read when the agent is chosen, and the
168
+ // one chosen; null spawns on the daemon's default target.
64
169
  private targets: TargetPick[] = [];
65
170
 
66
171
  private target: TargetPick | null = null;
67
172
 
173
+ // The git source's candidates and the scope they were listed under, or
174
+ // the notice that stands in for a list the daemon could not give, with
175
+ // the source and target they were listed for. A listing runs behind the
176
+ // step, so typing goes on.
177
+ private candidates: ListedCandidate[] = [];
178
+
179
+ private listedScope: string | null = null;
180
+
181
+ private listNotice: string | null = null;
182
+
183
+ private listedKey: string | null = null;
184
+
185
+ private listingSeq: number | null = null;
186
+
187
+ // The directory listing the flow waits on before it opens the directory
188
+ // step, or null when it waits on none.
189
+ private pathListingSeq: number | null = null;
190
+
191
+ // The source step's text while the flow is on another step, and what the
192
+ // step does as soon as it opens.
193
+ private sourceDraft = '';
194
+
195
+ private sourceAction: SourceStepAction = { kind: 'none' };
196
+
197
+ // The other URL form of a repository whose probe failed, offered as the
198
+ // source step's one item until the input changes.
199
+ private alternateURL: string | null = null;
200
+
201
+ private repo: ProbedRepo | null = null;
202
+
203
+ private ref: ChosenRef | null = null;
204
+
205
+ // The destination on the confirm screen, before any suffix a refused
206
+ // spawn adds, that suffix's number, and whether the user typed the
207
+ // destination rather than taking the default.
208
+ private destination = '';
209
+
210
+ private destinationAttempt = 1;
211
+
212
+ private destinationEdited = false;
213
+
214
+ // The repository the destination was built or typed for.
215
+ private destinationRepoURL: string | null = null;
216
+
68
217
  private name = '';
69
218
 
70
219
  private resume = false;
71
220
 
72
221
  private agent: AgentID = 'claude';
73
222
 
74
- // The daemon's refusal of the last spawn, shown in place of the hint until
75
- // the next key.
223
+ // Why the last action was refused, shown in place of the hint until the
224
+ // next key.
76
225
  private refusal: string | null = null;
77
226
 
227
+ private pending: PendingRequest | null = null;
228
+
229
+ private requestSeq = 0;
230
+
231
+ // The flow's generation. Opening the flow and every way out of it moves
232
+ // it on, and an answer that arrives for an earlier generation is dropped
233
+ // before it changes any state or draws anything.
234
+ private generation = 0;
235
+
78
236
  constructor(deps: SpawnPickerDeps<TMirror>) {
79
237
  this.deps = deps;
80
238
  }
81
239
 
82
240
  open(resume = false) {
241
+ this.generation += 1;
83
242
  this.resume = resume;
84
- this.target = null;
85
243
 
86
244
  const config = loadConfig();
87
245
 
88
246
  this.picks = collectAgentPicks(config);
89
247
  this.roots = config.dirs.roots;
248
+ this.workspaces = config.workspaces;
90
249
  this.input = '';
250
+ this.sources = null;
251
+ this.sourceIndex = -1;
252
+ this.target = null;
253
+ this.repo = null;
254
+ this.ref = null;
255
+ this.sourceDraft = '';
256
+ this.sourceAction = { kind: 'none' };
257
+ this.listedKey = null;
258
+ this.listingSeq = null;
259
+ this.pathListingSeq = null;
260
+ this.alternateURL = null;
261
+ this.destination = '';
262
+ this.destinationEdited = false;
263
+ this.destinationRepoURL = null;
91
264
 
92
265
  // A last-used agent that is no longer installed is not in the menu, so
93
266
  // the selection falls to the first one that is.
@@ -99,6 +272,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
99
272
  this.agent = this.picks[this.selected]?.agent ?? this.deps.getLastUsedAgent();
100
273
  this.step = 'agent';
101
274
  this.refusal = null;
275
+ this.pending = null;
102
276
 
103
277
  process.stdout.write(ansi.clear);
104
278
  this.render();
@@ -109,9 +283,46 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
109
283
 
110
284
  const edit = planTextEdit(buf, this.input, {
111
285
  isLeaderKey: this.deps.isLeaderKey,
112
- moves: this.step === 'agent' || this.step === 'dir' || this.step === 'target',
286
+ moves:
287
+ this.step === 'agent' ||
288
+ this.step === 'dir' ||
289
+ this.step === 'source' ||
290
+ this.step === 'ref' ||
291
+ this.step === 'target',
113
292
  });
114
293
 
294
+ // A request in flight owns the step: Esc cancels it, the leader leaves,
295
+ // and every other key waits for the answer.
296
+ if (this.pending !== null) {
297
+ if (edit.kind === 'cancel') {
298
+ this.applyPendingCancel();
299
+ } else if (edit.kind === 'leader') {
300
+ this.quitFlow();
301
+ }
302
+
303
+ return;
304
+ }
305
+
306
+ const tab = buf.length === 1 && buf[0] === KEY.tab;
307
+
308
+ // A tab to a source of directories waits for its listing on the step it
309
+ // left. Any other key there acts on that step, so the flow stays on it
310
+ // and the listing no longer opens its step.
311
+ if (!tab && (this.step === 'dir' || this.step === 'source')) {
312
+ this.pathListingSeq = null;
313
+ }
314
+
315
+ // Tab moves a spawn to the next source the daemon offers. Adopt resumes
316
+ // a session in a directory that already holds one, so it stays on its
317
+ // directories.
318
+ if (tab) {
319
+ if (!this.resume && (this.step === 'dir' || this.step === 'source')) {
320
+ this.openNextSource();
321
+ }
322
+
323
+ return;
324
+ }
325
+
115
326
  switch (edit.kind) {
116
327
  case 'none': {
117
328
  return;
@@ -122,7 +333,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
122
333
  return;
123
334
  }
124
335
  case 'leader': {
125
- this.deps.toBase();
336
+ this.quitFlow();
126
337
 
127
338
  return;
128
339
  }
@@ -141,14 +352,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
141
352
  return;
142
353
  }
143
354
  case 'input': {
144
- // The target step takes no text: its list is short and fixed.
145
- if (this.step === 'target') {
146
- return;
147
- }
148
-
149
- this.input = edit.value;
150
-
151
- this.render();
355
+ this.applyInput(edit.value);
152
356
  }
153
357
  }
154
358
  }
@@ -174,40 +378,25 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
174
378
  hint,
175
379
  });
176
380
  } else if (this.step === 'dir') {
177
- const items = this.collectDirItems().map((d) => formatDir(d));
178
-
179
- this.selected = Math.min(this.selected, Math.max(0, Math.min(items.length, 10) - 1));
180
-
181
- drawPicker({
182
- title: `${verb}: directory`,
183
- items,
184
- selected: this.selected,
185
- input: this.input,
186
- hint: 'type to filter, or a path (/ ~ .) · ↑↓ move · ⏎ select · esc cancel',
187
- });
381
+ this.renderDirStep(verb);
382
+ } else if (this.step === 'source') {
383
+ this.renderSourceStep();
384
+ } else if (this.step === 'ref') {
385
+ this.renderRefStep();
188
386
  } 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
- });
387
+ this.renderTargetStep(verb);
388
+ } else if (this.step === 'confirm') {
389
+ this.renderConfirmStep();
203
390
  } else if (this.step === 'name') {
391
+ const where = this.isGitFlow() ? this.formatDestination() : formatDir(this.dir);
392
+
204
393
  drawPicker({
205
394
  title: `${verb}: name`,
206
395
  items: [],
207
396
  selected: -1,
208
397
  input: this.input,
209
398
  placeholder: formatDirName(this.dir),
210
- hint: this.refusal ?? `session name for ${formatDir(this.dir)} · ⏎ accept · esc back`,
399
+ hint: this.refusal ?? `session name for ${where} · ⏎ accept · esc back`,
211
400
  });
212
401
  } else {
213
402
  drawPicker({
@@ -216,34 +405,262 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
216
405
  selected: -1,
217
406
  input: this.input,
218
407
  placeholder: 'optional — ⏎ to start interactive',
219
- hint: this.refusal ?? 'first message for the session · ⏎ spawn · esc back',
408
+ hint:
409
+ this.pending?.label ??
410
+ this.refusal ??
411
+ 'first message for the session · ⏎ spawn · esc back',
220
412
  });
221
413
  }
222
414
 
223
415
  this.deps.scheduleStatus();
224
416
  }
225
417
 
418
+ // Leaves the flow for the screen it came from, dropping every answer
419
+ // still on its way.
420
+ private quitFlow() {
421
+ this.stopFlow();
422
+ this.deps.toBase();
423
+ }
424
+
425
+ // Ends the flow's generation: no request it sent changes anything when
426
+ // its answer comes.
427
+ private stopFlow() {
428
+ this.generation += 1;
429
+ this.pending = null;
430
+ this.listingSeq = null;
431
+ this.pathListingSeq = null;
432
+ }
433
+
434
+ // Whether an answer belongs to a flow that has since ended.
435
+ private isStale(generation: number): boolean {
436
+ return generation !== this.generation;
437
+ }
438
+
439
+ private renderDirStep(verb: string) {
440
+ const items = this.collectDirItems().map((d) => this.dirLabels.get(d) ?? formatDir(d));
441
+
442
+ this.selected = Math.min(this.selected, Math.max(0, Math.min(items.length, 10) - 1));
443
+
444
+ drawPicker({
445
+ title: `${verb}: ${this.findSource()?.label ?? 'directory'}`,
446
+ items,
447
+ selected: this.selected,
448
+ input: this.input,
449
+ hint:
450
+ this.pending?.label ??
451
+ this.refusal ??
452
+ `type to filter, or a path (/ ~ .) · ↑↓ move · ⏎ select${this.formatTabHint()} · esc cancel`,
453
+ });
454
+ }
455
+
456
+ private renderTargetStep(verb: string) {
457
+ this.selected = Math.min(this.selected, this.targets.length - 1);
458
+
459
+ const git = this.isGitFlow();
460
+ const what = git ? 'the repository' : formatDir(this.dir);
461
+
462
+ drawPicker({
463
+ title: `${verb}: target`,
464
+ items: this.targets.map((t) => formatTargetPick(t)),
465
+ selected: this.selected,
466
+ input: '',
467
+ hint: this.refusal ?? `where ${what} runs · ↑↓ move · ⏎ select · esc back`,
468
+ dimmed: new Set(
469
+ this.targets.flatMap((t, i) =>
470
+ t.takesWorkspace || (!git && t.available && t.inPlace) ? [] : [i],
471
+ ),
472
+ ),
473
+ });
474
+ }
475
+
476
+ private renderSourceStep() {
477
+ const items = this.collectCandidateItems();
478
+
479
+ this.selected = Math.min(this.selected, Math.max(0, Math.min(items.length, 10) - 1));
480
+
481
+ const scope = this.listedScope === null ? '' : ` · ${this.listedScope}`;
482
+ const listing = this.listingSeq === null ? null : 'listing… · esc stops';
483
+
484
+ drawPicker({
485
+ title: `spawn: ${this.findSource()?.label ?? ''}${scope}`,
486
+ items: items.map((item) => item.label),
487
+ selected: items.length === 0 || items[0]?.notice === true ? -1 : this.selected,
488
+ input: this.input,
489
+ hint:
490
+ this.pending?.label ??
491
+ this.refusal ??
492
+ listing ??
493
+ `type to filter, or a git URL · ↑↓ move · ⏎ select${this.formatTabHint()} · esc back`,
494
+ dimmed: new Set(items.flatMap((item, i) => (item.notice === true ? [i] : []))),
495
+ });
496
+ }
497
+
498
+ private renderRefStep() {
499
+ const items = this.collectRefItems();
500
+
501
+ this.selected = Math.min(this.selected, Math.max(0, Math.min(items.length, 10) - 1));
502
+
503
+ drawPicker({
504
+ title: `spawn: ref · ${this.repo?.label ?? ''}`,
505
+ items: items.map((item) => formatRef(item, this.repo?.head ?? null)),
506
+ selected: items.length === 0 ? -1 : this.selected,
507
+ input: this.input,
508
+ hint:
509
+ this.pending?.label ??
510
+ this.refusal ??
511
+ 'branch, tag, or full commit id · ↑↓ move · ⏎ select · esc back',
512
+ });
513
+ }
514
+
515
+ private renderConfirmStep() {
516
+ const ref = this.ref;
517
+ const target = this.findEffectiveTarget();
518
+ let refRow = '';
519
+
520
+ if (ref !== null) {
521
+ refRow = ref.ref === null ? `commit ${ref.sha}` : `${ref.ref} → ${ref.sha.slice(0, 12)}`;
522
+ }
523
+
524
+ drawPicker({
525
+ title: 'spawn: confirm',
526
+ items: [
527
+ `source ${this.repo?.url ?? ''}`,
528
+ `ref ${refRow}`,
529
+ `target ${target === null ? 'default' : `${target.id} (${target.provider})`}`,
530
+ `dest ${this.formatDestination()}`,
531
+ `agent ${this.agent}`,
532
+ ],
533
+ selected: -1,
534
+ input: this.input,
535
+ placeholder: 'destination on the target',
536
+ hint: this.refusal ?? 'destination on the target · ⏎ continue · esc back',
537
+ });
538
+ }
539
+
540
+ // The source the flow is on, or null on the local directory flow.
541
+ private findSource(): AnnouncedSource | null {
542
+ return this.sources?.[this.sourceIndex] ?? null;
543
+ }
544
+
545
+ // Whether the flow is choosing a git repository rather than a directory.
546
+ private isGitFlow(): boolean {
547
+ return this.findSource()?.kind === 'git';
548
+ }
549
+
550
+ // The hint that Tab moves to the next source, when there is one to move
551
+ // to.
552
+ private formatTabHint(): string {
553
+ const sources = this.sources ?? [];
554
+
555
+ if (this.resume || sources.length < 2) {
556
+ return '';
557
+ }
558
+
559
+ const next = sources[(this.sourceIndex + 1) % sources.length];
560
+
561
+ return next === undefined ? '' : ` · tab ${next.label}`;
562
+ }
563
+
564
+ private applyInput(value: string) {
565
+ // The target step takes no text: its list is short and fixed.
566
+ if (this.step === 'target') {
567
+ return;
568
+ }
569
+
570
+ this.input = value;
571
+
572
+ if (this.step === 'source') {
573
+ this.alternateURL = null;
574
+ }
575
+
576
+ // A pasted URL in the directory step is a repository, not a path, so
577
+ // the flow moves to the first source of repositories with it.
578
+ if (this.step === 'dir' && !this.resume && isGitURL(value) && !value.startsWith('/')) {
579
+ const index = this.sources?.findIndex((source) => source.kind === 'git') ?? -1;
580
+
581
+ if (index !== -1) {
582
+ this.openGitSource(index, value, { kind: 'none' });
583
+
584
+ return;
585
+ }
586
+ }
587
+
588
+ this.render();
589
+ }
590
+
591
+ // Stops waiting on the request in flight. Its answer, when it comes, is
592
+ // dropped. A spawn goes on on the daemon, which lists the session once
593
+ // it starts, so the flow leaves rather than offer to spawn it again.
594
+ private applyPendingCancel() {
595
+ const cancelled = this.pending;
596
+
597
+ this.pending = null;
598
+
599
+ if (cancelled?.kind === 'spawn') {
600
+ this.quitFlow();
601
+
602
+ return;
603
+ }
604
+
605
+ this.refusal = 'cancelled · esc back';
606
+
607
+ process.stdout.write(ansi.clear);
608
+ this.render();
609
+ }
610
+
226
611
  private applyCancel() {
612
+ // Esc leaves whatever step the flow is on, so a directory listing it
613
+ // was waiting on no longer opens its step.
614
+ this.pathListingSeq = null;
615
+
227
616
  if (this.step === 'agent') {
228
- this.deps.toBase();
617
+ this.quitFlow();
229
618
 
230
619
  return;
231
620
  }
232
621
 
622
+ // Esc first stops a listing in flight, keeping the step.
623
+ if (this.step === 'source' && this.listingSeq !== null) {
624
+ this.listingSeq = null;
625
+ this.listedKey = null;
626
+ this.listNotice = 'listing stopped — type to enter one';
627
+
628
+ this.render();
629
+
630
+ return;
631
+ }
632
+
633
+ const typed = this.input;
634
+
233
635
  this.input = '';
234
636
 
235
637
  if (this.step === 'dir') {
236
- this.selected = Math.max(
237
- 0,
238
- this.picks.findIndex((p) => p.agent === this.agent),
239
- );
240
-
241
- this.step = 'agent';
638
+ this.openAgentStep();
639
+ } else if (this.step === 'source') {
640
+ this.sourceDraft = typed;
641
+
642
+ this.applySourceCancel();
643
+ } else if (this.step === 'ref') {
644
+ this.input = this.repo?.label ?? '';
645
+ this.repo = null;
646
+ this.step = 'source';
242
647
  } else if (this.step === 'target') {
243
- this.step = 'dir';
648
+ this.applyTargetCancel();
649
+
650
+ return;
651
+ } else if (this.step === 'confirm') {
652
+ this.openRefStep();
244
653
  } else if (this.step === 'name') {
245
- this.openTargetOrDir('dir');
654
+ if (this.isGitFlow()) {
655
+ this.input = this.buildDestination();
656
+ this.step = 'confirm';
657
+ } else {
658
+ this.openTargetStep(() => {
659
+ this.step = 'dir';
660
+ });
661
+ }
246
662
  } else {
663
+ this.input = this.name;
247
664
  this.step = 'name';
248
665
  }
249
666
 
@@ -251,6 +668,54 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
251
668
  this.render();
252
669
  }
253
670
 
671
+ private openAgentStep() {
672
+ this.selected = Math.max(
673
+ 0,
674
+ this.picks.findIndex((p) => p.agent === this.agent),
675
+ );
676
+
677
+ this.step = 'agent';
678
+ }
679
+
680
+ // Leaves a git source step backwards: to the target step when the daemon
681
+ // has more than one target, else to the agent.
682
+ private applySourceCancel() {
683
+ if (this.targets.length >= 2) {
684
+ this.openTargetStep(() => {});
685
+
686
+ return;
687
+ }
688
+
689
+ this.openAgentStep();
690
+ }
691
+
692
+ // Leaves the target step backwards: a directory goes back to its step,
693
+ // and a repository to the first source of directories, else the agent.
694
+ private applyTargetCancel() {
695
+ this.sourceAction = { kind: 'none' };
696
+
697
+ if (!this.isGitFlow()) {
698
+ this.step = 'dir';
699
+
700
+ process.stdout.write(ansi.clear);
701
+ this.render();
702
+
703
+ return;
704
+ }
705
+
706
+ const index = this.sources?.findIndex((source) => source.kind === 'path') ?? -1;
707
+
708
+ if (index === -1) {
709
+ this.openAgentStep();
710
+ process.stdout.write(ansi.clear);
711
+ this.render();
712
+
713
+ return;
714
+ }
715
+
716
+ void this.openPathSource(index, null);
717
+ }
718
+
254
719
  private applySubmit() {
255
720
  if (this.step === 'agent') {
256
721
  const pick = this.picks[this.selected];
@@ -262,43 +727,43 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
262
727
  }
263
728
 
264
729
  this.agent = pick.agent;
265
- void this.openDirStep();
730
+ void this.openFirstSource();
266
731
 
267
732
  return;
268
733
  }
269
734
 
270
- if (this.step === 'dir') {
271
- const chosen =
272
- this.collectDirItems()[this.selected] ??
273
- resolvePathInput(this.input.trim(), process.cwd(), homedir());
735
+ if (this.step === 'source') {
736
+ void this.applySourceSubmit();
274
737
 
275
- if (chosen === null) {
276
- return;
277
- }
738
+ return;
739
+ }
278
740
 
279
- this.dir = chosen;
280
- this.input = '';
741
+ if (this.step === 'ref') {
742
+ void this.applyRefSubmit();
281
743
 
282
- this.openTargetOrDir('name');
283
- } else if (this.step === 'target') {
284
- const pick = this.targets[this.selected];
744
+ return;
745
+ }
285
746
 
286
- if (pick === undefined) {
287
- return;
288
- }
747
+ if (this.step === 'dir') {
748
+ void this.applyDirSubmit();
289
749
 
290
- const refusal = findTargetRefusal(pick);
750
+ return;
751
+ }
291
752
 
292
- if (refusal !== null) {
293
- this.refusal = refusal;
753
+ if (this.step === 'target') {
754
+ this.applyTargetSubmit();
755
+
756
+ return;
757
+ }
294
758
 
759
+ if (this.step === 'confirm') {
760
+ if (!this.applyDestination(this.input.trim())) {
295
761
  this.render();
296
762
 
297
763
  return;
298
764
  }
299
765
 
300
- this.target = pick;
301
- this.input = '';
766
+ this.input = this.name;
302
767
  this.step = 'name';
303
768
  } else if (this.step === 'name') {
304
769
  this.name = this.input.trim();
@@ -323,131 +788,1002 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
323
788
  this.render();
324
789
  }
325
790
 
326
- /**
327
- * A typed path completes against the filesystem, like a shell; anything
328
- * else fuzzy-filters the merged list.
329
- */
330
- private collectDirItems(): string[] {
331
- return resolvePathInput(this.input, process.cwd(), homedir()) === null
332
- ? pickMatches(this.dirs, this.input)
333
- : collectPathCompletions(this.input, process.cwd(), homedir());
334
- }
791
+ // Takes the directory step's choice: a listed or completed directory, a
792
+ // typed path, or else what the sources read the text as.
793
+ private async applyDirSubmit() {
794
+ const typed = this.input.trim();
335
795
 
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;
796
+ const chosen =
797
+ this.collectDirItems()[this.selected] ?? resolvePathInput(typed, process.cwd(), homedir());
798
+
799
+ if (chosen !== null) {
800
+ this.applyDir(chosen);
344
801
 
345
802
  return;
346
803
  }
347
804
 
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
-
358
- private async openDirStep() {
359
- let recent: string[] = [];
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 = [];
805
+ if (typed !== '' && this.sources !== null) {
806
+ await this.readTypedInput(typed, true);
372
807
  }
808
+ }
373
809
 
374
- try {
375
- const answer = await this.deps.sendRequest('dirs.list');
376
-
377
- const dirs = answer['dirs'];
378
-
379
- if (Array.isArray(dirs)) {
380
- recent = dirs.filter((d): d is string => typeof d === 'string');
381
- }
382
- } catch {}
810
+ private applyDir(dir: string) {
811
+ this.dir = dir;
812
+ this.input = '';
383
813
 
384
- this.dirs = collectDirs({
385
- cwd: process.cwd(),
386
- recent,
387
- roots: this.roots,
388
- zoxide: await collectZoxideDirs(),
814
+ this.openTargetStep(() => {
815
+ this.step = 'name';
389
816
  });
390
817
 
391
- this.input = '';
392
- this.selected = 0;
393
- this.step = 'dir';
394
-
395
818
  process.stdout.write(ansi.clear);
396
819
  this.render();
397
820
  }
398
821
 
399
- private async spawn(prompt: string) {
400
- try {
401
- const ok = await this.deps.sendRequest('session.spawn', {
402
- cwd: this.dir,
403
- name: this.name,
404
- prompt,
405
- cols: cols(),
406
- rows: this.deps.ptyRows(),
407
- ...(this.resume ? { resume: true } : {}),
408
- agent: this.agent,
409
- ...this.buildTargetParams(),
410
- });
411
-
412
- const spawned = this.deps.toMirrorSession(ok['session']);
413
-
414
- if (spawned !== null) {
415
- this.deps.upsertMirror(spawned);
822
+ private applyTargetSubmit() {
823
+ const pick = this.targets[this.selected];
416
824
 
417
- await this.deps.attach(spawned.id);
825
+ if (pick === undefined) {
826
+ return;
827
+ }
418
828
 
419
- return;
420
- }
421
- } catch (error) {
422
- // A refused spawn keeps the picker on the step that sent it, with the
423
- // entered text back in the input, so the user can fix the cause and
424
- // retry or back out.
425
- const reason =
426
- error instanceof DaemonError ? `${error.code}: ${error.message}` : String(error);
829
+ const refusal = findTargetRefusal(pick, this.isGitFlow());
427
830
 
428
- this.refusal = `${reason} · ⏎ retry · esc back`;
429
- this.input = this.resume ? this.name : prompt;
831
+ if (refusal !== null) {
832
+ this.refusal = refusal;
430
833
 
431
- process.stdout.write(ansi.clear);
432
834
  this.render();
433
835
 
434
836
  return;
435
837
  }
436
838
 
437
- this.deps.toBase();
438
- }
839
+ // A default destination belongs to the target it was built for.
840
+ if (pick.id !== this.target?.id && !this.destinationEdited) {
841
+ this.destination = '';
842
+ }
843
+
844
+ this.target = pick;
845
+ this.input = '';
846
+
847
+ if (this.isGitFlow()) {
848
+ this.openSourceStep();
849
+
850
+ return;
851
+ }
852
+
853
+ this.step = 'name';
854
+
855
+ process.stdout.write(ansi.clear);
856
+ this.render();
857
+ }
858
+
859
+ /**
860
+ * A typed path completes against the filesystem, like a shell; anything
861
+ * else fuzzy-filters the merged list.
862
+ */
863
+ private collectDirItems(): string[] {
864
+ return resolvePathInput(this.input, process.cwd(), homedir()) === null
865
+ ? pickMatches(this.dirs, this.input)
866
+ : collectPathCompletions(this.input, process.cwd(), homedir());
867
+ }
868
+
869
+ // The git source step's rows: the other URL form after a failed probe,
870
+ // the notice in place of a list the daemon could not give, or the listed
871
+ // candidates the input matches.
872
+ private collectCandidateItems(): {
873
+ label: string;
874
+ notice?: boolean;
875
+ candidate?: ListedCandidate;
876
+ url?: string;
877
+ }[] {
878
+ if (this.alternateURL !== null) {
879
+ return [{ label: `try ${this.alternateURL} instead`, url: this.alternateURL }];
880
+ }
881
+
882
+ if (this.listNotice !== null) {
883
+ return [{ label: this.listNotice, notice: true }];
884
+ }
885
+
886
+ const filter = isGitURL(this.input) ? '' : this.input.trim();
887
+
888
+ const byLabel = new Map(this.candidates.map((candidate) => [candidate.label, candidate]));
889
+
890
+ return pickMatches([...byLabel.keys()], filter).flatMap((label) => {
891
+ const candidate = byLabel.get(label);
892
+
893
+ if (candidate === undefined) {
894
+ return [];
895
+ }
896
+
897
+ return [
898
+ {
899
+ label: candidate.detail === null ? label : `${label} ${candidate.detail}`,
900
+ candidate,
901
+ },
902
+ ];
903
+ });
904
+ }
905
+
906
+ // The ref step's rows: the default branch first, then every branch and
907
+ // tag in the order the upstream lists them, filtered by name.
908
+ private collectRefItems(): ProbedRef[] {
909
+ const refs = this.repo?.refs ?? [];
910
+ const head = this.repo?.head ?? null;
911
+
912
+ const ordered = [
913
+ ...refs.filter((ref) => ref.kind === 'branch' && ref.name === head),
914
+ ...refs.filter((ref) => !(ref.kind === 'branch' && ref.name === head)),
915
+ ];
916
+
917
+ const names = pickMatches([...new Set(ordered.map((ref) => ref.name))], this.input.trim());
918
+
919
+ return names.flatMap((name) => ordered.filter((ref) => ref.name === name));
920
+ }
921
+
922
+ // Opens the source after the current one, wrapping round.
923
+ private openNextSource() {
924
+ const sources = this.sources ?? [];
925
+
926
+ if (sources.length < 2) {
927
+ return;
928
+ }
929
+
930
+ this.openSource((this.sourceIndex + 1) % sources.length);
931
+ }
932
+
933
+ private openSource(index: number) {
934
+ const source = this.sources?.[index];
935
+
936
+ if (source?.kind === 'path') {
937
+ void this.openPathSource(index, null);
938
+ } else if (source?.kind === 'git') {
939
+ this.openGitSource(index, '', { kind: 'none' });
940
+ }
941
+ }
942
+
943
+ // Lists a source of directories and opens the directory step on it, with
944
+ // the client's own directory first. The flow stays where it is until the
945
+ // listing answers, and an answer that comes after the flow moved on is
946
+ // dropped.
947
+ private async openPathSource(index: number, scope: string | null) {
948
+ const source = this.sources?.[index];
949
+ const generation = this.generation;
950
+
951
+ this.requestSeq += 1;
952
+
953
+ const seq = this.requestSeq;
954
+
955
+ this.pathListingSeq = seq;
956
+
957
+ let candidates: ListedCandidate[] = [];
958
+
959
+ if (source !== undefined) {
960
+ try {
961
+ const answer = await this.deps.sendRequest('sources.list', {
962
+ source: source.id,
963
+ ...(scope === null ? {} : { scope }),
964
+ });
965
+
966
+ candidates = parseCandidates(answer['candidates']);
967
+ } catch {}
968
+ }
969
+
970
+ if (this.isStale(generation) || this.pathListingSeq !== seq) {
971
+ return;
972
+ }
973
+
974
+ this.pathListingSeq = null;
439
975
 
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 {};
976
+ const listed = candidates.flatMap((candidate) =>
977
+ candidate.pick.kind === 'path' ? [[candidate.pick.dir, candidate.label] as const] : [],
978
+ );
979
+
980
+ this.sourceIndex = index;
981
+
982
+ // A git listing dropped here never fills its candidates, so its source
983
+ // step lists again when it reopens.
984
+ if (this.listingSeq !== null) {
985
+ this.listingSeq = null;
986
+ this.listedKey = null;
446
987
  }
447
988
 
448
- return this.target.inPlace
449
- ? { target: this.target.id }
450
- : { target: this.target.id, workspace: { kind: 'path', path: this.dir } };
989
+ this.dirLabels = new Map(listed);
990
+
991
+ this.dirs = [...new Set([process.cwd(), ...listed.map(([dir]) => dir)])];
992
+ this.input = '';
993
+ this.selected = 0;
994
+ this.step = 'dir';
995
+
996
+ process.stdout.write(ansi.clear);
997
+ this.render();
998
+ }
999
+
1000
+ // Moves the flow to a source of git repositories, carrying the typed text
1001
+ // across: the target step first when the daemon has more than one target
1002
+ // and the flow had no repository target yet, then the source step, which
1003
+ // acts as asked once it opens.
1004
+ private openGitSource(index: number, draft: string, action: SourceStepAction) {
1005
+ const fromGit = this.isGitFlow();
1006
+
1007
+ this.pathListingSeq = null;
1008
+ this.sourceIndex = index;
1009
+ this.sourceDraft = draft;
1010
+ this.sourceAction = action;
1011
+
1012
+ if (fromGit) {
1013
+ this.openSourceStep();
1014
+
1015
+ return;
1016
+ }
1017
+
1018
+ if (this.targets.length >= 2) {
1019
+ this.openTargetStep(() => {});
1020
+ process.stdout.write(ansi.clear);
1021
+ this.render();
1022
+
1023
+ return;
1024
+ }
1025
+
1026
+ this.target = this.targets[0] ?? null;
1027
+
1028
+ this.openSourceStep();
1029
+ }
1030
+
1031
+ // Opens the git source step with its draft, listing the source again
1032
+ // when the source or the target changed since the last listing.
1033
+ private openSourceStep() {
1034
+ this.step = 'source';
1035
+ this.input = this.sourceDraft;
1036
+ this.selected = 0;
1037
+ this.alternateURL = null;
1038
+
1039
+ const action = this.sourceAction;
1040
+
1041
+ this.sourceAction = { kind: 'none' };
1042
+
1043
+ const key = `${this.findSource()?.id ?? ''}\n${this.target?.id ?? ''}`;
1044
+ const stale = this.listedKey !== key;
1045
+
1046
+ if (stale) {
1047
+ this.listedKey = key;
1048
+ this.candidates = [];
1049
+ this.listedScope = null;
1050
+ this.listNotice = null;
1051
+ }
1052
+
1053
+ process.stdout.write(ansi.clear);
1054
+ this.render();
1055
+
1056
+ // A scope a reading asked for is listed in place of the source's own
1057
+ // default, for the target the step now has.
1058
+ if (action.kind === 'browse') {
1059
+ void this.loadCandidates(action.scope);
1060
+ } else if (stale) {
1061
+ void this.loadCandidates(null);
1062
+ }
1063
+
1064
+ if (action.kind === 'probe') {
1065
+ void this.checkRepoAccess(this.sourceDraft, action.url);
1066
+ }
1067
+ }
1068
+
1069
+ // The target every source request is made for: the chosen one, or none
1070
+ // for the daemon's default when there is no choice to make.
1071
+ private buildTargetParam(): Readonly<Record<string, string>> {
1072
+ return this.target === null ? {} : { target: this.target.id };
1073
+ }
1074
+
1075
+ // Lists the git source's candidates behind the step, under a scope or
1076
+ // the source's own default. A daemon that cannot list them leaves a
1077
+ // notice row. Typing goes on meanwhile, and Esc stops the listing.
1078
+ private async loadCandidates(scope: string | null) {
1079
+ const source = this.findSource();
1080
+ const generation = this.generation;
1081
+
1082
+ if (source === null) {
1083
+ return;
1084
+ }
1085
+
1086
+ this.requestSeq += 1;
1087
+
1088
+ const seq = this.requestSeq;
1089
+
1090
+ this.listingSeq = seq;
1091
+
1092
+ this.render();
1093
+
1094
+ let answer: Readonly<Record<string, unknown>> | null = null;
1095
+ let failure: unknown = null;
1096
+
1097
+ try {
1098
+ const params = {
1099
+ source: source.id,
1100
+ ...(scope === null ? {} : { scope }),
1101
+ ...this.buildTargetParam(),
1102
+ };
1103
+
1104
+ answer = await this.deps.sendRequest('sources.list', params);
1105
+ } catch (error) {
1106
+ failure = error;
1107
+ }
1108
+
1109
+ if (this.isStale(generation) || this.listingSeq !== seq) {
1110
+ return;
1111
+ }
1112
+
1113
+ // An answer for a source the flow has left fills nothing, so that
1114
+ // source lists again when its step reopens.
1115
+ if (this.findSource()?.id !== source.id) {
1116
+ this.listingSeq = null;
1117
+ this.listedKey = null;
1118
+
1119
+ return;
1120
+ }
1121
+
1122
+ this.listingSeq = null;
1123
+
1124
+ if (answer === null) {
1125
+ this.candidates = [];
1126
+ this.listedScope = scope;
1127
+ this.listNotice = `${formatListFailure(failure)} — type to enter one`;
1128
+ } else {
1129
+ this.candidates = parseCandidates(answer['candidates']);
1130
+ this.listedScope = typeof answer['scope'] === 'string' ? answer['scope'] : scope;
1131
+
1132
+ this.listNotice =
1133
+ this.candidates.length === 0 && this.listedScope !== null
1134
+ ? `nothing listed for ${this.listedScope}`
1135
+ : null;
1136
+ }
1137
+
1138
+ // The list answers behind the flow, so only the source step's own
1139
+ // selection starts over.
1140
+ if (this.step === 'source') {
1141
+ this.selected = 0;
1142
+
1143
+ process.stdout.write(ansi.clear);
1144
+ this.render();
1145
+ }
1146
+ }
1147
+
1148
+ // Takes the git source step's choice: the other URL form on offer, a
1149
+ // typed git URL, what the source reads typed text as, the selected
1150
+ // candidate, or else what another source reads the text as.
1151
+ private async applySourceSubmit() {
1152
+ const typed = this.input.trim();
1153
+ const item = this.collectCandidateItems()[this.selected];
1154
+
1155
+ if (item?.url !== undefined) {
1156
+ await this.checkRepoAccess(item.url, item.url);
1157
+
1158
+ return;
1159
+ }
1160
+
1161
+ if (isGitURL(typed)) {
1162
+ await this.checkRepoAccess(typed, typed);
1163
+
1164
+ return;
1165
+ }
1166
+
1167
+ if (typed !== '') {
1168
+ const reading = await this.sendInterpret(this.sourceIndex, typed);
1169
+
1170
+ if (reading === null) {
1171
+ return;
1172
+ }
1173
+
1174
+ if (canTakeReading('git', reading)) {
1175
+ this.applyReading(this.sourceIndex, reading, typed);
1176
+
1177
+ return;
1178
+ }
1179
+ }
1180
+
1181
+ const candidate = item?.candidate;
1182
+
1183
+ if (candidate?.pick.kind === 'git') {
1184
+ await this.checkRepoAccess(candidate.label, candidate.pick.url);
1185
+
1186
+ return;
1187
+ }
1188
+
1189
+ if (typed !== '') {
1190
+ await this.readTypedInput(typed, false);
1191
+ }
1192
+ }
1193
+
1194
+ // Asks the sources in order what typed text is, the flow's own first when
1195
+ // asked, and moves the flow to the first that reads it as something its
1196
+ // kind takes. Text none reads is refused with the reason.
1197
+ private async readTypedInput(typed: string, withOwn: boolean) {
1198
+ const order = (this.sources ?? []).flatMap((_, index) =>
1199
+ index === this.sourceIndex ? [] : [index],
1200
+ );
1201
+
1202
+ for (const index of withOwn ? [this.sourceIndex, ...order] : order) {
1203
+ const reading = await this.sendInterpret(index, typed);
1204
+
1205
+ if (reading === null) {
1206
+ return;
1207
+ }
1208
+
1209
+ if (canTakeReading(this.sources?.[index]?.kind, reading)) {
1210
+ this.applyReading(index, reading, typed);
1211
+
1212
+ return;
1213
+ }
1214
+ }
1215
+
1216
+ this.refusal = `no source reads ${typed} · esc back`;
1217
+
1218
+ process.stdout.write(ansi.clear);
1219
+ this.render();
1220
+ }
1221
+
1222
+ // Asks one source what typed text is. Null when Esc cancelled the
1223
+ // request; a refused request reads as nothing.
1224
+ private async sendInterpret(index: number, typed: string): Promise<Interpretation | null> {
1225
+ const source = this.sources?.[index];
1226
+
1227
+ if (source === undefined) {
1228
+ return { kind: 'none' };
1229
+ }
1230
+
1231
+ const answered = await this.sendPending('interpret', `reading ${typed}…`, 'sources.interpret', {
1232
+ source: source.id,
1233
+ input: typed,
1234
+ ...(source.kind === 'git' ? this.buildTargetParam() : {}),
1235
+ });
1236
+
1237
+ if (answered === null) {
1238
+ return null;
1239
+ }
1240
+
1241
+ return answered.ok ? parseInterpretation(answered.answer) : { kind: 'none' };
1242
+ }
1243
+
1244
+ // Moves the flow to what a source read typed text as: its scope listed,
1245
+ // the directory chosen, or the repository probed.
1246
+ private applyReading(index: number, reading: Interpretation, typed: string) {
1247
+ if (reading.kind === 'none') {
1248
+ return;
1249
+ }
1250
+
1251
+ if (reading.kind === 'path') {
1252
+ this.sourceIndex = index;
1253
+
1254
+ this.applyDir(reading.dir);
1255
+
1256
+ return;
1257
+ }
1258
+
1259
+ const scope = reading.kind === 'browse' ? reading.scope : null;
1260
+
1261
+ if (this.sources?.[index]?.kind === 'path') {
1262
+ void this.openPathSource(index, scope);
1263
+
1264
+ return;
1265
+ }
1266
+
1267
+ // The step lists the scope or probes the repository once it opens,
1268
+ // after any target step, so the request is made for the spawn's target.
1269
+ if (index !== this.sourceIndex || this.step !== 'source') {
1270
+ const action: SourceStepAction =
1271
+ reading.kind === 'git'
1272
+ ? { kind: 'probe', url: reading.url }
1273
+ : { kind: 'browse', scope: reading.scope };
1274
+
1275
+ const draft = reading.kind === 'git' ? typed : '';
1276
+
1277
+ this.openGitSource(index, draft, action);
1278
+
1279
+ return;
1280
+ }
1281
+
1282
+ if (reading.kind === 'browse') {
1283
+ this.input = '';
1284
+ void this.loadCandidates(reading.scope);
1285
+
1286
+ return;
1287
+ }
1288
+
1289
+ void this.checkRepoAccess(typed, reading.url);
1290
+ }
1291
+
1292
+ // Sends a request the step waits on, and answers with its result, or
1293
+ // null when Esc cancelled it before the answer came.
1294
+ private async sendPending(
1295
+ kind: PendingRequest['kind'],
1296
+ label: string,
1297
+ m: string,
1298
+ p: Readonly<Record<string, unknown>>,
1299
+ ): Promise<
1300
+ | { readonly ok: true; readonly answer: Readonly<Record<string, unknown>> }
1301
+ | { readonly ok: false; readonly error: unknown }
1302
+ | null
1303
+ > {
1304
+ this.requestSeq += 1;
1305
+
1306
+ const seq = this.requestSeq;
1307
+ const generation = this.generation;
1308
+ const esc = kind === 'spawn' ? 'esc stops waiting; the session still lists' : 'esc cancels';
1309
+
1310
+ this.pending = { label: `${label} · ${esc}`, seq, kind };
1311
+
1312
+ this.render();
1313
+
1314
+ let result:
1315
+ | { readonly ok: true; readonly answer: Readonly<Record<string, unknown>> }
1316
+ | { readonly ok: false; readonly error: unknown };
1317
+
1318
+ try {
1319
+ result = { ok: true, answer: await this.deps.sendRequest(m, p) };
1320
+ } catch (error) {
1321
+ result = { ok: false, error };
1322
+ }
1323
+
1324
+ if (this.isStale(generation) || this.pending?.seq !== seq) {
1325
+ return null;
1326
+ }
1327
+
1328
+ this.pending = null;
1329
+
1330
+ return result;
1331
+ }
1332
+
1333
+ // Checks that the daemon's host can read the repository at this URL for
1334
+ // the spawn's target, and opens the ref step with its refs. A failure
1335
+ // stays on the source step with git's error and offers the other URL
1336
+ // form the daemon returns, when there is one.
1337
+ private async checkRepoAccess(label: string, url: string) {
1338
+ const probed = await this.sendPending('probe', `checking access to ${url}…`, 'git.probe', {
1339
+ url,
1340
+ ...this.buildTargetParam(),
1341
+ });
1342
+
1343
+ if (probed === null) {
1344
+ return;
1345
+ }
1346
+
1347
+ if (!probed.ok) {
1348
+ this.alternateURL = findAlternateURL(probed.error);
1349
+ this.selected = 0;
1350
+ this.refusal = `${formatError(probed.error)} · esc back`;
1351
+ this.input = label;
1352
+
1353
+ process.stdout.write(ansi.clear);
1354
+ this.render();
1355
+
1356
+ return;
1357
+ }
1358
+
1359
+ const repo = buildProbedRepo(label, url, probed.answer);
1360
+
1361
+ // A destination typed for one repository is not meant for another.
1362
+ if (repo.url !== this.destinationRepoURL) {
1363
+ this.destinationEdited = false;
1364
+ this.destination = '';
1365
+ }
1366
+
1367
+ this.repo = repo;
1368
+ this.ref = null;
1369
+
1370
+ this.openRefStep();
1371
+ process.stdout.write(ansi.clear);
1372
+ this.render();
1373
+ }
1374
+
1375
+ // Reads the repository's refs again after the commit the spawn pinned
1376
+ // left the upstream, and opens the ref step on them with the refusal.
1377
+ private async refreshRefs(reason: string) {
1378
+ const repo = this.repo;
1379
+
1380
+ if (repo === null) {
1381
+ return;
1382
+ }
1383
+
1384
+ this.openRefStep();
1385
+
1386
+ const probed = await this.sendPending('probe', `re-reading refs of ${repo.url}…`, 'git.probe', {
1387
+ url: repo.url,
1388
+ ...this.buildTargetParam(),
1389
+ });
1390
+
1391
+ if (probed === null) {
1392
+ return;
1393
+ }
1394
+
1395
+ if (probed.ok) {
1396
+ this.repo = buildProbedRepo(repo.label, repo.url, probed.answer);
1397
+ this.refusal = `refs re-read · ${reason} · esc back`;
1398
+ } else {
1399
+ this.refusal = `${reason} · ${formatError(probed.error)} · esc back`;
1400
+ }
1401
+
1402
+ process.stdout.write(ansi.clear);
1403
+ this.render();
1404
+ }
1405
+
1406
+ private openRefStep() {
1407
+ this.step = 'ref';
1408
+ this.input = this.ref === null ? '' : (this.ref.ref ?? this.ref.sha);
1409
+ this.selected = 0;
1410
+ }
1411
+
1412
+ private async applyRefSubmit() {
1413
+ const typed = this.input.trim();
1414
+ const named = this.repo?.refs.find((ref) => ref.name === typed);
1415
+ const item = this.collectRefItems()[this.selected];
1416
+
1417
+ if (named !== undefined) {
1418
+ this.applyRef({ ref: named.name, sha: named.sha });
1419
+
1420
+ return;
1421
+ }
1422
+
1423
+ if (FULL_SHA_PATTERN.test(typed)) {
1424
+ this.applyRef({ ref: null, sha: typed });
1425
+
1426
+ return;
1427
+ }
1428
+
1429
+ // An abbreviated commit id could name another commit by the time the
1430
+ // clone runs, so only a full one pins the checkout.
1431
+ if (SHORT_SHA_PATTERN.test(typed)) {
1432
+ this.refusal = 'full commit id required · esc back';
1433
+
1434
+ this.render();
1435
+
1436
+ return;
1437
+ }
1438
+
1439
+ if (item !== undefined) {
1440
+ this.applyRef({ ref: item.name, sha: item.sha });
1441
+
1442
+ return;
1443
+ }
1444
+
1445
+ if (typed === '' || this.repo === null) {
1446
+ return;
1447
+ }
1448
+
1449
+ // A ref the listing leaves out, such as `refs/tags/v1`, is resolved by
1450
+ // the daemon the way the spawn would resolve it.
1451
+ const probed = await this.sendPending('probe', `resolving ${typed}…`, 'git.probe', {
1452
+ url: this.repo.url,
1453
+ ref: typed,
1454
+ ...this.buildTargetParam(),
1455
+ });
1456
+
1457
+ if (probed === null) {
1458
+ return;
1459
+ }
1460
+
1461
+ const resolved =
1462
+ probed.ok && isRecord(probed.answer['resolved']) ? probed.answer['resolved'] : {};
1463
+
1464
+ if (typeof resolved['sha'] === 'string') {
1465
+ this.applyRef({ ref: typed, sha: resolved['sha'] });
1466
+
1467
+ return;
1468
+ }
1469
+
1470
+ this.refusal = probed.ok
1471
+ ? `the daemon resolved no commit for ${typed} · esc back`
1472
+ : `${formatError(probed.error)} · esc back`;
1473
+
1474
+ process.stdout.write(ansi.clear);
1475
+ this.render();
1476
+ }
1477
+
1478
+ private applyRef(ref: ChosenRef) {
1479
+ // A default destination belongs to the commit it was built for.
1480
+ if (!this.destinationEdited) {
1481
+ this.destination = '';
1482
+ }
1483
+
1484
+ this.ref = ref;
1485
+ this.input = '';
1486
+
1487
+ this.openConfirmStep();
1488
+ process.stdout.write(ansi.clear);
1489
+ this.render();
1490
+ }
1491
+
1492
+ // Opens the confirm screen with the destination the user last accepted,
1493
+ // else the default under the target's workspace root. A root the target
1494
+ // cannot take leaves the destination empty with the reason.
1495
+ private openConfirmStep() {
1496
+ this.step = 'confirm';
1497
+ this.destinationAttempt = 1;
1498
+
1499
+ if (this.destination === '' && this.repo !== null && this.ref !== null) {
1500
+ const target = this.findEffectiveTarget();
1501
+
1502
+ const root = resolveWorkspaceRoot(this.workspaces, {
1503
+ id: target?.id ?? 'local',
1504
+ inPlace: target?.inPlace ?? true,
1505
+ });
1506
+
1507
+ if (root.ok) {
1508
+ this.destinationRepoURL = this.repo.url;
1509
+
1510
+ this.destination = buildWorkspaceDestination({
1511
+ root: root.root,
1512
+ url: this.repo.url,
1513
+ ref: this.ref.ref,
1514
+ sha: this.ref.sha,
1515
+ });
1516
+ } else {
1517
+ this.refusal = root.message;
1518
+ }
1519
+ }
1520
+
1521
+ this.input = this.buildDestination();
1522
+ }
1523
+
1524
+ // Takes the confirm screen's destination: absolute on its target, with a
1525
+ // leading `~` expanded only on the daemon's own machine. False, with the
1526
+ // reason shown, when the target cannot take it.
1527
+ private applyDestination(typed: string): boolean {
1528
+ const inPlace = this.findEffectiveTarget()?.inPlace ?? true;
1529
+
1530
+ const dir =
1531
+ inPlace && (typed === '~' || typed.startsWith('~/'))
1532
+ ? `${resolveHomeDir()}${typed.slice(1)}`
1533
+ : typed;
1534
+
1535
+ if (!dir.startsWith('/')) {
1536
+ this.refusal = inPlace
1537
+ ? 'the destination must be an absolute path'
1538
+ : 'the destination must be an absolute path on the target; ~ is not expanded there';
1539
+
1540
+ return false;
1541
+ }
1542
+
1543
+ if (dir !== this.buildDestination()) {
1544
+ this.destination = dir;
1545
+ this.destinationAttempt = 1;
1546
+ this.destinationEdited = true;
1547
+ }
1548
+
1549
+ this.destinationRepoURL = this.repo?.url ?? null;
1550
+ this.dir = this.buildDestination();
1551
+
1552
+ return true;
1553
+ }
1554
+
1555
+ // The destination with the suffix a refused spawn added.
1556
+ private buildDestination(): string {
1557
+ return this.destinationAttempt === 1
1558
+ ? this.destination
1559
+ : `${this.destination}-${this.destinationAttempt}`;
1560
+ }
1561
+
1562
+ private formatDestination(): string {
1563
+ return `${this.findEffectiveTarget()?.id ?? 'local'}:${this.buildDestination()}`;
1564
+ }
1565
+
1566
+ // The target the spawn runs on: the chosen one, else the daemon's
1567
+ // default, else null when the daemon lists no targets.
1568
+ private findEffectiveTarget(): TargetPick | null {
1569
+ return this.target ?? this.targets.find((t) => t.isDefault) ?? null;
1570
+ }
1571
+
1572
+ // Opens the target step when the daemon has more than one target, and
1573
+ // the fallback step otherwise. The chosen target is selected, else for a
1574
+ // repository the default when it takes a workspace and the first target
1575
+ // that does when it does not, else the default.
1576
+ private openTargetStep(fallback: () => void) {
1577
+ if (this.targets.length < 2) {
1578
+ // The one target is the spawn's, default or not, so the spawn names it
1579
+ // and its provider decides whether a directory runs in place.
1580
+ this.target = this.targets[0] ?? null;
1581
+
1582
+ fallback();
1583
+
1584
+ return;
1585
+ }
1586
+
1587
+ const preferred =
1588
+ this.target ??
1589
+ (this.isGitFlow()
1590
+ ? (this.targets.find((t) => t.isDefault && t.takesWorkspace) ??
1591
+ this.targets.find((t) => t.takesWorkspace))
1592
+ : undefined) ??
1593
+ this.targets.find((t) => t.isDefault);
1594
+
1595
+ this.selected = Math.max(
1596
+ 0,
1597
+ this.targets.findIndex((t) => t.id === preferred?.id),
1598
+ );
1599
+
1600
+ this.input = '';
1601
+ this.step = 'target';
1602
+ }
1603
+
1604
+ // Reads the daemon's targets and sources once the agent is chosen, and
1605
+ // opens the first source: the first of directories for an adopt, and the
1606
+ // local directory flow for a daemon that offers no sources.
1607
+ private async openFirstSource() {
1608
+ const generation = this.generation;
1609
+ let listed: Readonly<Record<string, unknown>> = {};
1610
+
1611
+ try {
1612
+ listed = await this.deps.sendRequest('agents.list');
1613
+ } catch {}
1614
+
1615
+ if (this.isStale(generation)) {
1616
+ return;
1617
+ }
1618
+
1619
+ const picks = collectTargetPicks(listed);
1620
+
1621
+ // An adopt resumes a session from its history on this host, which a
1622
+ // fresh checkout elsewhere does not hold, so it is offered only the
1623
+ // targets that run here.
1624
+ this.targets = this.resume ? picks.filter((t) => t.available && t.inPlace) : picks;
1625
+
1626
+ const announced = parseAnnouncedSources(listed['sources']);
1627
+ const index = this.resume ? announced.findIndex((source) => source.kind === 'path') : 0;
1628
+
1629
+ if (announced.length > 0 && index !== -1) {
1630
+ this.sources = announced;
1631
+
1632
+ this.openSource(index);
1633
+
1634
+ return;
1635
+ }
1636
+
1637
+ this.sources = null;
1638
+
1639
+ await this.openLocalDirs(generation);
1640
+ }
1641
+
1642
+ // The local directory flow of a daemon that offers no sources: the
1643
+ // client's own directory, the daemon's spawn history, the configured
1644
+ // roots, and zoxide's list on this host.
1645
+ private async openLocalDirs(generation: number) {
1646
+ let recent: string[] = [];
1647
+
1648
+ try {
1649
+ const answer = await this.deps.sendRequest('dirs.list');
1650
+
1651
+ const dirs = answer['dirs'];
1652
+
1653
+ if (Array.isArray(dirs)) {
1654
+ recent = dirs.filter((d): d is string => typeof d === 'string');
1655
+ }
1656
+ } catch {}
1657
+
1658
+ const zoxide = await collectZoxideDirs();
1659
+
1660
+ if (this.isStale(generation)) {
1661
+ return;
1662
+ }
1663
+
1664
+ this.dirs = collectDirs({ cwd: process.cwd(), recent, roots: this.roots, zoxide });
1665
+
1666
+ this.dirLabels = new Map();
1667
+
1668
+ this.sourceIndex = -1;
1669
+ this.input = '';
1670
+ this.selected = 0;
1671
+ this.step = 'dir';
1672
+
1673
+ process.stdout.write(ansi.clear);
1674
+ this.render();
1675
+ }
1676
+
1677
+ private async spawn(prompt: string) {
1678
+ const params = {
1679
+ cwd: this.dir,
1680
+ name: this.name,
1681
+ prompt,
1682
+ cols: cols(),
1683
+ rows: this.deps.ptyRows(),
1684
+ ...(this.resume ? { resume: true } : {}),
1685
+ agent: this.agent,
1686
+ ...this.buildSourceParams(),
1687
+ };
1688
+
1689
+ const label = this.isGitFlow() ? 'materializing the workspace…' : 'spawning…';
1690
+
1691
+ const spawned = await this.sendPending('spawn', label, 'session.spawn', params);
1692
+
1693
+ if (spawned === null) {
1694
+ return;
1695
+ }
1696
+
1697
+ if (!spawned.ok) {
1698
+ this.applySpawnRefusal(spawned.error, prompt);
1699
+
1700
+ return;
1701
+ }
1702
+
1703
+ const session = this.deps.toMirrorSession(spawned.answer['session']);
1704
+
1705
+ if (session === null) {
1706
+ this.quitFlow();
1707
+
1708
+ return;
1709
+ }
1710
+
1711
+ this.stopFlow();
1712
+ this.deps.upsertMirror(session);
1713
+
1714
+ await this.deps.attach(session.id);
1715
+ }
1716
+
1717
+ // A refused git workspace spawn returns to the step that can fix it. Any
1718
+ // other refusal keeps the picker on the step that sent it, with the
1719
+ // entered text back in the input, so the user can fix the cause and retry
1720
+ // or back out.
1721
+ private applySpawnRefusal(error: unknown, prompt: string) {
1722
+ const reason = formatError(error);
1723
+ const code = error instanceof DaemonError ? error.code : 'internal';
1724
+ const step = this.isGitFlow() ? pickRefusalStep(code) : null;
1725
+
1726
+ if (step === 'ref') {
1727
+ void this.refreshRefs(reason);
1728
+
1729
+ return;
1730
+ }
1731
+
1732
+ if (step === 'destination') {
1733
+ this.destinationAttempt += 1;
1734
+ this.step = 'confirm';
1735
+ this.input = this.buildDestination();
1736
+ this.refusal = `${reason} · ⏎ use ${this.buildDestination()} · esc back`;
1737
+ } else if (step === 'source') {
1738
+ this.input = this.repo?.label ?? '';
1739
+ this.repo = null;
1740
+ this.step = 'source';
1741
+ this.refusal = `${reason} · esc back`;
1742
+ } else if (step === 'confirm') {
1743
+ const phase =
1744
+ error instanceof DaemonError && typeof error.data?.['phase'] === 'string'
1745
+ ? ` while ${error.data['phase']}`
1746
+ : '';
1747
+
1748
+ this.step = 'confirm';
1749
+ this.input = this.buildDestination();
1750
+ this.refusal = `${reason}${phase} · esc back`;
1751
+ } else {
1752
+ this.refusal = `${reason} · ⏎ retry · esc back`;
1753
+ this.input = this.resume ? this.name : prompt;
1754
+ }
1755
+
1756
+ process.stdout.write(ansi.clear);
1757
+ this.render();
1758
+ }
1759
+
1760
+ // What the spawn sends for its source. A git repository goes as a git
1761
+ // workspace at the commit the confirm screen showed, with the ref it was
1762
+ // chosen by. A chosen target goes with the spawn; a local directory runs
1763
+ // in place when the spawn's target is on the daemon's own machine, and is
1764
+ // materialized at the same path from its pushed HEAD on any other.
1765
+ private buildSourceParams(): Readonly<Record<string, unknown>> {
1766
+ const target = this.buildTargetParam();
1767
+
1768
+ if (this.isGitFlow() && this.repo !== null && this.ref !== null) {
1769
+ return {
1770
+ ...target,
1771
+ workspace: {
1772
+ kind: 'git',
1773
+ url: this.repo.url,
1774
+ ...(this.ref.ref === null ? {} : { ref: this.ref.ref }),
1775
+ sha: this.ref.sha,
1776
+ },
1777
+ };
1778
+ }
1779
+
1780
+ const effective = this.findEffectiveTarget();
1781
+
1782
+ if (effective === null || effective.inPlace) {
1783
+ return target;
1784
+ }
1785
+
1786
+ return { ...target, workspace: { kind: 'path', path: this.dir } };
451
1787
  }
452
1788
  }
453
1789
 
@@ -462,15 +1798,152 @@ function formatTargetPick(pick: TargetPick): string {
462
1798
  return `${pick.id} ${notes.join(' · ')}`;
463
1799
  }
464
1800
 
465
- // Why a local directory cannot run on a target, or null when it can.
466
- function findTargetRefusal(pick: TargetPick): string | null {
1801
+ // Why a source cannot run on a target, or null when it can. A git
1802
+ // repository needs a target that takes a workspace; a local directory also
1803
+ // runs in place on the daemon's own machine.
1804
+ function findTargetRefusal(pick: TargetPick, git: boolean): string | null {
467
1805
  if (!pick.available) {
468
1806
  return `target '${pick.id}' is unavailable on this daemon · esc back`;
469
1807
  }
470
1808
 
471
- if (!pick.inPlace && !pick.takesWorkspace) {
1809
+ if (!pick.takesWorkspace && (git || !pick.inPlace)) {
472
1810
  return `target '${pick.id}' cannot take a workspace · esc back`;
473
1811
  }
474
1812
 
475
1813
  return null;
476
1814
  }
1815
+
1816
+ // Whether a source of a kind can act on a reading: a scope it lists, or a
1817
+ // pick of its own kind.
1818
+ function canTakeReading(
1819
+ kind: AnnouncedSource['kind'] | undefined,
1820
+ reading: Interpretation,
1821
+ ): boolean {
1822
+ return reading.kind === 'browse' || (reading.kind !== 'none' && reading.kind === kind);
1823
+ }
1824
+
1825
+ function formatRef(ref: ProbedRef, head: string | null): string {
1826
+ const notes = [
1827
+ ...(ref.kind === 'tag' ? ['tag'] : []),
1828
+ ...(ref.kind === 'branch' && ref.name === head ? ['default'] : []),
1829
+ ref.sha.slice(0, 7),
1830
+ ];
1831
+
1832
+ return `${ref.name} ${notes.join(' · ')}`;
1833
+ }
1834
+
1835
+ function formatError(error: unknown): string {
1836
+ return error instanceof DaemonError ? `${error.code}: ${error.message}` : String(error);
1837
+ }
1838
+
1839
+ // Why the daemon gave no list, in the words the notice row uses.
1840
+ function formatListFailure(error: unknown): string {
1841
+ if (!(error instanceof DaemonError)) {
1842
+ return String(error);
1843
+ }
1844
+
1845
+ if (error.code === 'unknown_method') {
1846
+ return 'this daemon cannot list sources';
1847
+ }
1848
+
1849
+ return error.message;
1850
+ }
1851
+
1852
+ // The other URL form a refused probe holds, or null when it holds none.
1853
+ function findAlternateURL(error: unknown): string | null {
1854
+ const alternates = error instanceof DaemonError ? error.data?.['alternates'] : undefined;
1855
+ const first: unknown = Array.isArray(alternates) ? alternates[0] : undefined;
1856
+
1857
+ return typeof first === 'string' ? first : null;
1858
+ }
1859
+
1860
+ function buildProbedRepo(
1861
+ label: string,
1862
+ url: string,
1863
+ answer: Readonly<Record<string, unknown>>,
1864
+ ): ProbedRepo {
1865
+ return {
1866
+ label,
1867
+ url: typeof answer['url'] === 'string' ? answer['url'] : url,
1868
+ head: typeof answer['head'] === 'string' ? answer['head'] : null,
1869
+ refs: parseProbedRefs(answer['refs']),
1870
+ };
1871
+ }
1872
+
1873
+ function parseAnnouncedSources(raw: unknown): AnnouncedSource[] {
1874
+ if (!Array.isArray(raw)) {
1875
+ return [];
1876
+ }
1877
+
1878
+ return raw.flatMap((entry: unknown): AnnouncedSource[] =>
1879
+ isRecord(entry) &&
1880
+ typeof entry['id'] === 'string' &&
1881
+ typeof entry['label'] === 'string' &&
1882
+ (entry['kind'] === 'git' || entry['kind'] === 'path')
1883
+ ? [{ id: entry['id'], label: entry['label'], kind: entry['kind'] }]
1884
+ : [],
1885
+ );
1886
+ }
1887
+
1888
+ function parseCandidates(raw: unknown): ListedCandidate[] {
1889
+ if (!Array.isArray(raw)) {
1890
+ return [];
1891
+ }
1892
+
1893
+ return raw.flatMap((entry: unknown): ListedCandidate[] => {
1894
+ if (!isRecord(entry) || typeof entry['label'] !== 'string') {
1895
+ return [];
1896
+ }
1897
+
1898
+ const pick = parsePick(entry['pick']);
1899
+
1900
+ return pick === null
1901
+ ? []
1902
+ : [
1903
+ {
1904
+ label: entry['label'],
1905
+ detail: typeof entry['detail'] === 'string' ? entry['detail'] : null,
1906
+ pick,
1907
+ },
1908
+ ];
1909
+ });
1910
+ }
1911
+
1912
+ function parseInterpretation(answer: Readonly<Record<string, unknown>>): Interpretation {
1913
+ if (answer['kind'] === 'browse' && typeof answer['scope'] === 'string') {
1914
+ return { kind: 'browse', scope: answer['scope'] };
1915
+ }
1916
+
1917
+ return parsePick(answer) ?? { kind: 'none' };
1918
+ }
1919
+
1920
+ function parsePick(raw: unknown): SourcePick | null {
1921
+ if (!isRecord(raw)) {
1922
+ return null;
1923
+ }
1924
+
1925
+ if (raw['kind'] === 'path' && typeof raw['dir'] === 'string') {
1926
+ return { kind: 'path', dir: raw['dir'] };
1927
+ }
1928
+
1929
+ if (raw['kind'] === 'git' && typeof raw['url'] === 'string') {
1930
+ return { kind: 'git', url: raw['url'] };
1931
+ }
1932
+
1933
+ return null;
1934
+ }
1935
+
1936
+ function parseProbedRefs(raw: unknown): ProbedRef[] {
1937
+ if (!Array.isArray(raw)) {
1938
+ return [];
1939
+ }
1940
+
1941
+ return raw.flatMap((entry: unknown): ProbedRef[] =>
1942
+ isRecord(entry) &&
1943
+ typeof entry['name'] === 'string' &&
1944
+ typeof entry['sha'] === 'string' &&
1945
+ (entry['kind'] === 'branch' || entry['kind'] === 'tag')
1946
+ ? [{ name: entry['name'], kind: entry['kind'], sha: entry['sha'] }]
1947
+ : [],
1948
+ );
1949
+ }