@jhizzard/termdeck 1.8.1 → 1.10.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.
@@ -72,8 +72,9 @@ const HELP = [
72
72
  ' --skip-verify Skip the final memory_status_aggregation() sanity call',
73
73
  '',
74
74
  'What this does:',
75
- ' 1. Prompts for Supabase URL, service_role key, direct Postgres connection',
76
- ' string, OpenAI API key, and (optional) Anthropic API key — or reuses',
75
+ ' 1. Prompts for Supabase URL, service_role key, Postgres connection',
76
+ ' string (Shared Pooler; IPv4-safe), OpenAI API key, and (optional)',
77
+ ' Anthropic API key — or reuses',
77
78
  ' saved values if a complete set already exists in secrets.env.',
78
79
  ' 2. Writes ~/.termdeck/secrets.env IMMEDIATELY (merge-aware) so a later',
79
80
  ' pg connect or migration failure does not lose what you typed in.',
@@ -157,6 +158,11 @@ function inputsFromEnv() {
157
158
 
158
159
  const dbErr = urlHelper.looksLikePostgresUrl(required.DATABASE_URL);
159
160
  if (dbErr) throw new Error(`DATABASE_URL: ${dbErr}`);
161
+ // Sprint 75 T2 (part B): warn-only — a direct-endpoint URL passes
162
+ // validation (warn ≠ reject) but gets the IPv4 trap warning printed once.
163
+ for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(required.DATABASE_URL))) {
164
+ process.stdout.write(` ${line}\n`);
165
+ }
160
166
 
161
167
  const srErr = urlHelper.looksLikeServiceRole(required.SUPABASE_SERVICE_ROLE_KEY);
162
168
  if (srErr) throw new Error(`SUPABASE_SERVICE_ROLE_KEY: ${srErr}`);
@@ -188,7 +194,7 @@ TermDeck Mnestra Setup
188
194
 
189
195
  This wizard configures TermDeck's Tier 2 memory layer (Mnestra) by:
190
196
  1. Asking for your Supabase URL and service_role key
191
- 2. Asking for a direct Postgres connection string
197
+ 2. Asking for a Postgres connection string (Shared Pooler)
192
198
  3. Asking for an OpenAI API key (embeddings)
193
199
  4. Asking for an Anthropic API key (optional, summaries)
194
200
  5. Writing ~/.termdeck/secrets.env (before any database work, so a
@@ -253,6 +259,12 @@ async function collectInputs({ yes, reset }) {
253
259
  process.stdout.write(
254
260
  `Found saved secrets in ~/.termdeck/secrets.env (project ${ref}, db ${masked}).\n`
255
261
  );
262
+ // Sprint 75 T2 (part B): highest-value warn site — an operator whose
263
+ // EARLIER install stored a direct-endpoint URL (the Brad case) would
264
+ // otherwise sail through reuse with zero feedback. Warn-only.
265
+ for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(found.inputs.databaseUrl))) {
266
+ process.stdout.write(` ${line}\n`);
267
+ }
256
268
  const reuse = yes ? true : await prompts.confirm(' Reuse saved secrets?', { defaultYes: true });
257
269
  if (reuse) {
258
270
  process.stdout.write(' Reusing saved secrets. Skipping prompts.\n\n');
@@ -284,11 +296,19 @@ async function collectInputs({ yes, reset }) {
284
296
  );
285
297
 
286
298
  process.stdout.write(
287
- '? Direct Postgres connection string\n' +
288
- ` (Supabase dashboard → Project Settings Database Connection String → Transaction pooler)\n` +
289
- ' postgres://postgres.REF:PW@... '
299
+ '? Postgres connection string (Shared Pooler)\n' +
300
+ ' (Supabase dashboard → Connect (green button) → Transaction pooler →\n' +
301
+ ' toggle ON "Use IPv4 connection (Shared Pooler)" — the OFF default shows an\n' +
302
+ ' IPv6-only URL that hangs on IPv4-only hosts)\n' +
303
+ ' postgres://postgres.<project-ref>:PW@aws-<n>-<region>.pooler.supabase.com:6543/postgres '
290
304
  );
291
305
  const databaseUrl = await promptSecretWithValidation(urlHelper.looksLikePostgresUrl);
306
+ // Sprint 75 T2 (part B): warn-only endpoint-shape feedback. The validator
307
+ // above ACCEPTS direct URLs (IPv6-capable hosts use them legitimately);
308
+ // this prints the IPv4 trap warning without changing acceptance.
309
+ for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(databaseUrl))) {
310
+ process.stdout.write(` ${line}\n`);
311
+ }
292
312
 
293
313
  process.stdout.write('? OpenAI API key (starts sk-proj- or sk-): ');
294
314
  const openaiKey = await promptSecretWithValidation(urlHelper.looksLikeOpenAiKey);
@@ -713,13 +733,34 @@ function refreshBundledHookIfNewer(opts = {}) {
713
733
  // actually run after upgrading the package.
714
734
 
715
735
  const SETTINGS_JSON_PATH = path.join(require('os').homedir(), '.claude', 'settings.json');
716
- const HOOK_COMMAND = 'node ~/.claude/hooks/memory-session-end.js';
736
+
737
+ // Sprint 75 T2 — hook commands are written into ~/.claude/settings.json with
738
+ // ABSOLUTE paths. The pre-1.10 literal `node ~/.claude/hooks/...` shape relied
739
+ // on shell tilde expansion — it worked on macOS/Linux only by luck of how the
740
+ // harness invokes hook commands, and is a hard break on Windows (audit item 4).
741
+ // Computed at CALL time (not require time) from os.homedir() so a process that
742
+ // re-points HOME (tests, sandboxed installs) gets the right path. The path is
743
+ // double-quoted so a home dir containing spaces (`/Users/First Last/`) still
744
+ // produces a command the harness shell can execute. Lockstep twin lives in
745
+ // packages/stack-installer/src/index.js (`_hookCommandFor`) — INSTALLER-
746
+ // PITFALLS Class N: change both or neither.
747
+ function _hookCommandFor(filename) {
748
+ return `node "${path.join(require('os').homedir(), '.claude', 'hooks', filename)}"`;
749
+ }
750
+
751
+ // True when an entry's command still carries the legacy tilde shape and
752
+ // should be rewritten to the absolute form.
753
+ function _isTildeHookCommand(command) {
754
+ return typeof command === 'string' && command.includes('~/');
755
+ }
756
+
757
+ const HOOK_COMMAND = _hookCommandFor('memory-session-end.js');
717
758
  const HOOK_TIMEOUT_SECONDS = 30;
718
759
 
719
760
  // Sprint 64 T3 — PreCompact hook (Investigation 2 of CRITICAL-READ-FIRST-
720
761
  // 2026-05-07.md). Lives alongside the SessionEnd hook; refreshes via the
721
762
  // same Sprint 51.6 T3 version-stamp gate.
722
- const PRECOMPACT_HOOK_COMMAND = 'node ~/.claude/hooks/memory-pre-compact.js';
763
+ const PRECOMPACT_HOOK_COMMAND = _hookCommandFor('memory-pre-compact.js');
723
764
  const PRECOMPACT_HOOK_TIMEOUT_SECONDS = 30;
724
765
 
725
766
  function _isSessionEndHookEntry(entry) {
@@ -734,7 +775,8 @@ function _isSessionEndHookEntry(entry) {
734
775
  // `packages/stack-installer/src/index.js:451` byte-for-byte (modulo
735
776
  // constants pulled from this file's scope).
736
777
  function _mergeSessionEndHookEntry(settings, opts = {}) {
737
- const command = opts.command || HOOK_COMMAND;
778
+ // Command computed at call time (Sprint 75 T2) — see _hookCommandFor.
779
+ const command = opts.command || _hookCommandFor('memory-session-end.js');
738
780
  const timeout = opts.timeout != null ? opts.timeout : HOOK_TIMEOUT_SECONDS;
739
781
  const entry = { type: 'command', command, timeout };
740
782
 
@@ -759,10 +801,29 @@ function _mergeSessionEndHookEntry(settings, opts = {}) {
759
801
 
760
802
  if (!Array.isArray(settings.hooks.SessionEnd)) settings.hooks.SessionEnd = [];
761
803
 
804
+ // Sprint 75 T2 — rewrite a stale literal-`~` command (written by installers
805
+ // ≤ v1.9.x) to the absolute form. The "already wired?" predicate matches by
806
+ // hook FILENAME substring, so without this rewrite a legacy entry would be
807
+ // reported already-installed and keep its `~` forever. Idempotent: absolute
808
+ // commands (and user-custom commands without `~/`) are never touched.
809
+ let tildeMigrated = false;
810
+ for (const group of settings.hooks.SessionEnd) {
811
+ if (!group || !Array.isArray(group.hooks)) continue;
812
+ for (const e of group.hooks) {
813
+ if (_isSessionEndHookEntry(e) && _isTildeHookCommand(e.command)) {
814
+ e.command = command;
815
+ tildeMigrated = true;
816
+ }
817
+ }
818
+ }
819
+
762
820
  for (const group of settings.hooks.SessionEnd) {
763
821
  if (!group || !Array.isArray(group.hooks)) continue;
764
822
  if (group.hooks.some(_isSessionEndHookEntry)) {
765
- return { settings, status: migrated ? 'migrated-from-stop' : 'already-installed' };
823
+ const status = tildeMigrated ? 'migrated-tilde-path'
824
+ : migrated ? 'migrated-from-stop'
825
+ : 'already-installed';
826
+ return { settings, status };
766
827
  }
767
828
  }
768
829
 
@@ -788,17 +849,30 @@ function _isPreCompactHookEntry(entry) {
788
849
  }
789
850
 
790
851
  function _mergePreCompactHookEntry(settings, opts = {}) {
791
- const command = opts.command || PRECOMPACT_HOOK_COMMAND;
852
+ // Command computed at call time (Sprint 75 T2) — see _hookCommandFor.
853
+ const command = opts.command || _hookCommandFor('memory-pre-compact.js');
792
854
  const timeout = opts.timeout != null ? opts.timeout : PRECOMPACT_HOOK_TIMEOUT_SECONDS;
793
855
  const entry = { type: 'command', command, timeout };
794
856
 
795
857
  if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
796
858
  if (!Array.isArray(settings.hooks.PreCompact)) settings.hooks.PreCompact = [];
797
859
 
860
+ // Sprint 75 T2 — same stale literal-`~` rewrite as the SessionEnd merge.
861
+ let tildeMigrated = false;
862
+ for (const group of settings.hooks.PreCompact) {
863
+ if (!group || !Array.isArray(group.hooks)) continue;
864
+ for (const e of group.hooks) {
865
+ if (_isPreCompactHookEntry(e) && _isTildeHookCommand(e.command)) {
866
+ e.command = command;
867
+ tildeMigrated = true;
868
+ }
869
+ }
870
+ }
871
+
798
872
  for (const group of settings.hooks.PreCompact) {
799
873
  if (!group || !Array.isArray(group.hooks)) continue;
800
874
  if (group.hooks.some(_isPreCompactHookEntry)) {
801
- return { settings, status: 'already-installed' };
875
+ return { settings, status: tildeMigrated ? 'migrated-tilde-path' : 'already-installed' };
802
876
  }
803
877
  }
804
878
 
@@ -934,10 +1008,14 @@ function runSettingsJsonMigration({ dryRun = false } = {}) {
934
1008
  ok(r.backup ? `installed (SessionEnd; backup: ${path.basename(r.backup)})` : 'installed (SessionEnd)');
935
1009
  } else if (r.status === 'migrated-from-stop') {
936
1010
  ok(r.backup ? `migrated Stop → SessionEnd (was firing on every turn; backup: ${path.basename(r.backup)})` : 'migrated Stop → SessionEnd (was firing on every turn)');
1011
+ } else if (r.status === 'migrated-tilde-path') {
1012
+ ok(r.backup ? `rewrote legacy ~ command to absolute path (backup: ${path.basename(r.backup)})` : 'rewrote legacy ~ command to absolute path');
937
1013
  } else if (r.status === 'would-installed') {
938
1014
  ok('would install (SessionEnd) (dry-run)');
939
1015
  } else if (r.status === 'would-migrated-from-stop') {
940
1016
  ok('would migrate Stop → SessionEnd (dry-run)');
1017
+ } else if (r.status === 'would-migrated-tilde-path') {
1018
+ ok('would rewrite legacy ~ command to absolute path (dry-run)');
941
1019
  } else if (r.status === 'malformed') {
942
1020
  ok(`(skipped: settings.json malformed: ${r.error})`);
943
1021
  } else {
@@ -964,8 +1042,12 @@ function runSettingsJsonMigration({ dryRun = false } = {}) {
964
1042
  ok('already wired (PreCompact)');
965
1043
  } else if (r.status === 'installed') {
966
1044
  ok(r.backup ? `installed (PreCompact; backup: ${path.basename(r.backup)})` : 'installed (PreCompact)');
1045
+ } else if (r.status === 'migrated-tilde-path') {
1046
+ ok(r.backup ? `rewrote legacy ~ command to absolute path (backup: ${path.basename(r.backup)})` : 'rewrote legacy ~ command to absolute path');
967
1047
  } else if (r.status === 'would-installed') {
968
1048
  ok('would install (PreCompact) (dry-run)');
1049
+ } else if (r.status === 'would-migrated-tilde-path') {
1050
+ ok('would rewrite legacy ~ command to absolute path (dry-run)');
969
1051
  } else if (r.status === 'malformed') {
970
1052
  ok(`(skipped: settings.json malformed: ${r.error})`);
971
1053
  } else {
@@ -1162,7 +1244,10 @@ async function main(argv) {
1162
1244
  } catch (err) {
1163
1245
  fail(err.message);
1164
1246
  process.stderr.write(
1165
- '\nDouble-check the connection string from Supabase → Project Settings Database Connection String.\n'
1247
+ '\nDouble-check the connection string: Supabase dashboard ConnectTransaction pooler →\n' +
1248
+ 'toggle ON "Use IPv4 connection (Shared Pooler)". If the connect HUNG (timeout rather than\n' +
1249
+ 'auth error), the URL is probably the IPv6-only db.<project-ref> endpoint and this host has\n' +
1250
+ 'no IPv6 route — use the Shared Pooler URL.\n'
1166
1251
  );
1167
1252
  printResumeHint();
1168
1253
  return 3;
@@ -1233,3 +1318,9 @@ module.exports._isSessionEndHookEntry = _isSessionEndHookEntry;
1233
1318
  module.exports.SETTINGS_JSON_PATH = SETTINGS_JSON_PATH;
1234
1319
  module.exports.HOOK_COMMAND = HOOK_COMMAND;
1235
1320
  module.exports.HOOK_TIMEOUT_SECONDS = HOOK_TIMEOUT_SECONDS;
1321
+ // Sprint 75 T2 — absolute-path hook-command builders (lockstep twin in
1322
+ // packages/stack-installer/src/index.js; exported for tests).
1323
+ module.exports._hookCommandFor = _hookCommandFor;
1324
+ module.exports._isTildeHookCommand = _isTildeHookCommand;
1325
+ module.exports._mergePreCompactHookEntry = _mergePreCompactHookEntry;
1326
+ module.exports.migrateSettingsJsonPreCompactEntry = migrateSettingsJsonPreCompactEntry;
@@ -212,6 +212,13 @@ function preflight() {
212
212
  ok();
213
213
  }
214
214
 
215
+ // Sprint 75 T2 (part B): warn-only endpoint-shape feedback — the same
216
+ // stored URL feeds the Edge Function secrets, so a direct-endpoint URL
217
+ // (IPv6-only) carried over from an earlier install gets flagged here too.
218
+ for (const line of urlHelper.directEndpointWarningLines(urlHelper.classifyDbEndpoint(secrets.DATABASE_URL))) {
219
+ process.stdout.write(` ${line}\n`);
220
+ }
221
+
215
222
  // OPENAI_API_KEY is optional: when present, Rumen's Relate phase generates
216
223
  // real embeddings for semantic+keyword hybrid search. When absent, Rumen
217
224
  // falls back to keyword-only matching (still works, but loses cross-project
@@ -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
+ });