@jhizzard/termdeck 1.2.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.
@@ -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="2x2">2x2</button>
41
- <button class="layout-btn" data-layout="3x2">3x2</button>
42
- <button class="layout-btn" data-layout="2x4">2x4</button>
43
- <button class="layout-btn" data-layout="4x2">4x2</button>
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;
@@ -221,6 +221,17 @@ const claudeAdapter = {
221
221
  binary: 'claude',
222
222
  defaultArgs: [],
223
223
  env: {},
224
+ // Sprint 64 T2 (carve-out 2.4) — when shellWrap is `false`, spawnTerminalSession
225
+ // bypasses the `zsh -c <command>` wrapper and execs `binary` + `defaultArgs`
226
+ // directly via `pty.spawn`. Preserves the user's PATH lookup of `claude`
227
+ // while keeping the PTY rooted in an interactive context (zsh -c discards
228
+ // the TTY-interactive flags Claude Code's input handler needs). Falls back
229
+ // to the legacy wrapper when the user-supplied `command` carries additional
230
+ // args (e.g. `claude --resume <uuid>` from a launcher button) so user args
231
+ // are not silently dropped. See packages/server/src/index.js:1118-1175 for
232
+ // the dispatch logic and packages/server/tests/adapter-spawn-shell-wrap.test.js
233
+ // for the fence.
234
+ shellWrap: false,
224
235
  },
225
236
  patterns: {
226
237
  prompt: PROMPT,
@@ -155,6 +155,41 @@ function _codexCandidateDirs(homedir, now) {
155
155
  return out;
156
156
  }
157
157
 
158
+ // Sprint 64 T2 (carve-out 2.1) — `min(birthtime, mtime)` is the right gate
159
+ // for cross-panel contamination. Sprint 63 EXIT-CAPTURE-VERIFICATION.md
160
+ // Finding #1 documents the failure mode: when codex panel-B spawned and
161
+ // self-exited during the 0.129→0.130 auto-update, panel-A's rollout was
162
+ // still being written by panel-A's ongoing turns; A's `mtimeMs` exceeded
163
+ // B's `createdAtMs`, so A was returned as B's transcript.
164
+ //
165
+ // Why `min(birthtime, mtime)` rather than birthtime alone or mtime alone:
166
+ // • Cross-panel contamination (Sprint 63 Finding #1): Panel-A active panel
167
+ // has `birthtime=T_A_create` (in the past) and `mtime=NOW` (bumped each
168
+ // turn). min = birthtime — correctly rejects when birthtime < spawn time.
169
+ // • Backdated-mtime stale rollouts: mtime < birthtime. min = mtime —
170
+ // correctly rejects when backdated mtime < spawn time.
171
+ // • Same-session rollout (this panel's own): birthtime AND mtime both
172
+ // post-spawn. min = birthtime ≈ mtime — correctly admits.
173
+ // • Platforms without birthtime (some Linux tmpfs returns birthtimeMs=0):
174
+ // fall back to `mtime` for both terms of the min → equivalent to mtime
175
+ // gate, same behavior as pre-fix.
176
+ //
177
+ // Gate epsilon (per Sprint 64 T4-CODEX 16:21 AUDIT-CONCERN — deterministic
178
+ // pre-spawn rejection on birthtime-capable platforms):
179
+ // • Birthtime-capable platforms (APFS, ext4 with `statx`, NTFS): STRICT,
180
+ // no epsilon. Birthtime is deterministic FS metadata — no jitter, no
181
+ // quantization beyond ~1ns. A file with `birthtimeMs < spawnTimestampMs`
182
+ // was unambiguously created before this panel spawned and CANNOT be
183
+ // this panel's rollout. Strict gate is correct.
184
+ // • Mtime-fallback platforms (rare; some Linux tmpfs): use
185
+ // `_CODEX_GATE_EPSILON_MS_MTIME_FALLBACK = 5000ms` to absorb FS time
186
+ // quantization rounding plus any small clock-skew between OS time and
187
+ // `Date.now()`. mtime can drift in production (active concurrent panel
188
+ // bumps it), so this epsilon path is intentionally narrower than
189
+ // birthtime — it's a structural fallback, not a tolerance knob.
190
+ const _CODEX_GATE_EPSILON_MS_BIRTHTIME = 0;
191
+ const _CODEX_GATE_EPSILON_MS_MTIME_FALLBACK = 5000;
192
+
158
193
  async function resolveTranscriptPath(session) {
159
194
  const fs = require('fs');
160
195
  const path = require('path');
@@ -164,6 +199,15 @@ async function resolveTranscriptPath(session) {
164
199
  const createdAtMs = session.meta.createdAt
165
200
  ? Date.parse(session.meta.createdAt)
166
201
  : 0;
202
+ // Sprint 64 T2 (carve-out 2.1) — spawnTimestampMs is set in spawnTerminalSession
203
+ // immediately after `pty.spawn` returns; strictly later than createdAt (which
204
+ // is set in `sessions.create` BEFORE pty.spawn). Use it when present; fall
205
+ // back to createdAt for older sessions reloaded from SQLite that pre-date the
206
+ // field. The `- _CODEX_GATE_EPSILON_MS` accounts for filesystem time-stamp
207
+ // quantization rounding (worst-case 1s on some platforms).
208
+ const spawnAtMs = (typeof session.meta.spawnTimestampMs === 'number' && session.meta.spawnTimestampMs > 0)
209
+ ? session.meta.spawnTimestampMs
210
+ : createdAtMs;
167
211
  const candidates = [];
168
212
  for (const dir of _codexCandidateDirs(os.homedir(), Date.now())) {
169
213
  let entries;
@@ -174,7 +218,18 @@ async function resolveTranscriptPath(session) {
174
218
  const full = path.join(dir, name);
175
219
  let st;
176
220
  try { st = fs.statSync(full); } catch (_) { continue; }
177
- if (createdAtMs && st.mtimeMs < createdAtMs) continue;
221
+ // Per-file gate: prefer strict birthtime when the platform exposes it;
222
+ // fall back to epsilon-tolerant mtime only when birthtime is unavailable.
223
+ // Either signal indicates "this rollout existed before the panel
224
+ // spawned" → reject the candidate.
225
+ const hasBirthtime = (typeof st.birthtimeMs === 'number' && st.birthtimeMs > 0);
226
+ const epsilonForFile = hasBirthtime
227
+ ? _CODEX_GATE_EPSILON_MS_BIRTHTIME
228
+ : _CODEX_GATE_EPSILON_MS_MTIME_FALLBACK;
229
+ const gateMsForFile = spawnAtMs > 0 ? spawnAtMs - epsilonForFile : 0;
230
+ const fileBirthMs = hasBirthtime ? st.birthtimeMs : st.mtimeMs;
231
+ const fileMinMs = Math.min(fileBirthMs, st.mtimeMs);
232
+ if (gateMsForFile && fileMinMs < gateMsForFile) continue;
178
233
  candidates.push({ full, mtime: st.mtimeMs });
179
234
  }
180
235
  }
@@ -264,6 +319,138 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
264
319
  ].join('\n');
265
320
  }
266
321
 
322
+ // ──────────────────────────────────────────────────────────────────────────
323
+ // probeCodexVersion — Sprint 64 T2 (carve-out 2.3).
324
+ //
325
+ // Pre-spawn version probe for the Codex CLI auto-update lifecycle hazard
326
+ // documented in Sprint 63 EXIT-CAPTURE-VERIFICATION.md Finding #1. Codex CLI
327
+ // has no `--no-update` flag (verified 2026-05-11 against codex 0.130.0), so a
328
+ // stale codex panel may fire its interactive update picker on spawn, accept
329
+ // "Update now," `npm install -g @openai/codex`, and exit 0 — BEFORE any canary
330
+ // inject lands. Joshua's Sprint 63 T2 lost a codex canary panel to exactly
331
+ // this failure mode at 13:26 ET.
332
+ //
333
+ // Approach (per Sprint 64 ORCH SCOPE 16:14 ET adjudication of T4-CODEX 16:11
334
+ // AUDIT-CONCERN #3 default-install visibility): two complementary WARN paths.
335
+ //
336
+ // • **Persisted last-seen-version drift.** Read
337
+ // `~/.termdeck/.last-codex-version`. Absent → write `observed` silently,
338
+ // no WARN (first run is "baseline," not "drift"). Present and
339
+ // `observed !== persisted` → log WARN + update persisted to new observed
340
+ // (self-heals: next spawn is silent on the new version). Catches the
341
+ // Sprint 63 auto-update hazard for the default operator with no env-var
342
+ // setup required. Doesn't false-alarm on stable installs (no env, no
343
+ // persisted file changes once written).
344
+ //
345
+ // • **`CODEX_PINNED_VERSION` env knob.** Operator-explicit pin retained
346
+ // as a separate signal — useful in CI / multi-user installs where the
347
+ // persisted file is per-user but the pin is global. WARN on observed ≠
348
+ // pinned; independent of the drift path above.
349
+ //
350
+ // Why not a hardcoded "known-good window"? Codex shipped 0.125 → 0.129 →
351
+ // 0.130 in ~10 days; a baked-in version list goes stale in a week. The
352
+ // persisted-self-heal path is the deterministic answer.
353
+ //
354
+ // Why not a wrapper shim (option B) that intercepts the update picker? The
355
+ // picker has already shifted shape across recent codex releases; a shim that
356
+ // answers "n\n" today may answer "yes\n" to a future renamed prompt. Real
357
+ // fix lives upstream — file a `--no-update` flag against the Codex CLI repo.
358
+ // Tracking that filing is cheaper than maintaining a shim.
359
+ //
360
+ // Dependency-injected `spawnSync` + `logger` + `fsApi` keep the fence test
361
+ // free of a live codex binary on PATH or filesystem dependence.
362
+ // ──────────────────────────────────────────────────────────────────────────
363
+
364
+ // Module-level constants for testability — ORCH SCOPE 16:14 ET. Fence tests
365
+ // override the path by passing `{ persistedVersionPath: '...' }`.
366
+ const _CODEX_PERSISTED_VERSION_FILENAME = '.last-codex-version';
367
+
368
+ function _defaultPersistedVersionPath() {
369
+ const os = require('os');
370
+ const path = require('path');
371
+ return path.join(os.homedir(), '.termdeck', _CODEX_PERSISTED_VERSION_FILENAME);
372
+ }
373
+
374
+ function probeCodexVersion({
375
+ pinnedVersion = process.env.CODEX_PINNED_VERSION,
376
+ spawnSync = require('child_process').spawnSync,
377
+ logger = console,
378
+ fsApi = require('fs'),
379
+ persistedVersionPath = _defaultPersistedVersionPath(),
380
+ } = {}) {
381
+ let observed = null;
382
+ try {
383
+ const res = spawnSync('codex', ['--version'], { encoding: 'utf8', timeout: 2000 });
384
+ if (!res || res.status !== 0 || !res.stdout) {
385
+ return { ok: null, observed: null, reason: 'probe-failed' };
386
+ }
387
+ const match = String(res.stdout).match(/(\d+\.\d+\.\d+)/);
388
+ observed = match ? match[1] : null;
389
+ } catch (_) {
390
+ return { ok: null, observed: null, reason: 'probe-error' };
391
+ }
392
+ if (!observed) {
393
+ return { ok: null, observed: null, reason: 'no-version-string' };
394
+ }
395
+
396
+ // Drift path: compare observed against persisted last-seen value.
397
+ let persisted = null;
398
+ let driftDetected = false;
399
+ try {
400
+ if (fsApi.existsSync(persistedVersionPath)) {
401
+ const raw = fsApi.readFileSync(persistedVersionPath, 'utf8');
402
+ const trimmed = String(raw || '').trim();
403
+ persisted = trimmed.length > 0 ? trimmed : null;
404
+ }
405
+ } catch (_) {
406
+ // Read failure is non-fatal — treat as absent. Persistence is best-effort.
407
+ persisted = null;
408
+ }
409
+ if (persisted === null) {
410
+ // First-run baseline — write silently, no WARN.
411
+ _writePersistedVersion(fsApi, persistedVersionPath, observed);
412
+ } else if (persisted !== observed) {
413
+ driftDetected = true;
414
+ if (logger && typeof logger.warn === 'function') {
415
+ logger.warn(
416
+ `[codex] version drift detected: observed=${observed} persisted=${persisted} — `
417
+ + 'codex CLI may have auto-updated since last spawn (Sprint 63 lifecycle hazard).'
418
+ );
419
+ }
420
+ _writePersistedVersion(fsApi, persistedVersionPath, observed);
421
+ }
422
+
423
+ // Pin path: independent of drift. Warns on every spawn where pin ≠ observed.
424
+ let pinnedMismatch = false;
425
+ if (pinnedVersion && observed !== pinnedVersion) {
426
+ pinnedMismatch = true;
427
+ if (logger && typeof logger.warn === 'function') {
428
+ logger.warn(
429
+ `[codex] version pin mismatch: observed=${observed} pinned=${pinnedVersion} — `
430
+ + 'CODEX_PINNED_VERSION env var requires explicit re-pin (Sprint 63 lifecycle hazard).'
431
+ );
432
+ }
433
+ }
434
+
435
+ if (driftDetected || pinnedMismatch) {
436
+ return { ok: false, observed, persisted, pinned: pinnedVersion || null, driftDetected, pinnedMismatch };
437
+ }
438
+ return { ok: true, observed, persisted, pinned: pinnedVersion || null, driftDetected: false, pinnedMismatch: false };
439
+ }
440
+
441
+ function _writePersistedVersion(fsApi, p, version) {
442
+ try {
443
+ const path = require('path');
444
+ const dir = path.dirname(p);
445
+ try { fsApi.mkdirSync(dir, { recursive: true }); }
446
+ catch (_) { /* fail-soft — usually already exists */ }
447
+ fsApi.writeFileSync(p, `${version}\n`, 'utf8');
448
+ } catch (_) {
449
+ // Persistence failure is non-fatal — WARN behavior is unaffected the
450
+ // next spawn (we'll re-detect drift against whatever is/isn't on disk).
451
+ }
452
+ }
453
+
267
454
  const codexAdapter = {
268
455
  name: 'codex',
269
456
  sessionType: 'codex',
@@ -274,6 +461,14 @@ const codexAdapter = {
274
461
  binary: 'codex',
275
462
  defaultArgs: [],
276
463
  env: { OPENAI_API_KEY: process.env.OPENAI_API_KEY },
464
+ // Sprint 64 T2 (carve-out 2.4) — direct spawn (no `zsh -c` wrapper) when
465
+ // the launching command is exactly the binary name. Sprint 63
466
+ // EXIT-CAPTURE-VERIFICATION.md § 6 flagged this as a probable contributor
467
+ // to codex's fast-death window during the 2026-05-11 13:26 ET update-picker
468
+ // event — codex spawned through `zsh -c codex` may have lost the
469
+ // interactive-TTY context the update-picker dialog needed. See claude.js
470
+ // for the full rationale + fallback semantics.
471
+ shellWrap: false,
277
472
  },
278
473
  patterns: {
279
474
  prompt: PROMPT,
@@ -329,4 +524,11 @@ const codexAdapter = {
329
524
  },
330
525
  };
331
526
 
527
+ // Sprint 64 T2 (carve-out 2.3) — expose probeCodexVersion on the adapter object
528
+ // so call sites can `require('./codex').probeCodexVersion(...)` without
529
+ // threading through the registry. Adapter-shape parity tests (Sprint 45 T4's
530
+ // tests/agent-adapter-parity.test.js) iterate a fixed allowlist of fields and
531
+ // tolerate extra properties — adding this function is safe.
532
+ codexAdapter.probeCodexVersion = probeCodexVersion;
533
+
332
534
  module.exports = codexAdapter;
@@ -247,6 +247,10 @@ const geminiAdapter = {
247
247
  // not for in-adapter overriding. OAuth-personal is the typical auth
248
248
  // path (settings.json `security.auth.selectedType: 'oauth-personal'`).
249
249
  env: {},
250
+ // Sprint 64 T2 (carve-out 2.4) — direct spawn (no `zsh -c` wrapper) when
251
+ // the launching command is exactly the binary name. See claude.js for the
252
+ // full rationale + fallback semantics.
253
+ shellWrap: false,
250
254
  },
251
255
  patterns: {
252
256
  prompt: PROMPT,
@@ -446,6 +446,10 @@ const grokAdapter = {
446
446
  env: {
447
447
  GROK_MODEL: chooseModel(),
448
448
  },
449
+ // Sprint 64 T2 (carve-out 2.4) — direct spawn (no `zsh -c` wrapper) when
450
+ // the launching command is exactly the binary name. See claude.js for the
451
+ // full rationale + fallback semantics.
452
+ shellWrap: false,
449
453
  },
450
454
  patterns: {
451
455
  prompt: PROMPT,
@@ -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