@jhizzard/termdeck 1.3.0 → 1.5.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/README.md +1 -1
- package/package.json +3 -5
- package/packages/cli/src/index.js +2 -2
- package/packages/client/public/app.js +645 -8
- package/packages/client/public/index.html +28 -6
- package/packages/client/public/style.css +136 -0
- package/packages/server/src/database.js +20 -1
- package/packages/server/src/index.js +111 -21
- package/packages/server/src/orchestration-preview.js +1 -1
- package/packages/server/src/session.js +46 -8
- package/packages/server/src/sprint-inject.js +2 -2
|
@@ -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;
|
|
@@ -567,6 +694,15 @@
|
|
|
567
694
|
.ctrl-btn:hover { color: var(--tg-text); border-color: var(--tg-border-active); }
|
|
568
695
|
.ctrl-btn.active { color: var(--tg-accent); border-color: var(--tg-accent-dim); }
|
|
569
696
|
|
|
697
|
+
/* Sprint 66 T1 (Task 1.3) — the "mark / unmark orchestrator" toggle in the
|
|
698
|
+
Overview controls. When the panel IS the orchestrator the button takes
|
|
699
|
+
the gold/amber accent — the same hue as the ORCH-pin border + badge — so
|
|
700
|
+
the control and the panel treatment read as one feature. */
|
|
701
|
+
.ctrl-btn.orch-toggle.is-orch {
|
|
702
|
+
color: var(--tg-accent-orch, #d4a017);
|
|
703
|
+
border-color: var(--tg-accent-orch, #d4a017);
|
|
704
|
+
}
|
|
705
|
+
|
|
570
706
|
.theme-select {
|
|
571
707
|
background: var(--tg-bg);
|
|
572
708
|
border: 1px solid var(--tg-border);
|
|
@@ -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
|
|
@@ -10,7 +10,6 @@ const os = require('os');
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const dns = require('dns');
|
|
12
12
|
const { spawn: spawnChild } = require('child_process');
|
|
13
|
-
const { v4: uuidv4 } = require('uuid');
|
|
14
13
|
const { createCachedLookup, createFailureLogger } = require('./rumen-pool-resilience');
|
|
15
14
|
|
|
16
15
|
// Conditional imports (graceful fallback if not installed yet)
|
|
@@ -29,7 +28,7 @@ try {
|
|
|
29
28
|
console.error('[db] better-sqlite3 native ABI mismatch (Node was upgraded after install).');
|
|
30
29
|
console.error('[db] TermDeck cannot serve memory features without a working SQLite.');
|
|
31
30
|
console.error('[db] Fix:');
|
|
32
|
-
|
|
31
|
+
process.stderr.write(' cd "$(npm root -g)/@jhizzard/termdeck" && npm rebuild better-sqlite3\n');
|
|
33
32
|
console.error('[db] Then restart TermDeck. Aborting.');
|
|
34
33
|
process.exit(1);
|
|
35
34
|
}
|
|
@@ -150,6 +149,15 @@ const SECRETS_EXCLUDED_FROM_PTY = new Set([
|
|
|
150
149
|
'NPM_TOKEN',
|
|
151
150
|
]);
|
|
152
151
|
|
|
152
|
+
// Sprint 65 T2 (2.1) — explicit operator-role whitelist for the optional
|
|
153
|
+
// `role` field on POST /api/sessions (Brad's 2026-05-13 v2 dashboard spec,
|
|
154
|
+
// Approach A). `null` is the valid "unroled" value; an absent field also
|
|
155
|
+
// defaults to null. The dashboard renders the ORCH pin when
|
|
156
|
+
// `meta.role === 'orchestrator'`; worker/reviewer/auditor are accepted now
|
|
157
|
+
// for forward-compat with the canonical 3+1+1 role taxonomy. Unknown values
|
|
158
|
+
// are rejected with 400 at the route. Exported for the route-fence test.
|
|
159
|
+
const ALLOWED_SESSION_ROLES = ['orchestrator', 'worker', 'reviewer', 'auditor', null];
|
|
160
|
+
|
|
153
161
|
function readTermdeckSecretsForPty() {
|
|
154
162
|
if (_termdeckSecretsCache !== null) return _termdeckSecretsCache;
|
|
155
163
|
const secretsPath = path.join(os.homedir(), '.termdeck', 'secrets.env');
|
|
@@ -201,13 +209,13 @@ function _defaultSpawnSessionEndHookImpl(hookPath, payload, env) {
|
|
|
201
209
|
env,
|
|
202
210
|
});
|
|
203
211
|
child.on('error', (err) => {
|
|
204
|
-
console.error('[
|
|
212
|
+
console.error('[panel-close] hook spawn error:', err && err.message ? err.message : err);
|
|
205
213
|
});
|
|
206
214
|
try {
|
|
207
215
|
child.stdin.write(JSON.stringify(payload));
|
|
208
216
|
child.stdin.end();
|
|
209
217
|
} catch (err) {
|
|
210
|
-
console.error('[
|
|
218
|
+
console.error('[panel-close] hook stdin write failed:', err && err.message ? err.message : err);
|
|
211
219
|
}
|
|
212
220
|
child.unref();
|
|
213
221
|
return child;
|
|
@@ -283,7 +291,7 @@ async function onPanelClose(session) {
|
|
|
283
291
|
...readTermdeckSecretsForPty(),
|
|
284
292
|
});
|
|
285
293
|
} catch (err) {
|
|
286
|
-
console.error('[
|
|
294
|
+
console.error('[panel-close] error:', err && err.message ? err.message : err);
|
|
287
295
|
}
|
|
288
296
|
}
|
|
289
297
|
|
|
@@ -358,7 +366,7 @@ async function onPanelPeriodicCapture(session) {
|
|
|
358
366
|
session._periodicCapture.lastSize = stat.size;
|
|
359
367
|
session._periodicCapture.lastFireMs = Date.now();
|
|
360
368
|
} catch (err) {
|
|
361
|
-
console.error('[
|
|
369
|
+
console.error('[periodic-capture] error:', err && err.message ? err.message : err);
|
|
362
370
|
}
|
|
363
371
|
}
|
|
364
372
|
|
|
@@ -415,7 +423,7 @@ function _getT2DestFor() {
|
|
|
415
423
|
|
|
416
424
|
function _termdeckVersion() {
|
|
417
425
|
try { return require('../../../package.json').version; }
|
|
418
|
-
catch { return '0.0.0'; }
|
|
426
|
+
catch (err) { console.error('[version] package.json read failed:', err); return '0.0.0'; }
|
|
419
427
|
}
|
|
420
428
|
|
|
421
429
|
// Sprint 60 v1.0.14 (Item 3) — safe PTY resize. Brad's 2026-05-07 r730 crash
|
|
@@ -423,7 +431,7 @@ function _termdeckVersion() {
|
|
|
423
431
|
// EBADF/ENOTTY` per 13h uptime. Race: WS `resize` message arrives for a PTY
|
|
424
432
|
// that pty-reaper has already closed (or the child has exited), and
|
|
425
433
|
// `pty.resize()` ioctls a stale fd. The error is race-expected, not a bug,
|
|
426
|
-
// but the noisy
|
|
434
|
+
// but the noisy stderr trace pollutes diagnostics and obscures real
|
|
427
435
|
// errors. This helper guards against the race and downgrades the known
|
|
428
436
|
// race-class errors (EBADF, ENOTTY) to a silent return. Set
|
|
429
437
|
// TERMDECK_DEBUG_PTY_RACES=1 to log to console.debug for diagnostics.
|
|
@@ -1240,7 +1248,15 @@ function createServer(config) {
|
|
|
1240
1248
|
|
|
1241
1249
|
// GET /api/sessions - list all active sessions
|
|
1242
1250
|
app.get('/api/sessions', (req, res) => {
|
|
1243
|
-
|
|
1251
|
+
// Sprint 65 T2 (2.2) — exited (dead-PTY) sessions are excluded by default
|
|
1252
|
+
// so an orchestrator polling this endpoint doesn't see dead panels as
|
|
1253
|
+
// live (Brad's "18 windows open, 10 were dead codex cli" — BACKLOG § D.5).
|
|
1254
|
+
// `?includeExited=true` returns the legacy full shape for `termdeck
|
|
1255
|
+
// doctor` + debug tooling. The 2s status_broadcast is intentionally NOT
|
|
1256
|
+
// filtered (it calls bare getAll()) so the dashboard's missed-exit
|
|
1257
|
+
// reconciliation still has exited sessions to work from.
|
|
1258
|
+
const includeExited = req.query.includeExited === 'true';
|
|
1259
|
+
res.json(sessions.getAll({ includeExited }));
|
|
1244
1260
|
});
|
|
1245
1261
|
|
|
1246
1262
|
// Reusable PTY spawn + wire helper. Used by POST /api/sessions and the
|
|
@@ -1248,7 +1264,7 @@ function createServer(config) {
|
|
|
1248
1264
|
// the same wiring (transcripts, RAG, Mnestra flashback) without copy-paste.
|
|
1249
1265
|
// Returns the Session object regardless of PTY success — status will be
|
|
1250
1266
|
// 'errored' if pty.spawn threw.
|
|
1251
|
-
function spawnTerminalSession({ command, cwd, project, label, type, theme, reason }) {
|
|
1267
|
+
function spawnTerminalSession({ command, cwd, project, label, type, theme, reason, role }) {
|
|
1252
1268
|
const rawCwd = cwd || config.projects?.[project]?.path || os.homedir();
|
|
1253
1269
|
const resolvedCwd = path.resolve(rawCwd.replace(/^~/, os.homedir()));
|
|
1254
1270
|
|
|
@@ -1259,7 +1275,10 @@ function createServer(config) {
|
|
|
1259
1275
|
command: command || config.shell,
|
|
1260
1276
|
cwd: resolvedCwd,
|
|
1261
1277
|
theme: theme || config.projects?.[project]?.defaultTheme || config.defaultTheme,
|
|
1262
|
-
reason: reason || 'launched via API'
|
|
1278
|
+
reason: reason || 'launched via API',
|
|
1279
|
+
// Sprint 65 T2 (2.1) — explicit operator role. Route validation has
|
|
1280
|
+
// already rejected unknown values; here `undefined`/`null` → null.
|
|
1281
|
+
role: role || null,
|
|
1263
1282
|
});
|
|
1264
1283
|
|
|
1265
1284
|
if (pty) {
|
|
@@ -1437,7 +1456,7 @@ function createServer(config) {
|
|
|
1437
1456
|
session._periodicCapture = { lastSize: 0, lastFireMs: 0, timer: null };
|
|
1438
1457
|
session._periodicCapture.timer = setInterval(() => {
|
|
1439
1458
|
onPanelPeriodicCapture(session).catch((err) => {
|
|
1440
|
-
console.error('[
|
|
1459
|
+
console.error('[periodic-capture] async error:', err && err.message ? err.message : err);
|
|
1441
1460
|
});
|
|
1442
1461
|
}, intervalMs);
|
|
1443
1462
|
// Don't keep the event loop alive solely for this timer — the PTY
|
|
@@ -1468,6 +1487,10 @@ function createServer(config) {
|
|
|
1468
1487
|
term.onExit(({ exitCode, signal }) => {
|
|
1469
1488
|
session.meta.status = 'exited';
|
|
1470
1489
|
session.meta.exitCode = exitCode;
|
|
1490
|
+
// Sprint 65 T2 (2.4) — stamp the exit timestamp so the panel_exited
|
|
1491
|
+
// WS frame (below) and the 410 body on POST .../input can both
|
|
1492
|
+
// report when the panel died.
|
|
1493
|
+
session.meta.exitedAt = new Date().toISOString();
|
|
1471
1494
|
session.meta.statusDetail = `Exited (${exitCode})${signal ? `, signal ${signal}` : ''}`;
|
|
1472
1495
|
|
|
1473
1496
|
if (session.ws && session.ws.readyState === 1) {
|
|
@@ -1478,6 +1501,32 @@ function createServer(config) {
|
|
|
1478
1501
|
}));
|
|
1479
1502
|
}
|
|
1480
1503
|
|
|
1504
|
+
// Sprint 65 T2 (2.4) — broadcast panel_exited to ALL dashboard WS
|
|
1505
|
+
// clients so the grid can auto-remove the dead tile (Brad's
|
|
1506
|
+
// 2026-05-12 item 2b — CLI panels must auto-close on PTY exit).
|
|
1507
|
+
// Distinct from the `exit` frame above, which targets ONLY this
|
|
1508
|
+
// panel's own socket; panel_exited goes to every connected client
|
|
1509
|
+
// because any of them may be rendering this tile in its grid.
|
|
1510
|
+
// Inlined wss.clients broadcast — same idiom as status_broadcast /
|
|
1511
|
+
// config_changed / projects_changed elsewhere in this file.
|
|
1512
|
+
try {
|
|
1513
|
+
const exitPayload = JSON.stringify({
|
|
1514
|
+
type: 'panel_exited',
|
|
1515
|
+
sessionId: session.id,
|
|
1516
|
+
exitCode,
|
|
1517
|
+
signal: signal || null,
|
|
1518
|
+
exitedAt: session.meta.exitedAt,
|
|
1519
|
+
});
|
|
1520
|
+
wss.clients.forEach((client) => {
|
|
1521
|
+
if (client.readyState === 1) {
|
|
1522
|
+
try { client.send(exitPayload); }
|
|
1523
|
+
catch (err) { console.error('[ws] panel_exited send failed:', err); }
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
} catch (err) {
|
|
1527
|
+
console.error('[ws] panel_exited broadcast failed:', err);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1481
1530
|
rag.onSessionEnded(session);
|
|
1482
1531
|
|
|
1483
1532
|
// Fire-and-forget session log (T2.5)
|
|
@@ -1499,7 +1548,7 @@ function createServer(config) {
|
|
|
1499
1548
|
// skip-claude + skip-when-no-transcript. Fire-and-forget; any
|
|
1500
1549
|
// error logs and never blocks teardown.
|
|
1501
1550
|
onPanelClose(session).catch((err) => {
|
|
1502
|
-
console.error('[
|
|
1551
|
+
console.error('[panel-close] async error:', err && err.message ? err.message : err);
|
|
1503
1552
|
});
|
|
1504
1553
|
|
|
1505
1554
|
// Sprint 59 T4-CODEX UPLOAD-AUDIT-CONCERN closure: blow away the
|
|
@@ -1663,8 +1712,17 @@ function createServer(config) {
|
|
|
1663
1712
|
|
|
1664
1713
|
// POST /api/sessions - create a new terminal session
|
|
1665
1714
|
app.post('/api/sessions', (req, res) => {
|
|
1666
|
-
const { command, cwd, project, label, type, theme, reason } = req.body || {};
|
|
1667
|
-
|
|
1715
|
+
const { command, cwd, project, label, type, theme, reason, role } = req.body || {};
|
|
1716
|
+
// Sprint 65 T2 (2.1) — validate the optional explicit operator-role flag
|
|
1717
|
+
// (Approach A). An absent field (`undefined`) is fine — it defaults to
|
|
1718
|
+
// null in spawnTerminalSession. Any present value must be in the
|
|
1719
|
+
// whitelist (case-sensitive exact match; `null` is allowed). Unknown
|
|
1720
|
+
// values are a 400 so a typo'd role surfaces immediately rather than
|
|
1721
|
+
// silently rendering as an unroled panel.
|
|
1722
|
+
if (role !== undefined && !ALLOWED_SESSION_ROLES.includes(role)) {
|
|
1723
|
+
return res.status(400).json({ ok: false, code: 'invalid_role', allowed: ALLOWED_SESSION_ROLES });
|
|
1724
|
+
}
|
|
1725
|
+
const session = spawnTerminalSession({ command, cwd, project, label, type, theme, reason, role });
|
|
1668
1726
|
res.status(201).json(session.toJSON());
|
|
1669
1727
|
});
|
|
1670
1728
|
|
|
@@ -1695,7 +1753,19 @@ function createServer(config) {
|
|
|
1695
1753
|
|
|
1696
1754
|
// PATCH /api/sessions/:id - update session metadata
|
|
1697
1755
|
app.patch('/api/sessions/:id', (req, res) => {
|
|
1698
|
-
|
|
1756
|
+
// Sprint 66 T1 (Task 1.2) — `role` is PATCH-mutable so an operator can tag
|
|
1757
|
+
// a live panel as orchestrator in place. Validate it exactly as POST
|
|
1758
|
+
// /api/sessions does (index.js — the `invalid_role` 400 above): an absent
|
|
1759
|
+
// field is fine, any present value must be in ALLOWED_SESSION_ROLES
|
|
1760
|
+
// (orchestrator/worker/reviewer/auditor/null) — an unknown value is a 400
|
|
1761
|
+
// so a typo surfaces immediately rather than silently mis-tagging the
|
|
1762
|
+
// panel. Validation runs BEFORE updateMeta so a bad role never reaches the
|
|
1763
|
+
// whitelist apply or the SQLite write.
|
|
1764
|
+
const body = req.body || {};
|
|
1765
|
+
if (body.role !== undefined && !ALLOWED_SESSION_ROLES.includes(body.role)) {
|
|
1766
|
+
return res.status(400).json({ ok: false, code: 'invalid_role', allowed: ALLOWED_SESSION_ROLES });
|
|
1767
|
+
}
|
|
1768
|
+
const session = sessions.updateMeta(req.params.id, body);
|
|
1699
1769
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
1700
1770
|
res.json(session.toJSON());
|
|
1701
1771
|
});
|
|
@@ -1729,8 +1799,25 @@ function createServer(config) {
|
|
|
1729
1799
|
app.post('/api/sessions/:id/input', (req, res) => {
|
|
1730
1800
|
const session = sessions.get(req.params.id);
|
|
1731
1801
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
1802
|
+
// Sprint 65 T2 (2.3) — inject to a dead panel returns 410 Gone, not the
|
|
1803
|
+
// pre-Sprint-65 silent 404. The orchestrator POSTing to an exited panel
|
|
1804
|
+
// (Brad's D.5 item 3 — "10 dead codex cli") got a 404 that reads as
|
|
1805
|
+
// "session never existed"; 410 = "the resource was here, has been
|
|
1806
|
+
// intentionally removed" — the semantically correct + debuggable signal.
|
|
1807
|
+
// Mirrors POST /api/sessions/:id/resize (Sprint 63). The body carries
|
|
1808
|
+
// `error` (backward-compat with the client api()/sendReply() path that
|
|
1809
|
+
// treats a missing `.error` as success — T4-CODEX 19:44) AND `code`
|
|
1810
|
+
// (programmatic discriminator) AND `ok:false`.
|
|
1732
1811
|
if (session.meta.status === 'exited' || !session.pty) {
|
|
1733
|
-
|
|
1812
|
+
const msg = `Panel ${req.params.id} has exited`;
|
|
1813
|
+
return res.status(410).json({
|
|
1814
|
+
ok: false,
|
|
1815
|
+
code: 'panel_exited',
|
|
1816
|
+
error: msg,
|
|
1817
|
+
message: msg,
|
|
1818
|
+
exitCode: session.meta.exitCode ?? null,
|
|
1819
|
+
exitedAt: session.meta.exitedAt || null,
|
|
1820
|
+
});
|
|
1734
1821
|
}
|
|
1735
1822
|
|
|
1736
1823
|
const { text, source, fromSessionId } = req.body || {};
|
|
@@ -1947,7 +2034,7 @@ function createServer(config) {
|
|
|
1947
2034
|
return res.status(410).json({ error: 'PTY is gone (session exited)' });
|
|
1948
2035
|
}
|
|
1949
2036
|
|
|
1950
|
-
const { cols, rows } = req.body;
|
|
2037
|
+
const { cols, rows } = req.body || {};
|
|
1951
2038
|
try {
|
|
1952
2039
|
const resized = safelyResizePty(session, cols, rows);
|
|
1953
2040
|
if (!resized) {
|
|
@@ -2527,7 +2614,7 @@ function createServer(config) {
|
|
|
2527
2614
|
|
|
2528
2615
|
// POST /api/ai/query - query Mnestra memory via the bridge (direct|webhook|mcp)
|
|
2529
2616
|
app.post('/api/ai/query', async (req, res) => {
|
|
2530
|
-
let { question, sessionId, project } = req.body;
|
|
2617
|
+
let { question, sessionId, project } = req.body || {};
|
|
2531
2618
|
if (!question) return res.status(400).json({ error: 'Missing question' });
|
|
2532
2619
|
|
|
2533
2620
|
let searchAll = false;
|
|
@@ -2682,8 +2769,9 @@ function createServer(config) {
|
|
|
2682
2769
|
});
|
|
2683
2770
|
}, 2000);
|
|
2684
2771
|
|
|
2685
|
-
// Fallback route → serve index.html
|
|
2686
|
-
|
|
2772
|
+
// Fallback route → serve index.html. Express 5: named wildcard '/{*splat}'
|
|
2773
|
+
// (path-to-regexp v8 — a bare '*' throws at registration; this matches all paths incl. root).
|
|
2774
|
+
app.get('/{*splat}', (req, res) => {
|
|
2687
2775
|
res.sendFile(path.join(clientDir, 'index.html'));
|
|
2688
2776
|
});
|
|
2689
2777
|
|
|
@@ -2972,6 +3060,8 @@ module.exports = {
|
|
|
2972
3060
|
// Sprint 64 T1 (ORCH SCOPE 16:29 item 4) — management-token exclusion list.
|
|
2973
3061
|
// Exported for `packages/cli/tests/spawn-env-exclusion.test.js` fence.
|
|
2974
3062
|
SECRETS_EXCLUDED_FROM_PTY,
|
|
3063
|
+
// Sprint 65 T2 (2.1) — operator-role whitelist, exported for the route fence.
|
|
3064
|
+
ALLOWED_SESSION_ROLES,
|
|
2975
3065
|
// Sprint 50 T1 — exported for unit testing the per-agent SessionEnd
|
|
2976
3066
|
// hook trigger (skip-claude, no-transcript, no-hook-installed,
|
|
2977
3067
|
// payload shape, fire-and-forget).
|
|
@@ -187,7 +187,7 @@ async function generateScaffolding({ name, projects, cwd, force, initProject, te
|
|
|
187
187
|
// Refuse on existing non-empty dir without force, mirroring T2's CLI semantics.
|
|
188
188
|
if (fs.existsSync(targetPath)) {
|
|
189
189
|
const entries = (() => {
|
|
190
|
-
try { return fs.readdirSync(targetPath); } catch { return []; }
|
|
190
|
+
try { return fs.readdirSync(targetPath); } catch (err) { console.error('[orch-preview] readdir failed:', err); return []; }
|
|
191
191
|
})();
|
|
192
192
|
const nonEmpty = entries.length > 0;
|
|
193
193
|
if (nonEmpty && !force) {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// metadata broadcast in index.js untouched — `s.meta.theme` already returns
|
|
11
11
|
// the right thing whenever index.js dereferences it.
|
|
12
12
|
|
|
13
|
-
const {
|
|
13
|
+
const { randomUUID } = require('crypto');
|
|
14
14
|
const os = require('os');
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { resolveTheme } = require('./theme-resolver');
|
|
@@ -135,7 +135,7 @@ const PATTERNS = {
|
|
|
135
135
|
|
|
136
136
|
class Session {
|
|
137
137
|
constructor(options) {
|
|
138
|
-
this.id = options.id ||
|
|
138
|
+
this.id = options.id || randomUUID();
|
|
139
139
|
this.pid = null;
|
|
140
140
|
this.pty = null;
|
|
141
141
|
this.ws = null;
|
|
@@ -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.
|
|
@@ -614,7 +634,15 @@ class SessionManager {
|
|
|
614
634
|
'label',
|
|
615
635
|
'project',
|
|
616
636
|
'ragEnabled',
|
|
617
|
-
'flashbackEnabled'
|
|
637
|
+
'flashbackEnabled',
|
|
638
|
+
// Sprint 66 T1 (Task 1.2) — `role` is now PATCH-mutable so an operator can
|
|
639
|
+
// tag a live panel as orchestrator in place (Brad's existing orch panel
|
|
640
|
+
// was spawned with no role and had no way to set one short of a raw-API
|
|
641
|
+
// destroy + recreate). The PATCH /api/sessions/:id route validates the
|
|
642
|
+
// value against ALLOWED_SESSION_ROLES before this whitelist is consulted —
|
|
643
|
+
// the same "route validates, model trusts" boundary as POST /api/sessions
|
|
644
|
+
// and the Session constructor.
|
|
645
|
+
'role'
|
|
618
646
|
]);
|
|
619
647
|
|
|
620
648
|
updateMeta(id, updates) {
|
|
@@ -637,6 +665,16 @@ class SessionManager {
|
|
|
637
665
|
.run(applied.theme == null ? null : applied.theme, id);
|
|
638
666
|
}
|
|
639
667
|
|
|
668
|
+
// Sprint 66 T1 (Task 1.2) — persist a role change to SQLite so a panel
|
|
669
|
+
// tagged orchestrator via PATCH keeps the role across a server restart /
|
|
670
|
+
// dashboard reload, exactly as a spawn-time role does. create() writes the
|
|
671
|
+
// `role` column on INSERT; this is its UPDATE counterpart. The column was
|
|
672
|
+
// added by Sprint 65 T2 (CREATE TABLE + a PRAGMA-guarded ALTER migration).
|
|
673
|
+
if ('role' in applied && this.db) {
|
|
674
|
+
this.db.prepare('UPDATE sessions SET role = ? WHERE id = ?')
|
|
675
|
+
.run(applied.role == null ? null : applied.role, id);
|
|
676
|
+
}
|
|
677
|
+
|
|
640
678
|
this._emit('session:updated', session);
|
|
641
679
|
return session;
|
|
642
680
|
}
|