@zgeoff/atc 2.16.0 → 2.19.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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +24 -1
  3. package/src/client/build-workspace-destination.ts +38 -0
  4. package/src/client/collect-target-picks.ts +53 -0
  5. package/src/client/dirs.ts +1 -1
  6. package/src/client/pick-refusal-step.ts +29 -0
  7. package/src/client/resolve-workspace-root.ts +49 -0
  8. package/src/client/spawn-picker.ts +1683 -84
  9. package/src/client/ui.ts +6 -1
  10. package/src/daemon/daemon-connection.ts +129 -0
  11. package/src/daemon/daemon-context.ts +23 -0
  12. package/src/daemon/daemon.ts +29 -0
  13. package/src/daemon/materialize-workspace.ts +42 -18
  14. package/src/daemon/require-git-transports.ts +23 -0
  15. package/src/mcp/require-daemon-features.ts +2 -0
  16. package/src/protocol/daemon-features.ts +8 -0
  17. package/src/protocol/protocol.ts +2 -0
  18. package/src/protocol/request-param-schemas.ts +67 -15
  19. package/src/shared/collect-workspaces-config.ts +120 -0
  20. package/src/shared/config.ts +30 -1
  21. package/src/shared/default-git-transports.ts +5 -0
  22. package/src/shared/is-git-url.ts +11 -0
  23. package/src/sources/build-sources.ts +40 -0
  24. package/src/sources/collect-builtin-sources.ts +41 -0
  25. package/src/sources/dirs/build-dirs-source.ts +70 -0
  26. package/src/sources/git/build-git-source.ts +25 -0
  27. package/src/sources/github/build-github-source.ts +113 -0
  28. package/src/sources/github/collect-github-repos.ts +165 -0
  29. package/src/sources/github/find-github-alternate-url.ts +20 -0
  30. package/src/sources/github/read-git-protocol.ts +11 -0
  31. package/src/sources/github/run-gh.ts +66 -0
  32. package/src/sources/types.ts +70 -0
  33. package/src/workspace/check-git-transport.ts +61 -0
  34. package/src/workspace/check-repository-access.ts +108 -0
  35. package/src/workspace/collect-remote-refs.ts +154 -0
  36. package/src/workspace/create-git-askpass.ts +84 -0
  37. package/src/workspace/create-workspace-clone.ts +29 -76
  38. package/src/workspace/expand-git-shorthand.ts +13 -0
  39. package/src/workspace/find-remote-ref.ts +34 -0
  40. package/src/workspace/normalize-git-url.ts +2 -7
  41. package/src/workspace/resolve-git-url.ts +46 -0
  42. package/src/workspace/resolve-path-source.ts +11 -4
  43. package/src/workspace/run-git.ts +52 -4
  44. package/src/workspace/workspace-source.ts +10 -2
  45. /package/src/{client → shared}/collect-root-dirs.ts +0 -0
  46. /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
@@ -1,17 +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';
5
+ import { collectZoxideDirs } from '../shared/collect-zoxide-dirs';
4
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';
5
12
  import { collectAgentPicks } from './collect-agent-picks';
6
13
  import type { AgentPick } from './collect-agent-picks';
7
14
  import { collectPathCompletions } from './collect-path-completions';
8
- import { collectZoxideDirs } from './collect-zoxide-dirs';
15
+ import { collectTargetPicks } from './collect-target-picks';
16
+ import type { TargetPick } from './collect-target-picks';
9
17
  import { collectDirs, formatDir, formatDirName, pickMatches } from './dirs';
10
- import { planTextEdit } from './keys';
18
+ import { KEY, planTextEdit } from './keys';
19
+ import { pickRefusalStep } from './pick-refusal-step';
11
20
  import { resolvePathInput } from './resolve-path-input';
21
+ import { resolveWorkspaceRoot } from './resolve-workspace-root';
12
22
  import { ansi, cols, drawPicker } from './ui';
13
23
 
14
- type PickerStep = 'agent' | 'dir' | 'name' | 'prompt';
24
+ type PickerStep = 'agent' | 'dir' | 'source' | 'ref' | 'target' | 'confirm' | 'name' | 'prompt';
15
25
 
16
26
  // What the flow borrows from the client that owns the screen, the daemon
17
27
  // connection and the fleet mirror.
@@ -30,17 +40,103 @@ export interface SpawnPickerDeps<TMirror> {
30
40
  readonly upsertMirror: (session: Readonly<TMirror>) => void;
31
41
  }
32
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
+
33
112
  /**
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
- * session or returns the client to the screen it came from. A spawn the
37
- * 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.
38
127
  */
39
128
  export class SpawnPicker<TMirror extends { readonly id: string }> {
40
129
  private readonly deps: SpawnPickerDeps<TMirror>;
41
130
 
42
131
  private step: PickerStep = 'agent';
43
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
+
44
140
  private input = '';
45
141
 
46
142
  private selected = 0;
@@ -49,35 +145,122 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
49
145
  // edit or a fresh install lands without a client restart.
50
146
  private picks: AgentPick[] = [];
51
147
 
148
+ // The directories the directory step lists, and the label each shows.
52
149
  private dirs: string[] = [];
53
150
 
54
- // 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.
55
155
  private roots: readonly string[] = [];
56
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
+
57
165
  private dir = '';
58
166
 
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.
169
+ private targets: TargetPick[] = [];
170
+
171
+ private target: TargetPick | null = null;
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
+
59
217
  private name = '';
60
218
 
61
219
  private resume = false;
62
220
 
63
221
  private agent: AgentID = 'claude';
64
222
 
65
- // The daemon's refusal of the last spawn, shown in place of the hint until
66
- // the next key.
223
+ // Why the last action was refused, shown in place of the hint until the
224
+ // next key.
67
225
  private refusal: string | null = null;
68
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
+
69
236
  constructor(deps: SpawnPickerDeps<TMirror>) {
70
237
  this.deps = deps;
71
238
  }
72
239
 
73
240
  open(resume = false) {
241
+ this.generation += 1;
74
242
  this.resume = resume;
75
243
 
76
244
  const config = loadConfig();
77
245
 
78
246
  this.picks = collectAgentPicks(config);
79
247
  this.roots = config.dirs.roots;
248
+ this.workspaces = config.workspaces;
80
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;
81
264
 
82
265
  // A last-used agent that is no longer installed is not in the menu, so
83
266
  // the selection falls to the first one that is.
@@ -89,6 +272,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
89
272
  this.agent = this.picks[this.selected]?.agent ?? this.deps.getLastUsedAgent();
90
273
  this.step = 'agent';
91
274
  this.refusal = null;
275
+ this.pending = null;
92
276
 
93
277
  process.stdout.write(ansi.clear);
94
278
  this.render();
@@ -99,9 +283,46 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
99
283
 
100
284
  const edit = planTextEdit(buf, this.input, {
101
285
  isLeaderKey: this.deps.isLeaderKey,
102
- moves: this.step === 'agent' || this.step === 'dir',
286
+ moves:
287
+ this.step === 'agent' ||
288
+ this.step === 'dir' ||
289
+ this.step === 'source' ||
290
+ this.step === 'ref' ||
291
+ this.step === 'target',
103
292
  });
104
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
+
105
326
  switch (edit.kind) {
106
327
  case 'none': {
107
328
  return;
@@ -112,7 +333,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
112
333
  return;
113
334
  }
114
335
  case 'leader': {
115
- this.deps.toBase();
336
+ this.quitFlow();
116
337
 
117
338
  return;
118
339
  }
@@ -131,9 +352,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
131
352
  return;
132
353
  }
133
354
  case 'input': {
134
- this.input = edit.value;
135
-
136
- this.render();
355
+ this.applyInput(edit.value);
137
356
  }
138
357
  }
139
358
  }
@@ -159,25 +378,25 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
159
378
  hint,
160
379
  });
161
380
  } else if (this.step === 'dir') {
162
- const items = this.collectDirItems().map((d) => formatDir(d));
163
-
164
- this.selected = Math.min(this.selected, Math.max(0, Math.min(items.length, 10) - 1));
165
-
166
- drawPicker({
167
- title: `${verb}: directory`,
168
- items,
169
- selected: this.selected,
170
- input: this.input,
171
- hint: 'type to filter, or a path (/ ~ .) · ↑↓ move · ⏎ select · esc cancel',
172
- });
381
+ this.renderDirStep(verb);
382
+ } else if (this.step === 'source') {
383
+ this.renderSourceStep();
384
+ } else if (this.step === 'ref') {
385
+ this.renderRefStep();
386
+ } else if (this.step === 'target') {
387
+ this.renderTargetStep(verb);
388
+ } else if (this.step === 'confirm') {
389
+ this.renderConfirmStep();
173
390
  } else if (this.step === 'name') {
391
+ const where = this.isGitFlow() ? this.formatDestination() : formatDir(this.dir);
392
+
174
393
  drawPicker({
175
394
  title: `${verb}: name`,
176
395
  items: [],
177
396
  selected: -1,
178
397
  input: this.input,
179
398
  placeholder: formatDirName(this.dir),
180
- hint: this.refusal ?? `session name for ${formatDir(this.dir)} · ⏎ accept · esc back`,
399
+ hint: this.refusal ?? `session name for ${where} · ⏎ accept · esc back`,
181
400
  });
182
401
  } else {
183
402
  drawPicker({
@@ -186,32 +405,262 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
186
405
  selected: -1,
187
406
  input: this.input,
188
407
  placeholder: 'optional — ⏎ to start interactive',
189
- 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',
190
412
  });
191
413
  }
192
414
 
193
415
  this.deps.scheduleStatus();
194
416
  }
195
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
+
196
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
+
197
616
  if (this.step === 'agent') {
198
- this.deps.toBase();
617
+ this.quitFlow();
618
+
619
+ return;
620
+ }
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();
199
629
 
200
630
  return;
201
631
  }
202
632
 
633
+ const typed = this.input;
634
+
203
635
  this.input = '';
204
636
 
205
637
  if (this.step === 'dir') {
206
- this.selected = Math.max(
207
- 0,
208
- this.picks.findIndex((p) => p.agent === this.agent),
209
- );
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';
647
+ } else if (this.step === 'target') {
648
+ this.applyTargetCancel();
210
649
 
211
- this.step = 'agent';
650
+ return;
651
+ } else if (this.step === 'confirm') {
652
+ this.openRefStep();
212
653
  } else if (this.step === 'name') {
213
- this.step = '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
+ }
214
662
  } else {
663
+ this.input = this.name;
215
664
  this.step = 'name';
216
665
  }
217
666
 
@@ -219,6 +668,54 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
219
668
  this.render();
220
669
  }
221
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
+
222
719
  private applySubmit() {
223
720
  if (this.step === 'agent') {
224
721
  const pick = this.picks[this.selected];
@@ -230,22 +727,43 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
230
727
  }
231
728
 
232
729
  this.agent = pick.agent;
233
- void this.openDirStep();
730
+ void this.openFirstSource();
731
+
732
+ return;
733
+ }
734
+
735
+ if (this.step === 'source') {
736
+ void this.applySourceSubmit();
737
+
738
+ return;
739
+ }
740
+
741
+ if (this.step === 'ref') {
742
+ void this.applyRefSubmit();
234
743
 
235
744
  return;
236
745
  }
237
746
 
238
747
  if (this.step === 'dir') {
239
- const chosen =
240
- this.collectDirItems()[this.selected] ??
241
- resolvePathInput(this.input.trim(), process.cwd(), homedir());
748
+ void this.applyDirSubmit();
749
+
750
+ return;
751
+ }
752
+
753
+ if (this.step === 'target') {
754
+ this.applyTargetSubmit();
755
+
756
+ return;
757
+ }
758
+
759
+ if (this.step === 'confirm') {
760
+ if (!this.applyDestination(this.input.trim())) {
761
+ this.render();
242
762
 
243
- if (chosen === null) {
244
763
  return;
245
764
  }
246
765
 
247
- this.dir = chosen;
248
- this.input = '';
766
+ this.input = this.name;
249
767
  this.step = 'name';
250
768
  } else if (this.step === 'name') {
251
769
  this.name = this.input.trim();
@@ -270,6 +788,74 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
270
788
  this.render();
271
789
  }
272
790
 
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();
795
+
796
+ const chosen =
797
+ this.collectDirItems()[this.selected] ?? resolvePathInput(typed, process.cwd(), homedir());
798
+
799
+ if (chosen !== null) {
800
+ this.applyDir(chosen);
801
+
802
+ return;
803
+ }
804
+
805
+ if (typed !== '' && this.sources !== null) {
806
+ await this.readTypedInput(typed, true);
807
+ }
808
+ }
809
+
810
+ private applyDir(dir: string) {
811
+ this.dir = dir;
812
+ this.input = '';
813
+
814
+ this.openTargetStep(() => {
815
+ this.step = 'name';
816
+ });
817
+
818
+ process.stdout.write(ansi.clear);
819
+ this.render();
820
+ }
821
+
822
+ private applyTargetSubmit() {
823
+ const pick = this.targets[this.selected];
824
+
825
+ if (pick === undefined) {
826
+ return;
827
+ }
828
+
829
+ const refusal = findTargetRefusal(pick, this.isGitFlow());
830
+
831
+ if (refusal !== null) {
832
+ this.refusal = refusal;
833
+
834
+ this.render();
835
+
836
+ return;
837
+ }
838
+
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
+
273
859
  /**
274
860
  * A typed path completes against the filesystem, like a shell; anything
275
861
  * else fuzzy-filters the merged list.
@@ -280,64 +866,489 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
280
866
  : collectPathCompletions(this.input, process.cwd(), homedir());
281
867
  }
282
868
 
283
- private async openDirStep() {
284
- let recent: string[] = [];
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
+ }
285
881
 
286
- try {
287
- const answer = await this.deps.sendRequest('dirs.list');
882
+ if (this.listNotice !== null) {
883
+ return [{ label: this.listNotice, notice: true }];
884
+ }
288
885
 
289
- const dirs = answer['dirs'];
886
+ const filter = isGitURL(this.input) ? '' : this.input.trim();
290
887
 
291
- if (Array.isArray(dirs)) {
292
- recent = dirs.filter((d): d is string => typeof d === 'string');
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 [];
293
895
  }
294
- } catch {}
295
896
 
296
- this.dirs = collectDirs({
297
- cwd: process.cwd(),
298
- recent,
299
- roots: this.roots,
300
- zoxide: await collectZoxideDirs(),
897
+ return [
898
+ {
899
+ label: candidate.detail === null ? label : `${label} ${candidate.detail}`,
900
+ candidate,
901
+ },
902
+ ];
301
903
  });
904
+ }
302
905
 
303
- this.input = '';
304
- this.selected = 0;
305
- this.step = 'dir';
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;
306
911
 
307
- process.stdout.write(ansi.clear);
308
- this.render();
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));
309
920
  }
310
921
 
311
- private async spawn(prompt: string) {
312
- try {
313
- const ok = await this.deps.sendRequest('session.spawn', {
314
- cwd: this.dir,
315
- name: this.name,
316
- prompt,
317
- cols: cols(),
318
- rows: this.deps.ptyRows(),
319
- ...(this.resume ? { resume: true } : {}),
320
- agent: this.agent,
321
- });
922
+ // Opens the source after the current one, wrapping round.
923
+ private openNextSource() {
924
+ const sources = this.sources ?? [];
322
925
 
323
- const spawned = this.deps.toMirrorSession(ok['session']);
926
+ if (sources.length < 2) {
927
+ return;
928
+ }
324
929
 
325
- if (spawned !== null) {
326
- this.deps.upsertMirror(spawned);
930
+ this.openSource((this.sourceIndex + 1) % sources.length);
931
+ }
327
932
 
328
- await this.deps.attach(spawned.id);
933
+ private openSource(index: number) {
934
+ const source = this.sources?.[index];
329
935
 
330
- return;
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;
975
+
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;
987
+ }
988
+
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;
331
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) };
332
1320
  } catch (error) {
333
- // A refused spawn keeps the picker on the step that sent it, with the
334
- // entered text back in the input, so the user can fix the cause and
335
- // retry or back out.
336
- const reason =
337
- error instanceof DaemonError ? `${error.code}: ${error.message}` : String(error);
1321
+ result = { ok: false, error };
1322
+ }
338
1323
 
339
- this.refusal = `${reason} · ⏎ retry · esc back`;
340
- this.input = this.resume ? this.name : prompt;
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;
341
1352
 
342
1353
  process.stdout.write(ansi.clear);
343
1354
  this.render();
@@ -345,6 +1356,594 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
345
1356
  return;
346
1357
  }
347
1358
 
348
- this.deps.toBase();
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;
349
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 } };
1787
+ }
1788
+ }
1789
+
1790
+ function formatTargetPick(pick: TargetPick): string {
1791
+ const notes = [
1792
+ pick.provider,
1793
+ ...(pick.isDefault ? ['default'] : []),
1794
+ ...(pick.available ? [] : ['unavailable']),
1795
+ ...(pick.available && !pick.takesWorkspace ? ['no workspace'] : []),
1796
+ ];
1797
+
1798
+ return `${pick.id} ${notes.join(' · ')}`;
1799
+ }
1800
+
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 {
1805
+ if (!pick.available) {
1806
+ return `target '${pick.id}' is unavailable on this daemon · esc back`;
1807
+ }
1808
+
1809
+ if (!pick.takesWorkspace && (git || !pick.inPlace)) {
1810
+ return `target '${pick.id}' cannot take a workspace · esc back`;
1811
+ }
1812
+
1813
+ return null;
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
+ );
350
1949
  }