@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.
@@ -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
- console.error(' cd "$(npm root -g)/@jhizzard/termdeck" && npm rebuild better-sqlite3');
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('[onPanelClose] hook spawn error:', err && err.message ? err.message : err);
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('[onPanelClose] hook stdin write failed:', err && err.message ? err.message : err);
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('[onPanelClose] error:', err && err.message ? err.message : err);
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('[onPanelPeriodicCapture] error:', err && err.message ? err.message : err);
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 console.error trace pollutes diagnostics and obscures real
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('[onPanelPeriodicCapture] async error:', err && err.message ? err.message : err);
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('[onPanelClose] async error:', err && err.message ? err.message : err);
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
- const session = sessions.updateMeta(req.params.id, req.body);
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
- app.get('*', (req, res) => {
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 { v4: uuidv4 } = require('uuid');
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 || uuidv4();
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
+ };