@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.
- package/package.json +2 -1
- package/packages/cli/assets/supervise/com.jhizzard.termdeck-supervise.plist +38 -0
- package/packages/cli/assets/supervise/termdeck-supervise.service +27 -0
- package/packages/cli/assets/supervise/termdeck-supervise.sh +146 -0
- package/packages/cli/assets/supervise/termdeck-supervise.timer +14 -0
- package/packages/cli/src/index.js +15 -2
- package/packages/cli/src/init-bridge.js +1270 -0
- package/packages/cli/src/init.js +1 -0
- package/packages/client/public/app.js +135 -9
- package/packages/client/public/index.html +1 -0
- package/packages/client/public/input-guard.js +192 -0
- package/packages/client/public/style.css +63 -0
- package/packages/server/src/agent-adapters/web-chat-grok.js +22 -22
- package/packages/server/src/index.js +6 -1
- package/packages/stack-installer/assets/hooks/README.md +25 -15
- package/packages/stack-installer/assets/hooks/memory-pre-compact.js +35 -7
- package/packages/stack-installer/assets/hooks/memory-session-end.js +121 -27
package/packages/cli/src/init.js
CHANGED
|
@@ -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
|
-
|
|
598
|
-
|
|
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
|
-
//
|
|
2447
|
-
entry.
|
|
2448
|
-
|
|
2449
|
-
|
|
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'`
|
|
17
|
-
//
|
|
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'` (
|
|
43
|
-
//
|
|
44
|
-
// surface
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
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
|
|
53
|
-
// transcripts < 5 KB unless sessionType is
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
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-
|
|
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.
|
|
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 `
|
|
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.
|
|
26
|
-
|
|
27
|
-
|
|
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-
|
|
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
|
|
74
|
-
session lands under `project: 'global'`
|
|
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.
|
|
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
|
|