@officexapp/vidfarm-devcli 0.21.12 → 0.21.14

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.
@@ -0,0 +1,173 @@
1
+ // Detect (and optionally reap) local vidfarm / hyperframes server processes.
2
+ //
3
+ // Two failure modes this exists for:
4
+ // 1. Concurrency — a customer runs several local video jobs; `doctor` should
5
+ // show which serve/preview boxes are up and on what ports.
6
+ // 2. Orphans — when the devcli is upgraded, renamed (e.g. @mevdragon →
7
+ // @officexapp), or its package dir is pruned, any still-running serve /
8
+ // hyperframes-preview process keeps holding its port but now executes from
9
+ // a DELETED path. Such a process serves broken assets forever (the classic
10
+ // "Studio bundle missing" / "Waiting for preview server…" hang) yet never
11
+ // exits. We flag those as `orphaned` (their script file no longer exists)
12
+ // so they can be reaped and the port reclaimed.
13
+ //
14
+ // Backend-free: only `node:child_process` (ps) + `node:fs`. POSIX only — on
15
+ // win32 we return an empty list with a note rather than guessing at wmic.
16
+ import { spawnSync } from "node:child_process";
17
+ import { existsSync } from "node:fs";
18
+ // A command line is one of OUR long-running local servers only if it drives a
19
+ // `vidfarm serve` (or its `cli.js serve` runtime) or a `hyperframes preview`
20
+ // server. Short-lived commands (`vidfarm jobs`, `vidfarm render`, …) are NOT
21
+ // servers and must not appear — they hold no port and would only add noise.
22
+ function classify(command) {
23
+ const c = command.toLowerCase();
24
+ const isPreview = /hyperframes\b.*\bpreview\b/.test(c) || /\bpreview\b\s.*--port/.test(c);
25
+ if (isPreview)
26
+ return "preview";
27
+ const isVidfarm = /\bvidfarm\b/.test(c) || /vidfarm-devcli/.test(c) || /\/cli\.js\b/.test(c);
28
+ if (isVidfarm && /\bserve\b/.test(c))
29
+ return "serve";
30
+ return null;
31
+ }
32
+ function parsePort(command) {
33
+ const m = command.match(/--port(?:[=\s]+)(\d{2,5})\b/);
34
+ if (m)
35
+ return Number(m[1]);
36
+ return null;
37
+ }
38
+ // Fallback when the command line carries no --port (the default port, or an
39
+ // auto-advanced one): ask lsof for the pid's listening TCP socket. Best-effort
40
+ // and cheap — one lsof per matched SERVER process, not per ps row.
41
+ function detectListeningPort(pid) {
42
+ const lsof = spawnSync("lsof", ["-nP", "-a", "-p", String(pid), "-iTCP", "-sTCP:LISTEN", "-Fn"], {
43
+ encoding: "utf8",
44
+ timeout: 2_000
45
+ });
46
+ if (lsof.status !== 0 || !lsof.stdout)
47
+ return null;
48
+ // -Fn emits lines like `n*:3001` / `n127.0.0.1:3001` per listening socket.
49
+ const m = lsof.stdout.match(/^n.*:(\d{2,5})$/m);
50
+ return m ? Number(m[1]) : null;
51
+ }
52
+ // Pull the launched script path (the first `.../something.js` token after the
53
+ // node executable) so we can test whether it still exists on disk.
54
+ function parseScriptPath(command) {
55
+ const m = command.match(/\s(\/[^\s]+?\.(?:js|mjs|cjs))\b/);
56
+ return m ? m[1] : null;
57
+ }
58
+ /**
59
+ * Enumerate local vidfarm/hyperframes server processes via `ps`. Best-effort:
60
+ * any parsing failure yields an empty (but `supported:true`) list rather than
61
+ * throwing, so callers can treat this as advisory.
62
+ */
63
+ export function scanLocalServers() {
64
+ if (process.platform === "win32") {
65
+ return { supported: false, note: "process scan is POSIX-only (macOS/Linux)", servers: [] };
66
+ }
67
+ const ps = spawnSync("ps", ["-Ao", "pid=,command="], { encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
68
+ if (ps.status !== 0 || !ps.stdout) {
69
+ return { supported: false, note: "could not run `ps`", servers: [] };
70
+ }
71
+ const servers = [];
72
+ for (const line of ps.stdout.split("\n")) {
73
+ const trimmed = line.trim();
74
+ if (!trimmed)
75
+ continue;
76
+ const spaceIdx = trimmed.indexOf(" ");
77
+ if (spaceIdx === -1)
78
+ continue;
79
+ const pid = Number(trimmed.slice(0, spaceIdx));
80
+ if (!Number.isInteger(pid))
81
+ continue;
82
+ const command = trimmed.slice(spaceIdx + 1).trim();
83
+ // Skip our own scanning invocation (the `ps` line itself and this grep-like
84
+ // command) and anything that isn't a vidfarm/hyperframes server.
85
+ const kind = classify(command);
86
+ if (!kind)
87
+ continue;
88
+ const scriptPath = parseScriptPath(command);
89
+ // Orphaned = launched from a script file that no longer exists on disk.
90
+ // Only assert this when we actually resolved a path (else unknown, not orphaned).
91
+ const orphaned = Boolean(scriptPath) && !existsSync(scriptPath);
92
+ servers.push({
93
+ pid,
94
+ command,
95
+ kind,
96
+ port: parsePort(command) ?? detectListeningPort(pid),
97
+ scriptPath,
98
+ orphaned,
99
+ isSelf: pid === process.pid
100
+ });
101
+ }
102
+ return { supported: true, servers };
103
+ }
104
+ /** True when `pid` is still alive (signal 0 probes without delivering). */
105
+ export function isAlive(pid) {
106
+ try {
107
+ process.kill(pid, 0);
108
+ return true;
109
+ }
110
+ catch (error) {
111
+ // ESRCH = gone; EPERM = alive but not ours to signal.
112
+ return error.code === "EPERM";
113
+ }
114
+ }
115
+ /**
116
+ * Reap the given pids (never self): SIGTERM first, then — after `graceMs` —
117
+ * SIGKILL any that ignored it. Returns per-pid outcomes with the strongest
118
+ * signal delivered and whether the process is confirmed gone. Async so the
119
+ * grace period doesn't block the event loop.
120
+ */
121
+ export async function reapProcesses(pids, graceMs = 1500) {
122
+ const results = new Map();
123
+ const pending = [];
124
+ for (const pid of pids) {
125
+ if (pid === process.pid) {
126
+ results.set(pid, { pid, signal: null, killed: false, error: "refusing to kill self" });
127
+ continue;
128
+ }
129
+ try {
130
+ process.kill(pid, "SIGTERM");
131
+ results.set(pid, { pid, signal: "SIGTERM", killed: false });
132
+ pending.push(pid);
133
+ }
134
+ catch (error) {
135
+ const code = error.code;
136
+ // ESRCH → already gone (success); anything else → real failure.
137
+ results.set(pid, {
138
+ pid,
139
+ signal: null,
140
+ killed: code === "ESRCH",
141
+ error: code === "ESRCH" ? undefined : (error instanceof Error ? error.message : String(error))
142
+ });
143
+ }
144
+ }
145
+ if (pending.length > 0) {
146
+ await new Promise((resolve) => setTimeout(resolve, graceMs));
147
+ for (const pid of pending) {
148
+ const result = results.get(pid);
149
+ if (!isAlive(pid)) {
150
+ result.killed = true;
151
+ continue;
152
+ }
153
+ // Ignored SIGTERM — escalate to SIGKILL, which cannot be caught or
154
+ // ignored. A successful delivery (or ESRCH = already gone) means the
155
+ // process is reaped; we do NOT re-probe immediately because the kernel
156
+ // may not have torn it down yet (and a child pid lingers as a zombie
157
+ // until waited on). Only EPERM/other errors are real failures.
158
+ try {
159
+ process.kill(pid, "SIGKILL");
160
+ result.signal = "SIGKILL";
161
+ result.killed = true;
162
+ }
163
+ catch (error) {
164
+ const code = error.code;
165
+ result.killed = code === "ESRCH";
166
+ if (!result.killed)
167
+ result.error = error instanceof Error ? error.message : String(error);
168
+ }
169
+ }
170
+ }
171
+ return pids.map((pid) => results.get(pid));
172
+ }
173
+ //# sourceMappingURL=process-scan.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@officexapp/vidfarm-devcli",
3
- "version": "0.21.12",
3
+ "version": "0.21.14",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,8 @@
21
21
  "dist/src/devcli/local-backend.js",
22
22
  "dist/src/devcli/local-frontend-server.js",
23
23
  "dist/src/devcli/local-render.js",
24
+ "dist/src/devcli/port-utils.js",
25
+ "dist/src/devcli/process-scan.js",
24
26
  "dist/src/devcli/skills.js",
25
27
  "dist/src/devcli/speech.js",
26
28
  "dist/src/devcli/stills.js",
@@ -1490,6 +1490,23 @@ html,body{margin:0;background:#050604;color:#fffbe6}
1490
1490
  setLeftMode(''); // show the conversation, not the Files/History drawer
1491
1491
  openThread(id);
1492
1492
  }
1493
+ var hadHandoff = !!handoffThread;
1494
+ // Sticky active thread per template so a page refresh reopens the SAME
1495
+ // conversation instead of a blank chat (server routes deliberately never
1496
+ // attach ?thread= to editor URLs — the browser owns "which chat is active").
1497
+ function activeThreadKey() { return 'rk-chat-active:' + TEMPLATE_ID; }
1498
+ function saveActiveThread(id) {
1499
+ try { if (id) localStorage.setItem(activeThreadKey(), id); else localStorage.removeItem(activeThreadKey()); } catch (e) {}
1500
+ }
1501
+ var restoredActive = false;
1502
+ function restoreActiveThread() {
1503
+ if (restoredActive || hadHandoff) return;
1504
+ restoredActive = true;
1505
+ if (convo.length) return; // user already chatting — don't clobber
1506
+ var saved = null;
1507
+ try { saved = localStorage.getItem(activeThreadKey()); } catch (e) {}
1508
+ if (saved) openThread(saved);
1509
+ }
1493
1510
  var busy = false;
1494
1511
  var pendingAbort = null; // AbortController for the in-flight reply (Stop button)
1495
1512
 
@@ -1540,14 +1557,20 @@ html,body{margin:0;background:#050604;color:#fffbe6}
1540
1557
  // outgoing user turn — verbatim to what the SPA's own chat sends — so the agent
1541
1558
  // knows which fork to read (video_context) and mutate (editor_action). Only the
1542
1559
  // /editor dock has this bridge; elsewhere it returns ''.
1560
+ // Async: the Option-B bridge's getSnapshot() returns a PROMISE (it re-reads the
1561
+ // composition through the files API). The old sync call JSON.stringify'd the
1562
+ // Promise itself, sending the model a literal "{}" editor_context — no fork id,
1563
+ // no layers, no viral DNA. Always resolve before serializing.
1543
1564
  function editorContextBlock() {
1544
- if (!isEditorDock) return '';
1565
+ if (!isEditorDock) return Promise.resolve('');
1545
1566
  var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
1546
- if (!bridge || typeof bridge.getSnapshot !== 'function') return '';
1567
+ if (!bridge || typeof bridge.getSnapshot !== 'function') return Promise.resolve('');
1547
1568
  var snap; try { snap = bridge.getSnapshot(); } catch (e) { snap = null; }
1548
- if (!snap) return '';
1549
- try { return '\n\n<editor_context>\n' + JSON.stringify(snap, null, 2) + '\n</editor_context>'; }
1550
- catch (e) { return ''; }
1569
+ return Promise.resolve(snap).then(function (s) {
1570
+ if (!s) return '';
1571
+ try { return '\n\n<editor_context>\n' + JSON.stringify(s, null, 2) + '\n</editor_context>'; }
1572
+ catch (e) { return ''; }
1573
+ }, function () { return ''; });
1551
1574
  }
1552
1575
  function loadBoot() {
1553
1576
  if (BOOT_STATE === 'ready' || BOOT_STATE === 'loading') return;
@@ -1570,6 +1593,7 @@ html,body{margin:0;background:#050604;color:#fffbe6}
1570
1593
  loadThreads();
1571
1594
  if (leftMode() === 'cloud') loadTasks();
1572
1595
  consumeHandoff();
1596
+ restoreActiveThread();
1573
1597
  })
1574
1598
  .catch(function () { BOOT_STATE = 'error'; });
1575
1599
  }
@@ -2123,8 +2147,10 @@ html,body{margin:0;background:#050604;color:#fffbe6}
2123
2147
  setBusy(true);
2124
2148
  if (!threadId) threadId = genId('thread');
2125
2149
  // Attach a fresh <editor_context> to the current (last) user turn only, so the
2126
- // model sees the composition state without bloating persisted history.
2127
- var ctxBlock = editorContextBlock();
2150
+ // model sees the composition state without bloating persisted history. The
2151
+ // block resolves asynchronously (files-API read) — wait for it before building
2152
+ // the outgoing messages so the model actually receives the composition state.
2153
+ editorContextBlock().then(function (ctxBlock) {
2128
2154
  // Attachments (pasted files OR files picked from the directory explorer) must
2129
2155
  // ride in the model messages as file content parts + a URL text line — the
2130
2156
  // backend only feeds the model messages[].content, NOT user_message.attachments
@@ -2142,6 +2168,9 @@ html,body{margin:0;background:#050604;color:#fffbe6}
2142
2168
  }
2143
2169
  return { role: m.role, content: content };
2144
2170
  });
2171
+ // The send is what turns a freshly minted thread id into a real saved
2172
+ // thread — make it the sticky-restore target from this moment on.
2173
+ saveActiveThread(threadId);
2145
2174
  var body = {
2146
2175
  messages: outMessages,
2147
2176
  thread_id: threadId,
@@ -2205,6 +2234,7 @@ html,body{margin:0;background:#050604;color:#fffbe6}
2205
2234
  if (!API_KEY) msg = msg + '\n\nAdd an AI provider key in Settings to chat on your own keys.';
2206
2235
  view.fail(msg); setBusy(false); if (input) input.focus();
2207
2236
  });
2237
+ }); // end editorContextBlock().then
2208
2238
  }
2209
2239
 
2210
2240
  function resetConversation() {
@@ -2458,10 +2488,25 @@ html,body{margin:0;background:#050604;color:#fffbe6}
2458
2488
  // fall through to chat-attach for folders / non-placeable files.
2459
2489
  if (isEditorDock && it && it.viewUrl) {
2460
2490
  var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
2461
- if (bridge && typeof bridge.placeMediaAtPlayhead === 'function') {
2462
- var res = bridge.placeMediaAtPlayhead({ url: it.viewUrl, name: it.name, contentType: it.contentType });
2463
- if (res && res.ok) { editorPlaceToast('Added \u201c' + (it.name || 'media') + '\u201d to the timeline'); return; }
2464
- // Non-placeable (folder / doc / not-ready): fall through to chat-attach.
2491
+ // Placeability must be decided SYNCHRONOUSLY (the Option-B bridge returns a
2492
+ // Promise, so we can't branch on its result to decide chat-attach fallback —
2493
+ // checking ".ok" on the Promise made EVERY click fall through, placing the
2494
+ // media AND attaching it to chat, with no toast). Only image/video/audio go
2495
+ // on the timeline; folders/docs still fall through to chat-attach.
2496
+ var ct = String(it.contentType || '');
2497
+ if (bridge && typeof bridge.placeMediaAtPlayhead === 'function'
2498
+ && (ct.indexOf('image/') === 0 || ct.indexOf('video/') === 0 || ct.indexOf('audio/') === 0)) {
2499
+ var placedName = it.name || 'media';
2500
+ var settlePlace = function (r) {
2501
+ if (r && r.ok) { editorPlaceToast('Added \u201c' + placedName + '\u201d to the timeline'); }
2502
+ else { editorPlaceToast('Couldn\u2019t add \u201c' + placedName + '\u201d' + ((r && r.error) ? ': ' + r.error : ''), true); }
2503
+ };
2504
+ try {
2505
+ var res = bridge.placeMediaAtPlayhead({ url: it.viewUrl, name: it.name, contentType: it.contentType });
2506
+ if (res && typeof res.then === 'function') { res.then(settlePlace, function (e) { settlePlace({ ok: false, error: (e && e.message) ? e.message : String(e) }); }); }
2507
+ else { settlePlace(res); }
2508
+ } catch (e) { settlePlace({ ok: false, error: (e && e.message) ? e.message : String(e) }); }
2509
+ return;
2465
2510
  }
2466
2511
  }
2467
2512
  // Files-only drawer (opened from the /chat page) has no chat composer of its
@@ -2578,7 +2623,11 @@ html,body{margin:0;background:#050604;color:#fffbe6}
2578
2623
  return wrap;
2579
2624
  }
2580
2625
  function setActiveThread(id) {
2581
- threadId = id;
2626
+ // null = "fresh unsaved chat": KEEP the freshly minted threadId (sends must
2627
+ // always carry a real thread_id or the server silently skips persistence)
2628
+ // and clear the sticky restore key; a real id becomes both current + sticky.
2629
+ if (id) { threadId = id; saveActiveThread(id); }
2630
+ else { saveActiveThread(null); }
2582
2631
  if (!histBody) return;
2583
2632
  var rows = histBody.querySelectorAll('.rk-aichat-frow');
2584
2633
  for (var i = 0; i < rows.length; i++) rows[i].classList.toggle('is-active', rows[i].getAttribute('data-id') === id);
@@ -2671,7 +2720,7 @@ html,body{margin:0;background:#050604;color:#fffbe6}
2671
2720
  .then(function (r) {
2672
2721
  if (!r.ok && r.status !== 404) throw new Error('http ' + r.status);
2673
2722
  threads = threads.filter(function (t) { return t.id !== id; });
2674
- if (id === threadId) resetConversation();
2723
+ if (id === threadId) { resetConversation(); saveActiveThread(null); }
2675
2724
  renderHistory();
2676
2725
  })
2677
2726
  .catch(function () {});
@@ -1799,6 +1799,23 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
1799
1799
  setLeftMode(''); // show the conversation, not the Files/History drawer
1800
1800
  openThread(id);
1801
1801
  }
1802
+ var hadHandoff = !!handoffThread;
1803
+ // Sticky active thread per template so a page refresh reopens the SAME
1804
+ // conversation instead of a blank chat (server routes deliberately never
1805
+ // attach ?thread= to editor URLs — the browser owns "which chat is active").
1806
+ function activeThreadKey() { return 'rk-chat-active:' + TEMPLATE_ID; }
1807
+ function saveActiveThread(id) {
1808
+ try { if (id) localStorage.setItem(activeThreadKey(), id); else localStorage.removeItem(activeThreadKey()); } catch (e) {}
1809
+ }
1810
+ var restoredActive = false;
1811
+ function restoreActiveThread() {
1812
+ if (restoredActive || hadHandoff) return;
1813
+ restoredActive = true;
1814
+ if (convo.length) return; // user already chatting — don't clobber
1815
+ var saved = null;
1816
+ try { saved = localStorage.getItem(activeThreadKey()); } catch (e) {}
1817
+ if (saved) openThread(saved);
1818
+ }
1802
1819
  var busy = false;
1803
1820
  var pendingAbort = null; // AbortController for the in-flight reply (Stop button)
1804
1821
 
@@ -1849,14 +1866,20 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
1849
1866
  // outgoing user turn — verbatim to what the SPA's own chat sends — so the agent
1850
1867
  // knows which fork to read (video_context) and mutate (editor_action). Only the
1851
1868
  // /editor dock has this bridge; elsewhere it returns ''.
1869
+ // Async: the Option-B bridge's getSnapshot() returns a PROMISE (it re-reads the
1870
+ // composition through the files API). The old sync call JSON.stringify'd the
1871
+ // Promise itself, sending the model a literal "{}" editor_context — no fork id,
1872
+ // no layers, no viral DNA. Always resolve before serializing.
1852
1873
  function editorContextBlock() {
1853
- if (!isEditorDock) return '';
1874
+ if (!isEditorDock) return Promise.resolve('');
1854
1875
  var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
1855
- if (!bridge || typeof bridge.getSnapshot !== 'function') return '';
1876
+ if (!bridge || typeof bridge.getSnapshot !== 'function') return Promise.resolve('');
1856
1877
  var snap; try { snap = bridge.getSnapshot(); } catch (e) { snap = null; }
1857
- if (!snap) return '';
1858
- try { return '\n\n<editor_context>\n' + JSON.stringify(snap, null, 2) + '\n</editor_context>'; }
1859
- catch (e) { return ''; }
1878
+ return Promise.resolve(snap).then(function (s) {
1879
+ if (!s) return '';
1880
+ try { return '\n\n<editor_context>\n' + JSON.stringify(s, null, 2) + '\n</editor_context>'; }
1881
+ catch (e) { return ''; }
1882
+ }, function () { return ''; });
1860
1883
  }
1861
1884
  function loadBoot() {
1862
1885
  if (BOOT_STATE === 'ready' || BOOT_STATE === 'loading') return;
@@ -1879,6 +1902,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
1879
1902
  loadThreads();
1880
1903
  if (leftMode() === 'cloud') loadTasks();
1881
1904
  consumeHandoff();
1905
+ restoreActiveThread();
1882
1906
  })
1883
1907
  .catch(function () { BOOT_STATE = 'error'; });
1884
1908
  }
@@ -2432,8 +2456,10 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2432
2456
  setBusy(true);
2433
2457
  if (!threadId) threadId = genId('thread');
2434
2458
  // Attach a fresh <editor_context> to the current (last) user turn only, so the
2435
- // model sees the composition state without bloating persisted history.
2436
- var ctxBlock = editorContextBlock();
2459
+ // model sees the composition state without bloating persisted history. The
2460
+ // block resolves asynchronously (files-API read) — wait for it before building
2461
+ // the outgoing messages so the model actually receives the composition state.
2462
+ editorContextBlock().then(function (ctxBlock) {
2437
2463
  // Attachments (pasted files OR files picked from the directory explorer) must
2438
2464
  // ride in the model messages as file content parts + a URL text line — the
2439
2465
  // backend only feeds the model messages[].content, NOT user_message.attachments
@@ -2451,6 +2477,9 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2451
2477
  }
2452
2478
  return { role: m.role, content: content };
2453
2479
  });
2480
+ // The send is what turns a freshly minted thread id into a real saved
2481
+ // thread — make it the sticky-restore target from this moment on.
2482
+ saveActiveThread(threadId);
2454
2483
  var body = {
2455
2484
  messages: outMessages,
2456
2485
  thread_id: threadId,
@@ -2514,6 +2543,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2514
2543
  if (!API_KEY) msg = msg + '\n\nAdd an AI provider key in Settings to chat on your own keys.';
2515
2544
  view.fail(msg); setBusy(false); if (input) input.focus();
2516
2545
  });
2546
+ }); // end editorContextBlock().then
2517
2547
  }
2518
2548
 
2519
2549
  function resetConversation() {
@@ -2767,10 +2797,25 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2767
2797
  // fall through to chat-attach for folders / non-placeable files.
2768
2798
  if (isEditorDock && it && it.viewUrl) {
2769
2799
  var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
2770
- if (bridge && typeof bridge.placeMediaAtPlayhead === 'function') {
2771
- var res = bridge.placeMediaAtPlayhead({ url: it.viewUrl, name: it.name, contentType: it.contentType });
2772
- if (res && res.ok) { editorPlaceToast('Added \u201c' + (it.name || 'media') + '\u201d to the timeline'); return; }
2773
- // Non-placeable (folder / doc / not-ready): fall through to chat-attach.
2800
+ // Placeability must be decided SYNCHRONOUSLY (the Option-B bridge returns a
2801
+ // Promise, so we can't branch on its result to decide chat-attach fallback —
2802
+ // checking ".ok" on the Promise made EVERY click fall through, placing the
2803
+ // media AND attaching it to chat, with no toast). Only image/video/audio go
2804
+ // on the timeline; folders/docs still fall through to chat-attach.
2805
+ var ct = String(it.contentType || '');
2806
+ if (bridge && typeof bridge.placeMediaAtPlayhead === 'function'
2807
+ && (ct.indexOf('image/') === 0 || ct.indexOf('video/') === 0 || ct.indexOf('audio/') === 0)) {
2808
+ var placedName = it.name || 'media';
2809
+ var settlePlace = function (r) {
2810
+ if (r && r.ok) { editorPlaceToast('Added \u201c' + placedName + '\u201d to the timeline'); }
2811
+ else { editorPlaceToast('Couldn\u2019t add \u201c' + placedName + '\u201d' + ((r && r.error) ? ': ' + r.error : ''), true); }
2812
+ };
2813
+ try {
2814
+ var res = bridge.placeMediaAtPlayhead({ url: it.viewUrl, name: it.name, contentType: it.contentType });
2815
+ if (res && typeof res.then === 'function') { res.then(settlePlace, function (e) { settlePlace({ ok: false, error: (e && e.message) ? e.message : String(e) }); }); }
2816
+ else { settlePlace(res); }
2817
+ } catch (e) { settlePlace({ ok: false, error: (e && e.message) ? e.message : String(e) }); }
2818
+ return;
2774
2819
  }
2775
2820
  }
2776
2821
  // Files-only drawer (opened from the /chat page) has no chat composer of its
@@ -2887,7 +2932,11 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2887
2932
  return wrap;
2888
2933
  }
2889
2934
  function setActiveThread(id) {
2890
- threadId = id;
2935
+ // null = "fresh unsaved chat": KEEP the freshly minted threadId (sends must
2936
+ // always carry a real thread_id or the server silently skips persistence)
2937
+ // and clear the sticky restore key; a real id becomes both current + sticky.
2938
+ if (id) { threadId = id; saveActiveThread(id); }
2939
+ else { saveActiveThread(null); }
2891
2940
  if (!histBody) return;
2892
2941
  var rows = histBody.querySelectorAll('.rk-aichat-frow');
2893
2942
  for (var i = 0; i < rows.length; i++) rows[i].classList.toggle('is-active', rows[i].getAttribute('data-id') === id);
@@ -2980,7 +3029,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2980
3029
  .then(function (r) {
2981
3030
  if (!r.ok && r.status !== 404) throw new Error('http ' + r.status);
2982
3031
  threads = threads.filter(function (t) { return t.id !== id; });
2983
- if (id === threadId) resetConversation();
3032
+ if (id === threadId) { resetConversation(); saveActiveThread(null); }
2984
3033
  renderHistory();
2985
3034
  })
2986
3035
  .catch(function () {});
@@ -2753,6 +2753,23 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2753
2753
  setLeftMode(''); // show the conversation, not the Files/History drawer
2754
2754
  openThread(id);
2755
2755
  }
2756
+ var hadHandoff = !!handoffThread;
2757
+ // Sticky active thread per template so a page refresh reopens the SAME
2758
+ // conversation instead of a blank chat (server routes deliberately never
2759
+ // attach ?thread= to editor URLs — the browser owns "which chat is active").
2760
+ function activeThreadKey() { return 'rk-chat-active:' + TEMPLATE_ID; }
2761
+ function saveActiveThread(id) {
2762
+ try { if (id) localStorage.setItem(activeThreadKey(), id); else localStorage.removeItem(activeThreadKey()); } catch (e) {}
2763
+ }
2764
+ var restoredActive = false;
2765
+ function restoreActiveThread() {
2766
+ if (restoredActive || hadHandoff) return;
2767
+ restoredActive = true;
2768
+ if (convo.length) return; // user already chatting — don't clobber
2769
+ var saved = null;
2770
+ try { saved = localStorage.getItem(activeThreadKey()); } catch (e) {}
2771
+ if (saved) openThread(saved);
2772
+ }
2756
2773
  var busy = false;
2757
2774
  var pendingAbort = null; // AbortController for the in-flight reply (Stop button)
2758
2775
 
@@ -2803,14 +2820,20 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2803
2820
  // outgoing user turn — verbatim to what the SPA's own chat sends — so the agent
2804
2821
  // knows which fork to read (video_context) and mutate (editor_action). Only the
2805
2822
  // /editor dock has this bridge; elsewhere it returns ''.
2823
+ // Async: the Option-B bridge's getSnapshot() returns a PROMISE (it re-reads the
2824
+ // composition through the files API). The old sync call JSON.stringify'd the
2825
+ // Promise itself, sending the model a literal "{}" editor_context — no fork id,
2826
+ // no layers, no viral DNA. Always resolve before serializing.
2806
2827
  function editorContextBlock() {
2807
- if (!isEditorDock) return '';
2828
+ if (!isEditorDock) return Promise.resolve('');
2808
2829
  var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
2809
- if (!bridge || typeof bridge.getSnapshot !== 'function') return '';
2830
+ if (!bridge || typeof bridge.getSnapshot !== 'function') return Promise.resolve('');
2810
2831
  var snap; try { snap = bridge.getSnapshot(); } catch (e) { snap = null; }
2811
- if (!snap) return '';
2812
- try { return '\n\n<editor_context>\n' + JSON.stringify(snap, null, 2) + '\n</editor_context>'; }
2813
- catch (e) { return ''; }
2832
+ return Promise.resolve(snap).then(function (s) {
2833
+ if (!s) return '';
2834
+ try { return '\n\n<editor_context>\n' + JSON.stringify(s, null, 2) + '\n</editor_context>'; }
2835
+ catch (e) { return ''; }
2836
+ }, function () { return ''; });
2814
2837
  }
2815
2838
  function loadBoot() {
2816
2839
  if (BOOT_STATE === 'ready' || BOOT_STATE === 'loading') return;
@@ -2833,6 +2856,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2833
2856
  loadThreads();
2834
2857
  if (leftMode() === 'cloud') loadTasks();
2835
2858
  consumeHandoff();
2859
+ restoreActiveThread();
2836
2860
  })
2837
2861
  .catch(function () { BOOT_STATE = 'error'; });
2838
2862
  }
@@ -3386,8 +3410,10 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
3386
3410
  setBusy(true);
3387
3411
  if (!threadId) threadId = genId('thread');
3388
3412
  // Attach a fresh <editor_context> to the current (last) user turn only, so the
3389
- // model sees the composition state without bloating persisted history.
3390
- var ctxBlock = editorContextBlock();
3413
+ // model sees the composition state without bloating persisted history. The
3414
+ // block resolves asynchronously (files-API read) — wait for it before building
3415
+ // the outgoing messages so the model actually receives the composition state.
3416
+ editorContextBlock().then(function (ctxBlock) {
3391
3417
  // Attachments (pasted files OR files picked from the directory explorer) must
3392
3418
  // ride in the model messages as file content parts + a URL text line — the
3393
3419
  // backend only feeds the model messages[].content, NOT user_message.attachments
@@ -3405,6 +3431,9 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
3405
3431
  }
3406
3432
  return { role: m.role, content: content };
3407
3433
  });
3434
+ // The send is what turns a freshly minted thread id into a real saved
3435
+ // thread — make it the sticky-restore target from this moment on.
3436
+ saveActiveThread(threadId);
3408
3437
  var body = {
3409
3438
  messages: outMessages,
3410
3439
  thread_id: threadId,
@@ -3468,6 +3497,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
3468
3497
  if (!API_KEY) msg = msg + '\n\nAdd an AI provider key in Settings to chat on your own keys.';
3469
3498
  view.fail(msg); setBusy(false); if (input) input.focus();
3470
3499
  });
3500
+ }); // end editorContextBlock().then
3471
3501
  }
3472
3502
 
3473
3503
  function resetConversation() {
@@ -3721,10 +3751,25 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
3721
3751
  // fall through to chat-attach for folders / non-placeable files.
3722
3752
  if (isEditorDock && it && it.viewUrl) {
3723
3753
  var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
3724
- if (bridge && typeof bridge.placeMediaAtPlayhead === 'function') {
3725
- var res = bridge.placeMediaAtPlayhead({ url: it.viewUrl, name: it.name, contentType: it.contentType });
3726
- if (res && res.ok) { editorPlaceToast('Added \u201c' + (it.name || 'media') + '\u201d to the timeline'); return; }
3727
- // Non-placeable (folder / doc / not-ready): fall through to chat-attach.
3754
+ // Placeability must be decided SYNCHRONOUSLY (the Option-B bridge returns a
3755
+ // Promise, so we can't branch on its result to decide chat-attach fallback —
3756
+ // checking ".ok" on the Promise made EVERY click fall through, placing the
3757
+ // media AND attaching it to chat, with no toast). Only image/video/audio go
3758
+ // on the timeline; folders/docs still fall through to chat-attach.
3759
+ var ct = String(it.contentType || '');
3760
+ if (bridge && typeof bridge.placeMediaAtPlayhead === 'function'
3761
+ && (ct.indexOf('image/') === 0 || ct.indexOf('video/') === 0 || ct.indexOf('audio/') === 0)) {
3762
+ var placedName = it.name || 'media';
3763
+ var settlePlace = function (r) {
3764
+ if (r && r.ok) { editorPlaceToast('Added \u201c' + placedName + '\u201d to the timeline'); }
3765
+ else { editorPlaceToast('Couldn\u2019t add \u201c' + placedName + '\u201d' + ((r && r.error) ? ': ' + r.error : ''), true); }
3766
+ };
3767
+ try {
3768
+ var res = bridge.placeMediaAtPlayhead({ url: it.viewUrl, name: it.name, contentType: it.contentType });
3769
+ if (res && typeof res.then === 'function') { res.then(settlePlace, function (e) { settlePlace({ ok: false, error: (e && e.message) ? e.message : String(e) }); }); }
3770
+ else { settlePlace(res); }
3771
+ } catch (e) { settlePlace({ ok: false, error: (e && e.message) ? e.message : String(e) }); }
3772
+ return;
3728
3773
  }
3729
3774
  }
3730
3775
  // Files-only drawer (opened from the /chat page) has no chat composer of its
@@ -3841,7 +3886,11 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
3841
3886
  return wrap;
3842
3887
  }
3843
3888
  function setActiveThread(id) {
3844
- threadId = id;
3889
+ // null = "fresh unsaved chat": KEEP the freshly minted threadId (sends must
3890
+ // always carry a real thread_id or the server silently skips persistence)
3891
+ // and clear the sticky restore key; a real id becomes both current + sticky.
3892
+ if (id) { threadId = id; saveActiveThread(id); }
3893
+ else { saveActiveThread(null); }
3845
3894
  if (!histBody) return;
3846
3895
  var rows = histBody.querySelectorAll('.rk-aichat-frow');
3847
3896
  for (var i = 0; i < rows.length; i++) rows[i].classList.toggle('is-active', rows[i].getAttribute('data-id') === id);
@@ -3934,7 +3983,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
3934
3983
  .then(function (r) {
3935
3984
  if (!r.ok && r.status !== 404) throw new Error('http ' + r.status);
3936
3985
  threads = threads.filter(function (t) { return t.id !== id; });
3937
- if (id === threadId) resetConversation();
3986
+ if (id === threadId) { resetConversation(); saveActiveThread(null); }
3938
3987
  renderHistory();
3939
3988
  })
3940
3989
  .catch(function () {});