@jhizzard/termdeck 0.10.4 → 0.12.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.12.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"
@@ -29,7 +29,8 @@
29
29
  "server": "node packages/server/src/index.js",
30
30
  "start": "NODE_ENV=production node packages/cli/src/index.js",
31
31
  "test": "node --test packages/server/tests/**/*.test.js",
32
- "install:app": "bash install.sh"
32
+ "install:app": "bash install.sh",
33
+ "sync-rumen-functions": "bash scripts/sync-rumen-functions.sh"
33
34
  },
34
35
  "dependencies": {
35
36
  "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
@@ -18,10 +18,12 @@
18
18
  // 2. Derive project ref from SUPABASE_URL; confirm with user
19
19
  // 3. supabase link --project-ref <ref>
20
20
  // 4. Apply rumen migration 001 via pg
21
- // 5. supabase functions deploy rumen-tick --no-verify-jwt
21
+ // 5. supabase functions deploy rumen-tick AND graph-inference (Sprint 43 T3)
22
+ // from a single staging dir with multi-function supabase/config.toml
22
23
  // 6. supabase secrets set DATABASE_URL=... ANTHROPIC_API_KEY=... [OPENAI_API_KEY=...]
23
- // 7. Test the function with a manual POST (fetch)
24
- // 8. Apply pg_cron schedule migration (002) with project ref substituted
24
+ // 7. Test rumen-tick with a manual POST (graph-inference is cron-only)
25
+ // 8. Apply pg_cron schedule migrations 002 (rumen-tick) AND 003 (graph-inference)
26
+ // with project ref substituted
25
27
 
26
28
  const path = require('path');
27
29
  const fs = require('fs');
@@ -34,6 +36,7 @@ const {
34
36
  dotenv,
35
37
  supabaseUrl: urlHelper,
36
38
  migrations,
39
+ migrationTemplating,
37
40
  pgRunner,
38
41
  preconditions
39
42
  } = require(SETUP_DIR);
@@ -300,67 +303,98 @@ async function applyRumenTables(secrets, dryRun) {
300
303
  }
301
304
  }
302
305
 
303
- function deployFunction(rumenVersion, dryRun) {
304
- step('Running: supabase functions deploy rumen-tick --no-verify-jwt...');
306
+ // Sprint 43 T3: rumen-tick is the only function with a `__RUMEN_VERSION__`
307
+ // placeholder (its `npm:@jhizzard/rumen@<ver>` import is rewritten at deploy
308
+ // time). graph-inference pins its own deps (`npm:postgres@3.4.4`) and is
309
+ // copied verbatim. If a future function adds a placeholder, list it here.
310
+ const FUNCTIONS_WITH_VERSION_PLACEHOLDER = new Set(['rumen-tick']);
311
+
312
+ function deployFunctions(rumenVersion, dryRun) {
313
+ const fnNames = migrations.listRumenFunctions();
314
+ if (fnNames.length === 0) {
315
+ fail('no Rumen Edge Function source found in bundled setup or @jhizzard/rumen package');
316
+ return false;
317
+ }
318
+
319
+ step(`Staging ${fnNames.length} Edge Function(s) (${fnNames.join(', ')})...`);
305
320
  if (dryRun) { ok('(dry-run)'); return true; }
306
321
 
307
- // We need the supabase command to run against a repo layout with
308
- // `supabase/functions/rumen-tick/`. The TermDeck install does NOT include
309
- // a `supabase/` directory at the project root, so we stage a tiny working
310
- // directory under `os.tmpdir()` that mirrors what the CLI expects.
311
- const stage = stageRumenFunction(rumenVersion);
322
+ // Stage all functions in one directory and one config.toml so a single
323
+ // `supabase functions deploy <name>` invocation per function can share the
324
+ // project root. This mirrors how a real Supabase repo is laid out.
325
+ let stage;
326
+ try {
327
+ stage = stageRumenFunctions(rumenVersion);
328
+ } catch (err) {
329
+ fail(err.message);
330
+ return false;
331
+ }
312
332
  if (!stage) {
313
- fail('could not stage rumen-tick function source');
333
+ fail('could not stage Rumen Edge Function source');
314
334
  return false;
315
335
  }
316
-
317
- const r = runShell('supabase', ['functions', 'deploy', 'rumen-tick', '--no-verify-jwt'], {
318
- cwd: stage
319
- });
320
- if (!r.ok) { fail(`deploy failed (exit ${r.code})`); return false; }
321
336
  ok();
337
+
338
+ for (const name of fnNames) {
339
+ step(`Running: supabase functions deploy ${name} --no-verify-jwt...`);
340
+ const r = runShell('supabase', ['functions', 'deploy', name, '--no-verify-jwt'], {
341
+ cwd: stage
342
+ });
343
+ if (!r.ok) {
344
+ fail(`deploy of ${name} failed (exit ${r.code})`);
345
+ return false;
346
+ }
347
+ ok();
348
+ }
322
349
  return true;
323
350
  }
324
351
 
325
- // Create a staging directory containing:
326
- // <stage>/supabase/functions/rumen-tick/{index.ts, tsconfig.json}
327
- // Also write a minimal `supabase/config.toml` so `supabase functions deploy`
328
- // doesn't complain about a missing project root.
329
- function stageRumenFunction(rumenVersion) {
352
+ // Create a staging directory containing every bundled Rumen Edge Function:
353
+ // <stage>/supabase/functions/<name>/{index.ts, tsconfig.json}
354
+ // <stage>/supabase/config.toml (one [functions.<name>] block per function)
355
+ //
356
+ // `__RUMEN_VERSION__` is substituted only in functions listed in
357
+ // FUNCTIONS_WITH_VERSION_PLACEHOLDER (currently just rumen-tick). Other
358
+ // files are copied verbatim. Returns the staging dir path or null if no
359
+ // function source could be located.
360
+ function stageRumenFunctions(rumenVersion) {
330
361
  if (!rumenVersion || !/^\d+\.\d+\.\d+/.test(rumenVersion)) {
331
- throw new Error(`stageRumenFunction: invalid rumenVersion ${JSON.stringify(rumenVersion)}`);
362
+ throw new Error(`stageRumenFunctions: invalid rumenVersion ${JSON.stringify(rumenVersion)}`);
332
363
  }
364
+ const root = migrations.rumenFunctionsRoot();
365
+ const fnNames = migrations.listRumenFunctions();
366
+ if (fnNames.length === 0) return null;
367
+
333
368
  const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'termdeck-rumen-stage-'));
334
- const functionSrc = migrations.rumenFunctionDir();
335
- if (!fs.existsSync(functionSrc)) return null;
336
-
337
- const dest = path.join(stage, 'supabase', 'functions', 'rumen-tick');
338
- fs.mkdirSync(dest, { recursive: true });
339
- for (const f of fs.readdirSync(functionSrc)) {
340
- const srcPath = path.join(functionSrc, f);
341
- const destPath = path.join(dest, f);
342
- // Substitute the version placeholder in the Deno entry point. Other files
343
- // in the directory (tsconfig.json, etc.) are copied verbatim.
344
- if (f === 'index.ts') {
345
- const raw = fs.readFileSync(srcPath, 'utf-8');
346
- if (!raw.includes('__RUMEN_VERSION__')) {
347
- throw new Error(
348
- `rumen-tick/index.ts is missing the __RUMEN_VERSION__ placeholder — ` +
349
- `has someone reintroduced a hardcoded version?`
350
- );
369
+
370
+ for (const name of fnNames) {
371
+ const fnSrc = path.join(root, name);
372
+ const fnDest = path.join(stage, 'supabase', 'functions', name);
373
+ fs.mkdirSync(fnDest, { recursive: true });
374
+ for (const f of fs.readdirSync(fnSrc)) {
375
+ const srcPath = path.join(fnSrc, f);
376
+ const destPath = path.join(fnDest, f);
377
+ if (f === 'index.ts' && FUNCTIONS_WITH_VERSION_PLACEHOLDER.has(name)) {
378
+ const raw = fs.readFileSync(srcPath, 'utf-8');
379
+ if (!raw.includes('__RUMEN_VERSION__')) {
380
+ throw new Error(
381
+ `${name}/index.ts is missing the __RUMEN_VERSION__ placeholder — ` +
382
+ `has someone reintroduced a hardcoded version? ` +
383
+ `Re-run scripts/sync-rumen-functions.sh to repopulate the placeholder.`
384
+ );
385
+ }
386
+ fs.writeFileSync(destPath, raw.replace(/__RUMEN_VERSION__/g, rumenVersion));
387
+ } else {
388
+ fs.copyFileSync(srcPath, destPath);
351
389
  }
352
- fs.writeFileSync(destPath, raw.replace(/__RUMEN_VERSION__/g, rumenVersion));
353
- } else {
354
- fs.copyFileSync(srcPath, destPath);
355
390
  }
356
391
  }
357
392
 
393
+ const fnBlocks = fnNames.map((name) => `[functions.${name}]\nverify_jwt = false\n`).join('\n');
358
394
  const configToml = `# staged by termdeck init --rumen
359
395
  project_id = "termdeck-rumen-stage"
360
396
 
361
- [functions.rumen-tick]
362
- verify_jwt = false
363
- `;
397
+ ${fnBlocks}`;
364
398
  fs.writeFileSync(path.join(stage, 'supabase', 'config.toml'), configToml);
365
399
 
366
400
  return stage;
@@ -428,26 +462,53 @@ async function testFunction(projectRef, secrets, dryRun) {
428
462
  return true;
429
463
  }
430
464
 
465
+ // Sprint 42 T3: bundle of cron-schedule migrations whose `<project-ref>`
466
+ // placeholder must be substituted at apply-time. Both are idempotent
467
+ // (cron.unschedule + cron.schedule), so applying in sequence is safe even
468
+ // when one was already installed. Pre-Sprint 42, only 002 was applied —
469
+ // migration 003 (graph-inference-tick) shipped bundled but unsubstituted
470
+ // and unscheduled, which is part of why Sprint 38 close-out left the
471
+ // graph-inference cron disabled.
472
+ const SCHEDULE_MIGRATIONS = [
473
+ { matcher: /002.*pg_cron/, label: '002_pg_cron_schedule (rumen-tick)' },
474
+ { matcher: /003.*graph_inference/, label: '003_graph_inference_schedule (graph-inference-tick)' }
475
+ ];
476
+
431
477
  async function applySchedule(projectRef, secrets, dryRun) {
432
- step('Applying pg_cron schedule (every 15 minutes)...');
478
+ step('Applying pg_cron schedules (rumen-tick + graph-inference-tick)...');
433
479
  if (dryRun) { ok('(dry-run)'); return true; }
434
480
 
435
481
  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.
482
+ const planned = [];
483
+ for (const { matcher, label } of SCHEDULE_MIGRATIONS) {
484
+ const file = files.find((f) => matcher.test(path.basename(f)));
485
+ if (!file) { fail(`bundled ${label} is missing`); return false; }
486
+ planned.push({ file, label });
487
+ }
488
+
489
+ // Substitute the project ref into each schedule body. Both bundled
490
+ // migrations ship with the `<project-ref>` placeholder per Rumen's
491
+ // deploy docs; the helper also accepts `{{PROJECT_REF}}` for robustness
492
+ // and refuses to ship an unsubstituted placeholder to the database.
493
+ const substituted = [];
494
+ try {
495
+ for (const { file, label } of planned) {
496
+ const raw = migrations.readFile(file);
497
+ substituted.push({
498
+ sql: migrationTemplating.applyTemplating(raw, { projectRef }),
499
+ label
500
+ });
501
+ }
502
+ } catch (err) {
503
+ fail(err.message);
504
+ return false;
505
+ }
506
+
507
+ // The shipped migrations use Supabase Vault (`vault.decrypted_secrets`)
508
+ // to pull the service-role keys (`rumen_service_role_key` for 002 and
509
+ // `graph_inference_service_role_key` for 003). If a key isn't stored in
510
+ // Vault the corresponding cron call will fail at runtime. We leave that
511
+ // as a post-install step and print a reminder below.
451
512
 
452
513
  let client;
453
514
  try {
@@ -457,20 +518,21 @@ async function applySchedule(projectRef, secrets, dryRun) {
457
518
  return false;
458
519
  }
459
520
  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;
521
+ for (const { sql, label } of substituted) {
522
+ try {
523
+ await pgRunner.run(client, sql);
524
+ } catch (err) {
525
+ fail(`${label}: ${err.message}`);
526
+ process.stderr.write(
527
+ '\nThe schedule SQL failed — the most common cause is that pg_cron or pg_net\n' +
528
+ 'is not enabled in the Supabase project. Enable them in Dashboard → Database\n' +
529
+ ' Extensions, then re-run `termdeck init --rumen --skip-schedule=false`.\n'
530
+ );
531
+ return false;
532
+ }
473
533
  }
534
+ ok();
535
+ return true;
474
536
  } finally {
475
537
  try { await client.end(); } catch (_err) { /* ignore */ }
476
538
  }
@@ -540,7 +602,8 @@ function wireAccessTokenInMcpJson({ token, mcpJsonPath, _testFs } = {}) {
540
602
  }
541
603
 
542
604
  function printNextSteps(projectRef) {
543
- const functionUrl = `https://${projectRef}.supabase.co/functions/v1/rumen-tick`;
605
+ const rumenTickUrl = `https://${projectRef}.supabase.co/functions/v1/rumen-tick`;
606
+ const graphInferenceUrl = `https://${projectRef}.supabase.co/functions/v1/graph-inference`;
544
607
  const now = new Date();
545
608
  // Round up to the next 15-minute mark so the hint is accurate.
546
609
  const next = new Date(now.getTime());
@@ -548,16 +611,20 @@ function printNextSteps(projectRef) {
548
611
  process.stdout.write(`
549
612
  Rumen is deployed.
550
613
 
551
- Schedule: every 15 minutes via pg_cron
552
- First scheduled run: ${next.toISOString().replace(/\.\d+Z$/, 'Z')}
553
- Edge Function URL: ${functionUrl}
614
+ Edge Functions:
615
+ rumen-tick every 15 min — first run: ${next.toISOString().replace(/\.\d+Z$/, 'Z')}
616
+ ${rumenTickUrl}
617
+ graph-inference daily at 03:00 UTC (Sprint 42 cron)
618
+ ${graphInferenceUrl}
554
619
 
555
620
  Next steps:
556
- 1. Monitor: psql "$DATABASE_URL" -c "SELECT * FROM rumen_jobs ORDER BY started_at DESC LIMIT 5"
557
- 2. Store the service_role key in Supabase Vault as \`rumen_service_role_key\`
558
- so the cron call in migrations/002_pg_cron_schedule.sql can authenticate.
621
+ 1. Monitor rumen jobs: psql "$DATABASE_URL" -c "SELECT * FROM rumen_jobs ORDER BY started_at DESC LIMIT 5"
622
+ 2. Store service_role keys in Supabase Vault required for both cron schedules:
623
+ rumen_service_role_key (used by 002_pg_cron_schedule.sql)
624
+ graph_inference_service_role_key (used by 003_graph_inference_schedule.sql)
559
625
  3. Rumen insights flow back into Mnestra's memory_items via rumen_insights.
560
- 4. TermDeck's Flashback will surface cross-project patterns automatically.
626
+ 4. graph-inference fills memory_relationships edges nightly (cosine similarity ≥ 0.85).
627
+ 5. TermDeck's Flashback will surface cross-project patterns automatically.
561
628
  `);
562
629
  }
563
630
 
@@ -635,7 +702,7 @@ async function main(argv) {
635
702
  process.stderr.write(` ! falling back to pinned FALLBACK_RUMEN_VERSION=${FALLBACK_RUMEN_VERSION}\n`);
636
703
  }
637
704
 
638
- if (!deployFunction(resolved.version, flags.dryRun)) return 6;
705
+ if (!deployFunctions(resolved.version, flags.dryRun)) return 6;
639
706
  if (!setFunctionSecrets(secrets, flags.dryRun)) return 7;
640
707
  if (!(await testFunction(projectRef, secrets, flags.dryRun))) return 8;
641
708
  if (!flags.skipSchedule) {
@@ -671,3 +738,6 @@ module.exports = main;
671
738
  // pin the access-token detection without spawning a real `supabase` binary.
672
739
  module.exports._looksLikeMissingAccessToken = looksLikeMissingAccessToken;
673
740
  module.exports._wireAccessTokenInMcpJson = wireAccessTokenInMcpJson;
741
+ // Sprint 43 T3: stage helper exposed so init-rumen-deploy.test.js can pin
742
+ // the multi-function staging contract without shelling out to `supabase`.
743
+ module.exports._stageRumenFunctions = stageRumenFunctions;
@@ -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",
@@ -238,7 +303,7 @@
238
303
  updatePanelMeta(id, msg.session.meta);
239
304
  break;
240
305
  case 'proactive_memory':
241
- showProactiveToast(id, msg.hit);
306
+ showProactiveToast(id, msg.hit, msg.flashback_event_id);
242
307
  break;
243
308
  case 'exit':
244
309
  updatePanelMeta(id, {
@@ -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
  };
@@ -509,7 +583,7 @@
509
583
  }
510
584
  }
511
585
 
512
- function showProactiveToast(id, hit) {
586
+ function showProactiveToast(id, hit, flashbackEventId) {
513
587
  const entry = state.sessions.get(id);
514
588
  if (!entry || !entry.el) return;
515
589
 
@@ -532,16 +606,29 @@
532
606
 
533
607
  entry.el.appendChild(toast);
534
608
 
609
+ // Sprint 43 T2: track dismiss/click-through against flashback_events.
610
+ // The id is set server-side in the proactive_memory WS frame; if it's
611
+ // missing (server-side INSERT failed, or older server) the POSTs are
612
+ // skipped and the live toast still works — persistence is best-effort.
535
613
  const dismiss = () => {
536
614
  toast.remove();
537
615
  clearTimeout(toast._autoTimer);
616
+ if (flashbackEventId) {
617
+ fetch(`${API}/api/flashback/${flashbackEventId}/dismissed`, { method: 'POST' })
618
+ .catch((err) => console.warn('[flashback] dismiss POST failed:', err.message));
619
+ }
538
620
  };
539
621
  toast.querySelector('.t-dismiss').addEventListener('click', (e) => {
540
622
  e.stopPropagation();
541
623
  dismiss();
542
624
  });
543
625
  toast.addEventListener('click', () => {
544
- dismiss();
626
+ toast.remove();
627
+ clearTimeout(toast._autoTimer);
628
+ if (flashbackEventId) {
629
+ fetch(`${API}/api/flashback/${flashbackEventId}/clicked`, { method: 'POST' })
630
+ .catch((err) => console.warn('[flashback] clicked POST failed:', err.message));
631
+ }
545
632
  showFlashbackModal(hit, id);
546
633
  });
547
634
 
@@ -1260,7 +1347,7 @@
1260
1347
  updatePanelMeta(id, msg.session.meta);
1261
1348
  break;
1262
1349
  case 'proactive_memory':
1263
- showProactiveToast(id, msg.hit);
1350
+ showProactiveToast(id, msg.hit, msg.flashback_event_id);
1264
1351
  break;
1265
1352
  case 'exit':
1266
1353
  updatePanelMeta(id, { status: 'exited', statusDetail: `Exited (${msg.exitCode})` });
@@ -1281,6 +1368,14 @@
1281
1368
  if (typeof updateRagIndicator === 'function') updateRagIndicator();
1282
1369
  }
1283
1370
  break;
1371
+ case 'projects_changed':
1372
+ // Sprint 42 T4: parity with main WS handler. Project add/remove
1373
+ // broadcasts arrive on every ws client; idempotent.
1374
+ if (msg.projects && state.config) {
1375
+ state.config.projects = msg.projects;
1376
+ if (typeof rebuildProjectDropdown === 'function') rebuildProjectDropdown();
1377
+ }
1378
+ break;
1284
1379
  }
1285
1380
  } catch (err) { console.error('[client] reconnect ws message failed:', err); }
1286
1381
  };
@@ -1552,6 +1647,104 @@
1552
1647
  }
1553
1648
  }
1554
1649
 
1650
+ // ===== Remove Project modal (Sprint 42 T4) =====
1651
+ // Removes a project from ~/.termdeck/config.yaml. Files on disk at the
1652
+ // project's `path` are NEVER touched — the modal copy makes that explicit
1653
+ // so users don't fear data loss. 409 from the server (live PTY sessions
1654
+ // for that project) prompts the user with a force-override.
1655
+ function openRemoveProjectModal() {
1656
+ const modal = document.getElementById('removeProjectModal');
1657
+ const sel = document.getElementById('rpmSelect');
1658
+ sel.innerHTML = '<option value="">— pick a project —</option>';
1659
+ for (const name of Object.keys(state.config.projects || {})) {
1660
+ const opt = document.createElement('option');
1661
+ opt.value = name;
1662
+ opt.textContent = name;
1663
+ sel.appendChild(opt);
1664
+ }
1665
+ sel.value = '';
1666
+ document.getElementById('rpmConfirm').disabled = true;
1667
+ document.getElementById('rpmConfirm').dataset.force = '';
1668
+ document.getElementById('rpmConfirm').textContent = 'remove project';
1669
+ const warn = document.getElementById('rpmWarning');
1670
+ warn.hidden = true;
1671
+ warn.textContent = '';
1672
+ setRpmStatus('', null);
1673
+ modal.classList.add('open');
1674
+ setTimeout(() => sel.focus(), 50);
1675
+ }
1676
+
1677
+ function closeRemoveProjectModal() {
1678
+ document.getElementById('removeProjectModal').classList.remove('open');
1679
+ }
1680
+
1681
+ function setRpmStatus(msg, kind) {
1682
+ const el = document.getElementById('rpmStatus');
1683
+ if (!el) return;
1684
+ el.textContent = msg || '';
1685
+ el.classList.remove('error', 'ok');
1686
+ if (kind) el.classList.add(kind);
1687
+ }
1688
+
1689
+ function onRpmSelectChange() {
1690
+ const name = document.getElementById('rpmSelect').value;
1691
+ const btn = document.getElementById('rpmConfirm');
1692
+ btn.disabled = !name;
1693
+ btn.dataset.force = '';
1694
+ btn.textContent = name ? `remove "${name}"` : 'remove project';
1695
+ const warn = document.getElementById('rpmWarning');
1696
+ warn.hidden = true;
1697
+ warn.textContent = '';
1698
+ setRpmStatus('', null);
1699
+ }
1700
+
1701
+ async function submitRemoveProject() {
1702
+ const name = document.getElementById('rpmSelect').value;
1703
+ if (!name) return;
1704
+ const btn = document.getElementById('rpmConfirm');
1705
+ const force = btn.dataset.force === 'true';
1706
+ btn.disabled = true;
1707
+ setRpmStatus(force ? 'Removing (with force)…' : 'Removing…', null);
1708
+
1709
+ try {
1710
+ const url = `${API}/api/projects/${encodeURIComponent(name)}${force ? '?force=true' : ''}`;
1711
+ const res = await fetch(url, { method: 'DELETE' });
1712
+ const text = await res.text();
1713
+ let body = {};
1714
+ try { body = JSON.parse(text); } catch { body = { error: text }; }
1715
+
1716
+ if (res.status === 409) {
1717
+ const live = body.liveSessions || 0;
1718
+ const warn = document.getElementById('rpmWarning');
1719
+ warn.hidden = false;
1720
+ warn.innerHTML =
1721
+ `<strong>"${name}" has ${live} live PTY session${live === 1 ? '' : 's'}.</strong> ` +
1722
+ `Closing those terminals first is recommended. ` +
1723
+ `Or click <em>remove anyway</em> to force removal — terminals stay open but lose their project tag in config.yaml.`;
1724
+ btn.dataset.force = 'true';
1725
+ btn.textContent = 'remove anyway';
1726
+ btn.disabled = false;
1727
+ setRpmStatus('', null);
1728
+ return;
1729
+ }
1730
+
1731
+ if (!res.ok) {
1732
+ setRpmStatus(`Failed: ${body.error || res.statusText}`, 'error');
1733
+ btn.disabled = false;
1734
+ return;
1735
+ }
1736
+
1737
+ // Success — sync in-memory config + dropdown.
1738
+ state.config.projects = body.projects || {};
1739
+ rebuildProjectDropdown();
1740
+ setRpmStatus(`Removed "${name}" ✓ (files on disk untouched)`, 'ok');
1741
+ setTimeout(() => { closeRemoveProjectModal(); }, 900);
1742
+ } catch (err) {
1743
+ setRpmStatus(`Failed: ${err.message || err}`, 'error');
1744
+ btn.disabled = false;
1745
+ }
1746
+ }
1747
+
1555
1748
  // ===== Orchestration preview modal (Sprint 37 T3) =====
1556
1749
  // The preview button next to the project select shows what
1557
1750
  // `termdeck init --project <name>` would create for the currently
@@ -3714,6 +3907,16 @@
3714
3907
  if (e.key === 'Escape') { e.preventDefault(); closeAddProjectModal(); }
3715
3908
  });
3716
3909
 
3910
+ // Remove-project modal wiring (Sprint 42 T4)
3911
+ document.getElementById('btnRemoveProject').addEventListener('click', openRemoveProjectModal);
3912
+ document.getElementById('rpmCancel').addEventListener('click', closeRemoveProjectModal);
3913
+ document.getElementById('rpmConfirm').addEventListener('click', submitRemoveProject);
3914
+ document.getElementById('rpmSelect').addEventListener('change', onRpmSelectChange);
3915
+ document.querySelector('#removeProjectModal .remove-project-backdrop').addEventListener('click', closeRemoveProjectModal);
3916
+ document.getElementById('removeProjectModal').addEventListener('keydown', (e) => {
3917
+ if (e.key === 'Escape') { e.preventDefault(); closeRemoveProjectModal(); }
3918
+ });
3919
+
3717
3920
  // Orchestration preview modal wiring (Sprint 37 T3)
3718
3921
  document.getElementById('btnPreviewProject').addEventListener('click', openPreviewModal);
3719
3922
  document.getElementById('promptProject').addEventListener('change', syncPreviewButton);