@jhizzard/termdeck 0.10.3 → 0.11.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.
@@ -298,6 +298,54 @@ function addProject({ name, path: projectPath, defaultTheme, defaultCommand }) {
298
298
  return parsed.projects;
299
299
  }
300
300
 
301
+ // Remove a project from ~/.termdeck/config.yaml and return the updated projects
302
+ // map. Mirrors addProject for the inverse operation. Throws ENOENT-shaped
303
+ // errors with `code` set so callers can map cleanly to HTTP status. Files on
304
+ // disk at the project's `path` are NEVER touched — this only edits the YAML
305
+ // entry. The user retains all source code.
306
+ function removeProject(name, configPath = CONFIG_PATH) {
307
+ if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
308
+ const err = new Error('Project name must be non-empty and contain only letters, digits, . _ or -');
309
+ err.code = 'BAD_NAME';
310
+ throw err;
311
+ }
312
+
313
+ const yaml = require('yaml');
314
+ let parsed = {};
315
+ if (fs.existsSync(configPath)) {
316
+ const raw = fs.readFileSync(configPath, 'utf-8');
317
+ try {
318
+ parsed = yaml.parse(raw) || {};
319
+ } catch (err) {
320
+ throw new Error(`config.yaml is not valid YAML — cannot safely rewrite: ${err.message}`);
321
+ }
322
+ }
323
+
324
+ if (!parsed.projects || typeof parsed.projects !== 'object' || !parsed.projects[name]) {
325
+ const err = new Error(`Project "${name}" not found in config.yaml`);
326
+ err.code = 'NOT_FOUND';
327
+ throw err;
328
+ }
329
+
330
+ delete parsed.projects[name];
331
+
332
+ if (fs.existsSync(configPath)) {
333
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
334
+ const bak = `${configPath}.${ts}.bak`;
335
+ try {
336
+ fs.copyFileSync(configPath, bak);
337
+ } catch (err) {
338
+ console.warn('[config] Could not write backup before removing project:', err.message);
339
+ }
340
+ }
341
+
342
+ const out = yaml.stringify(parsed);
343
+ fs.writeFileSync(configPath, out, 'utf-8');
344
+ console.log(`[config] Removed project "${name}" (files on disk untouched)`);
345
+
346
+ return parsed.projects;
347
+ }
348
+
301
349
  // Apply a structural patch to ~/.termdeck/config.yaml. Sprint 36 introduces
302
350
  // this for the dashboard RAG toggle (PATCH /api/config) but the helper is
303
351
  // generic — pass a deep partial of the config tree, every leaf in `patch` that
@@ -394,6 +442,7 @@ function updateConfig(patch, configPath = CONFIG_PATH) {
394
442
  module.exports = {
395
443
  loadConfig,
396
444
  addProject,
445
+ removeProject,
397
446
  updateConfig,
398
447
  // exported for tests / introspection
399
448
  _parseDotenv: parseDotenv,
@@ -62,11 +62,13 @@ const { TranscriptWriter } = require('./transcripts');
62
62
  const { createHealthHandler, runPreflight } = require('./preflight');
63
63
  const { getFullHealth } = require('./health');
64
64
  const { themes, statusColors } = require('./themes');
65
- const { loadConfig, addProject, updateConfig } = require('./config');
65
+ const { loadConfig, addProject, removeProject, updateConfig } = require('./config');
66
66
  const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
67
67
  const { createSprintRoutes } = require('./sprint-routes');
68
68
  const { createGraphRoutes } = require('./graph-routes');
69
+ const { createProjectsRoutes } = require('./projects-routes');
69
70
  const orchestrationPreview = require('./orchestration-preview');
71
+ const { createPtyReaper } = require('./pty-reaper');
70
72
 
71
73
  // Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
72
74
  // helper is decoupled from T2's templates.js / init-project.js; we resolve
@@ -167,6 +169,33 @@ function createServer(config) {
167
169
  // Initialize session manager
168
170
  const sessions = new SessionManager(db);
169
171
 
172
+ // PTY orphan reaper (Sprint 42 T2). Periodically walks the live process
173
+ // tree, tracks descendants of each session's shell PTY, and SIGTERMs any
174
+ // that survive the leader's death — closing the kern.tty.ptmx_max leak
175
+ // path that bit Joshua on 2026-04-28 (forkpty: Device not configured).
176
+ // Skipped when node-pty is unavailable (no PTYs to reap) and when the
177
+ // explicit kill switch is set (tests / opt-out).
178
+ const ptyReaperEnabled = pty
179
+ && process.env.TERMDECK_PTY_REAPER !== 'off'
180
+ && config.ptyReaper?.enabled !== false;
181
+ const ptyReaperIntervalMs = Number.parseInt(
182
+ process.env.TERMDECK_PTY_REAPER_INTERVAL_MS
183
+ || config.ptyReaper?.intervalMs
184
+ || 30000,
185
+ 10
186
+ );
187
+ const ptyReaper = ptyReaperEnabled
188
+ ? createPtyReaper({ sessions, intervalMs: ptyReaperIntervalMs })
189
+ : null;
190
+ if (ptyReaper) {
191
+ ptyReaper.start();
192
+ console.log(`[pty-reaper] enabled (interval ${ptyReaperIntervalMs}ms)`);
193
+ } else if (!pty) {
194
+ console.log('[pty-reaper] disabled (node-pty unavailable)');
195
+ } else {
196
+ console.log('[pty-reaper] disabled by config');
197
+ }
198
+
170
199
  // Initialize RAG + Mnestra bridge
171
200
  const rag = new RAGIntegration(config, db);
172
201
  const mnestraBridge = createBridge(config);
@@ -1262,20 +1291,29 @@ function createServer(config) {
1262
1291
  res.json(payload);
1263
1292
  });
1264
1293
 
1265
- // POST /api/projects - add a new project on the fly, persist to config.yaml
1266
- // Body: { name, path, defaultTheme?, defaultCommand? }
1267
- // Updates both the on-disk config.yaml and the in-memory config so new
1268
- // sessions can select the project immediately without a server restart.
1269
- app.post('/api/projects', (req, res) => {
1270
- const { name, path: projectPath, defaultTheme, defaultCommand } = req.body || {};
1271
- try {
1272
- const updatedProjects = addProject({ name, path: projectPath, defaultTheme, defaultCommand });
1273
- config.projects = updatedProjects;
1274
- res.json({ ok: true, projects: updatedProjects });
1275
- } catch (err) {
1276
- console.error('[config] addProject failed:', err.message);
1277
- res.status(400).json({ error: err.message });
1278
- }
1294
+ // POST /api/projects (add) + DELETE /api/projects/:name (remove) Sprint 42
1295
+ // T4 extracted both into projects-routes.js so tests can drive them without
1296
+ // bootstrapping the full server. Sessions are passed via getSessions() so
1297
+ // DELETE can enforce the 409 live-PTY guard. Files on disk at the project's
1298
+ // `path` are NEVER touched by remove — only the YAML entry is rewritten.
1299
+ createProjectsRoutes({
1300
+ app,
1301
+ config,
1302
+ getSessions: () => sessions.getAll(),
1303
+ addProject,
1304
+ removeProject,
1305
+ broadcast: (payload) => {
1306
+ try {
1307
+ const wsPayload = JSON.stringify(payload);
1308
+ wss.clients.forEach((client) => {
1309
+ if (client.readyState === 1) {
1310
+ try { client.send(wsPayload); } catch (err) { console.error('[ws] projects_changed send failed:', err); }
1311
+ }
1312
+ });
1313
+ } catch (err) {
1314
+ console.error('[ws] projects_changed broadcast failed:', err);
1315
+ }
1316
+ },
1279
1317
  });
1280
1318
 
1281
1319
  // GET /api/projects/:name/orchestration-preview — Sprint 37 T3.
@@ -1404,6 +1442,20 @@ function createServer(config) {
1404
1442
  res.json({ count: events.length, events });
1405
1443
  });
1406
1444
 
1445
+ // GET /api/pty-reaper/status — Sprint 42 T2 observability surface.
1446
+ // Returns the live registry (per-session PTY pid + tracked descendants) and
1447
+ // the reaped-history ring buffer so heavy-use installs can tell whether the
1448
+ // reaper is firing and what it's killing. Read-only.
1449
+ app.get('/api/pty-reaper/status', (req, res) => {
1450
+ if (!ptyReaper) {
1451
+ return res.json({
1452
+ enabled: false,
1453
+ reason: !pty ? 'node-pty-unavailable' : 'disabled-by-config',
1454
+ });
1455
+ }
1456
+ res.json({ enabled: true, ...ptyReaper.status() });
1457
+ });
1458
+
1407
1459
  // ==================== Transcript endpoints (Sprint 6 T3) ====================
1408
1460
 
1409
1461
  // GET /api/transcripts/search - FTS across all sessions
@@ -1757,7 +1809,7 @@ function createServer(config) {
1757
1809
  res.sendFile(path.join(clientDir, 'index.html'));
1758
1810
  });
1759
1811
 
1760
- return { app, server, wss, sessions, rag, db, transcriptWriter };
1812
+ return { app, server, wss, sessions, rag, db, transcriptWriter, ptyReaper };
1761
1813
  }
1762
1814
 
1763
1815
  // ==================== Setup-configure helpers (Sprint 23 T2) ====================
@@ -1975,7 +2027,7 @@ if (require.main === module) {
1975
2027
  }
1976
2028
  }
1977
2029
 
1978
- const { server, transcriptWriter } = createServer(config);
2030
+ const { server, transcriptWriter, ptyReaper } = createServer(config);
1979
2031
 
1980
2032
  // Graceful shutdown — flush transcript buffer before exit
1981
2033
  let shutdownInProgress = false;
@@ -1983,6 +2035,11 @@ if (require.main === module) {
1983
2035
  if (shutdownInProgress) return;
1984
2036
  shutdownInProgress = true;
1985
2037
  console.log(`\n[server] ${signal} received, shutting down...`);
2038
+ if (ptyReaper) {
2039
+ try { ptyReaper.stop(); } catch (err) {
2040
+ console.error('[pty-reaper] stop failed:', err.message);
2041
+ }
2042
+ }
1986
2043
  if (transcriptWriter) {
1987
2044
  console.log('[transcript] Flushing buffer before exit...');
1988
2045
  try { await transcriptWriter.close(); } catch (err) {
@@ -0,0 +1,119 @@
1
+ // Projects routes — POST /api/projects (add) + DELETE /api/projects/:name
2
+ // (remove) extracted into a small factory so tests can drive them without
3
+ // bootstrapping the full server. Sprint 42 T4.
4
+ //
5
+ // Surface contract:
6
+ //
7
+ // POST /api/projects → add (existing v0.2 behavior)
8
+ // DELETE /api/projects/:name[?force=true] → remove
9
+ //
10
+ // DELETE semantics:
11
+ // - 404 if the project is not in config.yaml
12
+ // - 409 if any live PTY session has meta.project === name (i.e.
13
+ // meta.status !== 'exited'), unless ?force=true is set
14
+ // - On success: rewrites ~/.termdeck/config.yaml (with .bak), updates the
15
+ // in-memory config map, broadcasts `projects_changed` to all WS clients,
16
+ // and returns { ok, removed, projects, files_on_disk: 'untouched' }
17
+ //
18
+ // File contents at the project's `path` are NEVER touched here — the user's
19
+ // source code stays put. The dashboard modal copy reflects this so users
20
+ // don't fear data loss.
21
+
22
+ function createProjectsRoutes({
23
+ app,
24
+ config,
25
+ getSessions, // () => array of session objects with .meta.{project,status}
26
+ addProject, // (opts) => updated projects map (mutates config.yaml)
27
+ removeProject, // (name) => updated projects map (mutates config.yaml)
28
+ broadcast, // ({ type, projects }) => void (optional)
29
+ }) {
30
+ if (!app) throw new Error('createProjectsRoutes: app is required');
31
+ if (typeof addProject !== 'function') throw new Error('createProjectsRoutes: addProject is required');
32
+ if (typeof removeProject !== 'function') throw new Error('createProjectsRoutes: removeProject is required');
33
+
34
+ const safeBroadcast = (payload) => {
35
+ if (typeof broadcast !== 'function') return;
36
+ try { broadcast(payload); }
37
+ catch (err) { console.error('[projects-routes] broadcast failed:', err); }
38
+ };
39
+
40
+ // POST /api/projects — add a project, persist to config.yaml, broadcast.
41
+ // Body: { name, path, defaultTheme?, defaultCommand? }
42
+ app.post('/api/projects', (req, res) => {
43
+ const { name, path: projectPath, defaultTheme, defaultCommand } = req.body || {};
44
+ try {
45
+ const updatedProjects = addProject({ name, path: projectPath, defaultTheme, defaultCommand });
46
+ config.projects = updatedProjects;
47
+ safeBroadcast({ type: 'projects_changed', projects: updatedProjects });
48
+ res.json({ ok: true, projects: updatedProjects });
49
+ } catch (err) {
50
+ console.error('[config] addProject failed:', err.message);
51
+ res.status(400).json({ error: err.message });
52
+ }
53
+ });
54
+
55
+ // DELETE /api/projects/:name — remove a project. ?force=true to override
56
+ // the live-session 409 guard. Files on disk are untouched.
57
+ app.delete('/api/projects/:name', (req, res) => {
58
+ const name = req.params.name;
59
+ if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
60
+ return res.status(400).json({ error: 'Project name must be non-empty and contain only letters, digits, . _ or -' });
61
+ }
62
+
63
+ const projects = (config && config.projects) || {};
64
+ if (!projects[name]) {
65
+ return res.status(404).json({ error: `Project "${name}" not found` });
66
+ }
67
+
68
+ const force = req.query && (req.query.force === 'true' || req.query.force === '1');
69
+
70
+ let liveSessions = [];
71
+ try {
72
+ const all = (typeof getSessions === 'function' ? getSessions() : []) || [];
73
+ liveSessions = all.filter((s) => {
74
+ if (!s || !s.meta) return false;
75
+ return s.meta.project === name && s.meta.status !== 'exited';
76
+ });
77
+ } catch (err) {
78
+ console.error('[projects-routes] getSessions failed:', err);
79
+ liveSessions = [];
80
+ }
81
+
82
+ if (liveSessions.length > 0 && !force) {
83
+ return res.status(409).json({
84
+ error: `Project "${name}" has ${liveSessions.length} live PTY session${liveSessions.length === 1 ? '' : 's'}. Close them first, or pass ?force=true.`,
85
+ liveSessions: liveSessions.length,
86
+ sessionIds: liveSessions.map((s) => s.id).filter(Boolean),
87
+ });
88
+ }
89
+
90
+ let updatedProjects;
91
+ try {
92
+ updatedProjects = removeProject(name);
93
+ } catch (err) {
94
+ if (err && err.code === 'NOT_FOUND') {
95
+ return res.status(404).json({ error: err.message });
96
+ }
97
+ if (err && err.code === 'BAD_NAME') {
98
+ return res.status(400).json({ error: err.message });
99
+ }
100
+ console.error('[config] removeProject failed:', err.message);
101
+ return res.status(500).json({ error: err.message });
102
+ }
103
+
104
+ config.projects = updatedProjects;
105
+ safeBroadcast({ type: 'projects_changed', projects: updatedProjects });
106
+
107
+ res.json({
108
+ ok: true,
109
+ removed: name,
110
+ forced: !!force,
111
+ projects: updatedProjects,
112
+ files_on_disk: 'untouched',
113
+ });
114
+ });
115
+ }
116
+
117
+ module.exports = {
118
+ createProjectsRoutes,
119
+ };
@@ -0,0 +1,297 @@
1
+ // PTY orphan reaper (Sprint 42 T2).
2
+ //
3
+ // Each TermDeck session spawns one shell PTY (`term.pid` from node-pty). That
4
+ // shell typically forks Claude Code, which in turn forks MCP children
5
+ // (rag-system, imessage-mcp, …). When the user closes a panel TermDeck calls
6
+ // `term.kill()`, which delivers SIGHUP to the leader's process group — but
7
+ // some MCPs `setsid` to detach, escape the pgroup, and survive the parent.
8
+ // Reparented to launchd, those processes keep holding their PTY file
9
+ // descriptors, and on macOS that drains `kern.tty.ptmx_max` (511 by default).
10
+ // Joshua's 2026-04-28 morning incident: 585 PTY refs, `forkpty: Device not
11
+ // configured` blocking new terminals.
12
+ //
13
+ // This module periodically (every 30s by default) walks the live process tree
14
+ // and, for each known session, tracks descendants of its PTY leader. When the
15
+ // leader is gone or the session has transitioned to `exited`, any descendants
16
+ // that survived get SIGTERM'd and recorded to a ring buffer surfaced via
17
+ // /api/pty-reaper/status.
18
+ //
19
+ // All side-effects (`ps`, `kill`, `now`, the timer) are injectable so the
20
+ // tests in tests/pty-reaper.test.js can drive deterministic orphan scenarios
21
+ // without forking real processes.
22
+ //
23
+ // Public surface:
24
+ // createPtyReaper({ sessions, intervalMs?, ps?, kill?, now?, logger? })
25
+ // → { start(), stop(), tick(), status(), _resetForTest() }
26
+
27
+ const { execFileSync } = require('child_process');
28
+
29
+ const RING_SIZE = 200;
30
+ const DEFAULT_INTERVAL_MS = 30000;
31
+
32
+ // Default `ps` boundary — execFileSync is sandbox-friendly (no shell).
33
+ // `-e` lists every process; the trailing `=` on each column header suppresses
34
+ // the header row, so the output is one process per line: "<pid> <ppid> <cmd>".
35
+ function defaultPs() {
36
+ const stdout = execFileSync('ps', ['-e', '-o', 'pid=,ppid=,command='], {
37
+ encoding: 'utf8',
38
+ maxBuffer: 8 * 1024 * 1024,
39
+ });
40
+ return parsePsOutput(stdout);
41
+ }
42
+
43
+ function parsePsOutput(stdout) {
44
+ const out = [];
45
+ const lines = stdout.split('\n');
46
+ for (const raw of lines) {
47
+ const line = raw.trim();
48
+ if (!line) continue;
49
+ // Two leading whitespace-separated integers, then the rest is command.
50
+ const m = line.match(/^(\d+)\s+(\d+)\s+(.*)$/);
51
+ if (!m) continue;
52
+ const pid = parseInt(m[1], 10);
53
+ const ppid = parseInt(m[2], 10);
54
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue;
55
+ out.push({ pid, ppid, command: m[3] });
56
+ }
57
+ return out;
58
+ }
59
+
60
+ function defaultKill(pid, signal) {
61
+ process.kill(pid, signal);
62
+ }
63
+
64
+ function createPtyReaper({
65
+ sessions,
66
+ intervalMs = DEFAULT_INTERVAL_MS,
67
+ ps = defaultPs,
68
+ kill = defaultKill,
69
+ now = Date.now,
70
+ logger = console,
71
+ } = {}) {
72
+ if (!sessions) {
73
+ throw new Error('createPtyReaper: sessions (SessionManager) is required');
74
+ }
75
+
76
+ // Per-session registry: sessionId → { ptyPid, descendants:Set<pid>,
77
+ // firstSeenAt, lastSeenAliveAt }. Refreshed each tick while the leader is
78
+ // alive so when it dies we still know which descendants to chase.
79
+ const registry = new Map();
80
+ let reapedHistory = [];
81
+ let tickCount = 0;
82
+ let lastTickAt = null;
83
+ let lastError = null;
84
+ let timer = null;
85
+
86
+ function isoNow() {
87
+ return new Date(now()).toISOString();
88
+ }
89
+
90
+ function recordReap(entry) {
91
+ reapedHistory.push(entry);
92
+ if (reapedHistory.length > RING_SIZE) {
93
+ reapedHistory = reapedHistory.slice(-RING_SIZE);
94
+ }
95
+ }
96
+
97
+ function bfsDescendants(rootPid, childrenByPpid) {
98
+ const out = new Set();
99
+ const stack = [rootPid];
100
+ const seen = new Set([rootPid]);
101
+ while (stack.length) {
102
+ const cur = stack.pop();
103
+ const kids = childrenByPpid.get(cur);
104
+ if (!kids) continue;
105
+ for (const kid of kids) {
106
+ if (seen.has(kid.pid)) continue;
107
+ seen.add(kid.pid);
108
+ out.add(kid.pid);
109
+ stack.push(kid.pid);
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+
115
+ function iterSessions() {
116
+ // SessionManager.sessions is a Map<id, Session>; iterate the values
117
+ // directly so we get the live Session instances (not toJSON copies).
118
+ if (sessions.sessions && typeof sessions.sessions.values === 'function') {
119
+ return Array.from(sessions.sessions.values());
120
+ }
121
+ return [];
122
+ }
123
+
124
+ function tick() {
125
+ tickCount += 1;
126
+ lastTickAt = isoNow();
127
+
128
+ let snapshot;
129
+ try {
130
+ snapshot = ps();
131
+ } catch (err) {
132
+ lastError = err && err.message ? err.message : String(err);
133
+ if (logger && logger.error) {
134
+ logger.error('[pty-reaper] ps() failed:', lastError);
135
+ }
136
+ return { reaped: 0, refreshed: 0, error: lastError };
137
+ }
138
+
139
+ if (!Array.isArray(snapshot)) snapshot = [];
140
+ const livePids = new Set();
141
+ const procByPid = new Map();
142
+ const childrenByPpid = new Map();
143
+ for (const proc of snapshot) {
144
+ if (!proc || !Number.isFinite(proc.pid)) continue;
145
+ livePids.add(proc.pid);
146
+ procByPid.set(proc.pid, proc);
147
+ const kids = childrenByPpid.get(proc.ppid);
148
+ if (kids) kids.push(proc);
149
+ else childrenByPpid.set(proc.ppid, [proc]);
150
+ }
151
+
152
+ let refreshed = 0;
153
+ let reaped = 0;
154
+ const liveSessionIds = new Set();
155
+
156
+ // Pass 1: refresh registry for every known session whose leader is alive.
157
+ for (const session of iterSessions()) {
158
+ if (!session || !session.id) continue;
159
+ liveSessionIds.add(session.id);
160
+ const ptyPid = session.pid;
161
+ if (!Number.isFinite(ptyPid)) continue;
162
+
163
+ const leaderAlive = livePids.has(ptyPid);
164
+ const exited = session.meta && session.meta.status === 'exited';
165
+
166
+ if (leaderAlive && !exited) {
167
+ const descendants = bfsDescendants(ptyPid, childrenByPpid);
168
+ const existing = registry.get(session.id);
169
+ registry.set(session.id, {
170
+ ptyPid,
171
+ descendants,
172
+ firstSeenAt: existing ? existing.firstSeenAt : isoNow(),
173
+ lastSeenAliveAt: isoNow(),
174
+ });
175
+ refreshed += 1;
176
+ }
177
+ }
178
+
179
+ // Pass 2: for each registry entry whose leader has died OR whose session
180
+ // has transitioned to 'exited' (or whose Session has been removed from
181
+ // the manager entirely), kill any descendants still alive and drop the
182
+ // entry. We rely on the descendant snapshot captured by the most recent
183
+ // refresh — once the leader is reaped we can't BFS from a dead pid.
184
+ for (const [sessionId, entry] of Array.from(registry.entries())) {
185
+ const session = sessions.get ? sessions.get(sessionId) : null;
186
+ const stillRegistered = liveSessionIds.has(sessionId);
187
+ const leaderAlive = livePids.has(entry.ptyPid);
188
+ const exited = session && session.meta && session.meta.status === 'exited';
189
+
190
+ if (stillRegistered && leaderAlive && !exited) continue;
191
+
192
+ const reason = !leaderAlive
193
+ ? 'leader_dead'
194
+ : exited
195
+ ? 'session_exited'
196
+ : 'session_removed';
197
+
198
+ for (const descPid of entry.descendants) {
199
+ if (!livePids.has(descPid)) continue;
200
+ const meta = procByPid.get(descPid) || { pid: descPid, ppid: null, command: '' };
201
+ try {
202
+ kill(descPid, 'SIGTERM');
203
+ recordReap({
204
+ ts: isoNow(),
205
+ sessionId,
206
+ ptyPid: entry.ptyPid,
207
+ pid: descPid,
208
+ ppid: meta.ppid,
209
+ command: (meta.command || '').slice(0, 200),
210
+ reason,
211
+ outcome: 'signaled',
212
+ });
213
+ reaped += 1;
214
+ } catch (err) {
215
+ // ESRCH = already dead; anything else we record but don't throw.
216
+ const code = err && err.code ? err.code : null;
217
+ recordReap({
218
+ ts: isoNow(),
219
+ sessionId,
220
+ ptyPid: entry.ptyPid,
221
+ pid: descPid,
222
+ ppid: meta.ppid,
223
+ command: (meta.command || '').slice(0, 200),
224
+ reason,
225
+ outcome: code === 'ESRCH' ? 'already_dead' : 'kill_failed',
226
+ error: err && err.message ? err.message : String(err),
227
+ });
228
+ }
229
+ }
230
+ registry.delete(sessionId);
231
+ }
232
+
233
+ return { reaped, refreshed, error: null };
234
+ }
235
+
236
+ function start() {
237
+ if (timer) return;
238
+ timer = setInterval(() => {
239
+ try {
240
+ tick();
241
+ } catch (err) {
242
+ lastError = err && err.message ? err.message : String(err);
243
+ if (logger && logger.error) {
244
+ logger.error('[pty-reaper] tick() threw:', lastError);
245
+ }
246
+ }
247
+ }, intervalMs);
248
+ if (typeof timer.unref === 'function') timer.unref();
249
+ }
250
+
251
+ function stop() {
252
+ if (timer) {
253
+ clearInterval(timer);
254
+ timer = null;
255
+ }
256
+ }
257
+
258
+ function status() {
259
+ const registrySnapshot = [];
260
+ for (const [sessionId, entry] of registry) {
261
+ registrySnapshot.push({
262
+ sessionId,
263
+ ptyPid: entry.ptyPid,
264
+ descendantPids: Array.from(entry.descendants),
265
+ firstSeenAt: entry.firstSeenAt,
266
+ lastSeenAliveAt: entry.lastSeenAliveAt,
267
+ });
268
+ }
269
+ return {
270
+ tickCount,
271
+ lastTickAt,
272
+ intervalMs,
273
+ lastError,
274
+ registry: registrySnapshot,
275
+ reapedCount: reapedHistory.length,
276
+ reapedHistory: reapedHistory.slice(),
277
+ };
278
+ }
279
+
280
+ function _resetForTest() {
281
+ stop();
282
+ registry.clear();
283
+ reapedHistory = [];
284
+ tickCount = 0;
285
+ lastTickAt = null;
286
+ lastError = null;
287
+ }
288
+
289
+ return { start, stop, tick, status, _resetForTest };
290
+ }
291
+
292
+ module.exports = {
293
+ createPtyReaper,
294
+ parsePsOutput,
295
+ RING_SIZE,
296
+ DEFAULT_INTERVAL_MS,
297
+ };
@@ -10,6 +10,7 @@ module.exports = {
10
10
  yaml: require('./yaml-io'),
11
11
  supabaseUrl: require('./supabase-url'),
12
12
  migrations: require('./migrations'),
13
+ migrationTemplating: require('./migration-templating'),
13
14
  pgRunner: require('./pg-runner'),
14
15
  migrationRunner: require('./migration-runner'),
15
16
  preconditions: require('./preconditions')