@jhizzard/termdeck 0.10.4 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -34,6 +34,7 @@ const {
34
34
  dotenv,
35
35
  supabaseUrl: urlHelper,
36
36
  migrations,
37
+ migrationTemplating,
37
38
  pgRunner,
38
39
  preconditions
39
40
  } = require(SETUP_DIR);
@@ -428,26 +429,53 @@ async function testFunction(projectRef, secrets, dryRun) {
428
429
  return true;
429
430
  }
430
431
 
432
+ // Sprint 42 T3: bundle of cron-schedule migrations whose `<project-ref>`
433
+ // placeholder must be substituted at apply-time. Both are idempotent
434
+ // (cron.unschedule + cron.schedule), so applying in sequence is safe even
435
+ // when one was already installed. Pre-Sprint 42, only 002 was applied —
436
+ // migration 003 (graph-inference-tick) shipped bundled but unsubstituted
437
+ // and unscheduled, which is part of why Sprint 38 close-out left the
438
+ // graph-inference cron disabled.
439
+ const SCHEDULE_MIGRATIONS = [
440
+ { matcher: /002.*pg_cron/, label: '002_pg_cron_schedule (rumen-tick)' },
441
+ { matcher: /003.*graph_inference/, label: '003_graph_inference_schedule (graph-inference-tick)' }
442
+ ];
443
+
431
444
  async function applySchedule(projectRef, secrets, dryRun) {
432
- step('Applying pg_cron schedule (every 15 minutes)...');
445
+ step('Applying pg_cron schedules (rumen-tick + graph-inference-tick)...');
433
446
  if (dryRun) { ok('(dry-run)'); return true; }
434
447
 
435
448
  const files = migrations.listRumenMigrations();
436
- const scheduleFile = files.find((f) => /002.*pg_cron/.test(path.basename(f)));
437
- if (!scheduleFile) { fail('bundled 002_pg_cron_schedule.sql is missing'); return false; }
438
-
439
- const raw = migrations.readFile(scheduleFile);
440
- // Substitute the project ref into the schedule body. The bundled migration
441
- // ships with the placeholder `<project-ref>` per Rumen's deploy docs; we
442
- // also accept `{{PROJECT_REF}}` for robustness.
443
- const substituted = raw
444
- .replace(/<project-ref>/g, projectRef)
445
- .replace(/\{\{PROJECT_REF\}\}/g, projectRef);
446
-
447
- // The shipped migration uses Supabase Vault (`vault.decrypted_secrets`) to
448
- // pull the service-role key. If the user hasn't stored the key in Vault the
449
- // cron call will fail. We leave that as a post-install step and print a
450
- // reminder below.
449
+ const planned = [];
450
+ for (const { matcher, label } of SCHEDULE_MIGRATIONS) {
451
+ const file = files.find((f) => matcher.test(path.basename(f)));
452
+ if (!file) { fail(`bundled ${label} is missing`); return false; }
453
+ planned.push({ file, label });
454
+ }
455
+
456
+ // Substitute the project ref into each schedule body. Both bundled
457
+ // migrations ship with the `<project-ref>` placeholder per Rumen's
458
+ // deploy docs; the helper also accepts `{{PROJECT_REF}}` for robustness
459
+ // and refuses to ship an unsubstituted placeholder to the database.
460
+ const substituted = [];
461
+ try {
462
+ for (const { file, label } of planned) {
463
+ const raw = migrations.readFile(file);
464
+ substituted.push({
465
+ sql: migrationTemplating.applyTemplating(raw, { projectRef }),
466
+ label
467
+ });
468
+ }
469
+ } catch (err) {
470
+ fail(err.message);
471
+ return false;
472
+ }
473
+
474
+ // The shipped migrations use Supabase Vault (`vault.decrypted_secrets`)
475
+ // to pull the service-role keys (`rumen_service_role_key` for 002 and
476
+ // `graph_inference_service_role_key` for 003). If a key isn't stored in
477
+ // Vault the corresponding cron call will fail at runtime. We leave that
478
+ // as a post-install step and print a reminder below.
451
479
 
452
480
  let client;
453
481
  try {
@@ -457,20 +485,21 @@ async function applySchedule(projectRef, secrets, dryRun) {
457
485
  return false;
458
486
  }
459
487
  try {
460
- // Run the substituted SQL directly rather than applying the original file.
461
- try {
462
- await pgRunner.run(client, substituted);
463
- ok();
464
- return true;
465
- } catch (err) {
466
- fail(err.message);
467
- process.stderr.write(
468
- '\nThe schedule SQL failed the most common cause is that pg_cron or pg_net\n' +
469
- 'is not enabled in the Supabase project. Enable them in Dashboard → Database\n' +
470
- '→ Extensions, then re-run `termdeck init --rumen --skip-schedule=false`.\n'
471
- );
472
- return false;
488
+ for (const { sql, label } of substituted) {
489
+ try {
490
+ await pgRunner.run(client, sql);
491
+ } catch (err) {
492
+ fail(`${label}: ${err.message}`);
493
+ process.stderr.write(
494
+ '\nThe schedule SQL failed — the most common cause is that pg_cron or pg_net\n' +
495
+ 'is not enabled in the Supabase project. Enable them in Dashboard → Database\n' +
496
+ ' Extensions, then re-run `termdeck init --rumen --skip-schedule=false`.\n'
497
+ );
498
+ return false;
499
+ }
473
500
  }
501
+ ok();
502
+ return true;
474
503
  } finally {
475
504
  try { await client.end(); } catch (_err) { /* ignore */ }
476
505
  }
@@ -91,6 +91,65 @@
91
91
  setupGuideRail();
92
92
  }
93
93
 
94
+ // ===== Drag/drop reorder of PTY panels (Sprint 42 T4) =====
95
+ // The grip handle in panel-header-left flips draggable=true on mousedown
96
+ // so an accidental drag inside the xterm region never fires. Drop
97
+ // position is determined by cursor x within the target panel — left half
98
+ // inserts before, right half inserts after — so reordering matches the
99
+ // intent in any grid layout (1x2, 2x2, 2x4, etc.). DOM reorder only;
100
+ // session.creation-order remains canonical for Alt+1…9 and panel-index.
101
+ function setupPanelDragDrop(panel) {
102
+ const handle = panel.querySelector('.panel-drag-handle');
103
+ if (!handle) return;
104
+
105
+ handle.addEventListener('mousedown', () => { panel.draggable = true; });
106
+ // Mouse leaves handle without a drag starting → reset
107
+ handle.addEventListener('mouseleave', () => {
108
+ if (!panel.classList.contains('dragging')) panel.draggable = false;
109
+ });
110
+
111
+ panel.addEventListener('dragstart', (e) => {
112
+ if (!panel.draggable) { e.preventDefault(); return; }
113
+ try { e.dataTransfer.effectAllowed = 'move'; } catch (_e) {}
114
+ try { e.dataTransfer.setData('text/plain', panel.id); } catch (_e) {}
115
+ panel.classList.add('dragging');
116
+ });
117
+
118
+ panel.addEventListener('dragend', () => {
119
+ panel.classList.remove('dragging');
120
+ panel.draggable = false;
121
+ document.querySelectorAll('.term-panel.drag-over').forEach((p) => p.classList.remove('drag-over'));
122
+ });
123
+
124
+ panel.addEventListener('dragover', (e) => {
125
+ const dragging = document.querySelector('.term-panel.dragging');
126
+ if (!dragging || dragging === panel) return;
127
+ e.preventDefault();
128
+ try { e.dataTransfer.dropEffect = 'move'; } catch (_e) {}
129
+ panel.classList.add('drag-over');
130
+ });
131
+
132
+ panel.addEventListener('dragleave', (e) => {
133
+ // Only clear when leaving the panel entirely (not entering a child).
134
+ if (!panel.contains(e.relatedTarget)) panel.classList.remove('drag-over');
135
+ });
136
+
137
+ panel.addEventListener('drop', (e) => {
138
+ e.preventDefault();
139
+ panel.classList.remove('drag-over');
140
+ const draggedId = (() => {
141
+ try { return e.dataTransfer.getData('text/plain'); } catch (_e) { return ''; }
142
+ })();
143
+ const dragged = draggedId
144
+ ? document.getElementById(draggedId)
145
+ : document.querySelector('.term-panel.dragging');
146
+ if (!dragged || dragged === panel) return;
147
+ const rect = panel.getBoundingClientRect();
148
+ const dropAfter = (e.clientX - rect.left) > rect.width / 2;
149
+ panel.parentNode.insertBefore(dragged, dropAfter ? panel.nextSibling : panel);
150
+ });
151
+ }
152
+
94
153
  // ===== Create Terminal Panel =====
95
154
  function createTerminalPanel(sessionData) {
96
155
  const id = sessionData.id;
@@ -128,6 +187,7 @@
128
187
  panel.innerHTML = `
129
188
  <div class="panel-header">
130
189
  <div class="panel-header-left">
190
+ <span class="panel-drag-handle" title="Drag to reorder">⋮⋮</span>
131
191
  <span class="status-dot" id="dot-${id}" style="background:${getStatusColor(meta.status)}"></span>
132
192
  <span class="panel-type">${getTypeLabel(meta.type)}</span>
133
193
  ${meta.project ? `<span class="panel-project ${projClass}">${meta.project}</span>` : ''}
@@ -193,6 +253,11 @@
193
253
 
194
254
  document.getElementById('termGrid').appendChild(panel);
195
255
 
256
+ // Sprint 42 T4: drag/drop reorder. Inject identifier is the session
257
+ // UUID, so DOM reorder is purely visual — Alt+1…9 (creation-order),
258
+ // /api/sessions/:id/input, and reply-form targets are unaffected.
259
+ setupPanelDragDrop(panel);
260
+
196
261
  // Create xterm.js instance
197
262
  const terminal = new Terminal({
198
263
  fontFamily: "'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Consolas, monospace",
@@ -265,6 +330,15 @@
265
330
  if (typeof updateRagIndicator === 'function') updateRagIndicator();
266
331
  }
267
332
  break;
333
+ case 'projects_changed':
334
+ // Sprint 42 T4: server broadcasts on POST/DELETE /api/projects.
335
+ // Sync the in-memory projects map and re-render the dropdown so
336
+ // other open dashboard tabs stay consistent without a refresh.
337
+ if (msg.projects && state.config) {
338
+ state.config.projects = msg.projects;
339
+ if (typeof rebuildProjectDropdown === 'function') rebuildProjectDropdown();
340
+ }
341
+ break;
268
342
  }
269
343
  } catch (err) { console.error('[client] ws message parse failed:', err); }
270
344
  };
@@ -1281,6 +1355,14 @@
1281
1355
  if (typeof updateRagIndicator === 'function') updateRagIndicator();
1282
1356
  }
1283
1357
  break;
1358
+ case 'projects_changed':
1359
+ // Sprint 42 T4: parity with main WS handler. Project add/remove
1360
+ // broadcasts arrive on every ws client; idempotent.
1361
+ if (msg.projects && state.config) {
1362
+ state.config.projects = msg.projects;
1363
+ if (typeof rebuildProjectDropdown === 'function') rebuildProjectDropdown();
1364
+ }
1365
+ break;
1284
1366
  }
1285
1367
  } catch (err) { console.error('[client] reconnect ws message failed:', err); }
1286
1368
  };
@@ -1552,6 +1634,104 @@
1552
1634
  }
1553
1635
  }
1554
1636
 
1637
+ // ===== Remove Project modal (Sprint 42 T4) =====
1638
+ // Removes a project from ~/.termdeck/config.yaml. Files on disk at the
1639
+ // project's `path` are NEVER touched — the modal copy makes that explicit
1640
+ // so users don't fear data loss. 409 from the server (live PTY sessions
1641
+ // for that project) prompts the user with a force-override.
1642
+ function openRemoveProjectModal() {
1643
+ const modal = document.getElementById('removeProjectModal');
1644
+ const sel = document.getElementById('rpmSelect');
1645
+ sel.innerHTML = '<option value="">— pick a project —</option>';
1646
+ for (const name of Object.keys(state.config.projects || {})) {
1647
+ const opt = document.createElement('option');
1648
+ opt.value = name;
1649
+ opt.textContent = name;
1650
+ sel.appendChild(opt);
1651
+ }
1652
+ sel.value = '';
1653
+ document.getElementById('rpmConfirm').disabled = true;
1654
+ document.getElementById('rpmConfirm').dataset.force = '';
1655
+ document.getElementById('rpmConfirm').textContent = 'remove project';
1656
+ const warn = document.getElementById('rpmWarning');
1657
+ warn.hidden = true;
1658
+ warn.textContent = '';
1659
+ setRpmStatus('', null);
1660
+ modal.classList.add('open');
1661
+ setTimeout(() => sel.focus(), 50);
1662
+ }
1663
+
1664
+ function closeRemoveProjectModal() {
1665
+ document.getElementById('removeProjectModal').classList.remove('open');
1666
+ }
1667
+
1668
+ function setRpmStatus(msg, kind) {
1669
+ const el = document.getElementById('rpmStatus');
1670
+ if (!el) return;
1671
+ el.textContent = msg || '';
1672
+ el.classList.remove('error', 'ok');
1673
+ if (kind) el.classList.add(kind);
1674
+ }
1675
+
1676
+ function onRpmSelectChange() {
1677
+ const name = document.getElementById('rpmSelect').value;
1678
+ const btn = document.getElementById('rpmConfirm');
1679
+ btn.disabled = !name;
1680
+ btn.dataset.force = '';
1681
+ btn.textContent = name ? `remove "${name}"` : 'remove project';
1682
+ const warn = document.getElementById('rpmWarning');
1683
+ warn.hidden = true;
1684
+ warn.textContent = '';
1685
+ setRpmStatus('', null);
1686
+ }
1687
+
1688
+ async function submitRemoveProject() {
1689
+ const name = document.getElementById('rpmSelect').value;
1690
+ if (!name) return;
1691
+ const btn = document.getElementById('rpmConfirm');
1692
+ const force = btn.dataset.force === 'true';
1693
+ btn.disabled = true;
1694
+ setRpmStatus(force ? 'Removing (with force)…' : 'Removing…', null);
1695
+
1696
+ try {
1697
+ const url = `${API}/api/projects/${encodeURIComponent(name)}${force ? '?force=true' : ''}`;
1698
+ const res = await fetch(url, { method: 'DELETE' });
1699
+ const text = await res.text();
1700
+ let body = {};
1701
+ try { body = JSON.parse(text); } catch { body = { error: text }; }
1702
+
1703
+ if (res.status === 409) {
1704
+ const live = body.liveSessions || 0;
1705
+ const warn = document.getElementById('rpmWarning');
1706
+ warn.hidden = false;
1707
+ warn.innerHTML =
1708
+ `<strong>"${name}" has ${live} live PTY session${live === 1 ? '' : 's'}.</strong> ` +
1709
+ `Closing those terminals first is recommended. ` +
1710
+ `Or click <em>remove anyway</em> to force removal — terminals stay open but lose their project tag in config.yaml.`;
1711
+ btn.dataset.force = 'true';
1712
+ btn.textContent = 'remove anyway';
1713
+ btn.disabled = false;
1714
+ setRpmStatus('', null);
1715
+ return;
1716
+ }
1717
+
1718
+ if (!res.ok) {
1719
+ setRpmStatus(`Failed: ${body.error || res.statusText}`, 'error');
1720
+ btn.disabled = false;
1721
+ return;
1722
+ }
1723
+
1724
+ // Success — sync in-memory config + dropdown.
1725
+ state.config.projects = body.projects || {};
1726
+ rebuildProjectDropdown();
1727
+ setRpmStatus(`Removed "${name}" ✓ (files on disk untouched)`, 'ok');
1728
+ setTimeout(() => { closeRemoveProjectModal(); }, 900);
1729
+ } catch (err) {
1730
+ setRpmStatus(`Failed: ${err.message || err}`, 'error');
1731
+ btn.disabled = false;
1732
+ }
1733
+ }
1734
+
1555
1735
  // ===== Orchestration preview modal (Sprint 37 T3) =====
1556
1736
  // The preview button next to the project select shows what
1557
1737
  // `termdeck init --project <name>` would create for the currently
@@ -3714,6 +3894,16 @@
3714
3894
  if (e.key === 'Escape') { e.preventDefault(); closeAddProjectModal(); }
3715
3895
  });
3716
3896
 
3897
+ // Remove-project modal wiring (Sprint 42 T4)
3898
+ document.getElementById('btnRemoveProject').addEventListener('click', openRemoveProjectModal);
3899
+ document.getElementById('rpmCancel').addEventListener('click', closeRemoveProjectModal);
3900
+ document.getElementById('rpmConfirm').addEventListener('click', submitRemoveProject);
3901
+ document.getElementById('rpmSelect').addEventListener('change', onRpmSelectChange);
3902
+ document.querySelector('#removeProjectModal .remove-project-backdrop').addEventListener('click', closeRemoveProjectModal);
3903
+ document.getElementById('removeProjectModal').addEventListener('keydown', (e) => {
3904
+ if (e.key === 'Escape') { e.preventDefault(); closeRemoveProjectModal(); }
3905
+ });
3906
+
3717
3907
  // Orchestration preview modal wiring (Sprint 37 T3)
3718
3908
  document.getElementById('btnPreviewProject').addEventListener('click', openPreviewModal);
3719
3909
  document.getElementById('promptProject').addEventListener('change', syncPreviewButton);
@@ -135,6 +135,7 @@
135
135
  <option value="">no project</option>
136
136
  </select>
137
137
  <button class="prompt-add-project" id="btnAddProject" title="Add a new project">+</button>
138
+ <button class="prompt-remove-project" id="btnRemoveProject" title="Remove a project (files on disk are untouched)">−</button>
138
139
  <button class="prompt-preview-project" id="btnPreviewProject" title="Preview orchestration scaffolding for the selected project" disabled>preview</button>
139
140
  <button class="prompt-launch" id="promptLaunch">launch</button>
140
141
  </div>
@@ -169,6 +170,29 @@
169
170
  </div>
170
171
  </div>
171
172
 
173
+ <!-- Remove-project modal (Sprint 42 T4). Hidden by default. -->
174
+ <div class="remove-project-modal" id="removeProjectModal" role="dialog" aria-modal="true" aria-labelledby="rpmTitle">
175
+ <div class="remove-project-backdrop"></div>
176
+ <div class="remove-project-card">
177
+ <h3 id="rpmTitle">Remove a project</h3>
178
+ <p class="rpm-help">
179
+ This unregisters the project from <code>~/.termdeck/config.yaml</code>.
180
+ <strong>Files on disk are untouched</strong> — your source code, git history,
181
+ and everything at the project's path stay exactly where they are.
182
+ </p>
183
+ <label>
184
+ <span>Project to remove</span>
185
+ <select id="rpmSelect"><option value="">— pick a project —</option></select>
186
+ </label>
187
+ <div class="rpm-warning" id="rpmWarning" hidden></div>
188
+ <div class="rpm-status" id="rpmStatus"></div>
189
+ <div class="rpm-actions">
190
+ <button class="rpm-cancel" id="rpmCancel">cancel</button>
191
+ <button class="rpm-confirm" id="rpmConfirm" disabled>remove project</button>
192
+ </div>
193
+ </div>
194
+ </div>
195
+
172
196
  <!-- Orchestration-preview modal (Sprint 37 T3). Hidden by default. -->
173
197
  <div class="preview-project-modal" id="previewProjectModal" role="dialog" aria-modal="true" aria-labelledby="ppmTitle">
174
198
  <div class="preview-project-backdrop"></div>
@@ -389,6 +389,31 @@
389
389
  flex: 1;
390
390
  }
391
391
 
392
+ /* Sprint 42 T4 — drag handle for PTY panel reordering. Only the handle
393
+ is grabbable; the rest of the panel header / xterm body are unaffected. */
394
+ .panel-drag-handle {
395
+ cursor: grab;
396
+ color: var(--tg-text-dim);
397
+ font-size: 12px;
398
+ letter-spacing: -2px;
399
+ user-select: none;
400
+ flex-shrink: 0;
401
+ padding: 0 2px;
402
+ opacity: 0.5;
403
+ transition: opacity 0.15s;
404
+ }
405
+ .panel-drag-handle:hover { opacity: 1; color: var(--tg-accent); }
406
+ .panel-drag-handle:active { cursor: grabbing; }
407
+ .term-panel.dragging {
408
+ opacity: 0.5;
409
+ outline: 2px dashed var(--tg-accent);
410
+ outline-offset: -2px;
411
+ }
412
+ .term-panel.drag-over {
413
+ outline: 2px solid var(--tg-accent);
414
+ outline-offset: -2px;
415
+ }
416
+
392
417
  .status-dot {
393
418
  width: 8px; height: 8px;
394
419
  border-radius: 50%;
@@ -768,6 +793,156 @@
768
793
  filter: none;
769
794
  }
770
795
 
796
+ /* ===== REMOVE PROJECT BUTTON + MODAL (Sprint 42 T4) ===== */
797
+ .prompt-remove-project {
798
+ background: var(--tg-bg);
799
+ border: 1px solid var(--tg-border);
800
+ color: var(--tg-text-dim);
801
+ font-size: 18px;
802
+ line-height: 1;
803
+ padding: 0;
804
+ width: 26px;
805
+ height: 26px;
806
+ border-radius: var(--tg-radius-sm);
807
+ cursor: pointer;
808
+ font-family: var(--tg-sans);
809
+ transition: all 0.15s;
810
+ display: flex;
811
+ align-items: center;
812
+ justify-content: center;
813
+ margin-left: 4px;
814
+ }
815
+ .prompt-remove-project:hover {
816
+ border-color: var(--tg-red, #f7768e);
817
+ color: var(--tg-red, #f7768e);
818
+ }
819
+ .remove-project-modal {
820
+ display: none;
821
+ position: fixed;
822
+ inset: 0;
823
+ z-index: 3000;
824
+ align-items: center;
825
+ justify-content: center;
826
+ }
827
+ .remove-project-modal.open { display: flex; }
828
+ .remove-project-backdrop {
829
+ position: absolute;
830
+ inset: 0;
831
+ background: rgba(0, 0, 0, 0.72);
832
+ }
833
+ .remove-project-card {
834
+ position: relative;
835
+ background: var(--tg-surface);
836
+ border: 1px solid var(--tg-accent-dim);
837
+ border-radius: 10px;
838
+ padding: 22px 24px 18px;
839
+ width: 460px;
840
+ max-width: calc(100vw - 40px);
841
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
842
+ font-family: var(--tg-sans);
843
+ color: var(--tg-text);
844
+ }
845
+ .remove-project-card h3 {
846
+ margin: 0 0 4px;
847
+ font-size: 16px;
848
+ color: var(--tg-accent);
849
+ }
850
+ .remove-project-card .rpm-help {
851
+ margin: 0 0 14px;
852
+ font-size: 12px;
853
+ color: var(--tg-text-dim);
854
+ line-height: 1.5;
855
+ }
856
+ .remove-project-card .rpm-help code {
857
+ background: var(--tg-bg);
858
+ padding: 1px 5px;
859
+ border-radius: 3px;
860
+ font-family: var(--tg-mono);
861
+ font-size: 11px;
862
+ }
863
+ .remove-project-card .rpm-help strong {
864
+ color: var(--tg-green);
865
+ }
866
+ .remove-project-card label {
867
+ display: block;
868
+ margin-bottom: 10px;
869
+ }
870
+ .remove-project-card label > span {
871
+ display: block;
872
+ font-size: 11px;
873
+ color: var(--tg-text-dim);
874
+ margin-bottom: 3px;
875
+ text-transform: uppercase;
876
+ letter-spacing: 0.5px;
877
+ }
878
+ .remove-project-card select {
879
+ width: 100%;
880
+ background: var(--tg-bg);
881
+ border: 1px solid var(--tg-border);
882
+ color: var(--tg-text);
883
+ font-size: 13px;
884
+ padding: 7px 10px;
885
+ border-radius: var(--tg-radius-sm);
886
+ font-family: var(--tg-mono);
887
+ box-sizing: border-box;
888
+ }
889
+ .remove-project-card select:focus {
890
+ outline: none;
891
+ border-color: var(--tg-accent-dim);
892
+ }
893
+ .remove-project-card .rpm-warning {
894
+ background: rgba(247, 118, 142, 0.08);
895
+ border: 1px solid rgba(247, 118, 142, 0.4);
896
+ color: var(--tg-text);
897
+ font-size: 12px;
898
+ padding: 8px 10px;
899
+ border-radius: 4px;
900
+ margin: 6px 0 8px;
901
+ line-height: 1.5;
902
+ }
903
+ .remove-project-card .rpm-status {
904
+ font-size: 12px;
905
+ min-height: 16px;
906
+ margin: 4px 0 8px;
907
+ color: var(--tg-text-dim);
908
+ }
909
+ .remove-project-card .rpm-status.error { color: var(--tg-red); }
910
+ .remove-project-card .rpm-status.ok { color: var(--tg-green); }
911
+ .remove-project-card .rpm-actions {
912
+ display: flex;
913
+ justify-content: flex-end;
914
+ gap: 8px;
915
+ margin-top: 6px;
916
+ }
917
+ .remove-project-card button {
918
+ font-size: 12px;
919
+ font-weight: 600;
920
+ padding: 6px 16px;
921
+ border-radius: 4px;
922
+ cursor: pointer;
923
+ font-family: var(--tg-sans);
924
+ border: 1px solid var(--tg-border);
925
+ }
926
+ .remove-project-card .rpm-cancel {
927
+ background: transparent;
928
+ color: var(--tg-text-dim);
929
+ }
930
+ .remove-project-card .rpm-cancel:hover {
931
+ color: var(--tg-text);
932
+ border-color: var(--tg-border-active);
933
+ }
934
+ .remove-project-card .rpm-confirm {
935
+ background: var(--tg-red, #f7768e);
936
+ color: var(--tg-bg);
937
+ border-color: var(--tg-red, #f7768e);
938
+ }
939
+ .remove-project-card .rpm-confirm:hover { filter: brightness(1.1); }
940
+ .remove-project-card .rpm-confirm:disabled {
941
+ opacity: 0.5;
942
+ cursor: not-allowed;
943
+ filter: none;
944
+ }
945
+
771
946
  /* ===== Orchestration preview button + modal (Sprint 37 T3) ===== */
772
947
  .prompt-preview-project {
773
948
  background: transparent;
@@ -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')
@@ -0,0 +1,76 @@
1
+ // Migration SQL templating helper.
2
+ //
3
+ // Several Rumen migrations ship with placeholder markers for values that
4
+ // can only be resolved at apply-time: the user's Supabase project ref,
5
+ // service-role JWT name, etc. Migration 002 (rumen-tick schedule) and 003
6
+ // (graph-inference-tick schedule) both embed the project ref inside the
7
+ // pg_cron body that calls `net.http_post` on
8
+ // `https://<project-ref>.supabase.co/functions/v1/<name>`.
9
+ //
10
+ // Pre-Sprint 42, init-rumen.js::applySchedule did this substitution inline
11
+ // for migration 002 only — and migration 003 (added Sprint 38) shipped its
12
+ // `<project-ref>` placeholder unsubstituted. Sprint 42 T3 extracts the
13
+ // substitution into this shared helper so every migration that lists a
14
+ // known placeholder gets templated consistently.
15
+ //
16
+ // Supported placeholder syntaxes (both accepted; legacy + sigil-style):
17
+ // <project-ref>
18
+ // {{PROJECT_REF}}
19
+ //
20
+ // API:
21
+ // applyTemplating(sql, vars)
22
+ // sql: string — raw migration body
23
+ // vars: { projectRef?: string }
24
+ // returns: string — substituted body
25
+ // throws: Error — when SQL contains a placeholder but `vars` lacks the
26
+ // corresponding value. (Quietly leaving the placeholder in
27
+ // would let an unsubstituted URL ship to pg_cron, which is the
28
+ // very bug this module exists to prevent.)
29
+ //
30
+ // Idempotent: applying twice yields the same string. Safe on SQL with no
31
+ // placeholders (returns the input unchanged).
32
+
33
+ 'use strict';
34
+
35
+ const PLACEHOLDER_SYNTAXES = Object.freeze({
36
+ projectRef: [/<project-ref>/g, /\{\{PROJECT_REF\}\}/g],
37
+ });
38
+
39
+ function hasPlaceholder(sql, patterns) {
40
+ for (const pat of patterns) {
41
+ pat.lastIndex = 0;
42
+ if (pat.test(sql)) return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ function substitute(sql, patterns, value) {
48
+ let out = sql;
49
+ for (const pat of patterns) {
50
+ out = out.replace(pat, value);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function applyTemplating(sql, vars) {
56
+ if (typeof sql !== 'string') {
57
+ throw new TypeError('applyTemplating: sql must be a string');
58
+ }
59
+ const v = vars || {};
60
+ let out = sql;
61
+
62
+ for (const [varName, patterns] of Object.entries(PLACEHOLDER_SYNTAXES)) {
63
+ if (!hasPlaceholder(out, patterns)) continue;
64
+ const value = v[varName];
65
+ if (typeof value !== 'string' || value.length === 0) {
66
+ throw new Error(
67
+ `applyTemplating: SQL contains ${varName} placeholder but vars.${varName} is missing or empty. ` +
68
+ `Refusing to ship an unsubstituted placeholder to the database.`
69
+ );
70
+ }
71
+ out = substitute(out, patterns, value);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ module.exports = { applyTemplating, PLACEHOLDER_SYNTAXES };