@jhizzard/termdeck 1.8.0 → 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 +5 -3
- 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/agy.js +21 -30
- package/packages/server/src/agent-adapters/web-chat-grok.js +22 -22
- package/packages/server/src/index.js +98 -4
- package/packages/server/src/sprints/status-parser.js +14 -4
- 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;
|
|
@@ -309,32 +309,25 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
|
|
|
309
309
|
}
|
|
310
310
|
|
|
311
311
|
// ──────────────────────────────────────────────────────────────────────────
|
|
312
|
-
// mcpConfig —
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
312
|
+
// mcpConfig — null (Mnestra MCP auto-wire intentionally OFF for agy).
|
|
313
|
+
// VERIFIED 2026-06-08 (live 4-CLI 360): Antigravity's MCP is NOT file-config-
|
|
314
|
+
// driven — agy's MCP servers are managed by its embedded "exa" language-server
|
|
315
|
+
// (RPCs `RefreshMcpServers` / `GetMcpServerStates`; type
|
|
316
|
+
// `gemini.GeminiMCPServerConfig`), not a readable `mcp_config.json`. Ruled out
|
|
317
|
+
// empirically against a LIVE agy panel: a de-secreted mnestra block written to
|
|
318
|
+
// BOTH `~/.gemini/config/mcp_config.json` AND the appDataDir
|
|
319
|
+
// `~/.gemini/antigravity-cli/mcp_config.json` left agy reporting
|
|
320
|
+
// `NO-MNESTRA-TOOL`; `~/.gemini/settings.json` already carries mnestra (gemini
|
|
321
|
+
// reads it) yet agy ignores it; `agy --help` exposes no `mcp` subcommand and
|
|
322
|
+
// `agy plugin list` is empty. A file-based mcpConfig here only targets a dead
|
|
323
|
+
// path, so it is `null` → the shared mcp-autowire helper cleanly skips (exactly
|
|
324
|
+
// the Claude case). Wiring Mnestra into agy is a deferred follow-up via the
|
|
325
|
+
// Antigravity language-server registration mechanism (likely IDE- /
|
|
326
|
+
// `RefreshMcpServers`-driven). This was always a non-load-bearing nicety: agy's
|
|
327
|
+
// PTY panel + the memory CAPTURE path (source_agent=antigravity, Sprint 70) both
|
|
328
|
+
// work; only the agy-side memory READ is deferred.
|
|
323
329
|
// ──────────────────────────────────────────────────────────────────────────
|
|
324
330
|
|
|
325
|
-
const MNESTRA_ENV_KEYS = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY'];
|
|
326
|
-
|
|
327
|
-
function buildMnestraBlock({ secrets } = {}) {
|
|
328
|
-
const env = {};
|
|
329
|
-
for (const key of MNESTRA_ENV_KEYS) {
|
|
330
|
-
const value = secrets && secrets[key];
|
|
331
|
-
if (typeof value === 'string' && value.length > 0 && !/^\$\{[^}]*\}$/.test(value)) {
|
|
332
|
-
env[key] = value;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
return { mnestra: { command: 'mnestra', args: [], env } };
|
|
336
|
-
}
|
|
337
|
-
|
|
338
331
|
const antigravityAdapter = {
|
|
339
332
|
name: 'antigravity',
|
|
340
333
|
sessionType: 'antigravity',
|
|
@@ -385,12 +378,10 @@ const antigravityAdapter = {
|
|
|
385
378
|
// true (bracketed-paste fast path), flip to false if a lane-time test shows
|
|
386
379
|
// the TUI input box eats the paste markers.
|
|
387
380
|
acceptsPaste: true,
|
|
388
|
-
mcpConfig
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
mnestraBlock: buildMnestraBlock,
|
|
393
|
-
},
|
|
381
|
+
// See the mcpConfig note above — Antigravity MCP is language-server-mediated,
|
|
382
|
+
// not file-config; null so mcp-autowire skips (Claude-style) instead of writing
|
|
383
|
+
// a dead-path file. agy memory READ is a deferred follow-up; CAPTURE works.
|
|
384
|
+
mcpConfig: null,
|
|
394
385
|
};
|
|
395
386
|
|
|
396
387
|
module.exports = antigravityAdapter;
|
|
@@ -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
|