@zgeoff/atc 3.3.1 → 3.4.1

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 (206) hide show
  1. package/package.json +5 -3
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-agent-adapters.ts +21 -3
  4. package/src/agents/build-cli-argv.ts +2 -4
  5. package/src/agents/claude-adapter.ts +13 -3
  6. package/src/agents/codex-adapter.ts +31 -2
  7. package/src/agents/gateway-adapter.ts +20 -8
  8. package/src/agents/grok-adapter.ts +16 -13
  9. package/src/agents/load-claude-config-bundle.ts +6 -6
  10. package/src/agents/print-codex-hook-file.ts +7 -3
  11. package/src/agents/print-grok-hook-file.ts +8 -3
  12. package/src/agents/render-sdk-message.ts +57 -0
  13. package/src/agents/resolve-agent-home.ts +7 -3
  14. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  15. package/src/agents/start-claude-headless-run.ts +2 -57
  16. package/src/agents/update-claude-project-trust.ts +41 -30
  17. package/src/agents/with-claude-config-lock.ts +37 -12
  18. package/src/agents/write-atc-bridge.ts +10 -7
  19. package/src/agents/write-hook-settings.ts +13 -7
  20. package/src/client/boot-daemon.ts +122 -39
  21. package/src/client/dirs.ts +4 -2
  22. package/src/client/index.ts +17 -1
  23. package/src/client/log-client-event.ts +20 -0
  24. package/src/client/resolve-workspace-root.ts +10 -5
  25. package/src/client/spawn-picker.ts +200 -121
  26. package/src/client/ui.ts +7 -7
  27. package/src/clients.ts +41 -11
  28. package/src/daemon/daemon.ts +143 -33
  29. package/src/daemon/event-signal.ts +19 -3
  30. package/src/daemon/make-hook-runner.ts +82 -22
  31. package/src/daemon/permission-registry.ts +11 -6
  32. package/src/daemon/restore-fleet.ts +62 -17
  33. package/src/daemon/run-eject-handoff.ts +20 -2
  34. package/src/daemon/session-runtime.ts +0 -4
  35. package/src/daemon/sessions.ts +6 -0
  36. package/src/daemon/start-tcp-listener.ts +12 -10
  37. package/src/federation/daemon-caller.ts +25 -4
  38. package/src/federation/daemon-pool.ts +3 -1
  39. package/src/federation/open-gateway-caller.ts +6 -1
  40. package/src/gateway.ts +2 -179
  41. package/src/grants.ts +36 -11
  42. package/src/hook-report.ts +17 -3
  43. package/src/mcp/read-report-texts.ts +11 -7
  44. package/src/mcp/start-mcp-http-server.ts +5 -1
  45. package/src/mcp-http-server.ts +88 -32
  46. package/src/report.ts +21 -3
  47. package/src/run-bridge-tap.ts +36 -7
  48. package/src/run-config-migrate.ts +35 -11
  49. package/src/run-gateway-cli.ts +327 -0
  50. package/src/run-gateway.ts +51 -18
  51. package/src/shared/build-atc-command.ts +7 -3
  52. package/src/shared/bun-sqlite-driver.ts +10 -0
  53. package/src/shared/claim-daemon-lock.ts +10 -4
  54. package/src/shared/collect-dir-roots.ts +6 -6
  55. package/src/shared/collect-hooks.ts +7 -6
  56. package/src/shared/config.ts +19 -9
  57. package/src/shared/system-clock.ts +24 -0
  58. package/src/sources/github/collect-github-repos.ts +1 -1
  59. package/src/sources/github/read-git-protocol.ts +1 -1
  60. package/src/sources/github/run-gh.ts +24 -9
  61. package/src/store/state-store.ts +57 -7
  62. package/src/tap.ts +44 -13
  63. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  64. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  65. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  66. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  67. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  68. package/src/test-utils/build-mock-execution-target.ts +25 -0
  69. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  70. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  71. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  72. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  73. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  74. package/src/test-utils/build-mock-message-record.ts +26 -0
  75. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  76. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  77. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  78. package/src/test-utils/build-mock-session.ts +43 -0
  79. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  80. package/src/test-utils/build-mock-stored-event.ts +20 -0
  81. package/src/test-utils/build-mock-stored-row.ts +23 -0
  82. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  83. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  84. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  85. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  86. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  87. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  88. package/src/test-utils/build-stub-client-screens.ts +33 -0
  89. package/src/test-utils/build-stub-clock.ts +67 -0
  90. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  91. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  92. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  93. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  94. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  95. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  96. package/src/test-utils/build-stub-gh.ts +50 -0
  97. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  98. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  99. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  100. package/src/test-utils/build-stub-held-gh.ts +19 -0
  101. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  102. package/src/test-utils/build-stub-held-provider.ts +58 -0
  103. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  104. package/src/test-utils/build-stub-host-hold.ts +31 -0
  105. package/src/test-utils/build-stub-host-provider.ts +26 -0
  106. package/src/test-utils/build-stub-log.ts +15 -0
  107. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  108. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  109. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  110. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  111. package/src/test-utils/build-stub-picker-host.ts +36 -0
  112. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  113. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  114. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  115. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  116. package/src/test-utils/build-stub-recording-git.ts +8 -0
  117. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  118. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  119. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  120. package/src/test-utils/build-stub-targets.ts +58 -0
  121. package/src/test-utils/build-stub-terminal.ts +19 -0
  122. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  123. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  124. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  125. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  126. package/src/test-utils/build-target-options-from-config.ts +37 -0
  127. package/src/test-utils/collect-process-tree.ts +75 -0
  128. package/src/test-utils/create-git-fixture.ts +117 -0
  129. package/src/test-utils/create-migrated-state-db.ts +12 -10
  130. package/src/test-utils/create-stub-bin.ts +19 -0
  131. package/src/test-utils/create-stub-claude.ts +75 -0
  132. package/src/test-utils/create-stub-codex.ts +31 -0
  133. package/src/test-utils/create-stub-composer.ts +65 -0
  134. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  135. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  136. package/src/test-utils/create-stub-grok.ts +44 -0
  137. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  138. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  139. package/src/test-utils/{fixture-imp-port.ts → create-stub-imp-port.ts} +144 -63
  140. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  141. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  142. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  143. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +15 -9
  144. package/src/test-utils/get-gateway-config.ts +3 -3
  145. package/src/test-utils/get-only-effect-ref.ts +21 -0
  146. package/src/test-utils/get-only-imp-name.ts +19 -0
  147. package/src/test-utils/get-records.ts +20 -0
  148. package/src/test-utils/get-string.ts +14 -0
  149. package/src/test-utils/keys.ts +26 -0
  150. package/src/test-utils/merge-deep.ts +36 -0
  151. package/src/test-utils/mock-overrides.ts +13 -0
  152. package/src/test-utils/open-repo-step.ts +24 -0
  153. package/src/test-utils/read-query-plan.ts +22 -0
  154. package/src/test-utils/register-test-cleanup.ts +63 -0
  155. package/src/test-utils/resolve-atc-command.ts +13 -0
  156. package/src/test-utils/resolve-gateway-command.ts +13 -0
  157. package/src/test-utils/run-atc.ts +56 -0
  158. package/src/test-utils/run-legacy-daemon.ts +27 -5
  159. package/src/test-utils/run-listener-daemon.ts +5 -10
  160. package/src/test-utils/run-mcp-authorization.ts +7 -4
  161. package/src/test-utils/run-source-daemon.ts +2 -2
  162. package/src/test-utils/seed-faker.ts +7 -0
  163. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  164. package/src/test-utils/send-mcp-request.ts +25 -0
  165. package/src/test-utils/setup-mcp-home.ts +110 -0
  166. package/src/test-utils/setup-mcp-http.ts +26 -30
  167. package/src/test-utils/setup-temp-dir.ts +7 -6
  168. package/src/test-utils/spawn-claude-session.ts +34 -0
  169. package/src/test-utils/spawn-grok-session.ts +39 -0
  170. package/src/test-utils/start-cut-proxy.ts +29 -8
  171. package/src/test-utils/start-daemon-process.ts +193 -0
  172. package/src/test-utils/start-git-http-server.ts +28 -4
  173. package/src/test-utils/start-mcp-stdio.ts +190 -0
  174. package/src/test-utils/start-output-capture.ts +20 -0
  175. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  176. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  177. package/src/test-utils/start-stub-dropping-daemon.ts +105 -0
  178. package/src/test-utils/start-stub-impd-info.ts +38 -0
  179. package/src/test-utils/start-stub-impd.ts +143 -0
  180. package/src/test-utils/start-stub-legacy-daemon.ts +171 -0
  181. package/src/test-utils/start-stub-mcp-server.ts +55 -0
  182. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  183. package/src/test-utils/start-stub-refusing-listener.ts +43 -0
  184. package/src/test-utils/start-stub-reporter-socket.ts +79 -0
  185. package/src/test-utils/start-stub-restore-daemon.ts +59 -0
  186. package/src/test-utils/start-stub-session-bridge.ts +100 -0
  187. package/src/test-utils/start-stub-silent-server.ts +38 -0
  188. package/src/test-utils/start-stub-source-daemon.ts +41 -0
  189. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  190. package/src/test-utils/start-stub-stalled-listener.ts +44 -0
  191. package/src/test-utils/start-stub-tap.ts +38 -0
  192. package/src/test-utils/start-stub-tcp-daemon.ts +75 -0
  193. package/src/test-utils/start-stub-telemetry-collector.ts +32 -0
  194. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  195. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  196. package/src/test-utils/start-test-daemon.ts +229 -0
  197. package/src/test-utils/start-tui-harness.ts +291 -0
  198. package/src/test-utils/subscribe-to-socket-lines.ts +81 -14
  199. package/src/test-utils/try-send-request.ts +29 -0
  200. package/src/test-utils/wait-for-event.ts +41 -0
  201. package/src/test-utils/wait-for.ts +15 -3
  202. package/src/verify-restored-fleet.ts +30 -17
  203. package/src/workspace/check-repository-access.ts +4 -0
  204. package/src/workspace/collect-remote-refs.ts +4 -2
  205. package/src/workspace/run-git.ts +6 -0
  206. package/src/test-utils/start-legacy-daemon.ts +0 -111
@@ -21,13 +21,18 @@ const DEFAULT_ROOT = '~/.local/share/atc/workspaces';
21
21
  * root, else the global root, else the default. On the daemon's own
22
22
  * machine a leading `~` expands to the home directory. On any other target
23
23
  * the root must be absolute, since `~` there is a home this client cannot
24
- * see, so a root that is not is refused with the config key to set.
24
+ * see, so a root that is not is refused with the config key to set. The
25
+ * home directory defaults to the user's own.
25
26
  */
26
- export function resolveWorkspaceRoot(config: WorkspacesConfig, target: RootTarget): WorkspaceRoot {
27
+ export function resolveWorkspaceRoot(
28
+ config: WorkspacesConfig,
29
+ target: RootTarget,
30
+ home: string = resolveHomeDir(),
31
+ ): WorkspaceRoot {
27
32
  const configured = config.targetRoots.get(target.id) ?? config.root;
28
33
 
29
34
  if (target.inPlace) {
30
- const root = expandHome(configured ?? DEFAULT_ROOT);
35
+ const root = expandHome(configured ?? DEFAULT_ROOT, home);
31
36
 
32
37
  return root.startsWith('/')
33
38
  ? { ok: true, root }
@@ -44,6 +49,6 @@ export function resolveWorkspaceRoot(config: WorkspacesConfig, target: RootTarge
44
49
  return { ok: true, root: configured };
45
50
  }
46
51
 
47
- function expandHome(dir: string): string {
48
- return dir === '~' || dir.startsWith('~/') ? `${resolveHomeDir()}${dir.slice(1)}` : dir;
52
+ function expandHome(dir: string, home: string): string {
53
+ return dir === '~' || dir.startsWith('~/') ? `${home}${dir.slice(1)}` : dir;
49
54
  }
@@ -34,6 +34,18 @@ type PickerStep =
34
34
  | 'prompt'
35
35
  | 'spawned';
36
36
 
37
+ // The kinds of answer the flow can drop: a git source's listing, a
38
+ // directory source's listing, each request a step waits on, the daemon's
39
+ // targets and sources, and the local directory flow's listing.
40
+ export type DroppedAnswer =
41
+ | 'listing'
42
+ | 'directory listing'
43
+ | 'interpret'
44
+ | 'probe'
45
+ | 'spawn'
46
+ | 'targets and sources'
47
+ | 'local directory listing';
48
+
37
49
  // What the flow borrows from the client that owns the screen, the daemon
38
50
  // connection and the fleet mirror.
39
51
  export interface SpawnPickerDeps<TMirror> {
@@ -50,6 +62,27 @@ export interface SpawnPickerDeps<TMirror> {
50
62
  readonly toMirrorSession: (value: unknown) => TMirror | null;
51
63
  readonly upsertMirror: (session: Readonly<TMirror>) => void;
52
64
  readonly hasDaemonFeature: (feature: DaemonFeature) => boolean;
65
+
66
+ // Where the flow draws: every byte of the picker's screen goes through it.
67
+ readonly write: (chunk: string) => void;
68
+
69
+ // The client's own directory, which the directory step lists first.
70
+ readonly cwd: string;
71
+
72
+ // The config file the flow reads its agents, roots, and workspaces from
73
+ // each time it opens.
74
+ readonly configPath: string;
75
+
76
+ // Called each time the flow drops an answer, with the kind of answer it
77
+ // dropped: one for a flow or a request that has since moved on, which
78
+ // changes nothing, or a listing for a source the flow has left, which
79
+ // fills nothing and only clears the pending listing so that source lists
80
+ // again when its step reopens.
81
+ readonly onDropAnswer?: (kind: DroppedAnswer) => void;
82
+
83
+ // Records any other decision the flow takes without drawing anything,
84
+ // such as text it ignores on a step that takes none.
85
+ readonly log?: (line: string) => void;
53
86
  }
54
87
 
55
88
  // One source `agents.list` returned, in the order the daemon offers them.
@@ -279,7 +312,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
279
312
  this.generation += 1;
280
313
  this.resume = resume;
281
314
 
282
- const config = loadConfig();
315
+ const config = loadConfig(this.deps.configPath);
283
316
 
284
317
  this.picks = collectAgentPicks(config);
285
318
  this.roots = config.dirs.roots;
@@ -315,7 +348,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
315
348
  this.refusal = null;
316
349
  this.pending = null;
317
350
 
318
- process.stdout.write(ansi.clear);
351
+ this.deps.write(ansi.clear);
319
352
 
320
353
  // One installed agent is no choice to make, so the flow goes on with it.
321
354
  if (this.picks.length === 1) {
@@ -439,13 +472,16 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
439
472
  hint = 'reading targets and sources… · esc cancel';
440
473
  }
441
474
 
442
- drawPicker({
443
- title: `${verb}: agent`,
444
- items: this.picks.map((p) => p.label),
445
- selected: this.selected,
446
- input: this.input,
447
- hint,
448
- });
475
+ drawPicker(
476
+ {
477
+ title: `${verb}: agent`,
478
+ items: this.picks.map((p) => p.label),
479
+ selected: this.selected,
480
+ input: this.input,
481
+ hint,
482
+ },
483
+ this.deps.write,
484
+ );
449
485
  } else if (this.step === 'dir') {
450
486
  this.renderDirStep(verb);
451
487
  } else if (this.step === 'source') {
@@ -457,36 +493,45 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
457
493
  } else if (this.step === 'confirm') {
458
494
  this.renderConfirmStep();
459
495
  } else if (this.step === 'spawned') {
460
- drawPicker({
461
- title: `${verb}: started`,
462
- items: this.spawnWarnings.flatMap((w) => splitToWidth(w, getPickerWidth() - 4)),
463
- selected: -1,
464
- input: '',
465
- hint: '⏎ attach · esc back',
466
- });
496
+ drawPicker(
497
+ {
498
+ title: `${verb}: started`,
499
+ items: this.spawnWarnings.flatMap((w) => splitToWidth(w, getPickerWidth() - 4)),
500
+ selected: -1,
501
+ input: '',
502
+ hint: '⏎ attach · esc back',
503
+ },
504
+ this.deps.write,
505
+ );
467
506
  } else if (this.step === 'name') {
468
507
  const where = this.isGitFlow() ? this.formatDestination() : formatDir(this.dir);
469
508
 
470
- drawPicker({
471
- title: `${verb}: name`,
472
- items: [],
473
- selected: -1,
474
- input: this.input,
475
- placeholder: formatDirName(this.autoDestination ?? this.dir),
476
- hint: this.refusal ?? `session name for ${where} · ⏎ accept · esc back`,
477
- });
509
+ drawPicker(
510
+ {
511
+ title: `${verb}: name`,
512
+ items: [],
513
+ selected: -1,
514
+ input: this.input,
515
+ placeholder: formatDirName(this.autoDestination ?? this.dir),
516
+ hint: this.refusal ?? `session name for ${where} · ⏎ accept · esc back`,
517
+ },
518
+ this.deps.write,
519
+ );
478
520
  } else {
479
- drawPicker({
480
- title: 'spawn: initial prompt',
481
- items: [],
482
- selected: -1,
483
- input: this.input,
484
- placeholder: 'optional — ⏎ to start interactive',
485
- hint:
486
- this.pending?.label ??
487
- this.refusal ??
488
- 'first message for the session · ⏎ spawn · esc back',
489
- });
521
+ drawPicker(
522
+ {
523
+ title: 'spawn: initial prompt',
524
+ items: [],
525
+ selected: -1,
526
+ input: this.input,
527
+ placeholder: 'optional — ⏎ to start interactive',
528
+ hint:
529
+ this.pending?.label ??
530
+ this.refusal ??
531
+ 'first message for the session · ⏎ spawn · esc back',
532
+ },
533
+ this.deps.write,
534
+ );
490
535
  }
491
536
 
492
537
  this.deps.scheduleStatus();
@@ -518,16 +563,19 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
518
563
 
519
564
  this.selected = Math.min(this.selected, Math.max(0, Math.min(items.length, 10) - 1));
520
565
 
521
- drawPicker({
522
- title: `${verb}: ${this.findSource()?.label ?? 'directory'}`,
523
- items,
524
- selected: this.selected,
525
- input: this.input,
526
- hint:
527
- this.pending?.label ??
528
- this.refusal ??
529
- `type to filter, or a path (/ ~ .) · ↑↓ move · ⏎ select${this.formatTabHint()} · esc cancel`,
530
- });
566
+ drawPicker(
567
+ {
568
+ title: `${verb}: ${this.findSource()?.label ?? 'directory'}`,
569
+ items,
570
+ selected: this.selected,
571
+ input: this.input,
572
+ hint:
573
+ this.pending?.label ??
574
+ this.refusal ??
575
+ `type to filter, or a path (/ ~ .) · ↑↓ move · ⏎ select${this.formatTabHint()} · esc cancel`,
576
+ },
577
+ this.deps.write,
578
+ );
531
579
  }
532
580
 
533
581
  private renderTargetStep(verb: string) {
@@ -544,18 +592,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
544
592
  hint = `${gap} · set targets in config.json · esc back`;
545
593
  }
546
594
 
547
- drawPicker({
548
- title: `${verb}: target`,
549
- items: this.targets.map((t) => formatTargetPick(t)),
550
- selected: this.selected,
551
- input: '',
552
- hint: this.refusal ?? hint,
553
- dimmed: new Set(
554
- this.targets.flatMap((t, i) =>
555
- t.takesWorkspace || (!git && t.available && t.inPlace) ? [] : [i],
595
+ drawPicker(
596
+ {
597
+ title: `${verb}: target`,
598
+ items: this.targets.map((t) => formatTargetPick(t)),
599
+ selected: this.selected,
600
+ input: '',
601
+ hint: this.refusal ?? hint,
602
+ dimmed: new Set(
603
+ this.targets.flatMap((t, i) =>
604
+ t.takesWorkspace || (!git && t.available && t.inPlace) ? [] : [i],
605
+ ),
556
606
  ),
557
- ),
558
- });
607
+ },
608
+ this.deps.write,
609
+ );
559
610
  }
560
611
 
561
612
  private renderSourceStep() {
@@ -566,18 +617,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
566
617
  const scope = this.listedScope === null ? '' : ` · ${this.listedScope}`;
567
618
  const listing = this.listingSeq === null ? null : 'listing… · esc stops';
568
619
 
569
- drawPicker({
570
- title: `spawn: ${this.findSource()?.label ?? ''}${scope}`,
571
- items: items.map((item) => item.label),
572
- selected: items.length === 0 || items[0]?.notice === true ? -1 : this.selected,
573
- input: this.input,
574
- hint:
575
- this.pending?.label ??
576
- this.refusal ??
577
- listing ??
578
- `type to filter, or a git URL · ↑↓ move · ⏎ select${this.formatTabHint()} · esc back`,
579
- dimmed: new Set(items.flatMap((item, i) => (item.notice === true ? [i] : []))),
580
- });
620
+ drawPicker(
621
+ {
622
+ title: `spawn: ${this.findSource()?.label ?? ''}${scope}`,
623
+ items: items.map((item) => item.label),
624
+ selected: items.length === 0 || items[0]?.notice === true ? -1 : this.selected,
625
+ input: this.input,
626
+ hint:
627
+ this.pending?.label ??
628
+ this.refusal ??
629
+ listing ??
630
+ `type to filter, or a git URL · ↑↓ move · ⏎ select${this.formatTabHint()} · esc back`,
631
+ dimmed: new Set(items.flatMap((item, i) => (item.notice === true ? [i] : []))),
632
+ },
633
+ this.deps.write,
634
+ );
581
635
  }
582
636
 
583
637
  private renderRefStep() {
@@ -585,16 +639,19 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
585
639
 
586
640
  this.selected = Math.min(this.selected, Math.max(0, Math.min(items.length, 10) - 1));
587
641
 
588
- drawPicker({
589
- title: `spawn: ref · ${this.repo?.label ?? ''}`,
590
- items: items.map((item) => formatRef(item, this.repo?.head ?? null)),
591
- selected: items.length === 0 ? -1 : this.selected,
592
- input: this.input,
593
- hint:
594
- this.pending?.label ??
595
- this.refusal ??
596
- 'branch, tag, or full commit id · ↑↓ move · ⏎ select · esc back',
597
- });
642
+ drawPicker(
643
+ {
644
+ title: `spawn: ref · ${this.repo?.label ?? ''}`,
645
+ items: items.map((item) => formatRef(item, this.repo?.head ?? null)),
646
+ selected: items.length === 0 ? -1 : this.selected,
647
+ input: this.input,
648
+ hint:
649
+ this.pending?.label ??
650
+ this.refusal ??
651
+ 'branch, tag, or full commit id · ↑↓ move · ⏎ select · esc back',
652
+ },
653
+ this.deps.write,
654
+ );
598
655
  }
599
656
 
600
657
  private renderConfirmStep() {
@@ -606,23 +663,26 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
606
663
  refRow = ref.ref === null ? `commit ${ref.sha}` : `${ref.ref} → ${ref.sha.slice(0, 12)}`;
607
664
  }
608
665
 
609
- drawPicker({
610
- title: 'spawn: confirm',
611
- items: [
612
- `source ${this.repo?.url ?? ''}`,
613
- `ref ${refRow}`,
614
- `target ${target === null ? 'default' : `${target.id} (${target.provider})`}`,
615
- `dest ${this.formatDestination()}`,
616
- `agent ${this.agent}`,
617
- ],
618
- selected: -1,
619
- input: this.input,
620
- placeholder:
621
- this.autoDestination === null
622
- ? 'destination on the target'
623
- : 'picked on the target · or type an absolute path',
624
- hint: this.refusal ?? 'destination on the target · ⏎ continue · esc back',
625
- });
666
+ drawPicker(
667
+ {
668
+ title: 'spawn: confirm',
669
+ items: [
670
+ `source ${this.repo?.url ?? ''}`,
671
+ `ref ${refRow}`,
672
+ `target ${target === null ? 'default' : `${target.id} (${target.provider})`}`,
673
+ `dest ${this.formatDestination()}`,
674
+ `agent ${this.agent}`,
675
+ ],
676
+ selected: -1,
677
+ input: this.input,
678
+ placeholder:
679
+ this.autoDestination === null
680
+ ? 'destination on the target'
681
+ : 'picked on the target · or type an absolute path',
682
+ hint: this.refusal ?? 'destination on the target · ⏎ continue · esc back',
683
+ },
684
+ this.deps.write,
685
+ );
626
686
  }
627
687
 
628
688
  // The source the flow is on, or null on the local directory flow.
@@ -652,6 +712,8 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
652
712
  private applyInput(value: string) {
653
713
  // The target step takes no text: its list is short and fixed.
654
714
  if (this.step === 'target') {
715
+ this.deps.log?.('ignored text on the target step');
716
+
655
717
  return;
656
718
  }
657
719
 
@@ -692,7 +754,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
692
754
 
693
755
  this.refusal = 'cancelled · esc back';
694
756
 
695
- process.stdout.write(ansi.clear);
757
+ this.deps.write(ansi.clear);
696
758
  this.render();
697
759
  }
698
760
 
@@ -758,7 +820,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
758
820
  this.step = 'name';
759
821
  }
760
822
 
761
- process.stdout.write(ansi.clear);
823
+ this.deps.write(ansi.clear);
762
824
  this.render();
763
825
  }
764
826
 
@@ -779,7 +841,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
779
841
  this.sourcesPending = false;
780
842
  this.step = 'agent';
781
843
 
782
- process.stdout.write(ansi.clear);
844
+ this.deps.write(ansi.clear);
783
845
  this.render();
784
846
  }
785
847
 
@@ -787,7 +849,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
787
849
  // has one, else to the agent.
788
850
  private applySourceCancel() {
789
851
  if (this.openTargetStep()) {
790
- process.stdout.write(ansi.clear);
852
+ this.deps.write(ansi.clear);
791
853
  this.render();
792
854
 
793
855
  return;
@@ -804,7 +866,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
804
866
  if (!this.isGitFlow()) {
805
867
  this.step = 'dir';
806
868
 
807
- process.stdout.write(ansi.clear);
869
+ this.deps.write(ansi.clear);
808
870
  this.render();
809
871
 
810
872
  return;
@@ -889,7 +951,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
889
951
  return;
890
952
  }
891
953
 
892
- process.stdout.write(ansi.clear);
954
+ this.deps.write(ansi.clear);
893
955
  this.render();
894
956
  }
895
957
 
@@ -899,7 +961,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
899
961
  const typed = this.input.trim();
900
962
 
901
963
  const chosen =
902
- this.collectDirItems()[this.selected] ?? resolvePathInput(typed, process.cwd(), homedir());
964
+ this.collectDirItems()[this.selected] ?? resolvePathInput(typed, this.deps.cwd, homedir());
903
965
 
904
966
  if (chosen !== null) {
905
967
  this.applyDir(chosen);
@@ -920,7 +982,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
920
982
  this.step = 'name';
921
983
  }
922
984
 
923
- process.stdout.write(ansi.clear);
985
+ this.deps.write(ansi.clear);
924
986
  this.render();
925
987
  }
926
988
 
@@ -957,7 +1019,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
957
1019
 
958
1020
  this.step = 'name';
959
1021
 
960
- process.stdout.write(ansi.clear);
1022
+ this.deps.write(ansi.clear);
961
1023
  this.render();
962
1024
  }
963
1025
 
@@ -966,9 +1028,9 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
966
1028
  * else fuzzy-filters the merged list.
967
1029
  */
968
1030
  private collectDirItems(): string[] {
969
- return resolvePathInput(this.input, process.cwd(), homedir()) === null
1031
+ return resolvePathInput(this.input, this.deps.cwd, homedir()) === null
970
1032
  ? pickMatches(this.dirs, this.input)
971
- : collectPathCompletions(this.input, process.cwd(), homedir());
1033
+ : collectPathCompletions(this.input, this.deps.cwd, homedir());
972
1034
  }
973
1035
 
974
1036
  // The git source step's rows: the other URL form after a failed probe,
@@ -1029,6 +1091,8 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1029
1091
  const sources = this.sources ?? [];
1030
1092
 
1031
1093
  if (sources.length < 2) {
1094
+ this.deps.log?.('ignored tab with one source');
1095
+
1032
1096
  return;
1033
1097
  }
1034
1098
 
@@ -1073,6 +1137,8 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1073
1137
  }
1074
1138
 
1075
1139
  if (this.isStale(generation) || this.pathListingSeq !== seq) {
1140
+ this.deps.onDropAnswer?.('directory listing');
1141
+
1076
1142
  return;
1077
1143
  }
1078
1144
 
@@ -1093,12 +1159,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1093
1159
 
1094
1160
  this.dirLabels = new Map(listed);
1095
1161
 
1096
- this.dirs = [...new Set([process.cwd(), ...listed.map(([dir]) => dir)])];
1162
+ this.dirs = [...new Set([this.deps.cwd, ...listed.map(([dir]) => dir)])];
1097
1163
  this.input = '';
1098
1164
  this.selected = 0;
1099
1165
  this.step = 'dir';
1100
1166
 
1101
- process.stdout.write(ansi.clear);
1167
+ this.deps.write(ansi.clear);
1102
1168
  this.render();
1103
1169
  }
1104
1170
 
@@ -1121,7 +1187,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1121
1187
  }
1122
1188
 
1123
1189
  if (this.openTargetStep()) {
1124
- process.stdout.write(ansi.clear);
1190
+ this.deps.write(ansi.clear);
1125
1191
  this.render();
1126
1192
 
1127
1193
  return;
@@ -1152,7 +1218,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1152
1218
  this.listNotice = null;
1153
1219
  }
1154
1220
 
1155
- process.stdout.write(ansi.clear);
1221
+ this.deps.write(ansi.clear);
1156
1222
  this.render();
1157
1223
 
1158
1224
  // A scope a reading asked for is listed in place of the source's own
@@ -1209,6 +1275,8 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1209
1275
  }
1210
1276
 
1211
1277
  if (this.isStale(generation) || this.listingSeq !== seq) {
1278
+ this.deps.onDropAnswer?.('listing');
1279
+
1212
1280
  return;
1213
1281
  }
1214
1282
 
@@ -1217,6 +1285,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1217
1285
  if (this.findSource()?.id !== source.id) {
1218
1286
  this.listingSeq = null;
1219
1287
  this.listedKey = null;
1288
+ this.deps.onDropAnswer?.('listing');
1220
1289
 
1221
1290
  return;
1222
1291
  }
@@ -1242,9 +1311,13 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1242
1311
  if (this.step === 'source') {
1243
1312
  this.selected = 0;
1244
1313
 
1245
- process.stdout.write(ansi.clear);
1314
+ this.deps.write(ansi.clear);
1246
1315
  this.render();
1316
+
1317
+ return;
1247
1318
  }
1319
+
1320
+ this.deps.log?.(`kept the ${this.step} step through a listing answer`);
1248
1321
  }
1249
1322
 
1250
1323
  // Takes the git source step's choice: the other URL form on offer, a
@@ -1317,7 +1390,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1317
1390
 
1318
1391
  this.refusal = `no source reads ${typed} · esc back`;
1319
1392
 
1320
- process.stdout.write(ansi.clear);
1393
+ this.deps.write(ansi.clear);
1321
1394
  this.render();
1322
1395
  }
1323
1396
 
@@ -1424,6 +1497,8 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1424
1497
  }
1425
1498
 
1426
1499
  if (this.isStale(generation) || this.pending?.seq !== seq) {
1500
+ this.deps.onDropAnswer?.(kind);
1501
+
1427
1502
  return null;
1428
1503
  }
1429
1504
 
@@ -1452,7 +1527,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1452
1527
  this.refusal = `${formatError(probed.error)} · esc back`;
1453
1528
  this.input = label;
1454
1529
 
1455
- process.stdout.write(ansi.clear);
1530
+ this.deps.write(ansi.clear);
1456
1531
  this.render();
1457
1532
 
1458
1533
  return;
@@ -1470,7 +1545,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1470
1545
  this.ref = null;
1471
1546
 
1472
1547
  this.openRefStep();
1473
- process.stdout.write(ansi.clear);
1548
+ this.deps.write(ansi.clear);
1474
1549
  this.render();
1475
1550
  }
1476
1551
 
@@ -1501,7 +1576,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1501
1576
  this.refusal = `${reason} · ${formatError(probed.error)} · esc back`;
1502
1577
  }
1503
1578
 
1504
- process.stdout.write(ansi.clear);
1579
+ this.deps.write(ansi.clear);
1505
1580
  this.render();
1506
1581
  }
1507
1582
 
@@ -1573,7 +1648,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1573
1648
  ? `the daemon resolved no commit for ${typed} · esc back`
1574
1649
  : `${formatError(probed.error)} · esc back`;
1575
1650
 
1576
- process.stdout.write(ansi.clear);
1651
+ this.deps.write(ansi.clear);
1577
1652
  this.render();
1578
1653
  }
1579
1654
 
@@ -1587,7 +1662,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1587
1662
  this.input = '';
1588
1663
 
1589
1664
  this.openConfirmStep();
1590
- process.stdout.write(ansi.clear);
1665
+ this.deps.write(ansi.clear);
1591
1666
  this.render();
1592
1667
  }
1593
1668
 
@@ -1764,6 +1839,8 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1764
1839
  } catch {}
1765
1840
 
1766
1841
  if (this.isStale(generation)) {
1842
+ this.deps.onDropAnswer?.('targets and sources');
1843
+
1767
1844
  return;
1768
1845
  }
1769
1846
 
@@ -1820,10 +1897,12 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1820
1897
  const zoxide = await collectZoxideDirs();
1821
1898
 
1822
1899
  if (this.isStale(generation)) {
1900
+ this.deps.onDropAnswer?.('local directory listing');
1901
+
1823
1902
  return;
1824
1903
  }
1825
1904
 
1826
- this.dirs = collectDirs({ cwd: process.cwd(), recent, roots: this.roots, zoxide });
1905
+ this.dirs = collectDirs({ cwd: this.deps.cwd, recent, roots: this.roots, zoxide });
1827
1906
 
1828
1907
  this.dirLabels = new Map();
1829
1908
 
@@ -1832,7 +1911,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1832
1911
  this.selected = 0;
1833
1912
  this.step = 'dir';
1834
1913
 
1835
- process.stdout.write(ansi.clear);
1914
+ this.deps.write(ansi.clear);
1836
1915
  this.render();
1837
1916
  }
1838
1917
 
@@ -1882,7 +1961,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1882
1961
  this.spawnWarnings = warnings.filter((w): w is string => typeof w === 'string');
1883
1962
  this.step = 'spawned';
1884
1963
 
1885
- process.stdout.write(ansi.clear);
1964
+ this.deps.write(ansi.clear);
1886
1965
  this.render();
1887
1966
 
1888
1967
  return;
@@ -1960,7 +2039,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1960
2039
  this.input = this.resume ? this.name : prompt;
1961
2040
  }
1962
2041
 
1963
- process.stdout.write(ansi.clear);
2042
+ this.deps.write(ansi.clear);
1964
2043
  this.render();
1965
2044
  }
1966
2045