@jhizzard/termdeck 1.3.0 → 1.4.0
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/package.json +1 -1
- package/packages/client/public/app.js +555 -8
- package/packages/client/public/index.html +28 -6
- package/packages/client/public/style.css +127 -0
- package/packages/server/src/database.js +20 -1
- package/packages/server/src/index.js +84 -6
- package/packages/server/src/session.js +25 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
|
|
5
5
|
"bin": {
|
|
6
6
|
"termdeck": "./packages/cli/src/index.js"
|
|
@@ -25,7 +25,14 @@
|
|
|
25
25
|
// works if the endpoint 404s on an older server during a rolling
|
|
26
26
|
// upgrade — Claude only, anchored binary match.
|
|
27
27
|
agentAdapters: [{ name: 'claude', sessionType: 'claude-code', binary: 'claude', costBand: 'pay-per-token' }],
|
|
28
|
-
focusedId: null
|
|
28
|
+
focusedId: null,
|
|
29
|
+
// Sprint 65 T1 — selected project-filter chip ('' = All). Hydrated from
|
|
30
|
+
// localStorage in init(); a TDZ on PROJECT_FILTER_KEY prevents reading
|
|
31
|
+
// it here in the state literal.
|
|
32
|
+
projectFilter: '',
|
|
33
|
+
// Sprint 65 T1 (c) — global terminal font size (xterm.js default 13).
|
|
34
|
+
// Hydrated from localStorage in init() (same TDZ reason as projectFilter).
|
|
35
|
+
fontSize: 13
|
|
29
36
|
};
|
|
30
37
|
|
|
31
38
|
// ===== API helpers =====
|
|
@@ -33,11 +40,25 @@
|
|
|
33
40
|
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
34
41
|
if (body) opts.body = JSON.stringify(body);
|
|
35
42
|
const res = await fetch(`${API}${path}`, opts);
|
|
36
|
-
return
|
|
43
|
+
// Success path: unchanged — return the parsed JSON body verbatim.
|
|
44
|
+
if (res.ok) return res.json();
|
|
45
|
+
// Sprint 65 T1 (1.3b) — non-2xx: many callers gate on `.error` and
|
|
46
|
+
// otherwise treat the body as success. A 4xx/5xx body without an
|
|
47
|
+
// `error` key (e.g. the dead-panel shape 410 {ok:false,code,message})
|
|
48
|
+
// would be misreported as delivered. annotateApiFailure() synthesizes a
|
|
49
|
+
// uniform `error` field and surfaces `_httpStatus` for precise callers.
|
|
50
|
+
let data = null;
|
|
51
|
+
try { data = await res.json(); } catch (err) { /* non-JSON error body */ }
|
|
52
|
+
return annotateApiFailure(data, res.status);
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
// ===== Initialize =====
|
|
40
56
|
async function init() {
|
|
57
|
+
// Sprint 65 T1 — hydrate saved client prefs before any panel mounts: the
|
|
58
|
+
// project-filter chip (so a new tile filters right from frame 1) and the
|
|
59
|
+
// terminal font size (so restored panels open at the operator's size).
|
|
60
|
+
state.projectFilter = loadProjectFilter();
|
|
61
|
+
state.fontSize = loadFontSize();
|
|
41
62
|
// Load config
|
|
42
63
|
state.config = await api('GET', '/api/config');
|
|
43
64
|
updateRagIndicator();
|
|
@@ -105,6 +126,14 @@
|
|
|
105
126
|
|
|
106
127
|
updateEmptyState();
|
|
107
128
|
|
|
129
|
+
// Sprint 65 T1 — initial restore complete: subsequent createTerminalPanel
|
|
130
|
+
// calls are user-initiated launches, so arm the born-hidden chip guard.
|
|
131
|
+
_initialLoadComplete = true;
|
|
132
|
+
// Render chips + route the ORCH row now that the restored panels exist.
|
|
133
|
+
refreshDashboardChrome();
|
|
134
|
+
// Sprint 65 T1 (c) — sync the topbar font-size label to the saved size.
|
|
135
|
+
applyFontSizeToAll();
|
|
136
|
+
|
|
108
137
|
// Rumen insights badge + briefing (no-op when server reports enabled:false)
|
|
109
138
|
setupRumen();
|
|
110
139
|
|
|
@@ -433,7 +462,9 @@
|
|
|
433
462
|
</div>
|
|
434
463
|
`;
|
|
435
464
|
|
|
436
|
-
|
|
465
|
+
// Sprint 65 T1 (1.2) — orchestrator panels render in the pinned ORCH
|
|
466
|
+
// row above the grid; everything else goes in the grid.
|
|
467
|
+
placePanel(panel, meta);
|
|
437
468
|
|
|
438
469
|
// Sprint 42 T4: drag/drop reorder. Inject identifier is the session
|
|
439
470
|
// UUID, so DOM reorder is purely visual — Alt+1…9 (creation-order),
|
|
@@ -443,7 +474,7 @@
|
|
|
443
474
|
// Create xterm.js instance
|
|
444
475
|
const terminal = new Terminal({
|
|
445
476
|
fontFamily: "'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
|
446
|
-
fontSize:
|
|
477
|
+
fontSize: state.fontSize,
|
|
447
478
|
lineHeight: 1.3,
|
|
448
479
|
cursorBlink: true,
|
|
449
480
|
cursorStyle: 'bar',
|
|
@@ -499,6 +530,11 @@
|
|
|
499
530
|
refreshPanelIndices();
|
|
500
531
|
renderSwitcher();
|
|
501
532
|
break;
|
|
533
|
+
case 'panel_exited':
|
|
534
|
+
// Sprint 65 T1 (1.3) — T2 broadcasts this when a PTY exits.
|
|
535
|
+
// Primary path for auto-removing the dead tile from the grid.
|
|
536
|
+
handlePanelExited(msg.sessionId, msg.exitCode);
|
|
537
|
+
break;
|
|
502
538
|
case 'status_broadcast':
|
|
503
539
|
updateGlobalStats(msg.sessions);
|
|
504
540
|
break;
|
|
@@ -592,6 +628,11 @@
|
|
|
592
628
|
if (replyBtn) replyBtn.disabled = state.sessions.size < 2;
|
|
593
629
|
refreshAllReplyFormsFor(id);
|
|
594
630
|
refreshPanelIndices();
|
|
631
|
+
// Sprint 65 T1 — a user-launched panel must never be born hidden behind a
|
|
632
|
+
// stale chip filter (T3 20:10 / T4 20:11): switch the filter to its
|
|
633
|
+
// project first, then refresh chrome so the new tile is visible.
|
|
634
|
+
revealNewPanelIfFiltered(meta);
|
|
635
|
+
refreshDashboardChrome();
|
|
595
636
|
|
|
596
637
|
// Handle window resize
|
|
597
638
|
const resizeObserver = new ResizeObserver(() => {
|
|
@@ -611,6 +652,486 @@
|
|
|
611
652
|
return { terminal, ws, fitAddon };
|
|
612
653
|
}
|
|
613
654
|
|
|
655
|
+
// ===== Sprint 65: project-filter chips + ORCH-panel pin + tile lifecycle =====
|
|
656
|
+
// Brad's 2026-05-13 v2 spec (BACKLOG § D.5) — three dashboard-reliability
|
|
657
|
+
// surfaces sharing one client-side lane:
|
|
658
|
+
// 1.1 project-filter chips — a per-project visibility filter above the grid
|
|
659
|
+
// 1.2 ORCH pin — panels with meta.role==='orchestrator' get a dedicated
|
|
660
|
+
// always-visible row + gold/amber treatment, outside the chip filter
|
|
661
|
+
// 1.3 tile auto-removal — dead PTY panels leave the grid instead of
|
|
662
|
+
// lingering as "dead" tiles (Brad's "18 windows, 10 dead codex cli")
|
|
663
|
+
//
|
|
664
|
+
// Design note: TermDeck panels are persistent DOM + live xterm.js + a
|
|
665
|
+
// per-panel WebSocket. They are created once (createTerminalPanel) and
|
|
666
|
+
// never re-rendered per frame — status_broadcast only mutates meta. So the
|
|
667
|
+
// chip row and filter work by toggling classes on existing tiles, never by
|
|
668
|
+
// tearing panels down; ORCH routing is decided at panel-create time and
|
|
669
|
+
// re-checked cheaply on each broadcast.
|
|
670
|
+
|
|
671
|
+
const PROJECT_FILTER_KEY = 'termdeck.dashboard.projectFilter';
|
|
672
|
+
// Belt-and-suspenders thresholds for tile reconciliation (reconcileExitedPanels).
|
|
673
|
+
// ORPHAN_GRACE_MS > the tile-exit grace so the primary panel_exited path
|
|
674
|
+
// always wins when the WS frame is delivered.
|
|
675
|
+
const ORPHAN_GRACE_MS = 5000;
|
|
676
|
+
const STALE_EXITED_MS = 60000;
|
|
677
|
+
let _chromeRefreshScheduled = false;
|
|
678
|
+
// false during init()'s panel-restore loop, true afterward. Gates the
|
|
679
|
+
// born-hidden chip guard so a saved filter is honored on reload but a
|
|
680
|
+
// user-launched panel is always revealed.
|
|
681
|
+
let _initialLoadComplete = false;
|
|
682
|
+
|
|
683
|
+
// --- Pure helpers (no DOM, no globals — unit-tested in
|
|
684
|
+
// tests/dashboard-panels-client.test.js via the vm-extract pattern) ---
|
|
685
|
+
|
|
686
|
+
// Distinct non-null project tags across the given session-meta list, plus
|
|
687
|
+
// whether any panel carries no project at all. `metas` is an array of
|
|
688
|
+
// session.meta objects. Exited panels are excluded — chips count live work.
|
|
689
|
+
function discoverPanelProjects(metas) {
|
|
690
|
+
const projects = [];
|
|
691
|
+
const seen = Object.create(null);
|
|
692
|
+
let hasNullProject = false;
|
|
693
|
+
for (const m of (metas || [])) {
|
|
694
|
+
if (!m || m.status === 'exited') continue;
|
|
695
|
+
const p = m.project;
|
|
696
|
+
if (p === null || p === undefined || p === '') {
|
|
697
|
+
hasNullProject = true;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (!seen[p]) {
|
|
701
|
+
seen[p] = true;
|
|
702
|
+
projects.push(p);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
projects.sort();
|
|
706
|
+
return { projects: projects, hasNullProject: hasNullProject };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Count of live (non-exited) panels for one chip. The "All" chip passes
|
|
710
|
+
// project==='' and counts every live panel; a project chip counts only its
|
|
711
|
+
// own. Orchestrator panels are counted under their project too — the ORCH
|
|
712
|
+
// pin is a placement, not an exclusion from the totals.
|
|
713
|
+
function countPanelsForProject(metas, project) {
|
|
714
|
+
let n = 0;
|
|
715
|
+
for (const m of (metas || [])) {
|
|
716
|
+
if (!m || m.status === 'exited') continue;
|
|
717
|
+
if (project === '' || project === null || project === undefined) { n++; continue; }
|
|
718
|
+
if (m.project === project) n++;
|
|
719
|
+
}
|
|
720
|
+
return n;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Whether a grid tile with the given project should be visible under the
|
|
724
|
+
// current chip selection. The "All" selection ('') shows everything; a
|
|
725
|
+
// project selection shows only exact matches (null-project panels are
|
|
726
|
+
// hidden under any specific-project filter — they surface only under All).
|
|
727
|
+
function isPanelVisibleUnderFilter(panelProject, selectedFilter) {
|
|
728
|
+
if (selectedFilter === '' || selectedFilter === null || selectedFilter === undefined) return true;
|
|
729
|
+
return panelProject === selectedFilter;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// The chip row is only worth showing when there is real filtering to do:
|
|
733
|
+
// at least two project buckets, or one project plus some untagged panels.
|
|
734
|
+
function shouldShowChipRow(projects, hasNullProject) {
|
|
735
|
+
const n = (projects || []).length;
|
|
736
|
+
return n >= 2 || (n >= 1 && hasNullProject === true);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Approach A (Brad's 2026-05-13 spec): orchestrator identity is the
|
|
740
|
+
// explicit meta.role flag, never inferred from cwd.
|
|
741
|
+
function isOrchestratorRole(role) {
|
|
742
|
+
return role === 'orchestrator';
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Belt-and-suspenders for missed panel_exited frames: panel ids the
|
|
746
|
+
// dashboard still has a tile for, but which no longer appear in the
|
|
747
|
+
// server's broadcast session list. Works whether or not T2 filters exited
|
|
748
|
+
// sessions out of status_broadcast.
|
|
749
|
+
function findOrphanedPanelIds(knownPanelIds, broadcastSessionIds) {
|
|
750
|
+
const live = Object.create(null);
|
|
751
|
+
for (const id of (broadcastSessionIds || [])) live[id] = true;
|
|
752
|
+
const orphaned = [];
|
|
753
|
+
for (const id of (knownPanelIds || [])) {
|
|
754
|
+
if (!live[id]) orphaned.push(id);
|
|
755
|
+
}
|
|
756
|
+
return orphaned;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// 1.3b — give a non-2xx API body a uniform failure signal. Many callers
|
|
760
|
+
// gate on `.error`; a 4xx/5xx body that lacks it (e.g. the Sprint 65
|
|
761
|
+
// dead-panel shape 410 {ok:false,code:'panel_exited',message}) would
|
|
762
|
+
// otherwise be misread as success. Pure: takes the parsed body + status,
|
|
763
|
+
// returns the body annotated with `error` + `_httpStatus`.
|
|
764
|
+
function annotateApiFailure(body, httpStatus) {
|
|
765
|
+
const out = (body && typeof body === 'object' && !Array.isArray(body)) ? body : {};
|
|
766
|
+
if (out.error === undefined || out.error === null) {
|
|
767
|
+
out.error = out.message || out.code || ('HTTP ' + httpStatus);
|
|
768
|
+
}
|
|
769
|
+
out._httpStatus = httpStatus;
|
|
770
|
+
return out;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// 1.1 born-hidden guard (T3 20:10 / T4 20:11) — a panel the operator just
|
|
774
|
+
// launched must never be hidden by a stale chip filter. Given the active
|
|
775
|
+
// filter and a newly-created panel, returns the filter value that keeps the
|
|
776
|
+
// panel visible, or null when no switch is needed. Pure.
|
|
777
|
+
function filterValueRevealingPanel(currentFilter, panelProject, isOrchPanel, initialLoadComplete) {
|
|
778
|
+
if (initialLoadComplete !== true) return null; // initial restore — honor the saved filter
|
|
779
|
+
if (isOrchPanel === true) return null; // ORCH panels bypass the chip filter
|
|
780
|
+
const proj = panelProject || '';
|
|
781
|
+
if (!currentFilter) return null; // "All" already shows everything
|
|
782
|
+
if (currentFilter === proj) return null; // already visible under this filter
|
|
783
|
+
return proj; // switch the filter to the new panel's project
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// --- localStorage-backed filter persistence (origin-scoped, per-tab) ---
|
|
787
|
+
function loadProjectFilter() {
|
|
788
|
+
try {
|
|
789
|
+
const v = localStorage.getItem(PROJECT_FILTER_KEY);
|
|
790
|
+
return typeof v === 'string' ? v : '';
|
|
791
|
+
} catch (err) {
|
|
792
|
+
console.warn('[client] projectFilter load failed:', err);
|
|
793
|
+
return '';
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
function saveProjectFilter(value) {
|
|
797
|
+
try {
|
|
798
|
+
localStorage.setItem(PROJECT_FILTER_KEY, value || '');
|
|
799
|
+
} catch (err) {
|
|
800
|
+
console.warn('[client] projectFilter save failed:', err);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// session.meta for every panel currently mounted on THIS dashboard. Skips
|
|
805
|
+
// the _mounting placeholder createTerminalPanel reserves at function entry
|
|
806
|
+
// before the real entry is written.
|
|
807
|
+
function dashboardPanelMetas() {
|
|
808
|
+
const metas = [];
|
|
809
|
+
for (const entry of state.sessions.values()) {
|
|
810
|
+
if (!entry || entry._mounting || !entry.session || !entry.session.meta) continue;
|
|
811
|
+
metas.push(entry.session.meta);
|
|
812
|
+
}
|
|
813
|
+
return metas;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// 1.1 — (re)render the project-filter chip row from the live panel set.
|
|
817
|
+
// Chips are built with createElement (not innerHTML) so project names need
|
|
818
|
+
// no attribute escaping and cannot inject markup.
|
|
819
|
+
function renderProjectChips() {
|
|
820
|
+
const row = document.getElementById('project-chips');
|
|
821
|
+
if (!row) return;
|
|
822
|
+
const metas = dashboardPanelMetas();
|
|
823
|
+
const discovered = discoverPanelProjects(metas);
|
|
824
|
+
const projects = discovered.projects;
|
|
825
|
+
|
|
826
|
+
if (!shouldShowChipRow(projects, discovered.hasNullProject)) {
|
|
827
|
+
row.replaceChildren();
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// If the selected project no longer has any live panels, fall back to
|
|
832
|
+
// "All" so the grid never strands empty behind a dead chip.
|
|
833
|
+
if (state.projectFilter && projects.indexOf(state.projectFilter) === -1) {
|
|
834
|
+
state.projectFilter = '';
|
|
835
|
+
saveProjectFilter('');
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const chips = [{ project: '', label: 'All' }];
|
|
839
|
+
for (const p of projects) chips.push({ project: p, label: p });
|
|
840
|
+
|
|
841
|
+
const frag = document.createDocumentFragment();
|
|
842
|
+
for (const c of chips) {
|
|
843
|
+
const btn = document.createElement('button');
|
|
844
|
+
btn.type = 'button';
|
|
845
|
+
btn.className = 'project-chip' + (((state.projectFilter || '') === c.project) ? ' active' : '');
|
|
846
|
+
btn.dataset.project = c.project;
|
|
847
|
+
const label = document.createElement('span');
|
|
848
|
+
label.className = 'project-chip-label';
|
|
849
|
+
label.textContent = c.label;
|
|
850
|
+
const count = document.createElement('span');
|
|
851
|
+
count.className = 'project-chip-count';
|
|
852
|
+
count.textContent = '(' + countPanelsForProject(metas, c.project) + ')';
|
|
853
|
+
btn.appendChild(label);
|
|
854
|
+
btn.appendChild(count);
|
|
855
|
+
frag.appendChild(btn);
|
|
856
|
+
}
|
|
857
|
+
row.replaceChildren(frag);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// 1.1 — apply the current chip selection to the grid by toggling
|
|
861
|
+
// .panel--filtered-out (display:none) on tiles. PTYs are never torn down —
|
|
862
|
+
// a filtered panel keeps running, just hidden. Orchestrator tiles live
|
|
863
|
+
// outside the grid and are never filtered.
|
|
864
|
+
function applyProjectFilter() {
|
|
865
|
+
for (const entry of state.sessions.values()) {
|
|
866
|
+
if (!entry || entry._mounting || !entry.el || !entry.session) continue;
|
|
867
|
+
if (isOrchestratorRole(entry.session.meta && entry.session.meta.role)) {
|
|
868
|
+
entry.el.classList.remove('panel--filtered-out');
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const project = entry.session.meta ? entry.session.meta.project : null;
|
|
872
|
+
const visible = isPanelVisibleUnderFilter(project, state.projectFilter || '');
|
|
873
|
+
entry.el.classList.toggle('panel--filtered-out', !visible);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// 1.1 — chip click: single-select, persist, re-render + re-filter + refit.
|
|
878
|
+
function onProjectChipClick(e) {
|
|
879
|
+
const chip = e.target && e.target.closest ? e.target.closest('.project-chip') : null;
|
|
880
|
+
if (!chip) return;
|
|
881
|
+
const project = chip.getAttribute('data-project') || '';
|
|
882
|
+
state.projectFilter = project;
|
|
883
|
+
saveProjectFilter(project);
|
|
884
|
+
renderProjectChips();
|
|
885
|
+
applyProjectFilter();
|
|
886
|
+
requestAnimationFrame(function () { fitAll(); });
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// 1.1 — born-hidden guard for a freshly-created panel: if an active chip
|
|
890
|
+
// filter would hide it, switch the filter to the panel's project so the
|
|
891
|
+
// operator sees what they just launched. No-op during the initial restore.
|
|
892
|
+
function revealNewPanelIfFiltered(meta) {
|
|
893
|
+
const next = filterValueRevealingPanel(
|
|
894
|
+
state.projectFilter,
|
|
895
|
+
meta && meta.project,
|
|
896
|
+
isOrchestratorRole(meta && meta.role),
|
|
897
|
+
_initialLoadComplete
|
|
898
|
+
);
|
|
899
|
+
if (next === null) return;
|
|
900
|
+
state.projectFilter = next;
|
|
901
|
+
saveProjectFilter(next);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// 1.2 — route a freshly-built panel into the ORCH pin row or the grid.
|
|
905
|
+
function placePanel(panel, meta) {
|
|
906
|
+
const orchRow = document.getElementById('orch-pin-row');
|
|
907
|
+
if (orchRow && isOrchestratorRole(meta && meta.role)) {
|
|
908
|
+
panel.classList.add('panel--role-orch');
|
|
909
|
+
orchRow.appendChild(panel);
|
|
910
|
+
} else {
|
|
911
|
+
document.getElementById('termGrid').appendChild(panel);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// 1.2 — defensive reconcile: keep every tile in the container its role
|
|
916
|
+
// dictates. meta.role is immutable post-spawn so this is a no-op after a
|
|
917
|
+
// panel's first placement; it only acts if a panel was somehow built
|
|
918
|
+
// before its role was known. Returns true if any tile moved.
|
|
919
|
+
function reconcileOrchRow() {
|
|
920
|
+
const orchRow = document.getElementById('orch-pin-row');
|
|
921
|
+
const grid = document.getElementById('termGrid');
|
|
922
|
+
if (!orchRow || !grid) return false;
|
|
923
|
+
let moved = false;
|
|
924
|
+
for (const entry of state.sessions.values()) {
|
|
925
|
+
if (!entry || entry._mounting || !entry.el || !entry.session) continue;
|
|
926
|
+
const isOrch = isOrchestratorRole(entry.session.meta && entry.session.meta.role);
|
|
927
|
+
const inOrchRow = entry.el.parentElement === orchRow;
|
|
928
|
+
if (isOrch && !inOrchRow) {
|
|
929
|
+
entry.el.classList.add('panel--role-orch');
|
|
930
|
+
entry.el.classList.remove('panel--filtered-out');
|
|
931
|
+
orchRow.appendChild(entry.el);
|
|
932
|
+
moved = true;
|
|
933
|
+
} else if (!isOrch && inOrchRow) {
|
|
934
|
+
entry.el.classList.remove('panel--role-orch');
|
|
935
|
+
grid.appendChild(entry.el);
|
|
936
|
+
moved = true;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return moved;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Single entry point for keeping the dashboard chrome (ORCH row + chips +
|
|
943
|
+
// filter) in sync with the live panel set. Cheap — class toggles and a
|
|
944
|
+
// small chip-row rebuild.
|
|
945
|
+
function refreshDashboardChrome() {
|
|
946
|
+
const moved = reconcileOrchRow();
|
|
947
|
+
renderProjectChips();
|
|
948
|
+
applyProjectFilter();
|
|
949
|
+
if (moved) requestAnimationFrame(function () { fitAll(); });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// status_broadcast fires once per panel WebSocket; coalesce the resulting
|
|
953
|
+
// chrome refreshes into one per animation frame so an 18-panel dashboard
|
|
954
|
+
// doesn't rebuild the chip row 18× per 2s tick (T4's count-thrash concern).
|
|
955
|
+
function scheduleChromeRefresh() {
|
|
956
|
+
if (_chromeRefreshScheduled) return;
|
|
957
|
+
_chromeRefreshScheduled = true;
|
|
958
|
+
requestAnimationFrame(function () {
|
|
959
|
+
_chromeRefreshScheduled = false;
|
|
960
|
+
refreshDashboardChrome();
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// --- 1.3: tile auto-removal on PTY exit ---
|
|
965
|
+
|
|
966
|
+
// Grace window before a dead tile is pulled, so the operator sees the
|
|
967
|
+
// final post-exit lines. Overridable via <body data-tile-exit-grace-ms>
|
|
968
|
+
// for deterministic tests.
|
|
969
|
+
function tileExitGraceMs() {
|
|
970
|
+
try {
|
|
971
|
+
const attr = document.body && document.body.getAttribute('data-tile-exit-grace-ms');
|
|
972
|
+
const n = attr != null ? parseInt(attr, 10) : NaN;
|
|
973
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
974
|
+
} catch (err) {
|
|
975
|
+
console.warn('[client] tile-exit-grace read failed:', err);
|
|
976
|
+
}
|
|
977
|
+
return 3000;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Tear down a panel's DOM + xterm + WebSocket and drop it from state.
|
|
981
|
+
// Idempotent: a second call for an already-removed id is a no-op. Shared
|
|
982
|
+
// by handlePanelExited (primary) and reconcileExitedPanels (fallback).
|
|
983
|
+
function removePanelTile(id) {
|
|
984
|
+
const entry = state.sessions.get(id);
|
|
985
|
+
if (!entry) return;
|
|
986
|
+
try { if (entry.terminal) entry.terminal.dispose(); } catch (err) { console.warn('[client] terminal dispose failed:', err); }
|
|
987
|
+
try { if (entry.ws) entry.ws.close(); } catch (err) { console.warn('[client] ws close failed:', err); }
|
|
988
|
+
try { if (entry.el) entry.el.remove(); } catch (err) { console.warn('[client] tile remove failed:', err); }
|
|
989
|
+
state.sessions.delete(id);
|
|
990
|
+
updateEmptyState();
|
|
991
|
+
renderSwitcher();
|
|
992
|
+
refreshAllReplyFormsFor(id);
|
|
993
|
+
refreshPanelIndices();
|
|
994
|
+
refreshDashboardChrome();
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// 1.3 primary path — the server broadcast a panel_exited frame (T2 sub-task
|
|
998
|
+
// 2.4). Dim the tile for a grace window, then remove it. Guarded with
|
|
999
|
+
// _exitScheduled because panel_exited arrives on every panel's WebSocket.
|
|
1000
|
+
function handlePanelExited(sessionId, exitCode) {
|
|
1001
|
+
const entry = state.sessions.get(sessionId);
|
|
1002
|
+
if (!entry || !entry.el) return;
|
|
1003
|
+
if (entry._exitScheduled) return;
|
|
1004
|
+
entry._exitScheduled = true;
|
|
1005
|
+
entry.el.classList.add('panel--exiting');
|
|
1006
|
+
const statusEl = document.getElementById('status-' + sessionId);
|
|
1007
|
+
if (statusEl && exitCode !== undefined && exitCode !== null) {
|
|
1008
|
+
statusEl.textContent = 'Exited (' + exitCode + ')';
|
|
1009
|
+
}
|
|
1010
|
+
setTimeout(function () { removePanelTile(sessionId); }, tileExitGraceMs());
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// 1.3 belt-and-suspenders — covers a missed panel_exited frame. Two
|
|
1014
|
+
// independent checks so the dashboard cannot strand a dead tile forever
|
|
1015
|
+
// regardless of how T2 implements exited-session filtering:
|
|
1016
|
+
// (a) orphaned — a tile whose session id has dropped out of the
|
|
1017
|
+
// broadcast entirely (fires when T2 filters exited from the
|
|
1018
|
+
// broadcast); removed after ORPHAN_GRACE_MS.
|
|
1019
|
+
// (b) stale-exited — a tile still in the broadcast with status 'exited'
|
|
1020
|
+
// and lastActivity older than STALE_EXITED_MS (fires when T2 keeps
|
|
1021
|
+
// exited sessions in the broadcast).
|
|
1022
|
+
function reconcileExitedPanels(broadcastSessions) {
|
|
1023
|
+
const list = Array.isArray(broadcastSessions) ? broadcastSessions : [];
|
|
1024
|
+
const broadcastIds = [];
|
|
1025
|
+
const metaById = Object.create(null);
|
|
1026
|
+
for (const s of list) {
|
|
1027
|
+
if (s && s.id) { broadcastIds.push(s.id); metaById[s.id] = s.meta || {}; }
|
|
1028
|
+
}
|
|
1029
|
+
const knownIds = [];
|
|
1030
|
+
for (const [id, entry] of state.sessions) {
|
|
1031
|
+
if (entry && !entry._mounting && entry.el) knownIds.push(id);
|
|
1032
|
+
}
|
|
1033
|
+
const now = Date.now();
|
|
1034
|
+
|
|
1035
|
+
// (a) orphaned-from-broadcast
|
|
1036
|
+
const orphaned = findOrphanedPanelIds(knownIds, broadcastIds);
|
|
1037
|
+
const orphanSet = Object.create(null);
|
|
1038
|
+
for (const id of orphaned) {
|
|
1039
|
+
orphanSet[id] = true;
|
|
1040
|
+
const entry = state.sessions.get(id);
|
|
1041
|
+
if (!entry) continue;
|
|
1042
|
+
if (!entry._orphanedSince) entry._orphanedSince = now;
|
|
1043
|
+
if (now - entry._orphanedSince >= ORPHAN_GRACE_MS && !entry._exitScheduled) {
|
|
1044
|
+
removePanelTile(id);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// clear the stamp for any panel that is back in the broadcast
|
|
1048
|
+
for (const id of knownIds) {
|
|
1049
|
+
const entry = state.sessions.get(id);
|
|
1050
|
+
if (entry && entry._orphanedSince && !orphanSet[id]) entry._orphanedSince = null;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// (b) stale-exited still present in the broadcast
|
|
1054
|
+
for (const id of knownIds) {
|
|
1055
|
+
const meta = metaById[id];
|
|
1056
|
+
if (!meta || meta.status !== 'exited') continue;
|
|
1057
|
+
const entry = state.sessions.get(id);
|
|
1058
|
+
if (!entry || entry._exitScheduled) continue;
|
|
1059
|
+
const last = meta.lastActivity ? new Date(meta.lastActivity).getTime() : 0;
|
|
1060
|
+
if (last && now - last >= STALE_EXITED_MS) {
|
|
1061
|
+
removePanelTile(id);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// ===== Sprint 65 T1 (Joshua's 2026-05-16 ask c): terminal font size =====
|
|
1067
|
+
// A single global xterm.js font size, adjusted from the topbar A-/A+
|
|
1068
|
+
// stepper and persisted in localStorage. Applies to every panel (existing
|
|
1069
|
+
// + future) — operators running dense CLI output wanted smaller text. The
|
|
1070
|
+
// BACKLOG 2026-05-16 entry sanctions "per-panel OR global"; global is the
|
|
1071
|
+
// simpler, lower-risk option for this contingent sub-task.
|
|
1072
|
+
|
|
1073
|
+
const FONT_SIZE_KEY = 'termdeck.dashboard.fontSize';
|
|
1074
|
+
|
|
1075
|
+
// Clamp a requested font size to the supported range, coercing non-numbers
|
|
1076
|
+
// to the xterm.js default (13). Pure — unit-tested via the vm-extract path.
|
|
1077
|
+
function clampFontSize(n) {
|
|
1078
|
+
const v = Math.round(Number(n));
|
|
1079
|
+
if (!Number.isFinite(v)) return 13;
|
|
1080
|
+
if (v < 8) return 8;
|
|
1081
|
+
if (v > 22) return 22;
|
|
1082
|
+
return v;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function loadFontSize() {
|
|
1086
|
+
try {
|
|
1087
|
+
const raw = localStorage.getItem(FONT_SIZE_KEY);
|
|
1088
|
+
return raw != null ? clampFontSize(raw) : 13;
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
console.warn('[client] fontSize load failed:', err);
|
|
1091
|
+
return 13;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
function saveFontSize(n) {
|
|
1095
|
+
try {
|
|
1096
|
+
localStorage.setItem(FONT_SIZE_KEY, String(n));
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
console.warn('[client] fontSize save failed:', err);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Apply the global font size to every live terminal. Changing fontSize
|
|
1103
|
+
// resizes the character cell but NOT the container, so the per-panel
|
|
1104
|
+
// ResizeObserver does not fire — refit + push the new cols/rows explicitly.
|
|
1105
|
+
function applyFontSizeToAll() {
|
|
1106
|
+
for (const entry of state.sessions.values()) {
|
|
1107
|
+
if (!entry || entry._mounting || !entry.terminal) continue;
|
|
1108
|
+
try {
|
|
1109
|
+
entry.terminal.options.fontSize = state.fontSize;
|
|
1110
|
+
if (entry.fitAddon) entry.fitAddon.fit();
|
|
1111
|
+
if (entry.ws && entry.ws.readyState === WebSocket.OPEN) {
|
|
1112
|
+
entry.ws.send(JSON.stringify({
|
|
1113
|
+
type: 'resize',
|
|
1114
|
+
cols: entry.terminal.cols,
|
|
1115
|
+
rows: entry.terminal.rows,
|
|
1116
|
+
}));
|
|
1117
|
+
}
|
|
1118
|
+
} catch (err) {
|
|
1119
|
+
console.warn('[client] fontSize apply failed for a panel:', err);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
const label = document.getElementById('fontSizeLabel');
|
|
1123
|
+
if (label) label.textContent = String(state.fontSize);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Topbar A-/A+ stepper handler. delta is +1 or -1.
|
|
1127
|
+
function stepFontSize(delta) {
|
|
1128
|
+
const next = clampFontSize(state.fontSize + delta);
|
|
1129
|
+
if (next === state.fontSize) return;
|
|
1130
|
+
state.fontSize = next;
|
|
1131
|
+
saveFontSize(next);
|
|
1132
|
+
applyFontSizeToAll();
|
|
1133
|
+
}
|
|
1134
|
+
|
|
614
1135
|
// ===== Control dashboard (T1.6) =====
|
|
615
1136
|
async function enterControlMode() {
|
|
616
1137
|
// Pre-warm command history for every open session so the feed is dense.
|
|
@@ -1536,6 +2057,11 @@
|
|
|
1536
2057
|
const p = document.getElementById(`panel-${id}`);
|
|
1537
2058
|
if (p) p.classList.add('exited');
|
|
1538
2059
|
break;
|
|
2060
|
+
case 'panel_exited':
|
|
2061
|
+
// Sprint 65 T1 (1.3) — parity with the main WS handler so the
|
|
2062
|
+
// dead-tile removal still fires for reconnected panels.
|
|
2063
|
+
handlePanelExited(msg.sessionId, msg.exitCode);
|
|
2064
|
+
break;
|
|
1539
2065
|
case 'status_broadcast':
|
|
1540
2066
|
updateGlobalStats(msg.sessions);
|
|
1541
2067
|
break;
|
|
@@ -1621,6 +2147,8 @@
|
|
|
1621
2147
|
renderSwitcher();
|
|
1622
2148
|
refreshAllReplyFormsFor(id);
|
|
1623
2149
|
refreshPanelIndices();
|
|
2150
|
+
// Sprint 65 T1 — closing a panel changes chip counts / may empty a chip.
|
|
2151
|
+
refreshDashboardChrome();
|
|
1624
2152
|
}
|
|
1625
2153
|
|
|
1626
2154
|
function changeTheme(id, themeId) {
|
|
@@ -2954,6 +3482,10 @@
|
|
|
2954
3482
|
document.getElementById('stat-thinking').textContent = thinking;
|
|
2955
3483
|
document.getElementById('stat-idle').textContent = idle;
|
|
2956
3484
|
renderSwitcher();
|
|
3485
|
+
// Sprint 65 T1 — reconcile dead tiles against this broadcast, then
|
|
3486
|
+
// refresh chips + ORCH-row chrome (coalesced to one rebuild per frame).
|
|
3487
|
+
reconcileExitedPanels(sessions);
|
|
3488
|
+
scheduleChromeRefresh();
|
|
2957
3489
|
}
|
|
2958
3490
|
|
|
2959
3491
|
function updateEmptyState() {
|
|
@@ -4246,6 +4778,17 @@
|
|
|
4246
4778
|
btn.addEventListener('click', () => setLayout(btn.dataset.layout));
|
|
4247
4779
|
});
|
|
4248
4780
|
|
|
4781
|
+
// Sprint 65 T1 (1.1) — project-filter chip clicks (delegated; chips are
|
|
4782
|
+
// rebuilt on every status_broadcast so a per-chip listener would go stale).
|
|
4783
|
+
const projectChipsRow = document.getElementById('project-chips');
|
|
4784
|
+
if (projectChipsRow) projectChipsRow.addEventListener('click', onProjectChipClick);
|
|
4785
|
+
|
|
4786
|
+
// Sprint 65 T1 (c) — topbar terminal font-size stepper.
|
|
4787
|
+
const fontDecBtn = document.getElementById('btn-font-dec');
|
|
4788
|
+
const fontIncBtn = document.getElementById('btn-font-inc');
|
|
4789
|
+
if (fontDecBtn) fontDecBtn.addEventListener('click', () => stepFontSize(-1));
|
|
4790
|
+
if (fontIncBtn) fontIncBtn.addEventListener('click', () => stepFontSize(1));
|
|
4791
|
+
|
|
4249
4792
|
document.getElementById('promptLaunch').addEventListener('click', launchTerminal);
|
|
4250
4793
|
document.getElementById('promptInput').addEventListener('keydown', (e) => {
|
|
4251
4794
|
if (e.key === 'Enter') launchTerminal();
|
|
@@ -4362,11 +4905,15 @@
|
|
|
4362
4905
|
document.getElementById('promptInput').focus();
|
|
4363
4906
|
}
|
|
4364
4907
|
}
|
|
4365
|
-
// Ctrl+Shift+1-
|
|
4366
|
-
|
|
4908
|
+
// Ctrl+Shift+1-9,0 OR Cmd+Shift+1-9,0 → layout switch (Mac friendly).
|
|
4909
|
+
// Sprint 65 T1 (1.4) — keys 1-9 map to indices 0-8, key 0 maps to index
|
|
4910
|
+
// 9. Keys 1-7 keep their pre-Sprint-65 layouts (muscle memory); 8/9/0 are
|
|
4911
|
+
// the new dense presets. Topbar buttons cover every preset incl. 2x5/5x2/3x4.
|
|
4912
|
+
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key >= '0' && e.key <= '9') {
|
|
4367
4913
|
e.preventDefault();
|
|
4368
|
-
const layouts = ['1x1', '2x1', '2x2', '3x2', '2x4', '4x2', 'orch'];
|
|
4369
|
-
|
|
4914
|
+
const layouts = ['1x1', '2x1', '2x2', '3x2', '2x4', '4x2', 'orch', '1x2', '4x3', '4x4'];
|
|
4915
|
+
const idx = e.key === '0' ? 9 : parseInt(e.key, 10) - 1;
|
|
4916
|
+
if (layouts[idx]) setLayout(layouts[idx]);
|
|
4370
4917
|
}
|
|
4371
4918
|
// Ctrl+Shift+] / [ → cycle between terminals
|
|
4372
4919
|
if (e.ctrlKey && e.shiftKey && (e.key === ']' || e.key === '[')) {
|
|
@@ -35,12 +35,18 @@
|
|
|
35
35
|
</div>
|
|
36
36
|
|
|
37
37
|
<div class="topbar-center">
|
|
38
|
-
<button class="layout-btn" data-layout="1x1">1x1</button>
|
|
39
|
-
<button class="layout-btn active" data-layout="2x1">2x1</button>
|
|
40
|
-
<button class="layout-btn" data-layout="
|
|
41
|
-
<button class="layout-btn" data-layout="
|
|
42
|
-
<button class="layout-btn" data-layout="
|
|
43
|
-
<button class="layout-btn" data-layout="
|
|
38
|
+
<button class="layout-btn" data-layout="1x1" title="1 panel">1x1</button>
|
|
39
|
+
<button class="layout-btn active" data-layout="2x1" title="2 panels — side by side">2x1</button>
|
|
40
|
+
<button class="layout-btn" data-layout="1x2" title="2 panels — stacked vertically">1x2</button>
|
|
41
|
+
<button class="layout-btn" data-layout="2x2" title="4 panels">2x2</button>
|
|
42
|
+
<button class="layout-btn" data-layout="3x2" title="6 panels">3x2</button>
|
|
43
|
+
<button class="layout-btn" data-layout="2x4" title="8 panels — 2 cols × 4 rows">2x4</button>
|
|
44
|
+
<button class="layout-btn" data-layout="4x2" title="8 panels — 4 cols × 2 rows">4x2</button>
|
|
45
|
+
<button class="layout-btn" data-layout="2x5" title="10 panels — 2 cols × 5 rows">2x5</button>
|
|
46
|
+
<button class="layout-btn" data-layout="5x2" title="10 panels — 5 cols × 2 rows">5x2</button>
|
|
47
|
+
<button class="layout-btn" data-layout="4x3" title="12 panels — 4 cols × 3 rows">4x3</button>
|
|
48
|
+
<button class="layout-btn" data-layout="3x4" title="12 panels — 3 cols × 4 rows">3x4</button>
|
|
49
|
+
<button class="layout-btn" data-layout="4x4" title="16 panels">4x4</button>
|
|
44
50
|
<button class="layout-btn" data-layout="orch" title="Orchestrator: 4 workers across top, 1 full-width orchestrator across bottom">orch</button>
|
|
45
51
|
<button class="layout-btn control-btn" data-layout="control" title="Aggregate activity feed">control</button>
|
|
46
52
|
</div>
|
|
@@ -58,6 +64,13 @@
|
|
|
58
64
|
<button class="topbar-ql-btn" onclick="quickLaunch('python3 -m http.server 8080')" title="Open a Python HTTP server on :8080">python</button>
|
|
59
65
|
</div>
|
|
60
66
|
<div class="topbar-row-2-spacer"></div>
|
|
67
|
+
<!-- TERMINAL FONT-SIZE STEPPER (Sprint 65 T1): global xterm.js font size,
|
|
68
|
+
persisted in localStorage, applied to every panel. -->
|
|
69
|
+
<div class="topbar-fontsize" title="Terminal font size (applies to all panels)">
|
|
70
|
+
<button type="button" id="btn-font-dec" class="font-step-btn" aria-label="Decrease terminal font size">A−</button>
|
|
71
|
+
<span id="fontSizeLabel" class="font-size-label">13</span>
|
|
72
|
+
<button type="button" id="btn-font-inc" class="font-step-btn" aria-label="Increase terminal font size">A+</button>
|
|
73
|
+
</div>
|
|
61
74
|
<button id="btn-status" title="Global metrics: session counts, RAG mode, and memory bridge status">status</button>
|
|
62
75
|
<button id="btn-config" title="Configuration: project list, theme defaults, and live RAG-mode toggle">config</button> <button id="btn-sprint" title="Define and kick off a 4+1 sprint">sprint</button>
|
|
63
76
|
<button id="btn-graph" title="Open the knowledge-graph view (memory_items + memory_relationships, force-directed)" onclick="window.open('/graph.html','_blank','noopener')">graph</button>
|
|
@@ -67,6 +80,15 @@
|
|
|
67
80
|
</div>
|
|
68
81
|
</div>
|
|
69
82
|
|
|
83
|
+
<!-- ORCHESTRATOR PIN ROW (Sprint 65 T1): panels with meta.role==='orchestrator'
|
|
84
|
+
render here — pinned, always visible, outside the chip filter. The row
|
|
85
|
+
collapses to zero height when no orchestrator panel exists. -->
|
|
86
|
+
<div class="orch-pin-row" id="orch-pin-row"></div>
|
|
87
|
+
|
|
88
|
+
<!-- PROJECT FILTER CHIPS (Sprint 65 T1): per-project visibility filter,
|
|
89
|
+
auto-discovered from meta.project and populated by app.js. -->
|
|
90
|
+
<div class="project-chips-row" id="project-chips"></div>
|
|
91
|
+
|
|
70
92
|
<!-- TERMINAL GRID -->
|
|
71
93
|
<div class="grid-container layout-2x1" id="termGrid">
|
|
72
94
|
<div class="control-feed" id="controlFeed">
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
--tg-text-bright: #eef1ff;
|
|
11
11
|
--tg-accent: #7aa2f7;
|
|
12
12
|
--tg-accent-dim: #3d5a9e;
|
|
13
|
+
/* Sprint 65 T1 — orchestrator-panel accent (gold/amber). A theme can
|
|
14
|
+
override this var; the ORCH-pin CSS falls back to #d4a017 if unset. */
|
|
15
|
+
--tg-accent-orch: #d4a017;
|
|
13
16
|
--tg-green: #9ece6a;
|
|
14
17
|
--tg-amber: #e0af68;
|
|
15
18
|
--tg-red: #f7768e;
|
|
@@ -317,6 +320,14 @@
|
|
|
317
320
|
.grid-container.layout-3x2 { grid-template-columns: 1fr 1fr 1fr; grid-template-rows: 1fr 1fr; }
|
|
318
321
|
.grid-container.layout-4x2 { grid-template-columns: 1fr 1fr 1fr 1fr; grid-template-rows: 1fr 1fr; }
|
|
319
322
|
.grid-container.layout-2x4 { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr 1fr 1fr; }
|
|
323
|
+
/* Sprint 65 T1 (1.4 / Path A) — denser presets for many-parallel-projects
|
|
324
|
+
work. layout-1x2 already exists above; these cover 10 / 12 / 16-panel
|
|
325
|
+
grids. WxH = columns × rows, consistent with the presets above. */
|
|
326
|
+
.grid-container.layout-2x5 { grid-template-columns: 1fr 1fr; grid-template-rows: repeat(5, 1fr); }
|
|
327
|
+
.grid-container.layout-5x2 { grid-template-columns: repeat(5, 1fr); grid-template-rows: 1fr 1fr; }
|
|
328
|
+
.grid-container.layout-4x3 { grid-template-columns: repeat(4, 1fr); grid-template-rows: 1fr 1fr 1fr; }
|
|
329
|
+
.grid-container.layout-3x4 { grid-template-columns: 1fr 1fr 1fr; grid-template-rows: repeat(4, 1fr); }
|
|
330
|
+
.grid-container.layout-4x4 { grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(4, 1fr); }
|
|
320
331
|
|
|
321
332
|
/* Orchestrator: workers across the top (60%), one full-width orchestrator
|
|
322
333
|
panel across the bottom (40%). The last panel is always the orchestrator.
|
|
@@ -370,6 +381,122 @@
|
|
|
370
381
|
.term-panel.exited { opacity: 0.55; }
|
|
371
382
|
.term-panel.exited .panel-terminal { pointer-events: none; }
|
|
372
383
|
|
|
384
|
+
/* ===== Sprint 65 T1: project-filter chips + ORCH pin + tile lifecycle =====
|
|
385
|
+
Brad's 2026-05-13 v2 spec (BACKLOG § D.5). Placed after the .term-panel
|
|
386
|
+
base + variants so .term-panel.panel--role-orch wins the cascade against
|
|
387
|
+
.term-panel:hover / .term-panel.exited (equal specificity → source order). */
|
|
388
|
+
|
|
389
|
+
/* 1.1 — project-filter chip row, above the grid. app.js leaves it empty
|
|
390
|
+
when there is only one project bucket (nothing worth filtering). */
|
|
391
|
+
.project-chips-row {
|
|
392
|
+
display: flex;
|
|
393
|
+
flex-wrap: wrap;
|
|
394
|
+
align-items: center;
|
|
395
|
+
gap: 6px;
|
|
396
|
+
padding: 6px 38px 0 6px;
|
|
397
|
+
flex-shrink: 0;
|
|
398
|
+
}
|
|
399
|
+
.project-chips-row:empty { display: none; }
|
|
400
|
+
|
|
401
|
+
.project-chip {
|
|
402
|
+
display: inline-flex;
|
|
403
|
+
align-items: center;
|
|
404
|
+
gap: 5px;
|
|
405
|
+
font-family: var(--tg-mono);
|
|
406
|
+
font-size: 11px;
|
|
407
|
+
color: var(--tg-text-dim);
|
|
408
|
+
background: var(--tg-surface);
|
|
409
|
+
border: 1px solid var(--tg-border);
|
|
410
|
+
border-radius: 999px;
|
|
411
|
+
padding: 3px 11px;
|
|
412
|
+
cursor: pointer;
|
|
413
|
+
white-space: nowrap;
|
|
414
|
+
transition: color 0.1s, background 0.1s, border-color 0.1s;
|
|
415
|
+
}
|
|
416
|
+
.project-chip:hover {
|
|
417
|
+
color: var(--tg-text);
|
|
418
|
+
background: var(--tg-surface-hover);
|
|
419
|
+
border-color: var(--tg-border-active);
|
|
420
|
+
}
|
|
421
|
+
.project-chip.active {
|
|
422
|
+
color: var(--tg-accent);
|
|
423
|
+
border-color: var(--tg-accent);
|
|
424
|
+
}
|
|
425
|
+
.project-chip-count { font-size: 10px; opacity: 0.7; }
|
|
426
|
+
|
|
427
|
+
/* 1.1 — a tile hidden by the chip filter. display:none keeps the PTY +
|
|
428
|
+
xterm.js instance alive (no teardown); fitAll() / verifyLayoutHealth()
|
|
429
|
+
already skip display:none panels. Two classes → beats base .term-panel. */
|
|
430
|
+
.term-panel.panel--filtered-out { display: none; }
|
|
431
|
+
|
|
432
|
+
/* 1.2 — orchestrator pin row: above the chip row + grid. The orch tile is
|
|
433
|
+
always grid-column 1 for muscle-memory consistency; the row collapses to
|
|
434
|
+
zero height when no orchestrator panel exists. Right padding mirrors the
|
|
435
|
+
grid's 38px guide-rail reservation. */
|
|
436
|
+
.orch-pin-row {
|
|
437
|
+
display: grid;
|
|
438
|
+
grid-template-columns: minmax(280px, 1fr) 2fr;
|
|
439
|
+
gap: 6px;
|
|
440
|
+
height: clamp(180px, 24vh, 280px);
|
|
441
|
+
padding: 6px 38px 0 6px;
|
|
442
|
+
flex-shrink: 0;
|
|
443
|
+
box-sizing: border-box;
|
|
444
|
+
}
|
|
445
|
+
.orch-pin-row:empty { display: none; }
|
|
446
|
+
|
|
447
|
+
/* 1.2 — orchestrator panel: gold/amber border so the operator's primary
|
|
448
|
+
control surface is distinguishable at a glance (Brad's "from 6+ feet"
|
|
449
|
+
acceptance bar). */
|
|
450
|
+
.term-panel.panel--role-orch {
|
|
451
|
+
border: 2px solid var(--tg-accent-orch, #d4a017);
|
|
452
|
+
box-shadow: 0 0 0 1px rgba(212, 160, 23, 0.3);
|
|
453
|
+
}
|
|
454
|
+
.term-panel.panel--role-orch:hover {
|
|
455
|
+
border-color: var(--tg-accent-orch, #d4a017);
|
|
456
|
+
}
|
|
457
|
+
/* The ORCH text badge — prepended to the .panel-type slot so the header
|
|
458
|
+
reads "ORCH <type>". ORCH always wins the slot over the project label. */
|
|
459
|
+
.term-panel.panel--role-orch .panel-type::before {
|
|
460
|
+
content: "ORCH ";
|
|
461
|
+
font-weight: 700;
|
|
462
|
+
color: var(--tg-accent-orch, #d4a017);
|
|
463
|
+
margin-right: 4px;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/* 1.3 — a tile dimming out during the grace window before auto-removal. */
|
|
467
|
+
.term-panel.panel--exiting {
|
|
468
|
+
opacity: 0.5;
|
|
469
|
+
pointer-events: none;
|
|
470
|
+
transition: opacity 0.3s ease;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/* (c) — topbar terminal font-size stepper (Joshua's 2026-05-16 ask). */
|
|
474
|
+
.topbar-fontsize {
|
|
475
|
+
display: inline-flex;
|
|
476
|
+
align-items: center;
|
|
477
|
+
gap: 3px;
|
|
478
|
+
margin-right: 4px;
|
|
479
|
+
}
|
|
480
|
+
.font-step-btn {
|
|
481
|
+
background: var(--tg-surface);
|
|
482
|
+
border: 1px solid var(--tg-border);
|
|
483
|
+
color: var(--tg-text-dim);
|
|
484
|
+
font-family: var(--tg-mono);
|
|
485
|
+
font-size: 10px;
|
|
486
|
+
padding: 2px 6px;
|
|
487
|
+
border-radius: var(--tg-radius-sm);
|
|
488
|
+
cursor: pointer;
|
|
489
|
+
transition: color 0.1s, border-color 0.1s;
|
|
490
|
+
}
|
|
491
|
+
.font-step-btn:hover { color: var(--tg-text); border-color: var(--tg-border-active); }
|
|
492
|
+
.font-size-label {
|
|
493
|
+
font-family: var(--tg-mono);
|
|
494
|
+
font-size: 10px;
|
|
495
|
+
color: var(--tg-text-dim);
|
|
496
|
+
min-width: 14px;
|
|
497
|
+
text-align: center;
|
|
498
|
+
}
|
|
499
|
+
|
|
373
500
|
/* --- Panel Header (metadata bar) --- */
|
|
374
501
|
.panel-header {
|
|
375
502
|
display: flex;
|
|
@@ -66,7 +66,8 @@ function initDatabase(Database) {
|
|
|
66
66
|
exit_code INTEGER,
|
|
67
67
|
reason TEXT,
|
|
68
68
|
theme TEXT DEFAULT 'tokyo-night',
|
|
69
|
-
theme_override TEXT
|
|
69
|
+
theme_override TEXT,
|
|
70
|
+
role TEXT
|
|
70
71
|
);
|
|
71
72
|
|
|
72
73
|
CREATE TABLE IF NOT EXISTS command_history (
|
|
@@ -137,6 +138,24 @@ function initDatabase(Database) {
|
|
|
137
138
|
console.warn('[db] sessions.theme_override migration failed:', err.message);
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
// Migration (Sprint 65 T2): add sessions.role for the explicit
|
|
142
|
+
// orchestrator/worker/reviewer/auditor panel-role flag (Brad's 2026-05-13
|
|
143
|
+
// v2 dashboard spec — Approach A). SQLite has no `ADD COLUMN IF NOT EXISTS`,
|
|
144
|
+
// so PRAGMA-check first — same pattern as the command_history.source and
|
|
145
|
+
// sessions.theme_override migrations above. No backfill: pre-Sprint-65 rows
|
|
146
|
+
// stay role=NULL (unroled), which is the correct default for sessions that
|
|
147
|
+
// pre-date the feature.
|
|
148
|
+
try {
|
|
149
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
150
|
+
const hasRole = cols.some((c) => c.name === 'role');
|
|
151
|
+
if (!hasRole) {
|
|
152
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN role TEXT`);
|
|
153
|
+
console.log("[db] Migrated sessions: added 'role' column");
|
|
154
|
+
}
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.warn('[db] sessions.role migration failed:', err.message);
|
|
157
|
+
}
|
|
158
|
+
|
|
140
159
|
// Migration (v0.7.0): drop the dead projects.default_theme column. It was
|
|
141
160
|
// CREATE'd in early v0.1 but was never read or written by any code path
|
|
142
161
|
// (see Sprint 32 T1 grep). Removing it eliminates a latent contract-drift
|
|
@@ -150,6 +150,15 @@ const SECRETS_EXCLUDED_FROM_PTY = new Set([
|
|
|
150
150
|
'NPM_TOKEN',
|
|
151
151
|
]);
|
|
152
152
|
|
|
153
|
+
// Sprint 65 T2 (2.1) — explicit operator-role whitelist for the optional
|
|
154
|
+
// `role` field on POST /api/sessions (Brad's 2026-05-13 v2 dashboard spec,
|
|
155
|
+
// Approach A). `null` is the valid "unroled" value; an absent field also
|
|
156
|
+
// defaults to null. The dashboard renders the ORCH pin when
|
|
157
|
+
// `meta.role === 'orchestrator'`; worker/reviewer/auditor are accepted now
|
|
158
|
+
// for forward-compat with the canonical 3+1+1 role taxonomy. Unknown values
|
|
159
|
+
// are rejected with 400 at the route. Exported for the route-fence test.
|
|
160
|
+
const ALLOWED_SESSION_ROLES = ['orchestrator', 'worker', 'reviewer', 'auditor', null];
|
|
161
|
+
|
|
153
162
|
function readTermdeckSecretsForPty() {
|
|
154
163
|
if (_termdeckSecretsCache !== null) return _termdeckSecretsCache;
|
|
155
164
|
const secretsPath = path.join(os.homedir(), '.termdeck', 'secrets.env');
|
|
@@ -1240,7 +1249,15 @@ function createServer(config) {
|
|
|
1240
1249
|
|
|
1241
1250
|
// GET /api/sessions - list all active sessions
|
|
1242
1251
|
app.get('/api/sessions', (req, res) => {
|
|
1243
|
-
|
|
1252
|
+
// Sprint 65 T2 (2.2) — exited (dead-PTY) sessions are excluded by default
|
|
1253
|
+
// so an orchestrator polling this endpoint doesn't see dead panels as
|
|
1254
|
+
// live (Brad's "18 windows open, 10 were dead codex cli" — BACKLOG § D.5).
|
|
1255
|
+
// `?includeExited=true` returns the legacy full shape for `termdeck
|
|
1256
|
+
// doctor` + debug tooling. The 2s status_broadcast is intentionally NOT
|
|
1257
|
+
// filtered (it calls bare getAll()) so the dashboard's missed-exit
|
|
1258
|
+
// reconciliation still has exited sessions to work from.
|
|
1259
|
+
const includeExited = req.query.includeExited === 'true';
|
|
1260
|
+
res.json(sessions.getAll({ includeExited }));
|
|
1244
1261
|
});
|
|
1245
1262
|
|
|
1246
1263
|
// Reusable PTY spawn + wire helper. Used by POST /api/sessions and the
|
|
@@ -1248,7 +1265,7 @@ function createServer(config) {
|
|
|
1248
1265
|
// the same wiring (transcripts, RAG, Mnestra flashback) without copy-paste.
|
|
1249
1266
|
// Returns the Session object regardless of PTY success — status will be
|
|
1250
1267
|
// 'errored' if pty.spawn threw.
|
|
1251
|
-
function spawnTerminalSession({ command, cwd, project, label, type, theme, reason }) {
|
|
1268
|
+
function spawnTerminalSession({ command, cwd, project, label, type, theme, reason, role }) {
|
|
1252
1269
|
const rawCwd = cwd || config.projects?.[project]?.path || os.homedir();
|
|
1253
1270
|
const resolvedCwd = path.resolve(rawCwd.replace(/^~/, os.homedir()));
|
|
1254
1271
|
|
|
@@ -1259,7 +1276,10 @@ function createServer(config) {
|
|
|
1259
1276
|
command: command || config.shell,
|
|
1260
1277
|
cwd: resolvedCwd,
|
|
1261
1278
|
theme: theme || config.projects?.[project]?.defaultTheme || config.defaultTheme,
|
|
1262
|
-
reason: reason || 'launched via API'
|
|
1279
|
+
reason: reason || 'launched via API',
|
|
1280
|
+
// Sprint 65 T2 (2.1) — explicit operator role. Route validation has
|
|
1281
|
+
// already rejected unknown values; here `undefined`/`null` → null.
|
|
1282
|
+
role: role || null,
|
|
1263
1283
|
});
|
|
1264
1284
|
|
|
1265
1285
|
if (pty) {
|
|
@@ -1468,6 +1488,10 @@ function createServer(config) {
|
|
|
1468
1488
|
term.onExit(({ exitCode, signal }) => {
|
|
1469
1489
|
session.meta.status = 'exited';
|
|
1470
1490
|
session.meta.exitCode = exitCode;
|
|
1491
|
+
// Sprint 65 T2 (2.4) — stamp the exit timestamp so the panel_exited
|
|
1492
|
+
// WS frame (below) and the 410 body on POST .../input can both
|
|
1493
|
+
// report when the panel died.
|
|
1494
|
+
session.meta.exitedAt = new Date().toISOString();
|
|
1471
1495
|
session.meta.statusDetail = `Exited (${exitCode})${signal ? `, signal ${signal}` : ''}`;
|
|
1472
1496
|
|
|
1473
1497
|
if (session.ws && session.ws.readyState === 1) {
|
|
@@ -1478,6 +1502,32 @@ function createServer(config) {
|
|
|
1478
1502
|
}));
|
|
1479
1503
|
}
|
|
1480
1504
|
|
|
1505
|
+
// Sprint 65 T2 (2.4) — broadcast panel_exited to ALL dashboard WS
|
|
1506
|
+
// clients so the grid can auto-remove the dead tile (Brad's
|
|
1507
|
+
// 2026-05-12 item 2b — CLI panels must auto-close on PTY exit).
|
|
1508
|
+
// Distinct from the `exit` frame above, which targets ONLY this
|
|
1509
|
+
// panel's own socket; panel_exited goes to every connected client
|
|
1510
|
+
// because any of them may be rendering this tile in its grid.
|
|
1511
|
+
// Inlined wss.clients broadcast — same idiom as status_broadcast /
|
|
1512
|
+
// config_changed / projects_changed elsewhere in this file.
|
|
1513
|
+
try {
|
|
1514
|
+
const exitPayload = JSON.stringify({
|
|
1515
|
+
type: 'panel_exited',
|
|
1516
|
+
sessionId: session.id,
|
|
1517
|
+
exitCode,
|
|
1518
|
+
signal: signal || null,
|
|
1519
|
+
exitedAt: session.meta.exitedAt,
|
|
1520
|
+
});
|
|
1521
|
+
wss.clients.forEach((client) => {
|
|
1522
|
+
if (client.readyState === 1) {
|
|
1523
|
+
try { client.send(exitPayload); }
|
|
1524
|
+
catch (err) { console.error('[ws] panel_exited send failed:', err); }
|
|
1525
|
+
}
|
|
1526
|
+
});
|
|
1527
|
+
} catch (err) {
|
|
1528
|
+
console.error('[ws] panel_exited broadcast failed:', err);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1481
1531
|
rag.onSessionEnded(session);
|
|
1482
1532
|
|
|
1483
1533
|
// Fire-and-forget session log (T2.5)
|
|
@@ -1663,8 +1713,17 @@ function createServer(config) {
|
|
|
1663
1713
|
|
|
1664
1714
|
// POST /api/sessions - create a new terminal session
|
|
1665
1715
|
app.post('/api/sessions', (req, res) => {
|
|
1666
|
-
const { command, cwd, project, label, type, theme, reason } = req.body || {};
|
|
1667
|
-
|
|
1716
|
+
const { command, cwd, project, label, type, theme, reason, role } = req.body || {};
|
|
1717
|
+
// Sprint 65 T2 (2.1) — validate the optional explicit operator-role flag
|
|
1718
|
+
// (Approach A). An absent field (`undefined`) is fine — it defaults to
|
|
1719
|
+
// null in spawnTerminalSession. Any present value must be in the
|
|
1720
|
+
// whitelist (case-sensitive exact match; `null` is allowed). Unknown
|
|
1721
|
+
// values are a 400 so a typo'd role surfaces immediately rather than
|
|
1722
|
+
// silently rendering as an unroled panel.
|
|
1723
|
+
if (role !== undefined && !ALLOWED_SESSION_ROLES.includes(role)) {
|
|
1724
|
+
return res.status(400).json({ ok: false, code: 'invalid_role', allowed: ALLOWED_SESSION_ROLES });
|
|
1725
|
+
}
|
|
1726
|
+
const session = spawnTerminalSession({ command, cwd, project, label, type, theme, reason, role });
|
|
1668
1727
|
res.status(201).json(session.toJSON());
|
|
1669
1728
|
});
|
|
1670
1729
|
|
|
@@ -1729,8 +1788,25 @@ function createServer(config) {
|
|
|
1729
1788
|
app.post('/api/sessions/:id/input', (req, res) => {
|
|
1730
1789
|
const session = sessions.get(req.params.id);
|
|
1731
1790
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
1791
|
+
// Sprint 65 T2 (2.3) — inject to a dead panel returns 410 Gone, not the
|
|
1792
|
+
// pre-Sprint-65 silent 404. The orchestrator POSTing to an exited panel
|
|
1793
|
+
// (Brad's D.5 item 3 — "10 dead codex cli") got a 404 that reads as
|
|
1794
|
+
// "session never existed"; 410 = "the resource was here, has been
|
|
1795
|
+
// intentionally removed" — the semantically correct + debuggable signal.
|
|
1796
|
+
// Mirrors POST /api/sessions/:id/resize (Sprint 63). The body carries
|
|
1797
|
+
// `error` (backward-compat with the client api()/sendReply() path that
|
|
1798
|
+
// treats a missing `.error` as success — T4-CODEX 19:44) AND `code`
|
|
1799
|
+
// (programmatic discriminator) AND `ok:false`.
|
|
1732
1800
|
if (session.meta.status === 'exited' || !session.pty) {
|
|
1733
|
-
|
|
1801
|
+
const msg = `Panel ${req.params.id} has exited`;
|
|
1802
|
+
return res.status(410).json({
|
|
1803
|
+
ok: false,
|
|
1804
|
+
code: 'panel_exited',
|
|
1805
|
+
error: msg,
|
|
1806
|
+
message: msg,
|
|
1807
|
+
exitCode: session.meta.exitCode ?? null,
|
|
1808
|
+
exitedAt: session.meta.exitedAt || null,
|
|
1809
|
+
});
|
|
1734
1810
|
}
|
|
1735
1811
|
|
|
1736
1812
|
const { text, source, fromSessionId } = req.body || {};
|
|
@@ -2972,6 +3048,8 @@ module.exports = {
|
|
|
2972
3048
|
// Sprint 64 T1 (ORCH SCOPE 16:29 item 4) — management-token exclusion list.
|
|
2973
3049
|
// Exported for `packages/cli/tests/spawn-env-exclusion.test.js` fence.
|
|
2974
3050
|
SECRETS_EXCLUDED_FROM_PTY,
|
|
3051
|
+
// Sprint 65 T2 (2.1) — operator-role whitelist, exported for the route fence.
|
|
3052
|
+
ALLOWED_SESSION_ROLES,
|
|
2975
3053
|
// Sprint 50 T1 — exported for unit testing the per-agent SessionEnd
|
|
2976
3054
|
// hook trigger (skip-claude, no-transcript, no-hook-installed,
|
|
2977
3055
|
// payload shape, fire-and-forget).
|
|
@@ -162,6 +162,13 @@ class Session {
|
|
|
162
162
|
this.meta = {
|
|
163
163
|
type: options.type || 'shell', // shell, claude-code, gemini, python-server, one-shot
|
|
164
164
|
project: options.project || null,
|
|
165
|
+
// Sprint 65 T2 (2.1) — explicit operator role (Approach A). One of
|
|
166
|
+
// orchestrator / worker / reviewer / auditor / null. Set at spawn time
|
|
167
|
+
// via POST /api/sessions (route-validated against ALLOWED_SESSION_ROLES);
|
|
168
|
+
// flows through status_broadcast unchanged so the dashboard can pin the
|
|
169
|
+
// ORCH panel. Distinct from `type` (the agent CLI) — role is operator
|
|
170
|
+
// intent, type is the running program.
|
|
171
|
+
role: options.role || null,
|
|
165
172
|
label: options.label || '',
|
|
166
173
|
command: options.command || '',
|
|
167
174
|
cwd: options.cwd || os.homedir(),
|
|
@@ -575,8 +582,8 @@ class SessionManager {
|
|
|
575
582
|
// a PATCH from the dropdown sets it (see updateMeta).
|
|
576
583
|
if (this.db) {
|
|
577
584
|
this.db.prepare(`
|
|
578
|
-
INSERT INTO sessions (id, type, project, label, command, cwd, created_at, reason, theme, theme_override)
|
|
579
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
585
|
+
INSERT INTO sessions (id, type, project, label, command, cwd, created_at, reason, theme, theme_override, role)
|
|
586
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
580
587
|
`).run(
|
|
581
588
|
session.id,
|
|
582
589
|
session.meta.type,
|
|
@@ -587,7 +594,8 @@ class SessionManager {
|
|
|
587
594
|
session.meta.createdAt,
|
|
588
595
|
session.meta.reason,
|
|
589
596
|
session.meta.theme, // resolved snapshot, legacy column
|
|
590
|
-
session.theme_override
|
|
597
|
+
session.theme_override, // NULL by default
|
|
598
|
+
session.meta.role // Sprint 65 T2 — operator role, NULL by default
|
|
591
599
|
);
|
|
592
600
|
}
|
|
593
601
|
|
|
@@ -599,8 +607,20 @@ class SessionManager {
|
|
|
599
607
|
return this.sessions.get(id);
|
|
600
608
|
}
|
|
601
609
|
|
|
602
|
-
|
|
603
|
-
|
|
610
|
+
// Sprint 65 T2 (2.2) — `opts.includeExited` controls whether PTY-exited
|
|
611
|
+
// sessions appear in the listing. Default is legacy (include everything):
|
|
612
|
+
// the 2s status_broadcast (index.js:2675) and the projects-route live-PTY
|
|
613
|
+
// guard both call bare getAll() and must keep seeing the full set. Only
|
|
614
|
+
// GET /api/sessions opts into the filtered view (default on at the route).
|
|
615
|
+
// Brad's "18 windows open, 10 were dead codex cli" report (BACKLOG § D.5)
|
|
616
|
+
// is the orchestrator polling /api/sessions and seeing dead panels as live.
|
|
617
|
+
getAll(opts = {}) {
|
|
618
|
+
const all = Array.from(this.sessions.values());
|
|
619
|
+
const includeExited = opts.includeExited !== false;
|
|
620
|
+
const visible = includeExited
|
|
621
|
+
? all
|
|
622
|
+
: all.filter((s) => s.meta.status !== 'exited');
|
|
623
|
+
return visible.map((s) => s.toJSON());
|
|
604
624
|
}
|
|
605
625
|
|
|
606
626
|
// Fields a client is allowed to modify via PATCH /api/sessions/:id.
|