@jhizzard/termdeck 1.8.1 → 1.9.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.
@@ -102,6 +102,7 @@ const HELP = [
102
102
  'Sub-mode wizards (callable independently for advanced users):',
103
103
  ' termdeck init --mnestra Configure Tier 2 memory (Supabase + Mnestra)',
104
104
  ' termdeck init --rumen Deploy Tier 3 async learning (Rumen)',
105
+ ' termdeck init --bridge Scaffold the Tier 5 Web-Chat Bridge (named tunnel + supervisor)',
105
106
  ' termdeck init --project Scaffold a new project with CLAUDE.md + orchestration docs',
106
107
  '',
107
108
  'Get a Personal Access Token at: https://supabase.com/dashboard/account/tokens',
@@ -361,6 +361,80 @@
361
361
  }, { capture: true });
362
362
  }
363
363
 
364
+ // ===== termdeck#12 input guard (Sprint 73 T3) =====
365
+ // Runaway-typed-input defense at the single chokepoint every byte of
366
+ // human browser input flows through (the onData handler registered in
367
+ // createTerminalPanel). xterm@5.5.0 reconstructs composition input from
368
+ // its hidden textarea and can re-emit the accumulated buffer-so-far once
369
+ // per word boundary on IME/mobile/remote keyboards — the #12 cumulative-
370
+ // prefix runaway (~110 typed chars became a 3,042-char PTY stream).
371
+ // Detection logic lives in input-guard.js (UMD, unit-tested from node);
372
+ // these helpers wire it to panel state and the operator surface.
373
+ // Suppression is loud (console.error + panel toast), never silent;
374
+ // oversize chunks can be sent anyway from the toast.
375
+ function shouldSuppressPanelInput(entry, id, data) {
376
+ if (!entry._inputGuard) entry._inputGuard = InputGuard.createGuard();
377
+ const result = InputGuard.check(entry._inputGuard, data, Date.now());
378
+ if (result.verdict === 'pass') return false;
379
+ console.error(
380
+ `[input-guard] suppressed ${result.reason} input on panel ${id} ` +
381
+ `(chunk ${data.length} chars, chain ${result.chainLength}, ` +
382
+ `total suppressed ${result.suppressedCount} chunks / ${result.suppressedChars} chars):`,
383
+ JSON.stringify(data.slice(0, 120))
384
+ );
385
+ showInputGuardToast(entry, id, result, data);
386
+ return true;
387
+ }
388
+
389
+ function showInputGuardToast(entry, id, result, data) {
390
+ if (!entry || !entry.el) return;
391
+
392
+ // One toast per panel: repeated suppressions update the counter line
393
+ // (a runaway fires per word boundary — stacking toasts would bury the
394
+ // panel) and refresh the held chunk + auto-dismiss timer.
395
+ const existing = entry.el.querySelector('.input-guard-toast');
396
+ if (existing) {
397
+ const counter = existing.querySelector('.t-meta');
398
+ if (counter) counter.textContent = `${result.suppressedCount} chunks (${result.suppressedChars} chars) suppressed so far.`;
399
+ if (result.reason === 'oversize') existing._heldChunk = data;
400
+ clearTimeout(existing._autoTimer);
401
+ existing._autoTimer = setTimeout(() => existing.remove(), 60000);
402
+ return;
403
+ }
404
+
405
+ const toast = document.createElement('div');
406
+ toast.className = 'input-guard-toast';
407
+ const why = result.reason === 'oversize'
408
+ ? 'a single typed chunk was implausibly large'
409
+ : 'the keyboard started re-sending the whole buffer-so-far per keystroke (xterm composition runaway — termdeck#12)';
410
+ toast.innerHTML = `
411
+ <button class="t-dismiss" aria-label="Dismiss">×</button>
412
+ <div class="t-title">Input guard — runaway typing suppressed</div>
413
+ <div class="t-body">Blocked because ${why}. Check the terminal's input line before pressing Enter; clear it if it shows repeated text.${result.reason === 'oversize' ? ' If this was intentional, send it below.' : ''}</div>
414
+ <div class="t-meta">${result.suppressedCount} chunks (${result.suppressedChars} chars) suppressed so far.</div>
415
+ ${result.reason === 'oversize' ? '<button class="t-send-anyway">Send anyway</button>' : ''}
416
+ `;
417
+ if (result.reason === 'oversize') toast._heldChunk = data;
418
+ entry.el.appendChild(toast);
419
+
420
+ toast.querySelector('.t-dismiss').addEventListener('click', () => {
421
+ clearTimeout(toast._autoTimer);
422
+ toast.remove();
423
+ });
424
+ const sendBtn = toast.querySelector('.t-send-anyway');
425
+ if (sendBtn) {
426
+ sendBtn.addEventListener('click', () => {
427
+ const live = state.sessions.get(id);
428
+ if (toast._heldChunk && live && live.ws && live.ws.readyState === WebSocket.OPEN) {
429
+ live.ws.send(JSON.stringify({ type: 'input', data: toast._heldChunk }));
430
+ }
431
+ clearTimeout(toast._autoTimer);
432
+ toast.remove();
433
+ });
434
+ }
435
+ toast._autoTimer = setTimeout(() => toast.remove(), 60000);
436
+ }
437
+
364
438
  // ===== Create Terminal Panel =====
365
439
  function createTerminalPanel(sessionData) {
366
440
  const id = sessionData.id;
@@ -592,10 +666,20 @@
592
666
  }
593
667
  };
594
668
 
595
- // Terminal input → WebSocket
669
+ // Terminal input → WebSocket. Registered ONCE per Terminal instance and
670
+ // never re-registered: xterm's onData ADDS listeners, and the
671
+ // pre-Sprint-73 reconnect path stacked one leaked handler (closed over
672
+ // its dead socket) per reconnect — the termdeck#12 cause-B family. The
673
+ // handler dereferences entry.ws at event time, so reconnectSession just
674
+ // swaps entry.ws and this same registration follows it.
675
+ // shouldSuppressPanelInput is the #12 runaway chokepoint — every byte
676
+ // of human browser input to this PTY flows through this closure.
596
677
  terminal.onData((data) => {
597
- if (ws.readyState === WebSocket.OPEN) {
598
- ws.send(JSON.stringify({ type: 'input', data }));
678
+ const entry = state.sessions.get(id);
679
+ if (!entry || entry._mounting || !entry.ws) return;
680
+ if (shouldSuppressPanelInput(entry, id, data)) return;
681
+ if (entry.ws.readyState === WebSocket.OPEN) {
682
+ entry.ws.send(JSON.stringify({ type: 'input', data }));
599
683
  }
600
684
  });
601
685
 
@@ -608,6 +692,49 @@
608
692
  panel.classList.remove('active-input');
609
693
  });
610
694
 
695
+ // termdeck#12 (Sprint 73 T3) — two defenses on xterm's hidden textarea:
696
+ //
697
+ // (a) Paste tracking for the input guard: a DOM `paste` event stamps
698
+ // the guard so the following chunk is exempt — pastes are
699
+ // deliberate bulk input, and without the stamp a large
700
+ // un-bracketed paste would false-trip the oversize detector.
701
+ //
702
+ // (b) Idle-clear of the textarea. xterm@5.5.0 clears its helper
703
+ // textarea only on non-composition Enter/Ctrl+C keydowns
704
+ // (Terminal.ts:1066-1068); on composition keyboards (every keydown
705
+ // = keyCode 229: mobile/IME/dictation/remote bridges) that clear
706
+ // never fires, the textarea accumulates the whole message, and
707
+ // CompositionHelper's replace/substring reconstruction can re-emit
708
+ // the accumulated buffer once per word boundary — the #12
709
+ // cumulative-prefix runaway. Clearing after a short typing lull
710
+ // (never mid-composition; composition state tracked via the public
711
+ // compositionstart/end events) bounds what those paths can
712
+ // reconstruct to a single typing burst. xterm's own deferred
713
+ // textarea reads are setTimeout(0) — far inside the 250ms debounce
714
+ // — so the clear cannot race them.
715
+ const guardTa = terminal.textarea;
716
+ if (guardTa) {
717
+ let composing = false;
718
+ let idleClearTimer = null;
719
+ const armIdleClear = () => {
720
+ if (idleClearTimer) clearTimeout(idleClearTimer);
721
+ idleClearTimer = setTimeout(() => {
722
+ idleClearTimer = null;
723
+ if (!composing && guardTa.value) guardTa.value = '';
724
+ }, 250);
725
+ };
726
+ guardTa.addEventListener('compositionstart', () => { composing = true; });
727
+ guardTa.addEventListener('compositionend', () => { composing = false; armIdleClear(); });
728
+ guardTa.addEventListener('keydown', armIdleClear);
729
+ guardTa.addEventListener('input', armIdleClear);
730
+ guardTa.addEventListener('paste', () => {
731
+ const entry = state.sessions.get(id);
732
+ if (!entry) return;
733
+ if (!entry._inputGuard) entry._inputGuard = InputGuard.createGuard();
734
+ InputGuard.notePaste(entry._inputGuard, Date.now());
735
+ });
736
+ }
737
+
611
738
  // Store reference
612
739
  state.sessions.set(id, {
613
740
  session: sessionData,
@@ -2443,12 +2570,11 @@
2443
2570
  }
2444
2571
  };
2445
2572
 
2446
- // Re-wire terminal input
2447
- entry.terminal.onData((data) => {
2448
- if (ws.readyState === WebSocket.OPEN) {
2449
- ws.send(JSON.stringify({ type: 'input', data }));
2450
- }
2451
- });
2573
+ // No input re-wiring (Sprint 73 T3, termdeck#12): the single onData
2574
+ // handler registered at panel creation dereferences entry.ws at event
2575
+ // time, so the `entry.ws = ws` assignment in onopen above is all a
2576
+ // reconnect needs. Pre-Sprint-73 this function re-ran terminal.onData()
2577
+ // here, leaking one handler (closed over its dead socket) per reconnect.
2452
2578
  }
2453
2579
 
2454
2580
  function halfPanel(id) {
@@ -393,6 +393,7 @@
393
393
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
394
394
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
395
395
  <script src="launcher-resolver.js" defer></script>
396
+ <script src="input-guard.js" defer></script>
396
397
  <script src="app.js" defer></script>
397
398
  </body>
398
399
  </html>
@@ -0,0 +1,192 @@
1
+ // TermDeck input guard — extracted Sprint 73 T3 (termdeck#12, second half)
2
+ //
3
+ // Pure state-machine: given a stream of xterm `onData` chunks for one panel,
4
+ // decide per chunk whether it is plausible human/protocol input (`pass`) or a
5
+ // runaway re-emission of the input buffer (`suppress`). Lives in its own file
6
+ // so the same code runs in the browser (via <script src="input-guard.js">)
7
+ // AND under `node --test` (via `require('.../input-guard')`) — the
8
+ // launcher-resolver.js pattern.
9
+ //
10
+ // Why this exists (termdeck#12, the "input box accumulates buffer-so-far per
11
+ // keystroke" half): on composition-style keyboards — Android/iOS soft
12
+ // keyboards, IMEs, dictation, remote-access keyboard bridges — every keydown
13
+ // reaches xterm as keyCode 229 and xterm@5.5.0 reconstructs the typed data
14
+ // from its hidden helper <textarea>:
15
+ //
16
+ // 1. The textarea is cleared ONLY on a non-composition Enter/Ctrl+C keydown
17
+ // (xterm src/browser/Terminal.ts:1066-1068). Composition keydowns return
18
+ // early, so mid-message the textarea accumulates the entire buffer-so-far.
19
+ // 2. CompositionHelper._handleAnyTextareaChanges computes the keystroke as
20
+ // `newValue.replace(oldValue, '')` (CompositionHelper.ts:191). When the
21
+ // keyboard REWRITES text (autocorrect / auto-space / predictive commit —
22
+ // routine at word boundaries) `oldValue` is no longer a substring, the
23
+ // replace matches nothing, and the ENTIRE accumulated buffer is emitted
24
+ // as one data event.
25
+ // 3. CompositionHelper._finalizeComposition emits
26
+ // `textarea.value.substring(start[, end])` spans with offsets captured at
27
+ // composition boundaries (CompositionHelper.ts:134,163,168) — stale
28
+ // offsets re-emit accumulated tails per word commit.
29
+ //
30
+ // Net effect observed in #12: a ~110-char message became a 3,042-char PTY
31
+ // stream of cumulative prefixes ("i think" / "i think there" / "i think there
32
+ // is" …). Each individual chunk was small (~5-110 chars) — so a single-chunk
33
+ // size cap can NOT catch the primary shape. Detection keys on the structure:
34
+ // consecutive multi-char chunks where each strictly extends the previous one
35
+ // as a prefix. Legit input never looks like that: typed keys arrive as 1-char
36
+ // deltas, IME commits arrive as sibling words (not superstrings), pastes
37
+ // arrive as one chunk (and are exempted via the DOM `paste` event and/or
38
+ // bracketed-paste markers), and terminal protocol replies are ESC-prefixed.
39
+ //
40
+ // xterm itself is loaded from CDN, version-pinned (index.html) and not
41
+ // patchable in a zero-build client — but every byte of human browser input
42
+ // reaches the PTY through exactly one chokepoint TermDeck owns: the
43
+ // `terminal.onData` handler in app.js. This module is that chokepoint's
44
+ // brain. See tests/input-guard contract: packages/server/tests/input-guard.test.js.
45
+
46
+ (function (root, factory) {
47
+ if (typeof module === 'object' && module.exports) {
48
+ module.exports = factory();
49
+ } else {
50
+ root.InputGuard = factory();
51
+ }
52
+ })(typeof self !== 'undefined' ? self : this, function () {
53
+
54
+ const DEFAULTS = {
55
+ // A single non-paste, non-protocol chunk this large is not human typing.
56
+ // Largest legit non-paste chunks are IME phrase commits (CJK conversion,
57
+ // dictation segments) — realistically well under this. Suppressed chunks
58
+ // are held for an explicit "send anyway" so a false positive costs one
59
+ // click, while a true positive saves the session.
60
+ oversizeChunkChars: 512,
61
+
62
+ // Chunks shorter than this never participate in chain detection: 1-2
63
+ // chars is normal typing, 3 covers short escape sequences. Chain
64
+ // candidates start at 4 chars.
65
+ chainMinChunkChars: 4,
66
+
67
+ // Consecutive strictly-growing prefix-chained chunks before tripping.
68
+ // 3 means: chunk B extending chunk A passes (a single predictive-commit
69
+ // rewrite can legitimately look like that once), but a third consecutive
70
+ // extension is the #12 runaway. Brad's payload chains 20+ deep.
71
+ prefixChainTripCount: 3,
72
+
73
+ // Consecutive IDENTICAL multi-char chunks before tripping. Belt-and-
74
+ // suspenders for the stacked-handler / stuck-repeat shape. High threshold
75
+ // because short identical runs are legit ("ha ha ha ha" via IME commits).
76
+ repeatChainTripCount: 8,
77
+
78
+ // Chain links must arrive within this window of each other. Runaway
79
+ // emissions arrive at typing cadence; a long pause breaks the chain.
80
+ chainWindowMs: 5000,
81
+
82
+ // Grace period after a DOM `paste` event during which any chunk passes.
83
+ // Pastes are deliberate; they also reset chain state.
84
+ pasteGraceMs: 1500,
85
+ };
86
+
87
+ function createGuard(opts) {
88
+ return {
89
+ cfg: Object.assign({}, DEFAULTS, opts || {}),
90
+ lastPasteAt: 0,
91
+ prevChunk: '',
92
+ prevChunkAt: 0,
93
+ prefixChainLen: 0,
94
+ repeatChainLen: 0,
95
+ suppressedCount: 0,
96
+ suppressedChars: 0,
97
+ };
98
+ }
99
+
100
+ // Record a DOM `paste` event on the panel's textarea (timestamp from the
101
+ // caller so the module stays clock-free and testable).
102
+ function notePaste(guard, now) {
103
+ guard.lastPasteAt = now;
104
+ }
105
+
106
+ function resetChains(guard) {
107
+ guard.prevChunk = '';
108
+ guard.prevChunkAt = 0;
109
+ guard.prefixChainLen = 0;
110
+ guard.repeatChainLen = 0;
111
+ }
112
+
113
+ // Classify one onData chunk. Returns { verdict: 'pass' } or
114
+ // { verdict: 'suppress', reason: 'prefix-chain'|'repeat-chain'|'oversize',
115
+ // chainLength, suppressedCount, suppressedChars }.
116
+ function check(guard, data, now) {
117
+ const cfg = guard.cfg;
118
+
119
+ // Bracketed paste (xterm wraps pastes in \x1b[200~ … \x1b[201~ when the
120
+ // app enabled DECSET 2004): deliberate bulk input — pass and reset
121
+ // chains. Checked before the generic ESC pass-through so the reset fires.
122
+ if (data.startsWith('\x1b[200~')) {
123
+ resetChains(guard);
124
+ return { verdict: 'pass' };
125
+ }
126
+
127
+ // Terminal protocol traffic (cursor/function keys, mouse tracking
128
+ // reports, DA/DSR query replies) is ESC-prefixed and must NEVER be
129
+ // suppressed or held — dropping a query reply can hang a TUI. The #12
130
+ // runaway is plain text reconstructed from the textarea, which cannot
131
+ // contain ESC. Protocol chunks don't touch chain state either (mouse
132
+ // wheel bursts emit many near-identical chunks legitimately).
133
+ if (data.charCodeAt(0) === 0x1b) {
134
+ return { verdict: 'pass' };
135
+ }
136
+
137
+ // DOM-paste grace: a `paste` event just fired on this panel's textarea
138
+ // (un-bracketed paste path). Deliberate bulk input — pass and reset
139
+ // chains. lastPasteAt === 0 means "never pasted", not "pasted at epoch".
140
+ if (guard.lastPasteAt > 0 && (now - guard.lastPasteAt) <= cfg.pasteGraceMs) {
141
+ resetChains(guard);
142
+ return { verdict: 'pass' };
143
+ }
144
+
145
+ // Small chunks are normal typing / control chars: always pass, and leave
146
+ // chain state alone (runaway prefix emissions can interleave with real
147
+ // keystroke deltas; a 1-char delta must not amnesty the chain).
148
+ if (data.length < cfg.chainMinChunkChars) {
149
+ return { verdict: 'pass' };
150
+ }
151
+
152
+ // Multi-char plain-text chunk: update chain state.
153
+ const withinWindow = guard.prevChunk && (now - guard.prevChunkAt) <= cfg.chainWindowMs;
154
+ if (withinWindow && data.length > guard.prevChunk.length && data.startsWith(guard.prevChunk)) {
155
+ guard.prefixChainLen += 1;
156
+ guard.repeatChainLen = 1;
157
+ } else if (withinWindow && data === guard.prevChunk) {
158
+ guard.repeatChainLen += 1;
159
+ // An identical re-emission keeps a prefix chain alive but doesn't grow it.
160
+ } else {
161
+ guard.prefixChainLen = 1;
162
+ guard.repeatChainLen = 1;
163
+ }
164
+ guard.prevChunk = data;
165
+ guard.prevChunkAt = now;
166
+
167
+ let reason = null;
168
+ if (guard.prefixChainLen >= cfg.prefixChainTripCount) {
169
+ reason = 'prefix-chain';
170
+ } else if (guard.repeatChainLen >= cfg.repeatChainTripCount) {
171
+ reason = 'repeat-chain';
172
+ } else if (data.length >= cfg.oversizeChunkChars) {
173
+ reason = 'oversize';
174
+ }
175
+
176
+ if (reason) {
177
+ guard.suppressedCount += 1;
178
+ guard.suppressedChars += data.length;
179
+ return {
180
+ verdict: 'suppress',
181
+ reason,
182
+ chainLength: reason === 'repeat-chain' ? guard.repeatChainLen : guard.prefixChainLen,
183
+ suppressedCount: guard.suppressedCount,
184
+ suppressedChars: guard.suppressedChars,
185
+ };
186
+ }
187
+
188
+ return { verdict: 'pass' };
189
+ }
190
+
191
+ return { DEFAULTS, createGuard, notePaste, check };
192
+ });
@@ -1956,6 +1956,69 @@
1956
1956
  to { opacity: 1; transform: translateY(0); }
1957
1957
  }
1958
1958
 
1959
+ /* ===== Input-guard toast (Sprint 73 T3, termdeck#12) =====
1960
+ Same chrome as .proactive-toast but red-accented (it reports suppressed
1961
+ input, not a memory hit) and not cursor-pointer (clicking it does
1962
+ nothing; actions are the explicit buttons). */
1963
+ .input-guard-toast {
1964
+ position: absolute;
1965
+ right: 10px;
1966
+ bottom: 44px;
1967
+ max-width: 320px;
1968
+ padding: 8px 10px 8px 12px;
1969
+ background: rgba(23, 15, 15, 0.95);
1970
+ border: 1px solid var(--tg-accent-dim);
1971
+ border-left: 3px solid #f7768e;
1972
+ border-radius: var(--tg-radius-sm);
1973
+ box-shadow: 0 6px 18px rgba(0,0,0,0.35);
1974
+ color: var(--tg-text);
1975
+ font-size: 11px;
1976
+ z-index: 26;
1977
+ animation: toast-in 0.18s ease;
1978
+ }
1979
+ .input-guard-toast .t-title {
1980
+ font-size: 10px;
1981
+ text-transform: uppercase;
1982
+ letter-spacing: 0.4px;
1983
+ color: #f7768e;
1984
+ margin-bottom: 3px;
1985
+ }
1986
+ .input-guard-toast .t-body {
1987
+ font-size: 11px;
1988
+ line-height: 1.35;
1989
+ color: var(--tg-text);
1990
+ }
1991
+ .input-guard-toast .t-meta {
1992
+ margin-top: 4px;
1993
+ font-size: 9px;
1994
+ color: var(--tg-text-dim);
1995
+ }
1996
+ .input-guard-toast .t-dismiss {
1997
+ position: absolute;
1998
+ top: 2px;
1999
+ right: 4px;
2000
+ background: none;
2001
+ border: none;
2002
+ color: var(--tg-text-dim);
2003
+ cursor: pointer;
2004
+ font-size: 12px;
2005
+ padding: 0 4px;
2006
+ }
2007
+ .input-guard-toast .t-dismiss:hover { color: var(--tg-text); }
2008
+ .input-guard-toast .t-send-anyway {
2009
+ margin-top: 6px;
2010
+ padding: 2px 8px;
2011
+ background: none;
2012
+ border: 1px solid #f7768e;
2013
+ border-radius: var(--tg-radius-sm);
2014
+ color: #f7768e;
2015
+ font-size: 10px;
2016
+ cursor: pointer;
2017
+ }
2018
+ .input-guard-toast .t-send-anyway:hover {
2019
+ background: rgba(247, 118, 142, 0.15);
2020
+ }
2021
+
1959
2022
  /* ===== Flashback modal (Sprint 16 T2) ===== */
1960
2023
  .flashback-modal {
1961
2024
  display: none;
@@ -13,8 +13,8 @@
13
13
  // Grok's reasoning model, which the CLI rejects (`reasoningEffort` → HTTP 400;
14
14
  // see grok-models.js). Same provider, different runtime + different cost
15
15
  // realization, so it is a separate `sessionType:'web-chat'`. Provenance is
16
- // tagged `sourceAgent:'grok'` this sprint (ORCH zero-touch decision — see the
17
- // "source_agent attribution" section); a distinct 'grok-web' tag is deferred.
16
+ // tagged `sourceAgent:'grok-web'` (Sprint 73 T1 — see the "source_agent
17
+ // attribution" section), distinguishing web rows from Grok-CLI rows in Mnestra.
18
18
  //
19
19
  // ── The one hard constraint: NO node-pty, NO on-disk transcript ──────────────
20
20
  // There is no PTY stream and no conversation file on disk. The server seam
@@ -39,22 +39,22 @@
39
39
  // error) — index.js does not route web-chat text through `_detectErrors`.
40
40
  //
41
41
  // ── source_agent attribution ─────────────────────────────────────────────────
42
- // `sourceAgent:'grok'` (ORCH decision 2026-06-08, Blocker 3): we reuse the
43
- // already-allow-listed 'grok' tag so this sprint touches ZERO release-sensitive
44
- // surface the bundled hooks in packages/stack-installer/assets/hooks/* (Brad
45
- // runs the installed copy) stay pristine. The provenance is still accurate
46
- // (it IS Grok producing the content); it just doesn't yet distinguish web from
47
- // CLI. A distinct 'grok-web' tag which WOULD require adding 'grok-web' to the
48
- // hook's ALLOWED_SOURCE_AGENTS (else normalizeSourceAgent coerces the row to
49
- // 'claude') + a hook-version-stamp bump + an install refresh is deferred to a
50
- // follow-up. onPanelClose emits `adapter.sourceAgent || adapter.name`.
42
+ // `sourceAgent:'grok-web'` (Sprint 73 T1 flips the Sprint 72 ORCH zero-touch
43
+ // decision that shipped 'grok' to keep that sprint off the release-sensitive
44
+ // hook surface). Web and CLI Grok rows are now distinguishable in Mnestra:
45
+ // onPanelClose/periodic emit `adapter.sourceAgent || adapter.name`, the bundled
46
+ // hook allow-lists 'grok-web' (stamp v4, plus a `web-chat-grok` registry-name
47
+ // alias as the agy→antigravity-style safety net), and mnestra's source_agents
48
+ // enum + recall filter gain 'grok-web' via migration 024 (Sprint 74 T1
49
+ // ATOMIC release partner; neither side ships without the other, else rows are
50
+ // unfilterable or, on a stale installed hook, coerced to 'claude').
51
51
  //
52
- // Byte-floor note (also deferred with grok-web): the bundled hook skips
53
- // transcripts < 5 KB unless sessionType is specifically exempted (only
54
- // 'antigravity' is today). Our materialized envelope is compact, so a SHORT
55
- // (<5 KB) web-chat session is currently dropped — substantive auditor/worker
56
- // sessions (the real use case) run well past 5 KB and capture normally. The
57
- // exemption is a hook edit, so it rides the same deferred follow-up.
52
+ // Byte-floor (shipped with the flip, hook v4): the bundled hook skips
53
+ // transcripts < 5 KB unless the sessionType is exempted. Our materialized
54
+ // envelope is compact synthesized turn content, no JSONL metadata bloat;
55
+ // 48/49 live Sprint-72 envelopes were <5 KB — so 'web-chat' is exempted
56
+ // alongside 'antigravity', gated on parsed content (≥1 assistant turn)
57
+ // instead of raw bytes.
58
58
  //
59
59
  // Contract — see ./claude.js header for the full annotated adapter shape.
60
60
 
@@ -211,11 +211,11 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
211
211
  const webChatGrokAdapter = {
212
212
  name: 'web-chat-grok',
213
213
  sessionType: 'web-chat',
214
- // ORCH decision 2026-06-08 (Blocker 3): reuse the already-allow-listed 'grok'
215
- // tag so this sprint touches zero release-sensitive hook surface. A distinct
216
- // 'grok-web' tag is deferred (needs a hook allowlist edit + version bump).
217
- // See the "source_agent attribution" header for the full rationale.
218
- sourceAgent: 'grok',
214
+ // Sprint 73 T1 distinct web provenance (was 'grok', the Sprint 72 ORCH
215
+ // zero-touch decision). Pairs ATOMICALLY with hook v4 (ALLOWED_SOURCE_AGENTS
216
+ // + byte-floor exemption) and mnestra migration 024 (Sprint 74 T1). See the
217
+ // "source_agent attribution" header for the full rationale.
218
+ sourceAgent: 'grok-web',
219
219
  // Sprint 50 T3 — human-readable label for launcher buttons + panel headers.
220
220
  displayName: 'Grok (Web)',
221
221
  // Provider URL the CDP driver navigates the dedicated-profile tab to on
@@ -3467,8 +3467,13 @@ function validateSupabase(url, key) {
3467
3467
 
3468
3468
  function validateOpenAI(key) {
3469
3469
  return new Promise((resolve) => {
3470
+ // Probe with the EXACT request shape the bundled hooks use in production
3471
+ // (session-end v5: 3-large @ dimensions:1536, recall-parity with mnestra)
3472
+ // so a passing preflight means the real capture pipeline's call works —
3473
+ // not some other model the account may gate differently.
3470
3474
  const payload = JSON.stringify({
3471
- model: 'text-embedding-3-small',
3475
+ model: 'text-embedding-3-large',
3476
+ dimensions: 1536,
3472
3477
  input: 'termdeck setup test'
3473
3478
  });
3474
3479
  const req = https.request({
@@ -2,13 +2,14 @@
2
2
 
3
3
  The `@jhizzard/termdeck-stack` installer can drop `memory-session-end.js`
4
4
  into `~/.claude/hooks/` and wire it into `~/.claude/settings.json` under
5
- `hooks.Stop`. The installer prompts you before doing this; default is
6
- yes.
5
+ `hooks.SessionEnd`. The installer prompts you before doing this; default
6
+ is yes. (Early versions wired `hooks.Stop`, which fires every assistant
7
+ turn — the wizard migrates that to `SessionEnd` automatically.)
7
8
 
8
9
  ## What the hook does
9
10
 
10
- On every Claude Code session close, Claude Code fires its `Stop` hook
11
- with a JSON payload on stdin:
11
+ On every Claude Code session close, Claude Code fires its `SessionEnd`
12
+ hook with a JSON payload on stdin:
12
13
 
13
14
  ```json
14
15
  { "transcript_path": "/path/to/session.jsonl", "cwd": "/path/where/you/were/working", "session_id": "..." }
@@ -17,20 +18,28 @@ with a JSON payload on stdin:
17
18
  The hook:
18
19
 
19
20
  1. Skips transcripts smaller than 5 KB (no signal in tiny sessions —
20
- override via `TERMDECK_HOOK_MIN_BYTES`).
21
+ override via `TERMDECK_HOOK_MIN_BYTES`). Compact-envelope session
22
+ types (`antigravity`, `web-chat`) are exempt from the byte floor and
23
+ gate on parsed content instead (≥ 1 assistant turn).
21
24
  2. Validates env vars (`SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`,
22
25
  `OPENAI_API_KEY`); if any are missing, logs the missing list and
23
26
  exits cleanly without blocking the session close.
24
27
  3. Detects the project from `cwd` against a built-in regex table; falls
25
- back to `"global"` when nothing matches. **The default table is
26
- intentionally empty** — see "Customizing the project map" below to
27
- add your own entries.
28
+ back to `"global"` when nothing matches. The hook ships with a
29
+ default most-specific-first table — see "Customizing the project
30
+ map" below to extend it with your own entries.
28
31
  4. Builds a coarse session summary from the last ~30 messages of the
29
32
  transcript (~7 KB cap to stay inside OpenAI's embedding-input
30
33
  budget).
31
- 5. Embeds the summary via OpenAI `text-embedding-3-small` (1,536-dim).
34
+ 5. Embeds the summary via OpenAI `text-embedding-3-large` at
35
+ `dimensions: 1536` — deliberately identical to Mnestra's recall-query
36
+ embedder, so rows and queries share one vector space (rows embedded
37
+ with any other model rank as semantic noise in hybrid search).
32
38
  6. POSTs **one row** to Supabase `/rest/v1/memory_items` with
33
- `source_type='session_summary'`.
39
+ `source_type='session_summary'` (stamped
40
+ `metadata.embedding_model='text-embedding-3-large@1536'`), plus a
41
+ companion upsert to `/rest/v1/memory_sessions` keyed on
42
+ `session_id`.
34
43
  7. Logs every step to `~/.claude/hooks/memory-hook.log`.
35
44
 
36
45
  The hook is **fail-soft**: any error (network, parse, env-var-missing,
@@ -70,9 +79,9 @@ If any of the three is missing the log line will name them:
70
79
 
71
80
  ## Customizing the project map
72
81
 
73
- The hook ships with an **empty `PROJECT_MAP`** by default — every
74
- session lands under `project: 'global'` until you add entries. To add
75
- your own:
82
+ The hook ships with a default `PROJECT_MAP` (most-specific-first); a
83
+ session lands under `project: 'global'` only when no entry matches its
84
+ `cwd`. To add your own entries:
76
85
 
77
86
  1. Open `~/.claude/hooks/memory-session-end.js` after the installer
78
87
  has dropped it.
@@ -146,8 +155,8 @@ before overwriting; choose accordingly.
146
155
  Two options:
147
156
 
148
157
  1. Edit `~/.claude/settings.json` and remove the entry under
149
- `hooks.Stop` that references `memory-session-end.js`. Leave the
150
- file in place; it simply won't fire.
158
+ `hooks.SessionEnd` that references `memory-session-end.js`. Leave
159
+ the file in place; it simply won't fire.
151
160
  2. Or delete `~/.claude/hooks/memory-session-end.js` AND remove the
152
161
  `settings.json` entry. (Removing only the file leaves a broken
153
162
  `command` in settings — Claude Code will log a missing-file error
@@ -162,6 +171,7 @@ re-prompt to install. Decline at the prompt to stay opted out.
162
171
  |---|---|
163
172
  | `TERMDECK_HOOK_DEBUG=1` | Verbose `[debug]` lines in the log |
164
173
  | `TERMDECK_HOOK_MIN_BYTES=10000` | Override the 5 KB skip threshold |
174
+ | `TERMDECK_HOOK_MIN_MESSAGES=5` | Override the parsed-message floor (default 1) |
165
175
 
166
176
  ## Log file
167
177