@fresh-editor/fresh-editor 0.3.8 → 0.3.10

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,42 @@ 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
+ // `true` hides "trivial" sessions — those with no terminal and at
273
+ // most one open file/buffer (empty-unnamed-buffer and single-file
274
+ // shells left behind by one-off editor launches). The "Show
275
+ // empty/1-file sessions" checkbox (Alt+I / `orchestrator_toggle_trivial`)
276
+ // flips it. Defaults to true; remembered across opens via
277
+ // `lastHideTrivial`. The active session and discovered worktree rows
278
+ // are never hidden by this filter regardless of the flag.
279
+ hideTrivial: boolean;
280
+ // Progress marker for an in-flight *bulk* action. While set, the
281
+ // selection bar shows "Archiving 2/3…" and its buttons are
282
+ // hidden so a second Enter can't re-fire mid-batch. Cleared when
283
+ // the batch finishes.
284
+ bulkInFlight:
285
+ | { action: "stop" | "archive" | "delete"; total: number; done: number }
215
286
  | null;
216
287
  // Rows the embed reserves and rows the sessions list shows.
217
288
  // Captured once at dialog-open from the editor's viewport so
@@ -265,6 +336,92 @@ let openPanel: FloatingWidgetPanel | null = null;
265
336
  // showing every session; flipping it with the Project control / Alt+P
266
337
  // updates this and the next open honours it.
267
338
  let lastOpenScope: "current" | "all" = "all";
339
+ // Remembered across opens, like `lastOpenScope`: whether the
340
+ // discovered on-disk worktree rows are shown. Defaults to false
341
+ // (worktrees hidden) — surfacing them is opt-in via "Show all
342
+ // worktrees" (Alt+T).
343
+ let lastShowWorktrees = false;
344
+ // Remembered across opens: whether "trivial" sessions are hidden.
345
+ // Defaults to true — every editor launch on a throwaway directory or a
346
+ // single file leaves a workspace file behind, which restores as a shell
347
+ // window and clutters the list. Hiding them by default keeps the picker
348
+ // focused on real sessions; the "Show empty/1-file sessions" checkbox
349
+ // (Alt+I) reveals them.
350
+ let lastHideTrivial = true;
351
+
352
+ // Per-session content summary keyed by canonical session root, built
353
+ // from the on-disk workspace files. The restored shell windows don't
354
+ // carry their open-tab layout (it's lazily re-warmed on first dive), so
355
+ // the workspace file is the only place to learn how much a session
356
+ // holds. Rebuilt each time the picker opens. A session is "trivial"
357
+ // when it has no terminal and at most one real file/unnamed buffer —
358
+ // the empty-unnamed-buffer and single-file cases the filter targets.
359
+ interface SessionContent {
360
+ files: number;
361
+ hasTerminal: boolean;
362
+ trivial: boolean;
363
+ }
364
+ const sessionContentByRoot = new Map<string, SessionContent>();
365
+
366
+ // Roots from the editor (`WindowInfo.root`) and from workspace files
367
+ // (`working_dir`) are both canonical absolute paths, but normalise a
368
+ // trailing slash so the two always key the same map entry.
369
+ function normRoot(p: string): string {
370
+ return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
371
+ }
372
+
373
+ // Scan `<dataDir>/workspaces/*.json` and summarise each session's open
374
+ // content. Mirrors the host's own `discover_sessions` (which keys on the
375
+ // file's `working_dir`), so a root matches regardless of how the
376
+ // filename was percent-encoded. Best-effort: unreadable / unparseable
377
+ // files are skipped, and a missing summary is treated as "not trivial"
378
+ // (shown) by the filter, so we never hide a session we couldn't classify.
379
+ function scanSessionContent(): void {
380
+ sessionContentByRoot.clear();
381
+ const dir = editor.pathJoin(editor.getDataDir(), "workspaces");
382
+ let entries: DirEntry[];
383
+ try {
384
+ entries = editor.readDir(dir);
385
+ } catch {
386
+ return;
387
+ }
388
+ if (!entries) return;
389
+ for (const e of entries) {
390
+ if (!e.is_file || !e.name.endsWith(".json")) continue;
391
+ const raw = editor.readFile(editor.pathJoin(dir, e.name));
392
+ if (!raw) continue;
393
+ let ws: Record<string, unknown>;
394
+ try {
395
+ ws = JSON.parse(raw);
396
+ } catch {
397
+ continue;
398
+ }
399
+ const wd = ws["working_dir"];
400
+ if (typeof wd !== "string") continue;
401
+ let files = 0;
402
+ let hasTerminal = Array.isArray(ws["terminals"]) &&
403
+ (ws["terminals"] as unknown[]).length > 0;
404
+ const splits = ws["split_states"];
405
+ if (splits && typeof splits === "object") {
406
+ for (const sv of Object.values(splits as Record<string, unknown>)) {
407
+ const tabs = (sv as Record<string, unknown> | null)?.["open_tabs"];
408
+ if (!Array.isArray(tabs)) continue;
409
+ for (const t of tabs) {
410
+ if (t && typeof t === "object") {
411
+ if ("File" in t || "Unnamed" in t) files++;
412
+ else if ("Terminal" in t) hasTerminal = true;
413
+ }
414
+ }
415
+ }
416
+ }
417
+ sessionContentByRoot.set(normRoot(wd), {
418
+ files,
419
+ hasTerminal,
420
+ trivial: !hasTerminal && files <= 1,
421
+ });
422
+ }
423
+ }
424
+
268
425
  const OPEN_MODE = "orchestrator-open";
269
426
 
270
427
  // =============================================================================
@@ -298,11 +455,122 @@ function reconcileSessions(): void {
298
455
  if (s.shared_worktree != null) existing.sharedWorktree = s.shared_worktree;
299
456
  }
300
457
  }
458
+ // Live windows live in the positive id space; their absence from
459
+ // `listWindows()` means they were closed, so drop them. Discovered
460
+ // worktrees (negative ids) are NOT backed by a window and must
461
+ // survive this sweep — they're pruned separately, against the
462
+ // on-disk worktree set, by `refreshDiscoveredWorktrees`.
301
463
  for (const id of orchestratorSessions.keys()) {
302
- if (!seen.has(id)) orchestratorSessions.delete(id);
464
+ if (id > 0 && !seen.has(id)) orchestratorSessions.delete(id);
465
+ }
466
+ // A worktree that's now open as a live window must not also linger
467
+ // as a discovered row. Drop any discovered entry whose root a live
468
+ // session already occupies.
469
+ const liveRoots = new Set<string>();
470
+ for (const s of orchestratorSessions.values()) {
471
+ if (!s.discovered) liveRoots.add(s.root);
472
+ }
473
+ for (const [id, s] of orchestratorSessions) {
474
+ if (s.discovered && liveRoots.has(s.root)) orchestratorSessions.delete(id);
303
475
  }
304
476
  }
305
477
 
478
+ // =============================================================================
479
+ // Discovered-worktree scan
480
+ //
481
+ // Surfaces worktrees that exist on disk but have no live editor
482
+ // window, so the user doesn't have to add them by hand. Because
483
+ // open sessions can span several repos, `git worktree list` must
484
+ // run once *per project*: the scan set is the distinct canonical
485
+ // repo roots of every live session, plus the editor's cwd repo.
486
+ // Each linked worktree not already open (and not an
487
+ // orchestrator-internal tree) becomes a discovered row that dives
488
+ // by attaching a fresh session to it.
489
+ // =============================================================================
490
+
491
+ let discoveryInFlight = false;
492
+
493
+ function isInternalWorktreePath(path: string): boolean {
494
+ // The sync-workspace and the `.archived/` graveyard are
495
+ // orchestrator bookkeeping, not user sessions.
496
+ return path.includes(".sync-workspace") || path.includes("/.archived/");
497
+ }
498
+
499
+ async function refreshDiscoveredWorktrees(): Promise<void> {
500
+ if (discoveryInFlight) return;
501
+ discoveryInFlight = true;
502
+ try {
503
+ reconcileSessions();
504
+
505
+ // (1) Candidate dirs: every live session's root + the editor
506
+ // cwd. Resolve each to its canonical main repo root and
507
+ // dedupe so a repo with N open worktrees is scanned once.
508
+ const candidates = new Set<string>([editor.getCwd()]);
509
+ for (const s of orchestratorSessions.values()) {
510
+ if (!s.discovered) candidates.add(s.root);
511
+ }
512
+ const mainRoots = new Set<string>();
513
+ for (const dir of candidates) {
514
+ const canonical = await resolveCanonicalRepoRoot(dir);
515
+ if (canonical) mainRoots.add(canonical);
516
+ }
517
+
518
+ // (2) Roots already occupied by a live session — discovered rows
519
+ // for these would be duplicates.
520
+ const liveRoots = new Set<string>();
521
+ for (const s of orchestratorSessions.values()) {
522
+ if (!s.discovered) liveRoots.add(s.root);
523
+ }
524
+
525
+ // (3) Scan each repo and collect the linked worktrees worth
526
+ // surfacing.
527
+ const foundPaths = new Set<string>();
528
+ for (const repoRoot of mainRoots) {
529
+ const listed = await listLinkedWorktrees(repoRoot);
530
+ if (!listed) continue;
531
+ for (const wt of listed.worktrees) {
532
+ if (liveRoots.has(wt.path)) continue;
533
+ if (isInternalWorktreePath(wt.path)) continue;
534
+ foundPaths.add(wt.path);
535
+ const id = discoveredIdFor(wt.path);
536
+ const label = wt.branch || editor.pathBasename(wt.path);
537
+ const existing = orchestratorSessions.get(id);
538
+ if (existing) {
539
+ existing.label = label;
540
+ existing.root = wt.path;
541
+ existing.projectPath = listed.mainRoot;
542
+ existing.branch = wt.branch;
543
+ } else {
544
+ orchestratorSessions.set(id, {
545
+ id,
546
+ label,
547
+ root: wt.path,
548
+ projectPath: listed.mainRoot,
549
+ sharedWorktree: false,
550
+ terminalId: null,
551
+ state: "ready",
552
+ createdAt: Date.now(),
553
+ discovered: true,
554
+ branch: wt.branch,
555
+ });
556
+ }
557
+ }
558
+ }
559
+
560
+ // (4) Prune discovered rows that vanished from disk (or got
561
+ // opened, picked up by the liveRoots check above).
562
+ for (const [id, s] of orchestratorSessions) {
563
+ if (s.discovered && !foundPaths.has(s.root)) {
564
+ orchestratorSessions.delete(id);
565
+ discoveredIdByPath.delete(s.root);
566
+ }
567
+ }
568
+ } finally {
569
+ discoveryInFlight = false;
570
+ }
571
+ if (openPanel) refreshOpenDialog();
572
+ }
573
+
306
574
  // =============================================================================
307
575
  // Session display helpers
308
576
  // =============================================================================
@@ -381,12 +649,38 @@ function projectLabel(key: string): string {
381
649
  function filterSessions(needle: string): number[] {
382
650
  reconcileSessions();
383
651
  const scope = openDialog?.scope ?? "current";
652
+ const showWorktrees = openDialog?.showWorktrees ?? false;
653
+ const hideTrivial = openDialog?.hideTrivial ?? false;
384
654
  const cur = currentProjectKey();
385
- const allIds = Array.from(orchestratorSessions.keys());
655
+ let allIds = Array.from(orchestratorSessions.keys());
656
+ // "Show all worktrees" is opt-in: by default the discovered on-disk
657
+ // worktree rows are filtered out.
658
+ if (!showWorktrees) {
659
+ allIds = allIds.filter((id) => !orchestratorSessions.get(id)!.discovered);
660
+ }
661
+ // "Hide empty/1-file sessions": drop the restored shells that hold no
662
+ // real work. The active session is always kept (you must be able to
663
+ // see where you are), and discovered worktree rows are governed by
664
+ // their own toggle, not this one. A session with no summary (e.g. a
665
+ // freshly created agent session not yet written to disk) is kept too.
666
+ if (hideTrivial) {
667
+ const activeId = editor.activeWindow();
668
+ allIds = allIds.filter((id) => {
669
+ const s = orchestratorSessions.get(id)!;
670
+ if (s.discovered || id === activeId) return true;
671
+ const c = sessionContentByRoot.get(normRoot(s.root));
672
+ return !c || !c.trivial;
673
+ });
674
+ }
675
+
676
+ const isDisc = (id: number): number =>
677
+ orchestratorSessions.get(id)!.discovered ? 1 : 0;
386
678
 
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.
679
+ // Sort by (current-project-first, project, live-before-discovered,
680
+ // then id) so an "all" view groups the current project's sessions
681
+ // at the top and other projects' below, and within each project the
682
+ // pre-existing live sessions come first with the discovered on-disk
683
+ // worktrees listed after them.
390
684
  const byProjectThenId = (a: number, b: number): number => {
391
685
  const sa = orchestratorSessions.get(a)!;
392
686
  const sb = orchestratorSessions.get(b)!;
@@ -396,6 +690,9 @@ function filterSessions(needle: string): number[] {
396
690
  const ka = projectKeyOf(sa);
397
691
  const kb = projectKeyOf(sb);
398
692
  if (ka !== kb) return ka < kb ? -1 : 1;
693
+ const da = isDisc(a);
694
+ const db = isDisc(b);
695
+ if (da !== db) return da - db;
399
696
  return a - b;
400
697
  };
401
698
 
@@ -422,25 +719,33 @@ function filterSessions(needle: string): number[] {
422
719
  matches.push({ id, score: 2, len: label.length });
423
720
  }
424
721
  }
425
- matches.sort((a, b) => a.score - b.score || a.len - b.len || a.id - b.id);
722
+ // Live sessions before discovered worktrees at equal relevance, so
723
+ // the on-disk rows still trail the real sessions in search results.
724
+ matches.sort(
725
+ (a, b) =>
726
+ a.score - b.score || isDisc(a.id) - isDisc(b.id) || a.len - b.len ||
727
+ a.id - b.id,
728
+ );
426
729
  return matches.map((m) => m.id);
427
730
  }
428
731
 
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;
732
+ // Width of the NAME column before the trailing PROJECT column kicks
733
+ // in (filled only for cross-project rows). Kept in sync with
734
+ // `sessionsColumnHeader`. There is no id column the numeric window
735
+ // id is an internal handle the user never needs in the list; rows are
736
+ // identified by name, the active one rendered bold and on-disk
737
+ // worktrees flagged with a `· on-disk` tag.
738
+ const LIST_NAME_W = 24;
435
739
 
436
- // Header row above the session list: `ID NAME … PROJECT`.
740
+ // Header row above the session list: `NAME … PROJECT`.
437
741
  function sessionsColumnHeader(): WidgetSpec {
438
742
  return {
439
743
  kind: "raw",
440
744
  entries: [
441
745
  styledRow([
442
746
  {
443
- text: "ID".padEnd(LIST_ID_W) + "NAME".padEnd(LIST_NAME_W) + "PROJECT",
747
+ // 4-space lead aligns under the per-row `[ ] ` checkbox.
748
+ text: " " + "NAME".padEnd(LIST_NAME_W) + "PROJECT",
444
749
  style: { fg: "ui.menu_disabled_fg" },
445
750
  },
446
751
  ]),
@@ -448,39 +753,51 @@ function sessionsColumnHeader(): WidgetSpec {
448
753
  };
449
754
  }
450
755
 
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.
756
+ // Build one rendered list-item row for `id`:
757
+ // `[ ] ` <name + on-disk tag> <project basename>
758
+ // The active session's name renders bold; discovered (on-disk,
759
+ // unopened) worktrees render dim with a on-disk` tag instead of a
760
+ // glyph. The project column is filled only for sessions that don't
761
+ // belong to the current project.
456
762
  function renderListItem(id: number, activeId: number): TextPropertyEntry {
457
763
  const s = orchestratorSessions.get(id);
458
764
  if (!s) {
459
- return styledRow([{ text: `[${id}] (unknown)` }]);
765
+ return styledRow([{ text: "(unknown)" }]);
460
766
  }
461
767
  const isActive = id === activeId;
462
- const isBase = id === 1;
768
+ const isDiscovered = !!s.discovered;
769
+ const isChecked = openDialog?.selectedIds.has(id) ?? false;
770
+
771
+ // Leading multi-select checkbox. `[x]` when this row is in the
772
+ // bulk selection, `[ ]` otherwise — toggled with Space (the
773
+ // rebindable `orchestrator_toggle_select`) or a click.
774
+ const checkbox = {
775
+ text: isChecked ? "[x] " : "[ ] ",
776
+ style: isChecked
777
+ ? { fg: "ui.help_key_fg", bold: true }
778
+ : { fg: "ui.menu_disabled_fg" },
779
+ };
463
780
 
464
- const idText = `[${id}]`.padEnd(LIST_ID_W);
465
781
  const entries: { text: string; style?: Record<string, unknown> }[] = [
782
+ checkbox,
466
783
  {
467
- text: idText,
784
+ text: s.label,
468
785
  style: isActive
469
- ? { fg: "ui.tab_active_fg", bold: true }
470
- : { fg: "ui.help_key_fg" },
786
+ ? { fg: "ui.help_key_fg", bold: true }
787
+ : isDiscovered
788
+ ? { fg: "ui.menu_disabled_fg" }
789
+ : undefined,
471
790
  },
472
- { text: s.label, style: isActive ? { bold: true } : undefined },
473
791
  ];
474
- // Visible width of the NAME column so far (label + badges), used
792
+ // Visible width of the NAME column so far (label + tags), used
475
793
  // to pad out to LIST_NAME_W before the PROJECT column.
476
794
  let nameWidth = s.label.length;
477
- if (isBase) {
478
- entries.push({ text: " BASE", style: { fg: "ui.help_key_fg", bold: true } });
479
- nameWidth += 5;
480
- }
481
- if (s.sharedWorktree || countSiblingsAtRoot(s.root) > 1) {
482
- entries.push({ text: " ⇄", style: { fg: "ui.menu_disabled_fg" } });
483
- nameWidth += 2;
795
+ if (isDiscovered) {
796
+ entries.push({
797
+ text: " · on-disk",
798
+ style: { fg: "ui.menu_disabled_fg", italic: true },
799
+ });
800
+ nameWidth += 10;
484
801
  }
485
802
  // PROJECT column: basename for cross-project rows only; current-
486
803
  // project rows leave it blank (the whole list is one project when
@@ -515,57 +832,24 @@ function buildPreviewEntries(
515
832
  }
516
833
  const activeId = editor.activeWindow();
517
834
  const isActive = s.id === activeId;
518
- const isBase = s.id === 1;
519
835
  const stateText = isActive ? "ACT" : STATE_GLYPH[s.state].trim();
520
- // Count siblings sharing the same `root`. The set includes
521
- // `s` itself; `> 1` means at least one other session lives at
522
- // the same path (shared-worktree mode, or two sessions
523
- // explicitly aimed at the same directory).
524
- const sharedCount = countSiblingsAtRoot(s.root);
525
836
  const headerEntries: { text: string; style?: Record<string, unknown> }[] = [
526
837
  {
527
838
  text: stateText,
528
839
  style: isActive
529
- ? { fg: "ui.tab_active_fg", bold: true }
840
+ ? { fg: "ui.help_key_fg", bold: true }
530
841
  : { fg: "ui.menu_disabled_fg" },
531
842
  },
532
843
  { text: " " },
533
844
  { text: ageString(s.createdAt), style: { fg: "ui.menu_disabled_fg" } },
534
845
  ];
535
- if (isBase) {
536
- // BASE badge in the preview the long-form counterpart to
537
- // the list-row badge, with an inline explanation so the user
538
- // doesn't have to wonder why Stop / Archive / Delete are
539
- // greyed out.
540
- headerEntries.push(
541
- { text: " " },
542
- {
543
- text: "BASE",
544
- style: { fg: "ui.help_key_fg", bold: true },
545
- },
546
- { text: " — editor session", style: { fg: "ui.menu_disabled_fg", italic: true } },
547
- );
548
- }
549
- if (sharedCount > 1) {
846
+ if (!s.discovered && !ownsWorktree(s)) {
847
+ // In-place / launch session: runs inside a real checkout, owns no
848
+ // dedicated worktree. Surfaced so the user knows Archive doesn't
849
+ // apply (Delete just forgets it, leaving the directory untouched).
550
850
  headerEntries.push(
551
851
  { text: " " },
552
- {
553
- text: `SHARED ×${sharedCount}`,
554
- style: { fg: "ui.status_error_indicator_fg", bold: true },
555
- },
556
- );
557
- } else if (s.sharedWorktree) {
558
- // Single-session shared-worktree mode (the user opted out of
559
- // a dedicated worktree even though no second session is on
560
- // this root yet). Still worth surfacing so the user knows
561
- // why Archive / Delete refuse to run a `git worktree
562
- // remove` here.
563
- headerEntries.push(
564
- { text: " " },
565
- {
566
- text: "SHARED",
567
- style: { fg: "ui.menu_disabled_fg", italic: true },
568
- },
852
+ { text: "in-place", style: { fg: "ui.menu_disabled_fg", italic: true } },
569
853
  );
570
854
  }
571
855
  return [
@@ -576,16 +860,85 @@ function buildPreviewEntries(
576
860
  ];
577
861
  }
578
862
 
579
- /// Return the number of orchestrator sessions whose `root`
580
- /// equals `root`. Used to surface "SHARED ×N" in the preview
581
- /// pane and to refuse Archive / Delete on a shared root
582
- /// while another session still lives there.
583
- function countSiblingsAtRoot(root: string): number {
584
- let n = 0;
585
- for (const s of orchestratorSessions.values()) {
586
- if (s.root === root) n += 1;
863
+ // A session "owns" a removable git worktree when it was created as a
864
+ // dedicated `git worktree add` (project path set, not a shared/in-place
865
+ // root) or was discovered on disk via `git worktree list`. Only these
866
+ // have a worktree to `git worktree remove`/`move`. The launch session
867
+ // (the dir the editor was started in) and in-place sessions run inside
868
+ // a real checkout, so Archive (which moves the worktree) doesn't apply
869
+ // and Delete simply forgets the session without touching the directory.
870
+ function ownsWorktree(s: AgentSession): boolean {
871
+ return !!s.discovered || (!!s.projectPath && !s.sharedWorktree);
872
+ }
873
+
874
+ // =============================================================================
875
+ // Multi-select / bulk actions
876
+ //
877
+ // The user checkbox-selects rows (Space — the rebindable
878
+ // `orchestrator_toggle_select` — or a click). Once two or more rows
879
+ // are checked the preview pane swaps to the bulk selection bar
880
+ // (`buildBulkPane`) offering Stop / Archive / Delete over the whole
881
+ // set, with a single confirmation for the batch. Rows ineligible for
882
+ // a given action (the base session; live sessions sharing a worktree)
883
+ // are skipped, and each button's count reflects only the eligible
884
+ // members.
885
+ // =============================================================================
886
+
887
+ type BulkAction = "stop" | "archive" | "delete";
888
+
889
+ // Checked ids that still resolve to a known session, in the dialog's
890
+ // current display order (so the bulk bar lists them the way the list
891
+ // shows them). Selection persists across filter/scope changes, so an
892
+ // id can be checked while filtered out of view — those still count.
893
+ function selectedSessions(): number[] {
894
+ if (!openDialog) return [];
895
+ const order = openDialog.filteredIds;
896
+ const seen = new Set<number>();
897
+ const out: number[] = [];
898
+ for (const id of order) {
899
+ if (openDialog.selectedIds.has(id) && orchestratorSessions.has(id)) {
900
+ out.push(id);
901
+ seen.add(id);
902
+ }
903
+ }
904
+ // Checked-but-filtered-out rows, appended in id order so the count
905
+ // stays honest even when a search hides part of the selection.
906
+ for (const id of openDialog.selectedIds) {
907
+ if (!seen.has(id) && orchestratorSessions.has(id)) out.push(id);
908
+ }
909
+ return out;
910
+ }
911
+
912
+ // Is `id` a legal target for `action`? Base session is never
913
+ // touched. Stop only applies to live windows. Archive/Delete apply
914
+ // to discovered worktrees (removable on disk) and to live sessions
915
+ // that own their worktree outright (not shared with siblings or the
916
+ // project root).
917
+ function bulkEligible(action: BulkAction, id: number): boolean {
918
+ const s = orchestratorSessions.get(id);
919
+ if (!s) return false;
920
+ // Stop kills the agent process group — only meaningful for a live
921
+ // session that actually spawned one (never the launch session, which
922
+ // has no agent terminal, so signalling it can't touch the editor).
923
+ if (action === "stop") return !s.discovered && id > 0 && !!s.terminalId;
924
+ // Delete forgets any session. When it owns a worktree the worktree is
925
+ // removed too; otherwise (launch/in-place) it's just dropped.
926
+ if (action === "delete") return id > 0 || !!s.discovered;
927
+ // Archive moves the worktree to the graveyard, so it needs one.
928
+ return ownsWorktree(s);
929
+ }
930
+
931
+ function eligibleSelected(action: BulkAction): number[] {
932
+ return selectedSessions().filter((id) => bulkEligible(action, id));
933
+ }
934
+
935
+ // Drop checked ids whose session has vanished (closed window,
936
+ // pruned worktree) so the selection can't grow stale references.
937
+ function pruneSelection(): void {
938
+ if (!openDialog) return;
939
+ for (const id of [...openDialog.selectedIds]) {
940
+ if (!orchestratorSessions.has(id)) openDialog.selectedIds.delete(id);
587
941
  }
588
- return n;
589
942
  }
590
943
 
591
944
  // Blank-row separator used inside the Sessions column between
@@ -610,10 +963,10 @@ function maxListRowsForScreen(): number {
610
963
  const panelH = Math.floor(h * 0.9);
611
964
  // Chrome that isn't list rows: panel borders (2) + title (1) +
612
965
  // 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);
966
+ // column chrome above the list (New + Project + Worktree-filter +
967
+ // Trivial-filter + Filter + separator + header = 7) = 14. Floor at
968
+ // MIN_LIST_ROWS so a tiny terminal still shows something.
969
+ return Math.max(MIN_LIST_ROWS, panelH - 14);
617
970
  }
618
971
 
619
972
  // Compose the right-hand preview pane. Normally it shows info
@@ -658,130 +1011,28 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
658
1011
  ),
659
1012
  });
660
1013
  }
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
- }
1014
+ // Confirmation panel single-row Stop/Archive/Delete or a bulk
1015
+ // batch. Independent of the cursor row: the confirmed ids live in
1016
+ // `pendingConfirm`, so it renders whenever a confirm is pending.
1017
+ if (openDialog?.pendingConfirm) {
1018
+ return buildConfirmPane(openDialog.pendingConfirm);
1019
+ }
1020
+ // Bulk selection bar: two or more rows checked (or a bulk action
1021
+ // in flight) → operate on the whole batch rather than the cursor
1022
+ // row.
1023
+ if (selectedSessions().length >= 2 || openDialog?.bulkInFlight) {
1024
+ return buildBulkPane();
775
1025
  }
776
1026
  // Match the sessions column's content height so the two panes'
777
1027
  // 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;
1028
+ // borders = New (1) + Project (1) + Worktree-filter (1) +
1029
+ // Trivial-filter (1) + Filter (1) + separator (1) + header (1) +
1030
+ // list (listVisibleRows) = listVisibleRows + 7. Preview inside its
1031
+ // borders = button row (1) + spacer (1) + embedRows, so embedRows
1032
+ // must equal listVisibleRows + 5. When details ARE shown, two info
1033
+ // rows + a spacer eat three more lines — `_DETAILS_CHROME_ROWS`
1034
+ // accounts for that.
1035
+ const totalEmbedBase = (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) + 5;
785
1036
  const detailsOn = openDialog?.showDetails ?? false;
786
1037
  const _DETAILS_CHROME_ROWS = 3; // 2 info rows + 1 spacer
787
1038
  const embedRows = Math.max(
@@ -814,6 +1065,48 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
814
1065
  // turns details on, pressing `[ Preview ]` turns them off
815
1066
  // (back to compact).
816
1067
  const detailsToggleLabel = detailsOn ? "Preview" : "Details";
1068
+ // Discovered worktree: no live window to embed, so there's
1069
+ // nothing to Stop / Archive / Delete yet. Offer only "Open"
1070
+ // (Visit attaches a fresh session to the worktree) and describe
1071
+ // what diving will do. The empty `windowId: 0` embed keeps the
1072
+ // pane the same height as live-session previews so the dialog
1073
+ // doesn't jump when the selection moves between row kinds.
1074
+ if (s.discovered) {
1075
+ const openButtonRow = row(
1076
+ button("Open", { intent: "primary", key: "visit" }),
1077
+ flexSpacer(),
1078
+ button("Stop", { key: "stop", disabled: true }),
1079
+ spacer(2),
1080
+ button("Archive", { key: "archive", disabled: true }),
1081
+ spacer(2),
1082
+ button("Delete", { intent: "danger", key: "delete", disabled: true }),
1083
+ );
1084
+ const info: TextPropertyEntry[] = [
1085
+ styledRow([
1086
+ { text: "On-disk worktree (not open)", style: { fg: "ui.menu_disabled_fg", bold: true } },
1087
+ ]),
1088
+ styledRow([{ text: "" }]),
1089
+ styledRow([{ text: "branch ", style: { fg: "ui.menu_disabled_fg" } }, { text: s.branch || "(detached)" }]),
1090
+ styledRow([{ text: "path ", style: { fg: "ui.menu_disabled_fg" } }, { text: s.root }]),
1091
+ styledRow([{ text: "" }]),
1092
+ styledRow([
1093
+ {
1094
+ text: "Press Enter to open this worktree as a session.",
1095
+ style: { fg: "ui.help_key_fg", italic: true },
1096
+ },
1097
+ ]),
1098
+ ];
1099
+ return labeledSection({
1100
+ label: `${s.label} — on-disk worktree`,
1101
+ child: col(
1102
+ openButtonRow,
1103
+ spacer(0),
1104
+ { kind: "raw", entries: info },
1105
+ spacer(0),
1106
+ windowEmbed({ windowId: 0, rows: Math.max(3, embedRows - 6), key: "live-preview" }),
1107
+ ),
1108
+ });
1109
+ }
817
1110
  // Per-action availability. The row always renders all four
818
1111
  // buttons (no layout shift between selections), but each is
819
1112
  // marked disabled when its action would be refused against the
@@ -822,17 +1115,18 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
822
1115
  // same conditions that `stopSelectedSession`, `enterConfirm`,
823
1116
  // and the lifecycle handlers already check internally.
824
1117
  //
825
- // * Stop: refused on the base session (id 1).
826
- // * Archive / Delete: also refused on the base session, plus
827
- // when this session shares its worktree with the project
828
- // root (no `git worktree` entry to remove) or shares a root
829
- // with other live sessions (would yank disk out from
830
- // under them).
831
- const isBase = s.id === 1;
832
- const siblings = countSiblingsAtRoot(s.root);
833
- const sharesRoot = siblings > 1 || s.sharedWorktree;
834
- const stopDisabled = isBase;
835
- const lifecycleDisabled = isBase || sharesRoot;
1118
+ // * Stop: only a live session with an agent terminal can be
1119
+ // stopped (the launch session has none).
1120
+ // * Archive: needs an owned worktree to move to the graveyard.
1121
+ // * Delete: forgets the session, removing the worktree only when one
1122
+ // is owned (otherwise the directory is left untouched).
1123
+ // * Archive/Delete are both refused on the last live window — the
1124
+ // editor must always host at least one.
1125
+ const hasWorktree = ownsWorktree(s);
1126
+ const isLastWindow = s.id > 0 && liveWindowCount() <= 1;
1127
+ const stopDisabled = s.discovered || !s.terminalId;
1128
+ const archiveDisabled = !hasWorktree || isLastWindow;
1129
+ const deleteDisabled = isLastWindow;
836
1130
  const buttonRow = row(
837
1131
  button("Visit", { intent: "primary", key: "visit" }),
838
1132
  spacer(2),
@@ -841,12 +1135,12 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
841
1135
  spacer(2),
842
1136
  button("Stop", { key: "stop", disabled: stopDisabled }),
843
1137
  spacer(2),
844
- button("Archive", { key: "archive", disabled: lifecycleDisabled }),
1138
+ button("Archive", { key: "archive", disabled: archiveDisabled }),
845
1139
  spacer(2),
846
1140
  button("Delete", {
847
1141
  intent: "danger",
848
1142
  key: "delete",
849
- disabled: lifecycleDisabled,
1143
+ disabled: deleteDisabled,
850
1144
  }),
851
1145
  );
852
1146
  const embedWidget = windowEmbed({
@@ -863,20 +1157,225 @@ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
863
1157
  embedWidget,
864
1158
  )
865
1159
  : col(buttonRow, spacer(0), embedWidget);
866
- // Surface BASE in the preview section label so it's always visible
867
- // (the list-row badge gets truncated at 25% column width). The
868
- // base session is the editor process itselfclosing or moving
869
- // its worktree would close the editor / break the user's current
870
- // tree, so Stop / Archive / Delete refuse against it.
871
- const sectionLabel = isBase
872
- ? `[${s.id}] ${s.label} BASE — editor session`
873
- : `[${s.id}] ${s.label}`;
1160
+ // Surface the launch session in the preview label so it's always
1161
+ // visible (the list-row badge gets truncated at 25% column width).
1162
+ // It's the dir the editor was started in informational only; it's
1163
+ // deletable like any other session once another window exists.
1164
+ const sectionLabel = s.id === 1
1165
+ ? `${s.label} — launch session`
1166
+ : s.label;
874
1167
  return labeledSection({
875
1168
  label: sectionLabel,
876
1169
  child: body,
877
1170
  });
878
1171
  }
879
1172
 
1173
+ // The per-action bullet lines shown in the confirmation panel.
1174
+ // `delete` adds a separate red "uncommitted changes" line in the
1175
+ // caller because it needs distinct styling.
1176
+ function confirmActionLines(action: BulkAction): string[] {
1177
+ switch (action) {
1178
+ case "stop":
1179
+ return [
1180
+ " • send SIGTERM to all session processes",
1181
+ " • SIGKILL after a short grace period",
1182
+ "",
1183
+ "The worktree and session record remain.",
1184
+ ];
1185
+ case "archive":
1186
+ return [
1187
+ " • SIGKILL all session processes",
1188
+ " • close the editor session",
1189
+ " • move the worktree to .archived/",
1190
+ "",
1191
+ "Reversible via Unarchive.",
1192
+ ];
1193
+ case "delete":
1194
+ return [
1195
+ " • stop all session processes",
1196
+ " • run `git worktree remove`",
1197
+ " • drop the session record",
1198
+ ];
1199
+ }
1200
+ }
1201
+
1202
+ // Confirmation panel for a Stop/Archive/Delete over one or many
1203
+ // sessions. A single id renders the familiar per-session prompt; two
1204
+ // or more render a batch prompt that lists the targets. The Confirm
1205
+ // button reuses the same `confirm-<action>` key the single path
1206
+ // always used, so the existing widget_event handlers fire for both —
1207
+ // they read `pendingConfirm.ids`.
1208
+ function buildConfirmPane(
1209
+ confirm: { action: BulkAction; ids: number[] },
1210
+ ): WidgetSpec {
1211
+ const { action, ids } = confirm;
1212
+ const cap = action[0].toUpperCase() + action.slice(1);
1213
+ const existing = ids.filter((id) => orchestratorSessions.has(id));
1214
+ const bulk = existing.length > 1;
1215
+ const diskNote = (id: number): string =>
1216
+ orchestratorSessions.get(id)?.discovered ? " · on-disk" : "";
1217
+ const entries: TextPropertyEntry[] = [];
1218
+ if (bulk) {
1219
+ entries.push(
1220
+ styledRow([
1221
+ { text: `${cap} these ${existing.length} sessions?`, style: { bold: true } },
1222
+ ]),
1223
+ styledRow([{ text: "" }]),
1224
+ );
1225
+ for (const id of existing.slice(0, 8)) {
1226
+ const ss = orchestratorSessions.get(id)!;
1227
+ entries.push(
1228
+ styledRow([
1229
+ { text: ` ${ss.label}` },
1230
+ { text: diskNote(id), style: { fg: "ui.menu_disabled_fg", italic: true } },
1231
+ ]),
1232
+ );
1233
+ }
1234
+ if (existing.length > 8) {
1235
+ entries.push(
1236
+ styledRow([
1237
+ {
1238
+ text: ` … and ${existing.length - 8} more`,
1239
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1240
+ },
1241
+ ]),
1242
+ );
1243
+ }
1244
+ } else {
1245
+ const id = existing[0];
1246
+ const ss = id !== undefined ? orchestratorSessions.get(id) : undefined;
1247
+ entries.push(
1248
+ styledRow([
1249
+ { text: `${cap} session ${ss?.label ?? ""}?`, style: { bold: true } },
1250
+ ]),
1251
+ );
1252
+ }
1253
+ entries.push(
1254
+ styledRow([{ text: "" }]),
1255
+ styledRow([{ text: bulk ? "For each session this will:" : "This will:" }]),
1256
+ );
1257
+ for (const line of confirmActionLines(action)) {
1258
+ entries.push(styledRow([{ text: line }]));
1259
+ }
1260
+ if (action === "delete") {
1261
+ entries.push(
1262
+ styledRow([{ text: "" }]),
1263
+ styledRow([
1264
+ {
1265
+ text: "Uncommitted changes will be lost.",
1266
+ style: { fg: "ui.status_error_indicator_fg", bold: true },
1267
+ },
1268
+ ]),
1269
+ );
1270
+ }
1271
+ return labeledSection({
1272
+ label: bulk ? `Confirm ${cap} — ${existing.length} sessions` : `Confirm ${cap}`,
1273
+ child: col(
1274
+ { kind: "raw", entries },
1275
+ spacer(0),
1276
+ row(
1277
+ flexSpacer(),
1278
+ button("Cancel", { key: "confirm-cancel" }),
1279
+ spacer(2),
1280
+ button(`Confirm ${cap}`, { intent: "danger", key: `confirm-${action}` }),
1281
+ ),
1282
+ ),
1283
+ });
1284
+ }
1285
+
1286
+ // The dedicated bulk selection bar (Layout B). Shown in place of the
1287
+ // per-session preview when two or more rows are checked. The bulk
1288
+ // action buttons sit at the *top* of the pane; the list of affected
1289
+ // sessions renders below as a scrollable `list` widget (so a long
1290
+ // selection scrolls — keyboard, wheel, and the draggable scrollbar —
1291
+ // rather than overflowing the pane). Each action's count is the
1292
+ // number of *eligible* members; an action with no eligible members is
1293
+ // disabled.
1294
+ function buildBulkPane(): WidgetSpec {
1295
+ const sel = selectedSessions();
1296
+ const stopN = eligibleSelected("stop").length;
1297
+ const archiveN = eligibleSelected("archive").length;
1298
+ const deleteN = eligibleSelected("delete").length;
1299
+
1300
+ const inflight = openDialog?.bulkInFlight ?? null;
1301
+ const actionRow = inflight
1302
+ ? row(
1303
+ {
1304
+ kind: "raw",
1305
+ entries: [
1306
+ styledRow([
1307
+ {
1308
+ text: `${inflight.action[0].toUpperCase()}${inflight.action.slice(1)}ing ${inflight.done}/${inflight.total}…`,
1309
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1310
+ },
1311
+ ]),
1312
+ ],
1313
+ },
1314
+ flexSpacer(),
1315
+ )
1316
+ : row(
1317
+ button(`Stop (${stopN})`, { key: "bulk-stop", disabled: stopN === 0 }),
1318
+ spacer(2),
1319
+ button(`Archive (${archiveN})`, {
1320
+ key: "bulk-archive",
1321
+ disabled: archiveN === 0,
1322
+ }),
1323
+ spacer(2),
1324
+ button(`Delete (${deleteN})`, {
1325
+ intent: "danger",
1326
+ key: "bulk-delete",
1327
+ disabled: deleteN === 0,
1328
+ }),
1329
+ flexSpacer(),
1330
+ button("Clear", { key: "bulk-clear" }),
1331
+ );
1332
+
1333
+ // Affected-sessions list. Flag the rows a destructive action will
1334
+ // skip so the count discrepancy explains itself.
1335
+ const items: TextPropertyEntry[] = sel.map((id) => {
1336
+ const ss = orchestratorSessions.get(id)!;
1337
+ const rowParts: StyledSegment[] = [{ text: ` ${ss.label}` }];
1338
+ if (!ss.discovered && !ownsWorktree(ss)) {
1339
+ rowParts.push({
1340
+ text: " · in-place (forgotten, not removed)",
1341
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1342
+ });
1343
+ } else if (ss.discovered) {
1344
+ rowParts.push({
1345
+ text: " · on-disk worktree",
1346
+ style: { fg: "ui.menu_disabled_fg", italic: true },
1347
+ });
1348
+ }
1349
+ return styledRow(rowParts);
1350
+ });
1351
+ const itemKeys = sel.map((id) => `bulksel-${id}`);
1352
+ // Match the preview pane's height: content = action row (1) +
1353
+ // spacer (1) + list, and the embed pane reserves `listVisibleRows
1354
+ // + 4` for its body — so the list takes that height and the two
1355
+ // panes' bottom borders line up.
1356
+ const listRows = Math.max(3, (openDialog?.listVisibleRows ?? MIN_LIST_ROWS) + 4);
1357
+
1358
+ return labeledSection({
1359
+ label: `Bulk actions — ${sel.length} selected`,
1360
+ child: col(
1361
+ actionRow,
1362
+ spacer(0),
1363
+ list({
1364
+ items,
1365
+ itemKeys,
1366
+ // Display-only: no highlighted row, and out of the Tab cycle
1367
+ // (focus belongs on the action buttons). Up/Down still scroll
1368
+ // it via the host's smart-key forwarding, and the scrollbar
1369
+ // drags it.
1370
+ selectedIndex: -1,
1371
+ visibleRows: listRows,
1372
+ focusable: false,
1373
+ key: "bulk-list",
1374
+ }),
1375
+ ),
1376
+ });
1377
+ }
1378
+
880
1379
  function buildOpenSpec(): WidgetSpec {
881
1380
  if (!openDialog) return col();
882
1381
  const filtered = openDialog.filteredIds;
@@ -890,9 +1389,11 @@ function buildOpenSpec(): WidgetSpec {
890
1389
  const selIdx = filtered.length === 0
891
1390
  ? -1
892
1391
  : 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)
1392
+ // Gate on the *index* (selIdx < 0 means "filter matched nothing"),
1393
+ // not the sign of the id: discovered worktrees carry negative ids
1394
+ // and must still resolve to their row here.
1395
+ const selectedSession = selIdx >= 0
1396
+ ? orchestratorSessions.get(filtered[selIdx])
896
1397
  : undefined;
897
1398
 
898
1399
  // The "New Session" button advertises Alt+N (or whatever the
@@ -974,6 +1475,43 @@ function buildOpenSpec(): WidgetSpec {
974
1475
  scopeButton,
975
1476
  flexSpacer(),
976
1477
  );
1478
+ // Per-project filter checkbox, on its own row under the Project
1479
+ // control: opt-in toggle that surfaces the discovered on-disk
1480
+ // worktree rows. A `toggle` (single `[ ]`/`[v]` — no double
1481
+ // bracket) that's clickable and bound to Alt+T
1482
+ // (`orchestrator_toggle_worktrees`, rebindable). The label carries
1483
+ // the live keybinding hint, mirroring the Project control's
1484
+ // "(Alt+P)". Inert while a confirm prompt is up.
1485
+ const worktreeKey = editor.getKeybindingLabel(
1486
+ "orchestrator_toggle_worktrees",
1487
+ OPEN_MODE,
1488
+ );
1489
+ const worktreeLabel = worktreeKey
1490
+ ? `Show all worktrees (${worktreeKey})`
1491
+ : "Show all worktrees";
1492
+ const worktreeFilterRow = row(
1493
+ toggle(openDialog.showWorktrees, worktreeLabel, {
1494
+ key: openDialog.pendingConfirm !== null ? undefined : "worktree-show",
1495
+ }),
1496
+ flexSpacer(),
1497
+ );
1498
+ // Content filter checkbox, beneath the worktree one. The flag is
1499
+ // `hideTrivial`, but the checkbox reads as an opt-in "show" toggle to
1500
+ // match the worktree row: unchecked (default) hides the empty /
1501
+ // single-file shells, checking it reveals them. Inert during confirm.
1502
+ const trivialKey = editor.getKeybindingLabel(
1503
+ "orchestrator_toggle_trivial",
1504
+ OPEN_MODE,
1505
+ );
1506
+ const trivialLabel = trivialKey
1507
+ ? `Show empty/1-file sessions (${trivialKey})`
1508
+ : "Show empty/1-file sessions";
1509
+ const trivialFilterRow = row(
1510
+ toggle(!openDialog.hideTrivial, trivialLabel, {
1511
+ key: openDialog.pendingConfirm !== null ? undefined : "hide-trivial",
1512
+ }),
1513
+ flexSpacer(),
1514
+ );
977
1515
 
978
1516
  return col(
979
1517
  {
@@ -1028,6 +1566,8 @@ function buildOpenSpec(): WidgetSpec {
1028
1566
  flexSpacer(),
1029
1567
  ),
1030
1568
  projectControlRow,
1569
+ worktreeFilterRow,
1570
+ trivialFilterRow,
1031
1571
  filterInput,
1032
1572
  sessionsSeparator(),
1033
1573
  sessionsColumnHeader(),
@@ -1067,6 +1607,11 @@ function buildOpenSpec(): WidgetSpec {
1067
1607
  hintBar([
1068
1608
  { keys: "↑↓", label: "nav" },
1069
1609
  { keys: "Enter", label: "dive" },
1610
+ {
1611
+ keys: editor.getKeybindingLabel("orchestrator_toggle_select", OPEN_MODE) ||
1612
+ "Space",
1613
+ label: "select",
1614
+ },
1070
1615
  {
1071
1616
  keys: scopeKey || "⌥P",
1072
1617
  label: scope === "current" ? "all projects" : "current only",
@@ -1127,6 +1672,7 @@ function clearDialogError(): void {
1127
1672
 
1128
1673
  function refreshOpenDialog(): void {
1129
1674
  if (!openPanel || !openDialog) return;
1675
+ pruneSelection();
1130
1676
  openDialog.filteredIds = filterSessions(openDialog.filter.value);
1131
1677
  // Clamp the selection into range so a fresh filter or a
1132
1678
  // session vanishing under us doesn't leave us pointing past
@@ -1149,6 +1695,9 @@ function refreshOpenDialog(): void {
1149
1695
  function openControlRoom(): void {
1150
1696
  if (openPanel) return;
1151
1697
  reconcileSessions();
1698
+ // Summarise on-disk session content up front so the trivial filter
1699
+ // has data on the first render.
1700
+ scanSessionContent();
1152
1701
  const activeId = editor.activeWindow();
1153
1702
  // Seed with the screen-max; buildOpenSpec refits to the session
1154
1703
  // count on the first render (and every render after).
@@ -1167,6 +1716,10 @@ function openControlRoom(): void {
1167
1716
  // Restore the last-used scope (defaults to "all"); the Project
1168
1717
  // control / Alt+P updates it for next time.
1169
1718
  scope: lastOpenScope,
1719
+ selectedIds: new Set<number>(),
1720
+ showWorktrees: lastShowWorktrees,
1721
+ hideTrivial: lastHideTrivial,
1722
+ bulkInFlight: null,
1170
1723
  };
1171
1724
  openDialog.filteredIds = filterSessions("");
1172
1725
  const activeIdx = openDialog.filteredIds.indexOf(activeId);
@@ -1188,6 +1741,12 @@ function openControlRoom(): void {
1188
1741
  // safe — there's nothing to act on then anyway.
1189
1742
  openPanel.setFocusKey("visit");
1190
1743
  editor.setEditorMode(OPEN_MODE);
1744
+
1745
+ // Discover worktrees that exist on disk but aren't open yet and
1746
+ // fold them into the list. Async (it shells out to git per
1747
+ // project); the dialog renders immediately with live sessions and
1748
+ // gains the discovered rows when the scan lands.
1749
+ void refreshDiscoveredWorktrees();
1191
1750
  }
1192
1751
 
1193
1752
  function closeOpenDialog(): void {
@@ -1199,33 +1758,27 @@ function closeOpenDialog(): void {
1199
1758
  editor.setEditorMode(null);
1200
1759
  }
1201
1760
 
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
- }
1761
+ // Stop every process one session owns. Sends SIGTERM first via the
1762
+ // host's `signalWindow` (which fans out through the window's
1763
+ // process-group tracker), then follows up with SIGKILL after a short
1764
+ // grace period so ill-behaved agents that ignore SIGTERM still get
1765
+ // reaped. The session record stays put Stop only kills processes,
1766
+ // it doesn't touch the worktree or the editor session. Returns false
1767
+ // for ids it can't stop (base session, discovered worktrees with no
1768
+ // live window).
1769
+ function stopOne(id: number): boolean {
1770
+ const s = orchestratorSessions.get(id);
1771
+ if (!s || id <= 0 || s.discovered || !s.terminalId) return false;
1218
1772
  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
1773
+ // SIGKILL fallback for agents that ignore SIGTERM. The host's
1774
+ // signalWindow is idempotent on already-exited process groups, so
1775
+ // the second call is safe whether or not the first one took.
1776
+ // QuickJS has no `setTimeout`; `editor.delay(ms)` is the async
1224
1777
  // sleep primitive, which we kick off but don't await.
1225
1778
  void editor.delay(2000).then(() => {
1226
1779
  editor.signalWindow(id, "SIGKILL");
1227
1780
  });
1228
- editor.setStatus(`Orchestrator: stop signal sent to session [${id}]`);
1781
+ return true;
1229
1782
  }
1230
1783
 
1231
1784
  // ---------------------------------------------------------------------
@@ -1305,139 +1858,124 @@ function pickNextActiveSession(excludeId: number): number {
1305
1858
  for (const sid of orchestratorSessions.keys()) {
1306
1859
  if (sid !== excludeId && sid > 0) return sid;
1307
1860
  }
1308
- return 1;
1861
+ // No other live window. Callers guard against closing the last
1862
+ // window before reaching here, so this is a safe no-op swap (id 1
1863
+ // is no longer guaranteed to exist — it's deletable like any other).
1864
+ return excludeId;
1309
1865
  }
1310
1866
 
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;
1867
+ // Number of real editor windows. Discovered on-disk rows have negative
1868
+ // ids and are not windows. The editor must always host at least one
1869
+ // window, so deleting/archiving the last live window is refused.
1870
+ function liveWindowCount(): number {
1871
+ let n = 0;
1872
+ for (const s of orchestratorSessions.values()) {
1873
+ if (s.id > 0) n += 1;
1359
1874
  }
1875
+ return n;
1876
+ }
1360
1877
 
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;
1878
+ // Resolve the *main* repo root a session's worktree belongs to, so
1879
+ // `git worktree move/remove` runs from a stable directory (never from
1880
+ // inside the tree being moved/removed). Prefers the canonical
1881
+ // `projectPath` recorded at create/discovery time, falling back to
1882
+ // resolving from the worktree itself.
1883
+ async function worktreeRepoRoot(s: AgentSession): Promise<string | null> {
1884
+ if (s.projectPath) {
1885
+ const r = await resolveCanonicalRepoRoot(s.projectPath);
1886
+ if (r) return r;
1373
1887
  }
1374
- const repoRoot = (top.stdout || "").trim();
1888
+ return await resolveCanonicalRepoRoot(s.root);
1889
+ }
1375
1890
 
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);
1891
+ interface LifecycleResult {
1892
+ ok: boolean;
1893
+ err?: string;
1894
+ repoRoot?: string;
1895
+ }
1383
1896
 
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);
1897
+ // Archive a single session: SIGKILL its processes (archive is a
1898
+ // "done with this for now" action no graceful teardown needed since
1899
+ // the worktree stays on disk), close the editor session, move the
1900
+ // worktree to the `.archived/` graveyard, and append a manifest
1901
+ // entry so Unarchive can reverse it. Handles both live sessions and
1902
+ // discovered on-disk worktrees (the latter have no window to close).
1903
+ // Does NOT trigger sync — the caller batches one sync per repo after
1904
+ // the whole run.
1905
+ async function archiveOne(id: number): Promise<LifecycleResult> {
1906
+ const s = orchestratorSessions.get(id);
1907
+ if (!s) return { ok: false, err: "session gone" };
1908
+ // Archive moves the worktree to the graveyard — only sessions that
1909
+ // own one can be archived. A launch/in-place session has no separate
1910
+ // worktree, so there's nothing to move; use Delete to forget it.
1911
+ if (!ownsWorktree(s)) {
1912
+ return { ok: false, err: "no worktree to archive — use Delete to forget this session" };
1913
+ }
1914
+ // The editor must keep at least one window; closing the last one
1915
+ // would orphan the move. Refuse before touching the worktree.
1916
+ if (s.id > 0 && liveWindowCount() <= 1) {
1917
+ return { ok: false, err: "cannot archive the last window — open another session first" };
1918
+ }
1919
+ const repoRoot = await worktreeRepoRoot(s);
1920
+ if (!repoRoot) return { ok: false, err: "not a git repository" };
1921
+
1922
+ // Live session: close_window refuses to close the active window, so
1923
+ // switch away first, then SIGKILL the process group (so pty
1924
+ // children release worktree locks) and close the editor session.
1925
+ if (!s.discovered && id > 0) {
1926
+ if (id === editor.activeWindow()) {
1927
+ editor.setActiveWindow(pickNextActiveSession(id));
1928
+ }
1929
+ if (s.terminalId) editor.signalWindow(id, "SIGKILL");
1930
+ editor.closeWindow(id);
1931
+ // Brief settle so the filesystem reflects the pty's exit before
1932
+ // we move the worktree out from under it.
1933
+ await editor.delay(250);
1934
+ }
1387
1935
 
1388
- // git worktree move keeps git's internal bookkeeping
1389
- // consistent (the new path stays registered as a worktree).
1390
1936
  const archivedRoot = editor.pathJoin(
1391
1937
  editor.getDataDir(),
1392
1938
  "orchestrator",
1393
1939
  slugify(repoRoot),
1394
1940
  ".archived",
1395
- session.label,
1941
+ s.label,
1396
1942
  );
1397
1943
  const parent = editor.pathDirname(archivedRoot);
1398
1944
  if (!editor.createDir(parent)) {
1399
- editor.setStatus(
1400
- `Orchestrator: archive failed — could not create ${parent}`,
1401
- );
1402
- clearInFlight();
1403
- return;
1945
+ return { ok: false, err: `could not create ${parent}`, repoRoot };
1404
1946
  }
1947
+ // git worktree move keeps git's internal bookkeeping consistent
1948
+ // (the new path stays registered as a worktree).
1405
1949
  const moveRes = await spawnCollect(
1406
1950
  "git",
1407
- ["-C", repoRoot, "worktree", "move", session.root, archivedRoot],
1951
+ ["-C", repoRoot, "worktree", "move", s.root, archivedRoot],
1408
1952
  repoRoot,
1409
1953
  );
1410
1954
  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;
1955
+ return {
1956
+ ok: false,
1957
+ err: lastNonEmptyLine(moveRes.stderr) || "worktree move failed",
1958
+ repoRoot,
1959
+ };
1418
1960
  }
1419
1961
 
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
1962
  const manifest = loadArchiveManifest(repoRoot);
1425
1963
  manifest.sessions.push({
1426
- label: session.label,
1964
+ label: s.label,
1427
1965
  root: archivedRoot,
1428
- original_root: session.root,
1429
- branch: session.label,
1966
+ original_root: s.root,
1967
+ branch: s.branch || s.label,
1430
1968
  archived_at: new Date().toISOString(),
1431
1969
  });
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}`);
1970
+ saveArchiveManifest(repoRoot, manifest);
1971
+
1972
+ // A discovered row has no window_closed hook to drop it — remove it
1973
+ // from the model directly.
1974
+ if (s.discovered) {
1975
+ orchestratorSessions.delete(id);
1976
+ discoveredIdByPath.delete(s.root);
1438
1977
  }
1439
- clearInFlight();
1440
- triggerSyncAsync(repoRoot);
1978
+ return { ok: true, repoRoot };
1441
1979
  }
1442
1980
 
1443
1981
  // ---------------------------------------------------------------------
@@ -1640,86 +2178,149 @@ async function buildSyncSnapshot(repoRoot: string): Promise<unknown> {
1640
2178
  };
1641
2179
  }
1642
2180
 
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();
2181
+ // Delete a single session: close the editor session, then only when
2182
+ // the session owns a worktree — `git worktree remove --force` to drop
2183
+ // it from disk (and prune any archive-manifest entry). A launch or
2184
+ // in-place session owns no worktree, so Delete just forgets it: the
2185
+ // window closes and the directory is left untouched (a fresh session
2186
+ // can always be opened there again). Handles discovered on-disk
2187
+ // worktrees (no window to close). Does NOT trigger sync — the caller
2188
+ // batches it.
2189
+ async function deleteOne(id: number): Promise<LifecycleResult> {
2190
+ const s = orchestratorSessions.get(id);
2191
+ if (!s) return { ok: false, err: "session gone" };
2192
+ // The editor must keep at least one window. Refuse before any
2193
+ // close/worktree-removal so a removable session can't `git worktree
2194
+ // remove` the tree the live editor is still sitting in.
2195
+ if (s.id > 0 && liveWindowCount() <= 1) {
2196
+ return { ok: false, err: "cannot delete the last window — open another session first" };
2197
+ }
2198
+ const removable = ownsWorktree(s);
2199
+
2200
+ if (!s.discovered && id > 0) {
2201
+ // close_window refuses to close the active (and the last) window,
2202
+ // so swap away first. SIGKILL only when there's an agent terminal —
2203
+ // a launch/in-place session has none, and signalling it must never
2204
+ // touch the editor itself.
2205
+ if (id === editor.activeWindow()) {
2206
+ editor.setActiveWindow(pickNextActiveSession(id));
1661
2207
  }
1662
- };
1663
- const session = orchestratorSessions.get(id);
1664
- if (!session) {
1665
- clearInFlight();
1666
- return;
2208
+ if (s.terminalId) editor.signalWindow(id, "SIGKILL");
2209
+ editor.closeWindow(id);
2210
+ if (removable) await editor.delay(250);
1667
2211
  }
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));
2212
+
2213
+ let repoRoot: string | undefined;
2214
+ if (removable) {
2215
+ const rr = await worktreeRepoRoot(s);
2216
+ if (!rr) return { ok: false, err: "not a git repository" };
2217
+ repoRoot = rr;
2218
+ // `--force` because the worktree may have unstaged changes the user
2219
+ // explicitly chose to discard via the confirm step.
2220
+ const removeRes = await spawnCollect(
2221
+ "git",
2222
+ ["-C", rr, "worktree", "remove", "--force", s.root],
2223
+ rr,
2224
+ );
2225
+ if (removeRes.exit_code !== 0) {
2226
+ return {
2227
+ ok: false,
2228
+ err: lastNonEmptyLine(removeRes.stderr) || "worktree remove failed",
2229
+ repoRoot,
2230
+ };
2231
+ }
2232
+
2233
+ // Drop the matching manifest entry too, in case the session was
2234
+ // already archived (delete-from-archived drops dormant sessions).
2235
+ const manifest = loadArchiveManifest(rr);
2236
+ const before = manifest.sessions.length;
2237
+ manifest.sessions = manifest.sessions.filter((e) => e.label !== s.label);
2238
+ if (manifest.sessions.length !== before) {
2239
+ saveArchiveManifest(rr, manifest);
2240
+ }
1672
2241
  }
1673
2242
 
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;
2243
+ if (s.discovered) {
2244
+ orchestratorSessions.delete(id);
2245
+ discoveredIdByPath.delete(s.root);
1684
2246
  }
1685
- const repoRoot = (top.stdout || "").trim();
2247
+ return { ok: true, repoRoot };
2248
+ }
1686
2249
 
1687
- editor.signalWindow(id, "SIGKILL");
1688
- editor.closeWindow(id);
1689
- await editor.delay(250);
2250
+ // Unified runner for a confirmed Stop / Archive / Delete over one or
2251
+ // many ids. Re-filters to eligible targets at execution time (the
2252
+ // selection or single row may have gone stale between confirm and
2253
+ // run), drives the in-flight progress markers, runs the per-id cores
2254
+ // sequentially, prunes acted-on ids from the selection, and triggers
2255
+ // one sync per touched repo at the end.
2256
+ async function runConfirmedAction(
2257
+ action: BulkAction,
2258
+ ids: number[],
2259
+ ): Promise<void> {
2260
+ if (!openDialog) return;
2261
+ const targets = ids.filter((id) => bulkEligible(action, id));
2262
+ if (targets.length === 0) {
2263
+ setDialogError(`nothing eligible to ${action} in the selection`);
2264
+ refreshOpenDialog();
2265
+ return;
2266
+ }
1690
2267
 
1691
- // `--force` because the worktree may have unstaged changes
1692
- // the user explicitly chose to discard via the confirm step.
1693
- const removeRes = await spawnCollect(
1694
- "git",
1695
- ["-C", repoRoot, "worktree", "remove", "--force", session.root],
1696
- repoRoot,
1697
- );
1698
- if (removeRes.exit_code !== 0) {
1699
- editor.setStatus(
1700
- `Orchestrator: worktree remove failed: ${
1701
- lastNonEmptyLine(removeRes.stderr) || "unknown error"
1702
- }`,
1703
- );
1704
- clearInFlight();
2268
+ if (action === "stop") {
2269
+ let n = 0;
2270
+ for (const id of targets) if (stopOne(id)) n += 1;
2271
+ editor.setStatus(`Orchestrator: stop signal sent to ${n} session(s)`);
2272
+ // Stop leaves sessions in place; drop them from the selection so
2273
+ // the bulk bar reflects that the action ran.
2274
+ for (const id of targets) openDialog.selectedIds.delete(id);
2275
+ refreshOpenDialog();
1705
2276
  return;
1706
2277
  }
1707
2278
 
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).
1711
- const manifest = loadArchiveManifest(repoRoot);
1712
- const before = manifest.sessions.length;
1713
- manifest.sessions = manifest.sessions.filter(
1714
- (e) => e.label !== session.label,
1715
- );
1716
- if (manifest.sessions.length !== before) {
1717
- saveArchiveManifest(repoRoot, manifest);
2279
+ const single = targets.length === 1;
2280
+ if (single) {
2281
+ openDialog.inFlight = { action, sessionId: targets[0] };
2282
+ } else {
2283
+ openDialog.bulkInFlight = { action, total: targets.length, done: 0 };
2284
+ }
2285
+ refreshOpenDialog();
2286
+
2287
+ const touchedRepos = new Set<string>();
2288
+ let okCount = 0;
2289
+ let lastErr = "";
2290
+ for (let i = 0; i < targets.length; i++) {
2291
+ const id = targets[i];
2292
+ const res = action === "archive" ? await archiveOne(id) : await deleteOne(id);
2293
+ if (res.ok) {
2294
+ okCount += 1;
2295
+ if (res.repoRoot) touchedRepos.add(res.repoRoot);
2296
+ } else {
2297
+ lastErr = res.err ?? "failed";
2298
+ }
2299
+ openDialog?.selectedIds.delete(id);
2300
+ if (openDialog?.bulkInFlight) openDialog.bulkInFlight.done = i + 1;
2301
+ refreshOpenDialog();
2302
+ }
2303
+ if (openDialog) {
2304
+ openDialog.inFlight = null;
2305
+ openDialog.bulkInFlight = null;
1718
2306
  }
1719
2307
 
1720
- editor.setStatus(`Orchestrator: deleted [${id}] ${session.label}`);
1721
- clearInFlight();
1722
- triggerSyncAsync(repoRoot);
2308
+ const verb = action === "archive" ? "archived" : "deleted";
2309
+ if (okCount === 0) {
2310
+ setDialogError(`${action} failed: ${lastErr || "unknown error"}`);
2311
+ } else if (lastErr) {
2312
+ setDialogError(`${verb} ${okCount}/${targets.length}; last error: ${lastErr}`);
2313
+ } else {
2314
+ editor.setStatus(`Orchestrator: ${verb} ${okCount} session(s)`);
2315
+ }
2316
+ for (const repo of touchedRepos) triggerSyncAsync(repo);
2317
+ refreshOpenDialog();
2318
+ // The batch emptied the selection, so the pane is back in
2319
+ // single-preview mode — restore focus to Visit (the bulk buttons
2320
+ // it may have been on are gone).
2321
+ if (openPanel && selectedSessions().length < 2 && !openDialog.pendingConfirm) {
2322
+ openPanel.setFocusKey("visit");
2323
+ }
1723
2324
  }
1724
2325
 
1725
2326
  // `Alt+N` from inside the picker opens the new-session form — saves
@@ -1742,6 +2343,25 @@ editor.defineMode(
1742
2343
  // text; session names don't contain `/`, so that's an
1743
2344
  // acceptable trade for the quick-focus.)
1744
2345
  ["/", "orchestrator_focus_filter"],
2346
+ // Space toggles the highlighted row's membership in the bulk
2347
+ // selection. Bound as a mode chord (not a widget smart-key) so
2348
+ // it's user-rebindable in the keybinding editor and fires
2349
+ // regardless of which control holds focus — the host's
2350
+ // `dispatch_floating_widget_key` defers any explicitly-bound
2351
+ // mode key, including bare chars, before the text-input path.
2352
+ // The trade (same as `/`) is that Space can't be typed into the
2353
+ // filter while the picker is open; session names don't contain
2354
+ // spaces, so that's acceptable.
2355
+ ["Space", "orchestrator_toggle_select"],
2356
+ // Alt+T toggles "Show all worktrees" — the opt-in filter that
2357
+ // surfaces discovered on-disk worktree rows. Rebindable, same as
2358
+ // the scope toggle.
2359
+ ["M-t", "orchestrator_toggle_worktrees"],
2360
+ // Alt+I toggles "Show empty/1-file sessions" — reveals the trivial
2361
+ // restored shells hidden by default. Rebindable, same as the others.
2362
+ // (Alt+E is unavailable: it's the Edit menu's mnemonic, which the
2363
+ // menu bar claims before the picker's mode keymap sees it.)
2364
+ ["M-i", "orchestrator_toggle_trivial"],
1745
2365
  ],
1746
2366
  true,
1747
2367
  true,
@@ -1758,6 +2378,38 @@ registerHandler("orchestrator_focus_filter", () => {
1758
2378
  openPanel.setFocusKey("filter");
1759
2379
  });
1760
2380
 
2381
+ // Space (rebindable): toggle the highlighted row in/out of the bulk
2382
+ // selection. Manages focus across the single↔bulk transition: when
2383
+ // the second row is checked the preview pane swaps to the bulk bar
2384
+ // (so the now-absent "visit" focus would otherwise be clamped to a
2385
+ // random tabbable), and when the selection drops back below two the
2386
+ // per-session preview — with its "visit" button — returns.
2387
+ registerHandler("orchestrator_toggle_select", () => {
2388
+ if (!openDialog || !openPanel) return;
2389
+ // Inert while a confirm prompt is up — the selection is frozen
2390
+ // behind the confirmation panel.
2391
+ if (openDialog.pendingConfirm) return;
2392
+ const id = openDialog.filteredIds[openDialog.selectedIndex];
2393
+ if (typeof id !== "number") return;
2394
+ const wasBulk = selectedSessions().length >= 2;
2395
+ if (openDialog.selectedIds.has(id)) {
2396
+ openDialog.selectedIds.delete(id);
2397
+ } else {
2398
+ openDialog.selectedIds.add(id);
2399
+ }
2400
+ clearDialogError();
2401
+ refreshOpenDialog();
2402
+ const isBulk = selectedSessions().length >= 2;
2403
+ if (!wasBulk && isBulk) {
2404
+ // Entering bulk mode — land focus on a bulk button (Up/Down from
2405
+ // a button still drives the list, so navigation keeps working).
2406
+ openPanel.setFocusKey("bulk-archive");
2407
+ } else if (wasBulk && !isBulk) {
2408
+ // Back to single preview — restore focus to Visit.
2409
+ openPanel.setFocusKey("visit");
2410
+ }
2411
+ });
2412
+
1761
2413
  function toggleScope(): void {
1762
2414
  if (!openDialog) return;
1763
2415
  openDialog.scope = openDialog.scope === "current" ? "all" : "current";
@@ -1776,6 +2428,55 @@ function toggleScope(): void {
1776
2428
 
1777
2429
  registerHandler("orchestrator_toggle_scope", toggleScope);
1778
2430
 
2431
+ // Flip "Show all worktrees" — reveal/hide the discovered on-disk
2432
+ // worktree rows. Preserves the highlighted row across the re-filter
2433
+ // where possible; drops now-hidden discovered rows from the bulk
2434
+ // selection. Shared by the Alt+T chord and the checkbox click.
2435
+ function toggleShowWorktrees(): void {
2436
+ if (!openDialog) return;
2437
+ openDialog.showWorktrees = !openDialog.showWorktrees;
2438
+ lastShowWorktrees = openDialog.showWorktrees;
2439
+ // Hiding worktrees shouldn't leave them lingering in the selection.
2440
+ if (!openDialog.showWorktrees) {
2441
+ for (const id of [...openDialog.selectedIds]) {
2442
+ if (orchestratorSessions.get(id)?.discovered) {
2443
+ openDialog.selectedIds.delete(id);
2444
+ }
2445
+ }
2446
+ }
2447
+ const prevId = openDialog.filteredIds[openDialog.selectedIndex];
2448
+ openDialog.filteredIds = filterSessions(openDialog.filter.value);
2449
+ const nextIdx = prevId !== undefined ? openDialog.filteredIds.indexOf(prevId) : -1;
2450
+ openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
2451
+ refreshOpenDialog();
2452
+ }
2453
+
2454
+ registerHandler("orchestrator_toggle_worktrees", toggleShowWorktrees);
2455
+
2456
+ // Flip "Show empty/1-file sessions" — reveal/hide the trivial restored
2457
+ // shells. Preserves the highlighted row across the re-filter where
2458
+ // possible; drops now-hidden rows from the bulk selection. Shared by the
2459
+ // Alt+I chord and the checkbox click.
2460
+ function toggleHideTrivial(): void {
2461
+ if (!openDialog) return;
2462
+ openDialog.hideTrivial = !openDialog.hideTrivial;
2463
+ lastHideTrivial = openDialog.hideTrivial;
2464
+ const prevId = openDialog.filteredIds[openDialog.selectedIndex];
2465
+ openDialog.filteredIds = filterSessions(openDialog.filter.value);
2466
+ // Hiding trivial rows shouldn't leave them lingering in the selection.
2467
+ if (openDialog.hideTrivial) {
2468
+ const visible = new Set(openDialog.filteredIds);
2469
+ for (const id of [...openDialog.selectedIds]) {
2470
+ if (!visible.has(id)) openDialog.selectedIds.delete(id);
2471
+ }
2472
+ }
2473
+ const nextIdx = prevId !== undefined ? openDialog.filteredIds.indexOf(prevId) : -1;
2474
+ openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
2475
+ refreshOpenDialog();
2476
+ }
2477
+
2478
+ registerHandler("orchestrator_toggle_trivial", toggleHideTrivial);
2479
+
1779
2480
  // =============================================================================
1780
2481
  // New-session floating form
1781
2482
  // =============================================================================
@@ -2078,6 +2779,130 @@ async function pathIsInsideGitWorkTree(
2078
2779
  return (res.stdout || "").trim() === "true";
2079
2780
  }
2080
2781
 
2782
+ // =============================================================================
2783
+ // Worktree classification & discovery
2784
+ //
2785
+ // Two distinct git facts drive the "attach to an existing worktree"
2786
+ // flows:
2787
+ //
2788
+ // * `classifyWorktree(path)` answers "is this path a *linked*
2789
+ // worktree, and if so what repo does it belong to?" — used by
2790
+ // the new-session form to attach (rather than fork) when the
2791
+ // user points Project Path at an existing worktree.
2792
+ // * `listLinkedWorktrees(repoRoot)` enumerates every linked
2793
+ // worktree of a repo (via `git worktree list --porcelain`) —
2794
+ // used to surface on-disk worktrees in the Open dialog without
2795
+ // the user adding them by hand.
2796
+ // =============================================================================
2797
+
2798
+ interface WorktreeInfo {
2799
+ // `git rev-parse --show-toplevel` for the path.
2800
+ toplevel: string;
2801
+ // Canonical main-worktree root (dirname of `--git-common-dir`).
2802
+ // This is the repo the worktree belongs to, used as the
2803
+ // session's `projectPath` so attached worktrees group under
2804
+ // their repo in the picker.
2805
+ mainRoot: string;
2806
+ // `true` when the path is a *linked* worktree (its per-worktree
2807
+ // git dir differs from the shared common dir), i.e. a tree
2808
+ // created by `git worktree add` rather than the main checkout.
2809
+ isLinked: boolean;
2810
+ // Branch checked out there (`refs/heads/<name>` short form), or
2811
+ // empty when detached.
2812
+ branch: string;
2813
+ }
2814
+
2815
+ /// Classify `path` as a git worktree. Returns `null` when `path`
2816
+ /// is not inside any git work tree (the caller then treats it as a
2817
+ /// plain directory / shared root).
2818
+ async function classifyWorktree(path: string): Promise<WorktreeInfo | null> {
2819
+ if (!path) return null;
2820
+ const top = await spawnCollect("git", ["-C", path, "rev-parse", "--show-toplevel"], path);
2821
+ if (top.exit_code !== 0) return null;
2822
+ const toplevel = (top.stdout || "").trim();
2823
+ if (!toplevel) return null;
2824
+
2825
+ // The per-worktree git dir vs. the shared common dir: they are
2826
+ // equal for the main worktree and differ for every linked
2827
+ // worktree (`<common>/worktrees/<id>`). That difference is the
2828
+ // canonical "is this a linked worktree?" test.
2829
+ const [gitDir, commonDir] = await Promise.all([
2830
+ spawnCollect("git", ["-C", toplevel, "rev-parse", "--path-format=absolute", "--git-dir"], toplevel),
2831
+ spawnCollect(
2832
+ "git",
2833
+ ["-C", toplevel, "rev-parse", "--path-format=absolute", "--git-common-dir"],
2834
+ toplevel,
2835
+ ),
2836
+ ]);
2837
+ const gd = gitDir.exit_code === 0 ? (gitDir.stdout || "").trim() : "";
2838
+ const cd = commonDir.exit_code === 0 ? (commonDir.stdout || "").trim() : "";
2839
+ const isLinked = gd !== "" && cd !== "" && gd !== cd;
2840
+ const mainRoot = cd ? editor.pathDirname(cd) : toplevel;
2841
+
2842
+ const head = await spawnCollect(
2843
+ "git",
2844
+ ["-C", toplevel, "rev-parse", "--abbrev-ref", "HEAD"],
2845
+ toplevel,
2846
+ );
2847
+ let branch = head.exit_code === 0 ? (head.stdout || "").trim() : "";
2848
+ if (branch === "HEAD") branch = ""; // detached
2849
+
2850
+ return { toplevel, mainRoot, isLinked, branch };
2851
+ }
2852
+
2853
+ interface ParsedWorktree {
2854
+ path: string;
2855
+ branch: string;
2856
+ detached: boolean;
2857
+ }
2858
+
2859
+ /// Parse `git worktree list --porcelain` output. Blocks are
2860
+ /// separated by blank lines; the first block is the main worktree,
2861
+ /// the rest are linked. Each block has a `worktree <path>` line
2862
+ /// plus `branch refs/heads/<name>` or `detached`.
2863
+ function parseWorktreePorcelain(stdout: string): ParsedWorktree[] {
2864
+ const out: ParsedWorktree[] = [];
2865
+ let cur: ParsedWorktree | null = null;
2866
+ for (const raw of (stdout || "").split(/\r?\n/)) {
2867
+ const line = raw.trimEnd();
2868
+ if (line.startsWith("worktree ")) {
2869
+ if (cur) out.push(cur);
2870
+ cur = { path: line.slice("worktree ".length), branch: "", detached: false };
2871
+ } else if (cur && line.startsWith("branch ")) {
2872
+ const ref = line.slice("branch ".length);
2873
+ cur.branch = ref.replace(/^refs\/heads\//, "");
2874
+ } else if (cur && line === "detached") {
2875
+ cur.detached = true;
2876
+ } else if (line === "" && cur) {
2877
+ out.push(cur);
2878
+ cur = null;
2879
+ }
2880
+ }
2881
+ if (cur) out.push(cur);
2882
+ return out;
2883
+ }
2884
+
2885
+ /// Enumerate the *linked* worktrees of `repoRoot` (excludes the
2886
+ /// main worktree, which is the repo's own checkout). Returns the
2887
+ /// parsed entries with the main-repo root resolved so callers can
2888
+ /// tag discovered sessions with the right `projectPath`.
2889
+ async function listLinkedWorktrees(
2890
+ repoRoot: string,
2891
+ ): Promise<{ mainRoot: string; worktrees: ParsedWorktree[] } | null> {
2892
+ const res = await spawnCollect(
2893
+ "git",
2894
+ ["-C", repoRoot, "worktree", "list", "--porcelain"],
2895
+ repoRoot,
2896
+ );
2897
+ if (res.exit_code !== 0) return null;
2898
+ const all = parseWorktreePorcelain(res.stdout || "");
2899
+ if (all.length === 0) return null;
2900
+ // The first entry is always the main worktree.
2901
+ const mainRoot = all[0].path;
2902
+ const worktrees = all.slice(1);
2903
+ return { mainRoot, worktrees };
2904
+ }
2905
+
2081
2906
  async function nextAutoSessionName(
2082
2907
  repoRoot: string,
2083
2908
  options?: { persist?: boolean },
@@ -2164,9 +2989,11 @@ function buildFormSpec(): WidgetSpec {
2164
2989
  // inert when worktree creation is off.
2165
2990
  let branchPlaceholder: string;
2166
2991
  if (branchInert) {
2167
- branchPlaceholder = worktreeEnabled
2168
- ? "shared worktree — N/A"
2169
- : "no git — N/A";
2992
+ branchPlaceholder = !worktreeEnabled
2993
+ ? "no git — N/A"
2994
+ : form.projectPathIsLinkedWorktree === true
2995
+ ? "existing worktree — N/A"
2996
+ : "shared worktree — N/A";
2170
2997
  } else if (!form.defaultBranch) {
2171
2998
  branchPlaceholder = "detecting default branch…";
2172
2999
  } else if (form.defaultBranchIsHeadFallback) {
@@ -2241,6 +3068,24 @@ function buildFormSpec(): WidgetSpec {
2241
3068
  ]),
2242
3069
  ],
2243
3070
  },
3071
+ // Existing-worktree hint: when Project Path points at a linked
3072
+ // worktree, explain what the (un)checked box now means so the
3073
+ // attach behaviour isn't a silent surprise.
3074
+ ...(form.projectPathIsLinkedWorktree === true
3075
+ ? [{
3076
+ kind: "raw" as const,
3077
+ entries: [
3078
+ styledRow([
3079
+ {
3080
+ text: form.createWorktree
3081
+ ? " ↳ existing worktree here — uncheck to attach instead of forking a new one"
3082
+ : " ↳ existing worktree — this session will attach to it",
3083
+ style: { fg: "ui.help_key_fg", italic: true },
3084
+ },
3085
+ ]),
3086
+ ],
3087
+ }]
3088
+ : []),
2244
3089
  // === Form body: labeled, full-width inputs. ==================
2245
3090
  // Labels are plain — the `▸` glyph used to be baked into all
2246
3091
  // three strings and stayed put regardless of focus, which was
@@ -2380,6 +3225,7 @@ function openForm(options?: { fromPicker?: boolean }): void {
2380
3225
  lastError: null,
2381
3226
  defaultProjectPath: "",
2382
3227
  projectPathIsGit: null,
3228
+ projectPathIsLinkedWorktree: null,
2383
3229
  defaultSessionName: "",
2384
3230
  defaultBranch: "",
2385
3231
  defaultBranchIsHeadFallback: false,
@@ -2442,6 +3288,24 @@ async function probeProjectPathDefaults(): Promise<void> {
2442
3288
  if (!form || form.probeToken !== token) return;
2443
3289
  form.projectPathIsGit = isGit;
2444
3290
 
3291
+ // (2b) Existing-linked-worktree detection. When the path is a
3292
+ // worktree created by `git worktree add` (not the repo's main
3293
+ // checkout), default the checkbox to *unchecked* so the
3294
+ // natural action is to attach to it. Only flip on the
3295
+ // detection transition so we don't fight a user who
3296
+ // deliberately re-checks "create a new worktree".
3297
+ const wasLinked = form.projectPathIsLinkedWorktree;
3298
+ if (isGit) {
3299
+ const info = await classifyWorktree(effectivePath);
3300
+ if (!form || form.probeToken !== token) return;
3301
+ form.projectPathIsLinkedWorktree = info?.isLinked === true;
3302
+ } else {
3303
+ form.projectPathIsLinkedWorktree = false;
3304
+ }
3305
+ if (form.projectPathIsLinkedWorktree && wasLinked !== true) {
3306
+ form.createWorktree = false;
3307
+ }
3308
+
2445
3309
  // (3) Default branch + session name probes only make sense on
2446
3310
  // a git path. On non-git, leave both empty (the renderer
2447
3311
  // surfaces a "no git — N/A" branch placeholder, and the
@@ -2861,14 +3725,28 @@ async function submitForm(): Promise<void> {
2861
3725
  editor.setGlobalState("orchestrator.last_cmd", cmd);
2862
3726
  }
2863
3727
 
3728
+ // Attach-to-existing-worktree: when the user opted out of
3729
+ // creating a worktree but pointed Project Path at an *existing
3730
+ // linked worktree* (one created by `git worktree add`, possibly
3731
+ // for a repo Fresh has never opened before), treat it as the
3732
+ // dedicated worktree it is rather than a shared root. That means
3733
+ // `shared_worktree = false` (so Archive / Delete can
3734
+ // `git worktree move` / `remove` it) and a `project_path` of the
3735
+ // owning repo so the session groups with its siblings. A path
3736
+ // that's the repo's *main* worktree, or a non-git directory, stays
3737
+ // shared — you can't `git worktree remove` either of those.
3738
+ const attachInfo = !createWorktree ? await classifyWorktree(root) : null;
3739
+ if (!form) return;
3740
+ const isLinkedAttach = attachInfo?.isLinked === true;
3741
+ const effectiveProjectPath = isLinkedAttach ? attachInfo!.mainRoot : projectPath;
3742
+
2864
3743
  // 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.
3744
+ // `branchName` only exists in the worktree-create flow above; for
3745
+ // an attached linked worktree we report its checked-out branch;
3746
+ // for the shared-worktree / non-git case we leave it blank.
2869
3747
  const reportedBranch = createWorktree
2870
3748
  ? (branchInput || sessionName)
2871
- : "";
3749
+ : (isLinkedAttach ? attachInfo!.branch : "");
2872
3750
 
2873
3751
  // Append the user-effective values to per-field input
2874
3752
  // history so ↑/↓ can recall them on the next form open.
@@ -2886,7 +3764,9 @@ async function submitForm(): Promise<void> {
2886
3764
  // terminal IS the new window's seed buffer, so the window is
2887
3765
  // born with a single tab.
2888
3766
  const argv = splitAgentCmd(cmd);
2889
- const sharedWorktree = !createWorktree;
3767
+ // Shared only when we neither created a worktree nor attached to an
3768
+ // existing linked one (i.e. a non-git dir or the repo's main tree).
3769
+ const sharedWorktree = !createWorktree && !isLinkedAttach;
2890
3770
  try {
2891
3771
  const result = await editor.createWindowWithTerminal({
2892
3772
  root,
@@ -2898,17 +3778,26 @@ async function submitForm(): Promise<void> {
2898
3778
  const id = result.windowId;
2899
3779
  // `createWindowWithTerminal` already dove into the new window,
2900
3780
  // so `setWindowState` writes to it.
2901
- editor.setWindowState("project_path", projectPath);
3781
+ editor.setWindowState("project_path", effectiveProjectPath);
2902
3782
  editor.setWindowState("shared_worktree", sharedWorktree);
3783
+ // If we attached to a worktree that was sitting in the picker as
3784
+ // a discovered row, drop that placeholder — this live window
3785
+ // supersedes it.
3786
+ const discId = discoveredIdByPath.get(root);
3787
+ if (discId !== undefined) {
3788
+ orchestratorSessions.delete(discId);
3789
+ discoveredIdByPath.delete(root);
3790
+ }
2903
3791
  const tracked: AgentSession = {
2904
3792
  id,
2905
3793
  label: sessionName,
2906
3794
  root,
2907
- projectPath,
3795
+ projectPath: effectiveProjectPath,
2908
3796
  sharedWorktree,
2909
3797
  terminalId: result.terminalId,
2910
3798
  state: "running",
2911
3799
  createdAt: Date.now(),
3800
+ branch: reportedBranch || undefined,
2912
3801
  };
2913
3802
  orchestratorSessions.set(id, tracked);
2914
3803
  } catch (e) {
@@ -2920,6 +3809,54 @@ async function submitForm(): Promise<void> {
2920
3809
  }
2921
3810
  }
2922
3811
 
3812
+ /// Open a session in an existing worktree without creating one —
3813
+ /// the dive action for a discovered row, and the building block the
3814
+ /// new-session form reuses when the user points Project Path at an
3815
+ /// existing linked worktree. Spawns a bare terminal (no agent
3816
+ /// command) rooted at the worktree, tags the window with its
3817
+ /// canonical project + `shared_worktree = false` so Archive / Delete
3818
+ /// manage it as the real worktree it is, then drops the discovered
3819
+ /// placeholder (the live window supersedes it).
3820
+ async function attachToWorktree(opts: {
3821
+ root: string;
3822
+ projectPath: string;
3823
+ label: string;
3824
+ branch?: string;
3825
+ discoveredId?: number;
3826
+ }): Promise<void> {
3827
+ try {
3828
+ const result = await editor.createWindowWithTerminal({
3829
+ root: opts.root,
3830
+ label: opts.label,
3831
+ cwd: opts.root,
3832
+ });
3833
+ const id = result.windowId;
3834
+ editor.setWindowState("project_path", opts.projectPath);
3835
+ editor.setWindowState("shared_worktree", false);
3836
+ if (opts.discoveredId !== undefined) {
3837
+ orchestratorSessions.delete(opts.discoveredId);
3838
+ discoveredIdByPath.delete(opts.root);
3839
+ }
3840
+ orchestratorSessions.set(id, {
3841
+ id,
3842
+ label: opts.label,
3843
+ root: opts.root,
3844
+ projectPath: opts.projectPath,
3845
+ sharedWorktree: false,
3846
+ terminalId: result.terminalId,
3847
+ state: "running",
3848
+ createdAt: Date.now(),
3849
+ branch: opts.branch,
3850
+ });
3851
+ } catch (e) {
3852
+ editor.setStatus(
3853
+ `Orchestrator: failed to attach session — ${
3854
+ e instanceof Error ? e.message : String(e)
3855
+ }`,
3856
+ );
3857
+ }
3858
+ }
3859
+
2923
3860
  function startNewSession(): void {
2924
3861
  if (form) return; // already open
2925
3862
  openForm();
@@ -3116,39 +4053,54 @@ function enterConfirm(action: "stop" | "archive" | "delete"): void {
3116
4053
  if (!openDialog || !openPanel) return;
3117
4054
  const id = openDialog.filteredIds[openDialog.selectedIndex];
3118
4055
  if (typeof id !== "number" || id <= 0) return;
3119
- // Refuse Archive / Delete on a shared root while other
3120
- // sessions still live there. Both actions either move
3121
- // (`git worktree move`) or remove (`git worktree remove`)
3122
- // the on-disk path doing that under another running
3123
- // session would yank the rug out from under it. Stop is
3124
- // fine: it only signals THIS session's process group, no
3125
- // disk operation.
4056
+ // Archive moves the worktree to the graveyard, so it only applies to
4057
+ // a session that owns one. A launch/in-place session runs inside a
4058
+ // real checkout with no `git worktree` entry Archive would have
4059
+ // nothing to move (and must never rm-rf the user's actual project).
4060
+ // Delete forgets the session and removes the worktree only when one
4061
+ // is owned. Both refuse the last live window (the editor must keep at
4062
+ // least one) — surface that before the confirm step.
3126
4063
  if (action === "archive" || action === "delete") {
3127
4064
  const session = orchestratorSessions.get(id);
3128
- if (session) {
3129
- const siblings = countSiblingsAtRoot(session.root);
3130
- if (siblings > 1) {
3131
- setDialogError(
3132
- `cannot ${action} session [${id}] ${session.label} — ${siblings - 1} other session(s) share this worktree; close them first`,
3133
- );
3134
- refreshOpenDialog();
3135
- return;
3136
- }
3137
- if (session.sharedWorktree) {
3138
- // Single-session shared-worktree mode: there's no
3139
- // `git worktree` entry to remove for this session.
3140
- // Block both lifecycle actions so we don't run
3141
- // `git worktree remove` against a non-worktree path
3142
- // and rm-rf the user's actual project directory.
3143
- setDialogError(
3144
- `cannot ${action} session [${id}] ${session.label} — session shares its working tree with the project root; close it via the editor instead`,
3145
- );
3146
- refreshOpenDialog();
3147
- return;
3148
- }
4065
+ if (session && session.id > 0 && liveWindowCount() <= 1) {
4066
+ setDialogError(
4067
+ `cannot ${action} session [${id}] ${session.label} — it's the last window; open another session first`,
4068
+ );
4069
+ refreshOpenDialog();
4070
+ return;
3149
4071
  }
3150
4072
  }
3151
- openDialog.pendingConfirm = { action, sessionId: id };
4073
+ if (action === "archive") {
4074
+ const session = orchestratorSessions.get(id);
4075
+ if (session && !ownsWorktree(session)) {
4076
+ setDialogError(
4077
+ `cannot archive session [${id}] ${session.label} — it has no dedicated worktree; use Delete to forget it`,
4078
+ );
4079
+ refreshOpenDialog();
4080
+ return;
4081
+ }
4082
+ }
4083
+ openDialog.pendingConfirm = { action, ids: [id] };
4084
+ openPanel.update(buildOpenSpec());
4085
+ openPanel.setFocusKey("confirm-cancel");
4086
+ }
4087
+
4088
+ // Open the confirm panel for a *bulk* action over the current
4089
+ // checkbox selection. Filters to the eligible members up front (so
4090
+ // the confirm count matches what will actually run); refuses with a
4091
+ // banner when nothing is eligible.
4092
+ function enterBulkConfirm(action: BulkAction): void {
4093
+ if (!openDialog || !openPanel) return;
4094
+ const targets = eligibleSelected(action);
4095
+ if (targets.length === 0) {
4096
+ setDialogError(`no selected session can be ${action === "stop" ? "stopped" : action + "d"}`);
4097
+ refreshOpenDialog();
4098
+ return;
4099
+ }
4100
+ // All three actions confirm — even Stop, so a bulk Stop over a
4101
+ // large selection isn't a single mis-key away. The confirm panel
4102
+ // lists the targets and shows the eligible count.
4103
+ openDialog.pendingConfirm = { action, ids: targets };
3152
4104
  openPanel.update(buildOpenSpec());
3153
4105
  openPanel.setFocusKey("confirm-cancel");
3154
4106
  }
@@ -3290,7 +4242,16 @@ editor.on("widget_event", (e) => {
3290
4242
  refreshOpenDialog();
3291
4243
  return;
3292
4244
  }
3293
- if (e.event_type === "select" && e.widget_key === "sessions") {
4245
+ // List selection. Keyboard nav fires this with `widget_key`
4246
+ // "sessions" (the list's own key); a mouse click on a row fires it
4247
+ // with `widget_key` set to the clicked item's key, carrying the
4248
+ // list key in `payload.list_key` instead — accept both so clicking a
4249
+ // row selects it (highlight + preview) just like arrowing to it.
4250
+ if (
4251
+ e.event_type === "select" &&
4252
+ (e.widget_key === "sessions" ||
4253
+ ((e.payload ?? {}) as Record<string, unknown>).list_key === "sessions")
4254
+ ) {
3294
4255
  const payload = (e.payload ?? {}) as Record<string, unknown>;
3295
4256
  const idx = payload.index;
3296
4257
  if (typeof idx === "number") {
@@ -3307,8 +4268,11 @@ editor.on("widget_event", (e) => {
3307
4268
  // on the button. Snap focus back to Visit so the user can
3308
4269
  // press Enter to open the newly-highlighted session — the
3309
4270
  // dialog's whole reason for being. Idempotent when focus
3310
- // is already on Visit.
3311
- openPanel.setFocusKey("visit");
4271
+ // is already on Visit. Skipped in bulk mode and during a
4272
+ // confirm, where "visit" isn't in the spec.
4273
+ if (selectedSessions().length < 2 && !openDialog.pendingConfirm) {
4274
+ openPanel.setFocusKey("visit");
4275
+ }
3312
4276
  }
3313
4277
  return;
3314
4278
  }
@@ -3317,6 +4281,20 @@ editor.on("widget_event", (e) => {
3317
4281
  (e.widget_key === "sessions" || e.widget_key === "visit")
3318
4282
  ) {
3319
4283
  const id = openDialog.filteredIds[openDialog.selectedIndex];
4284
+ const sel = typeof id === "number" ? orchestratorSessions.get(id) : undefined;
4285
+ if (sel && sel.discovered) {
4286
+ // Discovered worktree: there's no window to switch to —
4287
+ // open one by attaching a fresh session to the worktree.
4288
+ closeOpenDialog();
4289
+ void attachToWorktree({
4290
+ root: sel.root,
4291
+ projectPath: sel.projectPath ?? sel.root,
4292
+ label: sel.label,
4293
+ branch: sel.branch,
4294
+ discoveredId: sel.id,
4295
+ });
4296
+ return;
4297
+ }
3320
4298
  if (typeof id === "number" && id > 0 && id !== editor.activeWindow()) {
3321
4299
  editor.setActiveWindow(id);
3322
4300
  }
@@ -3337,6 +4315,18 @@ editor.on("widget_event", (e) => {
3337
4315
  refreshOpenDialog();
3338
4316
  return;
3339
4317
  }
4318
+ if (e.event_type === "toggle" && e.widget_key === "worktree-show") {
4319
+ // The toggle widget reports the new checked state; route through
4320
+ // the shared flip so the Alt+T chord and the click stay in sync.
4321
+ toggleShowWorktrees();
4322
+ return;
4323
+ }
4324
+ if (e.event_type === "toggle" && e.widget_key === "hide-trivial") {
4325
+ // Same pattern as the worktree toggle: route the click through the
4326
+ // shared flip so the checkbox and the Alt+I chord stay in sync.
4327
+ toggleHideTrivial();
4328
+ return;
4329
+ }
3340
4330
  if (e.event_type === "activate" && e.widget_key === "stop") {
3341
4331
  enterConfirm("stop");
3342
4332
  return;
@@ -3349,44 +4339,47 @@ editor.on("widget_event", (e) => {
3349
4339
  enterConfirm("delete");
3350
4340
  return;
3351
4341
  }
3352
- if (e.event_type === "activate" && e.widget_key === "confirm-cancel") {
3353
- openDialog.pendingConfirm = null;
3354
- openPanel.update(buildOpenSpec());
4342
+ // Bulk action bar (Layout B) Stop / Archive / Delete over the
4343
+ // checkbox selection, plus Clear.
4344
+ if (e.event_type === "activate" && e.widget_key === "bulk-stop") {
4345
+ enterBulkConfirm("stop");
3355
4346
  return;
3356
4347
  }
3357
- if (e.event_type === "activate" && e.widget_key === "confirm-stop") {
3358
- openDialog.pendingConfirm = null;
3359
- stopSelectedSession();
3360
- if (openPanel) openPanel.update(buildOpenSpec());
4348
+ if (e.event_type === "activate" && e.widget_key === "bulk-archive") {
4349
+ enterBulkConfirm("archive");
3361
4350
  return;
3362
4351
  }
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);
4352
+ if (e.event_type === "activate" && e.widget_key === "bulk-delete") {
4353
+ enterBulkConfirm("delete");
4354
+ return;
4355
+ }
4356
+ if (e.event_type === "activate" && e.widget_key === "bulk-clear") {
4357
+ openDialog.selectedIds.clear();
3377
4358
  refreshOpenDialog();
4359
+ openPanel.setFocusKey("visit");
3378
4360
  return;
3379
4361
  }
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 };
4362
+ if (e.event_type === "activate" && e.widget_key === "confirm-cancel") {
4363
+ openDialog.pendingConfirm = null;
4364
+ openPanel.update(buildOpenSpec());
4365
+ return;
4366
+ }
4367
+ // Confirmed Stop / Archive / Delete single row or bulk batch.
4368
+ // The ids were captured into `pendingConfirm` by enterConfirm /
4369
+ // enterBulkConfirm; `runConfirmedAction` re-checks eligibility,
4370
+ // drives the in-flight markers, and triggers sync.
4371
+ if (
4372
+ e.event_type === "activate" &&
4373
+ (e.widget_key === "confirm-stop" ||
4374
+ e.widget_key === "confirm-archive" ||
4375
+ e.widget_key === "confirm-delete")
4376
+ ) {
4377
+ const confirm = openDialog.pendingConfirm;
4378
+ openDialog.pendingConfirm = null;
4379
+ if (confirm) {
4380
+ void runConfirmedAction(confirm.action, confirm.ids);
3387
4381
  }
3388
- void deleteConfirmedSession();
3389
- refreshOpenDialog();
4382
+ if (openPanel) openPanel.update(buildOpenSpec());
3390
4383
  return;
3391
4384
  }
3392
4385
  if (e.event_type === "cancel") {
@@ -3424,10 +4417,6 @@ function killSelected(): void {
3424
4417
  editor.setStatus("Orchestrator: select a session row first");
3425
4418
  return;
3426
4419
  }
3427
- if (id === 1) {
3428
- editor.setStatus("Orchestrator: cannot kill the base session");
3429
- return;
3430
- }
3431
4420
  if (id === editor.activeWindow()) {
3432
4421
  editor.setStatus(
3433
4422
  "Orchestrator: dive elsewhere first, then kill this session",