@jhizzard/termdeck 1.4.0 → 1.6.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/README.md +1 -1
- package/package.json +4 -5
- package/packages/cli/src/index.js +2 -2
- package/packages/client/public/app.js +98 -8
- package/packages/client/public/style.css +9 -0
- package/packages/server/share/termdeck/templates/claude-code-auditor.txt +34 -0
- package/packages/server/share/termdeck/templates/claude-code-orchestrator.txt +30 -0
- package/packages/server/share/termdeck/templates/claude-code-worker.txt +26 -0
- package/packages/server/share/termdeck/templates/codex-auditor.txt +36 -0
- package/packages/server/share/termdeck/templates/codex-worker.txt +28 -0
- package/packages/server/share/termdeck/templates/gemini-auditor.txt +33 -0
- package/packages/server/share/termdeck/templates/gemini-worker.txt +25 -0
- package/packages/server/share/termdeck/templates/grok-auditor.txt +35 -0
- package/packages/server/share/termdeck/templates/grok-worker.txt +25 -0
- package/packages/server/src/index.js +46 -15
- package/packages/server/src/orchestration-preview.js +1 -1
- package/packages/server/src/parked-detection.js +50 -0
- package/packages/server/src/session.js +25 -3
- package/packages/server/src/sprint-inject.js +2 -2
- package/packages/server/src/sprint-routes.js +17 -0
- package/packages/server/src/sprints/inject.js +323 -0
- package/packages/server/src/sprints/nudge.js +147 -0
- package/packages/server/src/sprints/status-parser.js +129 -0
- package/packages/server/src/templates/template-engine.js +197 -0
|
@@ -10,7 +10,6 @@ const os = require('os');
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const dns = require('dns');
|
|
12
12
|
const { spawn: spawnChild } = require('child_process');
|
|
13
|
-
const { v4: uuidv4 } = require('uuid');
|
|
14
13
|
const { createCachedLookup, createFailureLogger } = require('./rumen-pool-resilience');
|
|
15
14
|
|
|
16
15
|
// Conditional imports (graceful fallback if not installed yet)
|
|
@@ -29,7 +28,7 @@ try {
|
|
|
29
28
|
console.error('[db] better-sqlite3 native ABI mismatch (Node was upgraded after install).');
|
|
30
29
|
console.error('[db] TermDeck cannot serve memory features without a working SQLite.');
|
|
31
30
|
console.error('[db] Fix:');
|
|
32
|
-
|
|
31
|
+
process.stderr.write(' cd "$(npm root -g)/@jhizzard/termdeck" && npm rebuild better-sqlite3\n');
|
|
33
32
|
console.error('[db] Then restart TermDeck. Aborting.');
|
|
34
33
|
process.exit(1);
|
|
35
34
|
}
|
|
@@ -94,6 +93,13 @@ const { themes, statusColors } = require('./themes');
|
|
|
94
93
|
const { loadConfig, addProject, removeProject, updateConfig } = require('./config');
|
|
95
94
|
const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
|
|
96
95
|
const { createSprintRoutes } = require('./sprint-routes');
|
|
96
|
+
const { createSprintInjectRoutes } = require('./sprints/inject');
|
|
97
|
+
// Sprint 69 T1 — boot-prompt template engine. Exposed at the public surface
|
|
98
|
+
// so external callers (T2's inject route, integration tests, future tools)
|
|
99
|
+
// can do `require('@termdeck/server').templateEngine` instead of reaching
|
|
100
|
+
// into the internal `./templates/template-engine` path.
|
|
101
|
+
const templateEngine = require('./templates/template-engine');
|
|
102
|
+
const { createSprintNudgeRoutes } = require('./sprints/nudge');
|
|
97
103
|
const { createGraphRoutes } = require('./graph-routes');
|
|
98
104
|
const { createProjectsRoutes } = require('./projects-routes');
|
|
99
105
|
const orchestrationPreview = require('./orchestration-preview');
|
|
@@ -210,13 +216,13 @@ function _defaultSpawnSessionEndHookImpl(hookPath, payload, env) {
|
|
|
210
216
|
env,
|
|
211
217
|
});
|
|
212
218
|
child.on('error', (err) => {
|
|
213
|
-
console.error('[
|
|
219
|
+
console.error('[panel-close] hook spawn error:', err && err.message ? err.message : err);
|
|
214
220
|
});
|
|
215
221
|
try {
|
|
216
222
|
child.stdin.write(JSON.stringify(payload));
|
|
217
223
|
child.stdin.end();
|
|
218
224
|
} catch (err) {
|
|
219
|
-
console.error('[
|
|
225
|
+
console.error('[panel-close] hook stdin write failed:', err && err.message ? err.message : err);
|
|
220
226
|
}
|
|
221
227
|
child.unref();
|
|
222
228
|
return child;
|
|
@@ -292,7 +298,7 @@ async function onPanelClose(session) {
|
|
|
292
298
|
...readTermdeckSecretsForPty(),
|
|
293
299
|
});
|
|
294
300
|
} catch (err) {
|
|
295
|
-
console.error('[
|
|
301
|
+
console.error('[panel-close] error:', err && err.message ? err.message : err);
|
|
296
302
|
}
|
|
297
303
|
}
|
|
298
304
|
|
|
@@ -367,7 +373,7 @@ async function onPanelPeriodicCapture(session) {
|
|
|
367
373
|
session._periodicCapture.lastSize = stat.size;
|
|
368
374
|
session._periodicCapture.lastFireMs = Date.now();
|
|
369
375
|
} catch (err) {
|
|
370
|
-
console.error('[
|
|
376
|
+
console.error('[periodic-capture] error:', err && err.message ? err.message : err);
|
|
371
377
|
}
|
|
372
378
|
}
|
|
373
379
|
|
|
@@ -424,7 +430,7 @@ function _getT2DestFor() {
|
|
|
424
430
|
|
|
425
431
|
function _termdeckVersion() {
|
|
426
432
|
try { return require('../../../package.json').version; }
|
|
427
|
-
catch { return '0.0.0'; }
|
|
433
|
+
catch (err) { console.error('[version] package.json read failed:', err); return '0.0.0'; }
|
|
428
434
|
}
|
|
429
435
|
|
|
430
436
|
// Sprint 60 v1.0.14 (Item 3) — safe PTY resize. Brad's 2026-05-07 r730 crash
|
|
@@ -432,7 +438,7 @@ function _termdeckVersion() {
|
|
|
432
438
|
// EBADF/ENOTTY` per 13h uptime. Race: WS `resize` message arrives for a PTY
|
|
433
439
|
// that pty-reaper has already closed (or the child has exited), and
|
|
434
440
|
// `pty.resize()` ioctls a stale fd. The error is race-expected, not a bug,
|
|
435
|
-
// but the noisy
|
|
441
|
+
// but the noisy stderr trace pollutes diagnostics and obscures real
|
|
436
442
|
// errors. This helper guards against the race and downgrades the known
|
|
437
443
|
// race-class errors (EBADF, ENOTTY) to a silent return. Set
|
|
438
444
|
// TERMDECK_DEBUG_PTY_RACES=1 to log to console.debug for diagnostics.
|
|
@@ -1457,7 +1463,7 @@ function createServer(config) {
|
|
|
1457
1463
|
session._periodicCapture = { lastSize: 0, lastFireMs: 0, timer: null };
|
|
1458
1464
|
session._periodicCapture.timer = setInterval(() => {
|
|
1459
1465
|
onPanelPeriodicCapture(session).catch((err) => {
|
|
1460
|
-
console.error('[
|
|
1466
|
+
console.error('[periodic-capture] async error:', err && err.message ? err.message : err);
|
|
1461
1467
|
});
|
|
1462
1468
|
}, intervalMs);
|
|
1463
1469
|
// Don't keep the event loop alive solely for this timer — the PTY
|
|
@@ -1549,7 +1555,7 @@ function createServer(config) {
|
|
|
1549
1555
|
// skip-claude + skip-when-no-transcript. Fire-and-forget; any
|
|
1550
1556
|
// error logs and never blocks teardown.
|
|
1551
1557
|
onPanelClose(session).catch((err) => {
|
|
1552
|
-
console.error('[
|
|
1558
|
+
console.error('[panel-close] async error:', err && err.message ? err.message : err);
|
|
1553
1559
|
});
|
|
1554
1560
|
|
|
1555
1561
|
// Sprint 59 T4-CODEX UPLOAD-AUDIT-CONCERN closure: blow away the
|
|
@@ -1735,6 +1741,14 @@ function createServer(config) {
|
|
|
1735
1741
|
spawnTerminalSession,
|
|
1736
1742
|
getSession: (id) => sessions.get(id),
|
|
1737
1743
|
});
|
|
1744
|
+
createSprintInjectRoutes({
|
|
1745
|
+
app,
|
|
1746
|
+
getSession: (id) => sessions.get(id),
|
|
1747
|
+
});
|
|
1748
|
+
createSprintNudgeRoutes({
|
|
1749
|
+
app,
|
|
1750
|
+
getSession: (id) => sessions.get(id),
|
|
1751
|
+
});
|
|
1738
1752
|
|
|
1739
1753
|
// Graph endpoints (Sprint 38 T4) — knowledge-graph view backing graph.html.
|
|
1740
1754
|
// Reuses the daily-driver pg pool (same DATABASE_URL serves memory_items +
|
|
@@ -1754,7 +1768,19 @@ function createServer(config) {
|
|
|
1754
1768
|
|
|
1755
1769
|
// PATCH /api/sessions/:id - update session metadata
|
|
1756
1770
|
app.patch('/api/sessions/:id', (req, res) => {
|
|
1757
|
-
|
|
1771
|
+
// Sprint 66 T1 (Task 1.2) — `role` is PATCH-mutable so an operator can tag
|
|
1772
|
+
// a live panel as orchestrator in place. Validate it exactly as POST
|
|
1773
|
+
// /api/sessions does (index.js — the `invalid_role` 400 above): an absent
|
|
1774
|
+
// field is fine, any present value must be in ALLOWED_SESSION_ROLES
|
|
1775
|
+
// (orchestrator/worker/reviewer/auditor/null) — an unknown value is a 400
|
|
1776
|
+
// so a typo surfaces immediately rather than silently mis-tagging the
|
|
1777
|
+
// panel. Validation runs BEFORE updateMeta so a bad role never reaches the
|
|
1778
|
+
// whitelist apply or the SQLite write.
|
|
1779
|
+
const body = req.body || {};
|
|
1780
|
+
if (body.role !== undefined && !ALLOWED_SESSION_ROLES.includes(body.role)) {
|
|
1781
|
+
return res.status(400).json({ ok: false, code: 'invalid_role', allowed: ALLOWED_SESSION_ROLES });
|
|
1782
|
+
}
|
|
1783
|
+
const session = sessions.updateMeta(req.params.id, body);
|
|
1758
1784
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
1759
1785
|
res.json(session.toJSON());
|
|
1760
1786
|
});
|
|
@@ -2023,7 +2049,7 @@ function createServer(config) {
|
|
|
2023
2049
|
return res.status(410).json({ error: 'PTY is gone (session exited)' });
|
|
2024
2050
|
}
|
|
2025
2051
|
|
|
2026
|
-
const { cols, rows } = req.body;
|
|
2052
|
+
const { cols, rows } = req.body || {};
|
|
2027
2053
|
try {
|
|
2028
2054
|
const resized = safelyResizePty(session, cols, rows);
|
|
2029
2055
|
if (!resized) {
|
|
@@ -2603,7 +2629,7 @@ function createServer(config) {
|
|
|
2603
2629
|
|
|
2604
2630
|
// POST /api/ai/query - query Mnestra memory via the bridge (direct|webhook|mcp)
|
|
2605
2631
|
app.post('/api/ai/query', async (req, res) => {
|
|
2606
|
-
let { question, sessionId, project } = req.body;
|
|
2632
|
+
let { question, sessionId, project } = req.body || {};
|
|
2607
2633
|
if (!question) return res.status(400).json({ error: 'Missing question' });
|
|
2608
2634
|
|
|
2609
2635
|
let searchAll = false;
|
|
@@ -2758,8 +2784,9 @@ function createServer(config) {
|
|
|
2758
2784
|
});
|
|
2759
2785
|
}, 2000);
|
|
2760
2786
|
|
|
2761
|
-
// Fallback route → serve index.html
|
|
2762
|
-
|
|
2787
|
+
// Fallback route → serve index.html. Express 5: named wildcard '/{*splat}'
|
|
2788
|
+
// (path-to-regexp v8 — a bare '*' throws at registration; this matches all paths incl. root).
|
|
2789
|
+
app.get('/{*splat}', (req, res) => {
|
|
2763
2790
|
res.sendFile(path.join(clientDir, 'index.html'));
|
|
2764
2791
|
});
|
|
2765
2792
|
|
|
@@ -3050,6 +3077,10 @@ module.exports = {
|
|
|
3050
3077
|
SECRETS_EXCLUDED_FROM_PTY,
|
|
3051
3078
|
// Sprint 65 T2 (2.1) — operator-role whitelist, exported for the route fence.
|
|
3052
3079
|
ALLOWED_SESSION_ROLES,
|
|
3080
|
+
// Sprint 69 T1 — boot-prompt template engine. Exported so T2's inject
|
|
3081
|
+
// endpoint and integration tests can import without traversing the
|
|
3082
|
+
// internal `./templates/template-engine` path.
|
|
3083
|
+
templateEngine,
|
|
3053
3084
|
// Sprint 50 T1 — exported for unit testing the per-agent SessionEnd
|
|
3054
3085
|
// hook trigger (skip-claude, no-transcript, no-hook-installed,
|
|
3055
3086
|
// payload shape, fire-and-forget).
|
|
@@ -187,7 +187,7 @@ async function generateScaffolding({ name, projects, cwd, force, initProject, te
|
|
|
187
187
|
// Refuse on existing non-empty dir without force, mirroring T2's CLI semantics.
|
|
188
188
|
if (fs.existsSync(targetPath)) {
|
|
189
189
|
const entries = (() => {
|
|
190
|
-
try { return fs.readdirSync(targetPath); } catch { return []; }
|
|
190
|
+
try { return fs.readdirSync(targetPath); } catch (err) { console.error('[orch-preview] readdir failed:', err); return []; }
|
|
191
191
|
})();
|
|
192
192
|
const nonEmpty = entries.length > 0;
|
|
193
193
|
if (nonEmpty && !force) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parked-lane detection for TermDeck.
|
|
3
|
+
*
|
|
4
|
+
* Algorithm:
|
|
5
|
+
* - If session status is not "active", it's not parked (it might be thinking, editing, or exited).
|
|
6
|
+
* - If lastActivity was within the last 5 minutes, it's genuinely active.
|
|
7
|
+
* - Otherwise, parse the trailing output buffer for Claude Code's completion banners.
|
|
8
|
+
* - If matched, it's parked.
|
|
9
|
+
*
|
|
10
|
+
* Completion banners (Claude Code):
|
|
11
|
+
* - "Cogitated for 1m 2s"
|
|
12
|
+
* - "Churned for 5m 10s"
|
|
13
|
+
* - Verbs: Cogitated, Churned, Brewed, Cooked, Mused, Pondered, Wandered, Crafted.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { stripAnsi } = require('./transcripts');
|
|
17
|
+
|
|
18
|
+
function detectParked(session) {
|
|
19
|
+
if (!session || !session.meta) return false;
|
|
20
|
+
|
|
21
|
+
// Only "active" (PTY-wise) sessions can be "parked" (semantic-wise).
|
|
22
|
+
// "thinking" or "editing" statuses are already semantic indicators
|
|
23
|
+
// of work-in-progress.
|
|
24
|
+
if (session.meta.status !== 'active') return false;
|
|
25
|
+
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const lastActivity = new Date(session.meta.lastActivity).getTime();
|
|
28
|
+
const ageMs = now - lastActivity;
|
|
29
|
+
|
|
30
|
+
// Threshold: 5 minutes.
|
|
31
|
+
const FIVE_MIN_MS = 5 * 60 * 1000;
|
|
32
|
+
if (ageMs < FIVE_MIN_MS) return false;
|
|
33
|
+
|
|
34
|
+
// Read the session's output buffer (last ~4KB preserved in Session.analyzeOutput).
|
|
35
|
+
const buffer = session._outputBuffer || '';
|
|
36
|
+
if (!buffer) return false;
|
|
37
|
+
|
|
38
|
+
// Strip ANSI to match the plain-text banner
|
|
39
|
+
const cleanBuffer = stripAnsi(buffer);
|
|
40
|
+
|
|
41
|
+
// Regex per BRIEF + PLANNING:
|
|
42
|
+
// (Cogitated|Churned|Brewed|Cooked|Mused|Pondered|Wandered|Crafted) for \d+m \d+s
|
|
43
|
+
const PARKED_BANNER_RE = /(?:Cogitated|Churned|Brewed|Cooked|Mused|Pondered|Wandered|Crafted) for \d+m \d+s/i;
|
|
44
|
+
|
|
45
|
+
// Look in the last ~1000 chars of the cleaned buffer.
|
|
46
|
+
const tail = cleanBuffer.slice(-1000);
|
|
47
|
+
return PARKED_BANNER_RE.test(tail);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { detectParked };
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// metadata broadcast in index.js untouched — `s.meta.theme` already returns
|
|
11
11
|
// the right thing whenever index.js dereferences it.
|
|
12
12
|
|
|
13
|
-
const {
|
|
13
|
+
const { randomUUID } = require('crypto');
|
|
14
14
|
const os = require('os');
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { resolveTheme } = require('./theme-resolver');
|
|
@@ -18,6 +18,8 @@ const flashbackDiag = require('./flashback-diag');
|
|
|
18
18
|
const geminiAdapter = require('./agent-adapters/gemini');
|
|
19
19
|
const { detectAdapter, getAdapterForSessionType } = require('./agent-adapters');
|
|
20
20
|
|
|
21
|
+
const { detectParked } = require("./parked-detection");
|
|
22
|
+
|
|
21
23
|
// Strip ANSI escape codes for pattern matching
|
|
22
24
|
function stripAnsi(str) {
|
|
23
25
|
return str
|
|
@@ -135,7 +137,7 @@ const PATTERNS = {
|
|
|
135
137
|
|
|
136
138
|
class Session {
|
|
137
139
|
constructor(options) {
|
|
138
|
-
this.id = options.id ||
|
|
140
|
+
this.id = options.id || randomUUID();
|
|
139
141
|
this.pid = null;
|
|
140
142
|
this.pty = null;
|
|
141
143
|
this.ws = null;
|
|
@@ -540,8 +542,10 @@ class Session {
|
|
|
540
542
|
if (ageMs > Session.STALE_STATUS_THRESHOLD_MS) {
|
|
541
543
|
meta.status = 'idle';
|
|
542
544
|
meta.statusDetail = '';
|
|
545
|
+
meta.parked = true;
|
|
543
546
|
}
|
|
544
547
|
}
|
|
548
|
+
meta.parked = detectParked(this);
|
|
545
549
|
return {
|
|
546
550
|
id: this.id,
|
|
547
551
|
pid: this.pid,
|
|
@@ -634,7 +638,15 @@ class SessionManager {
|
|
|
634
638
|
'label',
|
|
635
639
|
'project',
|
|
636
640
|
'ragEnabled',
|
|
637
|
-
'flashbackEnabled'
|
|
641
|
+
'flashbackEnabled',
|
|
642
|
+
// Sprint 66 T1 (Task 1.2) — `role` is now PATCH-mutable so an operator can
|
|
643
|
+
// tag a live panel as orchestrator in place (Brad's existing orch panel
|
|
644
|
+
// was spawned with no role and had no way to set one short of a raw-API
|
|
645
|
+
// destroy + recreate). The PATCH /api/sessions/:id route validates the
|
|
646
|
+
// value against ALLOWED_SESSION_ROLES before this whitelist is consulted —
|
|
647
|
+
// the same "route validates, model trusts" boundary as POST /api/sessions
|
|
648
|
+
// and the Session constructor.
|
|
649
|
+
'role'
|
|
638
650
|
]);
|
|
639
651
|
|
|
640
652
|
updateMeta(id, updates) {
|
|
@@ -657,6 +669,16 @@ class SessionManager {
|
|
|
657
669
|
.run(applied.theme == null ? null : applied.theme, id);
|
|
658
670
|
}
|
|
659
671
|
|
|
672
|
+
// Sprint 66 T1 (Task 1.2) — persist a role change to SQLite so a panel
|
|
673
|
+
// tagged orchestrator via PATCH keeps the role across a server restart /
|
|
674
|
+
// dashboard reload, exactly as a spawn-time role does. create() writes the
|
|
675
|
+
// `role` column on INSERT; this is its UPDATE counterpart. The column was
|
|
676
|
+
// added by Sprint 65 T2 (CREATE TABLE + a PRAGMA-guarded ALTER migration).
|
|
677
|
+
if ('role' in applied && this.db) {
|
|
678
|
+
this.db.prepare('UPDATE sessions SET role = ? WHERE id = ?')
|
|
679
|
+
.run(applied.role == null ? null : applied.role, id);
|
|
680
|
+
}
|
|
681
|
+
|
|
660
682
|
this._emit('session:updated', session);
|
|
661
683
|
return session;
|
|
662
684
|
}
|
|
@@ -233,7 +233,7 @@ async function injectSprintPrompts({
|
|
|
233
233
|
} else {
|
|
234
234
|
anyPending = true;
|
|
235
235
|
}
|
|
236
|
-
} catch {
|
|
236
|
+
} catch (_err) {
|
|
237
237
|
anyPending = true;
|
|
238
238
|
}
|
|
239
239
|
}
|
|
@@ -257,7 +257,7 @@ async function injectSprintPrompts({
|
|
|
257
257
|
const s = await getStatus(lane.sessionId);
|
|
258
258
|
lane.finalStatus = s && s.status ? s.status : lane.finalStatus;
|
|
259
259
|
if (lane.finalStatus === 'thinking') lane.verified = true;
|
|
260
|
-
} catch {
|
|
260
|
+
} catch (_err) {
|
|
261
261
|
// ignore
|
|
262
262
|
}
|
|
263
263
|
}
|
|
@@ -22,6 +22,7 @@ const os = require('os');
|
|
|
22
22
|
const { execFileSync } = require('child_process');
|
|
23
23
|
|
|
24
24
|
const { injectSprintPrompts } = require('./sprint-inject');
|
|
25
|
+
const { parseStatusMd: parseStatusMdV2 } = require('./sprints/status-parser');
|
|
25
26
|
|
|
26
27
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,40}$/;
|
|
27
28
|
|
|
@@ -428,6 +429,22 @@ function createSprintRoutes({ app, config, spawnTerminalSession, getSession }) {
|
|
|
428
429
|
res.json({ project, sprints });
|
|
429
430
|
});
|
|
430
431
|
|
|
432
|
+
|
|
433
|
+
// GET /api/sprints/status?file=<path> — new structured parser (Sprint 69 T3)
|
|
434
|
+
app.get('/api/sprints/status', (req, res) => {
|
|
435
|
+
const filePath = req.query.file;
|
|
436
|
+
if (!filePath) return res.status(400).json({ error: 'file query param required' });
|
|
437
|
+
if (!fs.existsSync(filePath)) {
|
|
438
|
+
return res.status(404).json({ error: `file not found: ${filePath}` });
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
const parsed = parseStatusMdV2(filePath);
|
|
442
|
+
res.json(parsed);
|
|
443
|
+
} catch (err) {
|
|
444
|
+
res.status(500).json({ error: `parse failed: ${err.message}` });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
431
448
|
// GET /api/sprints/:name/status?project=foo — parse STATUS.md per-lane.
|
|
432
449
|
app.get('/api/sprints/:name/status', (req, res) => {
|
|
433
450
|
const project = req.query.project;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SUBMIT_OPTIONS = {
|
|
4
|
+
gapMs: 250,
|
|
5
|
+
settleMs: 400,
|
|
6
|
+
snapshotDelayMs: 5000,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const ALLOWED_ROLES = new Set(['worker', 'auditor', 'orchestrator']);
|
|
10
|
+
|
|
11
|
+
class SprintRequestError extends Error {
|
|
12
|
+
constructor(message, statusCode = 400, details = {}) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'SprintRequestError';
|
|
15
|
+
this.statusCode = statusCode;
|
|
16
|
+
this.details = details;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function defaultSleep(ms) {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isPlainObject(value) {
|
|
25
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeCliType(type) {
|
|
29
|
+
if (type === 'claude') return 'claude-code';
|
|
30
|
+
return type || 'shell';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function validateInjectBody(body) {
|
|
34
|
+
const input = isPlainObject(body) ? body : {};
|
|
35
|
+
if (!Array.isArray(input.panels) || input.panels.length === 0) {
|
|
36
|
+
throw new SprintRequestError('panels must be a non-empty array');
|
|
37
|
+
}
|
|
38
|
+
if (!isPlainObject(input.variables)) {
|
|
39
|
+
throw new SprintRequestError('variables must be an object');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const panels = input.panels.map((panel, index) => {
|
|
43
|
+
if (!isPlainObject(panel)) {
|
|
44
|
+
throw new SprintRequestError(`panels[${index}] must be an object`);
|
|
45
|
+
}
|
|
46
|
+
const tag = typeof panel.tag === 'string' ? panel.tag.trim() : '';
|
|
47
|
+
const sessionId = typeof panel.sessionId === 'string' ? panel.sessionId.trim() : '';
|
|
48
|
+
const role = typeof panel.role === 'string' ? panel.role.trim() : '';
|
|
49
|
+
const laneBrief = typeof panel.lane_brief === 'string' ? panel.lane_brief.trim() : '';
|
|
50
|
+
|
|
51
|
+
if (!tag) throw new SprintRequestError(`panels[${index}].tag is required`);
|
|
52
|
+
if (!sessionId) throw new SprintRequestError(`panels[${index}].sessionId is required`);
|
|
53
|
+
if (!ALLOWED_ROLES.has(role)) {
|
|
54
|
+
throw new SprintRequestError(
|
|
55
|
+
`panels[${index}].role must be one of: ${Array.from(ALLOWED_ROLES).join(', ')}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (!laneBrief) throw new SprintRequestError(`panels[${index}].lane_brief is required`);
|
|
59
|
+
|
|
60
|
+
return { tag, sessionId, role, lane_brief: laneBrief };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return { panels, variables: { ...input.variables } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolvePanelSessions(panels, getSession) {
|
|
67
|
+
if (typeof getSession !== 'function') {
|
|
68
|
+
throw new Error('getSession(sessionId) callback required');
|
|
69
|
+
}
|
|
70
|
+
return panels.map((panel) => {
|
|
71
|
+
const session = getSession(panel.sessionId);
|
|
72
|
+
if (!session) {
|
|
73
|
+
throw new SprintRequestError(`session not found: ${panel.sessionId}`, 400, {
|
|
74
|
+
code: 'invalid_session',
|
|
75
|
+
tag: panel.tag,
|
|
76
|
+
sessionId: panel.sessionId,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return session;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function defaultLoadTemplate(cliType, role, variables) {
|
|
84
|
+
// T1 owns this module in Sprint 69. Resolve lazily so T2's route can load
|
|
85
|
+
// before T1's engine has landed; endpoint calls surface a clear error.
|
|
86
|
+
const engine = require('../templates/template-engine');
|
|
87
|
+
if (!engine || typeof engine.loadTemplate !== 'function') {
|
|
88
|
+
throw new Error('template-engine must export loadTemplate(cliType, role, variables)');
|
|
89
|
+
}
|
|
90
|
+
return engine.loadTemplate(cliType, role, variables);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeMissingVariables(err) {
|
|
94
|
+
if (!err) return [];
|
|
95
|
+
const raw =
|
|
96
|
+
err.missingVariables
|
|
97
|
+
|| err.missing_variables
|
|
98
|
+
|| err.variables
|
|
99
|
+
|| err.variableNames
|
|
100
|
+
|| err.missing;
|
|
101
|
+
if (Array.isArray(raw)) return raw.map(String);
|
|
102
|
+
if (typeof raw === 'string' && raw) return [raw];
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function mapTemplateError(err) {
|
|
107
|
+
const message = err && err.message ? err.message : String(err);
|
|
108
|
+
const missingVariables = normalizeMissingVariables(err);
|
|
109
|
+
const lower = message.toLowerCase();
|
|
110
|
+
const name = err && err.name;
|
|
111
|
+
const code = err && err.code;
|
|
112
|
+
|
|
113
|
+
if (code === 'MODULE_NOT_FOUND' && /template-engine/.test(message)) {
|
|
114
|
+
return new SprintRequestError('template engine unavailable', 503, {
|
|
115
|
+
code: 'template_engine_unavailable',
|
|
116
|
+
detail: message,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
name === 'MissingVariableError'
|
|
122
|
+
|| code === 'missing_variable'
|
|
123
|
+
|| code === 'missing_variables'
|
|
124
|
+
|| missingVariables.length > 0
|
|
125
|
+
|| (lower.includes('missing') && lower.includes('variable'))
|
|
126
|
+
) {
|
|
127
|
+
return new SprintRequestError(message, 400, {
|
|
128
|
+
code: 'missing_template_variables',
|
|
129
|
+
missingVariables,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (
|
|
134
|
+
code === 'unknown_template'
|
|
135
|
+
|| code === 'unknown_cli_type'
|
|
136
|
+
|| code === 'unknown_role'
|
|
137
|
+
|| lower.includes('unknown template')
|
|
138
|
+
|| lower.includes('unknown cli')
|
|
139
|
+
|| lower.includes('unknown role')
|
|
140
|
+
|| lower.includes('template not found')
|
|
141
|
+
) {
|
|
142
|
+
return new SprintRequestError(message, 400, { code: 'template_error' });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return err;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function renderInjectPanels({ panels, variables, sessions, loadTemplate }) {
|
|
149
|
+
const loader = loadTemplate || defaultLoadTemplate;
|
|
150
|
+
const rendered = [];
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < panels.length; i++) {
|
|
153
|
+
const panel = panels[i];
|
|
154
|
+
const session = sessions[i];
|
|
155
|
+
const cliType = normalizeCliType(session && session.meta && session.meta.type);
|
|
156
|
+
const templateVars = {
|
|
157
|
+
...variables,
|
|
158
|
+
lane_brief: panel.lane_brief,
|
|
159
|
+
lane_tag: panel.tag,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const text = await Promise.resolve(loader(cliType, panel.role, templateVars));
|
|
164
|
+
if (typeof text !== 'string') {
|
|
165
|
+
throw new Error('loadTemplate must return a string');
|
|
166
|
+
}
|
|
167
|
+
rendered.push({ ...panel, cliType, text });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
throw mapTemplateError(err);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return rendered;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function sessionSnapshot(panel, session) {
|
|
177
|
+
const meta = (session && session.meta) || {};
|
|
178
|
+
return {
|
|
179
|
+
tag: panel.tag,
|
|
180
|
+
sessionId: panel.sessionId,
|
|
181
|
+
status: meta.status || null,
|
|
182
|
+
statusDetail: meta.statusDetail || '',
|
|
183
|
+
lastActivity: meta.lastActivity || null,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function createDefaultWriteInput(getSession) {
|
|
188
|
+
return async ({ sessionId, text }) => {
|
|
189
|
+
const session = getSession(sessionId);
|
|
190
|
+
if (!session) {
|
|
191
|
+
throw new SprintRequestError(`session not found: ${sessionId}`, 400, {
|
|
192
|
+
code: 'invalid_session',
|
|
193
|
+
sessionId,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (!session.pty || (session.meta && session.meta.status === 'exited')) {
|
|
197
|
+
throw new SprintRequestError(`Panel ${sessionId} has exited`, 410, {
|
|
198
|
+
code: 'panel_exited',
|
|
199
|
+
sessionId,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
session.pty.write(text);
|
|
204
|
+
if (typeof session.trackInput === 'function') session.trackInput(text);
|
|
205
|
+
session.meta.replyCount = (session.meta.replyCount || 0) + 1;
|
|
206
|
+
return { ok: true, bytes: text.length, replyCount: session.meta.replyCount };
|
|
207
|
+
} catch (err) {
|
|
208
|
+
throw new SprintRequestError(err && err.message ? err.message : String(err), 500, {
|
|
209
|
+
code: 'write_failed',
|
|
210
|
+
sessionId,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function runTwoStageSubmit({
|
|
217
|
+
panels,
|
|
218
|
+
getSession,
|
|
219
|
+
writeInput,
|
|
220
|
+
sleep,
|
|
221
|
+
options,
|
|
222
|
+
source,
|
|
223
|
+
}) {
|
|
224
|
+
const opts = { ...DEFAULT_SUBMIT_OPTIONS, ...(options || {}) };
|
|
225
|
+
const wait = sleep || defaultSleep;
|
|
226
|
+
const write = writeInput || createDefaultWriteInput(getSession);
|
|
227
|
+
|
|
228
|
+
if (!Array.isArray(panels) || panels.length === 0) {
|
|
229
|
+
throw new SprintRequestError('panels must be a non-empty array');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (let i = 0; i < panels.length; i++) {
|
|
233
|
+
const panel = panels[i];
|
|
234
|
+
await write({
|
|
235
|
+
sessionId: panel.sessionId,
|
|
236
|
+
text: `\x1b[200~${panel.text}\x1b[201~`,
|
|
237
|
+
source: source || 'sprint',
|
|
238
|
+
stage: 'paste',
|
|
239
|
+
panel,
|
|
240
|
+
});
|
|
241
|
+
if (i < panels.length - 1) await wait(opts.gapMs);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await wait(opts.settleMs);
|
|
245
|
+
|
|
246
|
+
for (let i = 0; i < panels.length; i++) {
|
|
247
|
+
const panel = panels[i];
|
|
248
|
+
await write({
|
|
249
|
+
sessionId: panel.sessionId,
|
|
250
|
+
text: '\r',
|
|
251
|
+
source: source || 'sprint',
|
|
252
|
+
stage: 'submit',
|
|
253
|
+
panel,
|
|
254
|
+
});
|
|
255
|
+
if (i < panels.length - 1) await wait(opts.gapMs);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (opts.snapshotDelayMs > 0) await wait(opts.snapshotDelayMs);
|
|
259
|
+
|
|
260
|
+
return panels.map((panel) => sessionSnapshot(panel, getSession(panel.sessionId)));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function sendError(res, err) {
|
|
264
|
+
const mapped = err instanceof SprintRequestError ? err : new SprintRequestError(
|
|
265
|
+
err && err.message ? err.message : String(err),
|
|
266
|
+
err && err.statusCode ? err.statusCode : 500,
|
|
267
|
+
err && err.details ? err.details : {},
|
|
268
|
+
);
|
|
269
|
+
return res.status(mapped.statusCode).json({
|
|
270
|
+
ok: false,
|
|
271
|
+
error: mapped.message,
|
|
272
|
+
...(mapped.details || {}),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function createSprintInjectHandler({ getSession, loadTemplate, writeInput, sleep, options } = {}) {
|
|
277
|
+
return async (req, res) => {
|
|
278
|
+
let parsed;
|
|
279
|
+
let sessions;
|
|
280
|
+
try {
|
|
281
|
+
parsed = validateInjectBody(req.body || {});
|
|
282
|
+
sessions = resolvePanelSessions(parsed.panels, getSession);
|
|
283
|
+
const rendered = await renderInjectPanels({
|
|
284
|
+
panels: parsed.panels,
|
|
285
|
+
variables: parsed.variables,
|
|
286
|
+
sessions,
|
|
287
|
+
loadTemplate,
|
|
288
|
+
});
|
|
289
|
+
const snapshots = await runTwoStageSubmit({
|
|
290
|
+
panels: rendered,
|
|
291
|
+
getSession,
|
|
292
|
+
writeInput,
|
|
293
|
+
sleep,
|
|
294
|
+
options,
|
|
295
|
+
source: 'sprint-inject',
|
|
296
|
+
});
|
|
297
|
+
return res.json({ ok: true, panels: snapshots });
|
|
298
|
+
} catch (err) {
|
|
299
|
+
return sendError(res, err);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function createSprintInjectRoutes(opts) {
|
|
305
|
+
if (!opts || !opts.app) throw new Error('app required');
|
|
306
|
+
opts.app.post('/api/sprints/inject', createSprintInjectHandler(opts));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = {
|
|
310
|
+
ALLOWED_ROLES,
|
|
311
|
+
DEFAULT_SUBMIT_OPTIONS,
|
|
312
|
+
SprintRequestError,
|
|
313
|
+
createDefaultWriteInput,
|
|
314
|
+
createSprintInjectHandler,
|
|
315
|
+
createSprintInjectRoutes,
|
|
316
|
+
defaultLoadTemplate,
|
|
317
|
+
normalizeCliType,
|
|
318
|
+
renderInjectPanels,
|
|
319
|
+
resolvePanelSessions,
|
|
320
|
+
runTwoStageSubmit,
|
|
321
|
+
sendError,
|
|
322
|
+
validateInjectBody,
|
|
323
|
+
};
|