@fresh-editor/fresh-editor 0.3.8 → 0.3.9

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.
@@ -76,6 +76,18 @@ interface AgentSession {
76
76
  state: AgentState;
77
77
  // Wall-clock ms when orchestrator.new fired createWindow.
78
78
  createdAt: number;
79
+ // `true` when this row is a worktree discovered on disk (via
80
+ // `git worktree list`) that has no live editor window yet.
81
+ // Discovered rows carry a synthetic negative `id`, no
82
+ // `terminalId`, and dive by *attaching* a new session to
83
+ // `root` rather than switching to an existing window. They are
84
+ // dropped from `orchestratorSessions` the moment a real window
85
+ // is opened at the same `root`.
86
+ discovered?: boolean;
87
+ // Branch checked out in this worktree (best-effort, for
88
+ // display). Set for discovered rows; left undefined for live
89
+ // sessions where the tab/label already carries the identity.
90
+ branch?: string;
79
91
  }
80
92
 
81
93
  // =============================================================================
@@ -84,6 +96,25 @@ interface AgentSession {
84
96
 
85
97
  const orchestratorSessions = new Map<number, AgentSession>();
86
98
 
99
+ // Stable synthetic ids for discovered (on-disk, not-yet-opened)
100
+ // worktrees, keyed by canonical path. Live windows own the
101
+ // positive id space (editor `WindowId`s); discovered rows take
102
+ // negative ids so the two never collide and the existing
103
+ // `orchestratorSessions.get(id)` call sites keep working. Ids
104
+ // stay stable across rescans so the dialog selection doesn't
105
+ // jump when the worktree set is refreshed. `-1` is reserved as a
106
+ // "no selection" sentinel elsewhere, so allocation starts at `-2`.
107
+ const discoveredIdByPath = new Map<string, number>();
108
+ let nextDiscoveredId = -2;
109
+ function discoveredIdFor(path: string): number {
110
+ let id = discoveredIdByPath.get(path);
111
+ if (id === undefined) {
112
+ id = nextDiscoveredId--;
113
+ discoveredIdByPath.set(path, id);
114
+ }
115
+ return id;
116
+ }
117
+
87
118
  // New-session form state. `null` ⇒ the floating form isn't
88
119
  // open. Each field's `value` + `cursor` mirrors what the host
89
120
  // renders inside the panel's TextInput widgets; the `submitting`
@@ -118,6 +149,14 @@ interface NewSessionForm {
118
149
  // (checkbox disabled, branch field inert). `null`: probe
119
150
  // in flight (keep checkbox in its last-known state).
120
151
  projectPathIsGit: boolean | null;
152
+ // `true` when the resolved Project Path is itself an existing
153
+ // *linked* worktree (created by `git worktree add`). In that
154
+ // case leaving "Create a new git worktree" unchecked attaches
155
+ // the session to it as a managed worktree rather than treating
156
+ // it as a shared root. The probe defaults the checkbox to
157
+ // unchecked when it first detects this, and `buildFormSpec`
158
+ // surfaces an explanatory hint. `null` while the probe runs.
159
+ projectPathIsLinkedWorktree: boolean | null;
121
160
  // Concrete session name the auto-generator would produce
122
161
  // for the current Project Path (e.g. "session-3"). Surfaced
123
162
  // as the Session Name placeholder so the user sees the
@@ -208,10 +247,34 @@ interface OpenDialogState {
208
247
  // anchor it needs.
209
248
  originalActiveSession: number;
210
249
  // When non-null, the preview pane swaps to a confirmation
211
- // panel for the named action against the named session id.
212
- // Cleared on Cancel or after the action completes.
250
+ // panel for the named action against the listed session ids.
251
+ // A single-element `ids` is the per-row Stop/Archive/Delete
252
+ // path; a multi-element `ids` is a bulk action over the
253
+ // checkbox selection. Cleared on Cancel or after the action
254
+ // completes.
213
255
  pendingConfirm:
214
- | { action: "stop" | "archive" | "delete"; sessionId: number }
256
+ | { action: "stop" | "archive" | "delete"; ids: number[] }
257
+ | null;
258
+ // Rows the user has checkbox-selected (Space, or click) for a
259
+ // bulk Stop/Archive/Delete. Holds session ids — positive for
260
+ // live windows, negative for discovered on-disk worktrees
261
+ // (which bulk-delete via `git worktree remove`). Survives filter
262
+ // and scope changes; pruned against the live set on every
263
+ // refresh. Bulk mode (the dedicated selection bar) engages once
264
+ // two or more rows are checked.
265
+ selectedIds: Set<number>;
266
+ // `true` shows the discovered on-disk worktree rows in the list.
267
+ // The "Show all worktrees" checkbox below the scope control toggles
268
+ // it (Alt+T / `orchestrator_toggle_worktrees`). Defaults to false
269
+ // (worktrees hidden) — discovery is opt-in. Remembered across opens
270
+ // via `lastShowWorktrees`.
271
+ showWorktrees: boolean;
272
+ // Progress marker for an in-flight *bulk* action. While set, the
273
+ // selection bar shows "Archiving 2/3…" and its buttons are
274
+ // hidden so a second Enter can't re-fire mid-batch. Cleared when
275
+ // the batch finishes.
276
+ bulkInFlight:
277
+ | { action: "stop" | "archive" | "delete"; total: number; done: number }
215
278
  | null;
216
279
  // Rows the embed reserves and rows the sessions list shows.
217
280
  // Captured once at dialog-open from the editor's viewport so
@@ -265,6 +328,11 @@ let openPanel: FloatingWidgetPanel | null = null;
265
328
  // showing every session; flipping it with the Project control / Alt+P
266
329
  // updates this and the next open honours it.
267
330
  let lastOpenScope: "current" | "all" = "all";
331
+ // Remembered across opens, like `lastOpenScope`: whether the
332
+ // discovered on-disk worktree rows are shown. Defaults to false
333
+ // (worktrees hidden) — surfacing them is opt-in via "Show all
334
+ // worktrees" (Alt+T).
335
+ let lastShowWorktrees = false;
268
336
  const OPEN_MODE = "orchestrator-open";
269
337
 
270
338
  // =============================================================================
@@ -298,11 +366,122 @@ function reconcileSessions(): void {
298
366
  if (s.shared_worktree != null) existing.sharedWorktree = s.shared_worktree;
299
367
  }
300
368
  }
369
+ // Live windows live in the positive id space; their absence from
370
+ // `listWindows()` means they were closed, so drop them. Discovered
371
+ // worktrees (negative ids) are NOT backed by a window and must
372
+ // survive this sweep — they're pruned separately, against the
373
+ // on-disk worktree set, by `refreshDiscoveredWorktrees`.
301
374
  for (const id of orchestratorSessions.keys()) {
302
- if (!seen.has(id)) orchestratorSessions.delete(id);
375
+ if (id > 0 && !seen.has(id)) orchestratorSessions.delete(id);
376
+ }
377
+ // A worktree that's now open as a live window must not also linger
378
+ // as a discovered row. Drop any discovered entry whose root a live
379
+ // session already occupies.
380
+ const liveRoots = new Set<string>();
381
+ for (const s of orchestratorSessions.values()) {
382
+ if (!s.discovered) liveRoots.add(s.root);
383
+ }
384
+ for (const [id, s] of orchestratorSessions) {
385
+ if (s.discovered && liveRoots.has(s.root)) orchestratorSessions.delete(id);
303
386
  }
304
387
  }
305
388
 
389
+ // =============================================================================
390
+ // Discovered-worktree scan
391
+ //
392
+ // Surfaces worktrees that exist on disk but have no live editor
393
+ // window, so the user doesn't have to add them by hand. Because
394
+ // open sessions can span several repos, `git worktree list` must
395
+ // run once *per project*: the scan set is the distinct canonical
396
+ // repo roots of every live session, plus the editor's cwd repo.
397
+ // Each linked worktree not already open (and not an
398
+ // orchestrator-internal tree) becomes a discovered row that dives
399
+ // by attaching a fresh session to it.
400
+ // =============================================================================
401
+
402
+ let discoveryInFlight = false;
403
+
404
+ function isInternalWorktreePath(path: string): boolean {
405
+ // The sync-workspace and the `.archived/` graveyard are
406
+ // orchestrator bookkeeping, not user sessions.
407
+ return path.includes(".sync-workspace") || path.includes("/.archived/");
408
+ }
409
+
410
+ async function refreshDiscoveredWorktrees(): Promise<void> {
411
+ if (discoveryInFlight) return;
412
+ discoveryInFlight = true;
413
+ try {
414
+ reconcileSessions();
415
+
416
+ // (1) Candidate dirs: every live session's root + the editor
417
+ // cwd. Resolve each to its canonical main repo root and
418
+ // dedupe so a repo with N open worktrees is scanned once.
419
+ const candidates = new Set<string>([editor.getCwd()]);
420
+ for (const s of orchestratorSessions.values()) {
421
+ if (!s.discovered) candidates.add(s.root);
422
+ }
423
+ const mainRoots = new Set<string>();
424
+ for (const dir of candidates) {
425
+ const canonical = await resolveCanonicalRepoRoot(dir);
426
+ if (canonical) mainRoots.add(canonical);
427
+ }
428
+
429
+ // (2) Roots already occupied by a live session — discovered rows
430
+ // for these would be duplicates.
431
+ const liveRoots = new Set<string>();
432
+ for (const s of orchestratorSessions.values()) {
433
+ if (!s.discovered) liveRoots.add(s.root);
434
+ }
435
+
436
+ // (3) Scan each repo and collect the linked worktrees worth
437
+ // surfacing.
438
+ const foundPaths = new Set<string>();
439
+ for (const repoRoot of mainRoots) {
440
+ const listed = await listLinkedWorktrees(repoRoot);
441
+ if (!listed) continue;
442
+ for (const wt of listed.worktrees) {
443
+ if (liveRoots.has(wt.path)) continue;
444
+ if (isInternalWorktreePath(wt.path)) continue;
445
+ foundPaths.add(wt.path);
446
+ const id = discoveredIdFor(wt.path);
447
+ const label = wt.branch || editor.pathBasename(wt.path);
448
+ const existing = orchestratorSessions.get(id);
449
+ if (existing) {
450
+ existing.label = label;
451
+ existing.root = wt.path;
452
+ existing.projectPath = listed.mainRoot;
453
+ existing.branch = wt.branch;
454
+ } else {
455
+ orchestratorSessions.set(id, {
456
+ id,
457
+ label,
458
+ root: wt.path,
459
+ projectPath: listed.mainRoot,
460
+ sharedWorktree: false,
461
+ terminalId: null,
462
+ state: "ready",
463
+ createdAt: Date.now(),
464
+ discovered: true,
465
+ branch: wt.branch,
466
+ });
467
+ }
468
+ }
469
+ }
470
+
471
+ // (4) Prune discovered rows that vanished from disk (or got
472
+ // opened, picked up by the liveRoots check above).
473
+ for (const [id, s] of orchestratorSessions) {
474
+ if (s.discovered && !foundPaths.has(s.root)) {
475
+ orchestratorSessions.delete(id);
476
+ discoveredIdByPath.delete(s.root);
477
+ }
478
+ }
479
+ } finally {
480
+ discoveryInFlight = false;
481
+ }
482
+ if (openPanel) refreshOpenDialog();
483
+ }
484
+
306
485
  // =============================================================================
307
486
  // Session display helpers
308
487
  // =============================================================================
@@ -381,12 +560,23 @@ function projectLabel(key: string): string {
381
560
  function filterSessions(needle: string): number[] {
382
561
  reconcileSessions();
383
562
  const scope = openDialog?.scope ?? "current";
563
+ const showWorktrees = openDialog?.showWorktrees ?? false;
384
564
  const cur = currentProjectKey();
385
- const allIds = Array.from(orchestratorSessions.keys());
565
+ let allIds = Array.from(orchestratorSessions.keys());
566
+ // "Show all worktrees" is opt-in: by default the discovered on-disk
567
+ // worktree rows are filtered out.
568
+ if (!showWorktrees) {
569
+ allIds = allIds.filter((id) => !orchestratorSessions.get(id)!.discovered);
570
+ }
571
+
572
+ const isDisc = (id: number): number =>
573
+ orchestratorSessions.get(id)!.discovered ? 1 : 0;
386
574
 
387
- // Sort by (current-project-first, then id) so an "all" view
388
- // groups the current project's sessions at the top and other
389
- // projects' sessions below in a stable order.
575
+ // Sort by (current-project-first, project, live-before-discovered,
576
+ // then id) so an "all" view groups the current project's sessions
577
+ // at the top and other projects' below, and within each project the
578
+ // pre-existing live sessions come first with the discovered on-disk
579
+ // worktrees listed after them.
390
580
  const byProjectThenId = (a: number, b: number): number => {
391
581
  const sa = orchestratorSessions.get(a)!;
392
582
  const sb = orchestratorSessions.get(b)!;
@@ -396,6 +586,9 @@ function filterSessions(needle: string): number[] {
396
586
  const ka = projectKeyOf(sa);
397
587
  const kb = projectKeyOf(sb);
398
588
  if (ka !== kb) return ka < kb ? -1 : 1;
589
+ const da = isDisc(a);
590
+ const db = isDisc(b);
591
+ if (da !== db) return da - db;
399
592
  return a - b;
400
593
  };
401
594
 
@@ -422,25 +615,33 @@ function filterSessions(needle: string): number[] {
422
615
  matches.push({ id, score: 2, len: label.length });
423
616
  }
424
617
  }
425
- matches.sort((a, b) => a.score - b.score || a.len - b.len || a.id - b.id);
618
+ // Live sessions before discovered worktrees at equal relevance, so
619
+ // the on-disk rows still trail the real sessions in search results.
620
+ matches.sort(
621
+ (a, b) =>
622
+ a.score - b.score || isDisc(a.id) - isDisc(b.id) || a.len - b.len ||
623
+ a.id - b.id,
624
+ );
426
625
  return matches.map((m) => m.id);
427
626
  }
428
627
 
429
- // Column widths for the tabular session list. ID holds `[NN] `;
430
- // NAME holds the label plus the BASE / badges; PROJECT (filled
431
- // only for cross-project rows) trails. Kept in sync with
432
- // `sessionsColumnHeader`.
433
- const LIST_ID_W = 5;
434
- const LIST_NAME_W = 20;
628
+ // Width of the NAME column before the trailing PROJECT column kicks
629
+ // in (filled only for cross-project rows). Kept in sync with
630
+ // `sessionsColumnHeader`. There is no id column the numeric window
631
+ // id is an internal handle the user never needs in the list; rows are
632
+ // identified by name, the active one rendered bold and on-disk
633
+ // worktrees flagged with a `· on-disk` tag.
634
+ const LIST_NAME_W = 24;
435
635
 
436
- // Header row above the session list: `ID NAME … PROJECT`.
636
+ // Header row above the session list: `NAME … PROJECT`.
437
637
  function sessionsColumnHeader(): WidgetSpec {
438
638
  return {
439
639
  kind: "raw",
440
640
  entries: [
441
641
  styledRow([
442
642
  {
443
- text: "ID".padEnd(LIST_ID_W) + "NAME".padEnd(LIST_NAME_W) + "PROJECT",
643
+ // 4-space lead aligns under the per-row `[ ] ` checkbox.
644
+ text: " " + "NAME".padEnd(LIST_NAME_W) + "PROJECT",
444
645
  style: { fg: "ui.menu_disabled_fg" },
445
646
  },
446
647
  ]),
@@ -448,28 +649,42 @@ function sessionsColumnHeader(): WidgetSpec {
448
649
  };
449
650
  }
450
651
 
451
- // Build one rendered list-item row for `id`, laid out in columns:
452
- // `[id]` <name + BASE/⇄ badges> <project basename>
453
- // The active session's id renders in the active-tab colour (the
454
- // list has no separate state column); the project column is filled
455
- // only for sessions that don't belong to the current project.
652
+ // Build one rendered list-item row for `id`:
653
+ // `[ ] ` <name + BASE/⇄ badges + on-disk tag> <project basename>
654
+ // The active session's name renders bold; discovered (on-disk,
655
+ // unopened) worktrees render dim with a on-disk` tag instead of a
656
+ // glyph. The project column is filled only for sessions that don't
657
+ // belong to the current project.
456
658
  function renderListItem(id: number, activeId: number): TextPropertyEntry {
457
659
  const s = orchestratorSessions.get(id);
458
660
  if (!s) {
459
- return styledRow([{ text: `[${id}] (unknown)` }]);
661
+ return styledRow([{ text: "(unknown)" }]);
460
662
  }
461
663
  const isActive = id === activeId;
462
664
  const isBase = id === 1;
665
+ const isDiscovered = !!s.discovered;
666
+ const isChecked = openDialog?.selectedIds.has(id) ?? false;
667
+
668
+ // Leading multi-select checkbox. `[x]` when this row is in the
669
+ // bulk selection, `[ ]` otherwise — toggled with Space (the
670
+ // rebindable `orchestrator_toggle_select`) or a click.
671
+ const checkbox = {
672
+ text: isChecked ? "[x] " : "[ ] ",
673
+ style: isChecked
674
+ ? { fg: "ui.help_key_fg", bold: true }
675
+ : { fg: "ui.menu_disabled_fg" },
676
+ };
463
677
 
464
- const idText = `[${id}]`.padEnd(LIST_ID_W);
465
678
  const entries: { text: string; style?: Record<string, unknown> }[] = [
679
+ checkbox,
466
680
  {
467
- text: idText,
681
+ text: s.label,
468
682
  style: isActive
469
- ? { fg: "ui.tab_active_fg", bold: true }
470
- : { fg: "ui.help_key_fg" },
683
+ ? { fg: "ui.help_key_fg", bold: true }
684
+ : isDiscovered
685
+ ? { fg: "ui.menu_disabled_fg" }
686
+ : undefined,
471
687
  },
472
- { text: s.label, style: isActive ? { bold: true } : undefined },
473
688
  ];
474
689
  // Visible width of the NAME column so far (label + badges), used
475
690
  // to pad out to LIST_NAME_W before the PROJECT column.
@@ -482,6 +697,13 @@ function renderListItem(id: number, activeId: number): TextPropertyEntry {
482
697
  entries.push({ text: " ⇄", style: { fg: "ui.menu_disabled_fg" } });
483
698
  nameWidth += 2;
484
699
  }
700
+ if (isDiscovered) {
701
+ entries.push({
702
+ text: " · on-disk",
703
+ style: { fg: "ui.menu_disabled_fg", italic: true },
704
+ });
705
+ nameWidth += 10;
706
+ }
485
707
  // PROJECT column: basename for cross-project rows only; current-
486
708
  // project rows leave it blank (the whole list is one project when
487
709
  // scoped, so this column is empty then).
@@ -526,7 +748,7 @@ function buildPreviewEntries(
526
748
  {
527
749
  text: stateText,
528
750
  style: isActive
529
- ? { fg: "ui.tab_active_fg", bold: true }
751
+ ? { fg: "ui.help_key_fg", bold: true }
530
752
  : { fg: "ui.menu_disabled_fg" },
531
753
  },
532
754
  { text: " " },
@@ -588,6 +810,72 @@ function countSiblingsAtRoot(root: string): number {
588
810
  return n;
589
811
  }
590
812
 
813
+ // =============================================================================
814
+ // Multi-select / bulk actions
815
+ //
816
+ // The user checkbox-selects rows (Space — the rebindable
817
+ // `orchestrator_toggle_select` — or a click). Once two or more rows
818
+ // are checked the preview pane swaps to the bulk selection bar
819
+ // (`buildBulkPane`) offering Stop / Archive / Delete over the whole
820
+ // set, with a single confirmation for the batch. Rows ineligible for
821
+ // a given action (the base session; live sessions sharing a worktree)
822
+ // are skipped, and each button's count reflects only the eligible
823
+ // members.
824
+ // =============================================================================
825
+
826
+ type BulkAction = "stop" | "archive" | "delete";
827
+
828
+ // Checked ids that still resolve to a known session, in the dialog's
829
+ // current display order (so the bulk bar lists them the way the list
830
+ // shows them). Selection persists across filter/scope changes, so an
831
+ // id can be checked while filtered out of view — those still count.
832
+ function selectedSessions(): number[] {
833
+ if (!openDialog) return [];
834
+ const order = openDialog.filteredIds;
835
+ const seen = new Set<number>();
836
+ const out: number[] = [];
837
+ for (const id of order) {
838
+ if (openDialog.selectedIds.has(id) && orchestratorSessions.has(id)) {
839
+ out.push(id);
840
+ seen.add(id);
841
+ }
842
+ }
843
+ // Checked-but-filtered-out rows, appended in id order so the count
844
+ // stays honest even when a search hides part of the selection.
845
+ for (const id of openDialog.selectedIds) {
846
+ if (!seen.has(id) && orchestratorSessions.has(id)) out.push(id);
847
+ }
848
+ return out;
849
+ }
850
+
851
+ // Is `id` a legal target for `action`? Base session is never
852
+ // touched. Stop only applies to live windows. Archive/Delete apply
853
+ // to discovered worktrees (removable on disk) and to live sessions
854
+ // that own their worktree outright (not shared with siblings or the
855
+ // project root).
856
+ function bulkEligible(action: BulkAction, id: number): boolean {
857
+ const s = orchestratorSessions.get(id);
858
+ if (!s) return false;
859
+ if (id === 1) return false;
860
+ if (action === "stop") return !s.discovered && id > 0;
861
+ if (s.discovered) return true;
862
+ const sharesRoot = countSiblingsAtRoot(s.root) > 1 || s.sharedWorktree;
863
+ return !sharesRoot;
864
+ }
865
+
866
+ function eligibleSelected(action: BulkAction): number[] {
867
+ return selectedSessions().filter((id) => bulkEligible(action, id));
868
+ }
869
+
870
+ // Drop checked ids whose session has vanished (closed window,
871
+ // pruned worktree) so the selection can't grow stale references.
872
+ function pruneSelection(): void {
873
+ if (!openDialog) return;
874
+ for (const id of [...openDialog.selectedIds]) {
875
+ if (!orchestratorSessions.has(id)) openDialog.selectedIds.delete(id);
876
+ }
877
+ }
878
+
591
879
  // Blank-row separator used inside the Sessions column between
592
880
  // the filter, the new-session button, and the list.
593
881
  function sessionsSeparator(): WidgetSpec {
@@ -610,10 +898,10 @@ function maxListRowsForScreen(): number {
610
898
  const panelH = Math.floor(h * 0.9);
611
899
  // Chrome that isn't list rows: panel borders (2) + title (1) +
612
900
  // spacer (1) + footer (1) + sessions-section borders (2) +
613
- // column chrome above the list (New + Project + Filter +
614
- // separator + header = 5) = 12. Floor at MIN_LIST_ROWS so a tiny
615
- // terminal still shows something.
616
- return Math.max(MIN_LIST_ROWS, panelH - 12);
901
+ // column chrome above the list (New + Project + Worktree-filter +
902
+ // Filter + separator + header = 6) = 13. Floor at MIN_LIST_ROWS so
903
+ // a tiny terminal still shows something.
904
+ return Math.max(MIN_LIST_ROWS, panelH - 13);
617
905
  }
618
906
 
619
907
  // Compose the right-hand preview pane. Normally it shows info
@@ -658,130 +946,28 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
658
946
  ),
659
947
  });
660
948
  }
661
- if (openDialog?.pendingConfirm && s && openDialog.pendingConfirm.sessionId === s.id) {
662
- const action = openDialog.pendingConfirm.action;
663
- if (action === "stop") {
664
- return labeledSection({
665
- label: "Confirm Stop",
666
- child: col(
667
- {
668
- kind: "raw",
669
- entries: [
670
- styledRow([
671
- {
672
- text: `Stop session [${s.id}] ${s.label}?`,
673
- style: { bold: true },
674
- },
675
- ]),
676
- styledRow([{ text: "" }]),
677
- styledRow([{ text: "This will:" }]),
678
- styledRow([{ text: " • send SIGTERM to all session processes" }]),
679
- styledRow([{ text: " • SIGKILL after a short grace period" }]),
680
- styledRow([{ text: "" }]),
681
- styledRow([{ text: "The worktree and session record remain." }]),
682
- ],
683
- },
684
- spacer(0),
685
- row(
686
- flexSpacer(),
687
- button("Cancel", { key: "confirm-cancel" }),
688
- spacer(2),
689
- button("Confirm Stop", {
690
- intent: "danger",
691
- key: "confirm-stop",
692
- }),
693
- ),
694
- ),
695
- });
696
- }
697
- if (action === "archive") {
698
- return labeledSection({
699
- label: "Confirm Archive",
700
- child: col(
701
- {
702
- kind: "raw",
703
- entries: [
704
- styledRow([
705
- {
706
- text: `Archive session [${s.id}] ${s.label}?`,
707
- style: { bold: true },
708
- },
709
- ]),
710
- styledRow([{ text: "" }]),
711
- styledRow([{ text: "This will:" }]),
712
- styledRow([{ text: " • SIGKILL all session processes" }]),
713
- styledRow([{ text: " • close the editor session" }]),
714
- styledRow([{ text: " • move the worktree to .archived/" }]),
715
- styledRow([{ text: "" }]),
716
- styledRow([{ text: "Reversible via Unarchive." }]),
717
- ],
718
- },
719
- spacer(0),
720
- row(
721
- flexSpacer(),
722
- button("Cancel", { key: "confirm-cancel" }),
723
- spacer(2),
724
- button("Confirm Archive", {
725
- intent: "danger",
726
- key: "confirm-archive",
727
- }),
728
- ),
729
- ),
730
- });
731
- }
732
- if (action === "delete") {
733
- return labeledSection({
734
- label: "Confirm Delete",
735
- child: col(
736
- {
737
- kind: "raw",
738
- entries: [
739
- styledRow([
740
- {
741
- text: `Delete session [${s.id}] ${s.label}?`,
742
- style: { bold: true },
743
- },
744
- ]),
745
- styledRow([{ text: "" }]),
746
- styledRow([{ text: "This will:" }]),
747
- styledRow([{ text: " • stop all session processes" }]),
748
- styledRow([{ text: " • run `git worktree remove`" }]),
749
- styledRow([{ text: " • drop the session record" }]),
750
- styledRow([{ text: "" }]),
751
- styledRow([
752
- {
753
- text: "Uncommitted changes will be lost.",
754
- style: {
755
- fg: "ui.status_error_indicator_fg",
756
- bold: true,
757
- },
758
- },
759
- ]),
760
- ],
761
- },
762
- spacer(0),
763
- row(
764
- flexSpacer(),
765
- button("Cancel", { key: "confirm-cancel" }),
766
- spacer(2),
767
- button("Confirm Delete", {
768
- intent: "danger",
769
- key: "confirm-delete",
770
- }),
771
- ),
772
- ),
773
- });
774
- }
949
+ // Confirmation panel single-row Stop/Archive/Delete or a bulk
950
+ // batch. Independent of the cursor row: the confirmed ids live in
951
+ // `pendingConfirm`, so it renders whenever a confirm is pending.
952
+ if (openDialog?.pendingConfirm) {
953
+ return buildConfirmPane(openDialog.pendingConfirm);
954
+ }
955
+ // Bulk selection bar: two or more rows checked (or a bulk action
956
+ // in flight) → operate on the whole batch rather than the cursor
957
+ // row.
958
+ if (selectedSessions().length >= 2 || openDialog?.bulkInFlight) {
959
+ return buildBulkPane();
775
960
  }
776
961
  // Match the sessions column's content height so the two panes'
777
962
  // bottom borders land on the same row. Sessions column inside its
778
- // borders = New (1) + Project (1) + Filter (1) + separator (1) +
779
- // header (1) + list (listVisibleRows) = listVisibleRows + 5.
780
- // Preview inside its borders = button row (1) + spacer (1) +
781
- // embedRows, so embedRows must equal listVisibleRows + 3. When
782
- // details ARE shown, two info rows + a spacer eat three more
783
- // lines — `_DETAILS_CHROME_ROWS` accounts for that.
784
- const totalEmbedBase = (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) + 3;
963
+ // borders = New (1) + Project (1) + Worktree-filter (1) +
964
+ // Filter (1) + separator (1) + header (1) + list (listVisibleRows)
965
+ // = listVisibleRows + 6. Preview inside its borders = button
966
+ // row (1) + spacer (1) + embedRows, so embedRows must equal
967
+ // listVisibleRows + 4. When details ARE shown, two info rows + a
968
+ // spacer eat three more lines — `_DETAILS_CHROME_ROWS` accounts
969
+ // for that.
970
+ const totalEmbedBase = (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) + 4;
785
971
  const detailsOn = openDialog?.showDetails ?? false;
786
972
  const _DETAILS_CHROME_ROWS = 3; // 2 info rows + 1 spacer
787
973
  const embedRows = Math.max(
@@ -814,6 +1000,48 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
814
1000
  // turns details on, pressing `[ Preview ]` turns them off
815
1001
  // (back to compact).
816
1002
  const detailsToggleLabel = detailsOn ? "Preview" : "Details";
1003
+ // Discovered worktree: no live window to embed, so there's
1004
+ // nothing to Stop / Archive / Delete yet. Offer only "Open"
1005
+ // (Visit attaches a fresh session to the worktree) and describe
1006
+ // what diving will do. The empty `windowId: 0` embed keeps the
1007
+ // pane the same height as live-session previews so the dialog
1008
+ // doesn't jump when the selection moves between row kinds.
1009
+ if (s.discovered) {
1010
+ const openButtonRow = row(
1011
+ button("Open", { intent: "primary", key: "visit" }),
1012
+ flexSpacer(),
1013
+ button("Stop", { key: "stop", disabled: true }),
1014
+ spacer(2),
1015
+ button("Archive", { key: "archive", disabled: true }),
1016
+ spacer(2),
1017
+ button("Delete", { intent: "danger", key: "delete", disabled: true }),
1018
+ );
1019
+ const info: TextPropertyEntry[] = [
1020
+ styledRow([
1021
+ { text: "On-disk worktree (not open)", style: { fg: "ui.menu_disabled_fg", bold: true } },
1022
+ ]),
1023
+ styledRow([{ text: "" }]),
1024
+ styledRow([{ text: "branch ", style: { fg: "ui.menu_disabled_fg" } }, { text: s.branch || "(detached)" }]),
1025
+ styledRow([{ text: "path ", style: { fg: "ui.menu_disabled_fg" } }, { text: s.root }]),
1026
+ styledRow([{ text: "" }]),
1027
+ styledRow([
1028
+ {
1029
+ text: "Press Enter to open this worktree as a session.",
1030
+ style: { fg: "ui.help_key_fg", italic: true },
1031
+ },
1032
+ ]),
1033
+ ];
1034
+ return labeledSection({
1035
+ label: `${s.label} — on-disk worktree`,
1036
+ child: col(
1037
+ openButtonRow,
1038
+ spacer(0),
1039
+ { kind: "raw", entries: info },
1040
+ spacer(0),
1041
+ windowEmbed({ windowId: 0, rows: Math.max(3, embedRows - 6), key: "live-preview" }),
1042
+ ),
1043
+ });
1044
+ }
817
1045
  // Per-action availability. The row always renders all four
818
1046
  // buttons (no layout shift between selections), but each is
819
1047
  // marked disabled when its action would be refused against the
@@ -869,14 +1097,225 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
869
1097
  // its worktree would close the editor / break the user's current
870
1098
  // tree, so Stop / Archive / Delete refuse against it.
871
1099
  const sectionLabel = isBase
872
- ? `[${s.id}] ${s.label} BASE editor session`
873
- : `[${s.id}] ${s.label}`;
1100
+ ? `${s.label} BASE (editor session)`
1101
+ : s.label;
874
1102
  return labeledSection({
875
1103
  label: sectionLabel,
876
1104
  child: body,
877
1105
  });
878
1106
  }
879
1107
 
1108
+ // The per-action bullet lines shown in the confirmation panel.
1109
+ // `delete` adds a separate red "uncommitted changes" line in the
1110
+ // caller because it needs distinct styling.
1111
+ function confirmActionLines(action: BulkAction): string[] {
1112
+ switch (action) {
1113
+ case "stop":
1114
+ return [
1115
+ " • send SIGTERM to all session processes",
1116
+ " • SIGKILL after a short grace period",
1117
+ "",
1118
+ "The worktree and session record remain.",
1119
+ ];
1120
+ case "archive":
1121
+ return [
1122
+ " • SIGKILL all session processes",
1123
+ " • close the editor session",
1124
+ " • move the worktree to .archived/",
1125
+ "",
1126
+ "Reversible via Unarchive.",
1127
+ ];
1128
+ case "delete":
1129
+ return [
1130
+ " • stop all session processes",
1131
+ " • run `git worktree remove`",
1132
+ " • drop the session record",
1133
+ ];
1134
+ }
1135
+ }
1136
+
1137
+ // Confirmation panel for a Stop/Archive/Delete over one or many
1138
+ // sessions. A single id renders the familiar per-session prompt; two
1139
+ // or more render a batch prompt that lists the targets. The Confirm
1140
+ // button reuses the same `confirm-<action>` key the single path
1141
+ // always used, so the existing widget_event handlers fire for both —
1142
+ // they read `pendingConfirm.ids`.
1143
+ function buildConfirmPane(
1144
+ confirm: { action: BulkAction; ids: number[] },
1145
+ ): WidgetSpec {
1146
+ const { action, ids } = confirm;
1147
+ const cap = action[0].toUpperCase() + action.slice(1);
1148
+ const existing = ids.filter((id) => orchestratorSessions.has(id));
1149
+ const bulk = existing.length > 1;
1150
+ const diskNote = (id: number): string =>
1151
+ orchestratorSessions.get(id)?.discovered ? " · on-disk" : "";
1152
+ const entries: TextPropertyEntry[] = [];
1153
+ if (bulk) {
1154
+ entries.push(
1155
+ styledRow([
1156
+ { text: `${cap} these ${existing.length} sessions?`, style: { bold: true } },
1157
+ ]),
1158
+ styledRow([{ text: "" }]),
1159
+ );
1160
+ for (const id of existing.slice(0, 8)) {
1161
+ const ss = orchestratorSessions.get(id)!;
1162
+ entries.push(
1163
+ styledRow([
1164
+ { text: ` ${ss.label}` },
1165
+ { text: diskNote(id), style: { fg: "ui.menu_disabled_fg", italic: true } },
1166
+ ]),
1167
+ );
1168
+ }
1169
+ if (existing.length > 8) {
1170
+ entries.push(
1171
+ styledRow([
1172
+ {
1173
+ text: ` … and ${existing.length - 8} more`,
1174
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1175
+ },
1176
+ ]),
1177
+ );
1178
+ }
1179
+ } else {
1180
+ const id = existing[0];
1181
+ const ss = id !== undefined ? orchestratorSessions.get(id) : undefined;
1182
+ entries.push(
1183
+ styledRow([
1184
+ { text: `${cap} session ${ss?.label ?? ""}?`, style: { bold: true } },
1185
+ ]),
1186
+ );
1187
+ }
1188
+ entries.push(
1189
+ styledRow([{ text: "" }]),
1190
+ styledRow([{ text: bulk ? "For each session this will:" : "This will:" }]),
1191
+ );
1192
+ for (const line of confirmActionLines(action)) {
1193
+ entries.push(styledRow([{ text: line }]));
1194
+ }
1195
+ if (action === "delete") {
1196
+ entries.push(
1197
+ styledRow([{ text: "" }]),
1198
+ styledRow([
1199
+ {
1200
+ text: "Uncommitted changes will be lost.",
1201
+ style: { fg: "ui.status_error_indicator_fg", bold: true },
1202
+ },
1203
+ ]),
1204
+ );
1205
+ }
1206
+ return labeledSection({
1207
+ label: bulk ? `Confirm ${cap} — ${existing.length} sessions` : `Confirm ${cap}`,
1208
+ child: col(
1209
+ { kind: "raw", entries },
1210
+ spacer(0),
1211
+ row(
1212
+ flexSpacer(),
1213
+ button("Cancel", { key: "confirm-cancel" }),
1214
+ spacer(2),
1215
+ button(`Confirm ${cap}`, { intent: "danger", key: `confirm-${action}` }),
1216
+ ),
1217
+ ),
1218
+ });
1219
+ }
1220
+
1221
+ // The dedicated bulk selection bar (Layout B). Shown in place of the
1222
+ // per-session preview when two or more rows are checked. The bulk
1223
+ // action buttons sit at the *top* of the pane; the list of affected
1224
+ // sessions renders below as a scrollable `list` widget (so a long
1225
+ // selection scrolls — keyboard, wheel, and the draggable scrollbar —
1226
+ // rather than overflowing the pane). Each action's count is the
1227
+ // number of *eligible* members; an action with no eligible members is
1228
+ // disabled.
1229
+ function buildBulkPane(): WidgetSpec {
1230
+ const sel = selectedSessions();
1231
+ const stopN = eligibleSelected("stop").length;
1232
+ const archiveN = eligibleSelected("archive").length;
1233
+ const deleteN = eligibleSelected("delete").length;
1234
+
1235
+ const inflight = openDialog?.bulkInFlight ?? null;
1236
+ const actionRow = inflight
1237
+ ? row(
1238
+ {
1239
+ kind: "raw",
1240
+ entries: [
1241
+ styledRow([
1242
+ {
1243
+ text: `${inflight.action[0].toUpperCase()}${inflight.action.slice(1)}ing ${inflight.done}/${inflight.total}…`,
1244
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1245
+ },
1246
+ ]),
1247
+ ],
1248
+ },
1249
+ flexSpacer(),
1250
+ )
1251
+ : row(
1252
+ button(`Stop (${stopN})`, { key: "bulk-stop", disabled: stopN === 0 }),
1253
+ spacer(2),
1254
+ button(`Archive (${archiveN})`, {
1255
+ key: "bulk-archive",
1256
+ disabled: archiveN === 0,
1257
+ }),
1258
+ spacer(2),
1259
+ button(`Delete (${deleteN})`, {
1260
+ intent: "danger",
1261
+ key: "bulk-delete",
1262
+ disabled: deleteN === 0,
1263
+ }),
1264
+ flexSpacer(),
1265
+ button("Clear", { key: "bulk-clear" }),
1266
+ );
1267
+
1268
+ // Affected-sessions list. Flag the rows a destructive action will
1269
+ // skip so the count discrepancy explains itself.
1270
+ const items: TextPropertyEntry[] = sel.map((id) => {
1271
+ const ss = orchestratorSessions.get(id)!;
1272
+ const rowParts: StyledSegment[] = [{ text: ` ${ss.label}` }];
1273
+ if (id === 1) {
1274
+ rowParts.push({
1275
+ text: " · base (protected)",
1276
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1277
+ });
1278
+ } else if (!ss.discovered && (countSiblingsAtRoot(ss.root) > 1 || ss.sharedWorktree)) {
1279
+ rowParts.push({
1280
+ text: " · shared worktree",
1281
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1282
+ });
1283
+ } else if (ss.discovered) {
1284
+ rowParts.push({
1285
+ text: " · on-disk worktree",
1286
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1287
+ });
1288
+ }
1289
+ return styledRow(rowParts);
1290
+ });
1291
+ const itemKeys = sel.map((id) => `bulksel-${id}`);
1292
+ // Match the preview pane's height: content = action row (1) +
1293
+ // spacer (1) + list, and the embed pane reserves `listVisibleRows
1294
+ // + 4` for its body — so the list takes that height and the two
1295
+ // panes' bottom borders line up.
1296
+ const listRows = Math.max(3, (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) + 4);
1297
+
1298
+ return labeledSection({
1299
+ label: `Bulk actions — ${sel.length} selected`,
1300
+ child: col(
1301
+ actionRow,
1302
+ spacer(0),
1303
+ list({
1304
+ items,
1305
+ itemKeys,
1306
+ // Display-only: no highlighted row, and out of the Tab cycle
1307
+ // (focus belongs on the action buttons). Up/Down still scroll
1308
+ // it via the host's smart-key forwarding, and the scrollbar
1309
+ // drags it.
1310
+ selectedIndex: -1,
1311
+ visibleRows: listRows,
1312
+ focusable: false,
1313
+ key: "bulk-list",
1314
+ }),
1315
+ ),
1316
+ });
1317
+ }
1318
+
880
1319
  function buildOpenSpec(): WidgetSpec {
881
1320
  if (!openDialog) return col();
882
1321
  const filtered = openDialog.filteredIds;
@@ -890,9 +1329,11 @@ function buildOpenSpec(): WidgetSpec {
890
1329
  const selIdx = filtered.length === 0
891
1330
  ? -1
892
1331
  : Math.max(0, Math.min(openDialog.selectedIndex, filtered.length - 1));
893
- const selectedId = selIdx >= 0 ? filtered[selIdx] : -1;
894
- const selectedSession = selectedId > 0
895
- ? orchestratorSessions.get(selectedId)
1332
+ // Gate on the *index* (selIdx < 0 means "filter matched nothing"),
1333
+ // not the sign of the id: discovered worktrees carry negative ids
1334
+ // and must still resolve to their row here.
1335
+ const selectedSession = selIdx >= 0
1336
+ ? orchestratorSessions.get(filtered[selIdx])
896
1337
  : undefined;
897
1338
 
898
1339
  // The "New Session" button advertises Alt+N (or whatever the
@@ -974,6 +1415,26 @@ function buildOpenSpec(): WidgetSpec {
974
1415
  scopeButton,
975
1416
  flexSpacer(),
976
1417
  );
1418
+ // Per-project filter checkbox, on its own row under the Project
1419
+ // control: opt-in toggle that surfaces the discovered on-disk
1420
+ // worktree rows. A `toggle` (single `[ ]`/`[v]` — no double
1421
+ // bracket) that's clickable and bound to Alt+T
1422
+ // (`orchestrator_toggle_worktrees`, rebindable). The label carries
1423
+ // the live keybinding hint, mirroring the Project control's
1424
+ // "(Alt+P)". Inert while a confirm prompt is up.
1425
+ const worktreeKey = editor.getKeybindingLabel(
1426
+ "orchestrator_toggle_worktrees",
1427
+ OPEN_MODE,
1428
+ );
1429
+ const worktreeLabel = worktreeKey
1430
+ ? `Show all worktrees (${worktreeKey})`
1431
+ : "Show all worktrees";
1432
+ const worktreeFilterRow = row(
1433
+ toggle(openDialog.showWorktrees, worktreeLabel, {
1434
+ key: openDialog.pendingConfirm !== null ? undefined : "worktree-show",
1435
+ }),
1436
+ flexSpacer(),
1437
+ );
977
1438
 
978
1439
  return col(
979
1440
  {
@@ -1028,6 +1489,7 @@ function buildOpenSpec(): WidgetSpec {
1028
1489
  flexSpacer(),
1029
1490
  ),
1030
1491
  projectControlRow,
1492
+ worktreeFilterRow,
1031
1493
  filterInput,
1032
1494
  sessionsSeparator(),
1033
1495
  sessionsColumnHeader(),
@@ -1067,6 +1529,11 @@ function buildOpenSpec(): WidgetSpec {
1067
1529
  hintBar([
1068
1530
  { keys: "↑↓", label: "nav" },
1069
1531
  { keys: "Enter", label: "dive" },
1532
+ {
1533
+ keys: editor.getKeybindingLabel("orchestrator_toggle_select", OPEN_MODE) ||
1534
+ "Space",
1535
+ label: "select",
1536
+ },
1070
1537
  {
1071
1538
  keys: scopeKey || "⌥P",
1072
1539
  label: scope === "current" ? "all projects" : "current only",
@@ -1127,6 +1594,7 @@ function clearDialogError(): void {
1127
1594
 
1128
1595
  function refreshOpenDialog(): void {
1129
1596
  if (!openPanel || !openDialog) return;
1597
+ pruneSelection();
1130
1598
  openDialog.filteredIds = filterSessions(openDialog.filter.value);
1131
1599
  // Clamp the selection into range so a fresh filter or a
1132
1600
  // session vanishing under us doesn't leave us pointing past
@@ -1167,6 +1635,9 @@ function openControlRoom(): void {
1167
1635
  // Restore the last-used scope (defaults to "all"); the Project
1168
1636
  // control / Alt+P updates it for next time.
1169
1637
  scope: lastOpenScope,
1638
+ selectedIds: new Set<number>(),
1639
+ showWorktrees: lastShowWorktrees,
1640
+ bulkInFlight: null,
1170
1641
  };
1171
1642
  openDialog.filteredIds = filterSessions("");
1172
1643
  const activeIdx = openDialog.filteredIds.indexOf(activeId);
@@ -1188,6 +1659,12 @@ function openControlRoom(): void {
1188
1659
  // safe — there's nothing to act on then anyway.
1189
1660
  openPanel.setFocusKey("visit");
1190
1661
  editor.setEditorMode(OPEN_MODE);
1662
+
1663
+ // Discover worktrees that exist on disk but aren't open yet and
1664
+ // fold them into the list. Async (it shells out to git per
1665
+ // project); the dialog renders immediately with live sessions and
1666
+ // gains the discovered rows when the scan lands.
1667
+ void refreshDiscoveredWorktrees();
1191
1668
  }
1192
1669
 
1193
1670
  function closeOpenDialog(): void {
@@ -1199,33 +1676,27 @@ function closeOpenDialog(): void {
1199
1676
  editor.setEditorMode(null);
1200
1677
  }
1201
1678
 
1202
- // Stop every process the highlighted session owns. Sends
1203
- // SIGTERM first via the host's `signalWindow` (which fans
1204
- // out through the window's process-group tracker), then
1205
- // follows up with SIGKILL after a short grace period so
1206
- // ill-behaved agents that ignore SIGTERM still get reaped.
1207
- // The session record stays put Stop only kills processes,
1208
- // it doesn't touch the worktree or the editor session.
1209
- function stopSelectedSession(): void {
1210
- if (!openDialog) return;
1211
- const id = openDialog.filteredIds[openDialog.selectedIndex];
1212
- if (typeof id !== "number" || id <= 0) return;
1213
- if (id === 1) {
1214
- setDialogError("cannot stop the base session");
1215
- refreshOpenDialog();
1216
- return;
1217
- }
1679
+ // Stop every process one session owns. Sends SIGTERM first via the
1680
+ // host's `signalWindow` (which fans out through the window's
1681
+ // process-group tracker), then follows up with SIGKILL after a short
1682
+ // grace period so ill-behaved agents that ignore SIGTERM still get
1683
+ // reaped. The session record stays put Stop only kills processes,
1684
+ // it doesn't touch the worktree or the editor session. Returns false
1685
+ // for ids it can't stop (base session, discovered worktrees with no
1686
+ // live window).
1687
+ function stopOne(id: number): boolean {
1688
+ const s = orchestratorSessions.get(id);
1689
+ if (!s || id <= 0 || id === 1 || s.discovered) return false;
1218
1690
  editor.signalWindow(id, "SIGTERM");
1219
- // SIGKILL fallback for agents that ignore SIGTERM. The
1220
- // host's signalWindow is idempotent on already-exited
1221
- // process groups, so the second call is safe whether or
1222
- // not the first one took. QuickJS has no `setTimeout`;
1223
- // the host exposes `editor.delay(ms)` as the asynchronous
1691
+ // SIGKILL fallback for agents that ignore SIGTERM. The host's
1692
+ // signalWindow is idempotent on already-exited process groups, so
1693
+ // the second call is safe whether or not the first one took.
1694
+ // QuickJS has no `setTimeout`; `editor.delay(ms)` is the async
1224
1695
  // sleep primitive, which we kick off but don't await.
1225
1696
  void editor.delay(2000).then(() => {
1226
1697
  editor.signalWindow(id, "SIGKILL");
1227
1698
  });
1228
- editor.setStatus(`Orchestrator: stop signal sent to session [${id}]`);
1699
+ return true;
1229
1700
  }
1230
1701
 
1231
1702
  // ---------------------------------------------------------------------
@@ -1308,136 +1779,97 @@ function pickNextActiveSession(excludeId: number): number {
1308
1779
  return 1;
1309
1780
  }
1310
1781
 
1311
- // Archive flow: stop all processes (SIGKILL archive is a
1312
- // "I'm done with this for now" action, no graceful teardown
1313
- // needed since the worktree stays on disk), close the editor
1314
- // session, move the worktree to the `.archived/` graveyard,
1315
- // and append a manifest entry so a future Unarchive flow can
1316
- // reverse it.
1317
- async function archiveSelectedSession(explicitId?: number): Promise<void> {
1318
- if (!openDialog) return;
1319
- // Prefer the explicit id from the confirm path. Otherwise read
1320
- // the currently selected row — used by the legacy direct-call
1321
- // entry points. Once the row is hidden synchronously after
1322
- // confirm, `filteredIds[selectedIndex]` no longer points at the
1323
- // session being archived (it shifts to whatever is now under
1324
- // the cursor).
1325
- const id = typeof explicitId === "number"
1326
- ? explicitId
1327
- : openDialog.filteredIds[openDialog.selectedIndex];
1328
- // Clear the in-flight marker so the preview pane stops showing
1329
- // "Archiving…" if the operation refuses or fails. After
1330
- // `closeWindow` succeeds the row is gone from `listWindows()`
1331
- // anyway, so clearing then is harmless.
1332
- const clearInFlight = () => {
1333
- if (
1334
- openDialog?.inFlight && typeof id === "number" &&
1335
- openDialog.inFlight.sessionId === id
1336
- ) {
1337
- openDialog.inFlight = null;
1338
- refreshOpenDialog();
1339
- }
1340
- };
1341
- if (typeof id !== "number" || id <= 0) return;
1342
- if (id === 1) {
1343
- setDialogError("cannot archive the base session");
1344
- clearInFlight();
1345
- return;
1346
- }
1347
- // close_window refuses to close the active window; swap to a
1348
- // different session first. The pick prefers something already
1349
- // in the dialog's current filter, falls back to the base
1350
- // session — both always exist (base is undeletable, and we'd
1351
- // have nothing to archive without at least one session).
1352
- if (id === editor.activeWindow()) {
1353
- editor.setActiveWindow(pickNextActiveSession(id));
1354
- }
1355
- const session = orchestratorSessions.get(id);
1356
- if (!session) {
1357
- clearInFlight();
1358
- return;
1359
- }
1360
-
1361
- // Resolve the repo root from cwd (the user is in the
1362
- // umbrella session's tree).
1363
- const cwd = editor.getCwd();
1364
- const top = await spawnCollect(
1365
- "git",
1366
- ["rev-parse", "--show-toplevel"],
1367
- cwd,
1368
- );
1369
- if (top.exit_code !== 0) {
1370
- editor.setStatus("Orchestrator: archive failed — not a git repository");
1371
- clearInFlight();
1372
- return;
1782
+ // Resolve the *main* repo root a session's worktree belongs to, so
1783
+ // `git worktree move/remove` runs from a stable directory (never from
1784
+ // inside the tree being moved/removed). Prefers the canonical
1785
+ // `projectPath` recorded at create/discovery time, falling back to
1786
+ // resolving from the worktree itself.
1787
+ async function worktreeRepoRoot(s: AgentSession): Promise<string | null> {
1788
+ if (s.projectPath) {
1789
+ const r = await resolveCanonicalRepoRoot(s.projectPath);
1790
+ if (r) return r;
1373
1791
  }
1374
- const repoRoot = (top.stdout || "").trim();
1792
+ return await resolveCanonicalRepoRoot(s.root);
1793
+ }
1375
1794
 
1376
- // SIGKILL the session's process group so the pty children
1377
- // release any locks on the worktree, then close the editor
1378
- // session. closeWindow already kills the pty via the child
1379
- // killer; signaling first via the window-level pg tracker
1380
- // catches stray subprocesses outside the pty.
1381
- editor.signalWindow(id, "SIGKILL");
1382
- editor.closeWindow(id);
1795
+ interface LifecycleResult {
1796
+ ok: boolean;
1797
+ err?: string;
1798
+ repoRoot?: string;
1799
+ }
1383
1800
 
1384
- // Brief settle so the filesystem reflects the pty's exit
1385
- // before we move the worktree out from under it.
1386
- await editor.delay(250);
1801
+ // Archive a single session: SIGKILL its processes (archive is a
1802
+ // "done with this for now" action no graceful teardown needed since
1803
+ // the worktree stays on disk), close the editor session, move the
1804
+ // worktree to the `.archived/` graveyard, and append a manifest
1805
+ // entry so Unarchive can reverse it. Handles both live sessions and
1806
+ // discovered on-disk worktrees (the latter have no window to close).
1807
+ // Does NOT trigger sync — the caller batches one sync per repo after
1808
+ // the whole run.
1809
+ async function archiveOne(id: number): Promise<LifecycleResult> {
1810
+ const s = orchestratorSessions.get(id);
1811
+ if (!s) return { ok: false, err: "session gone" };
1812
+ if (id === 1) return { ok: false, err: "cannot archive the base session" };
1813
+ const repoRoot = await worktreeRepoRoot(s);
1814
+ if (!repoRoot) return { ok: false, err: "not a git repository" };
1815
+
1816
+ // Live session: close_window refuses to close the active window, so
1817
+ // switch away first, then SIGKILL the process group (so pty
1818
+ // children release worktree locks) and close the editor session.
1819
+ if (!s.discovered && id > 0) {
1820
+ if (id === editor.activeWindow()) {
1821
+ editor.setActiveWindow(pickNextActiveSession(id));
1822
+ }
1823
+ editor.signalWindow(id, "SIGKILL");
1824
+ editor.closeWindow(id);
1825
+ // Brief settle so the filesystem reflects the pty's exit before
1826
+ // we move the worktree out from under it.
1827
+ await editor.delay(250);
1828
+ }
1387
1829
 
1388
- // git worktree move keeps git's internal bookkeeping
1389
- // consistent (the new path stays registered as a worktree).
1390
1830
  const archivedRoot = editor.pathJoin(
1391
1831
  editor.getDataDir(),
1392
1832
  "orchestrator",
1393
1833
  slugify(repoRoot),
1394
1834
  ".archived",
1395
- session.label,
1835
+ s.label,
1396
1836
  );
1397
1837
  const parent = editor.pathDirname(archivedRoot);
1398
1838
  if (!editor.createDir(parent)) {
1399
- editor.setStatus(
1400
- `Orchestrator: archive failed — could not create ${parent}`,
1401
- );
1402
- clearInFlight();
1403
- return;
1839
+ return { ok: false, err: `could not create ${parent}`, repoRoot };
1404
1840
  }
1841
+ // git worktree move keeps git's internal bookkeeping consistent
1842
+ // (the new path stays registered as a worktree).
1405
1843
  const moveRes = await spawnCollect(
1406
1844
  "git",
1407
- ["-C", repoRoot, "worktree", "move", session.root, archivedRoot],
1845
+ ["-C", repoRoot, "worktree", "move", s.root, archivedRoot],
1408
1846
  repoRoot,
1409
1847
  );
1410
1848
  if (moveRes.exit_code !== 0) {
1411
- editor.setStatus(
1412
- `Orchestrator: worktree move failed: ${
1413
- lastNonEmptyLine(moveRes.stderr) || "unknown error"
1414
- }`,
1415
- );
1416
- clearInFlight();
1417
- return;
1849
+ return {
1850
+ ok: false,
1851
+ err: lastNonEmptyLine(moveRes.stderr) || "worktree move failed",
1852
+ repoRoot,
1853
+ };
1418
1854
  }
1419
1855
 
1420
- // Append manifest entry. The branch info is best-effort:
1421
- // we assume Orchestrator's convention of branch==label (set in
1422
- // the new-session form) until a session knows its branch
1423
- // separately.
1424
1856
  const manifest = loadArchiveManifest(repoRoot);
1425
1857
  manifest.sessions.push({
1426
- label: session.label,
1858
+ label: s.label,
1427
1859
  root: archivedRoot,
1428
- original_root: session.root,
1429
- branch: session.label,
1860
+ original_root: s.root,
1861
+ branch: s.branch || s.label,
1430
1862
  archived_at: new Date().toISOString(),
1431
1863
  });
1432
- if (!saveArchiveManifest(repoRoot, manifest)) {
1433
- editor.setStatus(
1434
- "Orchestrator: archived, but failed to write archived.json",
1435
- );
1436
- } else {
1437
- editor.setStatus(`Orchestrator: archived [${id}] ${session.label}`);
1864
+ saveArchiveManifest(repoRoot, manifest);
1865
+
1866
+ // A discovered row has no window_closed hook to drop it — remove it
1867
+ // from the model directly.
1868
+ if (s.discovered) {
1869
+ orchestratorSessions.delete(id);
1870
+ discoveredIdByPath.delete(s.root);
1438
1871
  }
1439
- clearInFlight();
1440
- triggerSyncAsync(repoRoot);
1872
+ return { ok: true, repoRoot };
1441
1873
  }
1442
1874
 
1443
1875
  // ---------------------------------------------------------------------
@@ -1640,86 +2072,135 @@ async function buildSyncSnapshot(repoRoot: string): Promise<unknown> {
1640
2072
  };
1641
2073
  }
1642
2074
 
1643
- // Delete flow: stop processes (SIGKILL), close the editor
1644
- // session, then `git worktree remove --force` to drop the
1645
- // worktree from disk. If the session was archived (manifest
1646
- // entry exists), the manifest entry is dropped too. No
1647
- // recovery after this point.
1648
- async function deleteConfirmedSession(): Promise<void> {
1649
- if (!openDialog || !openDialog.pendingConfirm) return;
1650
- const { sessionId: id } = openDialog.pendingConfirm;
1651
- openDialog.pendingConfirm = null;
1652
- // Clear the in-flight marker on early failure. Mirrors the
1653
- // pattern in `archiveSelectedSession` — the confirm-delete
1654
- // handler set `inFlight` before kicking off this async work,
1655
- // and any path that aborts before `closeWindow` needs to undo
1656
- // it so the "Deleting…" overlay disappears.
1657
- const clearInFlight = () => {
1658
- if (openDialog?.inFlight && openDialog.inFlight.sessionId === id) {
1659
- openDialog.inFlight = null;
1660
- refreshOpenDialog();
2075
+ // Delete a single session: stop processes (SIGKILL), close the
2076
+ // editor session, then `git worktree remove --force` to drop the
2077
+ // worktree from disk. If the session was archived (manifest entry
2078
+ // exists), the manifest entry is dropped too. Handles discovered
2079
+ // on-disk worktrees (no window to close). No recovery after this
2080
+ // point. Does NOT trigger sync — the caller batches it.
2081
+ async function deleteOne(id: number): Promise<LifecycleResult> {
2082
+ const s = orchestratorSessions.get(id);
2083
+ if (!s) return { ok: false, err: "session gone" };
2084
+ if (id === 1) return { ok: false, err: "cannot delete the base session" };
2085
+ const repoRoot = await worktreeRepoRoot(s);
2086
+ if (!repoRoot) return { ok: false, err: "not a git repository" };
2087
+
2088
+ if (!s.discovered && id > 0) {
2089
+ // close_window refuses to close the active window, so swap away.
2090
+ if (id === editor.activeWindow()) {
2091
+ editor.setActiveWindow(pickNextActiveSession(id));
1661
2092
  }
1662
- };
1663
- const session = orchestratorSessions.get(id);
1664
- if (!session) {
1665
- clearInFlight();
1666
- return;
1667
- }
1668
- // Same auto-switch as archive — close_window refuses to close
1669
- // the active window, so swap to a different session first.
1670
- if (id === editor.activeWindow()) {
1671
- editor.setActiveWindow(pickNextActiveSession(id));
1672
- }
1673
-
1674
- const cwd = editor.getCwd();
1675
- const top = await spawnCollect(
1676
- "git",
1677
- ["rev-parse", "--show-toplevel"],
1678
- cwd,
1679
- );
1680
- if (top.exit_code !== 0) {
1681
- editor.setStatus("Orchestrator: delete failed — not a git repository");
1682
- clearInFlight();
1683
- return;
2093
+ editor.signalWindow(id, "SIGKILL");
2094
+ editor.closeWindow(id);
2095
+ await editor.delay(250);
1684
2096
  }
1685
- const repoRoot = (top.stdout || "").trim();
1686
-
1687
- editor.signalWindow(id, "SIGKILL");
1688
- editor.closeWindow(id);
1689
- await editor.delay(250);
1690
2097
 
1691
- // `--force` because the worktree may have unstaged changes
1692
- // the user explicitly chose to discard via the confirm step.
2098
+ // `--force` because the worktree may have unstaged changes the user
2099
+ // explicitly chose to discard via the confirm step.
1693
2100
  const removeRes = await spawnCollect(
1694
2101
  "git",
1695
- ["-C", repoRoot, "worktree", "remove", "--force", session.root],
2102
+ ["-C", repoRoot, "worktree", "remove", "--force", s.root],
1696
2103
  repoRoot,
1697
2104
  );
1698
2105
  if (removeRes.exit_code !== 0) {
1699
- editor.setStatus(
1700
- `Orchestrator: worktree remove failed: ${
1701
- lastNonEmptyLine(removeRes.stderr) || "unknown error"
1702
- }`,
1703
- );
1704
- clearInFlight();
1705
- return;
2106
+ return {
2107
+ ok: false,
2108
+ err: lastNonEmptyLine(removeRes.stderr) || "worktree remove failed",
2109
+ repoRoot,
2110
+ };
1706
2111
  }
1707
2112
 
1708
- // Drop the matching manifest entry too, in case the session
1709
- // was already archived (delete-from-archived is the natural
1710
- // way to drop dormant sessions).
2113
+ // Drop the matching manifest entry too, in case the session was
2114
+ // already archived (delete-from-archived is the natural way to drop
2115
+ // dormant sessions).
1711
2116
  const manifest = loadArchiveManifest(repoRoot);
1712
2117
  const before = manifest.sessions.length;
1713
- manifest.sessions = manifest.sessions.filter(
1714
- (e) => e.label !== session.label,
1715
- );
2118
+ manifest.sessions = manifest.sessions.filter((e) => e.label !== s.label);
1716
2119
  if (manifest.sessions.length !== before) {
1717
2120
  saveArchiveManifest(repoRoot, manifest);
1718
2121
  }
1719
2122
 
1720
- editor.setStatus(`Orchestrator: deleted [${id}] ${session.label}`);
1721
- clearInFlight();
1722
- triggerSyncAsync(repoRoot);
2123
+ if (s.discovered) {
2124
+ orchestratorSessions.delete(id);
2125
+ discoveredIdByPath.delete(s.root);
2126
+ }
2127
+ return { ok: true, repoRoot };
2128
+ }
2129
+
2130
+ // Unified runner for a confirmed Stop / Archive / Delete over one or
2131
+ // many ids. Re-filters to eligible targets at execution time (the
2132
+ // selection or single row may have gone stale between confirm and
2133
+ // run), drives the in-flight progress markers, runs the per-id cores
2134
+ // sequentially, prunes acted-on ids from the selection, and triggers
2135
+ // one sync per touched repo at the end.
2136
+ async function runConfirmedAction(
2137
+ action: BulkAction,
2138
+ ids: number[],
2139
+ ): Promise<void> {
2140
+ if (!openDialog) return;
2141
+ const targets = ids.filter((id) => bulkEligible(action, id));
2142
+ if (targets.length === 0) {
2143
+ setDialogError(`nothing eligible to ${action} in the selection`);
2144
+ refreshOpenDialog();
2145
+ return;
2146
+ }
2147
+
2148
+ if (action === "stop") {
2149
+ let n = 0;
2150
+ for (const id of targets) if (stopOne(id)) n += 1;
2151
+ editor.setStatus(`Orchestrator: stop signal sent to ${n} session(s)`);
2152
+ // Stop leaves sessions in place; drop them from the selection so
2153
+ // the bulk bar reflects that the action ran.
2154
+ for (const id of targets) openDialog.selectedIds.delete(id);
2155
+ refreshOpenDialog();
2156
+ return;
2157
+ }
2158
+
2159
+ const single = targets.length === 1;
2160
+ if (single) {
2161
+ openDialog.inFlight = { action, sessionId: targets[0] };
2162
+ } else {
2163
+ openDialog.bulkInFlight = { action, total: targets.length, done: 0 };
2164
+ }
2165
+ refreshOpenDialog();
2166
+
2167
+ const touchedRepos = new Set<string>();
2168
+ let okCount = 0;
2169
+ let lastErr = "";
2170
+ for (let i = 0; i < targets.length; i++) {
2171
+ const id = targets[i];
2172
+ const res = action === "archive" ? await archiveOne(id) : await deleteOne(id);
2173
+ if (res.ok) {
2174
+ okCount += 1;
2175
+ if (res.repoRoot) touchedRepos.add(res.repoRoot);
2176
+ } else {
2177
+ lastErr = res.err ?? "failed";
2178
+ }
2179
+ openDialog?.selectedIds.delete(id);
2180
+ if (openDialog?.bulkInFlight) openDialog.bulkInFlight.done = i + 1;
2181
+ refreshOpenDialog();
2182
+ }
2183
+ if (openDialog) {
2184
+ openDialog.inFlight = null;
2185
+ openDialog.bulkInFlight = null;
2186
+ }
2187
+
2188
+ const verb = action === "archive" ? "archived" : "deleted";
2189
+ if (okCount === 0) {
2190
+ setDialogError(`${action} failed: ${lastErr || "unknown error"}`);
2191
+ } else if (lastErr) {
2192
+ setDialogError(`${verb} ${okCount}/${targets.length}; last error: ${lastErr}`);
2193
+ } else {
2194
+ editor.setStatus(`Orchestrator: ${verb} ${okCount} session(s)`);
2195
+ }
2196
+ for (const repo of touchedRepos) triggerSyncAsync(repo);
2197
+ refreshOpenDialog();
2198
+ // The batch emptied the selection, so the pane is back in
2199
+ // single-preview mode — restore focus to Visit (the bulk buttons
2200
+ // it may have been on are gone).
2201
+ if (openPanel && selectedSessions().length < 2 && !openDialog.pendingConfirm) {
2202
+ openPanel.setFocusKey("visit");
2203
+ }
1723
2204
  }
1724
2205
 
1725
2206
  // `Alt+N` from inside the picker opens the new-session form — saves
@@ -1742,6 +2223,20 @@ editor.defineMode(
1742
2223
  // text; session names don't contain `/`, so that's an
1743
2224
  // acceptable trade for the quick-focus.)
1744
2225
  ["/", "orchestrator_focus_filter"],
2226
+ // Space toggles the highlighted row's membership in the bulk
2227
+ // selection. Bound as a mode chord (not a widget smart-key) so
2228
+ // it's user-rebindable in the keybinding editor and fires
2229
+ // regardless of which control holds focus — the host's
2230
+ // `dispatch_floating_widget_key` defers any explicitly-bound
2231
+ // mode key, including bare chars, before the text-input path.
2232
+ // The trade (same as `/`) is that Space can't be typed into the
2233
+ // filter while the picker is open; session names don't contain
2234
+ // spaces, so that's acceptable.
2235
+ ["Space", "orchestrator_toggle_select"],
2236
+ // Alt+T toggles "Show all worktrees" — the opt-in filter that
2237
+ // surfaces discovered on-disk worktree rows. Rebindable, same as
2238
+ // the scope toggle.
2239
+ ["M-t", "orchestrator_toggle_worktrees"],
1745
2240
  ],
1746
2241
  true,
1747
2242
  true,
@@ -1758,6 +2253,38 @@ registerHandler("orchestrator_focus_filter", () => {
1758
2253
  openPanel.setFocusKey("filter");
1759
2254
  });
1760
2255
 
2256
+ // Space (rebindable): toggle the highlighted row in/out of the bulk
2257
+ // selection. Manages focus across the single↔bulk transition: when
2258
+ // the second row is checked the preview pane swaps to the bulk bar
2259
+ // (so the now-absent "visit" focus would otherwise be clamped to a
2260
+ // random tabbable), and when the selection drops back below two the
2261
+ // per-session preview — with its "visit" button — returns.
2262
+ registerHandler("orchestrator_toggle_select", () => {
2263
+ if (!openDialog || !openPanel) return;
2264
+ // Inert while a confirm prompt is up — the selection is frozen
2265
+ // behind the confirmation panel.
2266
+ if (openDialog.pendingConfirm) return;
2267
+ const id = openDialog.filteredIds[openDialog.selectedIndex];
2268
+ if (typeof id !== "number") return;
2269
+ const wasBulk = selectedSessions().length >= 2;
2270
+ if (openDialog.selectedIds.has(id)) {
2271
+ openDialog.selectedIds.delete(id);
2272
+ } else {
2273
+ openDialog.selectedIds.add(id);
2274
+ }
2275
+ clearDialogError();
2276
+ refreshOpenDialog();
2277
+ const isBulk = selectedSessions().length >= 2;
2278
+ if (!wasBulk && isBulk) {
2279
+ // Entering bulk mode — land focus on a bulk button (Up/Down from
2280
+ // a button still drives the list, so navigation keeps working).
2281
+ openPanel.setFocusKey("bulk-archive");
2282
+ } else if (wasBulk && !isBulk) {
2283
+ // Back to single preview — restore focus to Visit.
2284
+ openPanel.setFocusKey("visit");
2285
+ }
2286
+ });
2287
+
1761
2288
  function toggleScope(): void {
1762
2289
  if (!openDialog) return;
1763
2290
  openDialog.scope = openDialog.scope === "current" ? "all" : "current";
@@ -1776,6 +2303,31 @@ function toggleScope(): void {
1776
2303
 
1777
2304
  registerHandler("orchestrator_toggle_scope", toggleScope);
1778
2305
 
2306
+ // Flip "Show all worktrees" — reveal/hide the discovered on-disk
2307
+ // worktree rows. Preserves the highlighted row across the re-filter
2308
+ // where possible; drops now-hidden discovered rows from the bulk
2309
+ // selection. Shared by the Alt+T chord and the checkbox click.
2310
+ function toggleShowWorktrees(): void {
2311
+ if (!openDialog) return;
2312
+ openDialog.showWorktrees = !openDialog.showWorktrees;
2313
+ lastShowWorktrees = openDialog.showWorktrees;
2314
+ // Hiding worktrees shouldn't leave them lingering in the selection.
2315
+ if (!openDialog.showWorktrees) {
2316
+ for (const id of [...openDialog.selectedIds]) {
2317
+ if (orchestratorSessions.get(id)?.discovered) {
2318
+ openDialog.selectedIds.delete(id);
2319
+ }
2320
+ }
2321
+ }
2322
+ const prevId = openDialog.filteredIds[openDialog.selectedIndex];
2323
+ openDialog.filteredIds = filterSessions(openDialog.filter.value);
2324
+ const nextIdx = prevId !== undefined ? openDialog.filteredIds.indexOf(prevId) : -1;
2325
+ openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
2326
+ refreshOpenDialog();
2327
+ }
2328
+
2329
+ registerHandler("orchestrator_toggle_worktrees", toggleShowWorktrees);
2330
+
1779
2331
  // =============================================================================
1780
2332
  // New-session floating form
1781
2333
  // =============================================================================
@@ -2078,6 +2630,130 @@ async function pathIsInsideGitWorkTree(
2078
2630
  return (res.stdout || "").trim() === "true";
2079
2631
  }
2080
2632
 
2633
+ // =============================================================================
2634
+ // Worktree classification & discovery
2635
+ //
2636
+ // Two distinct git facts drive the "attach to an existing worktree"
2637
+ // flows:
2638
+ //
2639
+ // * `classifyWorktree(path)` answers "is this path a *linked*
2640
+ // worktree, and if so what repo does it belong to?" — used by
2641
+ // the new-session form to attach (rather than fork) when the
2642
+ // user points Project Path at an existing worktree.
2643
+ // * `listLinkedWorktrees(repoRoot)` enumerates every linked
2644
+ // worktree of a repo (via `git worktree list --porcelain`) —
2645
+ // used to surface on-disk worktrees in the Open dialog without
2646
+ // the user adding them by hand.
2647
+ // =============================================================================
2648
+
2649
+ interface WorktreeInfo {
2650
+ // `git rev-parse --show-toplevel` for the path.
2651
+ toplevel: string;
2652
+ // Canonical main-worktree root (dirname of `--git-common-dir`).
2653
+ // This is the repo the worktree belongs to, used as the
2654
+ // session's `projectPath` so attached worktrees group under
2655
+ // their repo in the picker.
2656
+ mainRoot: string;
2657
+ // `true` when the path is a *linked* worktree (its per-worktree
2658
+ // git dir differs from the shared common dir), i.e. a tree
2659
+ // created by `git worktree add` rather than the main checkout.
2660
+ isLinked: boolean;
2661
+ // Branch checked out there (`refs/heads/<name>` short form), or
2662
+ // empty when detached.
2663
+ branch: string;
2664
+ }
2665
+
2666
+ /// Classify `path` as a git worktree. Returns `null` when `path`
2667
+ /// is not inside any git work tree (the caller then treats it as a
2668
+ /// plain directory / shared root).
2669
+ async function classifyWorktree(path: string): Promise<WorktreeInfo | null> {
2670
+ if (!path) return null;
2671
+ const top = await spawnCollect("git", ["-C", path, "rev-parse", "--show-toplevel"], path);
2672
+ if (top.exit_code !== 0) return null;
2673
+ const toplevel = (top.stdout || "").trim();
2674
+ if (!toplevel) return null;
2675
+
2676
+ // The per-worktree git dir vs. the shared common dir: they are
2677
+ // equal for the main worktree and differ for every linked
2678
+ // worktree (`<common>/worktrees/<id>`). That difference is the
2679
+ // canonical "is this a linked worktree?" test.
2680
+ const [gitDir, commonDir] = await Promise.all([
2681
+ spawnCollect("git", ["-C", toplevel, "rev-parse", "--path-format=absolute", "--git-dir"], toplevel),
2682
+ spawnCollect(
2683
+ "git",
2684
+ ["-C", toplevel, "rev-parse", "--path-format=absolute", "--git-common-dir"],
2685
+ toplevel,
2686
+ ),
2687
+ ]);
2688
+ const gd = gitDir.exit_code === 0 ? (gitDir.stdout || "").trim() : "";
2689
+ const cd = commonDir.exit_code === 0 ? (commonDir.stdout || "").trim() : "";
2690
+ const isLinked = gd !== "" && cd !== "" && gd !== cd;
2691
+ const mainRoot = cd ? editor.pathDirname(cd) : toplevel;
2692
+
2693
+ const head = await spawnCollect(
2694
+ "git",
2695
+ ["-C", toplevel, "rev-parse", "--abbrev-ref", "HEAD"],
2696
+ toplevel,
2697
+ );
2698
+ let branch = head.exit_code === 0 ? (head.stdout || "").trim() : "";
2699
+ if (branch === "HEAD") branch = ""; // detached
2700
+
2701
+ return { toplevel, mainRoot, isLinked, branch };
2702
+ }
2703
+
2704
+ interface ParsedWorktree {
2705
+ path: string;
2706
+ branch: string;
2707
+ detached: boolean;
2708
+ }
2709
+
2710
+ /// Parse `git worktree list --porcelain` output. Blocks are
2711
+ /// separated by blank lines; the first block is the main worktree,
2712
+ /// the rest are linked. Each block has a `worktree <path>` line
2713
+ /// plus `branch refs/heads/<name>` or `detached`.
2714
+ function parseWorktreePorcelain(stdout: string): ParsedWorktree[] {
2715
+ const out: ParsedWorktree[] = [];
2716
+ let cur: ParsedWorktree | null = null;
2717
+ for (const raw of (stdout || "").split(/\r?\n/)) {
2718
+ const line = raw.trimEnd();
2719
+ if (line.startsWith("worktree ")) {
2720
+ if (cur) out.push(cur);
2721
+ cur = { path: line.slice("worktree ".length), branch: "", detached: false };
2722
+ } else if (cur && line.startsWith("branch ")) {
2723
+ const ref = line.slice("branch ".length);
2724
+ cur.branch = ref.replace(/^refs\/heads\//, "");
2725
+ } else if (cur && line === "detached") {
2726
+ cur.detached = true;
2727
+ } else if (line === "" && cur) {
2728
+ out.push(cur);
2729
+ cur = null;
2730
+ }
2731
+ }
2732
+ if (cur) out.push(cur);
2733
+ return out;
2734
+ }
2735
+
2736
+ /// Enumerate the *linked* worktrees of `repoRoot` (excludes the
2737
+ /// main worktree, which is the repo's own checkout). Returns the
2738
+ /// parsed entries with the main-repo root resolved so callers can
2739
+ /// tag discovered sessions with the right `projectPath`.
2740
+ async function listLinkedWorktrees(
2741
+ repoRoot: string,
2742
+ ): Promise<{ mainRoot: string; worktrees: ParsedWorktree[] } | null> {
2743
+ const res = await spawnCollect(
2744
+ "git",
2745
+ ["-C", repoRoot, "worktree", "list", "--porcelain"],
2746
+ repoRoot,
2747
+ );
2748
+ if (res.exit_code !== 0) return null;
2749
+ const all = parseWorktreePorcelain(res.stdout || "");
2750
+ if (all.length === 0) return null;
2751
+ // The first entry is always the main worktree.
2752
+ const mainRoot = all[0].path;
2753
+ const worktrees = all.slice(1);
2754
+ return { mainRoot, worktrees };
2755
+ }
2756
+
2081
2757
  async function nextAutoSessionName(
2082
2758
  repoRoot: string,
2083
2759
  options?: { persist?: boolean },
@@ -2164,9 +2840,11 @@ function buildFormSpec(): WidgetSpec {
2164
2840
  // inert when worktree creation is off.
2165
2841
  let branchPlaceholder: string;
2166
2842
  if (branchInert) {
2167
- branchPlaceholder = worktreeEnabled
2168
- ? "shared worktree — N/A"
2169
- : "no git — N/A";
2843
+ branchPlaceholder = !worktreeEnabled
2844
+ ? "no git — N/A"
2845
+ : form.projectPathIsLinkedWorktree === true
2846
+ ? "existing worktree — N/A"
2847
+ : "shared worktree — N/A";
2170
2848
  } else if (!form.defaultBranch) {
2171
2849
  branchPlaceholder = "detecting default branch…";
2172
2850
  } else if (form.defaultBranchIsHeadFallback) {
@@ -2241,6 +2919,24 @@ function buildFormSpec(): WidgetSpec {
2241
2919
  ]),
2242
2920
  ],
2243
2921
  },
2922
+ // Existing-worktree hint: when Project Path points at a linked
2923
+ // worktree, explain what the (un)checked box now means so the
2924
+ // attach behaviour isn't a silent surprise.
2925
+ ...(form.projectPathIsLinkedWorktree === true
2926
+ ? [{
2927
+ kind: "raw" as const,
2928
+ entries: [
2929
+ styledRow([
2930
+ {
2931
+ text: form.createWorktree
2932
+ ? " ↳ existing worktree here — uncheck to attach instead of forking a new one"
2933
+ : " ↳ existing worktree — this session will attach to it",
2934
+ style: { fg: "ui.help_key_fg", italic: true },
2935
+ },
2936
+ ]),
2937
+ ],
2938
+ }]
2939
+ : []),
2244
2940
  // === Form body: labeled, full-width inputs. ==================
2245
2941
  // Labels are plain — the `▸` glyph used to be baked into all
2246
2942
  // three strings and stayed put regardless of focus, which was
@@ -2380,6 +3076,7 @@ function openForm(options?: { fromPicker?: boolean }): void {
2380
3076
  lastError: null,
2381
3077
  defaultProjectPath: "",
2382
3078
  projectPathIsGit: null,
3079
+ projectPathIsLinkedWorktree: null,
2383
3080
  defaultSessionName: "",
2384
3081
  defaultBranch: "",
2385
3082
  defaultBranchIsHeadFallback: false,
@@ -2442,6 +3139,24 @@ async function probeProjectPathDefaults(): Promise<void> {
2442
3139
  if (!form || form.probeToken !== token) return;
2443
3140
  form.projectPathIsGit = isGit;
2444
3141
 
3142
+ // (2b) Existing-linked-worktree detection. When the path is a
3143
+ // worktree created by `git worktree add` (not the repo's main
3144
+ // checkout), default the checkbox to *unchecked* so the
3145
+ // natural action is to attach to it. Only flip on the
3146
+ // detection transition so we don't fight a user who
3147
+ // deliberately re-checks "create a new worktree".
3148
+ const wasLinked = form.projectPathIsLinkedWorktree;
3149
+ if (isGit) {
3150
+ const info = await classifyWorktree(effectivePath);
3151
+ if (!form || form.probeToken !== token) return;
3152
+ form.projectPathIsLinkedWorktree = info?.isLinked === true;
3153
+ } else {
3154
+ form.projectPathIsLinkedWorktree = false;
3155
+ }
3156
+ if (form.projectPathIsLinkedWorktree && wasLinked !== true) {
3157
+ form.createWorktree = false;
3158
+ }
3159
+
2445
3160
  // (3) Default branch + session name probes only make sense on
2446
3161
  // a git path. On non-git, leave both empty (the renderer
2447
3162
  // surfaces a "no git — N/A" branch placeholder, and the
@@ -2861,14 +3576,28 @@ async function submitForm(): Promise<void> {
2861
3576
  editor.setGlobalState("orchestrator.last_cmd", cmd);
2862
3577
  }
2863
3578
 
3579
+ // Attach-to-existing-worktree: when the user opted out of
3580
+ // creating a worktree but pointed Project Path at an *existing
3581
+ // linked worktree* (one created by `git worktree add`, possibly
3582
+ // for a repo Fresh has never opened before), treat it as the
3583
+ // dedicated worktree it is rather than a shared root. That means
3584
+ // `shared_worktree = false` (so Archive / Delete can
3585
+ // `git worktree move` / `remove` it) and a `project_path` of the
3586
+ // owning repo so the session groups with its siblings. A path
3587
+ // that's the repo's *main* worktree, or a non-git directory, stays
3588
+ // shared — you can't `git worktree remove` either of those.
3589
+ const attachInfo = !createWorktree ? await classifyWorktree(root) : null;
3590
+ if (!form) return;
3591
+ const isLinkedAttach = attachInfo?.isLinked === true;
3592
+ const effectiveProjectPath = isLinkedAttach ? attachInfo!.mainRoot : projectPath;
3593
+
2864
3594
  // Branch / cmd values used for the per-window state record —
2865
- // `branchName` only exists in the worktree-create flow above;
2866
- // for the shared-worktree / non-git case we report whatever's
2867
- // currently checked out (best-effort) so the new session record
2868
- // matches the situation on disk.
3595
+ // `branchName` only exists in the worktree-create flow above; for
3596
+ // an attached linked worktree we report its checked-out branch;
3597
+ // for the shared-worktree / non-git case we leave it blank.
2869
3598
  const reportedBranch = createWorktree
2870
3599
  ? (branchInput || sessionName)
2871
- : "";
3600
+ : (isLinkedAttach ? attachInfo!.branch : "");
2872
3601
 
2873
3602
  // Append the user-effective values to per-field input
2874
3603
  // history so ↑/↓ can recall them on the next form open.
@@ -2886,7 +3615,9 @@ async function submitForm(): Promise<void> {
2886
3615
  // terminal IS the new window's seed buffer, so the window is
2887
3616
  // born with a single tab.
2888
3617
  const argv = splitAgentCmd(cmd);
2889
- const sharedWorktree = !createWorktree;
3618
+ // Shared only when we neither created a worktree nor attached to an
3619
+ // existing linked one (i.e. a non-git dir or the repo's main tree).
3620
+ const sharedWorktree = !createWorktree && !isLinkedAttach;
2890
3621
  try {
2891
3622
  const result = await editor.createWindowWithTerminal({
2892
3623
  root,
@@ -2898,17 +3629,26 @@ async function submitForm(): Promise<void> {
2898
3629
  const id = result.windowId;
2899
3630
  // `createWindowWithTerminal` already dove into the new window,
2900
3631
  // so `setWindowState` writes to it.
2901
- editor.setWindowState("project_path", projectPath);
3632
+ editor.setWindowState("project_path", effectiveProjectPath);
2902
3633
  editor.setWindowState("shared_worktree", sharedWorktree);
3634
+ // If we attached to a worktree that was sitting in the picker as
3635
+ // a discovered row, drop that placeholder — this live window
3636
+ // supersedes it.
3637
+ const discId = discoveredIdByPath.get(root);
3638
+ if (discId !== undefined) {
3639
+ orchestratorSessions.delete(discId);
3640
+ discoveredIdByPath.delete(root);
3641
+ }
2903
3642
  const tracked: AgentSession = {
2904
3643
  id,
2905
3644
  label: sessionName,
2906
3645
  root,
2907
- projectPath,
3646
+ projectPath: effectiveProjectPath,
2908
3647
  sharedWorktree,
2909
3648
  terminalId: result.terminalId,
2910
3649
  state: "running",
2911
3650
  createdAt: Date.now(),
3651
+ branch: reportedBranch || undefined,
2912
3652
  };
2913
3653
  orchestratorSessions.set(id, tracked);
2914
3654
  } catch (e) {
@@ -2920,6 +3660,54 @@ async function submitForm(): Promise<void> {
2920
3660
  }
2921
3661
  }
2922
3662
 
3663
+ /// Open a session in an existing worktree without creating one —
3664
+ /// the dive action for a discovered row, and the building block the
3665
+ /// new-session form reuses when the user points Project Path at an
3666
+ /// existing linked worktree. Spawns a bare terminal (no agent
3667
+ /// command) rooted at the worktree, tags the window with its
3668
+ /// canonical project + `shared_worktree = false` so Archive / Delete
3669
+ /// manage it as the real worktree it is, then drops the discovered
3670
+ /// placeholder (the live window supersedes it).
3671
+ async function attachToWorktree(opts: {
3672
+ root: string;
3673
+ projectPath: string;
3674
+ label: string;
3675
+ branch?: string;
3676
+ discoveredId?: number;
3677
+ }): Promise<void> {
3678
+ try {
3679
+ const result = await editor.createWindowWithTerminal({
3680
+ root: opts.root,
3681
+ label: opts.label,
3682
+ cwd: opts.root,
3683
+ });
3684
+ const id = result.windowId;
3685
+ editor.setWindowState("project_path", opts.projectPath);
3686
+ editor.setWindowState("shared_worktree", false);
3687
+ if (opts.discoveredId !== undefined) {
3688
+ orchestratorSessions.delete(opts.discoveredId);
3689
+ discoveredIdByPath.delete(opts.root);
3690
+ }
3691
+ orchestratorSessions.set(id, {
3692
+ id,
3693
+ label: opts.label,
3694
+ root: opts.root,
3695
+ projectPath: opts.projectPath,
3696
+ sharedWorktree: false,
3697
+ terminalId: result.terminalId,
3698
+ state: "running",
3699
+ createdAt: Date.now(),
3700
+ branch: opts.branch,
3701
+ });
3702
+ } catch (e) {
3703
+ editor.setStatus(
3704
+ `Orchestrator: failed to attach session — ${
3705
+ e instanceof Error ? e.message : String(e)
3706
+ }`,
3707
+ );
3708
+ }
3709
+ }
3710
+
2923
3711
  function startNewSession(): void {
2924
3712
  if (form) return; // already open
2925
3713
  openForm();
@@ -3148,7 +3936,27 @@ function enterConfirm(action: "stop" | "archive" | "delete"): void {
3148
3936
  }
3149
3937
  }
3150
3938
  }
3151
- openDialog.pendingConfirm = { action, sessionId: id };
3939
+ openDialog.pendingConfirm = { action, ids: [id] };
3940
+ openPanel.update(buildOpenSpec());
3941
+ openPanel.setFocusKey("confirm-cancel");
3942
+ }
3943
+
3944
+ // Open the confirm panel for a *bulk* action over the current
3945
+ // checkbox selection. Filters to the eligible members up front (so
3946
+ // the confirm count matches what will actually run); refuses with a
3947
+ // banner when nothing is eligible.
3948
+ function enterBulkConfirm(action: BulkAction): void {
3949
+ if (!openDialog || !openPanel) return;
3950
+ const targets = eligibleSelected(action);
3951
+ if (targets.length === 0) {
3952
+ setDialogError(`no selected session can be ${action === "stop" ? "stopped" : action + "d"}`);
3953
+ refreshOpenDialog();
3954
+ return;
3955
+ }
3956
+ // All three actions confirm — even Stop, so a bulk Stop over a
3957
+ // large selection isn't a single mis-key away. The confirm panel
3958
+ // lists the targets and shows the eligible count.
3959
+ openDialog.pendingConfirm = { action, ids: targets };
3152
3960
  openPanel.update(buildOpenSpec());
3153
3961
  openPanel.setFocusKey("confirm-cancel");
3154
3962
  }
@@ -3307,8 +4115,11 @@ editor.on("widget_event", (e) => {
3307
4115
  // on the button. Snap focus back to Visit so the user can
3308
4116
  // press Enter to open the newly-highlighted session — the
3309
4117
  // dialog's whole reason for being. Idempotent when focus
3310
- // is already on Visit.
3311
- openPanel.setFocusKey("visit");
4118
+ // is already on Visit. Skipped in bulk mode and during a
4119
+ // confirm, where "visit" isn't in the spec.
4120
+ if (selectedSessions().length < 2 && !openDialog.pendingConfirm) {
4121
+ openPanel.setFocusKey("visit");
4122
+ }
3312
4123
  }
3313
4124
  return;
3314
4125
  }
@@ -3317,6 +4128,20 @@ editor.on("widget_event", (e) => {
3317
4128
  (e.widget_key === "sessions" || e.widget_key === "visit")
3318
4129
  ) {
3319
4130
  const id = openDialog.filteredIds[openDialog.selectedIndex];
4131
+ const sel = typeof id === "number" ? orchestratorSessions.get(id) : undefined;
4132
+ if (sel && sel.discovered) {
4133
+ // Discovered worktree: there's no window to switch to —
4134
+ // open one by attaching a fresh session to the worktree.
4135
+ closeOpenDialog();
4136
+ void attachToWorktree({
4137
+ root: sel.root,
4138
+ projectPath: sel.projectPath ?? sel.root,
4139
+ label: sel.label,
4140
+ branch: sel.branch,
4141
+ discoveredId: sel.id,
4142
+ });
4143
+ return;
4144
+ }
3320
4145
  if (typeof id === "number" && id > 0 && id !== editor.activeWindow()) {
3321
4146
  editor.setActiveWindow(id);
3322
4147
  }
@@ -3337,6 +4162,12 @@ editor.on("widget_event", (e) => {
3337
4162
  refreshOpenDialog();
3338
4163
  return;
3339
4164
  }
4165
+ if (e.event_type === "toggle" && e.widget_key === "worktree-show") {
4166
+ // The toggle widget reports the new checked state; route through
4167
+ // the shared flip so the Alt+T chord and the click stay in sync.
4168
+ toggleShowWorktrees();
4169
+ return;
4170
+ }
3340
4171
  if (e.event_type === "activate" && e.widget_key === "stop") {
3341
4172
  enterConfirm("stop");
3342
4173
  return;
@@ -3349,44 +4180,47 @@ editor.on("widget_event", (e) => {
3349
4180
  enterConfirm("delete");
3350
4181
  return;
3351
4182
  }
3352
- if (e.event_type === "activate" && e.widget_key === "confirm-cancel") {
3353
- openDialog.pendingConfirm = null;
3354
- openPanel.update(buildOpenSpec());
4183
+ // Bulk action bar (Layout B) Stop / Archive / Delete over the
4184
+ // checkbox selection, plus Clear.
4185
+ if (e.event_type === "activate" && e.widget_key === "bulk-stop") {
4186
+ enterBulkConfirm("stop");
3355
4187
  return;
3356
4188
  }
3357
- if (e.event_type === "activate" && e.widget_key === "confirm-stop") {
3358
- openDialog.pendingConfirm = null;
3359
- stopSelectedSession();
3360
- if (openPanel) openPanel.update(buildOpenSpec());
4189
+ if (e.event_type === "activate" && e.widget_key === "bulk-archive") {
4190
+ enterBulkConfirm("archive");
3361
4191
  return;
3362
4192
  }
3363
- if (e.event_type === "activate" && e.widget_key === "confirm-archive") {
3364
- const id = openDialog.filteredIds[openDialog.selectedIndex];
3365
- openDialog.pendingConfirm = null;
3366
- // Mark the session in-flight so the preview swaps to
3367
- // "Archiving…" and its action buttons disappear until git
3368
- // finishes. The row stays in the list — `editor.listWindows()`
3369
- // is still the source of truth and will drop it on
3370
- // `closeWindow`, which is intentional: a slightly-laggy real
3371
- // state beats a synchronously faked one that can desync from
3372
- // git reality (e.g. when `git worktree move` fails).
3373
- if (typeof id === "number" && id > 0) {
3374
- openDialog.inFlight = { action: "archive", sessionId: id };
3375
- }
3376
- void archiveSelectedSession(id);
4193
+ if (e.event_type === "activate" && e.widget_key === "bulk-delete") {
4194
+ enterBulkConfirm("delete");
4195
+ return;
4196
+ }
4197
+ if (e.event_type === "activate" && e.widget_key === "bulk-clear") {
4198
+ openDialog.selectedIds.clear();
3377
4199
  refreshOpenDialog();
4200
+ openPanel.setFocusKey("visit");
4201
+ return;
4202
+ }
4203
+ if (e.event_type === "activate" && e.widget_key === "confirm-cancel") {
4204
+ openDialog.pendingConfirm = null;
4205
+ openPanel.update(buildOpenSpec());
3378
4206
  return;
3379
4207
  }
3380
- if (e.event_type === "activate" && e.widget_key === "confirm-delete") {
3381
- const id = openDialog.pendingConfirm?.sessionId;
3382
- // Mark in-flight — see comment on confirm-archive above.
3383
- // `deleteConfirmedSession` clears `pendingConfirm` itself, so
3384
- // we capture the id here before it goes away.
3385
- if (typeof id === "number" && id > 0) {
3386
- openDialog.inFlight = { action: "delete", sessionId: id };
4208
+ // Confirmed Stop / Archive / Delete single row or bulk batch.
4209
+ // The ids were captured into `pendingConfirm` by enterConfirm /
4210
+ // enterBulkConfirm; `runConfirmedAction` re-checks eligibility,
4211
+ // drives the in-flight markers, and triggers sync.
4212
+ if (
4213
+ e.event_type === "activate" &&
4214
+ (e.widget_key === "confirm-stop" ||
4215
+ e.widget_key === "confirm-archive" ||
4216
+ e.widget_key === "confirm-delete")
4217
+ ) {
4218
+ const confirm = openDialog.pendingConfirm;
4219
+ openDialog.pendingConfirm = null;
4220
+ if (confirm) {
4221
+ void runConfirmedAction(confirm.action, confirm.ids);
3387
4222
  }
3388
- void deleteConfirmedSession();
3389
- refreshOpenDialog();
4223
+ if (openPanel) openPanel.update(buildOpenSpec());
3390
4224
  return;
3391
4225
  }
3392
4226
  if (e.event_type === "cancel") {