@officexapp/vidfarm-devcli 0.21.11 → 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.
Files changed (28) hide show
  1. package/.agents/skills/hyperframes-core/SKILL.md +2 -0
  2. package/.agents/skills/hyperframes-creative/SKILL.md +1 -0
  3. package/.agents/skills/hyperframes-creative/references/beat-direction.md +17 -0
  4. package/.agents/skills/vidfarm-director/SKILL.md +4 -0
  5. package/.agents/skills/vidfarm-director/recipes/local-edit-render-approve.md +1 -1
  6. package/.agents/skills/vidfarm-director/references/automation-and-local-dev.md +23 -6
  7. package/.agents/skills/vidfarm-director/references/core-workflows.md +1 -1
  8. package/.agents/skills/vidfarm-director/references/editor-workflows.md +15 -2
  9. package/.agents/skills/vidfarm-director/references/primitives.md +1 -1
  10. package/.agents/skills/vidfarm-director/references/rest-api.md +1 -1
  11. package/.agents/skills/vidfarm-media/SKILL.md +16 -6
  12. package/README.md +2 -2
  13. package/SKILL.director.md +45 -11
  14. package/demo/dist/app.js +247 -226
  15. package/dist/src/cli.js +198 -30
  16. package/dist/src/devcli/composition-edit.js +99 -23
  17. package/dist/src/devcli/cost-mode.js +13 -4
  18. package/dist/src/devcli/doctor.js +65 -9
  19. package/dist/src/devcli/local-frontend-server.js +342 -50
  20. package/dist/src/devcli/port-utils.js +43 -0
  21. package/dist/src/devcli/process-scan.js +173 -0
  22. package/package.json +3 -1
  23. package/public/serve-shells/editor.html +75 -13
  24. package/public/serve-shells/library-files.html +75 -13
  25. package/public/serve-shells/library-raws.html +75 -13
  26. package/public/serve-shells/tools-clipper.html +75 -13
  27. package/public/serve-shells/tools-image.html +75 -13
  28. package/public/serve-shells/tools-video.html +75 -13
@@ -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.11",
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",
@@ -1266,12 +1266,25 @@ html,body{margin:0;background:#050604;color:#fffbe6}
1266
1266
  // Desktop-agents nudge banner: a full-width alert pinned to the top of the
1267
1267
  // /editor and /chat surfaces pointing to the setup guide. Both surfaces embed
1268
1268
  // this chrome script, so gate strictly by pathname. Dismissible per session.
1269
+ // The nudge is aimed at the DIRECTOR (the paid operator who drives Vidfarm with
1270
+ // a desktop AI agent). A client reviewing a shared video in the editor should
1271
+ // NOT be told to go install Claude Code / Codex — so on the editor we suppress
1272
+ // it for free-tier (client / reviewer) sessions, read from the editor boot JSON.
1269
1273
  (function mountDesktopAgentsBanner() {
1270
1274
  try {
1271
1275
  var path = location.pathname;
1272
1276
  var onEditor = path === '/editor' || path.indexOf('/editor/') === 0;
1273
1277
  var onChat = path === '/chat' || path.indexOf('/chat/') === 0;
1274
1278
  if (!onEditor && !onChat) return;
1279
+ if (onEditor) {
1280
+ var bootEl = document.getElementById('hf-boot');
1281
+ if (bootEl) {
1282
+ try {
1283
+ var boot = JSON.parse(bootEl.textContent || '{}');
1284
+ if (boot && boot.freeTier) return;
1285
+ } catch (e) {}
1286
+ }
1287
+ }
1275
1288
  if (document.querySelector('.vf-topbanner')) return;
1276
1289
  if (sessionStorage.getItem('vf-topbanner-dismissed') === '1') return;
1277
1290
  var GUIDE = '/blog/desktop-ai-agents';
@@ -1477,6 +1490,23 @@ html,body{margin:0;background:#050604;color:#fffbe6}
1477
1490
  setLeftMode(''); // show the conversation, not the Files/History drawer
1478
1491
  openThread(id);
1479
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
+ }
1480
1510
  var busy = false;
1481
1511
  var pendingAbort = null; // AbortController for the in-flight reply (Stop button)
1482
1512
 
@@ -1527,14 +1557,20 @@ html,body{margin:0;background:#050604;color:#fffbe6}
1527
1557
  // outgoing user turn — verbatim to what the SPA's own chat sends — so the agent
1528
1558
  // knows which fork to read (video_context) and mutate (editor_action). Only the
1529
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.
1530
1564
  function editorContextBlock() {
1531
- if (!isEditorDock) return '';
1565
+ if (!isEditorDock) return Promise.resolve('');
1532
1566
  var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
1533
- if (!bridge || typeof bridge.getSnapshot !== 'function') return '';
1567
+ if (!bridge || typeof bridge.getSnapshot !== 'function') return Promise.resolve('');
1534
1568
  var snap; try { snap = bridge.getSnapshot(); } catch (e) { snap = null; }
1535
- if (!snap) return '';
1536
- try { return '\n\n<editor_context>\n' + JSON.stringify(snap, null, 2) + '\n</editor_context>'; }
1537
- 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 ''; });
1538
1574
  }
1539
1575
  function loadBoot() {
1540
1576
  if (BOOT_STATE === 'ready' || BOOT_STATE === 'loading') return;
@@ -1557,6 +1593,7 @@ html,body{margin:0;background:#050604;color:#fffbe6}
1557
1593
  loadThreads();
1558
1594
  if (leftMode() === 'cloud') loadTasks();
1559
1595
  consumeHandoff();
1596
+ restoreActiveThread();
1560
1597
  })
1561
1598
  .catch(function () { BOOT_STATE = 'error'; });
1562
1599
  }
@@ -2110,8 +2147,10 @@ html,body{margin:0;background:#050604;color:#fffbe6}
2110
2147
  setBusy(true);
2111
2148
  if (!threadId) threadId = genId('thread');
2112
2149
  // Attach a fresh <editor_context> to the current (last) user turn only, so the
2113
- // model sees the composition state without bloating persisted history.
2114
- 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) {
2115
2154
  // Attachments (pasted files OR files picked from the directory explorer) must
2116
2155
  // ride in the model messages as file content parts + a URL text line — the
2117
2156
  // backend only feeds the model messages[].content, NOT user_message.attachments
@@ -2129,6 +2168,9 @@ html,body{margin:0;background:#050604;color:#fffbe6}
2129
2168
  }
2130
2169
  return { role: m.role, content: content };
2131
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);
2132
2174
  var body = {
2133
2175
  messages: outMessages,
2134
2176
  thread_id: threadId,
@@ -2192,6 +2234,7 @@ html,body{margin:0;background:#050604;color:#fffbe6}
2192
2234
  if (!API_KEY) msg = msg + '\n\nAdd an AI provider key in Settings to chat on your own keys.';
2193
2235
  view.fail(msg); setBusy(false); if (input) input.focus();
2194
2236
  });
2237
+ }); // end editorContextBlock().then
2195
2238
  }
2196
2239
 
2197
2240
  function resetConversation() {
@@ -2445,10 +2488,25 @@ html,body{margin:0;background:#050604;color:#fffbe6}
2445
2488
  // fall through to chat-attach for folders / non-placeable files.
2446
2489
  if (isEditorDock && it && it.viewUrl) {
2447
2490
  var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
2448
- if (bridge && typeof bridge.placeMediaAtPlayhead === 'function') {
2449
- var res = bridge.placeMediaAtPlayhead({ url: it.viewUrl, name: it.name, contentType: it.contentType });
2450
- if (res && res.ok) { editorPlaceToast('Added \u201c' + (it.name || 'media') + '\u201d to the timeline'); return; }
2451
- // 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;
2452
2510
  }
2453
2511
  }
2454
2512
  // Files-only drawer (opened from the /chat page) has no chat composer of its
@@ -2565,7 +2623,11 @@ html,body{margin:0;background:#050604;color:#fffbe6}
2565
2623
  return wrap;
2566
2624
  }
2567
2625
  function setActiveThread(id) {
2568
- 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); }
2569
2631
  if (!histBody) return;
2570
2632
  var rows = histBody.querySelectorAll('.rk-aichat-frow');
2571
2633
  for (var i = 0; i < rows.length; i++) rows[i].classList.toggle('is-active', rows[i].getAttribute('data-id') === id);
@@ -2658,7 +2720,7 @@ html,body{margin:0;background:#050604;color:#fffbe6}
2658
2720
  .then(function (r) {
2659
2721
  if (!r.ok && r.status !== 404) throw new Error('http ' + r.status);
2660
2722
  threads = threads.filter(function (t) { return t.id !== id; });
2661
- if (id === threadId) resetConversation();
2723
+ if (id === threadId) { resetConversation(); saveActiveThread(null); }
2662
2724
  renderHistory();
2663
2725
  })
2664
2726
  .catch(function () {});
@@ -1575,12 +1575,25 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
1575
1575
  // Desktop-agents nudge banner: a full-width alert pinned to the top of the
1576
1576
  // /editor and /chat surfaces pointing to the setup guide. Both surfaces embed
1577
1577
  // this chrome script, so gate strictly by pathname. Dismissible per session.
1578
+ // The nudge is aimed at the DIRECTOR (the paid operator who drives Vidfarm with
1579
+ // a desktop AI agent). A client reviewing a shared video in the editor should
1580
+ // NOT be told to go install Claude Code / Codex — so on the editor we suppress
1581
+ // it for free-tier (client / reviewer) sessions, read from the editor boot JSON.
1578
1582
  (function mountDesktopAgentsBanner() {
1579
1583
  try {
1580
1584
  var path = location.pathname;
1581
1585
  var onEditor = path === '/editor' || path.indexOf('/editor/') === 0;
1582
1586
  var onChat = path === '/chat' || path.indexOf('/chat/') === 0;
1583
1587
  if (!onEditor && !onChat) return;
1588
+ if (onEditor) {
1589
+ var bootEl = document.getElementById('hf-boot');
1590
+ if (bootEl) {
1591
+ try {
1592
+ var boot = JSON.parse(bootEl.textContent || '{}');
1593
+ if (boot && boot.freeTier) return;
1594
+ } catch (e) {}
1595
+ }
1596
+ }
1584
1597
  if (document.querySelector('.vf-topbanner')) return;
1585
1598
  if (sessionStorage.getItem('vf-topbanner-dismissed') === '1') return;
1586
1599
  var GUIDE = '/blog/desktop-ai-agents';
@@ -1786,6 +1799,23 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
1786
1799
  setLeftMode(''); // show the conversation, not the Files/History drawer
1787
1800
  openThread(id);
1788
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
+ }
1789
1819
  var busy = false;
1790
1820
  var pendingAbort = null; // AbortController for the in-flight reply (Stop button)
1791
1821
 
@@ -1836,14 +1866,20 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
1836
1866
  // outgoing user turn — verbatim to what the SPA's own chat sends — so the agent
1837
1867
  // knows which fork to read (video_context) and mutate (editor_action). Only the
1838
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.
1839
1873
  function editorContextBlock() {
1840
- if (!isEditorDock) return '';
1874
+ if (!isEditorDock) return Promise.resolve('');
1841
1875
  var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
1842
- if (!bridge || typeof bridge.getSnapshot !== 'function') return '';
1876
+ if (!bridge || typeof bridge.getSnapshot !== 'function') return Promise.resolve('');
1843
1877
  var snap; try { snap = bridge.getSnapshot(); } catch (e) { snap = null; }
1844
- if (!snap) return '';
1845
- try { return '\n\n<editor_context>\n' + JSON.stringify(snap, null, 2) + '\n</editor_context>'; }
1846
- 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 ''; });
1847
1883
  }
1848
1884
  function loadBoot() {
1849
1885
  if (BOOT_STATE === 'ready' || BOOT_STATE === 'loading') return;
@@ -1866,6 +1902,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
1866
1902
  loadThreads();
1867
1903
  if (leftMode() === 'cloud') loadTasks();
1868
1904
  consumeHandoff();
1905
+ restoreActiveThread();
1869
1906
  })
1870
1907
  .catch(function () { BOOT_STATE = 'error'; });
1871
1908
  }
@@ -2419,8 +2456,10 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2419
2456
  setBusy(true);
2420
2457
  if (!threadId) threadId = genId('thread');
2421
2458
  // Attach a fresh <editor_context> to the current (last) user turn only, so the
2422
- // model sees the composition state without bloating persisted history.
2423
- 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) {
2424
2463
  // Attachments (pasted files OR files picked from the directory explorer) must
2425
2464
  // ride in the model messages as file content parts + a URL text line — the
2426
2465
  // backend only feeds the model messages[].content, NOT user_message.attachments
@@ -2438,6 +2477,9 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2438
2477
  }
2439
2478
  return { role: m.role, content: content };
2440
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);
2441
2483
  var body = {
2442
2484
  messages: outMessages,
2443
2485
  thread_id: threadId,
@@ -2501,6 +2543,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2501
2543
  if (!API_KEY) msg = msg + '\n\nAdd an AI provider key in Settings to chat on your own keys.';
2502
2544
  view.fail(msg); setBusy(false); if (input) input.focus();
2503
2545
  });
2546
+ }); // end editorContextBlock().then
2504
2547
  }
2505
2548
 
2506
2549
  function resetConversation() {
@@ -2754,10 +2797,25 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2754
2797
  // fall through to chat-attach for folders / non-placeable files.
2755
2798
  if (isEditorDock && it && it.viewUrl) {
2756
2799
  var bridge = (typeof window !== 'undefined') ? window.__vidfarmEditorAction : null;
2757
- if (bridge && typeof bridge.placeMediaAtPlayhead === 'function') {
2758
- var res = bridge.placeMediaAtPlayhead({ url: it.viewUrl, name: it.name, contentType: it.contentType });
2759
- if (res && res.ok) { editorPlaceToast('Added \u201c' + (it.name || 'media') + '\u201d to the timeline'); return; }
2760
- // 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;
2761
2819
  }
2762
2820
  }
2763
2821
  // Files-only drawer (opened from the /chat page) has no chat composer of its
@@ -2874,7 +2932,11 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2874
2932
  return wrap;
2875
2933
  }
2876
2934
  function setActiveThread(id) {
2877
- 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); }
2878
2940
  if (!histBody) return;
2879
2941
  var rows = histBody.querySelectorAll('.rk-aichat-frow');
2880
2942
  for (var i = 0; i < rows.length; i++) rows[i].classList.toggle('is-active', rows[i].getAttribute('data-id') === id);
@@ -2967,7 +3029,7 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
2967
3029
  .then(function (r) {
2968
3030
  if (!r.ok && r.status !== 404) throw new Error('http ' + r.status);
2969
3031
  threads = threads.filter(function (t) { return t.id !== id; });
2970
- if (id === threadId) resetConversation();
3032
+ if (id === threadId) { resetConversation(); saveActiveThread(null); }
2971
3033
  renderHistory();
2972
3034
  })
2973
3035
  .catch(function () {});