@jhizzard/termdeck 0.7.3 → 0.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.
@@ -66,6 +66,14 @@ class RAGIntegration {
66
66
  this._circuitBreaker = new Map(); // table -> { count, open, openedAt, halfOpen }
67
67
  this._halfOpenDelayMs = 5 * 60 * 1000;
68
68
 
69
+ // Status-change debounce: rapid `active ↔ thinking` cycling from busy
70
+ // Claude Code workers (4+1 sprint lanes) produces dozens of status_changed
71
+ // events per second. Untreated, this floods stdout and the outbox; on
72
+ // 2026-04-27 it contributed to two server-kill incidents during Sprint 36.
73
+ // Drop intra-second status edges; let error transitions always pass.
74
+ this._statusWriteAt = new Map(); // sessionId -> last write timestamp (ms)
75
+ this._statusDebounceMs = 1000;
76
+
69
77
  if (this.enabled) {
70
78
  this._startSync();
71
79
  }
@@ -148,6 +156,16 @@ class RAGIntegration {
148
156
  }
149
157
 
150
158
  onStatusChanged(session, oldStatus, newStatus) {
159
+ // Always pass through error transitions — those carry signal worth ingesting
160
+ // every time. Debounce only the active ↔ thinking churn that floods the log
161
+ // when a worker cycles tool calls rapidly.
162
+ const isError = newStatus === 'errored' || oldStatus === 'errored';
163
+ if (!isError) {
164
+ const now = Date.now();
165
+ const last = this._statusWriteAt.get(session.id) || 0;
166
+ if (now - last < this._statusDebounceMs) return;
167
+ this._statusWriteAt.set(session.id, now);
168
+ }
151
169
  this._recordForSession(session, 'status_changed', {
152
170
  from: oldStatus,
153
171
  to: newStatus,
@@ -351,7 +369,32 @@ class RAGIntegration {
351
369
  stop() {
352
370
  if (this._syncTimer) {
353
371
  clearInterval(this._syncTimer);
372
+ this._syncTimer = null;
373
+ }
374
+ this._statusWriteAt.clear();
375
+ }
376
+
377
+ // Live-toggle for the dashboard RAG settings panel (Sprint 36 T3 Deliverable A).
378
+ // Re-evaluates eligibility — flipping `enabled: true` without configured
379
+ // Supabase creds is a no-op so the live integration never claims to be on
380
+ // when it can't actually push. Returns the resolved effective flag.
381
+ setEnabled(value) {
382
+ const desired = !!value;
383
+ if (this.config && this.config.rag) {
384
+ this.config.rag.enabled = desired;
385
+ }
386
+ const effective = !!(desired && this.supabaseUrl && this.supabaseKey);
387
+ if (effective === this.enabled) return effective;
388
+ this.enabled = effective;
389
+ if (effective) {
390
+ if (!this._syncTimer) this._startSync();
391
+ } else {
392
+ if (this._syncTimer) {
393
+ clearInterval(this._syncTimer);
394
+ this._syncTimer = null;
395
+ }
354
396
  }
397
+ return effective;
355
398
  }
356
399
  }
357
400
 
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ // Two-stage submit pattern for the in-dashboard 4+1 sprint runner (Sprint 37 T4).
4
+ //
5
+ // The cardinal rule from the global 4+1 inject mandate:
6
+ //
7
+ // Stage 1: write `\x1b[200~<prompt>\x1b[201~` to each session in turn,
8
+ // with a small inter-session gap. NO trailing CR.
9
+ // Settle: ~400ms so the PTY flushes the paste to the input handler.
10
+ // Stage 2: write `\r` alone to each session.
11
+ //
12
+ // Single-stage `<prompt>\x1b[201~\r` is BANNED — when the close marker and the
13
+ // CR ride in one PTY write, the OS-level chunk boundary is non-deterministic;
14
+ // some lanes treat `\r` as the trailing paste byte rather than a submit
15
+ // keystroke, leaving panels visually populated but waiting on a human Enter.
16
+ // That cost Joshua broken sleep during ClaimGuard Sprints 4-5 (2026-04-26) and
17
+ // the Sprint-36 inject (2026-04-27). This module is the structural fix.
18
+ //
19
+ // After both stages, this module verifies each panel reaches `status:'thinking'`
20
+ // within `verifyTimeoutMs`. Any lane that didn't get there is auto-poked
21
+ // (single CR-flood); we never page the user.
22
+ //
23
+ // Pure logic — caller injects writeBytes/getStatus/sleep so tests don't need
24
+ // a live PTY. Wired in by sprint-routes.js.
25
+
26
+ const DEFAULTS = {
27
+ gapMs: 250,
28
+ settleMs: 400,
29
+ verifyTimeoutMs: 8000,
30
+ verifyPollMs: 500,
31
+ postPokeWaitMs: 500,
32
+ };
33
+
34
+ async function injectSprintPrompts({
35
+ sessionIds,
36
+ prompts,
37
+ writeBytes,
38
+ getStatus,
39
+ sleep,
40
+ options,
41
+ }) {
42
+ if (!Array.isArray(sessionIds) || !Array.isArray(prompts)) {
43
+ throw new Error('sessionIds and prompts must be arrays');
44
+ }
45
+ if (sessionIds.length !== prompts.length) {
46
+ throw new Error('sessionIds and prompts must be the same length');
47
+ }
48
+ if (sessionIds.length === 0) {
49
+ throw new Error('at least one session required');
50
+ }
51
+ if (typeof writeBytes !== 'function') {
52
+ throw new Error('writeBytes(sessionId, bytes) callback required');
53
+ }
54
+ if (typeof sleep !== 'function') {
55
+ throw new Error('sleep(ms) callback required');
56
+ }
57
+
58
+ const opts = { ...DEFAULTS, ...(options || {}) };
59
+
60
+ const lanes = sessionIds.map((sessionId, i) => ({
61
+ sessionId,
62
+ prompt: prompts[i],
63
+ paste: null,
64
+ submit: null,
65
+ verified: false,
66
+ poked: false,
67
+ finalStatus: null,
68
+ }));
69
+
70
+ // Stage 1: paste-only across all lanes, gapMs between each.
71
+ for (let i = 0; i < lanes.length; i++) {
72
+ const lane = lanes[i];
73
+ const payload = `\x1b[200~${lane.prompt}\x1b[201~`;
74
+ try {
75
+ const r = await writeBytes(lane.sessionId, payload);
76
+ lane.paste = { ok: true, bytes: (r && r.bytes) || payload.length };
77
+ } catch (err) {
78
+ lane.paste = { ok: false, error: err && err.message ? err.message : String(err) };
79
+ }
80
+ if (i < lanes.length - 1) await sleep(opts.gapMs);
81
+ }
82
+
83
+ // Settle window — long enough for the PTY to flush each paste to the TUI's
84
+ // input handler before the trailing CR lands.
85
+ await sleep(opts.settleMs);
86
+
87
+ // Stage 2: submit-only (\r alone, guaranteed its own PTY write).
88
+ for (let i = 0; i < lanes.length; i++) {
89
+ const lane = lanes[i];
90
+ if (!lane.paste || !lane.paste.ok) {
91
+ lane.submit = { ok: false, skipped: 'paste-failed' };
92
+ continue;
93
+ }
94
+ try {
95
+ const r = await writeBytes(lane.sessionId, '\r');
96
+ lane.submit = { ok: true, bytes: (r && r.bytes) || 1 };
97
+ } catch (err) {
98
+ lane.submit = { ok: false, error: err && err.message ? err.message : String(err) };
99
+ }
100
+ if (i < lanes.length - 1) await sleep(opts.gapMs);
101
+ }
102
+
103
+ // Verify: poll each lane's status until it reads `thinking` or we hit the
104
+ // deadline. Lanes that never thinking → auto-/poke (cr-flood).
105
+ if (typeof getStatus === 'function') {
106
+ const deadline = Date.now() + opts.verifyTimeoutMs;
107
+ while (Date.now() < deadline) {
108
+ let anyPending = false;
109
+ for (const lane of lanes) {
110
+ if (lane.verified) continue;
111
+ try {
112
+ const s = await getStatus(lane.sessionId);
113
+ lane.finalStatus = s && s.status ? s.status : null;
114
+ if (lane.finalStatus === 'thinking') {
115
+ lane.verified = true;
116
+ } else {
117
+ anyPending = true;
118
+ }
119
+ } catch {
120
+ anyPending = true;
121
+ }
122
+ }
123
+ if (!anyPending) break;
124
+ await sleep(opts.verifyPollMs);
125
+ }
126
+
127
+ // Auto-poke (cr-flood) any lane that didn't reach `thinking`. Best-effort —
128
+ // never page the user; the orchestrator dashboard surfaces the result.
129
+ for (const lane of lanes) {
130
+ if (lane.verified) continue;
131
+ try {
132
+ await writeBytes(lane.sessionId, '\r\r\r');
133
+ lane.poked = true;
134
+ } catch (err) {
135
+ lane.pokeError = err && err.message ? err.message : String(err);
136
+ continue;
137
+ }
138
+ await sleep(opts.postPokeWaitMs);
139
+ try {
140
+ const s = await getStatus(lane.sessionId);
141
+ lane.finalStatus = s && s.status ? s.status : lane.finalStatus;
142
+ if (lane.finalStatus === 'thinking') lane.verified = true;
143
+ } catch {
144
+ // ignore
145
+ }
146
+ }
147
+ }
148
+
149
+ const ok = lanes.every((l) => l.paste && l.paste.ok && l.submit && l.submit.ok);
150
+ return { ok, lanes };
151
+ }
152
+
153
+ module.exports = {
154
+ injectSprintPrompts,
155
+ DEFAULTS,
156
+ };