pilotswarm-cli 0.1.11 → 0.1.12

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.
package/cli/tui.js CHANGED
@@ -20,10 +20,18 @@ import { markedTerminal } from "marked-terminal";
20
20
  import chalk from "chalk";
21
21
  import fs from "node:fs";
22
22
  import path from "node:path";
23
- import { spawn } from "node:child_process";
23
+ import { spawn, spawnSync } from "node:child_process";
24
24
  import { fileURLToPath } from "node:url";
25
25
  import { performance } from "node:perf_hooks";
26
26
  import os from "node:os";
27
+ import {
28
+ computeContextPercent,
29
+ formatTokenCount,
30
+ formatContextHeaderBadge,
31
+ formatContextListBadge,
32
+ formatContextCompactionBadge,
33
+ formatCompactionActivityMarkup,
34
+ } from "./context-usage.js";
27
35
 
28
36
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
37
 
@@ -56,6 +64,102 @@ const BASE_TUI_TITLE = (process.env._TUI_TITLE || "PilotSwarm").trim() || "Pilot
56
64
  const CUSTOM_TUI_SPLASH = process.env._TUI_SPLASH?.trim() || "";
57
65
  const ACTIVE_STARTUP_SPLASH_CONTENT = CUSTOM_TUI_SPLASH || STARTUP_SPLASH_CONTENT;
58
66
  const HAS_CUSTOM_TUI_BRANDING = BASE_TUI_TITLE !== "PilotSwarm" || Boolean(CUSTOM_TUI_SPLASH);
67
+ const TERMINAL_RESET_ESCAPE = "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1049l\x1b[?25h";
68
+ const SIGNAL_EXIT_CODES = {
69
+ SIGINT: 130,
70
+ SIGTERM: 143,
71
+ SIGHUP: 129,
72
+ };
73
+
74
+ let localEmbeddedRuntimeLockPath = null;
75
+ let shutdownInProgress = false;
76
+
77
+ function isProcessAlive(pid) {
78
+ if (!Number.isInteger(pid) || pid <= 0) return false;
79
+ try {
80
+ process.kill(pid, 0);
81
+ return true;
82
+ } catch (err) {
83
+ return err?.code === "EPERM";
84
+ }
85
+ }
86
+
87
+ function getLocalEmbeddedRuntimeLockPath() {
88
+ const sessionStateDir = process.env.SESSION_STATE_DIR || path.join(os.homedir(), ".copilot", "session-state");
89
+ return path.join(path.dirname(sessionStateDir), "embedded-local-runtime.lock.json");
90
+ }
91
+
92
+ function releaseLocalEmbeddedRuntimeLock() {
93
+ if (!localEmbeddedRuntimeLockPath) return;
94
+
95
+ try {
96
+ const raw = fs.readFileSync(localEmbeddedRuntimeLockPath, "utf-8");
97
+ const owner = JSON.parse(raw);
98
+ if (Number(owner?.pid) && Number(owner.pid) !== process.pid) {
99
+ return;
100
+ }
101
+ } catch {}
102
+
103
+ try {
104
+ fs.rmSync(localEmbeddedRuntimeLockPath, { force: true });
105
+ } catch {}
106
+ localEmbeddedRuntimeLockPath = null;
107
+ }
108
+
109
+ function acquireLocalEmbeddedRuntimeLock(store) {
110
+ const lockPath = getLocalEmbeddedRuntimeLockPath();
111
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
112
+
113
+ const lockBody = JSON.stringify({
114
+ pid: process.pid,
115
+ mode: "local-embedded",
116
+ cwd: process.cwd(),
117
+ store,
118
+ acquiredAt: new Date().toISOString(),
119
+ }, null, 2);
120
+
121
+ for (let attempt = 0; attempt < 2; attempt++) {
122
+ try {
123
+ const fd = fs.openSync(lockPath, "wx");
124
+ fs.writeFileSync(fd, lockBody, "utf-8");
125
+ fs.closeSync(fd);
126
+ localEmbeddedRuntimeLockPath = lockPath;
127
+ return;
128
+ } catch (err) {
129
+ if (err?.code !== "EEXIST") throw err;
130
+
131
+ let owner = null;
132
+ try {
133
+ owner = JSON.parse(fs.readFileSync(lockPath, "utf-8"));
134
+ } catch {}
135
+
136
+ const ownerPid = Number(owner?.pid);
137
+ if (ownerPid && ownerPid !== process.pid && isProcessAlive(ownerPid)) {
138
+ const ownerStore = owner?.store ? ` (${owner.store})` : "";
139
+ throw new Error(
140
+ `Another embedded local PilotSwarm TUI is already running (pid ${ownerPid})${ownerStore}. ` +
141
+ `Close it before starting a second local TUI. Remote mode still supports multiple windows.`,
142
+ );
143
+ }
144
+
145
+ try {
146
+ fs.rmSync(lockPath, { force: true });
147
+ } catch {}
148
+ }
149
+ }
150
+
151
+ throw new Error("Failed to acquire the embedded local runtime lock.");
152
+ }
153
+
154
+ function exitProcessNow(code) {
155
+ releaseLocalEmbeddedRuntimeLock();
156
+ try { fs.writeSync(1, Buffer.from(TERMINAL_RESET_ESCAPE)); } catch {}
157
+ process.exit(code);
158
+ }
159
+
160
+ process.on("exit", () => {
161
+ releaseLocalEmbeddedRuntimeLock();
162
+ });
59
163
 
60
164
  function formatWindowTitle(detail) {
61
165
  return detail ? `${BASE_TUI_TITLE} (${detail})` : BASE_TUI_TITLE;
@@ -79,6 +183,102 @@ function applyWindowTitle(title) {
79
183
  applyTerminalTitle(title);
80
184
  }
81
185
 
186
+ function enableMouseTracking(screenInstance) {
187
+ if (!screenInstance?.program?.input?.isTTY || !screenInstance?.program?.output?.isTTY) {
188
+ return;
189
+ }
190
+
191
+ try {
192
+ // neo-blessed only auto-enables mouse for a narrow set of TERM values.
193
+ // Request the common xterm-compatible modes explicitly so drag selection
194
+ // still works in newer terminals and IDE-integrated terminals.
195
+ screenInstance.enableMouse();
196
+ screenInstance.program.setMouse({
197
+ vt200Mouse: true,
198
+ utfMouse: true,
199
+ sgrMouse: true,
200
+ cellMotion: true,
201
+ allMotion: true,
202
+ }, true);
203
+ } catch {}
204
+ }
205
+
206
+ function openPathInDefaultApp(targetPath) {
207
+ if (!targetPath) return;
208
+
209
+ let command;
210
+ let args;
211
+
212
+ if (process.platform === "darwin") {
213
+ command = "open";
214
+ args = [targetPath];
215
+ } else if (process.platform === "win32") {
216
+ command = "cmd";
217
+ args = ["/c", "start", "", targetPath];
218
+ } else {
219
+ command = "xdg-open";
220
+ args = [targetPath];
221
+ }
222
+
223
+ try {
224
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
225
+ child.once("error", (err) => {
226
+ appendLog(`{red-fg}Open failed: ${err.message}{/red-fg}`);
227
+ setStatus("Open failed — check logs");
228
+ scheduleRender();
229
+ });
230
+ child.unref();
231
+ setStatus(`Opened: ${targetPath}`);
232
+ } catch (err) {
233
+ appendLog(`{red-fg}Open failed: ${err.message}{/red-fg}`);
234
+ setStatus("Open failed — check logs");
235
+ }
236
+ }
237
+
238
+ let suppressPaneClicksUntil = 0;
239
+
240
+ function copyTextToClipboard(text, { onError } = {}) {
241
+ if (!text) return false;
242
+
243
+ let command;
244
+ let args;
245
+
246
+ if (process.platform === "darwin") {
247
+ command = "pbcopy";
248
+ args = [];
249
+ } else if (process.platform === "win32") {
250
+ command = "clip";
251
+ args = [];
252
+ } else if (process.env.WAYLAND_DISPLAY) {
253
+ command = "wl-copy";
254
+ args = [];
255
+ } else {
256
+ command = "xclip";
257
+ args = ["-selection", "clipboard"];
258
+ }
259
+
260
+ try {
261
+ const result = spawnSync(command, args, {
262
+ input: text,
263
+ encoding: "utf8",
264
+ stdio: ["pipe", "ignore", "pipe"],
265
+ });
266
+ if (result.error) throw result.error;
267
+ if (typeof result.status === "number" && result.status !== 0) {
268
+ throw new Error((result.stderr || "").trim() || `${command} exited with status ${result.status}`);
269
+ }
270
+ return true;
271
+ } catch (err) {
272
+ try {
273
+ if (screen?.copyToClipboard?.(text)) {
274
+ return true;
275
+ }
276
+ } catch {}
277
+ onError?.(err);
278
+ return false;
279
+ }
280
+ }
281
+
82
282
  function hideTerminalCursor() {
83
283
  try { screen?.program?.hideCursor(); } catch {}
84
284
  }
@@ -137,6 +337,7 @@ const require = createRequire(import.meta.url);
137
337
  const _origStderr = process.stderr.write.bind(process.stderr);
138
338
  process.stderr.write = () => true;
139
339
  const blessed = require("neo-blessed");
340
+ const blessedColors = require("neo-blessed/lib/colors");
140
341
  process.stderr.write = _origStderr;
141
342
 
142
343
  // ─── Monkey-patch neo-blessed emoji width ────────────────────────
@@ -212,6 +413,234 @@ function isMarkdownTableLine(line) {
212
413
  || /^\s*\|?[:\- ]+\|[:\-| ]+\|?\s*$/.test(line);
213
414
  }
214
415
 
416
+ function getRenderablePaneWidth(pane, fallbackWidth) {
417
+ const numericWidth = typeof pane?.width === "number" ? pane.width : fallbackWidth;
418
+ const innerWidth = numericWidth - (pane?.iwidth || 2) - (pane?.scrollbar ? 1 : 0);
419
+ return Math.max(20, innerWidth);
420
+ }
421
+
422
+ function splitMarkdownTableCells(line) {
423
+ const trimmed = String(line || "").trim().replace(/^\|/, "").replace(/\|$/, "");
424
+ const cells = [];
425
+ let current = "";
426
+ let escaping = false;
427
+
428
+ for (const ch of trimmed) {
429
+ if (escaping) {
430
+ current += ch;
431
+ escaping = false;
432
+ continue;
433
+ }
434
+ if (ch === "\\") {
435
+ escaping = true;
436
+ continue;
437
+ }
438
+ if (ch === "|") {
439
+ cells.push(current.trim());
440
+ current = "";
441
+ continue;
442
+ }
443
+ current += ch;
444
+ }
445
+ cells.push(current.trim());
446
+ return cells;
447
+ }
448
+
449
+ function isMarkdownTableSeparatorRow(cells) {
450
+ return cells.length > 0 && cells.every(cell => /^:?-{3,}:?$/.test(String(cell || "").replace(/\s+/g, "")));
451
+ }
452
+
453
+ function sliceTextToDisplayWidth(text, maxWidth) {
454
+ if (maxWidth <= 0) return "";
455
+ let out = "";
456
+ let used = 0;
457
+ for (const ch of String(text || "")) {
458
+ const chWidth = Math.max(1, displayWidth(ch));
459
+ if (used + chWidth > maxWidth) break;
460
+ out += ch;
461
+ used += chWidth;
462
+ }
463
+ return out;
464
+ }
465
+
466
+ function wrapTableCellText(text, width) {
467
+ const normalized = String(text || "").replace(/\s+/g, " ").trim();
468
+ if (!normalized) return [""];
469
+
470
+ const words = normalized.split(" ");
471
+ const lines = [];
472
+ let current = "";
473
+
474
+ const appendWord = (word) => {
475
+ let remaining = word;
476
+ while (displayWidth(remaining) > width) {
477
+ const chunk = sliceTextToDisplayWidth(remaining, width);
478
+ if (!chunk) break;
479
+ if (current) {
480
+ lines.push(current);
481
+ current = "";
482
+ }
483
+ lines.push(chunk);
484
+ remaining = remaining.slice(chunk.length);
485
+ }
486
+ return remaining;
487
+ };
488
+
489
+ for (const word of words) {
490
+ const fittedWord = appendWord(word);
491
+ if (!fittedWord) continue;
492
+ if (!current) {
493
+ current = fittedWord;
494
+ continue;
495
+ }
496
+ const candidate = `${current} ${fittedWord}`;
497
+ if (displayWidth(candidate) <= width) {
498
+ current = candidate;
499
+ } else {
500
+ lines.push(current);
501
+ current = fittedWord;
502
+ }
503
+ }
504
+
505
+ if (current) lines.push(current);
506
+ return lines.length > 0 ? lines : [""];
507
+ }
508
+
509
+ function computeMarkdownTableColumnWidths(rows, maxWidth, separatorWidth) {
510
+ const columnCount = Math.max(1, ...rows.map(row => row.length));
511
+ const totalSeparatorWidth = typeof separatorWidth === "number"
512
+ ? separatorWidth
513
+ : (columnCount - 1) * 3;
514
+ const availableWidth = Math.max(columnCount * 3, maxWidth - totalSeparatorWidth);
515
+ const preferred = Array.from({ length: columnCount }, (_, index) => Math.max(
516
+ 3,
517
+ ...rows.map(row => displayWidth(row[index] || "")),
518
+ ));
519
+ const minimum = Array.from({ length: columnCount }, (_, index) => Math.max(
520
+ 3,
521
+ Math.min(displayWidth(rows[0]?.[index] || "") || 3, 18),
522
+ ));
523
+
524
+ if (columnCount === 2) {
525
+ const secondMinimum = Math.max(minimum[1], 16);
526
+ const firstCap = Math.max(minimum[0], Math.min(24, Math.floor(availableWidth * 0.32)));
527
+ let firstWidth = Math.min(preferred[0], firstCap);
528
+ let secondWidth = availableWidth - firstWidth;
529
+ if (secondWidth < secondMinimum) {
530
+ firstWidth = Math.max(minimum[0], availableWidth - secondMinimum);
531
+ secondWidth = availableWidth - firstWidth;
532
+ }
533
+ return [firstWidth, secondWidth];
534
+ }
535
+
536
+ const widths = minimum.slice();
537
+ let remaining = availableWidth - widths.reduce((sum, width) => sum + width, 0);
538
+ if (remaining < 0) {
539
+ const equalShare = Math.max(3, Math.floor(availableWidth / columnCount));
540
+ return widths.map((_, index) => index === columnCount - 1
541
+ ? Math.max(3, availableWidth - equalShare * (columnCount - 1))
542
+ : equalShare);
543
+ }
544
+
545
+ while (remaining > 0) {
546
+ let bestIndex = 0;
547
+ let bestNeed = -Infinity;
548
+ for (let index = 0; index < columnCount; index++) {
549
+ const need = preferred[index] - widths[index];
550
+ if (need > bestNeed) {
551
+ bestNeed = need;
552
+ bestIndex = index;
553
+ }
554
+ }
555
+ widths[bestIndex] += 1;
556
+ remaining -= 1;
557
+ }
558
+
559
+ return widths;
560
+ }
561
+
562
+ function estimateMarkdownTableWidth(rows) {
563
+ const columnCount = Math.max(1, ...rows.map(row => row.length));
564
+ const preferred = Array.from({ length: columnCount }, (_, index) => Math.max(
565
+ 3,
566
+ ...rows.map(row => displayWidth(row[index] || "")),
567
+ ));
568
+ return preferred.reduce((sum, width) => sum + width, 0) + (columnCount * 3 + 1);
569
+ }
570
+
571
+ function renderPlainMarkdownTable(rows, maxWidth) {
572
+ if (!rows || rows.length === 0) return "";
573
+ const columnCount = Math.max(1, ...rows.map(row => row.length));
574
+ const normalizedRows = rows.map(row => Array.from({ length: columnCount }, (_, index) => row[index] || ""));
575
+ const borderOverhead = columnCount * 3 + 1; // | cell | cell |
576
+ const widths = computeMarkdownTableColumnWidths(normalizedRows, maxWidth, borderOverhead);
577
+ const out = [];
578
+
579
+ const topBorder = `┌${widths.map(width => "─".repeat(width + 2)).join("┬")}┐`;
580
+ const middleBorder = `├${widths.map(width => "─".repeat(width + 2)).join("┼")}┤`;
581
+ const bottomBorder = `└${widths.map(width => "─".repeat(width + 2)).join("┴")}┘`;
582
+
583
+ const renderRow = (cells) => {
584
+ const wrapped = cells.map((cell, index) => wrapTableCellText(cell, widths[index]));
585
+ const height = Math.max(...wrapped.map(lines => lines.length));
586
+ for (let lineIndex = 0; lineIndex < height; lineIndex++) {
587
+ const parts = wrapped.map((lines, index) => padToWidth(lines[lineIndex] || "", widths[index]));
588
+ out.push(`│ ${parts.join(" │ ")} │`);
589
+ }
590
+ };
591
+
592
+ out.push(topBorder);
593
+ renderRow(normalizedRows[0]);
594
+ out.push(middleBorder);
595
+ for (const row of normalizedRows.slice(1)) {
596
+ renderRow(row);
597
+ }
598
+ out.push(bottomBorder);
599
+ return out.join("\n");
600
+ }
601
+
602
+ function formatMarkdownTables(md, maxWidth) {
603
+ const lines = String(md || "").split("\n");
604
+ const out = [];
605
+
606
+ for (let index = 0; index < lines.length; index++) {
607
+ const line = lines[index];
608
+ const nextLine = lines[index + 1];
609
+ if (!isMarkdownTableLine(line) || !isMarkdownTableLine(nextLine)) {
610
+ out.push(line);
611
+ continue;
612
+ }
613
+
614
+ const headerCells = splitMarkdownTableCells(line);
615
+ const separatorCells = splitMarkdownTableCells(nextLine);
616
+ if (!isMarkdownTableSeparatorRow(separatorCells) || headerCells.length !== separatorCells.length) {
617
+ out.push(line);
618
+ continue;
619
+ }
620
+
621
+ const rows = [headerCells];
622
+ const tableLines = [line, nextLine];
623
+ index += 1;
624
+ while (index + 1 < lines.length && isMarkdownTableLine(lines[index + 1])) {
625
+ index += 1;
626
+ tableLines.push(lines[index]);
627
+ const rowCells = splitMarkdownTableCells(lines[index]);
628
+ if (isMarkdownTableSeparatorRow(rowCells)) continue;
629
+ rows.push(rowCells);
630
+ }
631
+
632
+ if (estimateMarkdownTableWidth(rows) <= maxWidth) {
633
+ out.push(...tableLines);
634
+ continue;
635
+ }
636
+
637
+ const renderedTable = renderPlainMarkdownTable(rows, maxWidth);
638
+ out.push("```text", renderedTable, "```");
639
+ }
640
+
641
+ return out.join("\n");
642
+ }
643
+
215
644
  function isAsciiArtCandidateLine(line) {
216
645
  if (!line || !line.trim()) return false;
217
646
  if (isMarkdownTableLine(line)) return false;
@@ -296,14 +725,14 @@ function preserveAsciiArtBlocks(md) {
296
725
  return out.join("\n");
297
726
  }
298
727
 
299
- function renderMarkdown(md) {
728
+ function renderMarkdown(md, widthOverride) {
300
729
  const _ph = perfStart("renderMarkdown");
301
730
  try {
302
- // Dynamically set width to match chat pane (minus borders/padding)
303
- const mdWidth = Math.max(40, leftW() - 4);
304
- marked.use(markedTerminal({ reflowText: true, width: mdWidth, showSectionPrefix: false, tab: 2, blockquote: chalk.whiteBright.italic, html: chalk.white, codespan: chalk.yellowBright }, { theme: cliHighlightTheme }));
731
+ const fallbackWidth = Math.max(40, leftW() - 4);
732
+ const mdWidth = Math.max(40, widthOverride || getRenderablePaneWidth(typeof chatBox !== "undefined" ? chatBox : null, fallbackWidth));
733
+ marked.use(markedTerminal({ reflowText: true, width: mdWidth, showSectionPrefix: false, tab: 2, blockquote: chalk.whiteBright.italic, html: chalk.white, codespan: chalk.yellowBright }, { theme: cliHighlightTheme }));
305
734
  const unescaped = md.replace(/\\n/g, "\n");
306
- const preprocessed = preserveAsciiArtBlocks(unescaped);
735
+ const preprocessed = preserveAsciiArtBlocks(formatMarkdownTables(unescaped, mdWidth));
307
736
  let rendered = marked(preprocessed).replace(/\n{3,}/g, "\n\n").trimEnd();
308
737
  // marked-terminal uses ANSI codes for styling, not blessed tags.
309
738
  // Strip curly braces so blessed doesn't misinterpret them as tags.
@@ -333,6 +762,32 @@ function renderMarkdown(md) {
333
762
  // Format: artifact://sessionId/filename.md
334
763
  const ARTIFACT_URI_RE = /artifact:\/\/([a-f0-9-]+)\/([^\s"'{}]+)/g;
335
764
 
765
+ function normalizeArtifactFilename(filename) {
766
+ if (!filename) return filename;
767
+
768
+ let normalized = filename;
769
+ while (true) {
770
+ const next = normalized
771
+ // Common markdown wrappers: **artifact://...**, _artifact://..._, `artifact://...`
772
+ .replace(/[*_`]+$/g, "")
773
+ // Common trailing punctuation when the URI appears in prose or markdown links
774
+ .replace(/[)\]}>!,;:?]+$/g, "")
775
+ // Sentence-ending periods: artifact://.../report.md.
776
+ .replace(/(\.[A-Za-z0-9]{1,16})\.+$/g, "$1");
777
+ if (next === normalized) return normalized;
778
+ normalized = next;
779
+ }
780
+ }
781
+
782
+ function renderArtifactLinks(text) {
783
+ if (!text) return text;
784
+ ARTIFACT_URI_RE.lastIndex = 0;
785
+ return text.replace(ARTIFACT_URI_RE, (_match, _sessionId, rawFilename) => {
786
+ const filename = normalizeArtifactFilename(rawFilename);
787
+ return `📎 **${filename}** _(press 'a' to download)_`;
788
+ });
789
+ }
790
+
336
791
  /** Per-session artifact link registry. orchId → [{ sessionId, filename }] */
337
792
  const sessionArtifacts = new Map();
338
793
 
@@ -341,16 +796,18 @@ const _registeredArtifacts = new Set();
341
796
  const MAX_ARTIFACT_REGISTRY = 500;
342
797
 
343
798
  /** TUI-level artifact store for on-demand downloads. Created lazily.
344
- * Uses Azure Blob when configured, otherwise falls back to local filesystem. */
799
+ * Remote mode uses Azure Blob when configured; local mode stays on the
800
+ * repo-local filesystem artifact store.
801
+ */
345
802
  let _tuiArtifactStore = null;
346
803
  function getTuiArtifactStore() {
347
804
  if (_tuiArtifactStore) return _tuiArtifactStore;
348
805
  const connStr = process.env.AZURE_STORAGE_CONNECTION_STRING;
349
- if (connStr) {
806
+ if (isRemote && connStr) {
350
807
  const container = process.env.AZURE_STORAGE_CONTAINER || "copilot-sessions";
351
808
  _tuiArtifactStore = new SessionBlobStore(connStr, container);
352
809
  } else {
353
- _tuiArtifactStore = new FilesystemArtifactStore();
810
+ _tuiArtifactStore = new FilesystemArtifactStore(process.env.ARTIFACT_DIR);
354
811
  }
355
812
  return _tuiArtifactStore;
356
813
  }
@@ -364,7 +821,9 @@ function detectArtifactLinks(text, orchId) {
364
821
  ARTIFACT_URI_RE.lastIndex = 0;
365
822
  const matches = [...text.matchAll(ARTIFACT_URI_RE)];
366
823
  for (const m of matches) {
367
- const [, sessionId, filename] = m;
824
+ const [, sessionId, rawFilename] = m;
825
+ const filename = normalizeArtifactFilename(rawFilename);
826
+ if (!filename) continue;
368
827
  const key = `${sessionId}/${filename}`;
369
828
  if (_registeredArtifacts.has(key)) continue;
370
829
  _registeredArtifacts.add(key);
@@ -386,17 +845,18 @@ function detectArtifactLinks(text, orchId) {
386
845
  */
387
846
  async function downloadArtifact(sessionId, filename) {
388
847
  const store = getTuiArtifactStore();
848
+ const normalizedFilename = normalizeArtifactFilename(filename);
389
849
  const sessionDir = path.join(EXPORTS_DIR, sessionId.slice(0, 8));
390
850
  fs.mkdirSync(sessionDir, { recursive: true });
391
- const localPath = path.join(sessionDir, filename);
851
+ const localPath = path.join(sessionDir, normalizedFilename);
392
852
 
393
853
  try {
394
- const content = await store.downloadArtifact(sessionId, filename);
854
+ const content = await store.downloadArtifact(sessionId, normalizedFilename);
395
855
  fs.writeFileSync(localPath, content, "utf-8");
396
856
  appendLog(`{green-fg}📥 Downloaded: ~/${path.relative(os.homedir(), localPath)} (${(content.length / 1024).toFixed(1)}KB){/green-fg}`);
397
857
  return localPath;
398
858
  } catch (err) {
399
- appendLog(`{red-fg}📥 Download error for ${filename}: ${err.message}{/red-fg}`);
859
+ appendLog(`{red-fg}📥 Download error for ${normalizedFilename}: ${err.message}{/red-fg}`);
400
860
  return null;
401
861
  }
402
862
  }
@@ -441,7 +901,7 @@ function showArtifactPicker() {
441
901
  const items = artifacts.map(a => {
442
902
  const icon = a.downloaded ? " ✓" : " ↓";
443
903
  const sid = shortId(a.sessionId);
444
- return `${icon} ${sid}/${a.filename}`;
904
+ return `${icon} ${sid}/${normalizeArtifactFilename(a.filename)}`;
445
905
  });
446
906
  if (hasMultiple) items.push(" ⬇ Download All");
447
907
  return items;
@@ -635,7 +1095,7 @@ function showFileAttachPrompt() {
635
1095
 
636
1096
  // Show a snipped preview in chat (first 3 lines)
637
1097
  const lines = content.split("\n");
638
- const preview = lines.slice(0, 3).map(l => ` ${l.slice(0, 80)}`).join("\n");
1098
+ const preview = lines.slice(0, 3).map(l => ` ${safeSlice(l, 0, 80)}`).join("\n");
639
1099
  const suffix = lines.length > 3 ? `\n {gray-fg}… (${lines.length - 3} more lines){/gray-fg}` : "";
640
1100
  appendChatRaw(
641
1101
  `{yellow-fg}📄 Attached: ${displayName} (${(content.length / 1024).toFixed(1)}KB){/yellow-fg}\n${preview}${suffix}`,
@@ -684,9 +1144,57 @@ function ts() {
684
1144
  return formatDisplayTime(Date.now());
685
1145
  }
686
1146
 
1147
+ function formatHumanDurationSeconds(totalSeconds) {
1148
+ const numeric = Number(totalSeconds);
1149
+ if (!Number.isFinite(numeric)) {
1150
+ return `${asDisplayText(totalSeconds, "?")}s`;
1151
+ }
1152
+
1153
+ const roundedSeconds = Math.max(0, Math.round(numeric));
1154
+ if (roundedSeconds >= 86400) {
1155
+ const days = Math.floor(roundedSeconds / 86400);
1156
+ const hours = Math.floor((roundedSeconds % 86400) / 3600);
1157
+ const minutes = Math.floor((roundedSeconds % 3600) / 60);
1158
+ const seconds = roundedSeconds % 60;
1159
+ return `${days}d ${hours}h ${minutes}m ${seconds}s`;
1160
+ }
1161
+ if (roundedSeconds >= 3600) {
1162
+ const hours = Math.floor(roundedSeconds / 3600);
1163
+ const minutes = Math.floor((roundedSeconds % 3600) / 60);
1164
+ const seconds = roundedSeconds % 60;
1165
+ return `${hours}h ${minutes}m ${seconds}s`;
1166
+ }
1167
+ if (roundedSeconds >= 60) {
1168
+ const minutes = Math.floor(roundedSeconds / 60);
1169
+ const seconds = roundedSeconds % 60;
1170
+ return `${minutes}m ${seconds}s`;
1171
+ }
1172
+ return `${roundedSeconds}s`;
1173
+ }
1174
+
1175
+ function asDisplayText(value, fallback = "") {
1176
+ if (typeof value === "string") return value;
1177
+ if (value == null) return fallback;
1178
+ return String(value);
1179
+ }
1180
+
1181
+ function safeSlice(value, start, end, fallback = "") {
1182
+ return asDisplayText(value, fallback).slice(start, end);
1183
+ }
1184
+
1185
+ function safeTail(value, count, fallback = "") {
1186
+ return safeSlice(value, -Math.abs(count), undefined, fallback);
1187
+ }
1188
+
1189
+ function normalizePodName(podName) {
1190
+ const normalized = asDisplayText(podName, "unknown").trim();
1191
+ return normalized || "unknown";
1192
+ }
1193
+
687
1194
  /** Extract short display ID (last 8 chars of session UUID) from an orchId or sessionId. */
688
1195
  function shortId(id) {
689
- const sid = id.startsWith("session-") ? id.slice(8) : id;
1196
+ const rawId = asDisplayText(id);
1197
+ const sid = rawId.startsWith("session-") ? rawId.slice(8) : rawId;
690
1198
  return sid.slice(-8);
691
1199
  }
692
1200
 
@@ -702,10 +1210,11 @@ const screen = blessed.screen({
702
1210
  title: BASE_TUI_TITLE,
703
1211
  fullUnicode: true,
704
1212
  forceUnicode: true,
705
- mouse: false,
1213
+ mouse: true,
706
1214
  });
707
1215
  process.stderr.write = _origStderr;
708
1216
  applyWindowTitle(BASE_TUI_TITLE);
1217
+ enableMouseTracking(screen);
709
1218
 
710
1219
  // ─── Coalescing render loop (Option B) ───────────────────────────
711
1220
  // Instead of rendering on every screen.render() call (80+ sites),
@@ -817,6 +1326,10 @@ setInterval(() => {
817
1326
  _screenDirty = false;
818
1327
  const t0 = performance.now();
819
1328
  _origRender();
1329
+ const selectionDrawRange = applyPaneSelectionOverlay();
1330
+ if (selectionDrawRange) {
1331
+ screen.draw(selectionDrawRange.startY, selectionDrawRange.endY);
1332
+ }
820
1333
  const dur = performance.now() - t0;
821
1334
  _perfRenderCount++;
822
1335
  _perfRenderTotalMs += dur;
@@ -832,23 +1345,35 @@ setInterval(() => {
832
1345
  // ─── Layout calculations ─────────────────────────────────────────
833
1346
  // Left column: sessions (top) + chat (bottom). Right column: full-height logs.
834
1347
 
835
- let rightPaneAdjust = Math.floor(screen.width * 0.55 * 0.25); // start right pane at 3/4 of default
836
- function leftW() { return Math.floor(screen.width * 0.45) + rightPaneAdjust; }
837
- function rightW() { return screen.width - leftW(); }
1348
+ const DEFAULT_LEFT_PANE_RATIO = 0.70;
1349
+ const MIN_LEFT_PANE_WIDTH = 30;
1350
+ const MIN_RIGHT_PANE_WIDTH = 20;
1351
+ let rightPaneAdjust = 0;
1352
+
1353
+ function baseLeftW() {
1354
+ return Math.floor(screen.width * DEFAULT_LEFT_PANE_RATIO);
1355
+ }
1356
+
1357
+ function leftW() {
1358
+ const desired = baseLeftW() + rightPaneAdjust;
1359
+ return Math.max(MIN_LEFT_PANE_WIDTH, Math.min(desired, screen.width - MIN_RIGHT_PANE_WIDTH));
1360
+ }
1361
+
1362
+ function rightW() {
1363
+ return Math.max(MIN_RIGHT_PANE_WIDTH, screen.width - leftW());
1364
+ }
838
1365
  const MIN_PROMPT_EDITOR_ROWS = 1;
839
1366
  const MAX_PROMPT_EDITOR_ROWS = 8;
840
1367
  let promptValueCache = "";
1368
+ let inputBar = null;
841
1369
 
842
1370
  function promptLineCount(text) {
843
1371
  const str = String(text || "");
844
1372
  if (!str) return 1;
845
- // Count visual lines: each \n-delimited line may wrap across multiple visual rows
846
- const width = Math.max(1, screen.width - 2); // input bar inner width (full width minus borders)
847
- let visualLines = 0;
848
- for (const line of str.split("\n")) {
849
- visualLines += Math.max(1, Math.ceil((line.length + 1) / width));
1373
+ if (inputBar?._clines && inputBar._clines.content === str) {
1374
+ return Math.max(1, inputBar._clines.length || 1);
850
1375
  }
851
- return Math.max(1, visualLines);
1376
+ return Math.max(1, getCursorVisualPosition(str, str.length).row + 1);
852
1377
  }
853
1378
 
854
1379
  function promptEditorRows() {
@@ -917,7 +1442,7 @@ const orchList = blessed.list({
917
1442
  scrollbar: { style: { bg: "yellow" } },
918
1443
  keys: false,
919
1444
  vi: false,
920
- mouse: false,
1445
+ mouse: true,
921
1446
  interactive: true,
922
1447
  });
923
1448
 
@@ -991,7 +1516,7 @@ const chatBox = blessed.log({
991
1516
  scrollbar: { style: { bg: "cyan" } },
992
1517
  keys: true,
993
1518
  vi: true,
994
- mouse: false,
1519
+ mouse: true,
995
1520
  });
996
1521
 
997
1522
  // ─── Clickable URLs in chat ──────────────────────────────────────
@@ -1022,6 +1547,8 @@ function extractUrlFromLine(line) {
1022
1547
 
1023
1548
  // Mouse click → open URL in browser
1024
1549
  chatBox.on("click", function (_mouse) {
1550
+ if (Date.now() < suppressPaneClicksUntil) return;
1551
+ if (paneTextSelection.pane === this && paneTextSelection.active && paneTextSelection.dragging) return;
1025
1552
  // Calculate which content line was clicked
1026
1553
  // _mouse.y is absolute screen coordinate
1027
1554
  const absTop = this.atop != null ? this.atop : this.top;
@@ -1049,7 +1576,7 @@ const paneColors = ["yellow", "magenta", "green", "blue"];
1049
1576
  let nextColorIdx = 0;
1050
1577
 
1051
1578
  // Log viewing mode: "workers" | "orchestration" | "sequence" | "nodemap"
1052
- let logViewMode = "orchestration";
1579
+ let logViewMode = "sequence";
1053
1580
  // Markdown viewer overlay — toggled independently via 'v' key.
1054
1581
  // When active, replaces the entire right side (log panes + activity pane).
1055
1582
  let mdViewActive = false;
@@ -1088,25 +1615,20 @@ const orchLogPane = blessed.log({
1088
1615
  scrollbar: { style: { bg: "cyan" } },
1089
1616
  keys: true,
1090
1617
  vi: true,
1091
- mouse: false,
1618
+ mouse: true,
1092
1619
  hidden: true,
1093
1620
  });
1094
1621
 
1095
1622
  // ─── Sequence Diagram Mode (swimlane) ────────────────────────────
1096
1623
  // Vertical scrolling swimlane: one column per worker node.
1097
- // Activity boxes stay in the same column (session affinity).
1098
- // Migration arrows show when affinity resets after dehydrate.
1624
+ // Backed by CMS events (worker_node_id per event, seq-ordered).
1099
1625
 
1100
1626
  const TIME_W = 10; // "HH:MM:SS " — time + trailing space
1101
1627
 
1102
- const seqEventBuffers = new Map(); // orchId → [event]
1103
1628
  const seqNodes = []; // ordered short node names
1104
1629
  const seqNodeSet = new Set();
1105
1630
  const MAX_SEQ_RENDER_EVENTS = 120;
1106
1631
 
1107
- // Track per-session state for rendering
1108
- const seqLastActivityNode = new Map(); // orchId → last node that ran activity
1109
-
1110
1632
  const seqHeaderBox = blessed.box({
1111
1633
  parent: screen,
1112
1634
  tags: true,
@@ -1143,7 +1665,7 @@ const nodeMapPane = blessed.box({
1143
1665
  scrollbar: { style: { bg: "yellow" } },
1144
1666
  keys: true,
1145
1667
  vi: true,
1146
- mouse: false,
1668
+ mouse: true,
1147
1669
  hidden: true,
1148
1670
  });
1149
1671
 
@@ -1174,7 +1696,7 @@ const mdFileListPane = blessed.list({
1174
1696
  },
1175
1697
  keys: false,
1176
1698
  vi: false,
1177
- mouse: false,
1699
+ mouse: true,
1178
1700
  interactive: true,
1179
1701
  hidden: true,
1180
1702
  });
@@ -1199,7 +1721,7 @@ const mdPreviewPane = blessed.box({
1199
1721
  scrollbar: { style: { bg: "green" } },
1200
1722
  keys: true,
1201
1723
  vi: true,
1202
- mouse: false,
1724
+ mouse: true,
1203
1725
  hidden: true,
1204
1726
  });
1205
1727
 
@@ -1261,7 +1783,7 @@ function refreshMarkdownViewer() {
1261
1783
  const f = files[mdViewerSelectedIdx];
1262
1784
  try {
1263
1785
  const raw = fs.readFileSync(f.localPath, "utf-8");
1264
- const rendered = renderMarkdown(raw);
1786
+ const rendered = renderMarkdown(raw, getRenderablePaneWidth(mdPreviewPane, Math.max(40, leftW() - 4)));
1265
1787
  mdPreviewPane.setLabel(` ${f.filename} `);
1266
1788
  mdPreviewPane.setContent(rendered);
1267
1789
  mdPreviewPane.scrollTo(0);
@@ -1301,7 +1823,7 @@ function toggleMdViewOff() {
1301
1823
 
1302
1824
  // ─── Vim keybindings for markdown preview ────────────────────────
1303
1825
  // g = top, G = bottom, Ctrl-d = page down, Ctrl-u = page up
1304
- // o = open in $EDITOR, y = copy path
1826
+ // o = open in default app, y = copy path
1305
1827
  mdPreviewPane.key(["g"], () => { mdPreviewPane.scrollTo(0); scheduleRender(); });
1306
1828
  mdPreviewPane.key(["S-g"], () => { mdPreviewPane.setScrollPerc(100); scheduleRender(); });
1307
1829
  mdPreviewPane.key(["C-d"], () => {
@@ -1318,19 +1840,18 @@ mdPreviewPane.key(["o"], () => {
1318
1840
  const files = scanExportFiles();
1319
1841
  const f = files[mdViewerSelectedIdx];
1320
1842
  if (!f) return;
1321
- const editor = process.env.EDITOR || (process.platform === "darwin" ? "open" : "xdg-open");
1322
- spawn(editor, [f.localPath], { detached: true, stdio: "ignore" }).unref();
1843
+ openPathInDefaultApp(f.localPath);
1323
1844
  });
1324
1845
  mdPreviewPane.key(["y"], () => {
1325
1846
  const files = scanExportFiles();
1326
1847
  const f = files[mdViewerSelectedIdx];
1327
1848
  if (!f) return;
1328
- // Copy path to clipboard (macOS pbcopy, Linux xclip)
1329
- const cmd = process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard";
1330
- const proc = spawn(process.platform === "darwin" ? "pbcopy" : "xclip", process.platform === "darwin" ? [] : ["-selection", "clipboard"], { stdio: ["pipe", "ignore", "ignore"] });
1331
- proc.stdin.write(f.localPath);
1332
- proc.stdin.end();
1333
- setStatus(`{green-fg}Copied: ${f.localPath}{/green-fg}`);
1849
+ const copied = copyTextToClipboard(f.localPath, {
1850
+ onError: (err) => appendLog(`{red-fg}Copy failed: ${err.message}{/red-fg}`),
1851
+ });
1852
+ setStatus(copied
1853
+ ? `{green-fg}Copied: ${f.localPath}{/green-fg}`
1854
+ : "Copy failed — check logs");
1334
1855
  });
1335
1856
 
1336
1857
  // ─── Activity pane (sticky, bottom-right) ────────────────────────
@@ -1356,7 +1877,7 @@ const activityPane = blessed.log({
1356
1877
  scrollbar: { style: { bg: "gray" } },
1357
1878
  keys: true,
1358
1879
  vi: true,
1359
- mouse: false,
1880
+ mouse: true,
1360
1881
  });
1361
1882
 
1362
1883
  // Per-session activity buffers
@@ -1408,6 +1929,14 @@ function formatToolArgsSummary(toolName, args) {
1408
1929
  : "";
1409
1930
  return ` ${seconds}${preserve}${reason}`;
1410
1931
  }
1932
+ if (toolName === "cron") {
1933
+ if (args.action === "cancel") return " cancel";
1934
+ const seconds = args.seconds != null ? `${args.seconds}s` : "?";
1935
+ const reason = typeof args.reason === "string" && args.reason
1936
+ ? ` reason=${JSON.stringify(args.reason)}`
1937
+ : "";
1938
+ return ` ${seconds}${reason}`;
1939
+ }
1411
1940
 
1412
1941
  const entries = Object.entries(args)
1413
1942
  .slice(0, 4)
@@ -1456,10 +1985,30 @@ function ensureSessionSplashBuffer(orchId) {
1456
1985
  * Refresh the node map pane — vertical columns, one per worker node,
1457
1986
  * with sessions stacked underneath, color-coded by live status.
1458
1987
  */
1988
+ let _nodeMapTimelineLoadPending = false;
1989
+
1459
1990
  function refreshNodeMap() {
1460
1991
  const lines = [];
1461
1992
  nodeMapPane.setContent("");
1462
1993
 
1994
+ // Ensure CMS timelines are loaded for all sessions so we can map them to
1995
+ // worker columns. Only the active session's timeline is loaded eagerly;
1996
+ // kick off background loads for every other session the first time the
1997
+ // Node Map is opened (and whenever new sessions appear).
1998
+ const missingTimelines = [];
1999
+ for (const orchId of knownOrchestrationIds) {
2000
+ const sid = orchId.startsWith("session-") ? orchId.slice(8) : orchId;
2001
+ if (!cmsSeqTimelines.has(sid)) missingTimelines.push(sid);
2002
+ }
2003
+ if (missingTimelines.length > 0 && !_nodeMapTimelineLoadPending) {
2004
+ _nodeMapTimelineLoadPending = true;
2005
+ Promise.allSettled(missingTimelines.map(sid => loadCmsSeqTimeline(sid)))
2006
+ .then(() => {
2007
+ _nodeMapTimelineLoadPending = false;
2008
+ if (logViewMode === "nodemap") refreshNodeMap();
2009
+ });
2010
+ }
2011
+
1463
2012
  // Gather all known nodes from seqNodes (worker pane names)
1464
2013
  // Filter out synthetic nodes like "cms" that aren't real workers.
1465
2014
  const SYNTHETIC_NODES = new Set(["cms"]);
@@ -1480,9 +2029,20 @@ function refreshNodeMap() {
1480
2029
  const UNASSIGNED = "(unknown)";
1481
2030
  nodeSessionMap.set(UNASSIGNED, []);
1482
2031
 
1483
- // Walk all known orchestrations and assign to their last-known node
2032
+ // Walk all known orchestrations and assign to their last-known node (from CMS events).
1484
2033
  for (const orchId of knownOrchestrationIds) {
1485
- const node = seqLastActivityNode.get(orchId);
2034
+ const sid = orchId.startsWith("session-") ? orchId.slice(8) : orchId;
2035
+ const cmsTimeline = cmsSeqTimelines.get(sid);
2036
+ let node;
2037
+ if (cmsTimeline && cmsTimeline.length > 0) {
2038
+ for (let i = cmsTimeline.length - 1; i >= 0; i--) {
2039
+ const nodeId = getSeqEventNodeId(cmsTimeline[i]);
2040
+ if (nodeId && nodeId !== "(unknown)") {
2041
+ node = safeTail(nodeId, 5);
2042
+ break;
2043
+ }
2044
+ }
2045
+ }
1486
2046
  const status = getSessionVisualState(orchId);
1487
2047
  const uuid4 = shortId(orchId);
1488
2048
  const title = sessionHeadings.get(orchId);
@@ -1507,8 +2067,9 @@ function refreshNodeMap() {
1507
2067
 
1508
2068
  // Pad/clip text to column width (plain text, no tags)
1509
2069
  const fitCol = (text, w) => {
1510
- if (text.length > w) return text.slice(0, w);
1511
- return text + " ".repeat(w - text.length);
2070
+ const displayText = asDisplayText(text);
2071
+ if (displayText.length > w) return safeSlice(displayText, 0, w);
2072
+ return displayText + " ".repeat(w - displayText.length);
1512
2073
  };
1513
2074
 
1514
2075
  // Render header row: node names
@@ -1574,6 +2135,7 @@ function refreshNodeMap() {
1574
2135
  lines.push("");
1575
2136
  lines.push(
1576
2137
  "{green-fg}* running{/green-fg} " +
2138
+ "{magenta-fg}~ cron{/magenta-fg} " +
1577
2139
  "{yellow-fg}~ waiting{/yellow-fg} " +
1578
2140
  "{white-fg}. idle{/white-fg} " +
1579
2141
  "{cyan-fg}? input{/cyan-fg} " +
@@ -1607,10 +2169,233 @@ const seqPane = blessed.log({
1607
2169
  scrollbar: { style: { bg: "magenta" } },
1608
2170
  keys: true,
1609
2171
  vi: true,
1610
- mouse: false,
2172
+ mouse: true,
1611
2173
  hidden: true,
1612
2174
  });
1613
2175
 
2176
+ const selectablePaneLabels = new Map();
2177
+ const selectablePaneMouseBindings = new WeakSet();
2178
+ const SELECTION_BG = blessedColors.convert("yellow");
2179
+ const SELECTION_FG = blessedColors.convert("black");
2180
+ const paneTextSelection = {
2181
+ pane: null,
2182
+ anchor: null,
2183
+ head: null,
2184
+ active: false,
2185
+ dragging: false,
2186
+ visible: false,
2187
+ };
2188
+
2189
+ function selectionInteractionBlocked() {
2190
+ return Boolean(_fileAttachModal || startupLandingVisible || slashPicker);
2191
+ }
2192
+
2193
+ function registerSelectablePane(pane, label) {
2194
+ selectablePaneLabels.set(pane, label);
2195
+ try {
2196
+ pane.enableMouse?.();
2197
+ } catch {}
2198
+ if (!pane || selectablePaneMouseBindings.has(pane)) return;
2199
+ selectablePaneMouseBindings.add(pane);
2200
+ pane.on("mousedown", (data) => {
2201
+ if (selectionInteractionBlocked()) return;
2202
+ if (paneTextSelection.active && paneTextSelection.pane === pane) return;
2203
+ beginPaneSelection(data, pane);
2204
+ });
2205
+ pane.onScreenEvent("mouse", (data) => {
2206
+ if (selectionInteractionBlocked()) return;
2207
+ if (paneTextSelection.pane !== pane || !paneTextSelection.active) return;
2208
+ if (data.action === "mousemove" || data.action === "mousedown") {
2209
+ updatePaneSelection(data);
2210
+ return;
2211
+ }
2212
+ if (data.action === "mouseup") {
2213
+ updatePaneSelection(data);
2214
+ finalizePaneSelection();
2215
+ }
2216
+ });
2217
+ }
2218
+
2219
+ function getSelectablePaneLabel(pane) {
2220
+ return selectablePaneLabels.get(pane) || "pane";
2221
+ }
2222
+
2223
+ function clearPaneSelection({ render = true } = {}) {
2224
+ paneTextSelection.pane = null;
2225
+ paneTextSelection.anchor = null;
2226
+ paneTextSelection.head = null;
2227
+ paneTextSelection.active = false;
2228
+ paneTextSelection.dragging = false;
2229
+ paneTextSelection.visible = false;
2230
+ if (render) scheduleRender();
2231
+ }
2232
+
2233
+ function getPaneInnerBounds(pane) {
2234
+ if (!pane || pane.hidden || pane.visible === false || !pane.lpos) return null;
2235
+
2236
+ const xi = pane.lpos.xi + (pane.ileft || 0);
2237
+ const xl = pane.lpos.xl - (pane.iright || 0) - 1;
2238
+ const yi = pane.lpos.yi + (pane.itop || 0);
2239
+ const yl = pane.lpos.yl - (pane.ibottom || 0) - 1;
2240
+
2241
+ if (xi > xl || yi > yl) return null;
2242
+ return { xi, xl, yi, yl };
2243
+ }
2244
+
2245
+ function clampSelectionPoint(bounds, x, y) {
2246
+ return {
2247
+ x: Math.max(bounds.xi, Math.min(bounds.xl, x)),
2248
+ y: Math.max(bounds.yi, Math.min(bounds.yl, y)),
2249
+ };
2250
+ }
2251
+
2252
+ function normalizeSelectionRange(anchor, head) {
2253
+ if (head.y < anchor.y || (head.y === anchor.y && head.x < anchor.x)) {
2254
+ return { start: head, end: anchor };
2255
+ }
2256
+ return { start: anchor, end: head };
2257
+ }
2258
+
2259
+ function extractPaneSelectionText(selection = paneTextSelection) {
2260
+ if (!selection.pane || !selection.anchor || !selection.head) return "";
2261
+
2262
+ const bounds = getPaneInnerBounds(selection.pane);
2263
+ if (!bounds) return "";
2264
+
2265
+ const anchor = clampSelectionPoint(bounds, selection.anchor.x, selection.anchor.y);
2266
+ const head = clampSelectionPoint(bounds, selection.head.x, selection.head.y);
2267
+ const { start, end } = normalizeSelectionRange(anchor, head);
2268
+ const rows = [];
2269
+
2270
+ for (let y = start.y; y <= end.y; y++) {
2271
+ const fromX = y === start.y ? start.x : bounds.xi;
2272
+ const toX = y === end.y ? end.x : bounds.xl;
2273
+ const line = screen.lines[y] || [];
2274
+ let row = "";
2275
+ for (let x = fromX; x <= toX; x++) {
2276
+ const ch = line[x]?.[1];
2277
+ row += ch && ch !== "\x00" ? ch : " ";
2278
+ }
2279
+ rows.push(row.replace(/[ \t]+$/u, ""));
2280
+ }
2281
+
2282
+ return rows.join("\n").replace(/\n+$/u, "");
2283
+ }
2284
+
2285
+ function applyPaneSelectionOverlay() {
2286
+ if (!paneTextSelection.visible || !paneTextSelection.pane) return null;
2287
+
2288
+ const bounds = getPaneInnerBounds(paneTextSelection.pane);
2289
+ if (!bounds) {
2290
+ clearPaneSelection({ render: false });
2291
+ return null;
2292
+ }
2293
+
2294
+ const anchor = clampSelectionPoint(bounds, paneTextSelection.anchor.x, paneTextSelection.anchor.y);
2295
+ const head = clampSelectionPoint(bounds, paneTextSelection.head.x, paneTextSelection.head.y);
2296
+ const { start, end } = normalizeSelectionRange(anchor, head);
2297
+
2298
+ for (let y = start.y; y <= end.y; y++) {
2299
+ const fromX = y === start.y ? start.x : bounds.xi;
2300
+ const toX = y === end.y ? end.x : bounds.xl;
2301
+ const line = screen.lines[y];
2302
+ if (!line) continue;
2303
+
2304
+ for (let x = fromX; x <= toX; x++) {
2305
+ if (!line[x]) continue;
2306
+ const attr = line[x][0];
2307
+ line[x][0] = (attr & ~((0x1ff << 9) | 0x1ff | (8 << 18)))
2308
+ | (1 << 18)
2309
+ | (SELECTION_FG << 9)
2310
+ | SELECTION_BG;
2311
+ }
2312
+ line.dirty = true;
2313
+ }
2314
+
2315
+ return { startY: start.y, endY: end.y };
2316
+ }
2317
+
2318
+ function beginPaneSelection(mouse, pane) {
2319
+ const bounds = getPaneInnerBounds(pane);
2320
+ const hadVisibleSelection = paneTextSelection.visible;
2321
+
2322
+ clearPaneSelection({ render: false });
2323
+
2324
+ if (!bounds) {
2325
+ if (hadVisibleSelection) scheduleRender();
2326
+ return;
2327
+ }
2328
+
2329
+ const point = clampSelectionPoint(bounds, mouse.x, mouse.y);
2330
+ paneTextSelection.pane = pane;
2331
+ paneTextSelection.anchor = point;
2332
+ paneTextSelection.head = point;
2333
+ paneTextSelection.active = true;
2334
+ paneTextSelection.dragging = false;
2335
+ paneTextSelection.visible = false;
2336
+ if (typeof pane.focus === "function" && screen.focused !== pane) {
2337
+ pane.focus();
2338
+ }
2339
+ hideTerminalCursor();
2340
+ setStatus(`Drag to select text in ${getSelectablePaneLabel(pane)}...`);
2341
+
2342
+ scheduleRender();
2343
+ }
2344
+
2345
+ function updatePaneSelection(mouse) {
2346
+ if (!paneTextSelection.active || !paneTextSelection.pane || !paneTextSelection.anchor) return;
2347
+
2348
+ const bounds = getPaneInnerBounds(paneTextSelection.pane);
2349
+ if (!bounds) {
2350
+ clearPaneSelection();
2351
+ return;
2352
+ }
2353
+
2354
+ const next = clampSelectionPoint(bounds, mouse.x, mouse.y);
2355
+ if (paneTextSelection.head
2356
+ && paneTextSelection.head.x === next.x
2357
+ && paneTextSelection.head.y === next.y) {
2358
+ return;
2359
+ }
2360
+
2361
+ paneTextSelection.head = next;
2362
+ paneTextSelection.dragging = true;
2363
+ paneTextSelection.visible = paneTextSelection.anchor.x !== next.x
2364
+ || paneTextSelection.anchor.y !== next.y;
2365
+ scheduleRender();
2366
+ }
2367
+
2368
+ function finalizePaneSelection() {
2369
+ if (!paneTextSelection.pane) return;
2370
+ paneTextSelection.active = false;
2371
+
2372
+ if (!paneTextSelection.visible || !paneTextSelection.dragging) {
2373
+ clearPaneSelection();
2374
+ return;
2375
+ }
2376
+
2377
+ const text = extractPaneSelectionText();
2378
+ suppressPaneClicksUntil = Date.now() + 250;
2379
+
2380
+ if (!text) {
2381
+ clearPaneSelection();
2382
+ setStatus("Nothing to copy");
2383
+ return;
2384
+ }
2385
+
2386
+ const copied = copyTextToClipboard(text, {
2387
+ onError: (err) => appendLog(`{red-fg}Copy failed: ${err.message}{/red-fg}`),
2388
+ });
2389
+ const paneLabel = getSelectablePaneLabel(paneTextSelection.pane);
2390
+ const charCount = text.length;
2391
+ paneTextSelection.dragging = false;
2392
+ paneTextSelection.visible = true;
2393
+ setStatus(copied
2394
+ ? `Copied ${charCount} chars from ${paneLabel}`
2395
+ : "Copy failed — check logs");
2396
+ scheduleRender();
2397
+ }
2398
+
1614
2399
  // ─── Register focus ring on all panes ────────────────────────────
1615
2400
  registerFocusRing(orchList, "yellow");
1616
2401
  registerFocusRing(chatBox, "cyan");
@@ -1629,17 +2414,15 @@ activityPane.on("focus", () => setNavigationStatusForPane("activity"));
1629
2414
  seqPane.on("focus", () => setNavigationStatusForPane("sequence"));
1630
2415
  // Worker panes are created dynamically — registered in getOrCreateWorkerPane()
1631
2416
 
1632
- function addSeqNode(podName) {
1633
- const short = podName.slice(-5);
1634
- if (seqNodeSet.has(short)) return short;
1635
- seqNodeSet.add(short);
1636
- seqNodes.push(short);
1637
- // Update sticky header when a new node is discovered
1638
- if (logViewMode === "sequence") {
1639
- updateSeqHeader();
1640
- }
1641
- return short;
1642
- }
2417
+ registerSelectablePane(orchList, "sessions");
2418
+ registerSelectablePane(chatBox, "chat");
2419
+ registerSelectablePane(orchLogPane, "orchestration logs");
2420
+ registerSelectablePane(seqHeaderBox, "sequence header");
2421
+ registerSelectablePane(seqPane, "sequence");
2422
+ registerSelectablePane(nodeMapPane, "node map");
2423
+ registerSelectablePane(mdFileListPane, "markdown file list");
2424
+ registerSelectablePane(mdPreviewPane, "markdown preview");
2425
+ registerSelectablePane(activityPane, "activity");
1643
2426
 
1644
2427
  // Compute column width dynamically from pane inner width
1645
2428
  // Returns an array of per-column widths so remaining pixels are distributed
@@ -1657,13 +2440,6 @@ function seqColWidths() {
1657
2440
  return widths;
1658
2441
  }
1659
2442
 
1660
- // Legacy helper — returns the base column width (used in separator/header)
1661
- function seqColW() {
1662
- const innerW = (seqPane.width || 60) - 4;
1663
- const ncols = seqNodes.length || 1;
1664
- return Math.max(8, Math.floor((innerW - TIME_W) / ncols));
1665
- }
1666
-
1667
2443
  // Measure display width of a string (emoji = 2 cells)
1668
2444
  // eslint-disable-next-line no-control-regex
1669
2445
  const EMOJI_RE = /[\u{1F300}-\u{1FAD6}\u{2600}-\u{27BF}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}\u{E0020}-\u{E007F}]/gu;
@@ -1697,16 +2473,19 @@ function displayWidth(str) {
1697
2473
 
1698
2474
  // Pad string to target display width
1699
2475
  function padToWidth(str, targetW) {
1700
- const w = displayWidth(str);
2476
+ const text = asDisplayText(str);
2477
+ const w = displayWidth(text);
1701
2478
  const need = Math.max(0, targetW - w);
1702
- return str + " ".repeat(need);
2479
+ return text + " ".repeat(need);
1703
2480
  }
1704
2481
 
1705
2482
  // Build one swimlane line: place content in a specific column
1706
- function seqLine(time, colIdx, content, color) {
2483
+ function seqLine(time, colIdx, content, color, options = {}) {
1707
2484
  const ncols = seqNodes.length || 1;
1708
2485
  const widths = seqColWidths();
1709
2486
  const timeStr = (time || "").padEnd(TIME_W);
2487
+ const displayContent = asDisplayText(content);
2488
+ const underline = options.underline === true;
1710
2489
  let line = `{white-fg}${timeStr}{/white-fg}`;
1711
2490
 
1712
2491
  for (let i = 0; i < ncols; i++) {
@@ -1714,11 +2493,15 @@ function seqLine(time, colIdx, content, color) {
1714
2493
  if (i === colIdx) {
1715
2494
  // Content cell — clip to fit
1716
2495
  const maxContent = w - 2; // 1 space padding each side
1717
- const clipped = content.length > maxContent ? content.slice(0, maxContent) : content;
1718
- // Pad the clipped text to maxContent BEFORE applying color tags
1719
- const padded = padToWidth(clipped, maxContent);
1720
- const colored = color ? `{${color}-fg}${padded}{/${color}-fg}` : padded;
1721
- line += ` ${colored} `;
2496
+ const clipped = displayContent.length > maxContent
2497
+ ? safeSlice(displayContent, 0, maxContent)
2498
+ : displayContent;
2499
+ const remaining = Math.max(0, maxContent - displayWidth(clipped));
2500
+ const leftPadding = " ".repeat(Math.floor(remaining / 2));
2501
+ const rightPadding = " ".repeat(remaining - Math.floor(remaining / 2));
2502
+ let styled = color ? `{${color}-fg}${clipped}{/${color}-fg}` : clipped;
2503
+ if (underline) styled = `{underline}${styled}{/underline}`;
2504
+ line += ` ${leftPadding}${styled}${rightPadding} `;
1722
2505
  } else {
1723
2506
  // Empty cell — vertical bar for the swimlane (ASCII | avoids
1724
2507
  // ambiguous-width issues with Unicode box-drawing characters)
@@ -1729,17 +2512,6 @@ function seqLine(time, colIdx, content, color) {
1729
2512
  return line;
1730
2513
  }
1731
2514
 
1732
- // Full-width separator line for CAN / migration events
1733
- function seqSeparator(label, color) {
1734
- const widths = seqColWidths();
1735
- const totalW = TIME_W + widths.reduce((a, b) => a + b, 0);
1736
- const labelStr = ` ${label} `;
1737
- const dashCount = Math.max(0, totalW - labelStr.length);
1738
- const left = Math.floor(dashCount / 2);
1739
- const right = dashCount - left;
1740
- return `{${color}-fg}${"-".repeat(left)}${labelStr}${"-".repeat(right)}{/${color}-fg}`;
1741
- }
1742
-
1743
2515
  function seqHeader() {
1744
2516
  const widths = seqColWidths();
1745
2517
  let header = "{bold}" + "TIME".padEnd(TIME_W);
@@ -1758,324 +2530,322 @@ function seqHeader() {
1758
2530
  }
1759
2531
 
1760
2532
  /**
1761
- * Parse a raw log line into a sequence event.
1762
- * Returns null if the line isn't relevant for the sequence diagram.
2533
+ * Update the sticky header box with current node columns.
1763
2534
  */
1764
- function parseSeqEvent(plain, podName) {
1765
- const iMatch = plain.match(/instance_id=(\S+)/);
1766
- if (!iMatch) return null;
1767
- const orchId = iMatch[1].replace(/,.*$/, "");
1768
- if (!orchId.startsWith("session-")) return null;
1769
-
1770
- const tMatch = plain.match(/\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}:\d{2})/);
1771
- const time = tMatch ? tMatch[1] : "";
1772
-
1773
- const orchNode = addSeqNode(podName);
2535
+ function updateSeqHeader() {
2536
+ if (seqNodes.length > 0) {
2537
+ const [header, divider] = seqHeader();
2538
+ seqHeaderBox.setContent(`${header}\n${divider}`);
2539
+ } else {
2540
+ seqHeaderBox.setContent("{bold}TIME (waiting for events){/bold}");
2541
+ }
2542
+ }
1774
2543
 
1775
- // Extract worker node from activity logs
1776
- // Try local-mode pattern first (local-rt-N), then remote pod pattern
1777
- const wMatch = plain.match(/worker_id=\S*?(local-rt-\d+)/)
1778
- || plain.match(/worker_id=work-\d+-(\S+)-rt-\d+/);
1779
- const actNode = wMatch ? addSeqNode(wMatch[1]) : orchNode;
2544
+ /**
2545
+ * Full re-render of the sequence pane for the active session.
2546
+ */
2547
+ function refreshSeqPane() {
2548
+ refreshCmsSeqPane();
2549
+ }
1780
2550
 
1781
- // ─── Orchestration events (dots) ──────────
1782
- if (plain.includes("[turn ")) {
1783
- const turnMatch = plain.match(/\[turn (\d+)\]/);
1784
- const promptMatch = plain.match(/prompt="([^"]{0,30})/);
1785
- return { orchId, time, type: "turn", orchNode, actNode,
1786
- turn: turnMatch?.[1] || "?",
1787
- prompt: promptMatch?.[1] || "" };
1788
- }
1789
- if (plain.includes("execution start") || plain.includes("[orch] start:")) {
1790
- const iterMatch = plain.match(/iteration=(\d+)/) || plain.match(/iter=(\d+)/);
1791
- const hydrate = plain.includes("needsHydration=true") || plain.includes("hydrate=true");
1792
- return { orchId, time, type: "exec_start", orchNode, actNode,
1793
- iteration: parseInt(iterMatch?.[1] || "0", 10),
1794
- hydrate };
1795
- }
1796
- if (plain.includes("timer completed")) {
1797
- const sMatch = plain.match(/seconds=(\d+)/);
1798
- return { orchId, time, type: "timer_fired", orchNode, actNode,
1799
- seconds: sMatch?.[1] || "?" };
1800
- }
1801
- if (plain.includes("idle timeout")) {
1802
- return { orchId, time, type: "dehydrate", orchNode, actNode };
1803
- }
1804
- if (plain.includes("user responded within idle")) {
1805
- return { orchId, time, type: "user_idle", orchNode, actNode };
1806
- }
1807
- if (plain.includes("wait interrupted")) {
1808
- return { orchId, time, type: "interrupt", orchNode, actNode };
1809
- }
2551
+ // ─── CMS-Backed Sequence Diagram ─────────────────────────────────
2552
+ // Replaces log-parsed sequence data with CMS events from the management client.
2553
+ // CMS events have: seq (ordering), created_at (display time), event_type, data, worker_node_id.
1810
2554
 
1811
- // ─── Activity events (boxes) ──────────
1812
- if (plain.includes("[activity]") && (plain.includes("activity_name=runAgentTurn") || plain.includes("activity_name=runTurn"))) {
1813
- if (plain.includes("resuming session")) {
1814
- return { orchId, time, type: "resume", orchNode, actNode };
1815
- }
1816
- if (plain.includes("re-hydrating")) {
1817
- return { orchId, time, type: "resume", orchNode, actNode };
1818
- }
1819
- return { orchId, time, type: "activity_start", orchNode, actNode };
1820
- }
1821
- if (plain.includes("activity_name=dehydrateSession")) {
1822
- return { orchId, time, type: "dehydrate_act", orchNode, actNode };
1823
- }
1824
- if (plain.includes("activity_name=hydrateSession")) {
1825
- return { orchId, time, type: "hydrate_act", orchNode, actNode };
1826
- }
1827
- if (plain.includes("activity_name=listModels")) {
1828
- return { orchId, time, type: "listmodels_act", orchNode, actNode };
1829
- }
2555
+ const cmsSeqTimelines = new Map(); // sessionId → SeqEvent[]
2556
+ const cmsSeqLastSeq = new Map(); // sessionId → last seq seen (for incremental poll)
2557
+ const ORCHESTRATOR_SEQ_TYPES = new Set([
2558
+ "wait",
2559
+ "timer",
2560
+ "cron_start",
2561
+ "cron_fire",
2562
+ "cron_cancel",
2563
+ "spawn",
2564
+ "cmd_recv",
2565
+ "cmd_done",
2566
+ ]);
1830
2567
 
1831
- // ─── Command dispatch events ──────────
1832
- if (plain.includes("[orch-cmd]")) {
1833
- const cmdMatch = plain.match(/received command: (\S+)/);
1834
- if (cmdMatch) {
1835
- return { orchId, time, type: "cmd_recv", orchNode, actNode,
1836
- cmd: cmdMatch[1] };
1837
- }
1838
- const modelMatch = plain.match(/model changed: (.+)/);
1839
- if (modelMatch) {
1840
- return { orchId, time, type: "cmd_done", orchNode, actNode,
1841
- detail: modelMatch[1] };
1842
- }
1843
- }
2568
+ function isOrchestratorSeqEventType(type) {
2569
+ return ORCHESTRATOR_SEQ_TYPES.has(type);
2570
+ }
1844
2571
 
1845
- // ─── Grace period dehydration ──────────
1846
- if (plain.includes("Grace period elapsed, dehydrating")) {
1847
- return { orchId, time, type: "dehydrate", orchNode, actNode };
1848
- }
1849
- // explicit dehydrate log from orchestration
1850
- if (plain.includes("[orch] dehydrating session")) {
1851
- return { orchId, time, type: "dehydrate", orchNode, actNode };
1852
- }
2572
+ function getSeqEventNodeId(event) {
2573
+ return event?.displayWorkerNodeId || event?.workerNodeId || "(unknown)";
2574
+ }
1853
2575
 
1854
- // ─── Agent output events ──────────
1855
- if (plain.includes("[durable-agent] Durable timer") || plain.includes("[orch] durable timer:")) {
1856
- const sMatch = plain.match(/(?:Durable timer|durable timer):\s*(\d+)s/);
1857
- return { orchId, time, type: "wait", orchNode, actNode,
1858
- seconds: sMatch?.[1] || "?" };
1859
- }
1860
- if (plain.includes("[durable-agent] Intermediate content") || plain.includes("[orch] intermediate:")) {
1861
- const cMatch = plain.match(/(?:Intermediate content|intermediate):\s*(.{0,25})/);
1862
- return { orchId, time, type: "content", orchNode, actNode,
1863
- snippet: cMatch?.[1] || "…" };
1864
- }
1865
- if (plain.includes("[response]")) {
1866
- const rMatch = plain.match(/\[response\] (.{0,25})/);
1867
- return { orchId, time, type: "response", orchNode, actNode,
1868
- snippet: rMatch?.[1] || "" };
2576
+ function findPriorActivitySeqNodeId(timeline) {
2577
+ for (let i = timeline.length - 1; i >= 0; i--) {
2578
+ const candidate = timeline[i];
2579
+ if (!candidate || isOrchestratorSeqEventType(candidate.type)) continue;
2580
+ const nodeId = getSeqEventNodeId(candidate);
2581
+ if (nodeId && nodeId !== "(unknown)") return nodeId;
1869
2582
  }
1870
- // [runTurn] activity log
1871
- if (plain.includes("[runTurn]")) {
1872
- return { orchId, time, type: "activity_start", orchNode, actNode };
1873
- }
1874
-
1875
2583
  return null;
1876
2584
  }
1877
2585
 
1878
- /**
1879
- * Inject a synthetic "user sent a message" marker into the sequence diagram.
1880
- * Called from handleInput so the interaction is visible immediately,
1881
- * without waiting for kubectl logs to stream back.
1882
- */
1883
- function injectSeqUserEvent(orchId, label) {
1884
- const now = formatDisplayTime(Date.now(), { hour: "2-digit", minute: "2-digit", second: "2-digit" });
1885
- // Find the last node that ran an activity for this session, or fall back to first node
1886
- const lastAct = seqLastActivityNode.get(orchId) || seqNodes[0];
1887
- if (!lastAct) return; // no nodes yet
1888
- const col = seqNodes.indexOf(lastAct);
1889
- if (col < 0) return;
1890
-
1891
- const synth = { orchId, time: now, type: "user_msg_synth", orchNode: lastAct, actNode: lastAct, label };
1892
- appendSeqEvent(orchId, synth);
2586
+ function placeCmsSeqEvent(timeline, event) {
2587
+ if (!event) return null;
2588
+ const next = { ...event };
2589
+ if (isOrchestratorSeqEventType(next.type)) {
2590
+ const priorActivityNodeId = findPriorActivitySeqNodeId(timeline);
2591
+ if (priorActivityNodeId) next.displayWorkerNodeId = priorActivityNodeId;
2592
+ next.underline = true;
2593
+ }
2594
+ return next;
1893
2595
  }
1894
2596
 
1895
2597
  /**
1896
- * Append a parsed event and render it if sequence mode is active.
2598
+ * Map a CMS event to a sequence diagram event.
2599
+ * Returns null for event types that shouldn't appear in the diagram.
1897
2600
  */
1898
- function appendSeqEvent(orchId, event) {
1899
- if (!seqEventBuffers.has(orchId)) seqEventBuffers.set(orchId, []);
1900
- const buf = seqEventBuffers.get(orchId);
1901
- buf.push(event);
1902
- if (buf.length > 300) buf.splice(0, buf.length - 300);
1903
-
1904
- // Always track which node each session is on (for node map view),
1905
- // not just when the sequence pane is rendering.
1906
- if (event.type === "activity_start" || event.type === "resume" || event.type === "hydrate_act") {
1907
- seqLastActivityNode.set(orchId, event.actNode);
1908
- } else if (event.actNode && !seqLastActivityNode.has(orchId)) {
1909
- // First event for this session use whatever node we see
1910
- seqLastActivityNode.set(orchId, event.actNode);
1911
- }
1912
-
1913
- if (logViewMode === "sequence" && orchId === activeOrchId) {
1914
- renderSeqEventLine(event, orchId);
1915
- screen.render();
2601
+ function cmsEventToSeqEvent(evt) {
2602
+ const time = evt.createdAt instanceof Date
2603
+ ? formatDisplayTime(evt.createdAt.getTime(), { hour: "2-digit", minute: "2-digit", second: "2-digit" })
2604
+ : "";
2605
+ const node = evt.workerNodeId || "(unknown)";
2606
+ const base = { seq: evt.seq, time, workerNodeId: node };
2607
+
2608
+ switch (evt.eventType) {
2609
+ case "session.turn_started":
2610
+ return { ...base, type: "turn_start", detail: `turn ${evt.data?.iteration ?? "?"}` };
2611
+ case "session.turn_completed":
2612
+ return { ...base, type: "turn_end", detail: `turn ${evt.data?.iteration ?? "?"} done` };
2613
+ case "user.message": {
2614
+ const content = typeof evt.data?.content === "string" ? evt.data.content : "";
2615
+ const snip = summarizeActivityPreview(content, 20);
2616
+ return { ...base, type: "user_msg", detail: snip };
2617
+ }
2618
+ case "assistant.message": {
2619
+ const content = typeof evt.data?.content === "string" ? evt.data.content : "";
2620
+ const snip = summarizeActivityPreview(content, 20);
2621
+ return { ...base, type: "response", detail: snip };
2622
+ }
2623
+ case "session.wait_started":
2624
+ return { ...base, type: "wait", detail: `wait ${evt.data?.seconds ?? "?"}s` };
2625
+ case "session.wait_completed":
2626
+ return { ...base, type: "timer", detail: `${evt.data?.seconds ?? "?"}s up` };
2627
+ case "session.dehydrated":
2628
+ return { ...base, type: "dehydrate", detail: `ZZ ${evt.data?.reason ?? ""}` };
2629
+ case "session.hydrated":
2630
+ return { ...base, type: "hydrate", detail: "^^ rehydrated" };
2631
+ case "session.agent_spawned": {
2632
+ const who = evt.data?.agentId || evt.data?.childSessionId?.slice(0, 8) || "agent";
2633
+ return { ...base, type: "spawn", detail: `spawn ${who}` };
2634
+ }
2635
+ case "session.cron_started":
2636
+ return { ...base, type: "cron_start", detail: `cron ${formatHumanDurationSeconds(evt.data?.intervalSeconds ?? "?")}` };
2637
+ case "session.cron_fired":
2638
+ return { ...base, type: "cron_fire", detail: "cron fired" };
2639
+ case "session.cron_cancelled":
2640
+ return { ...base, type: "cron_cancel", detail: "cron off" };
2641
+ case "session.command_received":
2642
+ return { ...base, type: "cmd_recv", detail: `/${evt.data?.cmd ?? "?"}` };
2643
+ case "session.command_completed":
2644
+ return { ...base, type: "cmd_done", detail: `/${evt.data?.cmd ?? "?"} ok` };
2645
+ case "session.compaction_start":
2646
+ return { ...base, type: "compaction", detail: "compaction…" };
2647
+ case "session.compaction_complete":
2648
+ return { ...base, type: "compaction", detail: "compacted" };
2649
+ case "session.error":
2650
+ return { ...base, type: "error", detail: `err: ${(evt.data?.message || "").slice(0, 20)}` };
2651
+ // Skip purely-data events that don't add visual value
2652
+ case "assistant.usage":
2653
+ case "session.usage_info":
2654
+ return null;
2655
+ default:
2656
+ return null;
1916
2657
  }
1917
2658
  }
1918
2659
 
1919
2660
  /**
1920
- * Render a single event into the sequence pane.
2661
+ * Render a CMS-derived SeqEvent into the sequence pane.
1921
2662
  */
1922
- function renderSeqEventLine(event, orchId) {
1923
- const lastAct = seqLastActivityNode.get(orchId);
2663
+ function renderCmsSeqEventLine(event) {
2664
+ const col = cmsSeqNodeIndex(getSeqEventNodeId(event));
2665
+ const detail = event.detail || "";
2666
+ const lineStyle = event.underline ? { underline: true } : undefined;
1924
2667
 
1925
2668
  switch (event.type) {
1926
- case "exec_start":
1927
- // Suppress standalone exec_start — the turn event that always
1928
- // follows provides enough context. This halves the vertical
1929
- // density of the diagram.
2669
+ case "turn_start":
2670
+ seqPane.log(seqLine(event.time, col, detail, "gray", lineStyle));
1930
2671
  break;
1931
-
1932
- case "turn": {
1933
- const orchCol = seqNodes.indexOf(event.orchNode);
1934
- seqPane.log(seqLine(event.time, orchCol, `turn ${event.turn}`, "gray"));
2672
+ case "turn_end":
2673
+ // Suppress — turn_start is enough (same as existing log-based behavior)
1935
2674
  break;
1936
- }
1937
-
1938
- case "activity_start": {
1939
- const col = seqNodes.indexOf(event.actNode);
1940
- if (lastAct !== undefined && lastAct !== event.actNode) {
1941
- seqPane.log(seqLine(event.time, col, `> ${lastAct}->${event.actNode}`, "yellow"));
1942
- }
1943
- seqLastActivityNode.set(orchId, event.actNode);
1944
- seqPane.log(seqLine(event.time, col, "> agent", "cyan"));
2675
+ case "user_msg":
2676
+ seqPane.log(seqLine(event.time, col, `>> ${detail}`, "white", lineStyle));
1945
2677
  break;
1946
- }
1947
-
1948
- case "resume": {
1949
- const col = seqNodes.indexOf(event.actNode);
1950
- if (lastAct !== undefined && lastAct !== event.actNode) {
1951
- seqPane.log(seqLine(event.time, col, `> ${lastAct}->${event.actNode}`, "yellow"));
1952
- }
1953
- seqLastActivityNode.set(orchId, event.actNode);
1954
- seqPane.log(seqLine(event.time, col, "^ resume", "green"));
2678
+ case "response":
2679
+ seqPane.log(seqLine(event.time, col, `< ${detail}`, "green", lineStyle));
1955
2680
  break;
1956
- }
1957
-
1958
- case "content": {
1959
- // Skip verbose streaming-content rows in sequence mode to keep
1960
- // vertical density high; full content remains in chat pane.
2681
+ case "wait":
2682
+ seqPane.log(seqLine(event.time, col, detail, "yellow", lineStyle));
1961
2683
  break;
1962
- }
1963
-
1964
- case "response": {
1965
- const col = seqNodes.indexOf(lastAct || event.orchNode);
1966
- const colW = seqColW();
1967
- const maxSnip = Math.max(3, colW - 8);
1968
- const snip = (event.snippet || "ok").slice(0, maxSnip);
1969
- seqPane.log(seqLine(event.time, col, `< ${snip}`, "green"));
2684
+ case "timer":
2685
+ seqPane.log(seqLine(event.time, col, detail, "yellow", lineStyle));
1970
2686
  break;
1971
- }
1972
-
1973
- case "wait": {
1974
- const col = seqNodes.indexOf(lastAct || event.orchNode);
1975
- seqPane.log(seqLine(event.time, col, `wait ${event.seconds}s`, "yellow"));
2687
+ case "dehydrate":
2688
+ seqPane.log(seqLine(event.time, col, detail, "red", lineStyle));
1976
2689
  break;
1977
- }
1978
-
1979
- case "timer_fired": {
1980
- const col = seqNodes.indexOf(lastAct || event.orchNode);
1981
- seqPane.log(seqLine(event.time, col, `${event.seconds}s up`, "yellow"));
2690
+ case "hydrate":
2691
+ seqPane.log(seqLine(event.time, col, detail, "green", lineStyle));
1982
2692
  break;
1983
- }
1984
-
1985
- case "dehydrate": {
1986
- const col = seqNodes.indexOf(lastAct || event.orchNode);
1987
- seqPane.log(seqLine(event.time, col, "ZZ dehydrate", "red"));
2693
+ case "spawn":
2694
+ seqPane.log(seqLine(event.time, col, detail, "cyan", lineStyle));
1988
2695
  break;
1989
- }
1990
-
1991
- case "dehydrate_act": {
1992
- const col = seqNodes.indexOf(event.actNode);
1993
- seqPane.log(seqLine(event.time, col, "ZZ > blob", "red"));
2696
+ case "cron_start":
2697
+ seqPane.log(seqLine(event.time, col, detail, "magenta", lineStyle));
1994
2698
  break;
1995
- }
1996
-
1997
- case "hydrate_act": {
1998
- const col = seqNodes.indexOf(event.actNode);
1999
- seqPane.log(seqLine(event.time, col, "^^ < blob", "green"));
2699
+ case "cron_fire":
2700
+ seqPane.log(seqLine(event.time, col, detail, "magenta", lineStyle));
2000
2701
  break;
2001
- }
2002
-
2003
- case "user_idle": {
2004
- const col = seqNodes.indexOf(lastAct || event.orchNode);
2005
- seqPane.log(seqLine(event.time, col, ">> user msg", "cyan"));
2702
+ case "cron_cancel":
2703
+ seqPane.log(seqLine(event.time, col, detail, "magenta", lineStyle));
2006
2704
  break;
2007
- }
2008
-
2009
- case "interrupt": {
2010
- const col = seqNodes.indexOf(lastAct || event.orchNode);
2011
- seqPane.log(seqLine(event.time, col, ">> interrupt", "cyan"));
2705
+ case "cmd_recv":
2706
+ seqPane.log(seqLine(event.time, col, `>> ${detail}`, "magenta", lineStyle));
2012
2707
  break;
2013
- }
2014
-
2015
- case "user_msg_synth": {
2016
- const col = seqNodes.indexOf(event.orchNode);
2017
- const snip = event.label && event.label.length > 12 ? event.label.slice(0, 12) + "…" : (event.label || "msg");
2018
- seqPane.log(seqLine(event.time, col, `>> ${snip}`, "white"));
2708
+ case "cmd_done":
2709
+ seqPane.log(seqLine(event.time, col, `<< ${detail}`, "magenta", lineStyle));
2019
2710
  break;
2020
- }
2021
-
2022
- case "cmd_recv": {
2023
- const col = seqNodes.indexOf(event.orchNode);
2024
- seqPane.log(seqLine(event.time, col, `>> /${event.cmd}`, "magenta"));
2711
+ case "compaction":
2712
+ seqPane.log(seqLine(event.time, col, detail, "gray", lineStyle));
2713
+ break;
2714
+ case "error":
2715
+ seqPane.log(seqLine(event.time, col, detail, "red", lineStyle));
2025
2716
  break;
2717
+ }
2718
+ }
2719
+
2720
+ function getSeqAggregationKey(event) {
2721
+ if (!event) return null;
2722
+ const nodeId = getSeqEventNodeId(event);
2723
+ if (event.type === "spawn") {
2724
+ return `spawn:${nodeId}`;
2725
+ }
2726
+ if (isOrchestratorSeqEventType(event.type) && event.detail) {
2727
+ return `${event.type}:${nodeId}:${event.detail}`;
2728
+ }
2729
+ return null;
2730
+ }
2731
+
2732
+ function collapseSeqEventsForRender(events) {
2733
+ const collapsed = [];
2734
+ for (const event of events) {
2735
+ const prev = collapsed[collapsed.length - 1];
2736
+ const eventKey = getSeqAggregationKey(event);
2737
+ const prevKey = getSeqAggregationKey(prev);
2738
+
2739
+ if (eventKey && prevKey && eventKey === prevKey) {
2740
+ prev.aggregateCount = (prev.aggregateCount || 1) + 1;
2741
+ if (prev.type === "spawn") {
2742
+ prev.detail = `spawn ×${prev.aggregateCount}`;
2743
+ } else {
2744
+ prev.detail = `${prev.baseDetail || prev.detail} ×${prev.aggregateCount}`;
2745
+ }
2746
+ continue;
2026
2747
  }
2027
2748
 
2028
- case "cmd_done": {
2029
- const col = seqNodes.indexOf(event.orchNode);
2030
- seqPane.log(seqLine(event.time, col, `<< ${(event.detail || "ok").slice(0, 15)}`, "magenta"));
2031
- break;
2749
+ const next = { ...event, aggregateCount: 1, baseDetail: event.detail };
2750
+ collapsed.push(next);
2751
+ }
2752
+ return collapsed;
2753
+ }
2754
+
2755
+ /** Ensure a worker node is tracked in the sequence column list. Returns its index. */
2756
+ function cmsSeqNodeIndex(workerNodeId) {
2757
+ const short = workerNodeId ? safeTail(workerNodeId, 5) : "(unk)";
2758
+ if (!seqNodeSet.has(short)) {
2759
+ seqNodeSet.add(short);
2760
+ seqNodes.push(short);
2761
+ if (logViewMode === "sequence") updateSeqHeader();
2762
+ }
2763
+ return seqNodes.indexOf(short);
2764
+ }
2765
+
2766
+ /**
2767
+ * Load the full CMS event timeline for a session.
2768
+ * Called on session switch and TUI startup.
2769
+ */
2770
+ async function loadCmsSeqTimeline(sessionId) {
2771
+ try {
2772
+ const events = await mgmt.getSessionEvents(sessionId);
2773
+ const timeline = [];
2774
+ for (const evt of events) {
2775
+ const seqEvt = placeCmsSeqEvent(timeline, cmsEventToSeqEvent(evt));
2776
+ if (seqEvt) timeline.push(seqEvt);
2032
2777
  }
2778
+ cmsSeqTimelines.set(sessionId, timeline);
2779
+ cmsSeqLastSeq.set(sessionId, events.length > 0 ? events[events.length - 1].seq : 0);
2033
2780
 
2034
- case "listmodels_act": {
2035
- const col = seqNodes.indexOf(event.actNode);
2036
- seqPane.log(seqLine(event.time, col, "[= listModels]", "magenta"));
2037
- break;
2781
+ // Discover worker nodes from events
2782
+ for (const evt of timeline) {
2783
+ const nodeId = getSeqEventNodeId(evt);
2784
+ if (nodeId) cmsSeqNodeIndex(nodeId);
2038
2785
  }
2786
+ } catch (err) {
2787
+ // CMS not available — the log-based path will still work as fallback
2039
2788
  }
2040
- // Don't render here — callers batch renders
2041
2789
  }
2042
2790
 
2043
2791
  /**
2044
- * Update the sticky header box with current node columns.
2792
+ * Incrementally poll for new CMS events for a session.
2793
+ * Appends to existing timeline and renders if sequence mode is active.
2045
2794
  */
2046
- function updateSeqHeader() {
2047
- if (seqNodes.length > 0) {
2048
- const [header, divider] = seqHeader();
2049
- seqHeaderBox.setContent(`${header}\n${divider}`);
2050
- } else {
2051
- seqHeaderBox.setContent("{bold}TIME (waiting for events){/bold}");
2052
- }
2795
+ async function pollCmsSeqEvents(sessionId) {
2796
+ try {
2797
+ const afterSeq = cmsSeqLastSeq.get(sessionId) || 0;
2798
+ const events = await mgmt.getSessionEvents(sessionId, afterSeq, 100);
2799
+ if (events.length === 0) return;
2800
+
2801
+ const timeline = cmsSeqTimelines.get(sessionId) || [];
2802
+ for (const evt of events) {
2803
+ const seqEvt = placeCmsSeqEvent(timeline, cmsEventToSeqEvent(evt));
2804
+ if (seqEvt) {
2805
+ const nodeId = getSeqEventNodeId(seqEvt);
2806
+ if (nodeId) cmsSeqNodeIndex(nodeId);
2807
+ timeline.push(seqEvt);
2808
+ // Render incrementally if this is the active session in sequence mode
2809
+ const orchId = `session-${sessionId}`;
2810
+ if (logViewMode === "sequence" && orchId === activeOrchId) {
2811
+ refreshCmsSeqPane();
2812
+ }
2813
+ }
2814
+ }
2815
+ cmsSeqTimelines.set(sessionId, timeline);
2816
+ cmsSeqLastSeq.set(sessionId, events[events.length - 1].seq);
2817
+
2818
+ // Cap timeline at 300 events
2819
+ if (timeline.length > 300) timeline.splice(0, timeline.length - 300);
2820
+
2821
+ if (logViewMode === "sequence" && `session-${sessionId}` === activeOrchId) {
2822
+ screen.render();
2823
+ }
2824
+ } catch {}
2053
2825
  }
2054
2826
 
2055
2827
  /**
2056
- * Full re-render of the sequence pane for the active session.
2828
+ * Full re-render of the CMS-backed sequence pane for the active session.
2057
2829
  */
2058
- function refreshSeqPane() {
2830
+ function refreshCmsSeqPane() {
2059
2831
  seqPane.setContent("");
2060
2832
  const seqShortId = shortId(activeOrchId);
2061
2833
  seqPane.setLabel(` Sequence: ${seqShortId} `);
2062
-
2063
- // Update sticky header
2064
2834
  updateSeqHeader();
2065
2835
 
2066
- // Reset tracking state for this render pass
2067
- seqLastActivityNode.delete(activeOrchId);
2836
+ const sessionId = activeOrchId?.replace("session-", "");
2837
+ const timeline = sessionId ? cmsSeqTimelines.get(sessionId) : null;
2068
2838
 
2069
- const events = seqEventBuffers.get(activeOrchId);
2070
- if (events && events.length > 0) {
2071
- const renderEvents = events.length > MAX_SEQ_RENDER_EVENTS
2072
- ? events.slice(-MAX_SEQ_RENDER_EVENTS)
2073
- : events;
2074
- if (events.length > MAX_SEQ_RENDER_EVENTS) {
2075
- seqPane.log(`{gray-fg}… showing last ${MAX_SEQ_RENDER_EVENTS} of ${events.length} events …{/gray-fg}`);
2839
+ if (timeline && timeline.length > 0) {
2840
+ const renderEvents = timeline.length > MAX_SEQ_RENDER_EVENTS
2841
+ ? timeline.slice(-MAX_SEQ_RENDER_EVENTS)
2842
+ : timeline;
2843
+ const collapsedEvents = collapseSeqEventsForRender(renderEvents);
2844
+ if (timeline.length > MAX_SEQ_RENDER_EVENTS) {
2845
+ seqPane.log(`{gray-fg}… showing last ${MAX_SEQ_RENDER_EVENTS} of ${timeline.length} events …{/gray-fg}`);
2076
2846
  }
2077
- for (const event of renderEvents) {
2078
- renderSeqEventLine(event, activeOrchId);
2847
+ for (const event of collapsedEvents) {
2848
+ renderCmsSeqEventLine(event);
2079
2849
  }
2080
2850
  } else {
2081
2851
  seqPane.log("{white-fg}No events yet — interact with this session to populate{/white-fg}");
@@ -2088,8 +2858,9 @@ function refreshSeqPane() {
2088
2858
 
2089
2859
  function appendOrchLog(orchId, podName, text) {
2090
2860
  if (!orchLogBuffers.has(orchId)) orchLogBuffers.set(orchId, []);
2091
- const color = getPodColor(podName);
2092
- const shortPod = podName.slice(-5);
2861
+ const normalizedPodName = normalizePodName(podName);
2862
+ const color = getPodColor(normalizedPodName);
2863
+ const shortPod = safeTail(normalizedPodName, 5);
2093
2864
  const coloredLine = `{${color}-fg}[${shortPod}]{/${color}-fg} ${text}`;
2094
2865
  orchLogBuffers.get(orchId).push(coloredLine);
2095
2866
  // Cap buffer at 500 lines
@@ -2236,11 +3007,12 @@ function backfillOrchLogs(orchId) {
2236
3007
  }
2237
3008
 
2238
3009
  function getOrCreateWorkerPane(podName) {
3010
+ podName = normalizePodName(podName);
2239
3011
  if (workerPanes.has(podName)) return workerPanes.get(podName);
2240
3012
 
2241
3013
  const color = paneColors[nextColorIdx++ % paneColors.length];
2242
3014
  // Short name: last 5 chars of pod name
2243
- const shortName = podName.slice(-5);
3015
+ const shortName = safeTail(podName, 5);
2244
3016
 
2245
3017
  const pane = blessed.log({
2246
3018
  parent: screen,
@@ -2262,17 +3034,18 @@ function getOrCreateWorkerPane(podName) {
2262
3034
  scrollbar: { style: { bg: color } },
2263
3035
  keys: true,
2264
3036
  vi: true,
2265
- mouse: false,
3037
+ mouse: true,
2266
3038
  });
2267
3039
 
2268
3040
  workerPanes.set(podName, pane);
2269
3041
  workerPaneOrder.push(podName);
3042
+ registerSelectablePane(pane, `worker ${shortName}`);
2270
3043
  registerFocusRing(pane, color);
2271
3044
  pane.on("focus", () => setNavigationStatusForPane("workers"));
2272
3045
 
2273
3046
  // Register this pod as a sequence diagram column so all nodes
2274
3047
  // appear regardless of whether the active session has used them.
2275
- addSeqNode(podName);
3048
+ cmsSeqNodeIndex(podName);
2276
3049
 
2277
3050
  relayoutAll();
2278
3051
  return pane;
@@ -2423,7 +3196,7 @@ function redrawActiveViews() {
2423
3196
 
2424
3197
  // ─── Input bar ───────────────────────────────────────────────────
2425
3198
 
2426
- const inputBar = blessed.textarea({
3199
+ inputBar = blessed.textarea({
2427
3200
  parent: screen,
2428
3201
  label: " {bold}you:{/bold} ",
2429
3202
  tags: true,
@@ -2464,13 +3237,69 @@ function clampInputCursor(index, value = inputBar.getValue()) {
2464
3237
  }
2465
3238
 
2466
3239
  function getInputInnerWidth() {
3240
+ if (!inputBar) return Math.max(1, screen.width - 2);
2467
3241
  const numericWidth = typeof inputBar.width === "number" ? inputBar.width : screen.width;
2468
3242
  return Math.max(1, numericWidth - (inputBar.iwidth || 2));
2469
3243
  }
2470
3244
 
3245
+ function getInputWrapWidth() {
3246
+ // neo-blessed textarea reserves one extra cell of right margin for the
3247
+ // cursor, so wrapping happens at innerWidth - 1 rather than innerWidth.
3248
+ const margin = 1 + (inputBar?.scrollbar ? 1 : 0);
3249
+ return Math.max(1, getInputInnerWidth() - margin);
3250
+ }
3251
+
3252
+ function getInputStringWidth(text) {
3253
+ if (typeof inputBar?.strWidth === "function") {
3254
+ return inputBar.strWidth(String(text || ""));
3255
+ }
3256
+ return displayWidth(text);
3257
+ }
3258
+
3259
+ function getRawInputCursorPosition(value, cursorIndex) {
3260
+ const text = String(value || "");
3261
+ const target = clampInputCursor(cursorIndex, text);
3262
+ let line = 0;
3263
+ let column = 0;
3264
+
3265
+ for (let i = 0; i < target; i++) {
3266
+ if (text[i] === "\n") {
3267
+ line += 1;
3268
+ column = 0;
3269
+ continue;
3270
+ }
3271
+ column += 1;
3272
+ }
3273
+
3274
+ return { line, column };
3275
+ }
3276
+
2471
3277
  function getCursorVisualPosition(value, cursorIndex) {
2472
3278
  const text = String(value || "");
2473
- const width = getInputInnerWidth();
3279
+ const clines = inputBar?._clines;
3280
+ if (clines?.ftor && clines.content === text) {
3281
+ const { line: rawLineIndex, column: rawColumn } = getRawInputCursorPosition(text, cursorIndex);
3282
+ const wrappedIndexes = clines.ftor[rawLineIndex] || [];
3283
+
3284
+ if (wrappedIndexes.length > 0) {
3285
+ let remaining = rawColumn;
3286
+ for (let index = 0; index < wrappedIndexes.length; index++) {
3287
+ const wrappedLineIndex = wrappedIndexes[index];
3288
+ const wrappedText = clines[wrappedLineIndex] || "";
3289
+ const segmentLength = wrappedText.length;
3290
+ const isLastSegment = index === wrappedIndexes.length - 1;
3291
+
3292
+ if (remaining <= segmentLength || isLastSegment) {
3293
+ const visiblePrefix = wrappedText.slice(0, Math.min(remaining, segmentLength));
3294
+ return { row: wrappedLineIndex, col: getInputStringWidth(visiblePrefix) };
3295
+ }
3296
+
3297
+ remaining -= segmentLength;
3298
+ }
3299
+ }
3300
+ }
3301
+
3302
+ const width = getInputWrapWidth();
2474
3303
  let row = 0;
2475
3304
  let col = 0;
2476
3305
 
@@ -2481,11 +3310,12 @@ function getCursorVisualPosition(value, cursorIndex) {
2481
3310
  col = 0;
2482
3311
  continue;
2483
3312
  }
2484
- col += 1;
2485
- if (col >= width) {
3313
+ const chWidth = Math.max(1, displayWidth(ch));
3314
+ if (col > 0 && col + chWidth > width) {
2486
3315
  row += 1;
2487
3316
  col = 0;
2488
3317
  }
3318
+ col += chWidth;
2489
3319
  }
2490
3320
 
2491
3321
  return { row, col };
@@ -3010,6 +3840,7 @@ function appendLog(text) {
3010
3840
  }
3011
3841
 
3012
3842
  function appendWorkerLog(podName, text, orchId) {
3843
+ podName = normalizePodName(podName);
3013
3844
  const pane = getOrCreateWorkerPane(podName);
3014
3845
  // Buffer raw entry for recoloring on session switch
3015
3846
  if (!workerLogBuffers.has(podName)) workerLogBuffers.set(podName, []);
@@ -3058,42 +3889,167 @@ function recolorWorkerPanes() {
3058
3889
  });
3059
3890
  totalLines += Math.min(buf.length, MAX_WORKER_RENDER_LINES);
3060
3891
  }
3061
- screen.render();
3062
- perfEnd(_ph, { panes: paneCount, lines: totalLines });
3892
+ screen.render();
3893
+ perfEnd(_ph, { panes: paneCount, lines: totalLines });
3894
+ }
3895
+
3896
+ function showCopilotMessage(raw, orchId) {
3897
+ const _ph = perfStart("showCopilotMessage");
3898
+ stopChatSpinner(orchId);
3899
+ const prefix = `{white-fg}[${ts()}]{/white-fg} {cyan-fg}{bold}Copilot:{/bold}{/cyan-fg}`;
3900
+ const { lines } = buildAssistantChatLines(raw, orchId, prefix);
3901
+
3902
+ // Try to replace the preview lines in-place so the frame loop sees a
3903
+ // single buffer mutation instead of a shrink-then-grow. This avoids
3904
+ // the scroll-position jump that setContent() causes when the total
3905
+ // line count changes between frames.
3906
+ if (!replaceAssistantPreviewInPlace(orchId, raw, lines)) {
3907
+ // No matching preview — append normally.
3908
+ for (const line of lines) {
3909
+ appendChatRaw(line, orchId);
3910
+ }
3911
+ }
3912
+ perfEnd(_ph, { len: raw?.length || 0 });
3913
+ }
3914
+
3915
+ function isRenderedTableLikeLine(line) {
3916
+ const trimmed = String(line || "").trim();
3917
+ if (!trimmed) return false;
3918
+ if (isMarkdownTableLine(trimmed)) return true;
3919
+ return /^[┌├└│+|]/.test(trimmed);
3920
+ }
3921
+
3922
+ function buildPrefixedRenderedLines(prefix, rendered, options = {}) {
3923
+ const trailingBlank = options.trailingBlank !== false;
3924
+ const renderedLines = String(rendered || "").split("\n");
3925
+ const firstContentLineIndex = renderedLines.findIndex((line) => line.trim() !== "");
3926
+ const startsWithTable = firstContentLineIndex >= 0
3927
+ && isRenderedTableLikeLine(renderedLines[firstContentLineIndex]);
3928
+ const lines = startsWithTable
3929
+ ? [prefix, ...renderedLines]
3930
+ : (() => {
3931
+ const [firstLine = "", ...rest] = renderedLines;
3932
+ return [firstLine ? `${prefix} ${firstLine}` : prefix, ...rest];
3933
+ })();
3934
+ if (trailingBlank) lines.push("");
3935
+ return lines;
3936
+ }
3937
+
3938
+ function stripAssistantHeadingLine(text) {
3939
+ return typeof text === "string"
3940
+ ? text.replace(/^HEADING:.*\n?/m, "").trim()
3941
+ : "";
3942
+ }
3943
+
3944
+ function normalizeObserverChatText(text) {
3945
+ if (typeof text !== "string") return "";
3946
+ return text.replace(/\r\n/g, "\n").trim();
3947
+ }
3948
+
3949
+ function normalizeAssistantPreviewText(text) {
3950
+ return normalizeObserverChatText(stripAssistantHeadingLine(text));
3951
+ }
3952
+
3953
+ function removeChatLineSequence(buffer, lines, replacementLines) {
3954
+ if (!Array.isArray(buffer) || !Array.isArray(lines) || lines.length === 0) return false;
3955
+ for (let start = buffer.length - lines.length; start >= 0; start--) {
3956
+ let matches = true;
3957
+ for (let offset = 0; offset < lines.length; offset++) {
3958
+ if (buffer[start + offset] !== lines[offset]) {
3959
+ matches = false;
3960
+ break;
3961
+ }
3962
+ }
3963
+ if (matches) {
3964
+ if (replacementLines) {
3965
+ buffer.splice(start, lines.length, ...replacementLines);
3966
+ } else {
3967
+ buffer.splice(start, lines.length);
3968
+ }
3969
+ return true;
3970
+ }
3971
+ }
3972
+ return false;
3973
+ }
3974
+
3975
+ function stripRenderedChatFormatting(text) {
3976
+ return String(text || "")
3977
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
3978
+ .replace(/\x1b\[[0-9;]*m/g, "")
3979
+ .replace(/\{[^}]*\}/g, "");
3980
+ }
3981
+
3982
+ function prepareAssistantChatRender(raw, orchId, options = {}) {
3983
+ const sourceText = typeof raw === "string" ? raw : "";
3984
+ const bodyText = options.stripHeading === true
3985
+ ? stripAssistantHeadingLine(sourceText)
3986
+ : sourceText;
3987
+
3988
+ detectArtifactLinks(sourceText, orchId);
3989
+
3990
+ const renderedText = renderMarkdown(renderArtifactLinks(bodyText));
3991
+ return {
3992
+ sourceText,
3993
+ bodyText,
3994
+ renderedText: options.stripFormatting === true
3995
+ ? stripRenderedChatFormatting(renderedText)
3996
+ : renderedText,
3997
+ };
3998
+ }
3999
+
4000
+ function buildAssistantChatLines(raw, orchId, prefix, options = {}) {
4001
+ const { trailingBlank, lineDecorator } = options;
4002
+ const { bodyText, renderedText } = prepareAssistantChatRender(raw, orchId, options);
4003
+ let lines = buildPrefixedRenderedLines(prefix, renderedText, { trailingBlank });
4004
+ if (typeof lineDecorator === "function") {
4005
+ lines = lines.map((line) => lineDecorator(line, bodyText));
4006
+ }
4007
+ return { bodyText, renderedText, lines };
3063
4008
  }
3064
4009
 
3065
- function showCopilotMessage(raw, orchId) {
3066
- const _ph = perfStart("showCopilotMessage");
3067
- stopChatSpinner(orchId);
4010
+ function replaceAssistantPreviewInPlace(orchId, matchingText, replacementLines) {
4011
+ const preview = sessionPendingAssistantPreview.get(orchId);
4012
+ if (!preview) return false;
3068
4013
 
3069
- appendActivity(`{green-fg}[obs] showCopilotMessage called for ${orchId === activeOrchId ? "ACTIVE" : "background"} session, len=${raw?.length || 0}{/green-fg}`, orchId);
4014
+ if (matchingText != null) {
4015
+ const normalized = normalizeAssistantPreviewText(matchingText);
4016
+ if (!normalized || normalized !== preview.normalized) return false;
4017
+ }
3070
4018
 
3071
- // Detect and register artifact links before rendering
3072
- detectArtifactLinks(raw, orchId);
4019
+ const buffer = sessionChatBuffers.get(orchId);
4020
+ if (!buffer) return false;
3073
4021
 
3074
- // Replace artifact:// URIs with highlighted display before markdown rendering
3075
- let displayRaw = raw;
3076
- if (displayRaw) {
3077
- displayRaw = displayRaw.replace(
3078
- /artifact:\/\/[a-f0-9-]+\/([^\s"'{}]+)/g,
3079
- "📎 **$1** _(press 'a' to download)_",
3080
- );
3081
- }
4022
+ const spliced = removeChatLineSequence(buffer, preview.lines, replacementLines);
4023
+ sessionPendingAssistantPreview.delete(orchId);
4024
+ if (spliced && orchId === activeOrchId) invalidateChat();
4025
+ return spliced;
4026
+ }
3082
4027
 
3083
- const rendered = renderMarkdown(displayRaw);
3084
- const prefix = `{white-fg}[${ts()}]{/white-fg} {cyan-fg}{bold}Copilot:{/bold}{/cyan-fg}`;
3085
- appendChatRaw(prefix, orchId);
3086
- // Always show on separate lines for readability
3087
- for (const line of rendered.split("\n")) {
3088
- appendChatRaw(line, orchId);
3089
- }
3090
- appendChatRaw("", orchId); // blank line after each message
3091
- perfEnd(_ph, { len: raw?.length || 0 });
4028
+ function clearPendingAssistantPreview(orchId, matchingText) {
4029
+ return replaceAssistantPreviewInPlace(orchId, matchingText, null);
3092
4030
  }
3093
4031
 
3094
- function normalizeObserverChatText(text) {
3095
- if (typeof text !== "string") return "";
3096
- return text.replace(/\r\n/g, "\n").trim();
4032
+ function showLiveAssistantPreview(raw, orchId) {
4033
+ const normalized = normalizeAssistantPreviewText(raw);
4034
+ if (!normalized) return;
4035
+ if (sessionPromotedIntermediate.get(orchId) === normalized) return;
4036
+ if (sessionRecoveredTurnResult.get(orchId) === normalized) return;
4037
+
4038
+ const existing = sessionPendingAssistantPreview.get(orchId);
4039
+ if (existing?.normalized === normalized) return;
4040
+
4041
+ clearPendingAssistantPreview(orchId);
4042
+
4043
+ const { lines: previewLines } = buildAssistantChatLines(raw, orchId, `[${ts()}] Copilot [preview]:`, {
4044
+ stripHeading: true,
4045
+ stripFormatting: true,
4046
+ lineDecorator: (line) => line ? `{gray-fg}${line}{/gray-fg}` : "",
4047
+ });
4048
+
4049
+ for (const line of previewLines) {
4050
+ appendChatRaw(line, orchId);
4051
+ }
4052
+ sessionPendingAssistantPreview.set(orchId, { normalized, lines: previewLines });
3097
4053
  }
3098
4054
 
3099
4055
  function promoteIntermediateContent(raw, orchId) {
@@ -3106,6 +4062,7 @@ function promoteIntermediateContent(raw, orchId) {
3106
4062
 
3107
4063
  function shouldSkipCompletedTurnResult(raw, orchId) {
3108
4064
  const normalized = normalizeObserverChatText(raw);
4065
+ clearPendingAssistantPreview(orchId);
3109
4066
  const promoted = sessionPromotedIntermediate.get(orchId);
3110
4067
  sessionPromotedIntermediate.delete(orchId);
3111
4068
  const recovered = sessionRecoveredTurnResult.get(orchId);
@@ -3130,8 +4087,7 @@ function isBootstrapPromptForSession(text, orchId) {
3130
4087
  });
3131
4088
  }
3132
4089
 
3133
- // Track whether sequence view has been seeded from CMS for a session.
3134
- const seqCmsSeededSessions = new Set();
4090
+
3135
4091
 
3136
4092
  /**
3137
4093
  * Load conversation history from CMS and rebuild chat buffer for the session.
@@ -3203,6 +4159,8 @@ async function loadCmsHistory(orchId, options = {}) {
3203
4159
  : liveStatus.customStatus;
3204
4160
  } catch {}
3205
4161
  }
4162
+ updateSessionCronSchedule(orchId, liveCustomStatus);
4163
+ updateSessionContextUsageFromStatus(orchId, liveCustomStatus);
3206
4164
  if (liveCustomStatus?.responseVersion) {
3207
4165
  liveResponsePayload = await fetchLatestResponsePayload(orchId, dc);
3208
4166
  }
@@ -3218,6 +4176,9 @@ async function loadCmsHistory(orchId, options = {}) {
3218
4176
  sessionModels.set(orchId, info.model);
3219
4177
  if (orchId === activeOrchId) updateChatLabel();
3220
4178
  }
4179
+ if (info?.contextUsage) {
4180
+ noteSessionContextUsage(orchId, info.contextUsage);
4181
+ }
3221
4182
 
3222
4183
  if ((!events || events.length === 0) && !liveTurnContent) {
3223
4184
  if (sessionHistoryLoadGeneration.get(orchId) !== generation) {
@@ -3255,7 +4216,10 @@ async function loadCmsHistory(orchId, options = {}) {
3255
4216
  const stripHostPrefix = (text) => text?.replace(/^\[SYSTEM: Running on host "[^"]*"\.\]\n\n/, "") || text;
3256
4217
 
3257
4218
  // Filter out internal timer continuation prompts — these aren't real user messages
3258
- const isTimerPrompt = (text) => /^The \d+ second wait is now complete\./i.test(text);
4219
+ const isTimerPrompt = (text) =>
4220
+ /^The \d+ second wait is now complete\./i.test(text)
4221
+ || /^The wait was partially completed \(/i.test(text)
4222
+ || /^Resume the wait for the remaining \d+/i.test(text);
3259
4223
 
3260
4224
  const lines = [];
3261
4225
  const fmtTime = (value) => {
@@ -3297,9 +4261,10 @@ async function loadCmsHistory(orchId, options = {}) {
3297
4261
  const updateType = childMatch[2];
3298
4262
  const body = (childMatch[4] || "").trim();
3299
4263
  const childTitle = sessionHeadings.get(`session-${childMatch[1]}`) || `Agent ${childId}`;
3300
- const typeColor = updateType === "completed" ? "green" : updateType === "error" ? "red" : "magenta";
4264
+ const typeIcon = updateType === "completed" ? "" : updateType === "error" ? "" : "📡";
4265
+ const typeColor = updateType === "completed" ? "green" : updateType === "error" ? "red" : "yellow";
3301
4266
  lines.push(`{white-fg}[${timeStr}]{/white-fg}`);
3302
- lines.push(`{${typeColor}-fg}┌─ {bold}${childTitle}{/bold} · ${updateType} ─┐{/${typeColor}-fg}`);
4267
+ lines.push(`{${typeColor}-fg}┌─ ${typeIcon} {bold}${childTitle}{/bold} · ${updateType} ${"─".repeat(Math.max(1, 30 - childTitle.length))}┐{/${typeColor}-fg}`);
3303
4268
  if (body) {
3304
4269
  const bodyLines = body.split("\n");
3305
4270
  for (const bl of bodyLines.slice(0, 8)) {
@@ -3309,7 +4274,7 @@ async function loadCmsHistory(orchId, options = {}) {
3309
4274
  lines.push(`{${typeColor}-fg}│{/${typeColor}-fg} {gray-fg}… ${bodyLines.length - 8} more lines{/gray-fg}`);
3310
4275
  }
3311
4276
  }
3312
- lines.push(`{${typeColor}-fg}└${"─".repeat(30)}┘{/${typeColor}-fg}`);
4277
+ lines.push(`{${typeColor}-fg}└${"─".repeat(40)}┘{/${typeColor}-fg}`);
3313
4278
  lines.push("");
3314
4279
  } else {
3315
4280
  lines.push(`{white-fg}[${timeStr}]{/white-fg} {bold}You:{/bold} ${content}`);
@@ -3319,7 +4284,6 @@ async function loadCmsHistory(orchId, options = {}) {
3319
4284
  const content = evt.data?.content;
3320
4285
  if (content) {
3321
4286
  lastAssistantContent = content;
3322
- detectArtifactLinks(content, orchId);
3323
4287
  if (renderedChars >= MAX_TOTAL_RENDER_CHARS) {
3324
4288
  lines.push(`{gray-fg}── additional assistant output omitted to keep session switching fast ──{/gray-fg}`);
3325
4289
  lines.push("");
@@ -3328,17 +4292,12 @@ async function loadCmsHistory(orchId, options = {}) {
3328
4292
  const clipped = content.length > MAX_ASSISTANT_MESSAGE_CHARS
3329
4293
  ? content.slice(0, MAX_ASSISTANT_MESSAGE_CHARS) + "\n\n[output truncated in TUI history view]"
3330
4294
  : content;
3331
- const displayClipped = clipped.replace(
3332
- /artifact:\/\/[a-f0-9-]+\/([^\s"'{}]+)/g,
3333
- "📎 **$1** _(press 'a' to download)_",
3334
- );
3335
- lines.push(`{white-fg}[${timeStr}]{/white-fg} {cyan-fg}{bold}Copilot:{/bold}{/cyan-fg}`);
3336
- const rendered = renderMarkdown(displayClipped);
3337
4295
  renderedChars += clipped.length;
3338
- for (const line of rendered.split("\n")) {
3339
- lines.push(line);
3340
- }
3341
- lines.push("");
4296
+ lines.push(...buildAssistantChatLines(
4297
+ clipped,
4298
+ orchId,
4299
+ `{white-fg}[${timeStr}]{/white-fg} {cyan-fg}{bold}Copilot:{/bold}{/cyan-fg}`,
4300
+ ).lines);
3342
4301
  }
3343
4302
  } else if (type === "tool.execution_start") {
3344
4303
  activityLines.push(formatToolActivityLine(timeStr, evt, "start"));
@@ -3368,13 +4327,12 @@ async function loadCmsHistory(orchId, options = {}) {
3368
4327
  const clippedLiveTurn = liveTurnContent.length > MAX_ASSISTANT_MESSAGE_CHARS
3369
4328
  ? liveTurnContent.slice(0, MAX_ASSISTANT_MESSAGE_CHARS) + "\n\n[output truncated in TUI history view]"
3370
4329
  : liveTurnContent;
3371
- lines.push(`{white-fg}[${fmtTime(Date.now())}]{/white-fg} {cyan-fg}{bold}Copilot:{/bold}{/cyan-fg}`);
3372
- const renderedLiveTurn = renderMarkdown(clippedLiveTurn);
3373
4330
  renderedChars += clippedLiveTurn.length;
3374
- for (const line of renderedLiveTurn.split("\n")) {
3375
- lines.push(line);
3376
- }
3377
- lines.push("");
4331
+ lines.push(...buildAssistantChatLines(
4332
+ clippedLiveTurn,
4333
+ orchId,
4334
+ `{white-fg}[${fmtTime(Date.now())}]{/white-fg} {cyan-fg}{bold}Copilot:{/bold}{/cyan-fg}`,
4335
+ ).lines);
3378
4336
  sessionRecoveredTurnResult.set(orchId, normalizedLiveTurn);
3379
4337
  noteSeenResponseVersion(orchId, liveResponsePayload?.version);
3380
4338
  } else {
@@ -3408,12 +4366,8 @@ async function loadCmsHistory(orchId, options = {}) {
3408
4366
  // replacement would nuke it without this check.
3409
4367
  const pendingQ = sessionPendingQuestions.get(orchId);
3410
4368
  if (pendingQ) {
3411
- lines.push(`{cyan-fg}{bold}Copilot:{/bold}{/cyan-fg}`);
3412
4369
  const renderedQ = renderMarkdown(pendingQ);
3413
- for (const line of renderedQ.split("\n")) {
3414
- lines.push(line);
3415
- }
3416
- lines.push("");
4370
+ lines.push(...buildPrefixedRenderedLines(`{cyan-fg}{bold}Copilot:{/bold}{/cyan-fg}`, renderedQ));
3417
4371
  }
3418
4372
 
3419
4373
  // If CMS history produced no chat-visible lines (only the footer),
@@ -3441,7 +4395,19 @@ async function loadCmsHistory(orchId, options = {}) {
3441
4395
  } else {
3442
4396
  sessionChatBuffers.set(orchId, lines);
3443
4397
  }
3444
- sessionActivityBuffers.set(orchId, activityLines);
4398
+ // Guard: don't replace existing activity with an empty array.
4399
+ // The observer may have already appended live entries; nuking
4400
+ // them with an empty CMS result causes a blank Activity pane.
4401
+ const existingActivity = sessionActivityBuffers.get(orchId);
4402
+ const existingActivityHasContent = existingActivity && existingActivity.length > 0
4403
+ && existingActivity.some(l => l && !/no recent/.test(l));
4404
+ if (activityLines.length === 0 && existingActivityHasContent) {
4405
+ // keep observer-written activity intact
4406
+ } else if (activityLines.length === 0) {
4407
+ sessionActivityBuffers.set(orchId, ["{gray-fg}(no recent activity yet){/gray-fg}"]);
4408
+ } else {
4409
+ sessionActivityBuffers.set(orchId, activityLines);
4410
+ }
3445
4411
  sessionHistoryLoadedAt.set(orchId, Date.now());
3446
4412
  sessionRenderedCmsSeq.set(orchId, maxRenderedSeq);
3447
4413
 
@@ -3450,33 +4416,6 @@ async function loadCmsHistory(orchId, options = {}) {
3450
4416
  invalidateActivity();
3451
4417
  }
3452
4418
 
3453
- if (!seqCmsSeededSessions.has(orchId)) {
3454
- const existingSeq = seqEventBuffers.get(orchId) ?? [];
3455
- if (existingSeq.length === 0) {
3456
- const cmsNode = addSeqNode("cms");
3457
- const seeded = [];
3458
- for (const evt of events) {
3459
- const t = fmtTime(evt.createdAt);
3460
- if (evt.eventType === "user.message") {
3461
- const txt = stripHostPrefix(evt.data?.content || "");
3462
- if (txt && !isTimerPrompt(txt)) {
3463
- seeded.push({ type: "user_msg_synth", time: t, orchNode: cmsNode, actNode: cmsNode, label: txt });
3464
- }
3465
- } else if (evt.eventType === "assistant.message") {
3466
- const txt = evt.data?.content || "";
3467
- if (txt) {
3468
- seeded.push({ type: "response", time: t, orchNode: cmsNode, actNode: cmsNode, snippet: txt.slice(0, 40) });
3469
- }
3470
- } else if (evt.eventType === "tool.execution_start") {
3471
- seeded.push({ type: "activity_start", time: t, orchNode: cmsNode, actNode: cmsNode });
3472
- }
3473
- }
3474
- if (seeded.length > 0) {
3475
- seqEventBuffers.set(orchId, seeded);
3476
- }
3477
- }
3478
- seqCmsSeededSessions.add(orchId);
3479
- }
3480
4419
  } catch (err) {
3481
4420
  loadFailed = true;
3482
4421
  appendLog(`{yellow-fg}CMS history load failed: ${err.message}{/yellow-fg}`);
@@ -3508,6 +4447,62 @@ const duroxideSchema = process.env.DUROXIDE_SCHEMA || undefined;
3508
4447
  const numWorkers = parseInt(process.env.WORKERS ?? "4", 10);
3509
4448
  const isRemote = numWorkers === 0;
3510
4449
 
4450
+ // Register startup quit handlers before any embedded worker startup so the
4451
+ // first local run can always be interrupted, even if worker boot blocks.
4452
+ let _startupQuit = false;
4453
+ let _startupPhase = true;
4454
+ let _embeddedWorkerStartupInProgress = false;
4455
+ screen.key(["C-c"], () => {
4456
+ if (_startupPhase) { _startupQuit = true; exitProcessNow(0); }
4457
+ });
4458
+ const _startupQHandler = () => {
4459
+ if (_startupPhase) { _startupQuit = true; exitProcessNow(0); }
4460
+ };
4461
+ screen.key(["q"], _startupQHandler);
4462
+
4463
+ async function failStartupAndExit(message) {
4464
+ console.error(`[PilotSwarm TUI] ${message}`);
4465
+ setStatus(`{red-fg}${message}{/red-fg}`);
4466
+ chatBox.setContent(
4467
+ `${ACTIVE_STARTUP_SPLASH_CONTENT}\n\n {red-fg}${message}{/red-fg}\n\n ` +
4468
+ `{gray-fg}Embedded local mode only supports one TUI per workspace at a time. ` +
4469
+ `Use remote mode for multiple concurrent windows.{/gray-fg}`,
4470
+ );
4471
+ _origRender();
4472
+ await new Promise((resolve) => setTimeout(resolve, 150));
4473
+ exitProcessNow(1);
4474
+ }
4475
+
4476
+ function handleTerminationSignal(signal) {
4477
+ if (_startupPhase) {
4478
+ _startupQuit = true;
4479
+ exitProcessNow(SIGNAL_EXIT_CODES[signal] ?? 0);
4480
+ return;
4481
+ }
4482
+ void requestQuit();
4483
+ }
4484
+
4485
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
4486
+ process.on(signal, () => {
4487
+ handleTerminationSignal(signal);
4488
+ });
4489
+ }
4490
+
4491
+ setStatus(isRemote ? "Connecting to remote DB..." : `Starting ${numWorkers} local workers...`);
4492
+ chatBox.setContent(
4493
+ `${ACTIVE_STARTUP_SPLASH_CONTENT}\n\n {white-fg}${isRemote ? "Connecting..." : `Starting ${numWorkers} embedded workers...`}{/white-fg}`,
4494
+ );
4495
+ _origRender();
4496
+
4497
+ if (!isRemote) {
4498
+ try {
4499
+ acquireLocalEmbeddedRuntimeLock(store);
4500
+ } catch (err) {
4501
+ const message = err?.message || String(err);
4502
+ await failStartupAndExit(message);
4503
+ }
4504
+ }
4505
+
3511
4506
  if (isRemote) {
3512
4507
  const title = formatWindowTitle("Scaled — Remote Workers");
3513
4508
  screen.title = title;
@@ -3529,6 +4524,7 @@ appendLog("");
3529
4524
  const workers = [];
3530
4525
  let modelProviders = null;
3531
4526
  let logTailInterval = null;
4527
+ let firstLocalWorkerStarted = Promise.resolve();
3532
4528
  // Default model — prefer registry defaultModel, env override, then empty (worker picks default).
3533
4529
  let currentModel = process.env.COPILOT_MODEL || "";
3534
4530
  if (!isRemote) {
@@ -3550,6 +4546,31 @@ if (!isRemote) {
3550
4546
  process.stdout.write = () => true;
3551
4547
  process.stderr.write = () => true;
3552
4548
 
4549
+ const restoreBootstrapStdio = () => {
4550
+ process.stdout.write = origStdoutWrite;
4551
+ process.stderr.write = origStderrWrite;
4552
+ };
4553
+
4554
+ let resolveFirstLocalWorkerStarted;
4555
+ let rejectFirstLocalWorkerStarted;
4556
+ let firstLocalWorkerSettled = false;
4557
+ firstLocalWorkerStarted = new Promise((resolve, reject) => {
4558
+ resolveFirstLocalWorkerStarted = resolve;
4559
+ rejectFirstLocalWorkerStarted = reject;
4560
+ });
4561
+
4562
+ const noteFirstLocalWorkerStarted = () => {
4563
+ if (firstLocalWorkerSettled) return;
4564
+ firstLocalWorkerSettled = true;
4565
+ resolveFirstLocalWorkerStarted();
4566
+ };
4567
+
4568
+ const noteAllLocalWorkersFailed = (err) => {
4569
+ if (firstLocalWorkerSettled) return;
4570
+ firstLocalWorkerSettled = true;
4571
+ rejectFirstLocalWorkerStarted(err);
4572
+ };
4573
+
3553
4574
  // System message: env override > worker module > default agent (from plugin)
3554
4575
  const WORKER_SYSTEM_MESSAGE = process.env._TUI_SYSTEM_MESSAGE || undefined;
3555
4576
 
@@ -3583,69 +4604,106 @@ if (!isRemote) {
3583
4604
  }),
3584
4605
  } : undefined;
3585
4606
 
3586
- setStatus(`Starting ${numWorkers} workers...`);
3587
- for (let i = 0; i < numWorkers; i++) {
3588
- const w = new PilotSwarmWorker({
3589
- store,
3590
- ...(duroxideSchema ? { duroxideSchema } : {}),
3591
- ...(cmsSchema ? { cmsSchema } : {}),
3592
- githubToken: process.env.GITHUB_TOKEN,
3593
- logLevel: process.env.LOG_LEVEL || "error",
3594
- sessionStateDir: process.env.SESSION_STATE_DIR || path.join(os.homedir(), ".copilot", "session-state"),
3595
- blobConnectionString: workerModuleConfig.blobConnectionString || process.env.AZURE_STORAGE_CONNECTION_STRING,
3596
- blobContainer: process.env.AZURE_STORAGE_CONTAINER || "copilot-sessions",
3597
- workerNodeId: `local-rt-${i}`,
3598
- systemMessage: workerModuleConfig.systemMessage || WORKER_SYSTEM_MESSAGE || undefined,
3599
- pluginDirs,
3600
- ...(llmProvider && { provider: llmProvider }),
3601
- ...(workerModuleConfig.skillDirectories && { skillDirectories: workerModuleConfig.skillDirectories }),
3602
- ...(workerModuleConfig.customAgents && { customAgents: workerModuleConfig.customAgents }),
3603
- ...(workerModuleConfig.mcpServers && { mcpServers: workerModuleConfig.mcpServers }),
3604
- });
3605
- // Register custom tools from worker module
3606
- const workerTools = typeof workerModuleConfig.createTools === "function"
3607
- ? await workerModuleConfig.createTools({ workerNodeId: `local-rt-${i}`, workerIndex: i })
3608
- : workerModuleConfig.tools;
3609
- if (workerTools?.length) {
3610
- w.registerTools(workerTools);
3611
- }
3612
- await w.start();
3613
- workers.push(w);
3614
- appendLog(`Worker local-rt-${i} started ✓`);
3615
- }
3616
-
3617
- // Capture model provider registry from the first worker
3618
- modelProviders = workers[0]?.modelProviders || null;
3619
- if (modelProviders) {
3620
- const byProvider = modelProviders.getModelsByProvider();
3621
- for (const g of byProvider) {
3622
- const names = g.models.map(m => m.qualifiedName).join(", ");
3623
- appendLog(`{bold}${g.providerId}{/bold} (${g.type}): ${names}`);
4607
+ try {
4608
+ setStatus(`Starting ${numWorkers} workers...`);
4609
+ for (let i = 0; i < numWorkers; i++) {
4610
+ const w = new PilotSwarmWorker({
4611
+ store,
4612
+ ...(duroxideSchema ? { duroxideSchema } : {}),
4613
+ ...(cmsSchema ? { cmsSchema } : {}),
4614
+ githubToken: process.env.GITHUB_TOKEN,
4615
+ logLevel: process.env.LOG_LEVEL || "error",
4616
+ sessionStateDir: process.env.SESSION_STATE_DIR || path.join(os.homedir(), ".copilot", "session-state"),
4617
+ // Embedded local workers always use the filesystem-based session store.
4618
+ // Do not inherit Azure blob config from the shell environment here.
4619
+ workerNodeId: `local-rt-${i}`,
4620
+ systemMessage: workerModuleConfig.systemMessage || WORKER_SYSTEM_MESSAGE || undefined,
4621
+ pluginDirs,
4622
+ ...(llmProvider && { provider: llmProvider }),
4623
+ ...(workerModuleConfig.skillDirectories && { skillDirectories: workerModuleConfig.skillDirectories }),
4624
+ ...(workerModuleConfig.customAgents && { customAgents: workerModuleConfig.customAgents }),
4625
+ ...(workerModuleConfig.mcpServers && { mcpServers: workerModuleConfig.mcpServers }),
4626
+ });
4627
+ // Register custom tools from worker module
4628
+ const workerTools = typeof workerModuleConfig.createTools === "function"
4629
+ ? await workerModuleConfig.createTools({ workerNodeId: `local-rt-${i}`, workerIndex: i })
4630
+ : workerModuleConfig.tools;
4631
+ if (workerTools?.length) {
4632
+ w.registerTools(workerTools);
4633
+ }
4634
+ workers.push(w);
3624
4635
  }
3625
- // Use default model from registry if no explicit override
3626
- if (modelProviders.defaultModel && !currentModel) {
3627
- currentModel = modelProviders.defaultModel;
4636
+
4637
+ // Capture model provider registry from the first worker
4638
+ modelProviders = workers[0]?.modelProviders || null;
4639
+ if (modelProviders) {
4640
+ const byProvider = modelProviders.getModelsByProvider();
4641
+ for (const g of byProvider) {
4642
+ const names = g.models.map(m => m.qualifiedName).join(", ");
4643
+ appendLog(`{bold}${g.providerId}{/bold} (${g.type}): ${names}`);
4644
+ }
4645
+ // Use default model from registry if no explicit override
4646
+ if (modelProviders.defaultModel && !currentModel) {
4647
+ currentModel = modelProviders.defaultModel;
4648
+ }
3628
4649
  }
3629
- }
3630
4650
 
3631
- // Restore stdout/stderr after all workers initialized
3632
- process.stdout.write = origStdoutWrite;
3633
- // Keep stderr intercepted — MCP subprocesses (filesystem server, etc.) write
3634
- // warnings (ExperimentalWarning: SQLite, etc.) that corrupt the TUI.
3635
- // Route them to the log file instead of the terminal.
3636
- const logFd = fs.openSync(logFile, "a");
3637
- process.stderr.write = (chunk, encoding, cb) => {
3638
- try { fs.appendFileSync(logFd, chunk); } catch {}
3639
- if (typeof cb === "function") cb();
3640
- return true;
3641
- };
4651
+ // Restore stdout/stderr after all workers initialized
4652
+ process.stdout.write = origStdoutWrite;
4653
+ // Keep stderr intercepted — MCP subprocesses (filesystem server, etc.) write
4654
+ // warnings (ExperimentalWarning: SQLite, etc.) that corrupt the TUI.
4655
+ // Route them to the log file instead of the terminal.
4656
+ const logFd = fs.openSync(logFile, "a");
4657
+ process.stderr.write = (chunk, encoding, cb) => {
4658
+ try { fs.appendFileSync(logFd, chunk); } catch {}
4659
+ if (typeof cb === "function") cb();
4660
+ return true;
4661
+ };
3642
4662
 
3643
- // Rust native code writes directly to fd 1/2 during init, bypassing Node
3644
- // and corrupting blessed's alt-screen buffer. Wipe the terminal and force
3645
- // blessed to fully repaint from scratch.
3646
- process.stdout.write("\x1b[2J\x1b[H");
3647
- screen.realloc();
3648
- screen.render();
4663
+ const repaintAfterWorkerStartup = () => {
4664
+ if (_embeddedWorkerStartupInProgress) {
4665
+ process.stdout.write("\x1b[2J\x1b[H");
4666
+ screen.realloc();
4667
+ }
4668
+ screen.render();
4669
+ };
4670
+
4671
+ // Rust native code writes directly to fd 1/2 during init, bypassing Node
4672
+ // and corrupting blessed's alt-screen buffer. Repaint after construction,
4673
+ // then start workers one-by-one in the background to avoid Postgres schema races.
4674
+ _embeddedWorkerStartupInProgress = true;
4675
+ repaintAfterWorkerStartup();
4676
+
4677
+ const startWorkersSequentially = async () => {
4678
+ let startedWorkers = 0;
4679
+ try {
4680
+ for (let i = 0; i < workers.length; i++) {
4681
+ appendLog(`Starting worker local-rt-${i}…`);
4682
+ screen.render();
4683
+ try {
4684
+ await workers[i].start();
4685
+ startedWorkers++;
4686
+ noteFirstLocalWorkerStarted();
4687
+ appendLog(`Worker local-rt-${i} started ✓`);
4688
+ } catch (err) {
4689
+ appendLog(`{red-fg}Worker local-rt-${i} failed: ${err?.message || err}{/red-fg}`);
4690
+ }
4691
+ repaintAfterWorkerStartup();
4692
+ }
4693
+ } finally {
4694
+ repaintAfterWorkerStartup();
4695
+ _embeddedWorkerStartupInProgress = false;
4696
+ }
4697
+ if (startedWorkers === 0) {
4698
+ noteAllLocalWorkersFailed(new Error("All local workers failed to start."));
4699
+ }
4700
+ };
4701
+
4702
+ void startWorkersSequentially();
4703
+ } catch (err) {
4704
+ restoreBootstrapStdio();
4705
+ throw err;
4706
+ }
3649
4707
 
3650
4708
  // Tail the log file into per-worker panes
3651
4709
  let tailPos = 0;
@@ -3724,11 +4782,6 @@ if (!isRemote) {
3724
4782
  appendOrchLog(orchId, paneName, formatted);
3725
4783
  }
3726
4784
 
3727
- // Feed sequence diagram
3728
- const seqEvtLocal = parseSeqEvent(plain, paneName);
3729
- if (seqEvtLocal) {
3730
- appendSeqEvent(seqEvtLocal.orchId, seqEvtLocal);
3731
- }
3732
4785
  routed++;
3733
4786
  }
3734
4787
  tailReads++;
@@ -3799,22 +4852,25 @@ const mgmt = new PilotSwarmManagementClient({
3799
4852
  const STARTUP_DB_RETRY_MS = 30_000;
3800
4853
  const STARTUP_DB_CONNECT_TIMEOUT_MS = 10_000;
3801
4854
 
4855
+ async function withStartupTimeout(label, work) {
4856
+ let timer = null;
4857
+ try {
4858
+ return await Promise.race([
4859
+ work(),
4860
+ new Promise((_, reject) => {
4861
+ timer = setTimeout(() => reject(new Error(`${label} timed out (10s deadline)`)), STARTUP_DB_CONNECT_TIMEOUT_MS);
4862
+ }),
4863
+ ]);
4864
+ } finally {
4865
+ if (timer) clearTimeout(timer);
4866
+ }
4867
+ }
4868
+
3802
4869
  function isStartupTransientDbError(err) {
3803
4870
  const msg = String(err?.message || err || "");
3804
4871
  return /ENOTFOUND|ETIMEDOUT|ECONNREFUSED|ECONNRESET|EHOSTUNREACH|socket hang up|getaddrinfo|timeout|network|pool timed out while waiting for an open connection/i.test(msg);
3805
4872
  }
3806
4873
 
3807
- // Register quit handler BEFORE the connection loop so the user can always exit.
3808
- let _startupQuit = false;
3809
- let _startupPhase = true;
3810
- screen.key(["C-c"], () => {
3811
- if (_startupPhase) { _startupQuit = true; process.exit(0); }
3812
- });
3813
- const _startupQHandler = () => {
3814
- if (_startupPhase) { _startupQuit = true; process.exit(0); }
3815
- };
3816
- screen.key(["q"], _startupQHandler);
3817
-
3818
4874
  setStatus(isRemote ? "Connecting to remote DB..." : "Connecting client...");
3819
4875
 
3820
4876
  // Show splash immediately so the user sees something during DB connection
@@ -3827,41 +4883,35 @@ _origRender();
3827
4883
  while (true) {
3828
4884
  const _startPh = perfStart("startup.clientConnect");
3829
4885
 
3830
- // Race the connection against a timeout so unreachable hosts don't block for minutes
3831
- const timeoutPromise = new Promise((_, reject) =>
3832
- setTimeout(() => reject(new Error("Connection timed out (10s deadline)")), STARTUP_DB_CONNECT_TIMEOUT_MS)
3833
- );
3834
- const results = await Promise.allSettled([
3835
- Promise.race([client.start(), timeoutPromise]),
3836
- Promise.race([mgmt.start(), timeoutPromise]),
3837
- ]);
3838
- const failure = results.find(r => r.status === "rejected");
3839
-
3840
- if (!failure) {
4886
+ try {
4887
+ if (!isRemote) {
4888
+ await withStartupTimeout("First local worker start", () => firstLocalWorkerStarted);
4889
+ }
4890
+ await withStartupTimeout("Client start", () => client.start());
4891
+ await withStartupTimeout("Management client start", () => mgmt.start());
3841
4892
  perfEnd(_startPh);
3842
4893
  break;
3843
- }
3844
-
3845
- const err = failure.reason;
3846
- perfEnd(_startPh, { err: true });
4894
+ } catch (err) {
4895
+ perfEnd(_startPh, { err: true });
3847
4896
 
3848
- try { await client.stop(); } catch {}
3849
- try { await mgmt.stop(); } catch {}
4897
+ try { await client.stop(); } catch {}
4898
+ try { await mgmt.stop(); } catch {}
3850
4899
 
3851
- if (!isStartupTransientDbError(err)) {
3852
- throw err;
3853
- }
4900
+ if (!isStartupTransientDbError(err)) {
4901
+ throw err;
4902
+ }
3854
4903
 
3855
- const msg = String(err?.message || err || "Unknown database error");
3856
- setStatus(`Database unavailable — retrying in 30s (${msg.slice(0, 80)})`);
3857
- chatBox.setContent(`${ACTIVE_STARTUP_SPLASH_CONTENT}\n\n {yellow-fg}Database unavailable.{/yellow-fg}\n {white-fg}${msg}{/white-fg}\n\n {gray-fg}Retrying connection in 30 seconds... (press q or Ctrl+C to quit){/gray-fg}`);
3858
- _origRender();
4904
+ const msg = String(err?.message || err || "Unknown database error");
4905
+ setStatus(`Database unavailable — retrying in 30s (${safeSlice(msg, 0, 80)})`);
4906
+ chatBox.setContent(`${ACTIVE_STARTUP_SPLASH_CONTENT}\n\n {yellow-fg}Database unavailable.{/yellow-fg}\n {white-fg}${msg}{/white-fg}\n\n {gray-fg}Retrying connection in 30 seconds... (press q or Ctrl+C to quit){/gray-fg}`);
4907
+ _origRender();
3859
4908
 
3860
- // Interruptible sleep — check every 500ms if the user pressed quit
3861
- const retryEnd = Date.now() + STARTUP_DB_RETRY_MS;
3862
- while (Date.now() < retryEnd) {
3863
- if (_startupQuit) process.exit(0);
3864
- await new Promise(r => setTimeout(r, 500));
4909
+ // Interruptible sleep — check every 500ms if the user pressed quit
4910
+ const retryEnd = Date.now() + STARTUP_DB_RETRY_MS;
4911
+ while (Date.now() < retryEnd) {
4912
+ if (_startupQuit) process.exit(0);
4913
+ await new Promise(r => setTimeout(r, 500));
4914
+ }
3865
4915
  }
3866
4916
  }
3867
4917
 
@@ -3921,9 +4971,13 @@ const sessionRenderedCmsSeq = new Map(); // orchId → highest CMS seq already i
3921
4971
  const sessionExpandLevel = new Map(); // orchId → 0 (default) | 1 | 2 (how many times user expanded history)
3922
4972
  const sessionSplashApplied = new Set(); // orchIds that have had splash prepended (idempotency guard)
3923
4973
  const sessionPromotedIntermediate = new Map(); // orchId → normalized intermediate content already promoted to Chat
4974
+ const sessionPendingAssistantPreview = new Map(); // orchId → { normalized: string, lines: string[] } for provisional assistant.message preview
3924
4975
  const sessionRecoveredTurnResult = new Map(); // orchId → normalized completed turn recovered from live status during CMS load
3925
4976
  const sessionObservers = new Map(); // orchId → AbortController
4977
+ const sessionObserverPollState = new Map(); // orchId → { version: number }
3926
4978
  const sessionLiveStatus = new Map(); // orchId → "idle"|"running"|"waiting"|"input_required"
4979
+ const sessionCronSchedules = new Map(); // orchId → { interval: number, reason?: string }
4980
+ const sessionContextUsage = new Map(); // orchId → latest context usage snapshot
3927
4981
  const sessionPendingTurns = new Set(); // orchIds with a locally-sent turn awaiting first live status
3928
4982
 
3929
4983
  // ─── Inline chat spinner ─────────────────────────────────────────
@@ -4007,6 +5061,153 @@ function isTerminalSessionState(state) {
4007
5061
  return state === "completed" || state === "failed" || state === "error" || state === "terminated";
4008
5062
  }
4009
5063
 
5064
+ function sessionStateFromOrchestrationStatus(status) {
5065
+ switch (status) {
5066
+ case "Completed": return "completed";
5067
+ case "Failed": return "error";
5068
+ case "Terminated": return "terminated";
5069
+ default: return null;
5070
+ }
5071
+ }
5072
+
5073
+ function updateSessionCronSchedule(orchId, customStatus) {
5074
+ if (!orchId || !customStatus) return;
5075
+ if (customStatus.cronActive === true && typeof customStatus.cronInterval === "number") {
5076
+ const next = {
5077
+ interval: customStatus.cronInterval,
5078
+ reason: typeof customStatus.cronReason === "string" ? customStatus.cronReason : undefined,
5079
+ };
5080
+ const prev = sessionCronSchedules.get(orchId);
5081
+ if (prev?.interval === next.interval && prev?.reason === next.reason) return;
5082
+ sessionCronSchedules.set(orchId, next);
5083
+ updateSessionListIcons();
5084
+ return;
5085
+ }
5086
+ if (sessionCronSchedules.delete(orchId)) {
5087
+ updateSessionListIcons();
5088
+ }
5089
+ }
5090
+
5091
+ function cloneContextUsageSnapshot(contextUsage) {
5092
+ if (!contextUsage || typeof contextUsage !== "object") return null;
5093
+ return {
5094
+ ...contextUsage,
5095
+ ...(contextUsage.compaction && typeof contextUsage.compaction === "object"
5096
+ ? { compaction: { ...contextUsage.compaction } }
5097
+ : {}),
5098
+ };
5099
+ }
5100
+
5101
+ function getContextHeaderBadge(orchId) {
5102
+ return formatContextHeaderBadge(sessionContextUsage.get(orchId));
5103
+ }
5104
+
5105
+ function getContextCompactionBadge(orchId) {
5106
+ return formatContextCompactionBadge(sessionContextUsage.get(orchId));
5107
+ }
5108
+
5109
+ function getSessionContextBadge(orchId) {
5110
+ return formatContextListBadge(sessionContextUsage.get(orchId));
5111
+ }
5112
+
5113
+ function noteSessionContextUsage(orchId, contextUsage) {
5114
+ if (!orchId) return;
5115
+ const snapshot = cloneContextUsageSnapshot(contextUsage);
5116
+ if (!snapshot) return;
5117
+ sessionContextUsage.set(orchId, snapshot);
5118
+ if (orchId === activeOrchId) updateChatLabel();
5119
+ updateSessionListIcons();
5120
+ }
5121
+
5122
+ function updateSessionContextUsageFromStatus(orchId, customStatus) {
5123
+ if (customStatus?.contextUsage && typeof customStatus.contextUsage === "object") {
5124
+ noteSessionContextUsage(orchId, customStatus.contextUsage);
5125
+ }
5126
+ }
5127
+
5128
+ function applySessionUsageEvent(orchId, eventType, data) {
5129
+ if (!orchId || !eventType || !data || typeof data !== "object") return;
5130
+ const current = cloneContextUsageSnapshot(sessionContextUsage.get(orchId)) || {};
5131
+
5132
+ if (eventType === "session.usage_info") {
5133
+ if (typeof data.tokenLimit !== "number" || typeof data.currentTokens !== "number" || typeof data.messagesLength !== "number") {
5134
+ return;
5135
+ }
5136
+ current.tokenLimit = data.tokenLimit;
5137
+ current.currentTokens = data.currentTokens;
5138
+ current.messagesLength = data.messagesLength;
5139
+ current.utilization = data.tokenLimit > 0 ? data.currentTokens / data.tokenLimit : 0;
5140
+ if (typeof data.systemTokens === "number") current.systemTokens = data.systemTokens;
5141
+ if (typeof data.conversationTokens === "number") current.conversationTokens = data.conversationTokens;
5142
+ if (typeof data.toolDefinitionsTokens === "number") current.toolDefinitionsTokens = data.toolDefinitionsTokens;
5143
+ if (typeof data.isInitial === "boolean") current.isInitial = data.isInitial;
5144
+ noteSessionContextUsage(orchId, current);
5145
+ return;
5146
+ }
5147
+
5148
+ if (eventType === "assistant.usage") {
5149
+ if (typeof data.inputTokens === "number") current.lastInputTokens = data.inputTokens;
5150
+ if (typeof data.outputTokens === "number") current.lastOutputTokens = data.outputTokens;
5151
+ if (typeof data.cacheReadTokens === "number") current.lastCacheReadTokens = data.cacheReadTokens;
5152
+ if (typeof data.cacheWriteTokens === "number") current.lastCacheWriteTokens = data.cacheWriteTokens;
5153
+ noteSessionContextUsage(orchId, current);
5154
+ return;
5155
+ }
5156
+
5157
+ if (eventType === "session.compaction_start") {
5158
+ current.compaction = {
5159
+ ...(current.compaction || {}),
5160
+ state: "running",
5161
+ startedAt: Date.now(),
5162
+ completedAt: undefined,
5163
+ error: undefined,
5164
+ };
5165
+ noteSessionContextUsage(orchId, current);
5166
+ return;
5167
+ }
5168
+
5169
+ if (eventType === "session.compaction_complete") {
5170
+ current.compaction = {
5171
+ ...(current.compaction || {}),
5172
+ state: data.success === false ? "failed" : "succeeded",
5173
+ completedAt: Date.now(),
5174
+ error: typeof data.error === "string" ? data.error : undefined,
5175
+ preCompactionTokens: typeof data.preCompactionTokens === "number" ? data.preCompactionTokens : undefined,
5176
+ postCompactionTokens: typeof data.postCompactionTokens === "number" ? data.postCompactionTokens : undefined,
5177
+ preCompactionMessagesLength: typeof data.preCompactionMessagesLength === "number" ? data.preCompactionMessagesLength : undefined,
5178
+ messagesRemoved: typeof data.messagesRemoved === "number" ? data.messagesRemoved : undefined,
5179
+ tokensRemoved: typeof data.tokensRemoved === "number" ? data.tokensRemoved : undefined,
5180
+ systemTokens: typeof data.systemTokens === "number" ? data.systemTokens : undefined,
5181
+ conversationTokens: typeof data.conversationTokens === "number" ? data.conversationTokens : undefined,
5182
+ toolDefinitionsTokens: typeof data.toolDefinitionsTokens === "number" ? data.toolDefinitionsTokens : undefined,
5183
+ inputTokens: typeof data.compactionTokensUsed?.input === "number" ? data.compactionTokensUsed.input : undefined,
5184
+ outputTokens: typeof data.compactionTokensUsed?.output === "number" ? data.compactionTokensUsed.output : undefined,
5185
+ cachedInputTokens: typeof data.compactionTokensUsed?.cachedInput === "number" ? data.compactionTokensUsed.cachedInput : undefined,
5186
+ };
5187
+ if (typeof data.postCompactionTokens === "number" && typeof current.tokenLimit === "number" && current.tokenLimit > 0) {
5188
+ current.currentTokens = data.postCompactionTokens;
5189
+ current.utilization = data.postCompactionTokens / current.tokenLimit;
5190
+ }
5191
+ if (typeof data.preCompactionMessagesLength === "number" && typeof data.messagesRemoved === "number") {
5192
+ current.messagesLength = Math.max(0, data.preCompactionMessagesLength - data.messagesRemoved);
5193
+ }
5194
+ if (typeof data.systemTokens === "number") current.systemTokens = data.systemTokens;
5195
+ if (typeof data.conversationTokens === "number") current.conversationTokens = data.conversationTokens;
5196
+ if (typeof data.toolDefinitionsTokens === "number") current.toolDefinitionsTokens = data.toolDefinitionsTokens;
5197
+ noteSessionContextUsage(orchId, current);
5198
+ }
5199
+ }
5200
+
5201
+ function formatCompactionActivityLine(t, evt) {
5202
+ return formatCompactionActivityMarkup(t, evt.eventType, evt.data || {});
5203
+ }
5204
+
5205
+ function getSessionCronBadge(orchId) {
5206
+ const cron = sessionCronSchedules.get(orchId);
5207
+ if (!cron) return "";
5208
+ return ` {magenta-fg}[cron ${formatHumanDurationSeconds(cron.interval)}]{/magenta-fg}`;
5209
+ }
5210
+
4010
5211
  function shouldObserveSession(orchId, cached = orchStatusCache.get(orchId)) {
4011
5212
  if (!orchId || sessionObservers.has(orchId)) return false;
4012
5213
  const visualState = getSessionVisualState(orchId, cached);
@@ -4014,8 +5215,15 @@ function shouldObserveSession(orchId, cached = orchStatusCache.get(orchId)) {
4014
5215
  }
4015
5216
 
4016
5217
  function getSessionVisualState(orchId, cached = orchStatusCache.get(orchId)) {
5218
+ const terminalState = sessionStateFromOrchestrationStatus(cached?.status);
5219
+ if (terminalState) {
5220
+ return terminalState;
5221
+ }
4017
5222
  const liveState = sessionLiveStatus.get(orchId) || cached?.liveState;
4018
- if (liveState) return liveState;
5223
+ if (liveState) {
5224
+ if (liveState === "waiting" && sessionCronSchedules.has(orchId)) return "cron_waiting";
5225
+ return liveState;
5226
+ }
4019
5227
  switch (cached?.status) {
4020
5228
  case "Completed": return "completed";
4021
5229
  case "Failed": return "failed";
@@ -4029,13 +5237,14 @@ function getSessionVisualState(orchId, cached = orchStatusCache.get(orchId)) {
4029
5237
  function getSessionStateColor(state) {
4030
5238
  switch (state) {
4031
5239
  case "running": return "green";
5240
+ case "cron_waiting": return "magenta";
4032
5241
  case "waiting": return "yellow";
4033
5242
  case "idle": return "white";
4034
5243
  case "input_required": return "cyan";
4035
5244
  case "completed": return "gray";
4036
5245
  case "failed":
4037
5246
  case "error": return "red";
4038
- case "terminated": return "yellow";
5247
+ case "terminated": return "red";
4039
5248
  default: return "white";
4040
5249
  }
4041
5250
  }
@@ -4043,21 +5252,42 @@ function getSessionStateColor(state) {
4043
5252
  function getSessionStateIcon(state) {
4044
5253
  switch (state) {
4045
5254
  case "running": return "{green-fg}*{/green-fg}";
5255
+ case "cron_waiting": return "{magenta-fg}~{/magenta-fg}";
4046
5256
  case "waiting": return "{yellow-fg}~{/yellow-fg}";
4047
5257
  case "idle": return "{white-fg}.{/white-fg}";
4048
5258
  case "input_required": return "{cyan-fg}?{/cyan-fg}";
4049
5259
  case "failed":
4050
- case "error": return "{red-fg}!{/red-fg}";
5260
+ case "error":
5261
+ case "terminated": return "{red-fg}!{/red-fg}";
4051
5262
  default: return "";
4052
5263
  }
4053
5264
  }
4054
5265
 
5266
+ function getSessionListStateColor(state) {
5267
+ return state === "cron_waiting" ? "yellow" : getSessionStateColor(state);
5268
+ }
5269
+
5270
+ function getSessionListStateIcon(state) {
5271
+ return state === "cron_waiting"
5272
+ ? "{yellow-fg}~{/yellow-fg}"
5273
+ : getSessionStateIcon(state);
5274
+ }
5275
+
4055
5276
  function setSessionPendingTurn(orchId, pending) {
4056
5277
  if (!orchId) return;
4057
5278
  if (pending) sessionPendingTurns.add(orchId);
4058
5279
  else sessionPendingTurns.delete(orchId);
4059
5280
  }
4060
5281
 
5282
+ function formatWaitingLabel(statusLike) {
5283
+ const reason = statusLike?.cronActive === true
5284
+ ? (statusLike?.cronReason || statusLike?.waitReason || "timer")
5285
+ : (statusLike?.waitReason || "timer");
5286
+ return statusLike?.cronActive === true
5287
+ ? `Cron waiting (${reason})…`
5288
+ : `Waiting (${reason})…`;
5289
+ }
5290
+
4061
5291
  function isTurnInProgressForSession(orchId) {
4062
5292
  if (!orchId) return false;
4063
5293
  const liveStatus = sessionLiveStatus.get(orchId);
@@ -4200,7 +5430,7 @@ function handleDbUnavailable(err) {
4200
5430
  if (!wasOffline || prevError !== msg) {
4201
5431
  appendLog(`{yellow-fg}Database unavailable — retrying in 30s.{/yellow-fg}`);
4202
5432
  }
4203
- setStatus(`Database unavailable — retrying in 30s (${msg.slice(0, 80)})`);
5433
+ setStatus(`Database unavailable — retrying in 30s (${safeSlice(msg, 0, 80)})`);
4204
5434
  }
4205
5435
 
4206
5436
  function handleDbRecovered() {
@@ -4264,8 +5494,8 @@ function updateSessionListIcons() {
4264
5494
  const id = orchIdOrder[i];
4265
5495
  const cached = orchStatusCache.get(id);
4266
5496
  const visualState = getSessionVisualState(id, cached);
4267
- const statusIcon = getSessionStateIcon(visualState);
4268
- const color = getSessionStateColor(visualState);
5497
+ const statusIcon = getSessionListStateIcon(visualState);
5498
+ const color = getSessionListStateColor(visualState);
4269
5499
 
4270
5500
  // Rebuild just this item's label
4271
5501
  const uuid4 = shortId(id);
@@ -4278,11 +5508,10 @@ function updateSessionListIcons() {
4278
5508
  })
4279
5509
  : "";
4280
5510
 
4281
- const hasChanges = orchHasChanges.has(id);
4282
5511
  const isActive = id === activeOrchId;
4283
5512
  const marker = isActive ? "{bold}▸{/bold}" : " ";
4284
- const changeSuffix = hasChanges ? " {cyan-fg}{bold}●{/bold}{/cyan-fg}" : "";
4285
5513
  const statusIconSlot = statusIcon ? statusIcon + " " : " ";
5514
+ const badgeSuffix = formatSessionListSuffixes(id);
4286
5515
  const heading = sessionHeadings.get(id);
4287
5516
  // Use cached depth from last full refresh
4288
5517
  const depth = orchDepthMap?.get(id) ?? 0;
@@ -4290,16 +5519,14 @@ function updateSessionListIcons() {
4290
5519
 
4291
5520
  // System sessions get special rendering: yellow, ≋ icon
4292
5521
  if (systemSessionIds.has(id)) {
4293
- const collapseBadge = getCollapseBadge(id);
4294
5522
  const sysLabel = heading
4295
- ? `${heading} (${uuid4}) ${timeStr}${collapseBadge}${changeSuffix}`
4296
- : `System Agent (${uuid4}) ${timeStr}${collapseBadge}${changeSuffix}`;
5523
+ ? `${heading} (${uuid4}) ${timeStr}${badgeSuffix}`
5524
+ : `System Agent (${uuid4}) ${timeStr}${badgeSuffix}`;
4297
5525
  orchList.setItem(i, `${indent}${marker}{bold}{yellow-fg}≋ ${sysLabel}{/yellow-fg}{/bold}`);
4298
5526
  } else {
4299
- const collapseBadge = getCollapseBadge(id);
4300
5527
  const label = heading
4301
- ? `${heading} (${uuid4}) ${timeStr}${collapseBadge}${changeSuffix}`
4302
- : `(${uuid4}) ${timeStr}${collapseBadge}${changeSuffix}`;
5528
+ ? `${heading} (${uuid4}) ${timeStr}${badgeSuffix}`
5529
+ : `(${uuid4}) ${timeStr}${badgeSuffix}`;
4303
5530
  orchList.setItem(i, `${indent}${marker}${statusIconSlot}{${color}-fg}${label}{/${color}-fg}`);
4304
5531
  }
4305
5532
  }
@@ -4331,6 +5558,15 @@ function getCollapseBadge(orchId) {
4331
5558
  return hidden ? ` {cyan-fg}[+${hidden}]{/cyan-fg}` : "";
4332
5559
  }
4333
5560
 
5561
+ function formatSessionListSuffixes(orchId) {
5562
+ const cronBadge = getSessionCronBadge(orchId);
5563
+ const contextBadge = getSessionContextBadge(orchId);
5564
+ const collapseBadge = getCollapseBadge(orchId);
5565
+ const changeSuffix = orchHasChanges.has(orchId) ? " {cyan-fg}{bold}●{/bold}{/cyan-fg}" : "";
5566
+ // Keep badge ordering stable across lightweight updates and full refreshes.
5567
+ return `${cronBadge}${contextBadge}${collapseBadge}${changeSuffix}`;
5568
+ }
5569
+
4334
5570
  function canonicalSystemTitleFromSessionView(sv, orchId) {
4335
5571
  const agentId = sv?.agentId || sessionAgentIds.get(orchId) || "";
4336
5572
  if (agentId === "pilotswarm") return HAS_CUSTOM_TUI_BRANDING ? BASE_TUI_TITLE : "PilotSwarm Agent";
@@ -4426,6 +5662,14 @@ async function refreshOrchestrations(force = false) {
4426
5662
  if (sv.agentId) {
4427
5663
  sessionAgentIds.set(id, sv.agentId);
4428
5664
  }
5665
+ if (sv.cronActive === true && typeof sv.cronInterval === "number") {
5666
+ sessionCronSchedules.set(id, {
5667
+ interval: sv.cronInterval,
5668
+ reason: typeof sv.cronReason === "string" ? sv.cronReason : undefined,
5669
+ });
5670
+ } else if (sv.cronActive === false) {
5671
+ sessionCronSchedules.delete(id);
5672
+ }
4429
5673
 
4430
5674
  // Splash from CMS — store and pre-populate chat buffer on first discovery
4431
5675
  const splashText = brandedSplashForSessionView(sv, id);
@@ -4443,7 +5687,12 @@ async function refreshOrchestrations(force = false) {
4443
5687
 
4444
5688
  // Seed sessionLiveStatus from CMS if no observer has set it yet.
4445
5689
  // This ensures status icons show correctly on initial load.
4446
- if (!sessionLiveStatus.has(id) && liveState && liveState !== "pending") {
5690
+ const terminalState = sessionStateFromOrchestrationStatus(status);
5691
+ if (terminalState) {
5692
+ sessionLiveStatus.set(id, terminalState);
5693
+ setSessionPendingTurn(id, false);
5694
+ clearSessionPendingQuestion(id);
5695
+ } else if (!sessionLiveStatus.has(id) && liveState && liveState !== "pending") {
4447
5696
  sessionLiveStatus.set(id, liveState);
4448
5697
  }
4449
5698
 
@@ -4581,33 +5830,30 @@ async function refreshOrchestrations(force = false) {
4581
5830
  })
4582
5831
  : "";
4583
5832
  const visualState = getSessionVisualState(id, orchStatusCache.get(id) || { status, createdAt });
4584
- const color = getSessionStateColor(visualState);
5833
+ const color = getSessionListStateColor(visualState);
4585
5834
 
4586
5835
  // Highlight sessions with unseen changes
4587
- const hasChanges = orchHasChanges.has(id);
4588
5836
  const isActive = id === activeOrchId;
4589
5837
  const marker = isActive ? "{bold}▸{/bold}" : " ";
4590
- const changeSuffix = hasChanges ? " {cyan-fg}{bold}●{/bold}{/cyan-fg}" : "";
4591
5838
 
4592
5839
  // Live status indicator
4593
- const statusIcon = getSessionStateIcon(visualState);
5840
+ const statusIcon = getSessionListStateIcon(visualState);
4594
5841
 
4595
5842
  const statusIconSlot = statusIcon ? statusIcon + " " : " ";
5843
+ const badgeSuffix = formatSessionListSuffixes(id);
4596
5844
  const heading = sessionHeadings.get(id);
4597
5845
  const indent = depth > 0 ? " ".repeat(depth - 1) + " └ " : "";
4598
5846
 
4599
5847
  // System sessions get special rendering: yellow, ≋ icon
4600
5848
  if (systemSessionIds.has(id)) {
4601
- const collapseBadge = getCollapseBadge(id);
4602
5849
  const sysLabel = heading
4603
- ? `${heading} (${uuid4}) ${timeStr}${collapseBadge}${changeSuffix}`
4604
- : `System Agent (${uuid4}) ${timeStr}${collapseBadge}${changeSuffix}`;
5850
+ ? `${heading} (${uuid4}) ${timeStr}${badgeSuffix}`
5851
+ : `System Agent (${uuid4}) ${timeStr}${badgeSuffix}`;
4605
5852
  orchList.addItem(`${indent}${marker}{bold}{yellow-fg}≋ ${sysLabel}{/yellow-fg}{/bold}`);
4606
5853
  } else {
4607
- const collapseBadge = getCollapseBadge(id);
4608
5854
  const label = heading
4609
- ? `${heading} (${uuid4}) ${timeStr}${collapseBadge}${changeSuffix}`
4610
- : `(${uuid4}) ${timeStr}${collapseBadge}${changeSuffix}`;
5855
+ ? `${heading} (${uuid4}) ${timeStr}${badgeSuffix}`
5856
+ : `(${uuid4}) ${timeStr}${badgeSuffix}`;
4611
5857
  orchList.addItem(`${indent}${marker}${statusIconSlot}{${color}-fg}${label}{/${color}-fg}`);
4612
5858
  }
4613
5859
  }
@@ -4660,6 +5906,14 @@ async function refreshOrchestrations(force = false) {
4660
5906
  perfEnd(_ph, { sessions: entries.length });
4661
5907
  }
4662
5908
 
5909
+ function renderWaitingForSessionsLanding() {
5910
+ chatBox.setContent(
5911
+ `${ACTIVE_STARTUP_SPLASH_CONTENT}\n\n {white-fg}Waiting for local workers to publish sessions...{/white-fg}\n\n {gray-fg}Press n to create a session immediately.{/gray-fg}`,
5912
+ );
5913
+ chatBox.setScrollPerc(0);
5914
+ screen.render();
5915
+ }
5916
+
4663
5917
  // Poll orchestrations every 10 seconds (observers handle live status updates, so
4664
5918
  // this only needs to catch new sessions and structural changes like title/parent).
4665
5919
  let orchPollTimer = setInterval(() => {
@@ -4677,7 +5931,7 @@ const perfSummaryInterval = setInterval(() => {
4677
5931
  for (const l of lines) totalBufferBytes += l.length;
4678
5932
  }
4679
5933
  let totalSeqEvents = 0;
4680
- for (const [, evts] of seqEventBuffers) totalSeqEvents += evts.length;
5934
+ for (const [, evts] of cmsSeqTimelines) totalSeqEvents += evts.length;
4681
5935
  perfTrace("periodic_summary", {
4682
5936
  heapUsedMB: +(mem.heapUsed / 1024 / 1024).toFixed(1),
4683
5937
  heapTotalMB: +(mem.heapTotal / 1024 / 1024).toFixed(1),
@@ -4685,7 +5939,7 @@ const perfSummaryInterval = setInterval(() => {
4685
5939
  chatBuffers: sessionChatBuffers.size,
4686
5940
  chatBufferLines: totalBufferLines,
4687
5941
  chatBufferKB: +(totalBufferBytes / 1024).toFixed(1),
4688
- seqBuffers: seqEventBuffers.size,
5942
+ seqBuffers: cmsSeqTimelines.size,
4689
5943
  seqEvents: totalSeqEvents,
4690
5944
  observers: sessionObservers.size,
4691
5945
  renders: _perfRenderCount,
@@ -4728,6 +5982,11 @@ orchList.key(["c"], async () => {
4728
5982
  const sessionId = id.startsWith("session-") ? id.slice(8) : id;
4729
5983
  try {
4730
5984
  await mgmt.cancelSession(sessionId);
5985
+ sessionLiveStatus.set(id, "terminated");
5986
+ setSessionPendingTurn(id, false);
5987
+ clearSessionPendingQuestion(id);
5988
+ sessionCronSchedules.delete(id);
5989
+ updateSessionListIcons();
4731
5990
  appendLog(`{yellow-fg}Cancelled ${shortId(id)}{/yellow-fg}`);
4732
5991
  await refreshOrchestrations();
4733
5992
  } catch (err) {
@@ -4749,6 +6008,7 @@ orchList.key(["d"], async () => {
4749
6008
  await mgmt.deleteSession(sessionId);
4750
6009
  knownOrchestrationIds.delete(id);
4751
6010
  orchStatusCache.delete(id);
6011
+ sessionCronSchedules.delete(id);
4752
6012
  appendLog(`{yellow-fg}Deleted ${shortId(id)}{/yellow-fg}`);
4753
6013
  await refreshOrchestrations();
4754
6014
  } catch (err) {
@@ -5202,11 +6462,6 @@ function startLogStream() {
5202
6462
  appendOrchLog(orchId, podName, formatted);
5203
6463
  }
5204
6464
 
5205
- // Feed sequence diagram
5206
- const seqEvt = parseSeqEvent(plain, podName);
5207
- if (seqEvt) {
5208
- appendSeqEvent(seqEvt.orchId, seqEvt);
5209
- }
5210
6465
  }
5211
6466
  screen.render();
5212
6467
  });
@@ -5280,7 +6535,6 @@ if (isRemote) {
5280
6535
  // Map sessionId → PilotSwarmSession object
5281
6536
  const sessions = new Map();
5282
6537
  const sessionModels = new Map(); // orchId → model name used for that session
5283
- let shutdownInProgress = false;
5284
6538
 
5285
6539
  // currentModel is declared earlier (before model providers loading)
5286
6540
 
@@ -5549,20 +6803,45 @@ function updateChatLabel() {
5549
6803
  const model = sessionModels.get(activeOrchId) || "";
5550
6804
  const shortModel = model.includes(":") ? model.split(":")[1] : model;
5551
6805
  const modelTag = shortModel ? ` {cyan-fg}${shortModel}{/cyan-fg}` : "";
6806
+ const contextTag = getContextHeaderBadge(activeOrchId);
6807
+ const compactionTag = getContextCompactionBadge(activeOrchId);
5552
6808
  const collapseBadge = getCollapseBadge(activeOrchId);
5553
6809
  const isSweeper = systemSessionIds.has(activeOrchId);
5554
6810
  if (isSweeper) {
5555
6811
  const sysTitle = sessionHeadings.get(activeOrchId) || "System Agent";
5556
- chatBox.setLabel(` {bold}{yellow-fg}≋ ${sysTitle}${collapseBadge}{/yellow-fg}{/bold} {white-fg}[${activeSessionShort}]{/white-fg}${modelTag} `);
6812
+ chatBox.setLabel(` {bold}{yellow-fg}≋ ${sysTitle}${collapseBadge}{/yellow-fg}{/bold} {white-fg}[${activeSessionShort}]{/white-fg}${modelTag}${contextTag}${compactionTag} `);
5557
6813
  chatBox.style.border.fg = "yellow";
5558
6814
  } else {
5559
6815
  const title = sessionHeadings.get(activeOrchId) || "Chat";
5560
- chatBox.setLabel(` {bold}${title}${collapseBadge}{/bold} {white-fg}[${activeSessionShort}]{/white-fg}${modelTag} `);
6816
+ chatBox.setLabel(` {bold}${title}${collapseBadge}{/bold} {white-fg}[${activeSessionShort}]{/white-fg}${modelTag}${contextTag}${compactionTag} `);
5561
6817
  chatBox.style.border.fg = "cyan";
5562
6818
  }
5563
6819
  screen.render();
5564
6820
  }
5565
6821
 
6822
+ function setObserverPollState(orchId, version) {
6823
+ if (!orchId) return;
6824
+ sessionObserverPollState.set(orchId, { version: Math.max(0, version || 0) });
6825
+ if (orchId === activeOrchId) updateActivityLabel();
6826
+ }
6827
+
6828
+ function clearObserverPollState(orchId) {
6829
+ if (!orchId) return;
6830
+ if (sessionObserverPollState.delete(orchId) && orchId === activeOrchId) {
6831
+ updateActivityLabel();
6832
+ }
6833
+ }
6834
+
6835
+ function updateActivityLabel() {
6836
+ const pollState = activeOrchId ? sessionObserverPollState.get(activeOrchId) : null;
6837
+ const isPollingActiveSession = Boolean(activeOrchId && sessionObservers.has(activeOrchId) && pollState);
6838
+ const pollingTag = isPollingActiveSession
6839
+ ? ` {gray-fg}[polling current session v${pollState.version}]{/gray-fg}`
6840
+ : "";
6841
+ activityPane.setLabel(` {bold}Activity{/bold}${pollingTag} `);
6842
+ scheduleRender();
6843
+ }
6844
+
5566
6845
  /**
5567
6846
  * Start observing an orchestration's custom status and pipe turn results
5568
6847
  * into the chat buffer. Runs until aborted or the orchestration completes.
@@ -5578,6 +6857,7 @@ function startObserver(orchId) {
5578
6857
 
5579
6858
  const ac = new AbortController();
5580
6859
  sessionObservers.set(orchId, ac);
6860
+ setObserverPollState(orchId, 0);
5581
6861
  let lastVersion = 0;
5582
6862
  let lastIteration = -1;
5583
6863
 
@@ -5657,10 +6937,14 @@ function startObserver(orchId) {
5657
6937
  appendChatRaw("{bold}Session info:{/bold}", orchId);
5658
6938
  appendChatRaw(` Model: {cyan-fg}${r.model}{/cyan-fg}`, orchId);
5659
6939
  appendChatRaw(` Iteration: ${r.iteration}`, orchId);
5660
- appendChatRaw(` Session: ${r.sessionId?.slice(0, 12)}…`, orchId);
6940
+ appendChatRaw(` Session: ${safeSlice(r.sessionId, 0, 12)}…`, orchId);
5661
6941
  appendChatRaw(` Affinity: ${r.affinityKey}`, orchId);
5662
6942
  appendChatRaw(` Hydrated: ${r.needsHydration ? "no (dehydrated)" : "yes"}`, orchId);
5663
6943
  appendChatRaw(` Blob: ${r.blobEnabled ? "enabled" : "disabled"}`, orchId);
6944
+ if (r.contextUsage?.tokenLimit > 0 && typeof r.contextUsage?.currentTokens === "number") {
6945
+ const percent = computeContextPercent(r.contextUsage);
6946
+ appendChatRaw(` Context: ${formatTokenCount(r.contextUsage.currentTokens)}/${formatTokenCount(r.contextUsage.tokenLimit)}${percent != null ? ` (${percent}%)` : ""}`, orchId);
6947
+ }
5664
6948
  break;
5665
6949
  }
5666
6950
  case "done": {
@@ -5685,7 +6969,7 @@ function startObserver(orchId) {
5685
6969
  if (!response) return;
5686
6970
  stopChatSpinner(orchId);
5687
6971
  if (response.type === "completed" && response.content) {
5688
- appendActivity(`{green-fg}[obs] ✓ SHOWING ${source}: version=${response.version} type=completed content=${response.content.slice(0, 80)}{/green-fg}`, orchId);
6972
+ appendActivity(`{green-fg}[obs] ✓ SHOWING ${source}: version=${response.version} type=completed content=${safeSlice(response.content, 0, 80)}{/green-fg}`, orchId);
5689
6973
  renderCompletedContent(response.content);
5690
6974
  if (cs.status === "idle" || cs.status === "completed") {
5691
6975
  setStatusIfActive(cs.status === "completed" ? "Session completed" : "Ready — type a message");
@@ -5696,11 +6980,15 @@ function startObserver(orchId) {
5696
6980
  return;
5697
6981
  }
5698
6982
  if (response.type === "wait" && response.content) {
5699
- appendActivity(`{green-fg}[obs] ✓ SHOWING ${source}: version=${response.version} type=wait content=${response.content.slice(0, 80)}{/green-fg}`, orchId);
6983
+ appendActivity(`{green-fg}[obs] ✓ SHOWING ${source}: version=${response.version} type=wait content=${safeSlice(response.content, 0, 80)}{/green-fg}`, orchId);
5700
6984
  const preview = summarizeActivityPreview(response.content);
5701
6985
  appendActivity(`{white-fg}[${ts()}]{/white-fg} {gray-fg}[intermediate]{/gray-fg} ${preview}`, orchId);
5702
6986
  promoteIntermediateContent(response.content, orchId);
5703
- setStatusIfActive(`Waiting (${cs.waitReason || response.waitReason || "timer"})…`);
6987
+ setStatusIfActive(formatWaitingLabel({
6988
+ cronActive: cs?.cronActive === true,
6989
+ cronReason: cs?.cronReason,
6990
+ waitReason: cs?.waitReason || response.waitReason,
6991
+ }));
5704
6992
  return;
5705
6993
  }
5706
6994
  if (response.type === "input_required") {
@@ -5723,6 +7011,8 @@ function startObserver(orchId) {
5723
7011
  : statusSnapshot.customStatus;
5724
7012
  } catch {}
5725
7013
  }
7014
+ updateSessionCronSchedule(orchId, cs);
7015
+ updateSessionContextUsageFromStatus(orchId, cs);
5726
7016
  if (!cs) return null;
5727
7017
 
5728
7018
  lastVersion = Math.max(lastVersion, statusSnapshot.customStatusVersion || 0);
@@ -5790,6 +7080,7 @@ function startObserver(orchId) {
5790
7080
  sessionLoggedTerminalStatus.set(orchId, terminalStatus);
5791
7081
  setSessionPendingTurn(orchId, false);
5792
7082
  setTurnInProgressIfActive(false);
7083
+ clearObserverPollState(orchId);
5793
7084
  sessionObservers.delete(orchId);
5794
7085
  return true;
5795
7086
  }
@@ -5804,6 +7095,7 @@ function startObserver(orchId) {
5804
7095
  try {
5805
7096
  const currentStatus = await dc.getStatus(orchId);
5806
7097
  if (ac.signal.aborted) return;
7098
+ setObserverPollState(orchId, currentStatus?.customStatusVersion || 0);
5807
7099
 
5808
7100
  // Check for terminal states FIRST — before inspecting customStatus
5809
7101
  if (await stopObserverForTerminalStatus(currentStatus, "terminal")) {
@@ -5817,6 +7109,8 @@ function startObserver(orchId) {
5817
7109
  ? JSON.parse(currentStatus.customStatus) : currentStatus.customStatus;
5818
7110
  } catch {}
5819
7111
  if (cs) {
7112
+ updateSessionCronSchedule(orchId, cs);
7113
+ updateSessionContextUsageFromStatus(orchId, cs);
5820
7114
  lastVersion = currentStatus.customStatusVersion || 0;
5821
7115
  if (cs.turnResult && cs.turnResult.type === "completed") {
5822
7116
  lastIteration = cs.iteration || 0;
@@ -5847,7 +7141,7 @@ function startObserver(orchId) {
5847
7141
  setTurnInProgressIfActive(true);
5848
7142
  updateLiveStatus("running");
5849
7143
  } else if (cs.status === "waiting") {
5850
- setStatusIfActive(`Waiting (${cs.waitReason || "timer"})…`);
7144
+ setStatusIfActive(formatWaitingLabel(cs));
5851
7145
  updateLiveStatus("waiting");
5852
7146
  } else if (cs.status === "input_required") {
5853
7147
  if (cs.turnResult?.type === "input_required") {
@@ -5888,7 +7182,7 @@ function startObserver(orchId) {
5888
7182
  const _obsPh = perfStart("observer.waitForStatusChange");
5889
7183
  const _waitStart = Date.now();
5890
7184
  const statusResult = await dc.waitForStatusChange(
5891
- orchId, lastVersion, 200, 30_000
7185
+ orchId, lastVersion, 1_000, 60_000
5892
7186
  );
5893
7187
  const _waitMs = Date.now() - _waitStart;
5894
7188
  perfEnd(_obsPh, { orchId: orchId.slice(0, 12), ver: statusResult.customStatusVersion });
@@ -5907,10 +7201,8 @@ function startObserver(orchId) {
5907
7201
  appendActivity(`{yellow-fg}🔄 [obs] continueAsNew detected in try: v${lastVersion}→v${statusResult.customStatusVersion}, resetting lastIter=${lastIteration}→-1 (${_waitMs}ms){/yellow-fg}`, orchId);
5908
7202
  lastVersion = statusResult.customStatusVersion;
5909
7203
  lastIteration = -1;
5910
- } else {
5911
- // Same version — poll returned without change (shouldn't happen normally)
5912
- appendActivity(`{gray-fg}[obs] poll returned same version v${lastVersion} (${_waitMs}ms){/gray-fg}`, orchId);
5913
7204
  }
7205
+ setObserverPollState(orchId, lastVersion);
5914
7206
 
5915
7207
  let cs = null;
5916
7208
  if (statusResult.customStatus) {
@@ -5919,6 +7211,8 @@ function startObserver(orchId) {
5919
7211
  ? JSON.parse(statusResult.customStatus) : statusResult.customStatus;
5920
7212
  } catch {}
5921
7213
  }
7214
+ updateSessionCronSchedule(orchId, cs);
7215
+ updateSessionContextUsageFromStatus(orchId, cs);
5922
7216
 
5923
7217
  if (cs) {
5924
7218
  // Log every status change with key fields
@@ -5979,7 +7273,7 @@ function startObserver(orchId) {
5979
7273
  } else if (cs.turnResult && cs.iteration <= lastIteration) {
5980
7274
  appendActivity(`{yellow-fg}[obs] ⚠ SKIPPED turnResult: iter=${cs.iteration} <= lastIter=${lastIteration} (already shown){/yellow-fg}`, orchId);
5981
7275
  if (cs.status === "waiting") {
5982
- setStatusIfActive(`Waiting (${cs.waitReason || "timer"})…`);
7276
+ setStatusIfActive(formatWaitingLabel(cs));
5983
7277
  }
5984
7278
  } else if (cs.responseVersion) {
5985
7279
  const latestResponse = await consumeLatestResponsePayload(orchId, cs, dc);
@@ -5999,7 +7293,7 @@ function startObserver(orchId) {
5999
7293
  setStatusIfActive("Running…");
6000
7294
  setTurnInProgressIfActive(true);
6001
7295
  } else if (cs.status === "waiting") {
6002
- setStatusIfActive(`Waiting (${cs.waitReason || "timer"})…`);
7296
+ setStatusIfActive(formatWaitingLabel(cs));
6003
7297
  } else if (cs.status === "input_required") {
6004
7298
  setStatusIfActive("Waiting for your answer...");
6005
7299
  updateLiveStatus("input_required");
@@ -6017,7 +7311,7 @@ function startObserver(orchId) {
6017
7311
  setStatusIfActive("Running…");
6018
7312
  setTurnInProgressIfActive(true);
6019
7313
  } else if (cs.status === "waiting") {
6020
- setStatusIfActive(`Waiting (${cs.waitReason || "timer"})…`);
7314
+ setStatusIfActive(formatWaitingLabel(cs));
6021
7315
  }
6022
7316
 
6023
7317
  // Mark session as having unseen changes if not active
@@ -6029,20 +7323,23 @@ function startObserver(orchId) {
6029
7323
  } catch (err) {
6030
7324
  // waitForStatusChange timed out or failed — check terminal state or continueAsNew
6031
7325
  if (ac.signal.aborted) break;
6032
- appendActivity(`{yellow-fg}[obs] catch: ${err.message || "timeout"} lastVersion=${lastVersion} lastIteration=${lastIteration}{/yellow-fg}`, orchId);
7326
+ const errMsg = err?.message || "timeout";
7327
+ const isStatusTimeout = /timed out/i.test(errMsg);
7328
+ if (!isStatusTimeout) {
7329
+ appendActivity(`{yellow-fg}[obs] catch: ${errMsg} lastVersion=${lastVersion} lastIteration=${lastIteration}{/yellow-fg}`, orchId);
7330
+ }
6033
7331
  try {
6034
7332
  const info = await dc.getStatus(orchId);
7333
+ const currentVersion = info.customStatusVersion || 0;
7334
+ setObserverPollState(orchId, currentVersion);
6035
7335
  if (await stopObserverForTerminalStatus(info, "terminal")) {
6036
7336
  break;
6037
7337
  }
6038
7338
  // Detect continueAsNew: customStatusVersion went backwards
6039
- const currentVersion = info.customStatusVersion || 0;
6040
7339
  if (currentVersion < lastVersion) {
6041
7340
  appendActivity(`{yellow-fg}🔄 [obs] continueAsNew in catch: v${lastVersion}→v${currentVersion}{/yellow-fg}`, orchId);
6042
7341
  lastVersion = 0;
6043
7342
  lastIteration = -1;
6044
- } else {
6045
- appendActivity(`{gray-fg}[obs] catch: no version reset (v${currentVersion} >= v${lastVersion}){/gray-fg}`, orchId);
6046
7343
  }
6047
7344
  } catch {}
6048
7345
  await new Promise(r => setTimeout(r, 500));
@@ -6089,11 +7386,42 @@ function startCmsPoller(orchId) {
6089
7386
  sessionRenderedCmsSeq.set(orchId, evt.seq);
6090
7387
  }
6091
7388
 
7389
+ // ── Update CMS sequence timeline incrementally ────
7390
+ if (evt.seq && evt.eventType) {
7391
+ const timeline = cmsSeqTimelines.get(sid) || [];
7392
+ const seqEvt = placeCmsSeqEvent(timeline, cmsEventToSeqEvent(evt));
7393
+ if (seqEvt) {
7394
+ const nodeId = getSeqEventNodeId(seqEvt);
7395
+ if (nodeId) cmsSeqNodeIndex(nodeId);
7396
+ timeline.push(seqEvt);
7397
+ cmsSeqTimelines.set(sid, timeline);
7398
+ cmsSeqLastSeq.set(sid, evt.seq);
7399
+ if (timeline.length > 300) timeline.splice(0, timeline.length - 300);
7400
+ if (logViewMode === "sequence" && orchId === activeOrchId) {
7401
+ refreshCmsSeqPane();
7402
+ screen.render();
7403
+ }
7404
+ }
7405
+ }
7406
+
6092
7407
  const t = formatDisplayTime(Date.now());
6093
7408
  const type = evt.eventType;
6094
7409
 
6095
- // Don't render events that the customStatus observer already handles
6096
- if (type === "assistant.message") return;
7410
+ if (type === "session.usage_info" || type === "assistant.usage"
7411
+ || type === "session.compaction_start" || type === "session.compaction_complete") {
7412
+ applySessionUsageEvent(orchId, type, evt.data);
7413
+ }
7414
+
7415
+ // assistant.message is shown as a provisional gray chat entry.
7416
+ // The completed turn result clears that provisional copy.
7417
+ if (type === "assistant.message") {
7418
+ if (typeof evt.data?.content === "string" && evt.data.content.trim()) {
7419
+ showLiveAssistantPreview(evt.data.content, orchId);
7420
+ }
7421
+ return;
7422
+ }
7423
+ // user.message is already reflected by the local send path and the
7424
+ // history rebuild path, so keep the live CMS subscription quiet here.
6097
7425
  if (type === "user.message") return;
6098
7426
 
6099
7427
  if (type === "tool.execution_start") {
@@ -6108,6 +7436,8 @@ function startCmsPoller(orchId) {
6108
7436
  appendActivity(`{white-fg}[${t}]{/white-fg} {gray-fg}[reasoning]{/gray-fg}`, orchId);
6109
7437
  } else if (type === "assistant.turn_start") {
6110
7438
  appendActivity(`{white-fg}[${t}]{/white-fg} {gray-fg}[turn start]{/gray-fg}`, orchId);
7439
+ } else if (type === "session.compaction_start" || type === "session.compaction_complete") {
7440
+ appendActivity(formatCompactionActivityLine(t, evt), orchId);
6111
7441
  } else if (type === "assistant.usage" || type === "session.info" || type === "session.idle"
6112
7442
  || type === "session.usage_info" || type === "pending_messages.modified" || type === "abort") {
6113
7443
  // skip internal/noisy events
@@ -6153,6 +7483,8 @@ async function switchToOrchestration(orchId) {
6153
7483
  if (!sessionModels.has(orchId) && info?.customStatus) {
6154
7484
  try {
6155
7485
  const cs = typeof info.customStatus === "string" ? JSON.parse(info.customStatus) : info.customStatus;
7486
+ updateSessionCronSchedule(orchId, cs);
7487
+ updateSessionContextUsageFromStatus(orchId, cs);
6156
7488
  const turnResult = cs.turnResult || cs.lastTurnResult;
6157
7489
  if (turnResult?.model) {
6158
7490
  sessionModels.set(orchId, turnResult.model);
@@ -6171,6 +7503,8 @@ async function switchToOrchestration(orchId) {
6171
7503
  if (info?.customStatus) {
6172
7504
  try {
6173
7505
  const cs = typeof info.customStatus === "string" ? JSON.parse(info.customStatus) : info.customStatus;
7506
+ updateSessionCronSchedule(orchId, cs);
7507
+ updateSessionContextUsageFromStatus(orchId, cs);
6174
7508
  if (cs?.status) {
6175
7509
  sessionLiveStatus.set(orchId, cs.status);
6176
7510
  updateSessionListIcons();
@@ -6193,6 +7527,7 @@ async function switchToOrchestration(orchId) {
6193
7527
  // Clear chat and show switch indicator (only when switching to a different session)
6194
7528
  if (!isSameSession) {
6195
7529
  updateChatLabel();
7530
+ updateActivityLabel();
6196
7531
 
6197
7532
  // Show cached chat buffer instantly if available (no DB wait)
6198
7533
  const _cachePh = perfStart("switch.cachedRestore");
@@ -6248,6 +7583,14 @@ async function switchToOrchestration(orchId) {
6248
7583
  if (orchId === activeOrchId) startCmsPoller(orchId);
6249
7584
  });
6250
7585
 
7586
+ // Load CMS event timeline for sequence diagram (background, non-blocking)
7587
+ const sessionIdForSeq = orchId.replace("session-", "");
7588
+ loadCmsSeqTimeline(sessionIdForSeq).then(() => {
7589
+ if (orchId === activeOrchId && logViewMode === "sequence") {
7590
+ refreshSeqPane();
7591
+ }
7592
+ }).catch(() => {});
7593
+
6251
7594
  // Schedule list refresh in background too
6252
7595
  scheduleRefreshOrchestrations();
6253
7596
  } else {
@@ -6271,6 +7614,7 @@ async function switchToOrchestration(orchId) {
6271
7614
  }
6272
7615
 
6273
7616
  updateChatLabel();
7617
+ updateActivityLabel();
6274
7618
  const initialChatLines = ensureSessionSplashBuffer(activeOrchId) || sessionChatBuffers.get(activeOrchId) || [];
6275
7619
  if (initialChatLines.length > 0) {
6276
7620
  chatBox.setContent(initialChatLines.map(styleUrls).join("\n"));
@@ -6590,7 +7934,6 @@ async function handleInput(text) {
6590
7934
  appendActivity(`{cyan-fg}[send] interrupt: session busy, enqueuing to ${targetOrchId?.slice(0,20)}{/cyan-fg}`, targetOrchId);
6591
7935
  inputBar.clearValue();
6592
7936
  if (targetOrchId === activeOrchId) setStatus("Interrupting...");
6593
- injectSeqUserEvent(targetOrchId, trimmed);
6594
7937
  try {
6595
7938
  const dc = getDc();
6596
7939
  if (dc) await dc.enqueueEvent(targetOrchId, "messages", JSON.stringify({ prompt: trimmed }));
@@ -6609,7 +7952,6 @@ async function handleInput(text) {
6609
7952
  focusInput();
6610
7953
  setSessionPendingTurn(targetOrchId, true);
6611
7954
  if (targetOrchId === activeOrchId) setStatus("Thinking... (waiting for AKS worker)");
6612
- injectSeqUserEvent(targetOrchId, trimmed);
6613
7955
  screen.render();
6614
7956
 
6615
7957
  try {
@@ -6624,9 +7966,16 @@ async function handleInput(text) {
6624
7966
  || orchStatus.output?.split("\n")[0]
6625
7967
  || "Unknown error")
6626
7968
  : orchStatus.status;
7969
+ const terminalState = sessionStateFromOrchestrationStatus(orchStatus.status);
7970
+ if (terminalState) {
7971
+ sessionLiveStatus.set(targetOrchId, terminalState);
7972
+ }
6627
7973
  appendChatRaw(`{red-fg}❌ Cannot send — orchestration ${orchStatus.status}: ${reason}{/red-fg}`, targetOrchId);
6628
7974
  appendChatRaw(`{white-fg}Create a new session with 'n' to continue.{/white-fg}`, targetOrchId);
7975
+ stopChatSpinner(targetOrchId);
6629
7976
  setSessionPendingTurn(targetOrchId, false);
7977
+ clearSessionPendingQuestion(targetOrchId);
7978
+ updateSessionListIcons();
6630
7979
  if (targetOrchId === activeOrchId) setStatus(`${orchStatus.status} — session is dead`);
6631
7980
  screen.render();
6632
7981
  return;
@@ -6700,6 +8049,7 @@ function showHelpOverlay() {
6700
8049
  " {yellow-fg}u{/yellow-fg} Dump active session to Markdown file",
6701
8050
  " {yellow-fg}a{/yellow-fg} Show artifact picker (download files)",
6702
8051
  " {yellow-fg}Ctrl+A{/yellow-fg} Attach local file to prompt",
8052
+ " {yellow-fg}Mouse drag{/yellow-fg} Select visible text inside one pane and copy on release",
6703
8053
  "",
6704
8054
  "{bold}Sessions Pane{/bold}",
6705
8055
  " {yellow-fg}j / k{/yellow-fg} Navigate up / down",
@@ -6736,7 +8086,7 @@ function showHelpOverlay() {
6736
8086
  " {yellow-fg}d{/yellow-fg} Delete selected exported file",
6737
8087
  " {yellow-fg}g / G{/yellow-fg} Preview top / bottom",
6738
8088
  " {yellow-fg}Ctrl+D/U{/yellow-fg} Preview page down / up",
6739
- " {yellow-fg}o{/yellow-fg} Open selected file in $EDITOR",
8089
+ " {yellow-fg}o{/yellow-fg} Open selected file in default app",
6740
8090
  " {yellow-fg}y{/yellow-fg} Copy selected file path",
6741
8091
  " {yellow-fg}v{/yellow-fg} Exit markdown viewer",
6742
8092
  "",
@@ -6817,8 +8167,8 @@ function showHelpOverlay() {
6817
8167
  async function cleanup() {
6818
8168
  // Force-exit after 15s — don't let a stuck shutdown hang forever
6819
8169
  const forceExitTimer = setTimeout(() => {
6820
- const buf = Buffer.from("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1049l\x1b[?25h");
6821
- try { fs.writeSync(1, buf); } catch {}
8170
+ releaseLocalEmbeddedRuntimeLock();
8171
+ try { fs.writeSync(1, Buffer.from(TERMINAL_RESET_ESCAPE)); } catch {}
6822
8172
  process.exit(0);
6823
8173
  }, 15000);
6824
8174
  forceExitTimer.unref();
@@ -6839,6 +8189,7 @@ async function cleanup() {
6839
8189
  client.stop(),
6840
8190
  mgmt.stop(),
6841
8191
  ]);
8192
+ releaseLocalEmbeddedRuntimeLock();
6842
8193
 
6843
8194
  // Suppress ALL output before destroying — neo-blessed dumps terminfo
6844
8195
  // compilation junk (SetUlc) synchronously during destroy().
@@ -6847,8 +8198,7 @@ async function cleanup() {
6847
8198
  try { screen.destroy(); } catch {}
6848
8199
  // Write terminal reset directly to fd to bypass our suppression
6849
8200
  // Disable mouse tracking modes + exit alt-screen + show cursor
6850
- const buf = Buffer.from("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1049l\x1b[?25h");
6851
- try { fs.writeSync(1, buf); } catch {}
8201
+ try { fs.writeSync(1, Buffer.from(TERMINAL_RESET_ESCAPE)); } catch {}
6852
8202
  }
6853
8203
 
6854
8204
  screen.key(["C-c"], async () => {
@@ -6958,6 +8308,13 @@ screen.on("keypress", (ch, key) => {
6958
8308
  screen.render();
6959
8309
  return;
6960
8310
  }
8311
+ if (ch === "o") {
8312
+ const files = scanExportFiles();
8313
+ const f = files[mdViewerSelectedIdx];
8314
+ if (!f) return;
8315
+ openPathInDefaultApp(f.localPath);
8316
+ return;
8317
+ }
6961
8318
  // d: delete the selected file
6962
8319
  if (ch === "d") {
6963
8320
  const files = scanExportFiles();
@@ -6993,10 +8350,10 @@ screen.on("keypress", (ch, key) => {
6993
8350
  // [ / ]: resize right pane by 8 chars
6994
8351
  } else if ((ch === "[" || ch === "]") && screen.focused !== inputBar) {
6995
8352
  if (ch === "[") rightPaneAdjust += 8; // shrink right (grow left)
6996
- else rightPaneAdjust = Math.max(0, rightPaneAdjust - 8); // grow right (shrink left)
8353
+ else rightPaneAdjust -= 8; // grow right (shrink left)
6997
8354
  // Clamp: right pane min 20 chars, left pane min 30 chars
6998
- const maxAdj = screen.width - 20 - Math.floor(screen.width * 0.45);
6999
- rightPaneAdjust = Math.max(-(Math.floor(screen.width * 0.45) - 30), Math.min(rightPaneAdjust, maxAdj));
8355
+ const maxAdj = screen.width - MIN_RIGHT_PANE_WIDTH - baseLeftW();
8356
+ rightPaneAdjust = Math.max(-(baseLeftW() - MIN_LEFT_PANE_WIDTH), Math.min(rightPaneAdjust, maxAdj));
7000
8357
  relayoutAll();
7001
8358
  redrawActiveViews();
7002
8359
  return;
@@ -7166,6 +8523,7 @@ function buildFocusableList() {
7166
8523
  }
7167
8524
 
7168
8525
  screen.on("resize", () => {
8526
+ clearPaneSelection({ render: false });
7169
8527
  relayoutAll();
7170
8528
  if (logViewMode === "sequence") refreshSeqPane();
7171
8529
  if (logViewMode === "nodemap") refreshNodeMap();
@@ -7177,6 +8535,23 @@ await refreshOrchestrations();
7177
8535
  // Trigger initial right-pane render (orch logs, sequence, etc.)
7178
8536
  redrawActiveViews();
7179
8537
 
8538
+ if (!activeOrchId) {
8539
+ renderWaitingForSessionsLanding();
8540
+ if (!isRemote) {
8541
+ // Lazy worker startup means the first session list refresh can race ahead
8542
+ // of system-agent/session creation. Re-check quickly before the normal
8543
+ // 10s poll so the TUI does not appear stuck on the startup splash.
8544
+ for (const delay of [250, 750, 1500, 3000, 5000]) {
8545
+ setTimeout(() => {
8546
+ if (activeOrchId) return;
8547
+ refreshOrchestrations().then(() => {
8548
+ if (!activeOrchId) renderWaitingForSessionsLanding();
8549
+ }).catch(() => {});
8550
+ }, delay);
8551
+ }
8552
+ }
8553
+ }
8554
+
7180
8555
  // ─── Auto-summarize all existing sessions on startup ─────────────
7181
8556
  async function summarizeSession(orchId) {
7182
8557
  if (sessionSummarized.has(orchId)) return;
@@ -7219,7 +8594,7 @@ async function summarizeSession(orchId) {
7219
8594
  let sawRunning = false;
7220
8595
  while (Date.now() < deadline) {
7221
8596
  try {
7222
- const result = await dc.waitForStatusChange(orchId, version, 200, 15_000);
8597
+ const result = await dc.waitForStatusChange(orchId, version, 1_000, 60_000);
7223
8598
  if (result.customStatusVersion > version) {
7224
8599
  version = result.customStatusVersion;
7225
8600
  }
@@ -7230,6 +8605,8 @@ async function summarizeSession(orchId) {
7230
8605
  ? JSON.parse(result.customStatus) : result.customStatus;
7231
8606
  } catch {}
7232
8607
  }
8608
+ updateSessionCronSchedule(orchId, cs);
8609
+ updateSessionContextUsageFromStatus(orchId, cs);
7233
8610
  if (cs?.status === "running") {
7234
8611
  sawRunning = true;
7235
8612
  continue; // wait for the completed result