@fresh-editor/fresh-editor 0.3.4 → 0.3.6

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.
@@ -0,0 +1,1685 @@
1
+ /// <reference path="./lib/fresh.d.ts" />
2
+ //
3
+ // Orchestrator — multi-agent / multi-worktree session orchestration.
4
+ //
5
+ // MVP scope (`docs/internal/orchestrator-sessions-design.md`):
6
+ //
7
+ // - "Orchestrator: Open" opens a floating overlay prompt listing
8
+ // every session with its state column. Up/Down navigates,
9
+ // Enter dives into the selected session.
10
+ // - "Orchestrator: New Session" opens a single floating widget
11
+ // form with three optional fields (session name, agent
12
+ // command, branch), allocates a worktree-rooted session and
13
+ // spawns the agent in a terminal attached to it.
14
+ // - "Orchestrator: Kill Selected" closes the session whose row is
15
+ // currently highlighted in the open prompt.
16
+ // - Agent state column updates from terminal_output regex and
17
+ // terminal_exit code: RUNNING / AWAITING / READY / ERRORED.
18
+
19
+ import {
20
+ button,
21
+ col,
22
+ flexSpacer,
23
+ FloatingWidgetPanel,
24
+ hintBar,
25
+ key as widgetKey,
26
+ labeledSection,
27
+ list,
28
+ row,
29
+ spacer,
30
+ styledRow,
31
+ text,
32
+ textInputChar,
33
+ windowEmbed,
34
+ type WidgetSpec,
35
+ } from "./lib/widgets.ts";
36
+
37
+ const editor = getEditor();
38
+
39
+ // =============================================================================
40
+ // Types
41
+ // =============================================================================
42
+
43
+ type AgentState = "running" | "awaiting" | "ready" | "errored" | "killed";
44
+
45
+ interface AgentSession {
46
+ // Editor's stable session id.
47
+ id: number;
48
+ // Display label (defaults to root basename — Orchestrator never
49
+ // renames externally-created sessions).
50
+ label: string;
51
+ // Absolute filesystem root.
52
+ root: string;
53
+ // The terminal id Orchestrator spawned in this session, if any.
54
+ terminalId: number | null;
55
+ // Last parsed agent state. "active" is computed at render
56
+ // time from `editor.activeWindow()`, not stored.
57
+ state: AgentState;
58
+ // Wall-clock ms when orchestrator.new fired createWindow.
59
+ createdAt: number;
60
+ }
61
+
62
+ // =============================================================================
63
+ // Module state — editor-global, survives every dive.
64
+ // =============================================================================
65
+
66
+ const orchestratorSessions = new Map<number, AgentSession>();
67
+
68
+ // Pending session-creation intent. Stashed across the
69
+ // async `createWindow → window_created hook` handoff so the
70
+ // hook handler can attach the spawned terminal. (Internally
71
+ // the editor calls these "windows"; Orchestrator still presents
72
+ // them as "sessions" in its UX.)
73
+ let pendingNewSession:
74
+ | { label: string; branch: string; cmd: string; root: string }
75
+ | null = null;
76
+
77
+ // New-session form state. `null` ⇒ the floating form isn't
78
+ // open. Each field's `value` + `cursor` mirrors what the host
79
+ // renders inside the panel's TextInput widgets; the `submitting`
80
+ // flag debounces double-Enter on the Create button; `lastError`
81
+ // is rendered as a styled error row inside the form when the
82
+ // most recent submit failed (status bar would get clobbered —
83
+ // see MEMORY.md).
84
+ interface NewSessionForm {
85
+ name: { value: string; cursor: number };
86
+ cmd: { value: string; cursor: number };
87
+ branch: { value: string; cursor: number };
88
+ submitting: boolean;
89
+ lastError: string | null;
90
+ // Display-only "my_org/project_name" rendered in the
91
+ // dialog's subtitle. Computed once at openForm time from the
92
+ // current cwd so we don't subprocess on every render.
93
+ projectLabel: string;
94
+ // Resolved default branch (e.g. "origin/main"). Empty while
95
+ // the async `git fetch + symbolic-ref` probe is in flight;
96
+ // the branch input's placeholder reads this so the user sees
97
+ // the exact base ref the worktree will fork off if they
98
+ // leave the field blank.
99
+ defaultBranch: string;
100
+ }
101
+ let form: NewSessionForm | null = null;
102
+ let formPanel: FloatingWidgetPanel | null = null;
103
+
104
+ const NEW_SESSION_MODE = "orchestrator-new-form";
105
+
106
+ // Open dialog state. `null` ⇒ the picker isn't mounted. Lives
107
+ // alongside the new-session form state but is independent of
108
+ // it — the two dialogs share the orchestrator mode plumbing but
109
+ // not their data.
110
+ interface OpenDialogState {
111
+ // Filter input value + cursor byte. Mirrors what the host
112
+ // renders inside the panel's filter TextInput.
113
+ filter: { value: string; cursor: number };
114
+ // Subset of `orchestratorSessions` keys that pass the filter,
115
+ // in display order. Recomputed on every filter change.
116
+ filteredIds: number[];
117
+ // The selection inside the list widget. The host owns the
118
+ // authoritative copy as instance state; this mirror lets
119
+ // `buildOpenSpec` render the matching preview pane without a
120
+ // round-trip.
121
+ selectedIndex: number;
122
+ // Active session at the moment the dialog opened. Recorded
123
+ // so a future "Esc restores active" affordance has the
124
+ // anchor it needs.
125
+ originalActiveSession: number;
126
+ // When non-null, the preview pane swaps to a confirmation
127
+ // panel for the named action against the named session id.
128
+ // Cleared on Cancel or after the action completes.
129
+ pendingConfirm: { action: "delete"; sessionId: number } | null;
130
+ // Rows the embed reserves and rows the sessions list shows.
131
+ // Captured once at dialog-open from the editor's viewport so
132
+ // the layout stays constant across re-renders — recomputing
133
+ // mid-dialog would let the size jitter when the active
134
+ // window's viewport changes (e.g. terminal buffer's shorter
135
+ // height vs. a file buffer's).
136
+ listVisibleRows: number;
137
+ embedRows: number;
138
+ }
139
+ let openDialog: OpenDialogState | null = null;
140
+ let openPanel: FloatingWidgetPanel | null = null;
141
+ const OPEN_MODE = "orchestrator-open";
142
+
143
+ // =============================================================================
144
+ // Session-list reconciliation
145
+ // =============================================================================
146
+
147
+ function reconcileSessions(): void {
148
+ const editorSessions = editor.listWindows();
149
+ const seen = new Set<number>();
150
+ for (const s of editorSessions) {
151
+ seen.add(s.id);
152
+ const existing = orchestratorSessions.get(s.id);
153
+ if (!existing) {
154
+ orchestratorSessions.set(s.id, {
155
+ id: s.id,
156
+ label: s.label,
157
+ root: s.root,
158
+ terminalId: null,
159
+ // The base session has no agent; everything else
160
+ // defaults to "running" until a terminal_output /
161
+ // terminal_exit arrives.
162
+ state: "running",
163
+ createdAt: Date.now(),
164
+ });
165
+ } else {
166
+ existing.label = s.label;
167
+ existing.root = s.root;
168
+ }
169
+ }
170
+ for (const id of orchestratorSessions.keys()) {
171
+ if (!seen.has(id)) orchestratorSessions.delete(id);
172
+ }
173
+ }
174
+
175
+ // =============================================================================
176
+ // Session display helpers
177
+ // =============================================================================
178
+
179
+ const STATE_GLYPH: Record<AgentState, string> = {
180
+ running: "RUN ",
181
+ awaiting: "WAIT",
182
+ ready: "DONE",
183
+ errored: "ERR ",
184
+ killed: "KILL",
185
+ };
186
+
187
+ function ageString(createdAt: number): string {
188
+ const sec = Math.max(0, Math.floor((Date.now() - createdAt) / 1000));
189
+ if (sec < 60) return `${sec}s`;
190
+ if (sec < 3600) return `${Math.floor(sec / 60)}m`;
191
+ return `${Math.floor(sec / 3600)}h`;
192
+ }
193
+
194
+ // =============================================================================
195
+ // Open dialog — widget-based session picker (Phase 1 of the
196
+ // open-dialog redesign; see docs/internal/
197
+ // orchestrator-open-dialog-and-lifecycle.md).
198
+ //
199
+ // Dive is the only action the dialog wires up directly. Other
200
+ // lifecycle commands (Stop / Archive / Delete / New) ship in
201
+ // later phases. New session is still reachable through the
202
+ // "Orchestrator: New Session" palette command in the meantime.
203
+ // =============================================================================
204
+
205
+ // Case-insensitive substring match over a session's label and
206
+ // root path. Ordering: prefix-of-label hits beat substring hits,
207
+ // then ties broken by label length so shorter matches surface
208
+ // first. Empty needle returns the full list in numeric-id order.
209
+ function filterSessions(needle: string): number[] {
210
+ reconcileSessions();
211
+ const ids = Array.from(orchestratorSessions.keys()).sort((a, b) => a - b);
212
+ if (!needle) return ids;
213
+ const n = needle.toLowerCase();
214
+ type Scored = { id: number; score: number; len: number };
215
+ const matches: Scored[] = [];
216
+ for (const id of ids) {
217
+ const s = orchestratorSessions.get(id)!;
218
+ const label = s.label.toLowerCase();
219
+ const root = s.root.toLowerCase();
220
+ if (label.startsWith(n)) {
221
+ matches.push({ id, score: 0, len: label.length });
222
+ } else if (label.includes(n)) {
223
+ matches.push({ id, score: 1, len: label.length });
224
+ } else if (root.includes(n)) {
225
+ matches.push({ id, score: 2, len: label.length });
226
+ }
227
+ }
228
+ matches.sort((a, b) => a.score - b.score || a.len - b.len || a.id - b.id);
229
+ return matches.map((m) => m.id);
230
+ }
231
+
232
+ // Build one rendered list-item row for `id`. Style cues:
233
+ // * `[id]` in `ui.help_key_fg`
234
+ // * `ACT` (active session) in `ui.tab_active_fg` + bold
235
+ // * other states use the default fg
236
+ // * label in default fg
237
+ function renderListItem(id: number, activeId: number): TextPropertyEntry {
238
+ const s = orchestratorSessions.get(id);
239
+ if (!s) {
240
+ return styledRow([{ text: `[${id}] (unknown)` }]);
241
+ }
242
+ const isActive = id === activeId;
243
+ const stateText = isActive ? "ACT " : STATE_GLYPH[s.state];
244
+ return styledRow([
245
+ { text: `[${id}] `, style: { fg: "ui.help_key_fg" } },
246
+ {
247
+ text: stateText,
248
+ style: isActive
249
+ ? { fg: "ui.tab_active_fg", bold: true }
250
+ : { fg: "ui.menu_disabled_fg" },
251
+ },
252
+ { text: ` ${s.label}` },
253
+ ]);
254
+ }
255
+
256
+ // Preview-pane content for the currently selected session.
257
+ // Plain info for Phase 1; later phases append pgid/pids + the
258
+ // last terminal lines.
259
+ function buildPreviewEntries(
260
+ s: AgentSession | undefined,
261
+ ): TextPropertyEntry[] {
262
+ if (!s) {
263
+ return [
264
+ styledRow([
265
+ {
266
+ text: "No session selected",
267
+ style: { fg: "editor.whitespace_indicator_fg", italic: true },
268
+ },
269
+ ]),
270
+ ];
271
+ }
272
+ const activeId = editor.activeWindow();
273
+ const isActive = s.id === activeId;
274
+ const stateText = isActive ? "ACT" : STATE_GLYPH[s.state].trim();
275
+ return [
276
+ styledRow([
277
+ {
278
+ text: stateText,
279
+ style: isActive
280
+ ? { fg: "ui.tab_active_fg", bold: true }
281
+ : { fg: "ui.menu_disabled_fg" },
282
+ },
283
+ { text: " " },
284
+ { text: ageString(s.createdAt), style: { fg: "ui.menu_disabled_fg" } },
285
+ ]),
286
+ styledRow([
287
+ { text: s.root, style: { fg: "ui.menu_disabled_fg" } },
288
+ ]),
289
+ ];
290
+ }
291
+
292
+ // Approximate number of session rows the picker's list pane
293
+ // should show. Derived from the active buffer's viewport so the
294
+ // picker's row(list, preview) fills the panel and the hint bar
295
+ // sits flush at the panel's last row. Conservative — leaves
296
+ // room for header, filter input, footer, and section borders.
297
+ function openListVisibleRows(): number {
298
+ const vp = editor.getViewport();
299
+ const h = vp ? vp.height : 30;
300
+ const panelH = Math.floor(h * 0.9);
301
+ // header (1) + spacer (1) + filter section (3) + sessions
302
+ // section borders (2) + hint bar (1) = 8 rows of chrome.
303
+ // Floor at 4 so a tiny terminal still shows something.
304
+ return Math.max(4, panelH - 8);
305
+ }
306
+
307
+ // Compose the right-hand preview pane. Normally it shows info
308
+ // + action buttons (Stop, Archive, Delete); when a destructive
309
+ // action is pending confirmation it swaps to a "Confirm
310
+ // <action>?" panel with [ Confirm <action> ] / [ Cancel ]
311
+ // buttons. Cancel is default-focused for safety.
312
+ function buildPreviewPane(s: AgentSession | undefined): WidgetSpec {
313
+ if (openDialog?.pendingConfirm && s && openDialog.pendingConfirm.sessionId === s.id) {
314
+ const action = openDialog.pendingConfirm.action;
315
+ if (action === "delete") {
316
+ return labeledSection({
317
+ label: "Confirm Delete",
318
+ child: col(
319
+ {
320
+ kind: "raw",
321
+ entries: [
322
+ styledRow([
323
+ {
324
+ text: `Delete session [${s.id}] ${s.label}?`,
325
+ style: { bold: true },
326
+ },
327
+ ]),
328
+ styledRow([{ text: "" }]),
329
+ styledRow([{ text: "This will:" }]),
330
+ styledRow([{ text: " • stop all session processes" }]),
331
+ styledRow([{ text: " • run `git worktree remove`" }]),
332
+ styledRow([{ text: " • drop the session record" }]),
333
+ styledRow([{ text: "" }]),
334
+ styledRow([
335
+ {
336
+ text: "Uncommitted changes will be lost.",
337
+ style: {
338
+ fg: "ui.status_error_indicator_fg",
339
+ bold: true,
340
+ },
341
+ },
342
+ ]),
343
+ ],
344
+ },
345
+ spacer(0),
346
+ row(
347
+ flexSpacer(),
348
+ button("Cancel", { key: "confirm-cancel" }),
349
+ spacer(2),
350
+ button("Confirm Delete", {
351
+ intent: "danger",
352
+ key: "confirm-delete",
353
+ }),
354
+ ),
355
+ ),
356
+ });
357
+ }
358
+ }
359
+ // The embed reserves the bulk of the preview pane so the host
360
+ // can paint the selected window's live UI (splits, terminals,
361
+ // syntax highlighting) underneath the action button row. The
362
+ // row count is captured at dialog-open into `openDialog` and
363
+ // used verbatim across re-renders, so the preview pane stays
364
+ // a constant height regardless of which window's viewport the
365
+ // host happens to be reporting through `getViewport()`.
366
+ const embedRows = openDialog?.embedRows ?? 4;
367
+ return labeledSection({
368
+ label: s ? `[${s.id}] ${s.label}` : "Preview",
369
+ child: col(
370
+ row(
371
+ flexSpacer(),
372
+ button("Stop", { key: "stop" }),
373
+ spacer(2),
374
+ button("Archive", { key: "archive" }),
375
+ spacer(2),
376
+ button("Delete", { intent: "danger", key: "delete" }),
377
+ ),
378
+ spacer(0),
379
+ { kind: "raw", entries: buildPreviewEntries(s) },
380
+ spacer(0),
381
+ // Live window render. `windowId: 0` (no session selected)
382
+ // is a no-op on the host side — the embed reserves the
383
+ // rows but no paint happens.
384
+ windowEmbed({
385
+ windowId: s ? s.id : 0,
386
+ rows: embedRows,
387
+ key: "live-preview",
388
+ }),
389
+ ),
390
+ });
391
+ }
392
+
393
+ function buildOpenSpec(): WidgetSpec {
394
+ if (!openDialog) return col();
395
+ const filtered = openDialog.filteredIds;
396
+ const activeId = editor.activeWindow();
397
+ const items = filtered.map((id) => renderListItem(id, activeId));
398
+ const itemKeys = filtered.map(String);
399
+ const selIdx = filtered.length === 0
400
+ ? -1
401
+ : Math.max(0, Math.min(openDialog.selectedIndex, filtered.length - 1));
402
+ const selectedId = selIdx >= 0 ? filtered[selIdx] : -1;
403
+ const selectedSession = selectedId > 0
404
+ ? orchestratorSessions.get(selectedId)
405
+ : undefined;
406
+
407
+ return col(
408
+ {
409
+ kind: "raw",
410
+ entries: [
411
+ styledRow([
412
+ {
413
+ text: "ORCHESTRATOR :: Sessions",
414
+ style: { fg: "ui.popup_border_fg", bold: true },
415
+ },
416
+ ]),
417
+ ],
418
+ },
419
+ spacer(0),
420
+ labeledSection({
421
+ label: "Filter",
422
+ child: text({
423
+ value: openDialog.filter.value,
424
+ cursorByte: openDialog.filter.cursor,
425
+ placeholder: "type to filter…",
426
+ fullWidth: true,
427
+ key: "filter",
428
+ }),
429
+ }),
430
+ // Two-pane: sessions list | preview. Renderer's `row()`
431
+ // horizontally zips multi-line children so this composes
432
+ // the wireframed shape directly. Width split 25 / 75 —
433
+ // the preview pane carries the action buttons and the
434
+ // (Phase 7) live-window render, so it earns the bulk of
435
+ // the dialog.
436
+ row(
437
+ labeledSection({
438
+ label: `Sessions (${filtered.length})`,
439
+ widthPct: 25,
440
+ child: list({
441
+ items,
442
+ itemKeys,
443
+ selectedIndex: selIdx,
444
+ visibleRows: openDialog.listVisibleRows,
445
+ // Excluded from the Tab cycle — Up/Down on the
446
+ // filter input forwards to this list via host
447
+ // smart-keys, so Tab jumps straight to the action
448
+ // buttons instead of stopping here.
449
+ focusable: false,
450
+ key: "sessions",
451
+ }),
452
+ }),
453
+ // Preview pane has no explicit width — picks up the
454
+ // remaining 75% by default since the sessions list took
455
+ // 25%.
456
+ buildPreviewPane(selectedSession),
457
+ ),
458
+ row(
459
+ flexSpacer(),
460
+ hintBar([
461
+ { keys: "↑↓", label: "nav" },
462
+ { keys: "Enter", label: "dive" },
463
+ { keys: "Tab", label: "focus" },
464
+ { keys: "Esc", label: "close" },
465
+ ]),
466
+ flexSpacer(),
467
+ syncIndicator(),
468
+ ),
469
+ );
470
+ }
471
+
472
+ // Tiny status glyph rendered at the trailing edge of the
473
+ // footer. `↻` while a push is in flight, `⤒` when the last
474
+ // push failed (with the error in the tooltip — for now, just a
475
+ // status-bar setStatus on focus), and an empty entry otherwise
476
+ // so the layout stays put.
477
+ function syncIndicator(): WidgetSpec {
478
+ let glyph = "";
479
+ let style: { fg?: string; italic?: boolean } | undefined;
480
+ switch (syncStatus) {
481
+ case "syncing":
482
+ glyph = " ↻ ";
483
+ style = { fg: "editor.whitespace_indicator_fg" };
484
+ break;
485
+ case "error":
486
+ glyph = " ⤒ ";
487
+ style = { fg: "ui.status_error_indicator_fg" };
488
+ break;
489
+ default:
490
+ glyph = " ";
491
+ }
492
+ return {
493
+ kind: "raw",
494
+ entries: [styledRow([{ text: glyph, style }])],
495
+ };
496
+ }
497
+
498
+ function refreshOpenDialog(): void {
499
+ if (!openPanel || !openDialog) return;
500
+ openDialog.filteredIds = filterSessions(openDialog.filter.value);
501
+ // Clamp the selection into range so a fresh filter or a
502
+ // session vanishing under us doesn't leave us pointing past
503
+ // the end of the list.
504
+ if (openDialog.filteredIds.length === 0) {
505
+ openDialog.selectedIndex = 0;
506
+ } else if (openDialog.selectedIndex >= openDialog.filteredIds.length) {
507
+ openDialog.selectedIndex = openDialog.filteredIds.length - 1;
508
+ } else if (openDialog.selectedIndex < 0) {
509
+ openDialog.selectedIndex = 0;
510
+ }
511
+ openPanel.update(buildOpenSpec());
512
+ // The list widget's `selectedIndex` in the spec is initial-only;
513
+ // pin it via mutation so re-renders don't snap back to 0.
514
+ if (openDialog.filteredIds.length > 0) {
515
+ openPanel.setSelectedIndex("sessions", openDialog.selectedIndex);
516
+ }
517
+ }
518
+
519
+ function openControlRoom(): void {
520
+ if (openPanel) return;
521
+ reconcileSessions();
522
+ const activeId = editor.activeWindow();
523
+ const ids = Array.from(orchestratorSessions.keys()).sort((a, b) => a - b);
524
+ const activeIdx = ids.indexOf(activeId);
525
+ const listVisibleRows = openListVisibleRows();
526
+ openDialog = {
527
+ filter: { value: "", cursor: 0 },
528
+ filteredIds: ids,
529
+ selectedIndex: activeIdx >= 0 ? activeIdx : 0,
530
+ originalActiveSession: activeId,
531
+ pendingConfirm: null,
532
+ listVisibleRows,
533
+ // Mirror buildPreviewPane's chrome: 1 button row + 1 spacer
534
+ // + 2 info rows + 1 spacer = 4 rows reserved above the embed.
535
+ embedRows: Math.max(4, listVisibleRows - 4),
536
+ };
537
+ openPanel = new FloatingWidgetPanel();
538
+ // 90% × 90% of the terminal — the open dialog wants room for
539
+ // a real session list + preview pane, unlike the new-session
540
+ // form which stays compact.
541
+ openPanel.mount(buildOpenSpec(), { widthPct: 90, heightPct: 90 });
542
+ if (openDialog.filteredIds.length > 0) {
543
+ openPanel.setSelectedIndex("sessions", openDialog.selectedIndex);
544
+ }
545
+ editor.setEditorMode(OPEN_MODE);
546
+ }
547
+
548
+ function closeOpenDialog(): void {
549
+ if (openPanel) {
550
+ openPanel.unmount();
551
+ openPanel = null;
552
+ }
553
+ openDialog = null;
554
+ editor.setEditorMode(null);
555
+ }
556
+
557
+ // Stop every process the highlighted session owns. Sends
558
+ // SIGTERM first via the host's `signalWindow` (which fans
559
+ // out through the window's process-group tracker), then
560
+ // follows up with SIGKILL after a short grace period so
561
+ // ill-behaved agents that ignore SIGTERM still get reaped.
562
+ // The session record stays put — Stop only kills processes,
563
+ // it doesn't touch the worktree or the editor session.
564
+ function stopSelectedSession(): void {
565
+ if (!openDialog) return;
566
+ const id = openDialog.filteredIds[openDialog.selectedIndex];
567
+ if (typeof id !== "number" || id <= 0) return;
568
+ if (id === 1) {
569
+ editor.setStatus("Orchestrator: cannot stop the base session");
570
+ return;
571
+ }
572
+ editor.signalWindow(id, "SIGTERM");
573
+ // SIGKILL fallback for agents that ignore SIGTERM. The
574
+ // host's signalWindow is idempotent on already-exited
575
+ // process groups, so the second call is safe whether or
576
+ // not the first one took.
577
+ setTimeout(() => {
578
+ editor.signalWindow(id, "SIGKILL");
579
+ }, 2000);
580
+ editor.setStatus(`Orchestrator: stop signal sent to session [${id}]`);
581
+ }
582
+
583
+ // ---------------------------------------------------------------------
584
+ // Archive manifest — `<XDG>/orchestrator/<repo-slug>/archived.json`.
585
+ // Records sessions that have been archived (stopped + worktree moved
586
+ // to `.archived/`). Used today by the Archive action; Unarchive and
587
+ // "Show archived" surface in a follow-up phase.
588
+ // ---------------------------------------------------------------------
589
+
590
+ interface ArchivedSession {
591
+ label: string;
592
+ /** Current path of the moved worktree, under `.archived/`. */
593
+ root: string;
594
+ /** Path the worktree lived at before archiving. */
595
+ original_root: string;
596
+ /** Branch the worktree was on. */
597
+ branch: string;
598
+ /** ISO 8601 timestamp of when the session was archived. */
599
+ archived_at: string;
600
+ }
601
+
602
+ interface ArchiveManifest {
603
+ version: number;
604
+ sessions: ArchivedSession[];
605
+ }
606
+
607
+ function archiveManifestPath(repoRoot: string): string {
608
+ return editor.pathJoin(
609
+ editor.getDataDir(),
610
+ "orchestrator",
611
+ slugify(repoRoot),
612
+ "archived.json",
613
+ );
614
+ }
615
+
616
+ function loadArchiveManifest(repoRoot: string): ArchiveManifest {
617
+ const path = archiveManifestPath(repoRoot);
618
+ const raw = editor.readFile(path);
619
+ if (!raw) return { version: 1, sessions: [] };
620
+ try {
621
+ const parsed = JSON.parse(raw);
622
+ if (
623
+ parsed && typeof parsed === "object" &&
624
+ Array.isArray(parsed.sessions)
625
+ ) {
626
+ return parsed as ArchiveManifest;
627
+ }
628
+ } catch (_) {
629
+ // Fall through to fresh manifest — bad data shouldn't
630
+ // brick the dialog.
631
+ }
632
+ return { version: 1, sessions: [] };
633
+ }
634
+
635
+ function saveArchiveManifest(repoRoot: string, m: ArchiveManifest): boolean {
636
+ const path = archiveManifestPath(repoRoot);
637
+ const dir = editor.pathDirname(path);
638
+ if (!editor.createDir(dir)) return false;
639
+ return editor.writeFile(path, JSON.stringify(m, null, 2));
640
+ }
641
+
642
+ // Archive flow: stop all processes (SIGKILL — archive is a
643
+ // "I'm done with this for now" action, no graceful teardown
644
+ // needed since the worktree stays on disk), close the editor
645
+ // session, move the worktree to the `.archived/` graveyard,
646
+ // and append a manifest entry so a future Unarchive flow can
647
+ // reverse it.
648
+ async function archiveSelectedSession(): Promise<void> {
649
+ if (!openDialog) return;
650
+ const id = openDialog.filteredIds[openDialog.selectedIndex];
651
+ if (typeof id !== "number" || id <= 0) return;
652
+ if (id === 1) {
653
+ editor.setStatus("Orchestrator: cannot archive the base session");
654
+ return;
655
+ }
656
+ if (id === editor.activeWindow()) {
657
+ editor.setStatus(
658
+ "Orchestrator: dive elsewhere first, then archive this session",
659
+ );
660
+ return;
661
+ }
662
+ const session = orchestratorSessions.get(id);
663
+ if (!session) return;
664
+
665
+ // Resolve the repo root from cwd (the user is in the
666
+ // umbrella session's tree).
667
+ const cwd = editor.getCwd();
668
+ const top = await spawnCollect(
669
+ "git",
670
+ ["rev-parse", "--show-toplevel"],
671
+ cwd,
672
+ );
673
+ if (top.exit_code !== 0) {
674
+ editor.setStatus("Orchestrator: archive failed — not a git repository");
675
+ return;
676
+ }
677
+ const repoRoot = (top.stdout || "").trim();
678
+
679
+ // SIGKILL the session's process group so the pty children
680
+ // release any locks on the worktree, then close the editor
681
+ // session. closeWindow already kills the pty via the child
682
+ // killer; signaling first via the window-level pg tracker
683
+ // catches stray subprocesses outside the pty.
684
+ editor.signalWindow(id, "SIGKILL");
685
+ editor.closeWindow(id);
686
+
687
+ // Brief settle so the filesystem reflects the pty's exit
688
+ // before we move the worktree out from under it.
689
+ await new Promise((r) => setTimeout(r, 250));
690
+
691
+ // git worktree move keeps git's internal bookkeeping
692
+ // consistent (the new path stays registered as a worktree).
693
+ const archivedRoot = editor.pathJoin(
694
+ editor.getDataDir(),
695
+ "orchestrator",
696
+ slugify(repoRoot),
697
+ ".archived",
698
+ session.label,
699
+ );
700
+ const parent = editor.pathDirname(archivedRoot);
701
+ if (!editor.createDir(parent)) {
702
+ editor.setStatus(
703
+ `Orchestrator: archive failed — could not create ${parent}`,
704
+ );
705
+ return;
706
+ }
707
+ const moveRes = await spawnCollect(
708
+ "git",
709
+ ["-C", repoRoot, "worktree", "move", session.root, archivedRoot],
710
+ repoRoot,
711
+ );
712
+ if (moveRes.exit_code !== 0) {
713
+ editor.setStatus(
714
+ `Orchestrator: worktree move failed: ${
715
+ lastNonEmptyLine(moveRes.stderr) || "unknown error"
716
+ }`,
717
+ );
718
+ return;
719
+ }
720
+
721
+ // Append manifest entry. The branch info is best-effort:
722
+ // we assume Orchestrator's convention of branch==label (set in
723
+ // the new-session form) until a session knows its branch
724
+ // separately.
725
+ const manifest = loadArchiveManifest(repoRoot);
726
+ manifest.sessions.push({
727
+ label: session.label,
728
+ root: archivedRoot,
729
+ original_root: session.root,
730
+ branch: session.label,
731
+ archived_at: new Date().toISOString(),
732
+ });
733
+ if (!saveArchiveManifest(repoRoot, manifest)) {
734
+ editor.setStatus(
735
+ "Orchestrator: archived, but failed to write archived.json",
736
+ );
737
+ } else {
738
+ editor.setStatus(`Orchestrator: archived [${id}] ${session.label}`);
739
+ }
740
+ triggerSyncAsync(repoRoot);
741
+ }
742
+
743
+ // ---------------------------------------------------------------------
744
+ // Cross-machine recovery (Phase 6)
745
+ //
746
+ // Every lifecycle action that mutates the local archive manifest also
747
+ // fires an asynchronous push to `refs/heads/<user>/fresh-sessions` on
748
+ // origin so the same sessions can be recovered on another machine.
749
+ // The push runs in the background and never blocks the user-visible
750
+ // action; failures get surfaced through `syncStatus` (and a small ⤒
751
+ // glyph in the dialog footer when the error is fresh).
752
+ //
753
+ // The branch is orphan-style: a single root file `sessions.json` and
754
+ // commits with the sessions snapshot. We maintain it through a
755
+ // dedicated worktree at `<XDG>/orchestrator/.sync-workspace` so we don't
756
+ // disturb the user's normal `git worktree` set.
757
+ // ---------------------------------------------------------------------
758
+
759
+ type SyncStatus = "idle" | "syncing" | "error";
760
+ let syncStatus: SyncStatus = "idle";
761
+ let syncError: string | null = null;
762
+
763
+ function deriveSyncUser(): string {
764
+ // Priority order documented in
765
+ // docs/internal/orchestrator-open-dialog-and-lifecycle.md.
766
+ const envOverride = editor.getEnv("FRESH_SESSIONS_USER");
767
+ if (envOverride && envOverride.trim()) return envOverride.trim();
768
+ const localPart = (envEmailLocalPart() || "").trim();
769
+ if (localPart) return localPart;
770
+ const u = editor.getEnv("USER");
771
+ if (u && u.trim()) return u.trim();
772
+ return "fresh";
773
+ }
774
+
775
+ function envEmailLocalPart(): string | null {
776
+ // Best-effort sync read of git config user.email's local-part.
777
+ // Reading from env first (since spawnProcess is async) keeps
778
+ // deriveSyncUser synchronous; users with no env override will
779
+ // probably have `$USER` available as fallback.
780
+ const email = editor.getEnv("GIT_AUTHOR_EMAIL") ||
781
+ editor.getEnv("EMAIL");
782
+ if (!email) return null;
783
+ const at = email.indexOf("@");
784
+ return at > 0 ? email.slice(0, at) : null;
785
+ }
786
+
787
+ function syncWorkspacePath(): string {
788
+ return editor.pathJoin(editor.getDataDir(), "orchestrator", ".sync-workspace");
789
+ }
790
+
791
+ // Fire-and-forget sync. Never blocks the caller; updates
792
+ // `syncStatus`/`syncError` and refreshes the dialog (if open)
793
+ // so the footer indicator can reflect the result.
794
+ function triggerSyncAsync(repoRoot: string): void {
795
+ void (async () => {
796
+ syncStatus = "syncing";
797
+ if (openPanel) refreshOpenDialog();
798
+ const result = await syncSessions(repoRoot);
799
+ if (result.ok) {
800
+ syncStatus = "idle";
801
+ syncError = null;
802
+ } else {
803
+ syncStatus = "error";
804
+ syncError = result.err ?? "unknown error";
805
+ }
806
+ if (openPanel) refreshOpenDialog();
807
+ })();
808
+ }
809
+
810
+ interface SyncResult {
811
+ ok: boolean;
812
+ err?: string;
813
+ }
814
+
815
+ async function syncSessions(repoRoot: string): Promise<SyncResult> {
816
+ const user = deriveSyncUser();
817
+ const branch = `${user}/fresh-sessions`;
818
+ const wt = syncWorkspacePath();
819
+
820
+ // Ensure the sync worktree exists and is on the right branch.
821
+ // First-time setup creates the worktree as an orphan branch
822
+ // with no parent commit (cleanest history; no leftover files
823
+ // from the original tree).
824
+ if (!editor.createDir(editor.pathDirname(wt))) {
825
+ return { ok: false, err: "createDir failed for sync workspace parent" };
826
+ }
827
+ const branchExists = await spawnCollect(
828
+ "git",
829
+ ["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
830
+ repoRoot,
831
+ );
832
+ const wtExists = await spawnCollect(
833
+ "git",
834
+ ["-C", repoRoot, "worktree", "list", "--porcelain"],
835
+ repoRoot,
836
+ );
837
+ const wtAlreadyTracked = wtExists.exit_code === 0 &&
838
+ wtExists.stdout.includes(wt);
839
+
840
+ if (!wtAlreadyTracked) {
841
+ if (branchExists.exit_code === 0) {
842
+ const addRes = await spawnCollect(
843
+ "git",
844
+ ["-C", repoRoot, "worktree", "add", wt, branch],
845
+ repoRoot,
846
+ );
847
+ if (addRes.exit_code !== 0) {
848
+ return { ok: false, err: lastNonEmptyLine(addRes.stderr) };
849
+ }
850
+ } else {
851
+ // Create an orphan worktree by adding detached then
852
+ // switching to a new orphan branch.
853
+ const addRes = await spawnCollect(
854
+ "git",
855
+ ["-C", repoRoot, "worktree", "add", "--detach", wt, "HEAD"],
856
+ repoRoot,
857
+ );
858
+ if (addRes.exit_code !== 0) {
859
+ return { ok: false, err: lastNonEmptyLine(addRes.stderr) };
860
+ }
861
+ const orphanRes = await spawnCollect(
862
+ "git",
863
+ ["-C", wt, "checkout", "--orphan", branch],
864
+ wt,
865
+ );
866
+ if (orphanRes.exit_code !== 0) {
867
+ return { ok: false, err: lastNonEmptyLine(orphanRes.stderr) };
868
+ }
869
+ // Strip everything inherited from HEAD's tree so the
870
+ // orphan branch starts clean.
871
+ await spawnCollect("git", ["-C", wt, "rm", "-rf", "."], wt);
872
+ }
873
+ }
874
+
875
+ // Snapshot active + archived sessions into the JSON that
876
+ // lives at the root of the sync branch.
877
+ const snapshot = await buildSyncSnapshot(repoRoot);
878
+ const sessionsPath = editor.pathJoin(wt, "sessions.json");
879
+ if (!editor.writeFile(sessionsPath, JSON.stringify(snapshot, null, 2))) {
880
+ return { ok: false, err: "writeFile sessions.json failed" };
881
+ }
882
+
883
+ const addRes = await spawnCollect(
884
+ "git",
885
+ ["-C", wt, "add", "sessions.json"],
886
+ wt,
887
+ );
888
+ if (addRes.exit_code !== 0) {
889
+ return { ok: false, err: lastNonEmptyLine(addRes.stderr) };
890
+ }
891
+ // The commit may noop when nothing changed — git exits with
892
+ // 1 in that case, which we treat as success rather than an
893
+ // error.
894
+ const commitRes = await spawnCollect(
895
+ "git",
896
+ [
897
+ "-C",
898
+ wt,
899
+ "commit",
900
+ "--allow-empty-message",
901
+ "-m",
902
+ "Update sessions",
903
+ ],
904
+ wt,
905
+ );
906
+ if (commitRes.exit_code !== 0 && !commitRes.stdout.includes("nothing to commit")) {
907
+ // Permissive: stderr "nothing to commit" / "working tree clean"
908
+ // means there was nothing new to push. Skip the push and
909
+ // report success.
910
+ if (!commitRes.stderr.includes("nothing to commit")) {
911
+ // Other commit failures: report.
912
+ return { ok: false, err: lastNonEmptyLine(commitRes.stderr) };
913
+ }
914
+ }
915
+
916
+ const pushRes = await spawnCollect(
917
+ "git",
918
+ ["-C", wt, "push", "origin", branch],
919
+ wt,
920
+ );
921
+ if (pushRes.exit_code !== 0) {
922
+ return { ok: false, err: lastNonEmptyLine(pushRes.stderr) };
923
+ }
924
+ return { ok: true };
925
+ }
926
+
927
+ async function buildSyncSnapshot(repoRoot: string): Promise<unknown> {
928
+ const manifest = loadArchiveManifest(repoRoot);
929
+ return {
930
+ version: 1,
931
+ machine_id: editor.getEnv("HOSTNAME") || "unknown",
932
+ updated_at: new Date().toISOString(),
933
+ active: Array.from(orchestratorSessions.values()).map((s) => ({
934
+ label: s.label,
935
+ branch: s.label,
936
+ base_ref: "origin/master",
937
+ created_at: new Date(s.createdAt).toISOString(),
938
+ })),
939
+ archived: manifest.sessions,
940
+ };
941
+ }
942
+
943
+ // Delete flow: stop processes (SIGKILL), close the editor
944
+ // session, then `git worktree remove --force` to drop the
945
+ // worktree from disk. If the session was archived (manifest
946
+ // entry exists), the manifest entry is dropped too. No
947
+ // recovery after this point.
948
+ async function deleteConfirmedSession(): Promise<void> {
949
+ if (!openDialog || !openDialog.pendingConfirm) return;
950
+ const { sessionId: id } = openDialog.pendingConfirm;
951
+ openDialog.pendingConfirm = null;
952
+ const session = orchestratorSessions.get(id);
953
+ if (!session) {
954
+ if (openPanel) openPanel.update(buildOpenSpec());
955
+ return;
956
+ }
957
+ if (id === editor.activeWindow()) {
958
+ editor.setStatus(
959
+ "Orchestrator: dive elsewhere first, then delete this session",
960
+ );
961
+ if (openPanel) openPanel.update(buildOpenSpec());
962
+ return;
963
+ }
964
+
965
+ const cwd = editor.getCwd();
966
+ const top = await spawnCollect(
967
+ "git",
968
+ ["rev-parse", "--show-toplevel"],
969
+ cwd,
970
+ );
971
+ if (top.exit_code !== 0) {
972
+ editor.setStatus("Orchestrator: delete failed — not a git repository");
973
+ if (openPanel) openPanel.update(buildOpenSpec());
974
+ return;
975
+ }
976
+ const repoRoot = (top.stdout || "").trim();
977
+
978
+ editor.signalWindow(id, "SIGKILL");
979
+ editor.closeWindow(id);
980
+ await new Promise((r) => setTimeout(r, 250));
981
+
982
+ // `--force` because the worktree may have unstaged changes
983
+ // the user explicitly chose to discard via the confirm step.
984
+ const removeRes = await spawnCollect(
985
+ "git",
986
+ ["-C", repoRoot, "worktree", "remove", "--force", session.root],
987
+ repoRoot,
988
+ );
989
+ if (removeRes.exit_code !== 0) {
990
+ editor.setStatus(
991
+ `Orchestrator: worktree remove failed: ${
992
+ lastNonEmptyLine(removeRes.stderr) || "unknown error"
993
+ }`,
994
+ );
995
+ if (openPanel) openPanel.update(buildOpenSpec());
996
+ return;
997
+ }
998
+
999
+ // Drop the matching manifest entry too, in case the session
1000
+ // was already archived (delete-from-archived is the natural
1001
+ // way to drop dormant sessions).
1002
+ const manifest = loadArchiveManifest(repoRoot);
1003
+ const before = manifest.sessions.length;
1004
+ manifest.sessions = manifest.sessions.filter(
1005
+ (e) => e.label !== session.label,
1006
+ );
1007
+ if (manifest.sessions.length !== before) {
1008
+ saveArchiveManifest(repoRoot, manifest);
1009
+ }
1010
+
1011
+ editor.setStatus(`Orchestrator: deleted [${id}] ${session.label}`);
1012
+ if (openPanel) openPanel.update(buildOpenSpec());
1013
+ triggerSyncAsync(repoRoot);
1014
+ }
1015
+
1016
+ editor.defineMode(OPEN_MODE, [], true, true);
1017
+
1018
+ // =============================================================================
1019
+ // New-session floating form
1020
+ // =============================================================================
1021
+
1022
+ function slugify(p: string): string {
1023
+ // Drop any leading separator so the slug isn't anchored to the
1024
+ // filesystem root; replace remaining separators with underscores.
1025
+ return p.replace(/^[\\\/]+/, "").replace(/[\\\/]+/g, "_");
1026
+ }
1027
+
1028
+ function lastNonEmptyLine(s: string): string {
1029
+ const lines = (s || "").split(/\r?\n/).filter((l) => l.trim().length > 0);
1030
+ return lines.length ? lines[lines.length - 1].trim() : "";
1031
+ }
1032
+
1033
+ async function spawnCollect(
1034
+ command: string,
1035
+ args: string[],
1036
+ cwd: string,
1037
+ ): Promise<SpawnResult> {
1038
+ return await editor.spawnProcess(command, args, cwd);
1039
+ }
1040
+
1041
+ /// Resolve the origin's default branch as `"origin/<name>"` from
1042
+ /// the locally-cached symbolic-ref. Returns `"HEAD"` when there's
1043
+ /// no `origin` remote (purely-local repos) or the symbolic ref is
1044
+ /// missing — the caller treats that as the silent fallback.
1045
+ ///
1046
+ /// Deliberately does NOT fetch: `refs/remotes/origin/HEAD` is set
1047
+ /// at clone time and only changes when the remote renames its
1048
+ /// default branch (rare). A network round-trip per dialog open
1049
+ /// is too high a cost for that case.
1050
+ async function detectDefaultBranch(repoRoot: string): Promise<string> {
1051
+ const res = await spawnCollect(
1052
+ "git",
1053
+ ["-C", repoRoot, "symbolic-ref", "refs/remotes/origin/HEAD"],
1054
+ repoRoot,
1055
+ );
1056
+ if (res.exit_code === 0) {
1057
+ const trimmed = (res.stdout || "").trim();
1058
+ const prefix = "refs/remotes/";
1059
+ if (trimmed.startsWith(prefix)) {
1060
+ // e.g. "refs/remotes/origin/main" → "origin/main". This is
1061
+ // what the new worktree is forked off, so the user sees the
1062
+ // exact ref name they'd otherwise have to type by hand.
1063
+ return trimmed.slice(prefix.length);
1064
+ }
1065
+ }
1066
+ return "HEAD";
1067
+ }
1068
+
1069
+ function nextAutoSessionName(): string {
1070
+ // Persisted counter so consecutive empty submits produce
1071
+ // session-1, session-2, … even across plugin reloads.
1072
+ const counter = (editor.getGlobalState("orchestrator.session_counter") as
1073
+ | number
1074
+ | undefined) ?? 0;
1075
+ const next = counter + 1;
1076
+ editor.setGlobalState("orchestrator.session_counter", next);
1077
+ return `session-${next}`;
1078
+ }
1079
+
1080
+ // Three distinct styles for the header line: section keyword
1081
+ // ("ORCHESTRATOR"), structural separators ("::"), and step label. The
1082
+ // border-fg key picks up the same accent the floating panel border
1083
+ // uses, so the title visually anchors to the dialog chrome.
1084
+ const HEADER_KEYWORD_STYLE = {
1085
+ fg: "ui.popup_border_fg",
1086
+ bold: true,
1087
+ } as const;
1088
+ const HEADER_SEP_STYLE = { fg: "ui.menu_disabled_fg" } as const;
1089
+ const HEADER_LABEL_STYLE = { fg: "ui.menu_active_fg", bold: true } as const;
1090
+
1091
+ // Subtitle splits the static prefix "Project:" from the project
1092
+ // path so each gets its own foreground — matching the three-tier
1093
+ // (label / label-value / input) palette the design calls for.
1094
+ const SUBTITLE_LABEL_STYLE = { fg: "ui.menu_disabled_fg" } as const;
1095
+ const SUBTITLE_VALUE_STYLE = { fg: "ui.help_key_fg", bold: true } as const;
1096
+
1097
+ function buildFormSpec(): WidgetSpec {
1098
+ if (!form) return col();
1099
+ const children: WidgetSpec[] = [
1100
+ // === Header: title flanked by separators, centered. ==========
1101
+ row(
1102
+ flexSpacer(),
1103
+ {
1104
+ kind: "raw",
1105
+ entries: [
1106
+ styledRow([
1107
+ { text: "ORCHESTRATOR", style: HEADER_KEYWORD_STYLE },
1108
+ { text: " :: ", style: HEADER_SEP_STYLE },
1109
+ { text: "New Session Dialog", style: HEADER_LABEL_STYLE },
1110
+ { text: " :: ", style: HEADER_SEP_STYLE },
1111
+ { text: "Review Synthesized", style: HEADER_LABEL_STYLE },
1112
+ ]),
1113
+ ],
1114
+ },
1115
+ flexSpacer(),
1116
+ ),
1117
+ // === Subtitle: centered project identifier. ==================
1118
+ row(
1119
+ flexSpacer(),
1120
+ {
1121
+ kind: "raw",
1122
+ entries: [
1123
+ styledRow([
1124
+ { text: "Project: ", style: SUBTITLE_LABEL_STYLE },
1125
+ { text: form.projectLabel, style: SUBTITLE_VALUE_STYLE },
1126
+ ]),
1127
+ ],
1128
+ },
1129
+ flexSpacer(),
1130
+ ),
1131
+ spacer(0),
1132
+ // === Form body: three labeled, full-width inputs. ============
1133
+ labeledSection({
1134
+ label: "▸ Session Name",
1135
+ child: text({
1136
+ value: form.name.value,
1137
+ cursorByte: form.name.cursor,
1138
+ placeholder: "(auto-generated)",
1139
+ fullWidth: true,
1140
+ key: "name",
1141
+ }),
1142
+ }),
1143
+ labeledSection({
1144
+ label: "▸ Agent Command",
1145
+ child: text({
1146
+ value: form.cmd.value,
1147
+ cursorByte: form.cmd.cursor,
1148
+ // Empty submission spawns a bare terminal — the host
1149
+ // picks the shell with the same logic it uses for any
1150
+ // other embedded terminal, so the plugin doesn't have
1151
+ // to second-guess `$SHELL` resolution.
1152
+ placeholder: "terminal",
1153
+ fullWidth: true,
1154
+ key: "cmd",
1155
+ }),
1156
+ }),
1157
+ labeledSection({
1158
+ label: "▸ Branch",
1159
+ child: text({
1160
+ value: form.branch.value,
1161
+ cursorByte: form.branch.cursor,
1162
+ // Show the literal base ref the empty submission will
1163
+ // fork off (e.g. `origin/main`). While the probe runs
1164
+ // we still print a hint so the field isn't blank.
1165
+ placeholder: form.defaultBranch || "detecting default branch…",
1166
+ fullWidth: true,
1167
+ key: "branch",
1168
+ }),
1169
+ }),
1170
+ ];
1171
+ if (form.lastError) {
1172
+ children.push(spacer(0));
1173
+ children.push({
1174
+ kind: "raw",
1175
+ entries: [
1176
+ styledRow([
1177
+ {
1178
+ text: "Error: ",
1179
+ style: { fg: "ui.status_error_indicator_fg", bold: true },
1180
+ },
1181
+ { text: form.lastError },
1182
+ ]),
1183
+ ],
1184
+ });
1185
+ }
1186
+ children.push(
1187
+ spacer(0),
1188
+ // === Button row: bottom-right aligned. =======================
1189
+ row(
1190
+ flexSpacer(),
1191
+ button("Cancel", { intent: "danger", key: "cancel" }),
1192
+ spacer(2),
1193
+ button("Create Session", { intent: "primary", key: "create" }),
1194
+ ),
1195
+ spacer(0),
1196
+ // === Footer: keybinding helper, centered. ====================
1197
+ row(
1198
+ flexSpacer(),
1199
+ hintBar([
1200
+ { keys: "Tab", label: "next" },
1201
+ { keys: "S-Tab", label: "prev" },
1202
+ { keys: "Enter", label: "submit" },
1203
+ { keys: "Esc", label: "cancel" },
1204
+ ]),
1205
+ flexSpacer(),
1206
+ ),
1207
+ );
1208
+ return col(...children);
1209
+ }
1210
+
1211
+ // Derive a "my_org/project_name" style label from the current
1212
+ // working directory's tail. Orchestrator never opens this dialog
1213
+ // outside of a workspace; if the cwd has fewer than two
1214
+ // components we fall back to whatever's there.
1215
+ function deriveProjectLabel(): string {
1216
+ const cwd = editor.getCwd();
1217
+ const base = editor.pathBasename(cwd);
1218
+ const parent = editor.pathBasename(editor.pathDirname(cwd));
1219
+ if (parent && parent !== base) return `${parent}/${base}`;
1220
+ return base || cwd;
1221
+ }
1222
+
1223
+
1224
+ function renderForm(): void {
1225
+ if (!form || !formPanel) return;
1226
+ formPanel.update(buildFormSpec());
1227
+ }
1228
+
1229
+ function openForm(): void {
1230
+ pendingNewSession = null;
1231
+ const lastCmd =
1232
+ (editor.getGlobalState("orchestrator.last_cmd") as string | undefined) ?? "";
1233
+ form = {
1234
+ name: { value: "", cursor: 0 },
1235
+ cmd: { value: lastCmd, cursor: lastCmd.length },
1236
+ branch: { value: "", cursor: 0 },
1237
+ submitting: false,
1238
+ lastError: null,
1239
+ projectLabel: deriveProjectLabel(),
1240
+ defaultBranch: "",
1241
+ };
1242
+ formPanel = new FloatingWidgetPanel();
1243
+ formPanel.mount(buildFormSpec(), { widthPct: 60, heightPct: 50 });
1244
+ editor.setEditorMode(NEW_SESSION_MODE);
1245
+
1246
+ // Probe origin's default branch in the background and update
1247
+ // the branch field's placeholder once we know it. The dialog
1248
+ // is interactive immediately — the probe just refines the hint
1249
+ // from "(detecting…)" to the concrete ref name.
1250
+ void (async () => {
1251
+ const cwd = editor.getCwd();
1252
+ const top = await spawnCollect(
1253
+ "git",
1254
+ ["rev-parse", "--show-toplevel"],
1255
+ cwd,
1256
+ );
1257
+ if (top.exit_code !== 0 || !form) return;
1258
+ const repoRoot = (top.stdout || "").trim();
1259
+ const branch = await detectDefaultBranch(repoRoot);
1260
+ if (!form) return;
1261
+ form.defaultBranch = branch;
1262
+ renderForm();
1263
+ })();
1264
+ }
1265
+
1266
+ function closeForm(): void {
1267
+ if (formPanel) {
1268
+ formPanel.unmount();
1269
+ formPanel = null;
1270
+ }
1271
+ form = null;
1272
+ editor.setEditorMode(null);
1273
+ }
1274
+
1275
+ async function submitForm(): Promise<void> {
1276
+ if (!form || form.submitting) return;
1277
+ form.submitting = true;
1278
+ form.lastError = null;
1279
+ renderForm();
1280
+
1281
+ const sessionName = form.name.value.trim() || nextAutoSessionName();
1282
+ const cmd = form.cmd.value.trim();
1283
+ const branchInput = form.branch.value.trim();
1284
+
1285
+ const cwd = editor.getCwd();
1286
+ const top = await spawnCollect("git", ["rev-parse", "--show-toplevel"], cwd);
1287
+ if (top.exit_code !== 0) {
1288
+ if (!form) return;
1289
+ form.submitting = false;
1290
+ form.lastError = lastNonEmptyLine(top.stderr) || "not a git repository";
1291
+ renderForm();
1292
+ return;
1293
+ }
1294
+ const repoRoot = (top.stdout || "").trim();
1295
+
1296
+ const root = editor.pathJoin(
1297
+ editor.getDataDir(),
1298
+ "orchestrator",
1299
+ slugify(repoRoot),
1300
+ sessionName,
1301
+ );
1302
+ const parent = editor.pathDirname(root);
1303
+ if (!editor.createDir(parent)) {
1304
+ if (!form) return;
1305
+ form.submitting = false;
1306
+ form.lastError = `mkdir failed: ${parent}`;
1307
+ renderForm();
1308
+ return;
1309
+ }
1310
+
1311
+ const defaultBranch = await detectDefaultBranch(repoRoot);
1312
+ const branchName = branchInput || sessionName;
1313
+ // Try `-b <new>` first; if it fails because the branch already
1314
+ // exists, fall back to checking out the existing branch into a
1315
+ // new worktree.
1316
+ let addRes = await spawnCollect(
1317
+ "git",
1318
+ ["-C", repoRoot, "worktree", "add", root, "-b", branchName, defaultBranch],
1319
+ repoRoot,
1320
+ );
1321
+ if (addRes.exit_code !== 0) {
1322
+ const fallback = await spawnCollect(
1323
+ "git",
1324
+ ["-C", repoRoot, "worktree", "add", root, branchName],
1325
+ repoRoot,
1326
+ );
1327
+ if (fallback.exit_code !== 0) {
1328
+ if (!form) return;
1329
+ form.submitting = false;
1330
+ form.lastError = lastNonEmptyLine(addRes.stderr) ||
1331
+ lastNonEmptyLine(fallback.stderr) ||
1332
+ "git worktree add failed";
1333
+ renderForm();
1334
+ return;
1335
+ }
1336
+ addRes = fallback;
1337
+ }
1338
+
1339
+ if (cmd) {
1340
+ editor.setGlobalState("orchestrator.last_cmd", cmd);
1341
+ }
1342
+
1343
+ pendingNewSession = { label: sessionName, branch: branchName, cmd, root };
1344
+ closeForm();
1345
+ editor.createWindow(root, sessionName);
1346
+ }
1347
+
1348
+ function startNewSession(): void {
1349
+ if (form) return; // already open
1350
+ openForm();
1351
+ }
1352
+
1353
+ // Form key bindings — each delegates to smart-key dispatch on the
1354
+ // panel, which routes to the focused widget. `mode_text_input`
1355
+ // handles printable input outside this list.
1356
+ const FORM_MODE_BINDINGS: [string, string][] = [
1357
+ ["Tab", "orchestrator_form_key_tab"],
1358
+ ["S-Tab", "orchestrator_form_key_shift_tab"],
1359
+ ["Return", "orchestrator_form_key_enter"],
1360
+ ["Escape", "orchestrator_form_key_escape"],
1361
+ ["Backspace", "orchestrator_form_key_backspace"],
1362
+ ["Delete", "orchestrator_form_key_delete"],
1363
+ ["Home", "orchestrator_form_key_home"],
1364
+ ["End", "orchestrator_form_key_end"],
1365
+ ["Left", "orchestrator_form_key_left"],
1366
+ ["Right", "orchestrator_form_key_right"],
1367
+ ["Up", "orchestrator_form_key_up"],
1368
+ ["Down", "orchestrator_form_key_down"],
1369
+ ];
1370
+
1371
+ editor.defineMode(NEW_SESSION_MODE, FORM_MODE_BINDINGS, true, true);
1372
+
1373
+ function dispatchFormKey(name: string): void {
1374
+ if (!form || !formPanel) return;
1375
+ formPanel.command(widgetKey(name));
1376
+ }
1377
+
1378
+ registerHandler("orchestrator_form_key_tab", () => dispatchFormKey("Tab"));
1379
+ registerHandler(
1380
+ "orchestrator_form_key_shift_tab",
1381
+ () => dispatchFormKey("Shift+Tab"),
1382
+ );
1383
+ registerHandler("orchestrator_form_key_enter", () => dispatchFormKey("Enter"));
1384
+ registerHandler("orchestrator_form_key_escape", () => {
1385
+ if (form) closeForm();
1386
+ });
1387
+ registerHandler(
1388
+ "orchestrator_form_key_backspace",
1389
+ () => dispatchFormKey("Backspace"),
1390
+ );
1391
+ registerHandler("orchestrator_form_key_delete", () => dispatchFormKey("Delete"));
1392
+ registerHandler("orchestrator_form_key_home", () => dispatchFormKey("Home"));
1393
+ registerHandler("orchestrator_form_key_end", () => dispatchFormKey("End"));
1394
+ registerHandler("orchestrator_form_key_left", () => dispatchFormKey("Left"));
1395
+ registerHandler("orchestrator_form_key_right", () => dispatchFormKey("Right"));
1396
+ registerHandler("orchestrator_form_key_up", () => dispatchFormKey("Up"));
1397
+ registerHandler("orchestrator_form_key_down", () => dispatchFormKey("Down"));
1398
+
1399
+ // Printable input arrives via the global `mode_text_input` action.
1400
+ // Other plugins may also register a `mode_text_input` handler;
1401
+ // guard on `form` so this handler is a no-op outside the form.
1402
+ function orchestrator_mode_text_input(args: { text: string }): void {
1403
+ if (!form || !formPanel || !args?.text) return;
1404
+ formPanel.command(textInputChar(args.text));
1405
+ }
1406
+ registerHandler("mode_text_input", orchestrator_mode_text_input);
1407
+
1408
+ editor.on("widget_event", (e) => {
1409
+ // ---------------------------------------------------------------------
1410
+ // New-session form
1411
+ // ---------------------------------------------------------------------
1412
+ if (form && formPanel && e.panel_id === formPanel.id()) {
1413
+ if (e.event_type === "change") {
1414
+ const field = e.widget_key;
1415
+ const payload = (e.payload ?? {}) as Record<string, unknown>;
1416
+ const value = payload.value;
1417
+ const cursor = payload.cursorByte;
1418
+ if (typeof value !== "string") return;
1419
+ const slot = field === "name"
1420
+ ? form.name
1421
+ : field === "cmd"
1422
+ ? form.cmd
1423
+ : field === "branch"
1424
+ ? form.branch
1425
+ : null;
1426
+ if (slot) {
1427
+ slot.value = value;
1428
+ if (typeof cursor === "number") slot.cursor = cursor;
1429
+ }
1430
+ return;
1431
+ }
1432
+ if (e.event_type === "activate") {
1433
+ if (e.widget_key === "create") {
1434
+ void submitForm();
1435
+ } else if (e.widget_key === "cancel") {
1436
+ closeForm();
1437
+ }
1438
+ return;
1439
+ }
1440
+ if (e.event_type === "cancel") {
1441
+ // Host fires this when Esc unmounts the floating panel —
1442
+ // clean up our own state to match.
1443
+ form = null;
1444
+ formPanel = null;
1445
+ editor.setEditorMode(null);
1446
+ return;
1447
+ }
1448
+ return;
1449
+ }
1450
+
1451
+ // ---------------------------------------------------------------------
1452
+ // Open dialog (session picker)
1453
+ // ---------------------------------------------------------------------
1454
+ if (openPanel && openDialog && e.panel_id === openPanel.id()) {
1455
+ if (e.event_type === "change" && e.widget_key === "filter") {
1456
+ const payload = (e.payload ?? {}) as Record<string, unknown>;
1457
+ const value = payload.value;
1458
+ const cursor = payload.cursorByte;
1459
+ if (typeof value !== "string") return;
1460
+ openDialog.filter.value = value;
1461
+ if (typeof cursor === "number") openDialog.filter.cursor = cursor;
1462
+ // Preserve highlighted session across the filter narrowing
1463
+ // when possible — if the previously selected id is still in
1464
+ // the new filtered set, keep it; otherwise reset to 0.
1465
+ const prevId = openDialog.filteredIds[openDialog.selectedIndex];
1466
+ const next = filterSessions(value);
1467
+ openDialog.filteredIds = next;
1468
+ const nextIdx = prevId !== undefined ? next.indexOf(prevId) : -1;
1469
+ openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;
1470
+ refreshOpenDialog();
1471
+ return;
1472
+ }
1473
+ if (e.event_type === "select" && e.widget_key === "sessions") {
1474
+ const payload = (e.payload ?? {}) as Record<string, unknown>;
1475
+ const idx = payload.index;
1476
+ if (typeof idx === "number") {
1477
+ openDialog.selectedIndex = idx;
1478
+ // Update preview pane.
1479
+ openPanel.update(buildOpenSpec());
1480
+ // Re-pin the list selection so the spec re-emit doesn't
1481
+ // snap it back to 0.
1482
+ openPanel.setSelectedIndex("sessions", openDialog.selectedIndex);
1483
+ }
1484
+ return;
1485
+ }
1486
+ if (e.event_type === "activate" && e.widget_key === "sessions") {
1487
+ const id = openDialog.filteredIds[openDialog.selectedIndex];
1488
+ if (typeof id === "number" && id > 0 && id !== editor.activeWindow()) {
1489
+ editor.setActiveWindow(id);
1490
+ }
1491
+ closeOpenDialog();
1492
+ return;
1493
+ }
1494
+ if (e.event_type === "activate" && e.widget_key === "stop") {
1495
+ stopSelectedSession();
1496
+ return;
1497
+ }
1498
+ if (e.event_type === "activate" && e.widget_key === "archive") {
1499
+ void archiveSelectedSession();
1500
+ return;
1501
+ }
1502
+ if (e.event_type === "activate" && e.widget_key === "delete") {
1503
+ const id = openDialog.filteredIds[openDialog.selectedIndex];
1504
+ if (typeof id === "number" && id > 0) {
1505
+ openDialog.pendingConfirm = { action: "delete", sessionId: id };
1506
+ openPanel.update(buildOpenSpec());
1507
+ }
1508
+ return;
1509
+ }
1510
+ if (e.event_type === "activate" && e.widget_key === "confirm-cancel") {
1511
+ openDialog.pendingConfirm = null;
1512
+ openPanel.update(buildOpenSpec());
1513
+ return;
1514
+ }
1515
+ if (e.event_type === "activate" && e.widget_key === "confirm-delete") {
1516
+ void deleteConfirmedSession();
1517
+ return;
1518
+ }
1519
+ if (e.event_type === "cancel") {
1520
+ // Esc unmounted the panel — sync our own state.
1521
+ openDialog = null;
1522
+ openPanel = null;
1523
+ editor.setEditorMode(null);
1524
+ return;
1525
+ }
1526
+ return;
1527
+ }
1528
+ });
1529
+
1530
+ // Legacy kill helper retained for the `Orchestrator: Kill Selected`
1531
+ // command-palette command. In the widget-based picker (Phase 1)
1532
+ // the open dialog has no kill action — Phase 3-5 will replace
1533
+ // this with Stop / Archive / Delete. When invoked while the
1534
+ // open dialog is up, it targets that dialog's selection; when
1535
+ // invoked from the palette outside the dialog, it status-bars
1536
+ // with guidance.
1537
+ function killSelected(): void {
1538
+ if (!openDialog) {
1539
+ editor.setStatus(
1540
+ "Orchestrator: open the session list (Ctrl+P → Orchestrator: Open) first",
1541
+ );
1542
+ return;
1543
+ }
1544
+ const ids = openDialog.filteredIds;
1545
+ if (ids.length === 0) {
1546
+ editor.setStatus("Orchestrator: no session selected");
1547
+ return;
1548
+ }
1549
+ const id = ids[Math.max(0, Math.min(openDialog.selectedIndex, ids.length - 1))];
1550
+ if (id <= 0) {
1551
+ editor.setStatus("Orchestrator: select a session row first");
1552
+ return;
1553
+ }
1554
+ if (id === 1) {
1555
+ editor.setStatus("Orchestrator: cannot kill the base session");
1556
+ return;
1557
+ }
1558
+ if (id === editor.activeWindow()) {
1559
+ editor.setStatus(
1560
+ "Orchestrator: dive elsewhere first, then kill this session",
1561
+ );
1562
+ return;
1563
+ }
1564
+ const s = orchestratorSessions.get(id);
1565
+ if (s && s.terminalId !== null) {
1566
+ editor.closeTerminal(s.terminalId);
1567
+ }
1568
+ editor.closeWindow(id);
1569
+ }
1570
+
1571
+ // =============================================================================
1572
+ // Lifecycle hook handlers
1573
+ // =============================================================================
1574
+
1575
+ editor.on("window_created", async (payload) => {
1576
+ const id = payload.id;
1577
+ if (
1578
+ pendingNewSession &&
1579
+ payload.label === pendingNewSession.label
1580
+ ) {
1581
+ const intent = pendingNewSession;
1582
+ pendingNewSession = null;
1583
+ // windowId attaches the terminal to the new session's split
1584
+ // tree; we then dive so the user sees the shell/agent
1585
+ // immediately — creating a session is a visit-now action.
1586
+ const term = await editor.createTerminal({
1587
+ cwd: intent.root,
1588
+ focus: false,
1589
+ windowId: id,
1590
+ });
1591
+ const tracked: AgentSession = {
1592
+ id,
1593
+ label: intent.label,
1594
+ root: intent.root,
1595
+ terminalId: term.terminalId,
1596
+ state: "running",
1597
+ createdAt: Date.now(),
1598
+ };
1599
+ orchestratorSessions.set(id, tracked);
1600
+ if (intent.cmd) {
1601
+ editor.sendTerminalInput(term.terminalId, intent.cmd + "\n");
1602
+ }
1603
+ editor.setActiveWindow(id);
1604
+ }
1605
+ refreshOpenDialog();
1606
+ });
1607
+
1608
+ editor.on("window_closed", () => {
1609
+ refreshOpenDialog();
1610
+ });
1611
+
1612
+ editor.on("active_window_changed", () => {
1613
+ refreshOpenDialog();
1614
+ });
1615
+
1616
+ // =============================================================================
1617
+ // Agent state inference from terminal output / exit
1618
+ // =============================================================================
1619
+
1620
+ // Match common AI-agent prompts: "(Y/n)", "(y/N)", "Press <key>",
1621
+ // or a trailing question mark followed by optional whitespace.
1622
+ // Conservative — false positives mistakenly classify a busy
1623
+ // agent as "awaiting", which is recoverable by next output;
1624
+ // false negatives are worse (user thinks agent is busy when
1625
+ // it's actually waiting), so we err on the side of detecting.
1626
+ const AWAITING_RX = /(\(\s*[YyNn]\s*\/\s*[YyNn]\s*\):?\s*$)|(Press\s+(?:enter|return|any\s+key)[^\n]*$)|(\?\s*$)/i;
1627
+
1628
+ editor.on("terminal_output", (payload) => {
1629
+ const last = payload.last_line || "";
1630
+ for (const s of orchestratorSessions.values()) {
1631
+ if (s.terminalId === payload.terminal_id) {
1632
+ // RUNNING is the default; flip to AWAITING only when the
1633
+ // last visible line matches a prompt pattern. New output
1634
+ // that doesn't match restores RUNNING — agents usually
1635
+ // print their next chunk over the prompt line, so this
1636
+ // gives the right transition even for chatty agents.
1637
+ s.state = AWAITING_RX.test(last) ? "awaiting" : "running";
1638
+ break;
1639
+ }
1640
+ }
1641
+ refreshOpenDialog();
1642
+ });
1643
+
1644
+ editor.on("terminal_exit", (payload) => {
1645
+ for (const s of orchestratorSessions.values()) {
1646
+ if (s.terminalId === payload.terminal_id) {
1647
+ const code = payload.exit_code;
1648
+ // exit_code is currently always null (the editor's
1649
+ // wait-status capture is a follow-up). Treat unknown as
1650
+ // ready — Orchestrator doesn't have a better heuristic and
1651
+ // mis-marking a real error as "ready" is recoverable
1652
+ // (the user opens the dive and sees the failure).
1653
+ s.state = code === null || code === 0 ? "ready" : "errored";
1654
+ break;
1655
+ }
1656
+ }
1657
+ refreshOpenDialog();
1658
+ });
1659
+
1660
+ // =============================================================================
1661
+ // Commands
1662
+ // =============================================================================
1663
+
1664
+ registerHandler("orchestrator_open", openControlRoom);
1665
+ registerHandler("orchestrator_new", startNewSession);
1666
+ registerHandler("orchestrator_kill", killSelected);
1667
+
1668
+ editor.registerCommand(
1669
+ "Orchestrator: Open",
1670
+ "Show all editor sessions in a floating selector",
1671
+ "orchestrator_open",
1672
+ null,
1673
+ );
1674
+ editor.registerCommand(
1675
+ "Orchestrator: New Session",
1676
+ "Spawn a new editor session in a worktree",
1677
+ "orchestrator_new",
1678
+ null,
1679
+ );
1680
+ editor.registerCommand(
1681
+ "Orchestrator: Kill Selected",
1682
+ "Close the session highlighted in the open Orchestrator prompt",
1683
+ "orchestrator_kill",
1684
+ null,
1685
+ );