@floless/app 0.5.1

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,3274 @@
1
+ /* ============================================================================
2
+ * aware.js — wires the demo skin to live AWARE data via the floless.app server.
3
+ *
4
+ * Loaded AFTER app.js, so it shares its globals (PROMPTS, AGENTS, state,
5
+ * renderTopology, renderInspect, selectAgent, renderChat, $promptSel, $runBtn,
6
+ * escapeHtml, showToast, appendNarration). It mutates PROMPTS/AGENTS from real
7
+ * /api data and reuses the existing renderers — the canvas stays the demo's.
8
+ *
9
+ * The UI never becomes the brain: it reads AWARE state, triggers `aware` verbs
10
+ * (compile/run) through /api, and enforces the build-once/run-forever gate.
11
+ * ========================================================================== */
12
+ (() => {
13
+ const $compileBtn = document.getElementById('compile-btn');
14
+ const $menuBakeItem = document.getElementById('menu-bake-item');
15
+ const $menuBakeLabel = document.getElementById('menu-bake-label');
16
+ const $simBtn = document.getElementById('sim-btn');
17
+ const $runState = document.getElementById('run-state');
18
+ const $notesStrip = document.getElementById('notes-strip');
19
+ const $reportModal = document.getElementById('report-modal');
20
+ const $reportFrame = document.getElementById('report-frame');
21
+ const $reportOverlay = document.getElementById('report-overlay');
22
+ const $reportTitle = document.getElementById('report-title');
23
+ const $reportSub = document.getElementById('report-sub');
24
+ const $reportOpen = document.getElementById('report-open');
25
+ const $reportClose = document.getElementById('report-close');
26
+
27
+ // One persistent array the inspect Execution tab reads; we mutate in place so
28
+ // every AGENTS[node].execution reference stays valid across re-renders.
29
+ const liveTrace = [];
30
+ // Latest normalized app payload, keyed by id, so handlers can read mode/gate.
31
+ const apps = new Map();
32
+ let currentId = null;
33
+
34
+ // Local UI-state persistence (per origin; never touches the .flo or lock).
35
+ // The last-opened app is restored on refresh; per-app input values are saved
36
+ // explicitly via "Save inputs" (Ctrl+S) and rehydrated when the app opens.
37
+ const LS_LAST_APP = 'floless:lastApp';
38
+ const lsInputsKey = (id) => 'floless:inputs:' + id;
39
+ function loadSavedInputs(id) {
40
+ try {
41
+ const v = JSON.parse(localStorage.getItem(lsInputsKey(id)) || 'null');
42
+ return v && typeof v === 'object' ? v : null;
43
+ } catch { return null; }
44
+ }
45
+
46
+ // Notes-strip collapse memory, keyed by app + a signature of the note set, so a
47
+ // collapsed strip STAYS collapsed across sessions — but a recompile that changes
48
+ // the notes (a new issue) invalidates the signature and re-expands, so the user
49
+ // never misses a genuinely new note.
50
+ const lsNotesKey = (id) => 'floless:notes:' + id;
51
+ function notesSig(notes) {
52
+ const s = notes.map((nt) => (nt.nodeId || '') + '|' + (nt.kind || '') + '|' + nt.text).join('\n');
53
+ let h = 5381;
54
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
55
+ return notes.length + ':' + (h >>> 0).toString(36);
56
+ }
57
+ // Tri-state: true/false = the user's remembered choice for THIS note set;
58
+ // undefined = no choice yet (caller defaults by severity).
59
+ function notesRememberedCollapse(id, sig) {
60
+ try {
61
+ const v = JSON.parse(localStorage.getItem(lsNotesKey(id)) || 'null');
62
+ if (v && v.sig === sig) return !!v.collapsed;
63
+ } catch { /* ignore */ }
64
+ return undefined;
65
+ }
66
+ function saveNotesCollapsed(id, sig, collapsed) {
67
+ try { localStorage.setItem(lsNotesKey(id), JSON.stringify({ sig, collapsed })); } catch { /* ignore */ }
68
+ }
69
+
70
+ // Re-phrase a recognized AWARE info-note into plain language for AEC users.
71
+ // Severity always comes from note.kind, never this text — so if the wording
72
+ // ever changes and the pattern misses, we just fall back to the raw note.
73
+ function humanizeNote(text) {
74
+ const m = /command\s+(\S+)\s+(?:not found|is mode-overridable);\s*using author-declared mode:\s*(read|write)/i.exec(text);
75
+ if (m) {
76
+ const cmd = m[1];
77
+ return m[2].toLowerCase() === 'read'
78
+ ? `Runs custom read-only code (${cmd}) using the read-only mode set on this node — no model or file changes.`
79
+ : `Runs custom code (${cmd}) in the write mode set on this node.`;
80
+ }
81
+ return text;
82
+ }
83
+
84
+ const AGENT_ICONS = {
85
+ tekla: '⊞', file: '🗀', excel: '◵', revit: '◰', slack: '✦',
86
+ 'trimble-connect': '☁', 'microsoft-365': '✉', email: '✉', http: '⇄',
87
+ };
88
+ const iconFor = (agent) => AGENT_ICONS[agent] || '◈';
89
+
90
+ // The demo's renderChat staggers narration via setTimeout; its bootstrap call
91
+ // (fired before this script runs) would otherwise drip stale demo lines into
92
+ // our chat. Replace it with a synchronous version, and clear any stragglers
93
+ // already scheduled by the original before we took over.
94
+ renderChat = function renderChatSync() {
95
+ const p = PROMPTS[state.promptKey];
96
+ $messages.innerHTML = '';
97
+ if (!p) return;
98
+ $messages.appendChild(msgEl('user', '$ you', p.userText));
99
+ (p.narration || []).forEach((line) => $messages.appendChild(msgEl('aware', 'claude', mdInline(line))));
100
+ $messages.scrollTop = $messages.scrollHeight;
101
+ };
102
+
103
+ async function api(path, opts) {
104
+ const res = await fetch(path, {
105
+ headers: { 'content-type': 'application/json' },
106
+ ...opts,
107
+ });
108
+ const body = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
109
+ if (!res.ok || body.ok === false) throw new Error(body.error || `HTTP ${res.status}`);
110
+ return body;
111
+ }
112
+
113
+ // ── transform: normalized app -> the renderer's PROMPTS/AGENTS shapes ──────
114
+
115
+ function nodeYaml(n) {
116
+ const lines = [`id: ${n.id}`, `kind: ${n.kind}`];
117
+ if (n.agent) lines.push(`agent: ${n.agent}`);
118
+ if (n.command) lines.push(`command: ${n.command}`);
119
+ lines.push(`mode: ${n.mode}`);
120
+ const inputs = n.inputs && Object.keys(n.inputs).length ? n.inputs : n.config;
121
+ if (inputs && Object.keys(inputs).length) {
122
+ lines.push('inputs:');
123
+ for (const [k, v] of Object.entries(inputs)) lines.push(` ${k}: ${JSON.stringify(v)}`);
124
+ }
125
+ if (n.notes && n.notes.length) {
126
+ lines.push('notes:');
127
+ for (const note of n.notes) lines.push(` - [${note.kind}] ${note.text}`);
128
+ }
129
+ return lines.join('\n');
130
+ }
131
+
132
+ function kvTable(obj) {
133
+ const entries = Object.entries(obj || {});
134
+ if (!entries.length) return '<p class="empty-state">none</p>';
135
+ return `<table class="kv">${entries
136
+ .map(([k, v]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(typeof v === 'string' ? v : JSON.stringify(v))}</td></tr>`)
137
+ .join('')}</table>`;
138
+ }
139
+
140
+ // Dependency-free C# highlighter for the Code tab. Single-pass scanner that
141
+ // handles comments/strings/chars BEFORE keywords (so keywords inside strings
142
+ // aren't recolored), escapes every token's text, and reuses the demo's token
143
+ // classes (kw/ty/st/cm + a new nu for numbers). Kept tiny + local on purpose —
144
+ // the thin UI ships no syntax-highlighting library (no supply-chain surface).
145
+ const CSHARP_KEYWORDS = new Set([
146
+ 'using', 'namespace', 'class', 'struct', 'enum', 'interface', 'public', 'private',
147
+ 'protected', 'internal', 'static', 'readonly', 'const', 'var', 'new', 'return', 'if',
148
+ 'else', 'for', 'foreach', 'while', 'do', 'switch', 'case', 'break', 'continue', 'try',
149
+ 'catch', 'finally', 'throw', 'in', 'out', 'ref', 'is', 'as', 'null', 'true', 'false',
150
+ 'void', 'int', 'long', 'short', 'byte', 'uint', 'ulong', 'double', 'float', 'decimal',
151
+ 'bool', 'string', 'char', 'object', 'dynamic', 'this', 'base', 'typeof', 'nameof',
152
+ 'default', 'get', 'set', 'async', 'await', 'yield', 'lock', 'params', 'when', 'where',
153
+ ]);
154
+ function highlightCSharp(src) {
155
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
156
+ const span = (cls, text) => `<span class="${cls}">${esc(text)}</span>`;
157
+ const n = src.length;
158
+ let out = '';
159
+ let i = 0;
160
+ while (i < n) {
161
+ const c = src[i];
162
+ if (c === '/' && src[i + 1] === '/') { // line comment
163
+ let j = src.indexOf('\n', i); if (j < 0) j = n;
164
+ out += span('cm', src.slice(i, j)); i = j; continue;
165
+ }
166
+ if (c === '/' && src[i + 1] === '*') { // block comment
167
+ let j = src.indexOf('*/', i + 2); j = j < 0 ? n : j + 2;
168
+ out += span('cm', src.slice(i, j)); i = j; continue;
169
+ }
170
+ if (c === '@' && src[i + 1] === '"') { // verbatim string ("" escapes a quote)
171
+ let j = i + 2;
172
+ while (j < n) { if (src[j] === '"') { if (src[j + 1] === '"') { j += 2; continue; } j++; break; } j++; }
173
+ out += span('st', src.slice(i, j)); i = j; continue;
174
+ }
175
+ if (c === '"' || c === "'" || (c === '$' && src[i + 1] === '"')) { // string / char
176
+ const quote = c === '$' ? '"' : c;
177
+ let j = c === '$' ? i + 2 : i + 1;
178
+ while (j < n) {
179
+ if (src[j] === '\\') { j += 2; continue; }
180
+ if (src[j] === quote) { j++; break; }
181
+ if (src[j] === '\n') break;
182
+ j++;
183
+ }
184
+ out += span('st', src.slice(i, j)); i = j; continue;
185
+ }
186
+ if (c >= '0' && c <= '9') { // number
187
+ let j = i; while (j < n && /[0-9._a-fA-FxXdflmDFLM]/.test(src[j])) j++;
188
+ out += span('nu', src.slice(i, j)); i = j; continue;
189
+ }
190
+ if (/[A-Za-z_]/.test(c)) { // identifier / keyword / type
191
+ let j = i; while (j < n && /[A-Za-z0-9_]/.test(src[j])) j++;
192
+ const word = src.slice(i, j);
193
+ if (CSHARP_KEYWORDS.has(word)) out += span('kw', word);
194
+ else if (/^[A-Z]/.test(word)) out += span('ty', word);
195
+ else out += esc(word);
196
+ i = j; continue;
197
+ }
198
+ out += esc(c); i++;
199
+ }
200
+ return out;
201
+ }
202
+
203
+ // Plain-English summary of an exec node = its leading `//` comment header
204
+ // (authored as prose). Used in the Description tab so it explains what the node
205
+ // does — the raw C# is the Code tab's job, never Description's.
206
+ function leadingComment(code) {
207
+ const out = [];
208
+ for (const raw of String(code).split(/\r?\n/)) {
209
+ const line = raw.trim();
210
+ if (line.startsWith('//')) { out.push(line.replace(/^\/\/+\s?/, '')); continue; }
211
+ if (line === '') { if (out.length) break; else continue; }
212
+ break; // first real (non-comment, non-blank) line ends the header block
213
+ }
214
+ return out.join(' ').replace(/\s+/g, ' ').trim();
215
+ }
216
+
217
+ function buildAgentEntry(app, n) {
218
+ const pin = app.lock.agentPins && n.agent ? app.lock.agentPins[n.agent] : null;
219
+ // All file-derived strings are escaped here: cardEl/renderInspect inject these
220
+ // into innerHTML raw, so a hostile node id/agent/command/note must not be HTML.
221
+ const agentLabel = escapeHtml(n.agent || n.kind);
222
+ const cmd = n.command ? escapeHtml(n.command) : '';
223
+ const mode = escapeHtml(n.mode);
224
+ const modeBadge = `<span class="lvl ${n.mode === 'write' ? 'warn' : 'info'}">${mode}</span>`;
225
+ const notesHtml = n.notes.length
226
+ ? `<p><strong>Compile notes:</strong></p><ul>${n.notes.map((x) => `<li><span class="note-kind ${x.kind}">${x.kind}</span> ${escapeHtml(humanizeNote(x.text))}</li>`).join('')}</ul>`
227
+ : '';
228
+ const inputs = n.inputs && Object.keys(n.inputs).length ? n.inputs : n.config;
229
+ // The exec node's Roslyn C# (the actual script the host runs). null for
230
+ // pure-agent nodes that carry no inline code.
231
+ const execSource = n.config && typeof n.config.code === 'string' ? n.config.code : null;
232
+ // Description tab: a plain-English explanation + a small input table that
233
+ // EXCLUDES the code (code belongs to the Code tab, never dumped here).
234
+ const plainDesc = execSource ? leadingComment(execSource) : '';
235
+ const inputsNoCode = {};
236
+ for (const [k, v] of Object.entries(inputs || {})) if (k !== 'code') inputsNoCode[k] = v;
237
+ return {
238
+ _mode: n.mode,
239
+ icon: iconFor(n.agent),
240
+ kind: n.kind === 'agent' && n.agent ? `${agentLabel} agent` : escapeHtml(n.kind),
241
+ version: pin ? `v${escapeHtml(String(pin))}` : '—',
242
+ title: escapeHtml(n.id),
243
+ subtitle: `${agentLabel}${cmd ? '/' + cmd : ''} · ${mode}`,
244
+ blurb: n.notes[0] ? escapeHtml(humanizeNote(n.notes[0].text)) : `${mode}-mode ${cmd ? '<code>' + cmd + '</code>' : escapeHtml(n.kind)} node`,
245
+ description: `${plainDesc ? `<p>${escapeHtml(plainDesc)}</p>` : ''}<p>Resolves to ${modeBadge} via <code>${escapeHtml(n.agent || n.kind)}${n.command ? '.' + escapeHtml(n.command) : ''}</code>.</p>${Object.keys(inputsNoCode).length ? `<p><strong>Inputs</strong></p>${kvTable(inputsNoCode)}` : ''}${execSource ? `<p class="dim-note">Full source in the <strong>Code</strong> tab.</p>` : ''}${notesHtml}`,
246
+ skill: `<h3>Provided by</h3><p><code>${escapeHtml(n.agent || n.kind)}</code>${pin ? ' · pinned <code>v' + escapeHtml(pin) + '</code>' : ''}${n.command ? ' · command <code>' + escapeHtml(n.command) + '</code>' : ''}</p>
247
+ <p>Resolved write/read mode is part of the approved <code>.lock</code> — see the <strong>Code</strong> tab.</p>${notesHtml}`,
248
+ // Code tab: the REAL node source. For exec nodes (Roslyn C# run against a
249
+ // live host) that's `config.code` straight from the .flo — the same text
250
+ // the host compiles, so "Debug in VS" attaches to exactly this. For
251
+ // non-exec nodes there is no script; we show the resolved lockfile YAML.
252
+ code: execSource
253
+ ? highlightCSharp(`// ${n.agent || n.kind}${n.command ? '.' + n.command : ''} · node "${n.id}" · live source from ~/.aware/apps/${app.id}/\n${execSource}`)
254
+ : app.lock.present
255
+ ? `<span class="cm"># ${escapeHtml(app.id)}.lock · node "${escapeHtml(n.id)}"</span>\n${escapeHtml(nodeYaml(n))}`
256
+ : `<span class="cm"># not compiled</span>\nHit ⎙ Compile to freeze the .lock.`,
257
+ // raw (unescaped) exec source for the Debug-in-VS handler; null for non-exec.
258
+ _execSource: execSource,
259
+ _execVersion: n.config && typeof n.config.version === 'string' ? n.config.version : null,
260
+ _appId: app.id,
261
+ _nodeId: n.id,
262
+ execution: liveTrace,
263
+ };
264
+ }
265
+
266
+ // Best-effort DAG layering when a source declares layout: dag (longest-path
267
+ // columns; rows by arrival order within a column). Linear apps skip this.
268
+ function layoutDag(nodes, connections) {
269
+ const depth = new Map(nodes.map((n) => [n.id, 0]));
270
+ for (let pass = 0; pass < nodes.length; pass++) {
271
+ for (const c of connections) {
272
+ depth.set(c.to, Math.max(depth.get(c.to) || 0, (depth.get(c.from) || 0) + 1));
273
+ }
274
+ }
275
+ const rowByCol = {};
276
+ return nodes.map((n) => {
277
+ const col = (depth.get(n.id) || 0) + 1;
278
+ rowByCol[col] = (rowByCol[col] || 0) + 1;
279
+ return { id: n.id, col, row: rowByCol[col] };
280
+ });
281
+ }
282
+
283
+ function transform(app) {
284
+ app.nodes.forEach((n) => { AGENTS[n.id] = buildAgentEntry(app, n); });
285
+
286
+ const userText = escapeHtml(app.description?.trim()
287
+ ? app.description.trim().split('\n')[0]
288
+ : `${app.displayName} — ${app.nodes.length} node${app.nodes.length === 1 ? '' : 's'}.`);
289
+ // Escape interpolated values (not the literal **/`` markup, which mdInline renders).
290
+ const narration = [
291
+ `Reading **${escapeHtml(app.name)}** from \`~/.aware/apps/${escapeHtml(app.id)}\` — the canvas is a view of the real \`.flo\`.`,
292
+ ...app.nodes.map((n) => `**${escapeHtml(n.id)}** → \`${escapeHtml(n.agent || n.kind)}${n.command ? '/' + escapeHtml(n.command) : ''}\` · ${escapeHtml(n.mode)}`),
293
+ ];
294
+ if (app.notes.length) narration.push(`ℹ ${app.notes.length} compiler note${app.notes.length === 1 ? '' : 's'} — see the strip over the canvas.`);
295
+ narration.push(app.runnable
296
+ ? 'Lock is fresh (source-hash matches). **Run** is armed.'
297
+ : app.runState === 'drift'
298
+ ? 'The `.flo` changed since the last approve — **Compile** to refresh the lock before Run.'
299
+ : 'No lock yet — **Compile** to freeze the approved contract.');
300
+
301
+ if (app.layout === 'dag') {
302
+ PROMPTS[app.id] = { name: app.name, layout: 'dag', nodes: layoutDag(app.nodes, app.connections), connections: app.connections, userText, narration };
303
+ } else {
304
+ const ids = app.nodes.map((n) => n.id);
305
+ const wires = ids.slice(1).map((_, i) => app.connections[i]?.label || '');
306
+ PROMPTS[app.id] = { name: app.name, layout: 'linear', nodes: ids, wires, userText, narration };
307
+ }
308
+ }
309
+
310
+ // ── gate + notes UI ────────────────────────────────────────────────────────
311
+
312
+ function paintGate(app) {
313
+ // Task-language label (AEC users don't know "lock"/"drift"); the tooltip carries
314
+ // the why + the Compile step.
315
+ const labels = { ready: 'Ready to run', drift: 'Needs refresh', uncompiled: 'Not set up' };
316
+ $runState.className = `run-state ${app.runState}`;
317
+ $runState.textContent = labels[app.runState] || '';
318
+ const stateTips = {
319
+ ready: 'Ready to run.\nThis workflow was compiled from exactly the current .flo (the approved “lock” matches the source), so ▶ Run workflow is enabled.',
320
+ drift: 'Needs refresh.\nThe .flo changed since the last Compile, so the approved lock is out of date and Run is blocked. Hit ⎙ Compile to re-approve it.',
321
+ uncompiled: 'Not set up yet.\nThere is no approved lock for this workflow, so Run is blocked. Hit ⎙ Compile to freeze one — then ▶ Run workflow turns on.',
322
+ };
323
+ $runState.dataset.tip = stateTips[app.runState] || '';
324
+
325
+ $runBtn.disabled = !app.runnable;
326
+ $runBtn.dataset.tip = app.runnable
327
+ ? 'Run the approved workflow'
328
+ : app.runState === 'drift'
329
+ ? 'Blocked: the .flo changed since the last approve — Compile first'
330
+ : 'Blocked: not compiled — Compile first';
331
+
332
+ const needsCompile = app.runState !== 'ready';
333
+ $compileBtn.disabled = false;
334
+ $compileBtn.textContent = needsCompile ? '⎙ Compile *' : '⎙ Compile';
335
+
336
+ // Bake: package the workflow as a reusable agent. Gated on the same runnable
337
+ // state as Run (baking a stale/uncompiled app makes no sense — the lock is
338
+ // what the server reads to mirror inputs). Disabled-with-tooltip (teach the
339
+ // path), not hidden. Label flips to "Re-bake" when already exposed-as-agent.
340
+ if ($menuBakeItem) {
341
+ $menuBakeLabel.textContent = app.baked ? 'Re-bake agent' : 'Bake into agent';
342
+ $menuBakeItem.disabled = !app.runnable;
343
+ $menuBakeItem.dataset.tip = app.runnable
344
+ ? (app.baked
345
+ ? 'Already a reusable agent. Re-bake to refresh its exposed interface after edits.'
346
+ : 'Package this workflow as a reusable agent — drop it as a single node in any other workflow.')
347
+ : app.runState === 'drift'
348
+ ? 'Compile first — the .flo changed since the last approve, so Bake is blocked.'
349
+ : 'Compile first — there is no approved lock yet, so Bake is blocked.';
350
+ }
351
+
352
+ if (app.notes.length) {
353
+ const n = app.notes.length;
354
+ const sig = notesSig(app.notes);
355
+ // Strip severity = worst note kind (info < warn < error). Drives colour +
356
+ // icon; AWARE 0.48 tags each note with `kind` (#170) so we render by it
357
+ // rather than guessing from the text.
358
+ const worst = app.notes.some((x) => x.kind === 'error') ? 'error'
359
+ : app.notes.some((x) => x.kind === 'warn') ? 'warn' : 'info';
360
+ const icon = worst === 'error' ? '✕' : worst === 'warn' ? '⚠' : 'ℹ';
361
+ $notesStrip.hidden = false;
362
+ $notesStrip.dataset.appId = app.id;
363
+ $notesStrip.dataset.sig = sig;
364
+ $notesStrip.classList.remove('sev-info', 'sev-warn', 'sev-error');
365
+ $notesStrip.classList.add('sev-' + worst);
366
+ $notesStrip.innerHTML =
367
+ `<div class="notes-head"><span class="notes-title">${icon} ${n} compiler note${n === 1 ? '' : 's'}</span>` +
368
+ `<button class="notes-dismiss" type="button" aria-label="Collapse compiler notes" data-tip="Collapse">×</button></div>` +
369
+ `<ul>${app.notes.map((nt) => `<li><span class="note-kind ${nt.kind}">${nt.kind}</span><code>${escapeHtml(nt.nodeId)}</code> — ${escapeHtml(humanizeNote(nt.text))}</li>`).join('')}</ul>`;
370
+ // Collapse decision: a remembered choice for this exact note set wins;
371
+ // otherwise default to collapsed for benign info-only sets (keep the canvas
372
+ // quiet for AEC users) and expanded when anything is warn/error.
373
+ const remembered = notesRememberedCollapse(app.id, sig);
374
+ $notesStrip.classList.toggle('collapsed', remembered !== undefined ? remembered : worst === 'info');
375
+ } else {
376
+ $notesStrip.hidden = true;
377
+ $notesStrip.classList.remove('collapsed');
378
+ delete $notesStrip.dataset.appId;
379
+ delete $notesStrip.dataset.sig;
380
+ $notesStrip.innerHTML = '';
381
+ }
382
+ }
383
+
384
+ function paintModes() {
385
+ document.querySelectorAll('.agent-card').forEach((card) => {
386
+ const a = AGENTS[card.dataset.agentId];
387
+ card.classList.toggle('mode-write', a?._mode === 'write');
388
+ card.classList.toggle('mode-read', a?._mode === 'read');
389
+ });
390
+ }
391
+
392
+ // ── load / select / compile / run ───────────────────────────────────────────
393
+
394
+ async function loadApp(id) {
395
+ const { app } = await api(`/api/app/${encodeURIComponent(id)}`);
396
+ apps.set(id, app);
397
+ currentId = id;
398
+ try { localStorage.setItem(LS_LAST_APP, id); } catch { /* private mode / quota */ }
399
+ transform(app);
400
+ liveTrace.length = 0;
401
+ state.promptKey = id;
402
+ state.selectedAgentId = null;
403
+ state.hasRun = false;
404
+ if ($promptSel.value !== id) $promptSel.value = id;
405
+ setComboTriggerLabel(id);
406
+ setComboSelected(id);
407
+ renderChat();
408
+ renderTopology(); // selects first node + renderInspect
409
+ paintModes();
410
+ markStars();
411
+ seedAppInputs(app);
412
+ markSpecialNodes();
413
+ paintGate(app);
414
+ refreshDirtyIndicator(); // clean app → dot off; switched back to a dirty one → dot on
415
+ }
416
+
417
+ async function loadApps() {
418
+ setComboTriggerLabel('loading…');
419
+ renderCanvasPlaceholder('loading');
420
+ const { apps: list } = await api('/api/apps');
421
+ $promptSel.innerHTML = '';
422
+ if (!list.length) {
423
+ // The guided empty-state panel owns the "install a workflow" message now —
424
+ // don't also raise the warn-coloured notes strip (it's for real compiler
425
+ // notes, and a yellow band for a neutral first-run reads as a false alarm).
426
+ $notesStrip.hidden = true;
427
+ $runBtn.disabled = true;
428
+ $compileBtn.disabled = true;
429
+ if ($menuBakeItem) $menuBakeItem.disabled = true;
430
+ if ($wfTrigger) $wfTrigger.disabled = true;
431
+ setComboTriggerLabel('no workflows installed');
432
+ renderCanvasPlaceholder('empty');
433
+ return;
434
+ }
435
+ const $apps = document.getElementById('apps-count');
436
+ if ($apps) $apps.textContent = list.length;
437
+ for (const a of list) {
438
+ const opt = document.createElement('option');
439
+ opt.value = a.id;
440
+ opt.textContent = `${a.id} · ${a.nodes} node${a.nodes === 1 ? '' : 's'} · ${a.layout}`;
441
+ $promptSel.appendChild(opt);
442
+ }
443
+ buildWorkflowCombo(list); // searchable, provider-grouped picker over the hidden select
444
+ // Restore the last-opened app (remembered across refresh); fall back to the
445
+ // first app when there's no saved choice or it's no longer installed.
446
+ let startId = list[0].id;
447
+ try {
448
+ const saved = localStorage.getItem(LS_LAST_APP);
449
+ if (saved && list.some((a) => a.id === saved)) startId = saved;
450
+ } catch { /* ignore */ }
451
+ await loadApp(startId);
452
+ }
453
+
454
+ // ── Workflow combobox (searchable, provider-grouped) ─────────────────────────
455
+ // A custom control over the hidden <select id="prompt-select"> — the select stays
456
+ // the value model (it keeps firing change → loadApp), and this adds search +
457
+ // provider grouping a native <select> can't. Provider comes from /api/apps.
458
+ const PROVIDER_LABELS = {
459
+ tekla: 'Tekla', revit: 'Revit', sketchup: 'SketchUp', excel: 'Excel',
460
+ supabase: 'Supabase', http: 'HTTP', file: 'File', email: 'Email',
461
+ 'trimble-connect': 'Trimble Connect', 'microsoft-365': 'Microsoft 365',
462
+ 'ui-inspector': 'UI Inspector', 'html-report': 'HTML Report',
463
+ 'google-workspace': 'Google Workspace', other: 'Other',
464
+ };
465
+ const PROVIDER_ORDER = ['tekla', 'revit', 'sketchup', 'excel', 'supabase', 'http', 'trimble-connect', 'microsoft-365', 'email', 'file', 'ui-inspector'];
466
+ const providerLabel = (p) => PROVIDER_LABELS[p] || (p ? p.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) : 'Other');
467
+
468
+ const $wfCombo = document.getElementById('wf-combo');
469
+ const $wfTrigger = document.getElementById('wf-trigger');
470
+ const $wfTriggerLabel = document.getElementById('wf-trigger-label');
471
+ const $wfPopover = document.getElementById('wf-popover');
472
+ const $wfSearch = document.getElementById('wf-search');
473
+ const $wfList = document.getElementById('wf-list');
474
+ let comboHi = -1; // index into the currently-visible options (-1 = none)
475
+
476
+ function setComboTriggerLabel(text) { if ($wfTriggerLabel) $wfTriggerLabel.textContent = text || 'select workflow'; }
477
+ function setComboSelected(id) {
478
+ if (!$wfList) return;
479
+ $wfList.querySelectorAll('.wf-option').forEach((o) => {
480
+ const sel = o.dataset.id === id;
481
+ o.classList.toggle('selected', sel);
482
+ o.setAttribute('aria-selected', sel ? 'true' : 'false');
483
+ });
484
+ }
485
+
486
+ // Rebuild the grouped option list from the /api/apps payload (id, nodes, layout,
487
+ // provider). Groups are ordered by PROVIDER_ORDER, then any extras alpha, "Other"
488
+ // last. All file-derived strings are escaped (they land in innerHTML).
489
+ function buildWorkflowCombo(list) {
490
+ if (!$wfList) return;
491
+ if ($wfTrigger) $wfTrigger.disabled = false;
492
+ const groups = new Map();
493
+ for (const a of list) {
494
+ const p = a.provider || 'other';
495
+ if (!groups.has(p)) groups.set(p, []);
496
+ groups.get(p).push(a);
497
+ }
498
+ const order = [
499
+ ...PROVIDER_ORDER.filter((p) => groups.has(p)),
500
+ ...[...groups.keys()].filter((p) => !PROVIDER_ORDER.includes(p) && p !== 'other').sort(),
501
+ ...(groups.has('other') ? ['other'] : []),
502
+ ];
503
+ let html = '';
504
+ for (const p of order) {
505
+ const rows = groups.get(p).slice().sort((x, y) => x.id.localeCompare(y.id));
506
+ html += `<div class="wf-group" role="group" aria-label="${escapeAttr(providerLabel(p))}"><div class="wf-group-header">${escapeHtml(providerLabel(p))}</div>`;
507
+ for (const a of rows) {
508
+ const meta = `${escapeHtml(String(a.nodes))} node${a.nodes === 1 ? '' : 's'} · ${escapeHtml(String(a.layout))}`;
509
+ html += `<button type="button" class="wf-option" role="option" id="wf-opt-${escapeAttr(a.id)}" data-id="${escapeAttr(a.id)}" data-search="${escapeAttr((a.id + ' ' + p).toLowerCase())}" aria-selected="false">`
510
+ + `<span class="wf-option-name">${escapeHtml(a.id)}</span><span class="wf-option-meta">${meta}</span></button>`;
511
+ }
512
+ html += '</div>';
513
+ }
514
+ html += `<div class="wf-empty-search" hidden>No workflows match '<span class="wf-empty-term"></span>'</div>`;
515
+ $wfList.innerHTML = html;
516
+ setComboSelected(currentId || $promptSel.value);
517
+ }
518
+
519
+ function comboVisibleOptions() {
520
+ return [...$wfList.querySelectorAll('.wf-option')].filter((o) => !o.hidden && !o.closest('.wf-group').hidden);
521
+ }
522
+ function comboClearHighlight() {
523
+ $wfList.querySelectorAll('.wf-option.highlighted').forEach((o) => o.classList.remove('highlighted'));
524
+ comboHi = -1;
525
+ if ($wfSearch) $wfSearch.removeAttribute('aria-activedescendant');
526
+ }
527
+ function comboFilter(q) {
528
+ q = (q || '').toLowerCase().trim();
529
+ let any = false;
530
+ $wfList.querySelectorAll('.wf-group').forEach((g) => {
531
+ let groupVisible = false;
532
+ g.querySelectorAll('.wf-option').forEach((o) => {
533
+ const match = !q || o.dataset.search.includes(q);
534
+ o.hidden = !match;
535
+ if (match) groupVisible = true;
536
+ });
537
+ g.hidden = !groupVisible;
538
+ if (groupVisible) any = true;
539
+ });
540
+ const empty = $wfList.querySelector('.wf-empty-search');
541
+ if (empty) { empty.hidden = any; empty.querySelector('.wf-empty-term').textContent = q; }
542
+ comboClearHighlight();
543
+ }
544
+ function comboMove(delta) {
545
+ const vis = comboVisibleOptions();
546
+ if (!vis.length) return;
547
+ // From no highlight: Down → first, Up → last. Otherwise step + clamp.
548
+ comboHi = comboHi < 0 ? (delta > 0 ? 0 : vis.length - 1) : Math.max(0, Math.min(vis.length - 1, comboHi + delta));
549
+ vis.forEach((o, i) => o.classList.toggle('highlighted', i === comboHi));
550
+ const hi = vis[comboHi];
551
+ hi.scrollIntoView({ block: 'nearest' });
552
+ if ($wfSearch) $wfSearch.setAttribute('aria-activedescendant', hi.id);
553
+ }
554
+ function openCombo() {
555
+ if (!$wfPopover || ($wfTrigger && $wfTrigger.disabled)) return;
556
+ $wfPopover.hidden = false;
557
+ $wfTrigger.classList.add('open');
558
+ $wfTrigger.setAttribute('aria-expanded', 'true');
559
+ if ($wfSearch) $wfSearch.value = '';
560
+ comboFilter('');
561
+ setComboSelected(currentId || $promptSel.value);
562
+ const sel = $wfList.querySelector('.wf-option.selected');
563
+ if (sel) sel.scrollIntoView({ block: 'nearest' });
564
+ setTimeout(() => { if ($wfSearch) $wfSearch.focus(); }, 0);
565
+ }
566
+ function closeCombo(focusTrigger) {
567
+ if (!$wfPopover || $wfPopover.hidden) return;
568
+ $wfPopover.hidden = true;
569
+ $wfTrigger.classList.remove('open');
570
+ $wfTrigger.setAttribute('aria-expanded', 'false');
571
+ comboClearHighlight();
572
+ if (focusTrigger && $wfTrigger) $wfTrigger.focus();
573
+ }
574
+ // Pick a workflow: drive the hidden <select> (dispatch change → loadApp). Picking
575
+ // the already-open workflow just closes (no redundant reload).
576
+ // Re-entrancy latch: the switch-confirm dialog is async, and Ctrl+O (doOpen) can
577
+ // reopen the picker over it — ignore a second pick until the first resolves.
578
+ let switchConfirmPending = false;
579
+ async function selectComboById(id) {
580
+ const switching = id && id !== currentId;
581
+ // Guard a switch away from an app with unsaved input changes: close the picker,
582
+ // then ask Save / Don't save / Cancel before leaving. Non-user reloads (compile,
583
+ // fs-change, restore-last) call loadApp directly and never hit this path.
584
+ if (switching && isInputsDirty(currentId)) {
585
+ if (switchConfirmPending) return; // a decision is already pending
586
+ switchConfirmPending = true;
587
+ closeCombo(true);
588
+ const app = apps.get(currentId);
589
+ let choice;
590
+ try { choice = await confirmUnsavedSwitch(app ? app.displayName : currentId); }
591
+ finally { switchConfirmPending = false; }
592
+ if (choice === 'cancel') return; // stay on the current workflow
593
+ if (choice === 'save') saveCurrentInputs();
594
+ else revertInputs(currentId); // 'discard' → drop unsaved edits
595
+ } else {
596
+ closeCombo(true); // committed pick → return focus to the trigger, not the hidden search
597
+ }
598
+ if (switching) {
599
+ $promptSel.value = id;
600
+ $promptSel.dispatchEvent(new Event('change', { bubbles: true }));
601
+ }
602
+ setComboTriggerLabel(id);
603
+ }
604
+
605
+ if ($wfTrigger) {
606
+ $wfTrigger.addEventListener('click', () => ($wfPopover.hidden ? openCombo() : closeCombo(false)));
607
+ $wfTrigger.addEventListener('keydown', (e) => {
608
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openCombo(); }
609
+ });
610
+ }
611
+ if ($wfSearch) {
612
+ $wfSearch.addEventListener('input', () => comboFilter($wfSearch.value));
613
+ $wfSearch.addEventListener('keydown', (e) => {
614
+ if (e.key === 'ArrowDown') { e.preventDefault(); comboMove(1); }
615
+ else if (e.key === 'ArrowUp') { e.preventDefault(); comboMove(-1); }
616
+ else if (e.key === 'Enter') {
617
+ e.preventDefault();
618
+ const vis = comboVisibleOptions();
619
+ const pick = comboHi >= 0 ? vis[comboHi] : (vis.length === 1 ? vis[0] : null);
620
+ if (pick) selectComboById(pick.dataset.id);
621
+ } else if (e.key === 'Escape') { e.preventDefault(); closeCombo(true); }
622
+ });
623
+ }
624
+ if ($wfList) {
625
+ $wfList.addEventListener('click', (e) => {
626
+ const o = e.target.closest('.wf-option');
627
+ if (o) selectComboById(o.dataset.id);
628
+ });
629
+ }
630
+ document.addEventListener('mousedown', (e) => {
631
+ if ($wfPopover && !$wfPopover.hidden && $wfCombo && !$wfCombo.contains(e.target)) closeCombo(false);
632
+ });
633
+
634
+ // ── app inputs + HTML Viewer ────────────────────────────────────────────────
635
+
636
+ // The id of the report-viewer node — the terminal node a user double-clicks to
637
+ // LOAD the last report. The header "▶ Run workflow" generates a fresh one. The app
638
+ // qualifies only if some node runs exec C# returning HTML; the viewer is then
639
+ // the LAST node in the chain (e.g. the dedicated "HTML Report Viewer" node),
640
+ // which is also where extractReportHtml's output naturally surfaces.
641
+ // null when the app builds no report.
642
+ function reportNodeId() {
643
+ const app = currentId && apps.get(currentId);
644
+ if (!app || !app.nodes.length) return null;
645
+ // Qualify the app only if some node actually PRODUCES report HTML — its exec
646
+ // code returns an `html` field (the `data.result.html` extractReportHtml keys
647
+ // on), or it forwards `{{ <node>.result.html }}` through its args (a
648
+ // pass-through viewer). "any node has exec code" is NOT enough: a plain
649
+ // status-bar app like hello-world runs exec yet emits no HTML, so it has no
650
+ // report-viewer node (and Run must fill the trace, not open an empty viewer).
651
+ // The code test matches `html` in anonymous-object-property position only —
652
+ // `html =` OR shorthand `html` (preceded by `{`/`,`, followed by `=`/`,`/`}`)
653
+ // — so a local `html` variable in a non-report node doesn't false-qualify it.
654
+ const producesHtml = app.nodes.some((n) => {
655
+ const cfg = n.config;
656
+ if (!cfg) return false;
657
+ if (typeof cfg.code === 'string' && /[,{]\s*html\s*[=,}]/.test(cfg.code)) return true;
658
+ if (cfg.args && typeof cfg.args === 'object') {
659
+ for (const v of Object.values(cfg.args)) {
660
+ if (typeof v === 'string' && /\{\{\s*[\w.-]+\.result\.html\s*\}\}/.test(v)) return true;
661
+ }
662
+ }
663
+ return false;
664
+ });
665
+ return producesHtml ? app.nodes[app.nodes.length - 1].id : null;
666
+ }
667
+
668
+ // The node that carries the app inputs = the first node templating {{ inputs.* }}
669
+ // in its config.args. Double-clicking it opens the inputs dialog. null when the
670
+ // app declares no inputs / no node consumes them.
671
+ function inputNodeId() {
672
+ const app = currentId && apps.get(currentId);
673
+ if (!app || !Array.isArray(app.inputs) || !app.inputs.length) return null;
674
+ for (const n of app.nodes) {
675
+ const args = n.config && n.config.args;
676
+ if (args && typeof args === 'object') {
677
+ for (const v of Object.values(args)) {
678
+ if (typeof v === 'string' && /\{\{\s*inputs\.[A-Za-z0-9._-]+\s*\}\}/.test(v)) return n.id;
679
+ }
680
+ }
681
+ }
682
+ return null;
683
+ }
684
+
685
+ // Append a first-class action button to a card (e.g. "View report ▸"). The
686
+ // click is self-contained: it stops the canvas/inspect handlers from also
687
+ // firing, then runs the action. Idempotent — callers clear stale buttons first.
688
+ function addNodeAction(card, label, onClick) {
689
+ const btn = document.createElement('button');
690
+ btn.type = 'button';
691
+ btn.className = 'node-action';
692
+ btn.textContent = label;
693
+ btn.addEventListener('click', (e) => {
694
+ e.stopPropagation();
695
+ e.preventDefault();
696
+ onClick();
697
+ });
698
+ card.appendChild(btn);
699
+ }
700
+
701
+ // Show the input node's current values inline ("phase = 2"), so the user sees
702
+ // what the next run will use without opening the dialog. Reads live
703
+ // appInputValues (declared defaults + any saved/overridden value); refreshed on
704
+ // every markSpecialNodes — which openInputsDialog calls right after a change.
705
+ function addNodeInputs(card) {
706
+ const vals = currentInputs();
707
+ const keys = Object.keys(vals);
708
+ if (!keys.length) return;
709
+ const wrap = document.createElement('div');
710
+ wrap.className = 'node-inputs';
711
+ wrap.innerHTML = keys
712
+ .map((k) => `<span class="ni-pair"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-val">${escapeHtml(String(vals[k]))}</span></span>`)
713
+ .join('');
714
+ card.appendChild(wrap);
715
+ }
716
+
717
+ // Flag report + input nodes on the canvas and give each a visible action
718
+ // button — "View report ▸" / "Set inputs ▸" — so the affordance is first-class,
719
+ // not a hidden double-click. (Double-click still works via the delegated handler.)
720
+ // The input node also gets an at-a-glance read-out of its current values.
721
+ function markSpecialNodes() {
722
+ const rid = reportNodeId();
723
+ const iid = inputNodeId();
724
+ document.querySelectorAll('.agent-card').forEach((card) => {
725
+ const id = card.dataset.agentId;
726
+ const isReport = !!rid && id === rid;
727
+ const isInput = !!iid && id === iid;
728
+ card.classList.toggle('report-node', isReport);
729
+ card.classList.toggle('input-node', isInput);
730
+ // clear anything we injected last render, so re-renders never stack
731
+ card.querySelectorAll('.node-action, .node-inputs').forEach((b) => b.remove());
732
+ if (isInput) addNodeInputs(card); // current values, above the button
733
+ if (isReport) addNodeAction(card, 'View report ▸', () => showReport(id));
734
+ if (isInput) addNodeAction(card, 'Set inputs ▸', () => openInputsDialog());
735
+ });
736
+ }
737
+
738
+ // Per-app declared-input values, set via the input node's double-click dialog.
739
+ // Seeded from each input's declared default so the app is runnable with no action.
740
+ // (The old above-canvas inputs strip was removed — inputs live on the node now.)
741
+ const appInputValues = new Map(); // appId -> { name: value }
742
+
743
+ // Dirty tracking for input VALUES (never the .flo/lock). dirtyBaseline holds a
744
+ // canonical snapshot of an app's inputs at the moment they were last seeded or
745
+ // saved; "dirty" = the live values diverge from it. Comparing to a captured
746
+ // baseline (not raw localStorage) means defaults-untouched reads clean, and
747
+ // editing a value back to its saved value clears dirty again.
748
+ const dirtyBaseline = new Map(); // appId -> canonical JSON string
749
+ function canonicalInputs(obj) {
750
+ const o = obj || {};
751
+ return JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]));
752
+ }
753
+ function setInputBaseline(id) { if (id) dirtyBaseline.set(id, canonicalInputs(appInputValues.get(id))); }
754
+ function isInputsDirty(id) {
755
+ return !!id && dirtyBaseline.has(id) && canonicalInputs(appInputValues.get(id)) !== dirtyBaseline.get(id);
756
+ }
757
+ // Throw away unsaved edits: restore the live values to the baseline snapshot.
758
+ function revertInputs(id) {
759
+ if (!id || !dirtyBaseline.has(id)) return;
760
+ appInputValues.set(id, Object.fromEntries(JSON.parse(dirtyBaseline.get(id))));
761
+ }
762
+ // Toggle the unsaved-changes indicator (header dot + menu Save item) for the
763
+ // active app. Cheap; safe to call on every input change, save, and app load.
764
+ function refreshDirtyIndicator() {
765
+ const dirty = isInputsDirty(currentId);
766
+ const dot = document.getElementById('wf-dirty-dot');
767
+ if (dot) dot.hidden = !dirty;
768
+ const saveItem = document.querySelector('.menu-item[data-action="save"]');
769
+ if (saveItem) saveItem.classList.toggle('menu-item-dirty', dirty);
770
+ }
771
+ // Refresh/close guard: unsaved input values live only in memory, so warn before
772
+ // the page unloads while dirty (the browser shows its own generic confirm).
773
+ window.addEventListener('beforeunload', (e) => {
774
+ if (isInputsDirty(currentId)) { e.preventDefault(); e.returnValue = ''; }
775
+ });
776
+
777
+ function seedAppInputs(app) {
778
+ if (app && Array.isArray(app.inputs) && app.inputs.length) {
779
+ if (!appInputValues.has(app.id)) {
780
+ const seed = {};
781
+ app.inputs.forEach((inp) => { if (inp.default != null) seed[inp.name] = inp.default; });
782
+ // Overlay explicitly-saved values ("Save inputs"), but only for inputs the
783
+ // .flo still declares (a renamed/removed input is dropped) and only after
784
+ // coercing to the declared type — mirrors the inputs dialog, so a tampered
785
+ // or stale localStorage value can't feed a bad type to `aware app run`.
786
+ const saved = loadSavedInputs(app.id);
787
+ if (saved) {
788
+ app.inputs.forEach((inp) => {
789
+ if (!(inp.name in saved)) return;
790
+ let v = saved[inp.name];
791
+ if (inp.type === 'integer' || inp.type === 'number') {
792
+ const num = Number(v);
793
+ if (Number.isNaN(num)) return; // keep the default rather than forward garbage
794
+ v = num;
795
+ }
796
+ seed[inp.name] = v;
797
+ });
798
+ }
799
+ appInputValues.set(app.id, seed);
800
+ setInputBaseline(app.id); // first open this session → clean baseline
801
+ } else {
802
+ // Drop values whose declared input was renamed/removed since last seed.
803
+ const names = new Set(app.inputs.map((i) => i.name));
804
+ const cur = appInputValues.get(app.id);
805
+ Object.keys(cur).forEach((k) => { if (!names.has(k)) delete cur[k]; });
806
+ // Prune the baseline by the same key set so a .flo schema change (rename/
807
+ // remove) isn't mistaken for a user edit. Unchanged schema → no-op; edits to
808
+ // surviving keys stay dirty.
809
+ if (dirtyBaseline.has(app.id)) {
810
+ const base = Object.fromEntries(JSON.parse(dirtyBaseline.get(app.id)).filter(([k]) => names.has(k)));
811
+ dirtyBaseline.set(app.id, canonicalInputs(base));
812
+ }
813
+ }
814
+ }
815
+ }
816
+
817
+ // Current declared-input values for the active app (a copy).
818
+ function currentInputs() {
819
+ return { ...(appInputValues.get(currentId) || {}) };
820
+ }
821
+
822
+ // Save inputs (Ctrl+S / menu): persist this app's current input values locally so
823
+ // they survive refresh + reopen. Pure UI state — the .flo and lock are untouched,
824
+ // so Run stays armed. Apps with no declared inputs have nothing to save.
825
+ function saveCurrentInputs() {
826
+ const app = currentId && apps.get(currentId);
827
+ if (!app) { showToast('no app open', 'info'); return; }
828
+ if (!Array.isArray(app.inputs) || !app.inputs.length) {
829
+ showToast('this workflow has no inputs to save', 'info');
830
+ return;
831
+ }
832
+ try {
833
+ localStorage.setItem(lsInputsKey(app.id), JSON.stringify(appInputValues.get(app.id) || {}));
834
+ setInputBaseline(app.id); // saved → this is the new clean baseline
835
+ refreshDirtyIndicator();
836
+ showToast(`Saved inputs · ${app.displayName}`, 'ok');
837
+ } catch {
838
+ showToast('could not save inputs', 'err');
839
+ }
840
+ }
841
+
842
+ // Double-click the input node → styled dialog to set the declared inputs. Writes
843
+ // to appInputValues; the header "▶ Run workflow" uses them. No native window.prompt.
844
+ async function openInputsDialog() {
845
+ const app = currentId && apps.get(currentId);
846
+ if (!app || !Array.isArray(app.inputs) || !app.inputs.length) return;
847
+ const cur = currentInputs();
848
+ const fields = app.inputs.map((inp) => ({
849
+ name: inp.name,
850
+ label: inp.name + (inp.description ? ` — ${inp.description}` : ''),
851
+ type: inp.type === 'integer' || inp.type === 'number' ? 'number' : 'text',
852
+ value: cur[inp.name] != null ? cur[inp.name] : inp.default != null ? inp.default : '',
853
+ }));
854
+ const res = await formModal({ title: `Inputs · ${app.displayName}`, sub: 'Set the values this run uses, then ▶ Run workflow.', fields, okLabel: 'Set inputs' });
855
+ if (!res) return;
856
+ const store = {};
857
+ app.inputs.forEach((inp) => {
858
+ let v = String(res[inp.name] ?? '').trim();
859
+ if (v === '') { if (inp.default != null) v = String(inp.default); else return; }
860
+ if (inp.type === 'integer' || inp.type === 'number') { const num = Number(v); if (!Number.isNaN(num)) { store[inp.name] = num; return; } }
861
+ store[inp.name] = v;
862
+ });
863
+ appInputValues.set(currentId, store);
864
+ markSpecialNodes();
865
+ refreshDirtyIndicator(); // values changed → reflect unsaved state in the header/menu
866
+ const badge = Object.entries(store).map(([k, v]) => `${k}=${v}`).join(' · ');
867
+ appendNarration(`Inputs set — <strong>${escapeHtml(badge)}</strong>. Run with <strong>▶ Run workflow</strong>.`);
868
+ showToast('Inputs set: ' + badge, 'ok');
869
+ }
870
+
871
+ let reportRunning = false;
872
+ // Set when the user hits Stop. api() throws a bare Error on the cancelled
873
+ // response (the {cancelled:true} flag is lost), so we read this flag in the
874
+ // run catch blocks to show "cancelled" rather than a misleading "run failed".
875
+ let cancelRequested = false;
876
+ // The most recent rendered report per app id: { nodeId, label, html }. Lets a
877
+ // double-click LOAD the last result instantly instead of re-running.
878
+ const lastReportByApp = new Map();
879
+
880
+ // Stop the in-flight run (the escape hatch for a hung/unattached host). Marks
881
+ // cancelRequested so the catch shows "cancelled", clears the canvas running
882
+ // pulse, and asks the server to kill the `aware app run` child. The in-flight
883
+ // /api/run then rejects via api() and the catch renders the cancelled state.
884
+ async function stopRun() {
885
+ if (!reportRunning && !state.running) return;
886
+ cancelRequested = true;
887
+ clearNodeStatus();
888
+ const stopBtn = document.querySelector('.overlay-stop');
889
+ if (stopBtn) { stopBtn.disabled = true; stopBtn.textContent = 'Cancelling…'; }
890
+ try { await api('/api/run/stop', { method: 'POST' }); } catch { /* the run's own catch surfaces it */ }
891
+ }
892
+
893
+ function paintReport(html) { $reportFrame.srcdoc = html; $reportOverlay.hidden = true; $reportOverlay.innerHTML = ''; }
894
+
895
+ // Double-click the HTML Viewer node → LOAD the last report (no run; running is
896
+ // the header "▶ Run workflow" button's job). Shows a prompt if nothing has run yet.
897
+ function showReport(nodeId) {
898
+ const app = currentId && apps.get(currentId);
899
+ if (!app) return;
900
+ $reportModal.dataset.nodeId = nodeId;
901
+ $reportTitle.textContent = `HTML Viewer · ${app.displayName}`;
902
+ showModal($reportModal);
903
+ if (reportRunning) return; // a run is in flight — its overlay already shows progress
904
+ const cached = lastReportByApp.get(currentId);
905
+ if (cached && cached.html) {
906
+ $reportSub.innerHTML = `Last report${cached.label ? ' · ' + escapeHtml(cached.label) : ''} — rendered from the run's output, never composed by the UI.`;
907
+ paintReport(cached.html);
908
+ } else {
909
+ $reportFrame.srcdoc = '';
910
+ $reportOverlay.hidden = false;
911
+ $reportOverlay.innerHTML = `<div>No report yet. Click <strong>▶ Run workflow</strong> (top right) to run it against the live model, then double-click to view it any time.</div>`;
912
+ $reportSub.textContent = 'Double-click loads the last report; ▶ Run workflow generates a fresh one.';
913
+ }
914
+ }
915
+
916
+ // The dedicated RUN: executes the app for real (simulate:false) with the
917
+ // current inputs, renders + caches the HTML the report node returned. The UI
918
+ // never builds the HTML — it relays exactly what the exec node produced.
919
+ async function runReport(nodeId, opts = {}) {
920
+ const app = currentId && apps.get(currentId);
921
+ if (!app) return;
922
+ if (!app.runnable) { showToast('Compile the workflow first — Run is gated on a fresh lock.', 'warn'); return; }
923
+ if (reportRunning) return;
924
+ reportRunning = true;
925
+ cancelRequested = false;
926
+ markCanvasRunning(); // paints behind the modal; visible once it's closed
927
+
928
+ const inputs = currentInputs();
929
+ const inputBadge = Object.entries(inputs).map(([k, v]) => `${k}=${v}`).join(' · ');
930
+ $reportTitle.textContent = `HTML Viewer · ${app.displayName}`;
931
+ $reportSub.innerHTML = `Live run of <code>${escapeHtml(nodeId)}</code>${inputBadge ? ' · ' + escapeHtml(inputBadge) : ''} — rendered from the node's output, never composed by the UI.`;
932
+ $reportFrame.srcdoc = ''; // clear any previously rendered report while this run is in flight
933
+ $reportOverlay.hidden = false;
934
+ $reportOverlay.innerHTML = opts.debug
935
+ ? `<div class="spinner"></div><div>Launching the .NET debugger for <code>${escapeHtml(nodeId)}</code> — a Windows picker will pop; choose your <strong>Visual Studio</strong> to attach to <code>aware-tekla.exe</code>, then step through. The report renders when you let it finish.</div>`
936
+ : `<div class="spinner"></div><div>Running <code>${escapeHtml(currentId)}</code> against the live model…</div><button type="button" class="overlay-stop">■ Stop run</button>`;
937
+ showModal($reportModal);
938
+ $reportModal.dataset.nodeId = nodeId;
939
+
940
+ try {
941
+ // Debug-in-VS goes straight to the host bridge (Debugger.Launch injected),
942
+ // so VS attaches to aware-tekla.exe; a normal run goes through aware app run.
943
+ const res = opts.debug
944
+ ? await api('/api/debug-node', { method: 'POST', body: JSON.stringify({ id: currentId, nodeId, inputs }) })
945
+ : await api('/api/run', { method: 'POST', body: JSON.stringify({ id: currentId, simulate: false, inputs }) });
946
+ // Reflect the run in the Execution tab too.
947
+ if (Array.isArray(res.events)) { liveTrace.length = 0; res.events.forEach(pushTrace); state.hasRun = true; if (state.currentTab === 'execution') renderInspect(); }
948
+ const html = res.report && res.report.html;
949
+ if (!html) {
950
+ $reportOverlay.innerHTML = `<div>Run completed but <code>${escapeHtml(nodeId)}</code> returned no <code>html</code>. Check the Execution tab.</div>`;
951
+ return;
952
+ }
953
+ paintReport(html);
954
+ lastReportByApp.set(currentId, { nodeId, label: inputBadge, html });
955
+ $reportModal.dataset.html = '1';
956
+ appendNarration(`Rendered the report from <strong>${escapeHtml(nodeId)}</strong>${inputBadge ? ' (' + escapeHtml(inputBadge) + ')' : ''} — live run against the model.`);
957
+ } catch (e) {
958
+ const msg = e && e.message ? e.message : String(e);
959
+ if (cancelRequested) {
960
+ // User hit Stop — an expected outcome, not a failure. A node already
961
+ // executing on the host may still finish; the run result is discarded.
962
+ clearNodeStatus(); // wipe any status painted before the flag was set
963
+ $reportOverlay.innerHTML = `<div>Run cancelled. A node already running on the host may still finish there; click <strong>▶ Run workflow</strong> to run again.</div>`;
964
+ showToast('Run cancelled', 'info');
965
+ } else {
966
+ // Expected, recoverable failure (host not attached, stale lock, …): show it
967
+ // in the viewer overlay + a toast. No console.error — the server returns the
968
+ // failure in-band, so a failed run isn't a console-worthy fault.
969
+ $reportOverlay.innerHTML = `<div>Run failed: ${escapeHtml(msg)}. Check the host is attached and the lock is fresh.</div>`;
970
+ showToast('Run failed: ' + msg, 'warn');
971
+ }
972
+ } finally {
973
+ reportRunning = false;
974
+ }
975
+ }
976
+
977
+ function showModal(el) { el.classList.add('show'); }
978
+ function hideModal(el) { el.classList.remove('show'); }
979
+
980
+ // Save-on-switch confirmation. Resolves with 'save' | 'discard' | 'cancel'.
981
+ // Save is the primary (Enter); Esc and the backdrop both cancel (the safe,
982
+ // non-destructive default — never lose edits to a stray key/click).
983
+ const $confirmModal = document.getElementById('confirm-modal');
984
+ function confirmUnsavedSwitch(appName) {
985
+ return new Promise((resolve) => {
986
+ const $sub = document.getElementById('confirm-modal-sub');
987
+ const $save = document.getElementById('confirm-save');
988
+ const $dont = document.getElementById('confirm-dont-save');
989
+ const $cancel = document.getElementById('confirm-cancel');
990
+ $sub.textContent = `You changed the inputs for “${appName}” but haven’t saved them yet. What would you like to do?`;
991
+ showModal($confirmModal);
992
+ setTimeout(() => $save.focus(), 0);
993
+ const done = (result) => {
994
+ hideModal($confirmModal);
995
+ $save.onclick = $dont.onclick = $cancel.onclick = $confirmModal.onclick = null;
996
+ document.removeEventListener('keydown', onKey, true);
997
+ resolve(result);
998
+ };
999
+ const onKey = (e) => {
1000
+ if (e.key === 'Escape') { e.preventDefault(); done('cancel'); }
1001
+ else if (e.key === 'Enter') { e.preventDefault(); done('save'); }
1002
+ };
1003
+ $save.onclick = () => done('save');
1004
+ $dont.onclick = () => done('discard');
1005
+ $cancel.onclick = () => done('cancel');
1006
+ $confirmModal.onclick = (e) => { if (e.target === $confirmModal) done('cancel'); };
1007
+ document.addEventListener('keydown', onKey, true);
1008
+ });
1009
+ }
1010
+
1011
+ // Styled replacement for window.prompt — matches the app's modal style/scale.
1012
+ // `fields`: [{name,label,type,value,placeholder,multiline}]. Resolves with a
1013
+ // { name: value } object on Save (Enter), or null on Cancel (Esc / backdrop).
1014
+ const $formModal = document.getElementById('form-modal');
1015
+ function formModal({ title, sub = '', fields, okLabel = 'Save' }) {
1016
+ return new Promise((resolve) => {
1017
+ const $title = document.getElementById('form-modal-title');
1018
+ const $sub = document.getElementById('form-modal-sub');
1019
+ const $body = document.getElementById('form-modal-body');
1020
+ const $ok = document.getElementById('form-modal-ok');
1021
+ const $cancel = document.getElementById('form-modal-cancel');
1022
+ $title.textContent = title;
1023
+ $sub.textContent = sub || '';
1024
+ $sub.hidden = !sub;
1025
+ $body.innerHTML = fields.map((f) => {
1026
+ const id = `fm-${f.name}`;
1027
+ const val = f.value != null ? String(f.value) : '';
1028
+ const ph = escapeHtml(f.placeholder || '');
1029
+ let ctl;
1030
+ if (f.type === 'images') {
1031
+ ctl = `<div class="fm-images" data-fm-images="${escapeHtml(f.name)}">
1032
+ <div class="fm-drop" role="button" tabindex="0" aria-label="Paste a screenshot or click to attach">Paste a screenshot (Ctrl+V) or click to add<input type="file" accept="image/*" multiple class="fm-file-input" tabindex="-1" aria-hidden="true"></div>
1033
+ <div class="fm-thumbs" hidden></div>
1034
+ </div>`;
1035
+ } else {
1036
+ ctl = f.multiline
1037
+ ? `<textarea id="${id}" rows="4" data-fm="${escapeHtml(f.name)}" placeholder="${ph}">${escapeHtml(val)}</textarea>`
1038
+ : `<input id="${id}" type="${f.type || 'text'}"${f.type === 'number' ? ' step="1"' : ''} data-fm="${escapeHtml(f.name)}" value="${escapeHtml(val)}" placeholder="${ph}">`;
1039
+ }
1040
+ return `<div class="modal-field"><label for="${id}">${escapeHtml(f.label || f.name)}</label>${ctl}</div>`;
1041
+ }).join('');
1042
+ $ok.textContent = okLabel;
1043
+ showModal($formModal);
1044
+ const first = $body.querySelector('input:not(.fm-file-input),textarea');
1045
+ if (first) { first.focus(); if (first.select) first.select(); }
1046
+ // ── images-field setup ──────────────────────────────────────────────────────
1047
+ const imageStates = new Map();
1048
+ $body.querySelectorAll('.fm-images').forEach((box) => {
1049
+ const name = box.dataset.fmImages;
1050
+ const state = [];
1051
+ const MAX = 8;
1052
+ const thumbs = box.querySelector('.fm-thumbs');
1053
+ const drop = box.querySelector('.fm-drop');
1054
+ const fileInput = box.querySelector('.fm-file-input');
1055
+ const renderThumbs = () => {
1056
+ thumbs.hidden = state.length === 0;
1057
+ thumbs.innerHTML = state.map((im, i) =>
1058
+ `<div class="fm-thumb"><img src="${escapeAttr(im.dataUrl)}" alt="${escapeAttr(im.name || 'screenshot ' + (i + 1))}"><button type="button" class="fm-thumb-del" data-i="${i}" aria-label="Remove">×</button></div>`
1059
+ ).join('');
1060
+ thumbs.querySelectorAll('.fm-thumb-del').forEach((b) => {
1061
+ b.onclick = () => { state.splice(Number(b.dataset.i), 1); renderThumbs(); };
1062
+ });
1063
+ };
1064
+ const addFile = (file) => {
1065
+ if (!file || !file.type.startsWith('image/')) return;
1066
+ if (state.length >= MAX) { showToast(`Max ${MAX} snapshots`, 'warn'); return; }
1067
+ const reader = new FileReader();
1068
+ reader.onload = () => { state.push({ name: file.name, dataUrl: reader.result }); renderThumbs(); };
1069
+ reader.readAsDataURL(file);
1070
+ };
1071
+ imageStates.set(name, { state, renderThumbs });
1072
+ drop.onclick = () => fileInput.click();
1073
+ drop.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } };
1074
+ fileInput.onchange = () => {
1075
+ const picked = Array.from(fileInput.files || []);
1076
+ const room = Math.max(0, MAX - state.length);
1077
+ if (picked.length > room) showToast(`Max ${MAX} snapshots`, 'warn');
1078
+ picked.slice(0, room).forEach(addFile);
1079
+ fileInput.value = '';
1080
+ };
1081
+ });
1082
+ // ── paste support: paste an image into the modal → first images field ──────
1083
+ $body.onpaste = (e) => {
1084
+ const box = $body.querySelector('.fm-images');
1085
+ if (!box) return;
1086
+ const items = Array.from((e.clipboardData && e.clipboardData.items) || []);
1087
+ const imgs = items.filter((it) => it.kind === 'file' && it.type.startsWith('image/'));
1088
+ if (!imgs.length) return;
1089
+ e.preventDefault();
1090
+ const name = box.dataset.fmImages;
1091
+ const entry = imageStates.get(name);
1092
+ if (!entry) return;
1093
+ const { state, renderThumbs } = entry;
1094
+ const room = Math.max(0, 8 - state.length);
1095
+ if (imgs.length > room) showToast('Max 8 snapshots', 'warn');
1096
+ imgs.slice(0, room).forEach((it) => {
1097
+ const file = it.getAsFile();
1098
+ if (!file) return;
1099
+ const reader = new FileReader();
1100
+ reader.onload = () => { state.push({ name: file.name || 'pasted.png', dataUrl: reader.result }); renderThumbs(); };
1101
+ reader.readAsDataURL(file);
1102
+ });
1103
+ };
1104
+ const collect = () => {
1105
+ const out = {};
1106
+ $body.querySelectorAll('[data-fm]').forEach((el) => { out[el.dataset.fm] = el.value; });
1107
+ imageStates.forEach((entry, name) => { out[name] = entry.state; });
1108
+ return out;
1109
+ };
1110
+ const done = (result) => {
1111
+ hideModal($formModal);
1112
+ $ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $body.onkeydown = null; $body.onpaste = null;
1113
+ resolve(result);
1114
+ };
1115
+ $ok.onclick = () => done(collect());
1116
+ $cancel.onclick = () => done(null);
1117
+ $formModal.onclick = (e) => { if (e.target === $formModal) done(null); };
1118
+ $body.onkeydown = (e) => {
1119
+ if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); done(collect()); }
1120
+ else if (e.key === 'Escape') { e.preventDefault(); done(null); }
1121
+ };
1122
+ });
1123
+ }
1124
+
1125
+ // Double-click a node on the canvas: the report node → LOAD the last report (no
1126
+ // run); the input node → open the styled inputs dialog. Delegated + bound once;
1127
+ // survives every renderTopology that rebuilds the cards.
1128
+ $topology.addEventListener('dblclick', (e) => {
1129
+ const card = e.target.closest('.agent-card');
1130
+ if (!card) return;
1131
+ const id = card.dataset.agentId;
1132
+ if (!id) return;
1133
+ if (id === reportNodeId()) showReport(id);
1134
+ else if (id === inputNodeId()) openInputsDialog();
1135
+ });
1136
+
1137
+ $reportClose.onclick = () => hideModal($reportModal);
1138
+ $reportModal.addEventListener('click', (e) => { if (e.target === $reportModal) hideModal($reportModal); });
1139
+ // The Stop button is rebuilt into the overlay each run — delegate so one
1140
+ // listener survives every innerHTML swap.
1141
+ $reportOverlay.addEventListener('click', (e) => { if (e.target.closest('.overlay-stop')) stopRun(); });
1142
+ $reportOpen.onclick = () => {
1143
+ const html = $reportFrame.srcdoc;
1144
+ if (!html) return;
1145
+ // Open via a blob: URL — an opaque origin, so the report can't script the
1146
+ // app origin (mirrors the iframe's sandbox intent).
1147
+ const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
1148
+ window.open(url, '_blank');
1149
+ setTimeout(() => URL.revokeObjectURL(url), 30000);
1150
+ };
1151
+ $promptSel.onchange = () => { loadApp($promptSel.value).catch(reportErr); };
1152
+
1153
+ $compileBtn.onclick = async () => {
1154
+ if (!currentId || $compileBtn.disabled) return;
1155
+ const id = currentId;
1156
+ $compileBtn.disabled = true;
1157
+ const prev = $compileBtn.textContent;
1158
+ $compileBtn.textContent = '⎙ Compiling…';
1159
+ try {
1160
+ await api('/api/compile', { method: 'POST', body: JSON.stringify({ id }) });
1161
+ await loadApp(id);
1162
+ const app = apps.get(id);
1163
+ appendNarration(app.runnable
1164
+ ? `Compiled · <code>${escapeHtml(id)}.lock</code> frozen at source-hash. Run is armed.`
1165
+ : `Compiled, but ${app.notes.length} note(s) remain — review the strip.`);
1166
+ showToast('Compiled — lock frozen', 'ok');
1167
+ } catch (e) {
1168
+ $compileBtn.textContent = prev;
1169
+ $compileBtn.disabled = false;
1170
+ reportErr(e);
1171
+ }
1172
+ };
1173
+
1174
+ // ── Bake into agent ──────────────────────────────────────────────────────────
1175
+ // Open a preview/confirm modal (agent name + the inputs it will expose, mirrored
1176
+ // 1:1 from the app's declared inputs), then POST /api/bake. The action is
1177
+ // subscription-gated server-side (402 → the standard sign-in prompt); the UI just
1178
+ // triggers the verb and renders the outcome. Thin-UI: composes nothing itself.
1179
+ const $bakeModal = document.getElementById('bake-modal');
1180
+ function openBakeModal() {
1181
+ if (!currentId || ($menuBakeItem && $menuBakeItem.disabled)) return;
1182
+ const app = apps.get(currentId);
1183
+ if (!app) return;
1184
+ document.getElementById('bake-title').textContent = app.baked ? 'Re-bake agent' : 'Bake into agent';
1185
+ const inps = app.inputs || [];
1186
+ const inputsHtml = inps.length
1187
+ ? inps.map((i) => `<span class="ni-pair"><span class="ni-key">${escapeHtml(i.name)}</span><span class="ni-val">${escapeHtml(i.type)}</span></span>`).join('')
1188
+ : '<span class="bake-noinputs">No inputs — this agent is called with no parameters.</span>';
1189
+ // Rebuild the whole body each open — a prior runBake() replaces it with the
1190
+ // spinner (destroying #bake-name/#bake-inputs), so we can't assume they exist.
1191
+ const $body = document.getElementById('bake-body');
1192
+ $body.classList.remove('busy');
1193
+ $body.innerHTML =
1194
+ `<div class="modal-field"><label>Agent name</label><div id="bake-name" class="bake-name"><code>${escapeHtml(app.id)}</code></div></div>` +
1195
+ `<div class="modal-field"><label>Agent inputs</label><div id="bake-inputs" class="bake-inputs">${inputsHtml}</div></div>`;
1196
+ const $confirm = document.getElementById('bake-confirm');
1197
+ const $cancel = document.getElementById('bake-cancel');
1198
+ $confirm.disabled = false; $cancel.disabled = false;
1199
+ $confirm.textContent = '⊙ Bake';
1200
+ showModal($bakeModal);
1201
+ setTimeout(() => $confirm.focus(), 0);
1202
+ }
1203
+
1204
+ async function runBake() {
1205
+ const id = currentId;
1206
+ if (!id) return;
1207
+ const $confirm = document.getElementById('bake-confirm');
1208
+ const $cancel = document.getElementById('bake-cancel');
1209
+ const $body = document.getElementById('bake-body');
1210
+ $confirm.disabled = true; $cancel.disabled = true;
1211
+ $confirm.textContent = '⊙ Baking…';
1212
+ $body.classList.add('busy');
1213
+ $body.innerHTML = '<div class="bake-progress"><span class="rtn-spinner"></span><span>Reinstalling as an agent — this takes a few seconds…</span></div>';
1214
+ try {
1215
+ await api('/api/bake', { method: 'POST', body: JSON.stringify({ id }) });
1216
+ hideModal($bakeModal);
1217
+ await loadApp(id);
1218
+ appendNarration(`Baked <strong>${escapeHtml(id)}</strong> as a reusable agent — find it in the Agents library (<strong>⊞ Agents</strong>).`);
1219
+ showToast(`Baked — now in the Agents library as “${id}”`, 'ok');
1220
+ } catch (e) {
1221
+ hideModal($bakeModal);
1222
+ // The server restores the original install on failure (recoverable) → warn, not err.
1223
+ const msg = (e && e.message ? e.message : 'Bake failed').slice(0, 80);
1224
+ showToast(`Bake failed: ${msg}`, 'warn');
1225
+ }
1226
+ }
1227
+
1228
+ document.getElementById('bake-cancel').onclick = () => hideModal($bakeModal);
1229
+ document.getElementById('bake-confirm').onclick = () => runBake();
1230
+ $bakeModal.onclick = (e) => { if (e.target === $bakeModal && !document.getElementById('bake-confirm').disabled) hideModal($bakeModal); };
1231
+
1232
+ // ── Graft into agent ────────────────────────────────────────────────────────
1233
+ // Build an agent from a foreign tool (DLL / C# source / NuGet / OpenAPI / …).
1234
+ // The server stages an `aware build` into a throwaway AWARE_HOME, we preview the
1235
+ // discovered commands, then commit. Thin-UI: we trigger the verb and render the
1236
+ // result; AWARE owns the reader + recipes (incl. the Tekla model-plugin recipe),
1237
+ // so we SURFACE its output and shape nothing. The wizard body is rebuilt per step.
1238
+ //
1239
+ // SAFETY: every dynamic value below is escapeHtml()-escaped (the app's standard);
1240
+ // markup is injected via graftFill() (replaceChildren + insertAdjacentHTML) so the
1241
+ // escaping discipline is explicit at each call site.
1242
+ const $graftModal = document.getElementById('graft-modal');
1243
+ const $graftBack = document.getElementById('graft-back');
1244
+ const $graftCancel = document.getElementById('graft-cancel');
1245
+ const $graftPrimary = document.getElementById('graft-primary');
1246
+ function graftFill(el, markup) { if (!el) return; el.replaceChildren(); if (markup) el.insertAdjacentHTML('beforeend', markup); }
1247
+
1248
+ // Featured kinds (shown first) are the AEC-relevant ones; the rest live under "More".
1249
+ const GRAFT_KINDS = [
1250
+ { kind: 'dlls', label: '.NET / Tekla DLL', desc: 'A .dll or folder of .dlls — Tekla plugins, in-house .NET libraries', featured: true, decompile: true, match: true,
1251
+ field: { label: 'DLL path or glob', placeholder: 'C:\\Vendor\\Plugin\\*.dll', hint: 'Paste a path or a glob (a folder’s *.dll). Multiple assemblies load together.' } },
1252
+ { kind: 'csharp', label: 'C# source', desc: 'A .cs file/folder, or a .csproj / .sln — your in-house source', featured: true,
1253
+ field: { label: 'C# source path', placeholder: 'C:\\src\\MyPlugin (or a .csproj / .sln)', hint: 'A .cs file, folder, .csproj or .sln. AWARE reads it with Roslyn.' } },
1254
+ { kind: 'nuget', label: 'NuGet package', desc: 'A NuGet package by name and version', featured: true, decompile: true,
1255
+ field: { label: 'Package', placeholder: 'Tekla.Structures.Model@2025.0', hint: 'Format: Package@version' } },
1256
+ { kind: 'openapi', label: 'OpenAPI / REST', desc: 'An OpenAPI 3.x spec — a URL or a local file', featured: true,
1257
+ field: { label: 'Spec URL or file', placeholder: 'https://api.example.com/openapi.json', hint: 'A URL, or a local .json / .yaml file path' } },
1258
+ { kind: 'cli', label: 'CLI tool', desc: 'Any command-line tool on your PATH', featured: false,
1259
+ field: { label: 'Command name', placeholder: 'ffmpeg', hint: 'The command as it appears on your PATH' } },
1260
+ { kind: 'com', label: 'COM ProgID', desc: 'A COM component by ProgID', featured: false,
1261
+ field: { label: 'ProgID', placeholder: 'Excel.Application', hint: 'The COM ProgID from the registry' } },
1262
+ { kind: 'python', label: 'Python module', desc: 'An importable Python module', featured: false,
1263
+ field: { label: 'Module', placeholder: 'requests', hint: 'The importable module name' } },
1264
+ { kind: 'npm', label: 'npm package', desc: 'An npm package (reflected from its .d.ts)', featured: false,
1265
+ field: { label: 'Package', placeholder: 'three@0.162.0', hint: 'Format: package@version' } },
1266
+ { kind: 'ruby', label: 'Ruby gem', desc: 'A RubyGems package', featured: false,
1267
+ field: { label: 'Gem', placeholder: 'aws-sdk@3.1.0', hint: 'Format: gem@version' } },
1268
+ { kind: 'yard', label: 'YARD docs', desc: 'YARD-rendered Ruby docs — URL or folder', featured: false,
1269
+ field: { label: 'URL or directory', placeholder: 'https://docs.mylib.com', hint: 'A YARD-doc URL or a local directory' } },
1270
+ { kind: 'headers', label: 'C / C++ headers', desc: 'A C/C++ header glob', featured: false,
1271
+ field: { label: 'Header glob', placeholder: 'C:\\SDK\\include\\*.h', hint: 'Paste a glob or a folder path' } },
1272
+ ];
1273
+
1274
+ let graftState = {};
1275
+ const graftCfg = (kind) => GRAFT_KINDS.find((k) => k.kind === kind) || GRAFT_KINDS[0];
1276
+ const graftBodyEl = () => document.getElementById('graft-body');
1277
+ function graftFooter({ backShown = false, primaryLabel = 'Introspect', primaryDisabled = false } = {}) {
1278
+ $graftBack.hidden = !backShown;
1279
+ $graftPrimary.textContent = primaryLabel;
1280
+ $graftPrimary.disabled = primaryDisabled;
1281
+ }
1282
+
1283
+ function openGraftModal() {
1284
+ graftState = { kind: 'dlls', ref: '', decompile: false, showMore: false, preview: null, stagedRef: null, onPrimary: runGraftIntrospect, onBack: null };
1285
+ document.getElementById('graft-title').textContent = 'Graft into agent';
1286
+ document.getElementById('graft-sub').textContent = 'Point at a DLL, package, or API spec — AWARE reads it and builds an agent you can drop into any workflow.';
1287
+ $graftBack.disabled = false; $graftCancel.disabled = false;
1288
+ graftRenderPicker();
1289
+ showModal($graftModal);
1290
+ }
1291
+
1292
+ function graftRenderPicker() {
1293
+ graftState.onPrimary = runGraftIntrospect;
1294
+ graftState.onBack = null;
1295
+ const cfg = graftCfg(graftState.kind);
1296
+ const tiles = GRAFT_KINDS.filter((k) => k.featured || graftState.showMore).map((k) =>
1297
+ `<button type="button" class="graft-kind${k.kind === graftState.kind ? ' selected' : ''}" data-kind="${k.kind}">` +
1298
+ `<div class="gk-name">${escapeHtml(k.label)}</div><div class="gk-desc">${escapeHtml(k.desc)}</div></button>`).join('');
1299
+ const moreBtn = graftState.showMore ? '' : '<button type="button" class="graft-more" id="graft-more">▸ More source types</button>';
1300
+ const decompile = cfg.decompile
1301
+ ? `<label class="rtn-toggle" style="margin-top:4px;"><input type="checkbox" id="graft-decompile"${graftState.decompile ? ' checked' : ''}><span class="rtn-toggle-track"></span><span class="rtn-toggle-label">Include decompiled private types (slower)</span></label>`
1302
+ : '';
1303
+ graftFill(graftBodyEl(),
1304
+ `<div class="modal-field"><label>Source type</label><div class="graft-kinds">${tiles}</div>${moreBtn}</div>` +
1305
+ `<div class="modal-field"><label id="graft-field-label">${escapeHtml(cfg.field.label)}</label>` +
1306
+ `<div class="graft-row"><input type="text" id="graft-ref" placeholder="${escapeHtml(cfg.field.placeholder)}" value="${escapeHtml(graftState.ref)}" autocomplete="off" spellcheck="false"></div>` +
1307
+ `<div class="graft-hint" id="graft-hint">${escapeHtml(cfg.field.hint)}</div>` +
1308
+ `<div id="graft-matched"></div>${decompile}</div>`);
1309
+ graftBodyEl().querySelectorAll('.graft-kind').forEach((el) => { el.onclick = () => { graftState.kind = el.dataset.kind; graftRenderPicker(); }; });
1310
+ const more = document.getElementById('graft-more'); if (more) more.onclick = () => { graftState.showMore = true; graftRenderPicker(); };
1311
+ const ref = document.getElementById('graft-ref');
1312
+ ref.oninput = () => {
1313
+ graftState.ref = ref.value;
1314
+ graftFooter({ primaryLabel: 'Introspect', primaryDisabled: !graftState.ref.trim() });
1315
+ if (graftCfg(graftState.kind).match) graftMatchDebounced();
1316
+ };
1317
+ ref.onkeydown = (e) => { if (e.key === 'Enter' && graftState.ref.trim()) runGraftIntrospect(); };
1318
+ const dec = document.getElementById('graft-decompile'); if (dec) dec.onchange = () => { graftState.decompile = dec.checked; };
1319
+ graftFooter({ backShown: false, primaryLabel: 'Introspect', primaryDisabled: !graftState.ref.trim() });
1320
+ setTimeout(() => ref.focus(), 0);
1321
+ if (graftCfg(graftState.kind).match && graftState.ref.trim()) graftMatch();
1322
+ }
1323
+
1324
+ let graftMatchTimer = null;
1325
+ function graftMatchDebounced() { clearTimeout(graftMatchTimer); graftMatchTimer = setTimeout(graftMatch, 350); }
1326
+ async function graftMatch() {
1327
+ const el = document.getElementById('graft-matched'); if (!el) return;
1328
+ const glob = graftState.ref.trim();
1329
+ if (!glob) { el.replaceChildren(); return; }
1330
+ try {
1331
+ const { files } = await api('/api/graft/match', { method: 'POST', body: JSON.stringify({ glob }) });
1332
+ if (!files || !files.length) { el.replaceChildren(); return; }
1333
+ graftFill(el,
1334
+ `<div class="graft-matched"><div class="graft-matched-h">Matched assemblies (${files.length})</div>` +
1335
+ `<div class="graft-matched-f">${files.map(escapeHtml).join('<br>')}</div></div>` +
1336
+ (files.length > 1 ? '<div class="graft-hint">All assemblies load together so AWARE can resolve references across them.</div>' : ''));
1337
+ } catch { el.replaceChildren(); }
1338
+ }
1339
+
1340
+ function graftIntrospectingLabel(kind) {
1341
+ if (kind === 'nuget') return 'Restoring the NuGet package and reading its API…';
1342
+ if (kind === 'dlls' || kind === 'csharp') return 'Reading your assemblies…';
1343
+ if (kind === 'openapi' || kind === 'yard') return 'Fetching and reading the spec…';
1344
+ return 'Discovering commands…';
1345
+ }
1346
+
1347
+ async function runGraftIntrospect() {
1348
+ const cfg = graftCfg(graftState.kind);
1349
+ graftFill(graftBodyEl(),
1350
+ `<div class="graft-progress"><span class="rtn-spinner"></span><span>${escapeHtml(graftIntrospectingLabel(graftState.kind))}</span></div>` +
1351
+ '<div class="graft-hint">Large packages can take 30–60 seconds (a NuGet restore is included).</div>');
1352
+ graftFooter({ backShown: false, primaryLabel: 'Introspecting…', primaryDisabled: true });
1353
+ try {
1354
+ const body = { sourceKind: graftState.kind, sourceRef: graftState.ref.trim() };
1355
+ if (cfg.decompile && graftState.decompile) body.decompile = true;
1356
+ const { preview } = await api('/api/graft/introspect', { method: 'POST', body: JSON.stringify(body) });
1357
+ graftState.preview = preview;
1358
+ graftState.stagedRef = preview.stagedRef;
1359
+ graftRenderPreview(preview);
1360
+ } catch (e) {
1361
+ graftRenderPicker(); // restores the entered values from graftState
1362
+ const msg = (e && e.message) ? e.message : 'Introspection failed';
1363
+ const err = document.createElement('div');
1364
+ err.className = 'graft-err';
1365
+ err.textContent = msg;
1366
+ graftBodyEl().appendChild(err);
1367
+ }
1368
+ }
1369
+
1370
+ function graftPluginName(preview) {
1371
+ for (const c of preview.commands) {
1372
+ const m = (c.summary || '').match(/Insert the "([^"]+)"/);
1373
+ if (m) return m[1];
1374
+ }
1375
+ return null;
1376
+ }
1377
+ function graftCmdHtml(c) {
1378
+ const mode = (c.kind === 'write' || c.kind === 'read') ? `<span class="graft-cmd-mode ${c.kind}">${c.kind}</span>` : '';
1379
+ const inputs = (c.inputs && c.inputs.length)
1380
+ ? `<div class="graft-cmd-in">${c.inputs.map((i) => `${escapeHtml(i.name)} <span style="opacity:.6">(${escapeHtml(i.type)})</span>`).join(' · ')}</div>`
1381
+ : '';
1382
+ return `<div class="graft-cmd"><span class="graft-cmd-name">${escapeHtml(c.name)}</span>${mode}${inputs}</div>`;
1383
+ }
1384
+
1385
+ function graftRenderPreview(preview) {
1386
+ document.getElementById('graft-title').textContent = 'Review your new agent';
1387
+ document.getElementById('graft-sub').textContent = 'Here is what AWARE found — create it to add it to your Agents library.';
1388
+ const tekla = preview.recipe === 'tekla-plugin';
1389
+ const badge = tekla
1390
+ ? '<div class="graft-badge tekla">⊕ Tekla plug-in recipe</div>'
1391
+ : '<div class="graft-badge">◈ generic reflection</div>';
1392
+ let hero = '';
1393
+ if (tekla) {
1394
+ const name = graftPluginName(preview) || preview.agentId;
1395
+ hero =
1396
+ `<dl class="graft-identity"><dt>Plug-in</dt><dd>${escapeHtml(name)}</dd>` +
1397
+ `<dt>Recipe</dt><dd>Tekla model plug-in</dd>` +
1398
+ `<dt>Source</dt><dd>${escapeHtml(preview.sourceRef)}</dd></dl>` +
1399
+ '<div class="graft-hint">The picked points become a typed <code>input-points</code> input — no dialog, no mouse.</div>';
1400
+ }
1401
+ const warnings = (preview.warnings || []).map((w) => `<div class="graft-warn">⚠ ${escapeHtml(w)}</div>`).join('');
1402
+ const cmds = preview.commands.length
1403
+ ? `<div class="graft-cmds">${preview.commands.map(graftCmdHtml).join('')}</div>`
1404
+ : '<div class="graft-warn">No commands found in this source. It may have no public API surface, or the reader couldn’t parse it — try a different entry point or path.</div>';
1405
+ const skills = preview.skills ? `<div class="graft-skills">${preview.skills} skill${preview.skills === 1 ? '' : 's'} generated.</div>` : '';
1406
+ graftFill(graftBodyEl(),
1407
+ badge + hero + warnings + cmds + skills +
1408
+ `<div class="modal-field"><label>Agent name</label><div class="bake-name"><code>${escapeHtml(preview.agentId)}</code></div></div>`);
1409
+ graftState.onBack = graftRenderPicker;
1410
+ graftState.onPrimary = () => runGraftCommit(false);
1411
+ graftFooter({ backShown: true, primaryLabel: 'Create agent', primaryDisabled: preview.commands.length === 0 });
1412
+ }
1413
+
1414
+ async function runGraftCommit(force) {
1415
+ if (!graftState.stagedRef) return;
1416
+ graftFooter({ backShown: true, primaryLabel: 'Creating…', primaryDisabled: true });
1417
+ $graftBack.disabled = true; $graftCancel.disabled = true;
1418
+ try {
1419
+ const body = { stagedRef: graftState.stagedRef };
1420
+ if (force) body.force = true;
1421
+ const res = await api('/api/graft/commit', { method: 'POST', body: JSON.stringify(body) });
1422
+ graftFill(graftBodyEl(), `<div class="graft-success"><div class="gs-icon">⊕</div><div class="gs-msg">Agent created — now in the Agents library as <code>${escapeHtml(res.agentId)}</code>.</div></div>`);
1423
+ $graftBack.hidden = true; $graftCancel.disabled = false; $graftPrimary.textContent = 'Done'; $graftPrimary.disabled = false;
1424
+ graftState.onPrimary = () => hideModal($graftModal);
1425
+ loadAgentCatalog().catch(() => {}); // refresh count immediately (SSE also fires for other tabs)
1426
+ appendNarration(`Grafted <strong>${escapeHtml(res.agentId)}</strong> from ${escapeHtml(graftState.preview.sourceRef)} — find it in the Agents library (<strong>⊞ Agents</strong>).`);
1427
+ showToast(`Agent created — “${res.agentId}” is now in the Agents library`, 'ok');
1428
+ setTimeout(() => { if ($graftModal.classList.contains('show')) hideModal($graftModal); }, 1500);
1429
+ } catch (e) {
1430
+ $graftBack.disabled = false; $graftCancel.disabled = false;
1431
+ const msg = (e && e.message) ? e.message : 'Create failed';
1432
+ if (/already (installed|exists)/i.test(msg) && !force) {
1433
+ // The stage is kept server-side on a collision — offer to overwrite.
1434
+ graftFooter({ backShown: true, primaryLabel: 'Overwrite existing', primaryDisabled: false });
1435
+ graftState.onPrimary = () => runGraftCommit(true);
1436
+ if (!graftBodyEl().querySelector('.graft-warn.collide')) {
1437
+ const warn = document.createElement('div');
1438
+ warn.className = 'graft-warn collide';
1439
+ warn.textContent = `⚠ ${msg} Click “Overwrite existing” to replace it.`;
1440
+ graftBodyEl().prepend(warn);
1441
+ }
1442
+ } else {
1443
+ graftFooter({ backShown: true, primaryLabel: 'Create agent', primaryDisabled: false });
1444
+ graftState.onPrimary = () => runGraftCommit(false);
1445
+ showToast(`Create failed: ${msg.slice(0, 80)}`, 'warn');
1446
+ }
1447
+ }
1448
+ }
1449
+
1450
+ $graftBack.onclick = () => { if (graftState.onBack) graftState.onBack(); };
1451
+ $graftCancel.onclick = () => hideModal($graftModal);
1452
+ $graftPrimary.onclick = () => { if (graftState.onPrimary) graftState.onPrimary(); };
1453
+ $graftModal.onclick = (e) => { if (e.target === $graftModal && !$graftCancel.disabled) hideModal($graftModal); };
1454
+
1455
+ // Secondary entry: "⊕ Graft new agent" inside the Agents Library modal.
1456
+ const $libGraft = document.getElementById('lib-graft');
1457
+ if ($libGraft) $libGraft.onclick = () => { hideModal($libModal); openGraftModal(); };
1458
+
1459
+ // Route the ≡ menu's "Graft"/"Bake" items to their modals. handleMenuAction lives
1460
+ // in app.js; openGraftModal/openBakeModal live here — so wrap it (the same pattern
1461
+ // app.js's doOpen/doSave are reassigned from this file).
1462
+ const _handleMenuAction = handleMenuAction;
1463
+ handleMenuAction = function (action) {
1464
+ if (action === 'graft') { openGraftModal(); return; }
1465
+ if (action === 'bake') { openBakeModal(); return; }
1466
+ _handleMenuAction(action);
1467
+ };
1468
+
1469
+ // ── App self-update tag ───────────────────────────────────────────────────────
1470
+ // Surface updater.ts: poll GET /api/update (on load + every 6h); when a newer
1471
+ // installed build exists, show a footer tag → confirm → POST /api/update/apply,
1472
+ // which downloads + relaunches into the new version. Hidden in dev/npm (supported:false).
1473
+ const $appUpdate = document.getElementById('app-update');
1474
+ async function refreshUpdate() {
1475
+ if (!$appUpdate) return;
1476
+ try {
1477
+ const r = await fetch('/api/update');
1478
+ const d = await r.json();
1479
+ if (r.ok && d.supported && d.updateAvailable && d.targetVersion) {
1480
+ $appUpdate.textContent = '↑ Update to v' + d.targetVersion;
1481
+ $appUpdate.dataset.tip = 'A newer floless.app is available — click to download and relaunch into v' + d.targetVersion;
1482
+ $appUpdate.hidden = false;
1483
+ } else {
1484
+ $appUpdate.hidden = true; // up-to-date / unsupported (dev/npm) / feed error → no tag
1485
+ }
1486
+ } catch { $appUpdate.hidden = true; }
1487
+ }
1488
+ if ($appUpdate) {
1489
+ $appUpdate.onclick = async () => {
1490
+ const v = $appUpdate.textContent.replace(/^[^0-9]*/, ''); // "↑ Update to v0.4.1" → "0.4.1"
1491
+ if (!window.confirm('Download v' + v + ' and relaunch floless.app now?')) return;
1492
+ $appUpdate.disabled = true;
1493
+ $appUpdate.textContent = '↑ Updating…';
1494
+ try {
1495
+ const r = await fetch('/api/update/apply', { method: 'POST', headers: { 'content-type': 'application/json' } });
1496
+ const d = await r.json().catch(() => ({}));
1497
+ if (!r.ok || !d.ok) throw new Error(d.error || 'update failed');
1498
+ // Success: the server is exiting + Update.exe relaunches the new build. The health
1499
+ // poll flips to offline (R1 overlay), then the new version reconnects on its own.
1500
+ } catch (e) {
1501
+ $appUpdate.disabled = false;
1502
+ showToast('Update failed — ' + String((e && e.message) || e).slice(0, 80), 'warn');
1503
+ refreshUpdate();
1504
+ }
1505
+ };
1506
+ refreshUpdate();
1507
+ setInterval(refreshUpdate, 6 * 60 * 60 * 1000); // re-check every 6h so a long-running window notices
1508
+ }
1509
+
1510
+ // ONE Run (the approved single-Run model). "▶ Run workflow" does a REAL run against
1511
+ // the live host, using the app inputs. If the app has a report node, it drives
1512
+ // the in-app HTML Viewer (renders + caches the returned HTML); otherwise it
1513
+ // fills the Execution trace. "Simulate" is the demoted secondary: every node is
1514
+ // stubbed from its output-schema, no host sidecar is contacted (AWARE v0.37) —
1515
+ // a composition check that runs even when the real agents aren't connected. There is
1516
+ // no second "Run" control anywhere else; the viewer node only LOADS the last
1517
+ // report on double-click.
1518
+ async function runApp({ simulate = false } = {}) {
1519
+ const app = currentId && apps.get(currentId);
1520
+ if (!app) return;
1521
+ if (!app.runnable) { showToast($runBtn.dataset.tip, 'warn'); return; }
1522
+ if (state.running || reportRunning) return;
1523
+
1524
+ markCanvasRunning(); // immediate during-run feedback; the trace refines per node
1525
+
1526
+ // Real run + a report node → the HTML Viewer path (renders the report,
1527
+ // caches it for double-click, and mirrors the run into the Execution tab).
1528
+ const rid = reportNodeId();
1529
+ if (!simulate && rid) { runReport(rid); return; }
1530
+
1531
+ state.running = true;
1532
+ cancelRequested = false;
1533
+ $runBtn.disabled = true;
1534
+ if ($simBtn) $simBtn.disabled = true;
1535
+ $runBtn.textContent = '◆ Running…';
1536
+ liveTrace.length = 0;
1537
+ state.hasRun = true;
1538
+ try {
1539
+ const res = await api('/api/run', { method: 'POST', body: JSON.stringify({ id: currentId, simulate, inputs: currentInputs() }) });
1540
+ // SSE usually fills liveTrace first; backfill from the response if it didn't.
1541
+ if (liveTrace.length === 0 && Array.isArray(res.events)) res.events.forEach(pushTrace);
1542
+ switchToExecution();
1543
+ const steps = liveTrace.filter((r) => r.kind === 'node-start').length;
1544
+ appendNarration(simulate
1545
+ ? `Simulated run complete · ${steps} node${steps === 1 ? '' : 's'} stubbed (no host needed) · see the <strong>Execution</strong> tab.`
1546
+ : `Run complete · ${steps} node${steps === 1 ? '' : 's'} against the live model · see the <strong>Execution</strong> tab.`);
1547
+ } catch (e) {
1548
+ const msg = (e && e.message) ? e.message : String(e);
1549
+ if (cancelRequested) {
1550
+ clearNodeStatus();
1551
+ showToast('Run cancelled', 'info');
1552
+ appendNarration('Run cancelled. Click <strong>▶ Run workflow</strong> to run again.');
1553
+ } else {
1554
+ // A failed run is an expected, recoverable outcome (the server returns it
1555
+ // in-band) — surface it as a toast + narration, not a console error.
1556
+ showToast((simulate ? 'Simulate failed' : 'Run failed') + ': ' + msg, 'warn');
1557
+ appendNarration(simulate
1558
+ ? `Simulate couldn't validate <code>${escapeHtml(currentId)}</code> — it stubs each node from its output-schema, but exec nodes have none, so the cross-node templates render undefined. Use <strong>▶ Run workflow</strong> for a live run against the host.`
1559
+ : `Run failed: ${escapeHtml(msg)}. Check the host is attached and the lock is fresh.`);
1560
+ }
1561
+ } finally {
1562
+ state.running = false;
1563
+ $runBtn.disabled = false;
1564
+ if ($simBtn) $simBtn.disabled = false;
1565
+ $runBtn.textContent = '▶ Run workflow';
1566
+ }
1567
+ }
1568
+ $runBtn.onclick = () => runApp({ simulate: false });
1569
+ if ($simBtn) $simBtn.onclick = () => runApp({ simulate: true });
1570
+
1571
+ function switchToExecution() {
1572
+ state.currentTab = 'execution';
1573
+ document.querySelectorAll('.tabs button').forEach((b) => b.classList.toggle('active', b.dataset.tab === 'execution'));
1574
+ renderInspect();
1575
+ }
1576
+
1577
+ // ── trace + SSE ──────────────────────────────────────────────────────────────
1578
+
1579
+ function fmtTs(ts) {
1580
+ const d = ts ? new Date(ts) : new Date();
1581
+ return isNaN(d.getTime()) ? '' : d.toTimeString().slice(0, 8);
1582
+ }
1583
+
1584
+ // Trace fields come from the run JSONL (file-derived) and render raw into the
1585
+ // Execution tab via renderInspect — escape everything that lands in `msg`.
1586
+ // ── Per-node run status on the canvas ──────────────────────────────────────
1587
+ // The server broadcasts the whole trace when `aware app run` returns, so we
1588
+ // also mark every node "running" the instant Run is clicked (markCanvasRunning)
1589
+ // — that's the during-run feedback; pushTrace then refines each node to
1590
+ // done/error as the (batched) trace arrives. Covers UI runs, report runs (the
1591
+ // canvas paints behind the modal), and terminal-driven runs (trace-file).
1592
+ const NODE_STAT_LABEL = { running: '◆ running', done: '✓ done', error: '✗ failed' };
1593
+ function setNodeStatus(nodeId, status) {
1594
+ if (!nodeId) return;
1595
+ const card = document.querySelector(`.agent-card[data-agent-id="${(window.CSS && CSS.escape) ? CSS.escape(nodeId) : nodeId}"]`);
1596
+ if (!card) return;
1597
+ card.classList.remove('node-running', 'node-done', 'node-error');
1598
+ card.classList.add('node-' + status);
1599
+ let pip = card.querySelector('.node-stat');
1600
+ if (!pip) { pip = document.createElement('span'); pip.className = 'node-stat'; card.appendChild(pip); }
1601
+ pip.textContent = NODE_STAT_LABEL[status] || status;
1602
+ }
1603
+ function clearNodeStatus() {
1604
+ document.querySelectorAll('.agent-card').forEach((card) => {
1605
+ card.classList.remove('node-running', 'node-done', 'node-error');
1606
+ const pip = card.querySelector('.node-stat');
1607
+ if (pip) pip.remove();
1608
+ });
1609
+ }
1610
+ function markCanvasRunning() {
1611
+ clearNodeStatus();
1612
+ const app = currentId && apps.get(currentId);
1613
+ if (!app || !Array.isArray(app.nodes)) return;
1614
+ app.nodes.forEach((n) => setNodeStatus(n.id, 'running'));
1615
+ }
1616
+
1617
+ function pushTrace(ev) {
1618
+ const node = escapeHtml(ev.node || '');
1619
+ const agent = escapeHtml(ev.agent || '');
1620
+ const cmd = ev.command ? '/' + escapeHtml(ev.command) : '';
1621
+ let lvl = 'info';
1622
+ let msg;
1623
+ switch (ev.kind) {
1624
+ case 'run-start': msg = `run start · ${escapeHtml(ev.app || '')} (${escapeHtml(ev.instance || 'default')})`; break;
1625
+ case 'node-start': lvl = 'event'; msg = `node ${node} · ${agent}${cmd}`; break;
1626
+ case 'would-write': lvl = 'warn'; msg = `would-write ${node} · ${escapeHtml(JSON.stringify(ev.proposed_inputs || {}))}`; break;
1627
+ case 'node-output': case 'output': case 'write': lvl = 'ok'; msg = `${escapeHtml(ev.kind)} ${node}`.trim(); break;
1628
+ case 'run-end': lvl = ev.status === 'ok' ? 'ok' : 'err'; msg = `run ${escapeHtml(ev.status || 'ended')}`; break;
1629
+ default: msg = `${escapeHtml(ev.kind)} ${node}`.trim();
1630
+ }
1631
+ // `node` (the raw node id) feeds the Execution tab's node-filter chips.
1632
+ liveTrace.push({ kind: ev.kind, ts: fmtTs(ev.ts), lvl, msg, node: ev.node || '' });
1633
+
1634
+ // Paint the node's status on the canvas as the trace streams in. Once the
1635
+ // user hits Stop, leave the canvas alone — late SSE events (the fs-watcher
1636
+ // replaying the partial JSONL) would otherwise re-pulse a node "running"
1637
+ // after stopRun() cleared it.
1638
+ if (cancelRequested) return;
1639
+ if (ev.kind === 'run-start') clearNodeStatus();
1640
+ else if (ev.node && ev.kind === 'node-start') setNodeStatus(ev.node, 'running');
1641
+ else if (ev.node && /^(node-output|output|write|node-end|node-ok)$/.test(ev.kind)) setNodeStatus(ev.node, 'done');
1642
+ else if (ev.node && /error/i.test(ev.kind)) setNodeStatus(ev.node, 'error');
1643
+ else if (ev.kind === 'run-end' && ev.status && ev.status !== 'ok') {
1644
+ // run failed — any node still "running" (started, never produced output) failed
1645
+ document.querySelectorAll('.agent-card.node-running').forEach((c) => setNodeStatus(c.dataset.agentId, 'error'));
1646
+ }
1647
+ }
1648
+
1649
+ // ── Server status light + in-tab recovery ─────────────────────────────────
1650
+ // The footer dot/label show whether the LOCAL server is reachable, driven by the
1651
+ // SSE connection (onopen/onerror) plus a 5s /api/health backstop. When the server
1652
+ // is down we raise a prominent overlay; both it and the footer Restart are real
1653
+ // <a href="floless://start"> anchors the USER clicks. A genuine click carries the
1654
+ // user activation Chromium (Chrome AND Edge) requires to hand the registered scheme
1655
+ // off to the OS protocol handler (which relaunches the server even though HTTP is
1656
+ // dead). For a registered scheme the browser intercepts the click and shows its
1657
+ // native "Open FlolessApp?" prompt INSTEAD of following the URL, so this tab is
1658
+ // never navigated away. (A synthetic anchor.click() or hidden iframe — the old
1659
+ // approach — is silently dropped for external protocols: that was the dead-Restart
1660
+ // bug.) The tab then heals itself: the next health tick flips back online and, if
1661
+ // we never managed to load, pulls the workspace.
1662
+ let serverOnline = null;
1663
+
1664
+ // The overlay leads with AUTO-RECOVERY. The login watchdog (FlolessApp --supervise)
1665
+ // respawns a dead server within seconds, so a crash heals itself with NO user action
1666
+ // — the overlay's job is to reassure ("reconnecting…"), not to demand a click. We
1667
+ // keep a SMALL secondary "Start FloLess now" link as a manual kick, but it stays
1668
+ // HIDDEN until recovery is clearly stuck (still offline after ESCALATE_MS): showing
1669
+ // a button while the copy says "no action needed" is contradictory, and the
1670
+ // floless:// launch is a no-op on corporate-managed browsers anyway (they ignore the
1671
+ // user-level scheme handler). The link's real value is on non-managed browsers; the
1672
+ // reliable fallback when the watchdog itself is down is "reopen FloLess", which the
1673
+ // escalation line offers. The link is a real <a href> the USER clicks — a synthetic
1674
+ // anchor.click() is silently dropped by Chromium for external protocols.
1675
+ const ESCALATE_MS = 15000; // 3 missed 5s health polls ⇒ the watchdog isn't recovering
1676
+ let escalateTimer = null;
1677
+ let lastFocusBeforeOverlay = null;
1678
+ function onStartClickFeedback() {
1679
+ // Feedback only — the href performs the launch (genuine user activation); must NOT
1680
+ // preventDefault. No "approve the dialog" promise: on managed browsers no dialog
1681
+ // ever appears, so the escalation line's "reopen FloLess" stays the honest path.
1682
+ const s = document.getElementById('so-status');
1683
+ if (s) s.textContent = 'Starting FloLess…';
1684
+ }
1685
+
1686
+ let offlineOverlayEl = null;
1687
+ function showOfflineOverlay() {
1688
+ if (offlineOverlayEl) return;
1689
+ lastFocusBeforeOverlay = document.activeElement; // restored on heal (WCAG 2.4.3)
1690
+ const wrap = document.createElement('div');
1691
+ wrap.id = 'server-offline-overlay';
1692
+ // role=status on the card → screen readers announce its text on insertion. The
1693
+ // spinner is decorative (aria-hidden). The manual link is hidden until escalation.
1694
+ wrap.innerHTML =
1695
+ `<div class="so-card" role="status" tabindex="-1">
1696
+ <div class="so-spinner" aria-hidden="true"></div>
1697
+ <h1>Reconnecting to FloLess…</h1>
1698
+ <p>The local server stopped — this tab reconnects automatically in a few seconds.</p>
1699
+ <p class="so-status" id="so-status"></p>
1700
+ <a id="so-start" href="floless://start" class="secondary" hidden>Start FloLess now</a>
1701
+ </div>`;
1702
+ document.body.appendChild(wrap);
1703
+ offlineOverlayEl = wrap;
1704
+ document.getElementById('so-start').addEventListener('click', onStartClickFeedback);
1705
+ // No action is required, so move focus to the CARD (tabindex=-1), NOT the link —
1706
+ // keeps keyboard focus inside the overlay without implying the link is the action.
1707
+ wrap.querySelector('.so-card').focus();
1708
+ // Reveal the manual fallback only if recovery is clearly stuck (watchdog down).
1709
+ if (escalateTimer) clearTimeout(escalateTimer);
1710
+ escalateTimer = setTimeout(() => {
1711
+ if (serverOnline === true) return; // healed
1712
+ const s = document.getElementById('so-status');
1713
+ const a = document.getElementById('so-start');
1714
+ if (s) s.textContent = 'Taking longer than usual — try the button below, or reopen FloLess to bring it back.';
1715
+ if (a) a.hidden = false;
1716
+ }, ESCALATE_MS);
1717
+ }
1718
+ function hideOfflineOverlay() {
1719
+ if (escalateTimer) { clearTimeout(escalateTimer); escalateTimer = null; }
1720
+ if (!offlineOverlayEl) return;
1721
+ offlineOverlayEl.remove();
1722
+ offlineOverlayEl = null;
1723
+ // Return focus to wherever it was before the overlay grabbed it.
1724
+ if (lastFocusBeforeOverlay && document.contains(lastFocusBeforeOverlay)) {
1725
+ try { lastFocusBeforeOverlay.focus(); } catch { /* element gone */ }
1726
+ }
1727
+ lastFocusBeforeOverlay = null;
1728
+ }
1729
+
1730
+ // ── AWARE first-run setup overlay ──────────────────────────────────────────
1731
+ // First launch installs the AWARE runtime (npm i -g). While that runs the
1732
+ // workspace is meaningless, so we raise a blocking card (mirrors the offline
1733
+ // overlay's chrome, differentiated by copy). The server's bootstrap state
1734
+ // machine (probing→installing→ready|failed) drives it over SSE, with the 5s
1735
+ // health poll as a backstop. Three states: installing / failed / ready-beat.
1736
+
1737
+ // Pure: reason → the fixed, user-facing body copy for the FAILED state.
1738
+ // Isolated so the wording is trivially reviewable (and unit-testable). NEVER
1739
+ // leaks raw error codes (EACCES/permission denied) — those are dev phrasing.
1740
+ function bootstrapBody(reason) {
1741
+ switch (reason) {
1742
+ case 'no-node':
1743
+ case 'no-npm':
1744
+ return 'AWARE requires Node.js, which was not found on this machine. Install Node.js (nodejs.org), then try again.';
1745
+ case 'eacces':
1746
+ return "Couldn't install because this location requires administrator permissions. Run FloLess as administrator, or install Node.js globally yourself and try again.";
1747
+ case 'offline':
1748
+ return 'No network connection was detected. Connect to the internet, then try again.';
1749
+ case 'smoke-failed':
1750
+ return "AWARE installed but didn't start correctly. This sometimes resolves on its own — try again. If it keeps failing, reinstall AWARE manually: npm i -g @aware-aeco/cli";
1751
+ default:
1752
+ return "Setup didn't complete. Try again.";
1753
+ }
1754
+ }
1755
+
1756
+ let bootstrapOverlayEl = null;
1757
+ let bootstrapState = null; // last applied {status, reason, remediation}
1758
+ let bootstrapReadyTimer = null; // the 400ms "ready" dwell before dismiss
1759
+ // Set by the boot sequence: invoked ONCE when AWARE first reaches ready, to route
1760
+ // to the workspace (licensed) or the sign-in gate (unlicensed). "Setup first" — the
1761
+ // runtime overlay precedes the gate, so this routing is deferred until AWARE is up.
1762
+ let onBootstrapReady = null;
1763
+
1764
+ function showBootstrapOverlay() {
1765
+ if (bootstrapOverlayEl) return;
1766
+ // Only grab the pre-overlay focus if another overlay hasn't already (the
1767
+ // offline overlay shares this var); avoids clobbering it with our own card.
1768
+ if (!offlineOverlayEl && !lastFocusBeforeOverlay) lastFocusBeforeOverlay = document.activeElement;
1769
+ const wrap = document.createElement('div');
1770
+ wrap.id = 'aware-bootstrap-overlay';
1771
+ // role=status announces the card text on insertion; spinner + log are
1772
+ // decorative/noisy (aria-hidden); bs-status is the polite live channel.
1773
+ wrap.innerHTML =
1774
+ `<div class="bs-card" role="status" tabindex="-1">
1775
+ <div class="bs-spinner" aria-hidden="true"></div>
1776
+ <h1></h1>
1777
+ <p class="bs-body"></p>
1778
+ <p class="bs-status" id="bs-status" aria-live="polite"></p>
1779
+ <p class="bs-log" aria-hidden="true"></p>
1780
+ <p class="bs-remediation"></p>
1781
+ <button type="button" class="secondary" hidden>Try again</button>
1782
+ </div>`;
1783
+ document.body.appendChild(wrap);
1784
+ bootstrapOverlayEl = wrap;
1785
+ wrap.querySelector('.secondary').addEventListener('click', onBootstrapRetry);
1786
+ wrap.querySelector('.bs-card').focus();
1787
+ }
1788
+
1789
+ // The live npm ticker: REPLACE (never append) the single line; aria-hidden so
1790
+ // the rapid output is not announced. Only meaningful while installing.
1791
+ function setBootstrapLogLine(line) {
1792
+ if (!bootstrapOverlayEl) return;
1793
+ const log = bootstrapOverlayEl.querySelector('.bs-log');
1794
+ if (log) log.textContent = line || '';
1795
+ }
1796
+
1797
+ // Drive the card from a {status, reason, remediation} snapshot (SSE or health).
1798
+ function updateBootstrap(s) {
1799
+ if (!s) return;
1800
+ const status = s.status;
1801
+ if (status === 'ready') return finishBootstrapReady();
1802
+ if (status !== 'installing' && status !== 'probing' && status !== 'failed') return; // idle — nothing to show yet
1803
+ showBootstrapOverlay();
1804
+ if (!bootstrapOverlayEl) return;
1805
+ const prev = bootstrapState;
1806
+ bootstrapState = { status, reason: s.reason ?? null, remediation: s.remediation ?? null };
1807
+ const card = bootstrapOverlayEl.querySelector('.bs-card');
1808
+ const spinner = bootstrapOverlayEl.querySelector('.bs-spinner');
1809
+ const h1 = bootstrapOverlayEl.querySelector('h1');
1810
+ const body = bootstrapOverlayEl.querySelector('.bs-body');
1811
+ const statusEl = bootstrapOverlayEl.querySelector('.bs-status');
1812
+ const log = bootstrapOverlayEl.querySelector('.bs-log');
1813
+ const remediation = bootstrapOverlayEl.querySelector('.bs-remediation');
1814
+ const retry = bootstrapOverlayEl.querySelector('.secondary');
1815
+ if (status === 'failed') {
1816
+ // No spinner on failure — absence of motion is the signal (no error icon).
1817
+ if (spinner) spinner.remove();
1818
+ h1.textContent = 'Setup failed';
1819
+ body.textContent = bootstrapBody(s.reason);
1820
+ statusEl.textContent = '';
1821
+ if (log) log.textContent = ''; // ticker is only meaningful while installing
1822
+ // Server remediation is SUPPLEMENTARY dim context — omit if empty or if it
1823
+ // duplicates the fixed body (the fixed copy is the user-facing message).
1824
+ const rem = (s.remediation || '').trim();
1825
+ remediation.textContent = rem && rem !== body.textContent ? rem : '';
1826
+ retry.hidden = false;
1827
+ // Respect an in-flight retry: an SSE/poll re-render of `failed` mid-retry
1828
+ // would otherwise re-enable the button and allow duplicate clicks. The
1829
+ // server is idempotent today (terminal-state guard on /api/bootstrap/retry),
1830
+ // but the UI shouldn't depend on that.
1831
+ retry.disabled = bootstrapRetryInFlight;
1832
+ retry.textContent = bootstrapRetryInFlight ? 'Trying…' : 'Try again';
1833
+ } else {
1834
+ // probing / installing — spinner spins, no button.
1835
+ if (!bootstrapOverlayEl.querySelector('.bs-spinner')) {
1836
+ const sp = document.createElement('div');
1837
+ sp.className = 'bs-spinner';
1838
+ sp.setAttribute('aria-hidden', 'true');
1839
+ card.insertBefore(sp, h1);
1840
+ }
1841
+ h1.textContent = 'Setting up AWARE…';
1842
+ body.textContent = 'Installing the AWARE runtime. This happens once.';
1843
+ // probing may say "Checking…"; installing clears it (the ticker carries the signal).
1844
+ statusEl.textContent = status === 'probing' ? 'Checking for AWARE…' : '';
1845
+ if (status !== 'installing' && log) log.textContent = ''; // clear ticker when not installing
1846
+ remediation.textContent = '';
1847
+ retry.hidden = true;
1848
+ }
1849
+ // Re-focus the card when the STATE changes (not on every event) so the new
1850
+ // h1/body is re-announced via role=status without spamming on each ticker line.
1851
+ if (!prev || prev.status !== status) {
1852
+ if (document.activeElement !== card) card.focus();
1853
+ }
1854
+ }
1855
+
1856
+ // Transition installing→ready: a brief "Ready" beat, then remove (not instant)
1857
+ // so success registers before the workspace paints. A dwell, not an animation.
1858
+ function finishBootstrapReady() {
1859
+ if (!bootstrapOverlayEl) {
1860
+ // No overlay was ever raised (the happy path AWARE-already-ready boot, or a
1861
+ // very fast probing→ready before the first poll tick painted). Record the
1862
+ // state and route now if the boot sequence wired onBootstrapReady — without
1863
+ // this, routing depends on the boot's second health fetch winning the race
1864
+ // against SSE/poll delivering `ready` first. (One-shot; onBootstrapReady is
1865
+ // self-gated by `routed`, so a later call from the boot fetch is a no-op.)
1866
+ bootstrapState = { status: 'ready', reason: null, remediation: null };
1867
+ if (onBootstrapReady) onBootstrapReady();
1868
+ return;
1869
+ }
1870
+ if (bootstrapReadyTimer) return; // beat already scheduled
1871
+ bootstrapState = { status: 'ready', reason: null, remediation: null };
1872
+ const card = bootstrapOverlayEl.querySelector('.bs-card');
1873
+ const spinner = bootstrapOverlayEl.querySelector('.bs-spinner');
1874
+ if (spinner) spinner.remove();
1875
+ if (card) {
1876
+ card.querySelector('h1').textContent = 'AWARE is ready.';
1877
+ card.querySelector('.bs-body').textContent = '';
1878
+ card.querySelector('.bs-status').textContent = '';
1879
+ card.querySelector('.bs-log').textContent = '';
1880
+ card.querySelector('.bs-remediation').textContent = '';
1881
+ card.querySelector('.secondary').hidden = true;
1882
+ }
1883
+ bootstrapReadyTimer = setTimeout(() => {
1884
+ hideBootstrapOverlay();
1885
+ // AWARE is up — NOW route to the workspace or the sign-in gate (deferred until
1886
+ // ready by the "setup first" boot sequence). One-shot; guarded in the callback.
1887
+ if (onBootstrapReady) onBootstrapReady();
1888
+ }, 400);
1889
+ }
1890
+
1891
+ function hideBootstrapOverlay() {
1892
+ if (bootstrapReadyTimer) { clearTimeout(bootstrapReadyTimer); bootstrapReadyTimer = null; }
1893
+ if (!bootstrapOverlayEl) return;
1894
+ bootstrapOverlayEl.remove();
1895
+ bootstrapOverlayEl = null;
1896
+ // Restore focus only if WE own the saved focus and no other overlay is up.
1897
+ if (!offlineOverlayEl && lastFocusBeforeOverlay && document.contains(lastFocusBeforeOverlay)) {
1898
+ try { lastFocusBeforeOverlay.focus(); } catch { /* element gone */ }
1899
+ lastFocusBeforeOverlay = null;
1900
+ }
1901
+ }
1902
+
1903
+ let bootstrapRetryInFlight = false;
1904
+
1905
+ async function onBootstrapRetry(e) {
1906
+ if (bootstrapRetryInFlight) return; // guard double-clicks even before disabled paints
1907
+ bootstrapRetryInFlight = true;
1908
+ const btn = e.currentTarget;
1909
+ btn.disabled = true;
1910
+ btn.textContent = 'Trying…';
1911
+ try {
1912
+ await api('/api/bootstrap/retry', { method: 'POST' });
1913
+ // Server kicked the state machine back to probing/installing; the SSE
1914
+ // stream + health poll rebuild the card. Pre-empt with a probing view so
1915
+ // the click feels responsive even before the first event arrives — but
1916
+ // only if no faster SSE/poll event has already moved us past `failed`
1917
+ // (else we'd briefly clobber a definitive update with stale optimism).
1918
+ if (bootstrapState?.status === 'failed') {
1919
+ updateBootstrap({ status: 'probing', reason: null, remediation: null });
1920
+ }
1921
+ } catch (err) {
1922
+ // Retry call itself failed (server unreachable mid-retry) — leave the
1923
+ // failed card up, SURFACE the error so the user knows the click landed
1924
+ // nowhere (silent re-enable looks like the click didn't register), and
1925
+ // re-enable so they can try again. The offline overlay (if the server
1926
+ // is truly down) layers on top via its own health path.
1927
+ const statusEl = bootstrapOverlayEl?.querySelector('.bs-status');
1928
+ if (statusEl) statusEl.textContent = 'Retry failed — server unreachable.';
1929
+ console.warn('bootstrap retry failed', err);
1930
+ btn.disabled = false;
1931
+ btn.textContent = 'Try again';
1932
+ } finally {
1933
+ bootstrapRetryInFlight = false;
1934
+ }
1935
+ }
1936
+
1937
+ function initServerStatusUI() {
1938
+ if (document.getElementById('server-status-restart')) return;
1939
+ const style = document.createElement('style');
1940
+ style.textContent = `
1941
+ footer .status .stat-server .dot.offline{background:var(--err,#f87171);box-shadow:0 0 6px var(--err,#f87171);}
1942
+ #server-status-restart{appearance:none;border:1px solid var(--err,#f87171);background:transparent;color:var(--err,#f87171);
1943
+ font-family:var(--mono,monospace);font-size:11px;cursor:pointer;padding:1px 7px;border-radius:4px;margin-left:2px;text-decoration:none;display:inline-block;}
1944
+ /* display:inline-block on the ID above would beat the UA [hidden]{display:none}
1945
+ rule (ID > attribute specificity), leaving the link visible while online — so
1946
+ restore hiding with an ID+attribute rule that wins. */
1947
+ #server-status-restart[hidden]{display:none;}
1948
+ #server-status-restart:hover{background:color-mix(in srgb,var(--err,#f87171) 14%,transparent);}
1949
+ #server-offline-overlay{position:fixed;inset:0;z-index:99998;display:flex;align-items:center;justify-content:center;
1950
+ background:color-mix(in srgb,var(--bg,#020817) 88%,transparent);backdrop-filter:blur(2px);
1951
+ color:var(--text,#f8fafc);font-family:var(--ui,system-ui,sans-serif);}
1952
+ #server-offline-overlay .so-card{max-width:420px;text-align:center;padding:36px 34px;border-radius:14px;
1953
+ background:var(--surface,#0b1424);border:1px solid var(--border,#1e293b);box-shadow:0 20px 60px rgba(0,0,0,.5);}
1954
+ #server-offline-overlay .so-spinner{width:30px;height:30px;margin:0 auto 14px;border-radius:50%;
1955
+ border:3px solid color-mix(in srgb,var(--text-dim,#94a3b8) 28%,transparent);border-top-color:var(--accent,#3b82f6);
1956
+ animation:so-spin .8s linear infinite;}
1957
+ @keyframes so-spin{to{transform:rotate(360deg);}}
1958
+ @media (prefers-reduced-motion:reduce){#server-offline-overlay .so-spinner{animation:none;}}
1959
+ #server-offline-overlay h1{font-size:19px;margin:0 0 8px;font-weight:600;}
1960
+ #server-offline-overlay p{font-size:13.5px;line-height:1.5;color:var(--text-dim,#94a3b8);margin:0 0 4px;}
1961
+ #server-offline-overlay .so-card:focus{outline:none;}
1962
+ #server-offline-overlay .so-status{min-height:16px;margin:10px 0 0;font-size:12.5px;color:var(--text-dim,#94a3b8);}
1963
+ #server-offline-overlay .secondary{appearance:none;cursor:pointer;font-size:12.5px;margin-top:16px;
1964
+ background:transparent;border:1px solid var(--border,#1e293b);color:var(--text-dim,#94a3b8);
1965
+ padding:7px 16px;border-radius:7px;text-decoration:none;display:inline-block;}
1966
+ #server-offline-overlay .secondary:hover{border-color:var(--accent,#3b82f6);color:var(--text,#f8fafc);}
1967
+ /* display:inline-block (for padding) would beat the UA [hidden] rule — keep the
1968
+ link hidden until escalation with an ID+attribute rule that wins on specificity. */
1969
+ #server-offline-overlay .secondary[hidden]{display:none;}
1970
+ /* ── AWARE first-run setup overlay ──────────────────────────────────────
1971
+ Shares the offline overlay's .so-card chrome (same tokens, spinner, copy
1972
+ scale) but differentiates by COPY, not color/icon (see the UX spec). Sits
1973
+ BELOW the offline overlay (99997 < 99998 < 99999 gate): a server crash
1974
+ mid-setup should layer the reconnect card on top. */
1975
+ #aware-bootstrap-overlay{position:fixed;inset:0;z-index:99997;display:flex;align-items:center;justify-content:center;
1976
+ background:color-mix(in srgb,var(--bg,#020817) 88%,transparent);backdrop-filter:blur(2px);
1977
+ color:var(--text,#f8fafc);font-family:var(--ui,system-ui,sans-serif);}
1978
+ #aware-bootstrap-overlay .bs-card{max-width:420px;text-align:center;padding:36px 34px;border-radius:14px;
1979
+ background:var(--surface,#0b1424);border:1px solid var(--border,#1e293b);box-shadow:0 20px 60px rgba(0,0,0,.5);}
1980
+ #aware-bootstrap-overlay .bs-card:focus{outline:none;}
1981
+ #aware-bootstrap-overlay .bs-spinner{width:30px;height:30px;margin:0 auto 14px;border-radius:50%;
1982
+ border:3px solid color-mix(in srgb,var(--text-dim,#94a3b8) 28%,transparent);border-top-color:var(--accent,#3b82f6);
1983
+ animation:so-spin .8s linear infinite;}
1984
+ @media (prefers-reduced-motion:reduce){#aware-bootstrap-overlay .bs-spinner{animation:none;}}
1985
+ #aware-bootstrap-overlay h1{font-size:19px;margin:0 0 8px;font-weight:600;}
1986
+ #aware-bootstrap-overlay p{font-size:13.5px;line-height:1.5;color:var(--text-dim,#94a3b8);margin:0 0 4px;}
1987
+ #aware-bootstrap-overlay .bs-status{min-height:16px;margin:10px 0 0;font-size:12.5px;color:var(--text-dim,#94a3b8);}
1988
+ /* live npm ticker: dim mono single-line, truncated; replaced (not appended) per SSE line. */
1989
+ #aware-bootstrap-overlay .bs-log{font-family:var(--mono,monospace);font-size:11.5px;color:var(--text-dim,#94a3b8);
1990
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-height:16px;margin:8px 0 0;opacity:.7;}
1991
+ #aware-bootstrap-overlay .bs-remediation{margin:8px 0 0;font-size:12.5px;color:var(--text-dim,#94a3b8);}
1992
+ #aware-bootstrap-overlay .secondary{appearance:none;cursor:pointer;font-size:12.5px;margin-top:16px;
1993
+ background:transparent;border:1px solid var(--border,#1e293b);color:var(--text-dim,#94a3b8);
1994
+ padding:7px 16px;border-radius:7px;}
1995
+ #aware-bootstrap-overlay .secondary:hover{border-color:var(--accent,#3b82f6);color:var(--text,#f8fafc);}
1996
+ #aware-bootstrap-overlay .secondary:disabled{cursor:default;opacity:.55;border-color:var(--border,#1e293b);color:var(--text-dim,#94a3b8);}
1997
+ #aware-bootstrap-overlay .bs-log:empty,#aware-bootstrap-overlay .bs-remediation:empty{display:none;}`;
1998
+ document.head.appendChild(style);
1999
+ const stat = document.querySelector('footer .status .stat'); // first stat = runtime
2000
+ if (!stat) return;
2001
+ stat.classList.add('stat-server');
2002
+ const btn = document.createElement('a'); // real anchor — the href performs the launch
2003
+ btn.id = 'server-status-restart';
2004
+ btn.href = 'floless://start';
2005
+ btn.textContent = '↻ Restart FloLess';
2006
+ btn.dataset.tip = 'Restart the local server';
2007
+ btn.hidden = true;
2008
+ btn.addEventListener('click', () => {
2009
+ // Feedback only — the href launches; don't preventDefault.
2010
+ btn.textContent = 'starting…';
2011
+ setTimeout(() => { btn.textContent = '↻ Restart FloLess'; }, 4000); // health poll flips the light back to green
2012
+ });
2013
+ stat.appendChild(btn);
2014
+ }
2015
+ function setServerStatus(online) {
2016
+ if (online === serverOnline) return;
2017
+ const wasOnline = serverOnline;
2018
+ serverOnline = online;
2019
+ const stat = document.querySelector('footer .status .stat-server') || document.querySelector('footer .status .stat');
2020
+ const dot = stat && stat.querySelector('.dot');
2021
+ const label = stat && stat.querySelector('.stat-val');
2022
+ const btn = document.getElementById('server-status-restart');
2023
+ if (dot) dot.classList.toggle('offline', !online);
2024
+ if (label) label.textContent = online ? 'runtime online' : 'server offline';
2025
+ if (btn) btn.hidden = online;
2026
+ if (online) {
2027
+ hideOfflineOverlay(); // also clears the escalation timer + restores focus
2028
+ // Recovered from a boot-time / never-loaded outage: now that the server is
2029
+ // back, pull the workspace so the canvas fills in instead of staying empty.
2030
+ if (wasOnline === false && !currentId) loadWorkspaceData();
2031
+ } else {
2032
+ showOfflineOverlay();
2033
+ }
2034
+ }
2035
+ let shownVersion = false;
2036
+ function startHealthPoll() {
2037
+ const tick = async () => {
2038
+ let ok = false;
2039
+ try {
2040
+ const r = await fetch('/api/health', { cache: 'no-store' });
2041
+ ok = r.ok;
2042
+ if (ok) {
2043
+ const h = await r.json();
2044
+ // Stamp the build + AWARE runtime versions into the footer once (so a
2045
+ // stale install or AWARE mismatch is self-diagnosable). appVersion is
2046
+ // the real sq.version on an install (package.json only from source);
2047
+ // awareVersion is the aware npm package this app drives.
2048
+ if (!shownVersion) {
2049
+ const av = document.getElementById('app-version');
2050
+ if (av && h && h.appVersion) av.textContent = 'v' + h.appVersion;
2051
+ const wv = document.getElementById('aware-version');
2052
+ if (wv && h && h.awareVersion) wv.textContent = 'AWARE ' + h.awareVersion;
2053
+ if (h && (h.appVersion || h.awareVersion)) shownVersion = true;
2054
+ }
2055
+ // Bootstrap BACKSTOP: reconcile the setup overlay from cached health
2056
+ // state in case an SSE event was missed. ready → finish the beat &
2057
+ // dismiss; failed/installing/probing → (re)render that state.
2058
+ if (h && h.bootstrap) {
2059
+ updateBootstrap({ status: h.bootstrap, reason: h.bootstrapReason ?? null, remediation: h.bootstrapRemediation ?? null });
2060
+ }
2061
+ }
2062
+ } catch { ok = false; }
2063
+ setServerStatus(ok);
2064
+ };
2065
+ tick();
2066
+ setInterval(tick, 5000);
2067
+ }
2068
+
2069
+ // Re-check the subscription after a seat-takeover and gate this session if it
2070
+ // lost access. Re-uses the bootstrap gate panel.
2071
+ async function recheckLicenseAndGate() {
2072
+ let s; try { s = await api('/api/license/status'); } catch { return; }
2073
+ if (s.state !== 'valid' && s.state !== 'offline-grace') renderGate(s);
2074
+ }
2075
+
2076
+ function connectSse() {
2077
+ const es = new EventSource('/api/events');
2078
+ let reloadTimer = null;
2079
+ es.onopen = () => setServerStatus(true);
2080
+ es.onmessage = (e) => {
2081
+ let m;
2082
+ try { m = JSON.parse(e.data); } catch { return; }
2083
+ if (!m || typeof m !== 'object') return; // valid non-object JSON (null/number) has no .type
2084
+ if (m.type === 'bootstrap') {
2085
+ // First-run AWARE setup state machine. `line` carries an npm ticker line
2086
+ // (installing only) → dim ticker; `status`/`reason` drive the card state.
2087
+ if (m.line != null) setBootstrapLogLine(m.line);
2088
+ if (m.status) updateBootstrap({ status: m.status, reason: m.reason ?? null, remediation: m.remediation ?? null });
2089
+ } else if (m.type === 'trace' && m.id === currentId) {
2090
+ pushTrace(m.event);
2091
+ if (state.currentTab === 'execution') renderInspect();
2092
+ } else if (m.type === 'trace-file' && m.id === currentId && !state.running) {
2093
+ // A terminal-driven run wrote a trace for the open app (our own UI runs
2094
+ // are handled by the type:'trace' path above and skipped via state.running).
2095
+ liveTrace.length = 0;
2096
+ (m.events || []).forEach(pushTrace);
2097
+ state.hasRun = true;
2098
+ if (state.currentTab === 'execution') renderInspect();
2099
+ } else if (m.type === 'run-started' && m.id === currentId) {
2100
+ liveTrace.length = 0;
2101
+ state.hasRun = true;
2102
+ } else if (m.type === 'templates-changed') {
2103
+ loadTemplates().catch(() => {});
2104
+ } else if (m.type === 'baked' && m.id === currentId) {
2105
+ // Baked (here or in another tab) → refresh so the menu item flips to "Re-bake".
2106
+ loadApp(currentId).catch(() => {});
2107
+ } else if (m.type === 'grafted') {
2108
+ // A new agent was grafted (here or in another tab) → refresh the catalog +
2109
+ // count, and re-render the library if it's open.
2110
+ loadAgentCatalog().then(() => {
2111
+ if ($libModal && $libModal.classList.contains('show')) renderLibrary();
2112
+ }).catch(() => {});
2113
+ } else if (m.type === 'request-added' || m.type === 'requests-changed') {
2114
+ loadRequests();
2115
+ } else if (m.type === 'routine-changed') {
2116
+ loadRoutinesData();
2117
+ } else if (m.type === 'routine-run-started') {
2118
+ runningRoutines.add(m.id);
2119
+ if ($routinesModal.classList.contains('show')) renderRoutinesList();
2120
+ } else if (m.type === 'routine-run-ended') {
2121
+ runningRoutines.delete(m.id);
2122
+ loadRoutinesData();
2123
+ } else if (m.type === 'trigger-session-changed') {
2124
+ applyTriggerSnapshot(m.id, m.snapshot);
2125
+ } else if (m.type === 'connect-result') {
2126
+ // The device-code sign-in resolved. On success, refresh so the card flips
2127
+ // to Connected; otherwise surface the outcome in the integration's slot.
2128
+ connecting.delete(m.id);
2129
+ const slot = dcSlot(m.id);
2130
+ if (m.status === 'connected') {
2131
+ if (slot) slot.innerHTML = '';
2132
+ showToast(`Connected ${m.id}`, 'ok');
2133
+ refreshIntegrations();
2134
+ } else if (slot) {
2135
+ slot.innerHTML = `Sign-in ${escapeHtml(m.status || 'failed')}${m.error ? ' — ' + escapeHtml(m.error) : ''}. Click Connect to retry.`;
2136
+ }
2137
+ } else if (m.type === 'fs-change' && m.kind === 'credential') {
2138
+ // `aware connect`/`disconnect` wrote/removed a token in the local vault —
2139
+ // refresh the Integrations window so its status reflects reality live.
2140
+ refreshIntegrations();
2141
+ } else if (m.type === 'fs-change' && (m.kind === 'source' || m.kind === 'lock') && currentId && pathHasSegment(m.path, currentId)) {
2142
+ // The host AI (or anyone) edited this app's .flo/.lock in the terminal —
2143
+ // refresh so the canvas + gate reflect reality.
2144
+ clearTimeout(reloadTimer);
2145
+ reloadTimer = setTimeout(() => loadApp(currentId).catch(reportErr), 250);
2146
+ } else if (m.type === 'seat-taken') {
2147
+ // This session's seat was claimed by another device (newest-login-wins).
2148
+ showToast('Your session was taken over on another device — sign in to continue.', 'err');
2149
+ recheckLicenseAndGate();
2150
+ }
2151
+ };
2152
+ es.onerror = () => setServerStatus(false); // server unreachable → red; EventSource auto-reconnects (onopen → green)
2153
+ }
2154
+
2155
+ function reportErr(e) {
2156
+ console.error(e);
2157
+ showToast(e.message || String(e), 'err');
2158
+ }
2159
+
2160
+ // True if `id` appears as a full path segment (avoids `foo` matching `foo-bar`).
2161
+ function pathHasSegment(path, id) {
2162
+ return path.split(/[\\/]/).includes(id);
2163
+ }
2164
+
2165
+ // ── templates (reusable node palette / "notes"), server-persisted ───────────
2166
+ // state.favorites holds the template list so app.js's category-suggestion code
2167
+ // keeps working; the chip/star/library behaviour is overridden below.
2168
+ let agentCatalog = [];
2169
+
2170
+ async function loadTemplates() {
2171
+ const { templates } = await api('/api/templates');
2172
+ state.favorites = templates;
2173
+ renderFavBar();
2174
+ markStars();
2175
+ $favCount.textContent = templates.length;
2176
+ }
2177
+
2178
+ // The node a chip/star refers to is identified by source {appId,nodeId}; a card
2179
+ // is "starred" when a template was captured from it in this app.
2180
+ function templateForNode(nodeId) {
2181
+ return state.favorites.find((t) => t.source && t.source.appId === currentId && t.source.nodeId === nodeId);
2182
+ }
2183
+ function markStars() {
2184
+ document.querySelectorAll('.agent-card .fav-btn').forEach((btn) => {
2185
+ btn.classList.toggle('faved', !!templateForNode(btn.dataset.fav));
2186
+ });
2187
+ }
2188
+
2189
+ // Star on a card: save this node as a reusable template (or remove if already).
2190
+ toggleFav = function toggleFavTpl(nodeId) {
2191
+ const existing = templateForNode(nodeId);
2192
+ if (existing) { deleteTemplate(existing.id); return; }
2193
+ openAddFavModal(nodeId);
2194
+ };
2195
+
2196
+ openAddFavModal = function openAddFavModalTpl(nodeId) {
2197
+ state.pendingFavAgentId = nodeId;
2198
+ const a = AGENTS[nodeId];
2199
+ $addFavSub.textContent = `Save "${nodeId}" as a reusable template — usable in any project.`;
2200
+ $favName.value = a ? a.title : nodeId;
2201
+ $favCat.value = '';
2202
+ renderCategorySuggestions();
2203
+ $addFavModal.classList.add('show');
2204
+ setTimeout(() => $favCat.focus(), 50);
2205
+ };
2206
+
2207
+ commitFav = async function commitFavTpl() {
2208
+ const nodeId = state.pendingFavAgentId;
2209
+ if (!nodeId) return;
2210
+ const name = ($favName.value || '').trim() || nodeId;
2211
+ const category = ($favCat.value || '').trim() || 'Uncategorized';
2212
+ const app = apps.get(currentId);
2213
+ const n = app && app.nodes.find((x) => x.id === nodeId);
2214
+ if (!n) { showToast('node not found', 'err'); return; }
2215
+ const node = {
2216
+ agent: n.agent,
2217
+ command: n.command,
2218
+ kind: n.kind,
2219
+ config: n.inputs && Object.keys(n.inputs).length ? n.inputs : n.config,
2220
+ };
2221
+ try {
2222
+ await api('/api/templates', { method: 'POST', body: JSON.stringify({ name, category, node, source: { appId: currentId, nodeId } }) });
2223
+ $addFavModal.classList.remove('show');
2224
+ state.pendingFavAgentId = null;
2225
+ await loadTemplates();
2226
+ showToast(`Saved template "${name}"`, 'ok');
2227
+ } catch (e) { reportErr(e); }
2228
+ };
2229
+
2230
+ async function deleteTemplate(id) {
2231
+ try { await api(`/api/templates/${encodeURIComponent(id)}`, { method: 'DELETE' }); await loadTemplates(); }
2232
+ catch (e) { reportErr(e); }
2233
+ }
2234
+
2235
+ // Template chips: click = "use in this app" (reverse channel); × = delete.
2236
+ renderFavBar = function renderFavBarTpl() {
2237
+ const tpls = state.favorites;
2238
+ $favCount.textContent = tpls.length;
2239
+ if (!tpls.length) { $favChipRow.innerHTML = ''; $favBarEmpty.style.display = 'block'; return; }
2240
+ $favBarEmpty.style.display = 'none';
2241
+ $favChipRow.innerHTML = tpls.map((t) => `
2242
+ <div class="fav-chip" data-tpl="${escapeAttr(t.id)}" data-tip="Use in this workflow · ${escapeAttr(t.category)} · ${escapeAttr((t.node.agent || t.node.kind) + (t.node.command ? '/' + t.node.command : ''))}">
2243
+ <span class="cat">${escapeHtml(t.category)}</span>
2244
+ <span class="name">${escapeHtml(t.name)}</span>
2245
+ <span class="del" data-tip="Delete template">×</span>
2246
+ </div>`).join('');
2247
+ $favChipRow.querySelectorAll('.fav-chip').forEach((chip) => {
2248
+ const id = chip.dataset.tpl;
2249
+ chip.onclick = (e) => { if (e.target.closest('.del')) return; useTemplate(id); };
2250
+ chip.querySelector('.del').onclick = (e) => { e.stopPropagation(); deleteTemplate(id); };
2251
+ });
2252
+ };
2253
+
2254
+ refreshFavMarkers = function refreshFavMarkersTpl() {
2255
+ markStars();
2256
+ if ($libModal.classList.contains('show')) renderLibrary();
2257
+ };
2258
+ saveFavs = function saveFavsNoop() { $favCount.textContent = state.favorites.length; };
2259
+
2260
+ // ── reverse channel: record intent for the host AI, copy a paste-ready line ──
2261
+ function instructionFor(req) {
2262
+ if (req.type === 'use-template' && req.template) {
2263
+ const t = req.template;
2264
+ return `In floless app "${req.appId}", add a node from my saved template "${t.name}" (agent ${t.node.agent || t.node.kind}${t.node.command ? ' / ' + t.node.command : ''}, config ${JSON.stringify(t.node.config || {})}). Edit the .flo, then run \`aware app compile\`.`;
2265
+ }
2266
+ if (req.type === 'tweak') {
2267
+ const base = `In floless app "${req.appId}", change only node "${req.nodeId}": ${req.instruction}. Edit just that node in the .flo, then run \`aware app compile\`.`;
2268
+ return req.snapshots && req.snapshots.length
2269
+ ? `${base}\nReference snapshots (read these for visual context): ${req.snapshots.join(', ')}`
2270
+ : base;
2271
+ }
2272
+ return '';
2273
+ }
2274
+
2275
+ async function copyToClipboard(text) {
2276
+ try { await navigator.clipboard.writeText(text); return true; } catch { return false; }
2277
+ }
2278
+
2279
+ async function useTemplate(templateId) {
2280
+ if (!currentId) { showToast('open a workflow first', 'warn'); return; }
2281
+ try {
2282
+ const { request } = await api('/api/use-template', { method: 'POST', body: JSON.stringify({ appId: currentId, templateId }) });
2283
+ const line = instructionFor(request);
2284
+ const copied = await copyToClipboard(line);
2285
+ appendNarration(`Queued template <strong>${escapeHtml(request.template.name)}</strong> for workflow <code>${escapeHtml(currentId)}</code> — the UI can’t edit the workflow itself, so your terminal AI picks this up and applies it. ${copied ? 'Instruction copied to your clipboard — paste it in.' : 'Open the requests chip (bottom-right) to copy it.'}`);
2286
+ showToast(copied ? 'Queued for your terminal AI · copied to clipboard' : 'Queued for your terminal AI', 'ok');
2287
+ loadRequests();
2288
+ } catch (e) { reportErr(e); }
2289
+ }
2290
+
2291
+ async function tweakNode() {
2292
+ if (!currentId || !state.selectedAgentId) return;
2293
+ const node = state.selectedAgentId;
2294
+ const res = await formModal({
2295
+ title: `Tweak · ${node}`,
2296
+ sub: 'Describe the change in plain English. It is queued for your terminal AI to pick up (pull it with the floless skill, or copy it).',
2297
+ fields: [
2298
+ { name: 'instruction', label: 'What should change about this node?', multiline: true, placeholder: 'e.g. also group by ASSEMBLY_POS and add a per-assembly subtotal' },
2299
+ { name: 'snapshots', label: 'Screenshots (optional)', type: 'images' },
2300
+ ],
2301
+ okLabel: 'Queue for terminal',
2302
+ });
2303
+ const instruction = res && res.instruction ? res.instruction.trim() : '';
2304
+ if (!instruction) return;
2305
+ try {
2306
+ const snaps = Array.isArray(res.snapshots) ? res.snapshots.map((s) => ({ name: s.name, dataUrl: s.dataUrl })) : [];
2307
+ const { request } = await api('/api/tweak', { method: 'POST', body: JSON.stringify({ appId: currentId, nodeId: node, instruction, snapshots: snaps }) });
2308
+ const copied = await copyToClipboard(instructionFor(request));
2309
+ appendNarration(`Tweak queued for <code>${escapeHtml(node)}</code> — your terminal AI can pull it (floless skill) ${copied ? 'or paste the copied instruction' : ''}.`);
2310
+ const toastMsg = snaps.length
2311
+ ? `Tweak + ${snaps.length} snapshot(s) queued${copied ? ' + copied' : ''}`
2312
+ : copied ? 'Tweak queued + copied' : 'Tweak queued';
2313
+ showToast(toastMsg, 'ok');
2314
+ loadRequests();
2315
+ } catch (e) { reportErr(e); }
2316
+ }
2317
+
2318
+ let pendingRequests = [];
2319
+ async function loadRequests() {
2320
+ try {
2321
+ const { requests } = await api('/api/requests');
2322
+ pendingRequests = requests;
2323
+ const $rc = document.getElementById('req-count');
2324
+ const $rs = document.getElementById('req-stat');
2325
+ if ($rc) $rc.textContent = requests.length;
2326
+ if ($rs) $rs.classList.toggle('has-pending', requests.length > 0);
2327
+ const $rm = document.getElementById('requests-modal');
2328
+ if ($rm && $rm.classList.contains('show')) renderRequests(); // keep an open list live
2329
+ } catch { /* non-fatal */ }
2330
+ }
2331
+
2332
+ // The Requests window: every queued change in a list, each copyable + clearable,
2333
+ // plus copy-all / clear-all. instructionFor() output (which embeds file-derived
2334
+ // appId/nodeId/instruction) is rendered via textContent-equivalent escaping.
2335
+ function renderRequests() {
2336
+ const $list = document.getElementById('requests-list');
2337
+ const $clear = document.getElementById('requests-clear');
2338
+ const $copy = document.getElementById('requests-copy');
2339
+ if (!$list) return;
2340
+ const empty = pendingRequests.length === 0;
2341
+ // Hide (not just disable) the bulk actions when there's nothing to act on — a
2342
+ // disabled "Clear all"/"Copy all" over an empty list reads as broken UI.
2343
+ if ($clear) $clear.hidden = empty;
2344
+ if ($copy) $copy.hidden = empty;
2345
+ if (empty) {
2346
+ $list.innerHTML = '<div class="empty-state">No requests queued. Save a Template or ✎ Tweak a node to send a change to your terminal AI.</div>';
2347
+ return;
2348
+ }
2349
+ $list.innerHTML = pendingRequests.map((r) => {
2350
+ const label = r.type === 'use-template' ? 'template' : 'tweak';
2351
+ const badgeCls = r.type === 'tweak' ? 'req-type req-type-tweak' : 'req-type';
2352
+ const target = r.type === 'tweak' && r.nodeId ? ` · node <code>${escapeHtml(r.nodeId)}</code>` : '';
2353
+ const when = r.createdAt ? new Date(r.createdAt) : null;
2354
+ const time = when && !isNaN(when) ? `<span class="req-time">${escapeHtml(nowStamp(when))}</span>` : '';
2355
+ return `
2356
+ <div class="req-item">
2357
+ <div class="req-info">
2358
+ <div class="req-head"><span class="${badgeCls}">${label}</span> <code>${escapeHtml(r.appId || '')}</code>${target}${time}</div>
2359
+ <div class="req-instruction">${escapeHtml(instructionFor(r))}</div>
2360
+ ${r.snapshots && r.snapshots.length ? `<div class="req-thumbs">${r.snapshots.map((_, i) => `<button type="button" class="req-thumb" data-id="${escapeAttr(r.id)}" data-n="${i}" aria-label="View screenshot ${i + 1}"><img src="/api/requests/${encodeURIComponent(r.id)}/snapshot/${i}" alt=""></button>`).join('')}</div>` : ''}
2361
+ </div>
2362
+ <div class="req-actions">
2363
+ <button class="req-copy" data-id="${escapeAttr(r.id)}" data-tip="Copy this instruction">⧉</button>
2364
+ <button class="req-del" data-id="${escapeAttr(r.id)}" data-tip="Clear this request">×</button>
2365
+ </div>
2366
+ </div>`;
2367
+ }).join('');
2368
+ $list.querySelectorAll('.req-copy').forEach((b) => {
2369
+ b.onclick = async () => {
2370
+ const r = pendingRequests.find((x) => x.id === b.dataset.id);
2371
+ if (!r) return;
2372
+ const copied = await copyToClipboard(instructionFor(r));
2373
+ showToast(copied ? 'Copied — paste it to your terminal AI' : 'copy failed', copied ? 'ok' : 'err');
2374
+ };
2375
+ });
2376
+ $list.querySelectorAll('.req-del').forEach((b) => { b.onclick = () => deleteOneRequest(b.dataset.id); });
2377
+ $list.querySelectorAll('.req-thumb').forEach((b) => {
2378
+ b.onclick = () => window.open(`/api/requests/${encodeURIComponent(b.dataset.id)}/snapshot/${b.dataset.n}`, '_blank');
2379
+ });
2380
+ }
2381
+
2382
+ async function deleteOneRequest(id) {
2383
+ try { await api(`/api/requests/${encodeURIComponent(id)}`, { method: 'DELETE' }); await loadRequests(); renderRequests(); }
2384
+ catch (e) { reportErr(e); }
2385
+ }
2386
+ async function clearAllRequests() {
2387
+ if (!pendingRequests.length) return;
2388
+ try { await api('/api/requests', { method: 'DELETE' }); await loadRequests(); renderRequests(); showToast('Cleared all requests', 'ok'); }
2389
+ catch (e) { reportErr(e); }
2390
+ }
2391
+ function openRequests() {
2392
+ renderRequests();
2393
+ document.getElementById('requests-modal').classList.add('show');
2394
+ }
2395
+
2396
+ // ── Agents library: real installed agents + their commands ───────────────────
2397
+ async function loadAgentCatalog() {
2398
+ const { agents } = await api('/api/agents');
2399
+ agentCatalog = agents;
2400
+ const $ac = document.getElementById('agents-count');
2401
+ if ($ac) $ac.textContent = agents.length;
2402
+ // Narrative banner shows the REAL installed-agent count, not a placeholder.
2403
+ const $acn = document.getElementById('agent-count-narr');
2404
+ if ($acn) $acn.textContent = agents.length;
2405
+ }
2406
+
2407
+ openLibrary = function openLibraryReal() {
2408
+ $libSearch.value = '';
2409
+ renderLibrary();
2410
+ $libModal.classList.add('show');
2411
+ if (!agentCatalog.length) loadAgentCatalog().then(renderLibrary).catch(reportErr);
2412
+ };
2413
+
2414
+ renderLibrary = function renderLibraryReal() {
2415
+ const q = ($libSearch.value || '').toLowerCase().trim();
2416
+ const items = agentCatalog.filter((a) => !q || a.id.toLowerCase().includes(q) || (a.kind || '').toLowerCase().includes(q));
2417
+ $libList.innerHTML = items.map((a) => `
2418
+ <div class="lib-item" data-agent="${escapeAttr(a.id)}">
2419
+ <div class="info">
2420
+ <div class="name">${escapeHtml(a.id)} <span style="color:var(--text-dim);font-weight:400">v${escapeHtml(String(a.version))}</span></div>
2421
+ <div class="meta">${escapeHtml(a.kind || 'agent')} · ${a.commands} command${a.commands === 1 ? '' : 's'} · ${a.skills} skill${a.skills === 1 ? '' : 's'}</div>
2422
+ </div>
2423
+ <button class="lib-fav" data-expand="${escapeAttr(a.id)}" data-tip="Show commands">⌄</button>
2424
+ </div>
2425
+ <div class="lib-commands" id="cmds-${escapeAttr(a.id)}" hidden></div>
2426
+ `).join('') || '<div class="empty-state">No agents match.</div>';
2427
+ $libList.querySelectorAll('[data-expand]').forEach((btn) => {
2428
+ btn.onclick = () => expandAgent(btn.dataset.expand);
2429
+ });
2430
+ };
2431
+
2432
+ async function expandAgent(id) {
2433
+ const box = document.getElementById(`cmds-${id}`);
2434
+ if (!box) return;
2435
+ if (!box.hidden) { box.hidden = true; return; }
2436
+ box.hidden = false;
2437
+ box.innerHTML = '<div class="cmd-desc">loading…</div>';
2438
+ try {
2439
+ const { agent } = await api(`/api/agent/${encodeURIComponent(id)}`);
2440
+ const cmds = Array.isArray(agent.commands) ? agent.commands : [];
2441
+ box.innerHTML = cmds.map((c) => `
2442
+ <div class="lib-cmd">
2443
+ <span class="cmd-name">${escapeHtml(c.name)}</span>
2444
+ <span class="cmd-cat">${escapeHtml(c.category || '')}</span>
2445
+ <span class="cmd-desc">${escapeHtml(c.description || '')}</span>
2446
+ <button class="cmd-save" data-agent="${escapeAttr(id)}" data-cmd="${escapeAttr(c.name)}" data-tip="Save as template">★</button>
2447
+ </div>`).join('') || '<div class="cmd-desc">no commands</div>';
2448
+ box.querySelectorAll('.cmd-save').forEach((btn) => {
2449
+ btn.onclick = () => saveAgentCommandTemplate(btn.dataset.agent, btn.dataset.cmd);
2450
+ });
2451
+ } catch (e) { box.innerHTML = `<div class="cmd-desc">error: ${escapeHtml(e.message)}</div>`; }
2452
+ }
2453
+
2454
+ async function saveAgentCommandTemplate(agent, command) {
2455
+ try {
2456
+ await api('/api/templates', { method: 'POST', body: JSON.stringify({
2457
+ name: command, category: agent,
2458
+ node: { agent, command, kind: 'agent', config: {} },
2459
+ }) });
2460
+ await loadTemplates();
2461
+ showToast(`Saved template "${command}"`, 'ok');
2462
+ } catch (e) { reportErr(e); }
2463
+ }
2464
+
2465
+ // ── Integrations: real connection state from the local vault ────────────────
2466
+ const VENDOR_ICON = { microsoft: '▦', google: 'G', trimble: '◬', autodesk: 'A' };
2467
+ let integrations = [];
2468
+
2469
+ openIntegrations = function openIntegrationsReal() {
2470
+ $integrationsModal.classList.add('show');
2471
+ renderIntegrations();
2472
+ refreshIntegrations();
2473
+ };
2474
+
2475
+ // Re-fetch live connection state from the local vault; re-render if the modal
2476
+ // is open. Called on open and whenever a credential file changes (SSE), so an
2477
+ // open window flips to Connected the moment `aware connect` writes the token.
2478
+ function refreshIntegrations() {
2479
+ return api('/api/integrations')
2480
+ .then(({ integrations: list }) => {
2481
+ integrations = list;
2482
+ if ($integrationsModal.classList.contains('show')) renderIntegrations();
2483
+ })
2484
+ .catch(reportErr);
2485
+ }
2486
+
2487
+ renderIntegrations = function renderIntegrationsReal() {
2488
+ if (!integrations.length) {
2489
+ $integrationsList.innerHTML = '<div class="empty-state">Loading installed integrations…</div>';
2490
+ return;
2491
+ }
2492
+ $integrationsList.innerHTML = integrations.map((i) => {
2493
+ const action = i.status === 'connected' ? 'Disconnect' : i.status === 'expired' ? 'Reconnect' : 'Connect';
2494
+ const badge = i.status === 'connected' ? 'Connected' : i.status === 'expired' ? 'Token expired' : 'Not connected';
2495
+ const meta = i.status === 'disconnected' ? '—' : i.expiresAt ? `expires ${new Date(i.expiresAt).toLocaleString()}` : 'connected';
2496
+ return `
2497
+ <div class="int-item int-${i.status}">
2498
+ <div class="int-icon">${escapeHtml(VENDOR_ICON[i.vendor] || (i.name[0] || '?'))}</div>
2499
+ <div class="int-info">
2500
+ <div class="int-name">${escapeHtml(i.name)}</div>
2501
+ <div class="int-kind">${escapeHtml(i.vendor || 'integration')} · agent <code>${escapeHtml(i.agent)}</code></div>
2502
+ <div class="int-scopes">${escapeHtml((i.network || []).join(' · ') || '—')}</div>
2503
+ <div class="int-meta">${escapeHtml(meta)}</div>
2504
+ <div class="int-row">
2505
+ <span class="int-badge int-badge-${i.status}">${badge}</span>
2506
+ <button class="int-action" data-int="${escapeAttr(i.id)}" data-int-action="${action.toLowerCase()}">${action}</button>
2507
+ </div>
2508
+ ${i.status === 'connected' ? '' : `
2509
+ <label class="int-flow" data-tip="Use a one-time device code instead of opening the browser — for headless or locked-down machines where the loopback sign-in can't run">
2510
+ <input type="checkbox" class="int-flow-toggle" data-flow="${escapeAttr(i.id)}"> use a device code instead
2511
+ </label>`}
2512
+ <div class="int-devicecode" data-dc="${escapeAttr(i.id)}"></div>
2513
+ </div>
2514
+ </div>`;
2515
+ }).join('');
2516
+ $integrationsList.querySelectorAll('.int-action').forEach((btn) => {
2517
+ btn.onclick = async () => {
2518
+ // Connect/Reconnect drive the device-code sign-in IN the app (AWARE owns
2519
+ // the OAuth + token storage; we only show the code + reflect the result —
2520
+ // the token never crosses floless.app). Disconnect stays a copied command.
2521
+ if (btn.dataset.intAction === 'disconnect') {
2522
+ const cmd = 'aware disconnect ' + btn.dataset.int;
2523
+ const copied = await copyToClipboard(cmd);
2524
+ showToast(`${copied ? 'Copied — run' : 'Run'} in your terminal: ${cmd}`, 'info');
2525
+ return;
2526
+ }
2527
+ await startConnect(btn.dataset.int);
2528
+ };
2529
+ });
2530
+ };
2531
+
2532
+ // Device-code sign-in: ask the server to start `aware connect --device-code`,
2533
+ // show the user the code + verification URL, then wait for the SSE result.
2534
+ // `connecting` guards against duplicate clicks spawning parallel polls.
2535
+ const connecting = new Set();
2536
+ function dcSlot(id) {
2537
+ return $integrationsList.querySelector(`.int-devicecode[data-dc="${(window.CSS && CSS.escape) ? CSS.escape(id) : id}"]`);
2538
+ }
2539
+ async function startConnect(id) {
2540
+ if (connecting.has(id)) return;
2541
+ connecting.add(id);
2542
+ const slot = dcSlot(id);
2543
+ // Default flow is OAuth loopback+PKCE (safer; aware opens the browser). The
2544
+ // per-card toggle opts into device-code (a one-time code) for headless /
2545
+ // loopback-blocked machines.
2546
+ const esc = (window.CSS && CSS.escape) ? CSS.escape(id) : id;
2547
+ const toggle = $integrationsList.querySelector(`.int-flow-toggle[data-flow="${esc}"]`);
2548
+ const body = toggle && toggle.checked ? JSON.stringify({ flow: 'device-code' }) : '{}';
2549
+ if (slot) slot.innerHTML = 'Starting sign-in…';
2550
+ try {
2551
+ const res = await api(`/api/connect/${encodeURIComponent(id)}`, { method: 'POST', body });
2552
+ if (slot) {
2553
+ if (res.flow === 'device-code' && res.prompt) {
2554
+ // Fallback flow: show the device code to enter at the verification URL.
2555
+ slot.innerHTML =
2556
+ `Go to <a href="${escapeAttr(res.prompt.verification_uri)}" target="_blank" rel="noopener">${escapeHtml(res.prompt.verification_uri)}</a>` +
2557
+ ` and enter code <strong class="dc-code">${escapeHtml(res.prompt.user_code)}</strong> — waiting for sign-in…`;
2558
+ } else {
2559
+ // OAuth: aware opened the provider sign-in in the browser.
2560
+ slot.innerHTML = 'Opening your browser to sign in… complete it there and this will update automatically.';
2561
+ }
2562
+ }
2563
+ } catch (e) {
2564
+ connecting.delete(id);
2565
+ if (slot) slot.innerHTML = '';
2566
+ reportErr(e);
2567
+ }
2568
+ }
2569
+
2570
+ // Show the per-node Tweak button when a node is selected, and the Debug-in-VS
2571
+ // button only for exec nodes (those carry C# the host can run under a debugger).
2572
+ // Debug lives HERE in the main window, not in the report modal.
2573
+ const _selectAgent = selectAgent;
2574
+ selectAgent = function selectAgentTweakAware(id) {
2575
+ _selectAgent(id);
2576
+ const tb = document.getElementById('tweak-btn');
2577
+ if (tb) tb.hidden = !id;
2578
+ const db = document.getElementById('debug-btn');
2579
+ if (db) {
2580
+ const node = id && currentId && apps.get(currentId)?.nodes.find((n) => n.id === id);
2581
+ db.hidden = !(node && node.config && typeof node.config.code === 'string');
2582
+ }
2583
+ };
2584
+
2585
+ // ── Routines (scheduled .flo runs) ──────────────────────────────────────────
2586
+ // A routine is a TIME-based trigger of `aware app run` (the server scheduler fires
2587
+ // it). This panel is pure CRUD over /api/routines — the same seam the authoring
2588
+ // skill writes to. The UI never composes a workflow; it schedules an existing one.
2589
+ const $routinesModal = document.getElementById('routines-modal');
2590
+ const $routineEditModal = document.getElementById('routine-edit-modal');
2591
+ let routinesData = [];
2592
+ let routinesMax = 15;
2593
+ let editingRoutineId = null;
2594
+ let editWorkflows = []; // app list for the workflow <select>, cached per edit-open
2595
+ let editTriggerSource = null; // picked workflow's triggerSource (null = not trigger-eligible)
2596
+ let editRoutineMode = 'schedule'; // 'schedule' | 'trigger' — chosen kind in the edit form
2597
+ const runningRoutines = new Set(); // ids executing now (SSE start → ended)
2598
+
2599
+ const RTN_DAY_LABELS = { sun: 'Sun', mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat' };
2600
+ const RTN_DAY_ORDER = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
2601
+
2602
+ function scheduleText(s) {
2603
+ if (!s || !s.kind) return '—';
2604
+ if (s.kind === 'hourly') return s.everyHours === 1 ? 'Every hour' : `Every ${s.everyHours} hours`;
2605
+ if (s.kind === 'daily') return `Daily · ${s.time}`;
2606
+ if (s.kind === 'weekdays') return `Weekdays · ${s.time}`;
2607
+ if (s.kind === 'weekly') {
2608
+ const days = (s.days || []).slice().sort((a, b) => RTN_DAY_ORDER.indexOf(a) - RTN_DAY_ORDER.indexOf(b)).map((d) => RTN_DAY_LABELS[d] || d).join(', ');
2609
+ return `${days || '—'} · ${s.time}`;
2610
+ }
2611
+ if (s.kind === 'cron') return `Custom · ${s.expr}`;
2612
+ return '—';
2613
+ }
2614
+
2615
+ // Build the hour/minute <select> options once (00–23 / 00–59). App-styled selects
2616
+ // replace the native <input type="time"> so the picker matches the dark baseline.
2617
+ function fillTimeSelects() {
2618
+ const $h = document.getElementById('rtn-hour');
2619
+ const $m = document.getElementById('rtn-minute');
2620
+ if (!$h || !$m || $h.options.length) return; // build once
2621
+ const pad = (n) => String(n).padStart(2, '0');
2622
+ $h.innerHTML = Array.from({ length: 24 }, (_, i) => `<option value="${pad(i)}">${pad(i)}</option>`).join('');
2623
+ $m.innerHTML = Array.from({ length: 60 }, (_, i) => `<option value="${pad(i)}">${pad(i)}</option>`).join('');
2624
+ }
2625
+ function setTimeSelects(hhmm) {
2626
+ const [h, m] = (hhmm || '07:00').split(':');
2627
+ const $h = document.getElementById('rtn-hour');
2628
+ const $m = document.getElementById('rtn-minute');
2629
+ if ($h) $h.value = (h || '07').padStart(2, '0');
2630
+ if ($m) $m.value = (m || '00').padStart(2, '0');
2631
+ }
2632
+ function getTimeSelects() {
2633
+ const h = document.getElementById('rtn-hour').value || '07';
2634
+ const m = document.getElementById('rtn-minute').value || '00';
2635
+ return `${h}:${m}`;
2636
+ }
2637
+ function relTime(iso) {
2638
+ if (!iso) return '—';
2639
+ const ms = new Date(iso).getTime() - Date.now();
2640
+ if (isNaN(ms)) return '—';
2641
+ if (ms <= 0) return 'due';
2642
+ const min = Math.round(ms / 60000);
2643
+ if (min < 60) return `in ${min} min`;
2644
+ const h = Math.round(min / 60);
2645
+ if (h < 48) return `in ${h} h`;
2646
+ return `in ${Math.round(h / 24)} d`;
2647
+ }
2648
+ function absTime(iso) {
2649
+ if (!iso) return '';
2650
+ const d = new Date(iso);
2651
+ return isNaN(d.getTime()) ? '' : d.toLocaleString();
2652
+ }
2653
+ function lastRunTip(r) {
2654
+ if (!r.lastRun) return 'Never run yet';
2655
+ const lr = r.lastRun;
2656
+ const dur = lr.durationMs != null ? ` · ${(lr.durationMs / 1000).toFixed(1)} s` : '';
2657
+ return `Last run ${lr.status}${lr.reason ? ' — ' + lr.reason : ''} · ${absTime(lr.at)}${dur}`;
2658
+ }
2659
+
2660
+ // ── Trigger-routine row helpers ─────────────────────────────────────────────
2661
+ // A trigger routine's "when" descriptor (mirrors scheduleText for timers): the
2662
+ // source agent/command it fires on, from the routine's TriggerBinding.
2663
+ function triggerWhenText(r) {
2664
+ const t = r.trigger || {};
2665
+ const src = t.agent ? `${t.agent}${t.command ? '/' + t.command : ''}` : 'event';
2666
+ return `On trigger · ${src}`;
2667
+ }
2668
+
2669
+ // Short "x ago" for a PAST instant (relTime is for future fires). '' when absent.
2670
+ function agoTime(iso) {
2671
+ if (!iso) return '';
2672
+ const ms = Date.now() - new Date(iso).getTime();
2673
+ if (isNaN(ms) || ms < 0) return '';
2674
+ const min = Math.round(ms / 60000);
2675
+ if (min < 1) return 'just now';
2676
+ if (min < 60) return `${min}m ago`;
2677
+ const h = Math.round(min / 60);
2678
+ if (h < 48) return `${h}h ago`;
2679
+ return `${Math.round(h / 24)}d ago`;
2680
+ }
2681
+
2682
+ // Map a trigger routine's live session snapshot to its status cell —
2683
+ // {label, dotClass, tip, sr} mirroring a schedule row's nextLabel/dot. Dot classes
2684
+ // reuse the existing palette: listening=ok, blocked=skipped, error=failed, stopped=muted.
2685
+ // Pure render of server-pushed state; the UI never composes it.
2686
+ function triggerStatus(r) {
2687
+ if (!r.enabled) return { label: 'stopped', dotClass: '', tip: 'Disabled — turn on to start watching', sr: 'Status: stopped' };
2688
+ if (r.broken) return { label: 'blocked', dotClass: 'skipped', tip: r.broken.reason, sr: `Status: blocked — ${r.broken.reason}` };
2689
+ const ss = r.session || null;
2690
+ const state = ss ? ss.state : 'stopped';
2691
+ if (state === 'blocked') {
2692
+ const why = (ss && ss.error) || 'Not runnable — needs Compile';
2693
+ return { label: 'blocked', dotClass: 'skipped', tip: why, sr: `Status: blocked — ${why}` };
2694
+ }
2695
+ if (state === 'error') {
2696
+ const why = (ss && ss.error) || 'The watcher exited with an error';
2697
+ return { label: 'error', dotClass: 'failed', tip: why, sr: `Status: error — ${why}` };
2698
+ }
2699
+ if (state === 'listening') {
2700
+ const fired = (ss && ss.firedCount) || 0;
2701
+ const label = fired ? `listening · ${fired}×` : 'listening';
2702
+ let tip = 'Watching for events — runs on each one';
2703
+ if (fired) {
2704
+ const le = ss.lastEvent;
2705
+ tip = `Fired ${fired}×` + (le ? ` · ${le.summary} · ${agoTime(le.at) || absTime(le.at)}` : '');
2706
+ }
2707
+ const sr = `Status: listening, fired ${fired} times` + (ss && ss.lastEvent ? `, last event ${ss.lastEvent.summary}` : '');
2708
+ return { label, dotClass: 'ok', tip, sr };
2709
+ }
2710
+ return { label: 'stopped', dotClass: '', tip: 'Not running', sr: 'Status: stopped' };
2711
+ }
2712
+
2713
+ async function loadRoutinesData() {
2714
+ try {
2715
+ const res = await api('/api/routines');
2716
+ routinesData = res.routines || [];
2717
+ routinesMax = res.max || 15;
2718
+ // Reconcile running indicators from the server's authoritative state (queued/
2719
+ // active) so a missed SSE end-event can't leave a spinner wedged on.
2720
+ runningRoutines.clear();
2721
+ for (const r of routinesData) if (r.running) runningRoutines.add(r.id);
2722
+ if ($routinesModal.classList.contains('show')) renderRoutinesList();
2723
+ } catch { /* non-fatal */ }
2724
+ }
2725
+
2726
+ // SSE: a trigger routine's live session snapshot changed. Update the in-memory
2727
+ // row and re-render; flash the row when its fired count climbs (a real event).
2728
+ function applyTriggerSnapshot(id, snapshot) {
2729
+ const r = routinesData.find((x) => x.id === id);
2730
+ if (!r) { loadRoutinesData(); return; } // unknown id (created in another tab) → resync
2731
+ const prevFired = (r.session && r.session.firedCount) || 0;
2732
+ r.session = snapshot;
2733
+ if (!$routinesModal.classList.contains('show')) return;
2734
+ renderRoutinesList();
2735
+ if (snapshot && snapshot.firedCount > prevFired) flashRoutineRow(id);
2736
+ }
2737
+
2738
+ // Briefly tint a row when it just fired. Re-applies on rapid repeats via a forced
2739
+ // reflow (else the animation wouldn't restart). Honors reduced-motion (CSS).
2740
+ function flashRoutineRow(id) {
2741
+ // ids are slugs ([a-z0-9._-]) — safe inside a quoted attribute selector as-is.
2742
+ const el = document.querySelector(`.rtn-item[data-id="${id}"]`);
2743
+ if (!el) return;
2744
+ el.classList.remove('rtn-fired-flash');
2745
+ void el.offsetWidth;
2746
+ el.classList.add('rtn-fired-flash');
2747
+ }
2748
+
2749
+ function renderRoutinesList() {
2750
+ const $list = document.getElementById('routines-list');
2751
+ const $quota = document.getElementById('rtn-quota');
2752
+ const $add = document.getElementById('rtn-add');
2753
+ if (!$list) return;
2754
+ const n = routinesData.length;
2755
+ const full = n >= routinesMax;
2756
+ const near = !full && n >= routinesMax - 3;
2757
+ $quota.className = 'rtn-quota' + (full ? ' full' : near ? ' near' : '');
2758
+ $quota.innerHTML = `<span class="num">${n}</span> of ${routinesMax} routines used` +
2759
+ (full ? ' · <span class="cap-note">limit reached — delete one to add another</span>' : '');
2760
+ if ($add) $add.hidden = full; // hide (don't disable) at the cap
2761
+ if (!n) {
2762
+ $list.innerHTML = '<div class="empty-state">No routines yet. Add one to run a workflow on a schedule.</div>';
2763
+ return;
2764
+ }
2765
+ $list.innerHTML = routinesData.map((r) => {
2766
+ const isTrigger = r.kind === 'trigger';
2767
+ const running = runningRoutines.has(r.id);
2768
+ const broken = r.broken ? `<span class="rtn-dot">·</span><span style="color:var(--err)">${escapeHtml(r.broken.reason)}</span>` : '';
2769
+ let statusEl, nextTip, nextLabel;
2770
+ if (isTrigger) {
2771
+ const t = triggerStatus(r);
2772
+ nextLabel = t.label;
2773
+ nextTip = t.tip;
2774
+ statusEl = `<span class="rtn-status ${t.dotClass}" data-tip="${escapeAttr(t.tip)}"><span class="sr-only">${escapeHtml(t.sr)}</span></span>`;
2775
+ } else {
2776
+ statusEl = running
2777
+ ? '<span class="rtn-spinner" data-tip="Running now…"></span>'
2778
+ : `<span class="rtn-status ${escapeAttr(r.lastRun ? r.lastRun.status : '')}" data-tip="${escapeAttr(lastRunTip(r))}"></span>`;
2779
+ nextTip = r.nextFireAt ? `Next run: ${absTime(r.nextFireAt)}` : (r.broken ? r.broken.reason : r.enabled ? 'Not scheduled' : 'Disabled');
2780
+ nextLabel = !r.enabled ? 'disabled' : r.broken ? 'broken' : relTime(r.nextFireAt);
2781
+ }
2782
+ const whenText = isTrigger ? triggerWhenText(r) : scheduleText(r.schedule);
2783
+ const toggleTip = isTrigger ? (r.enabled ? 'Stop listening' : 'Start listening') : `${r.enabled ? 'Disable' : 'Enable'} this routine`;
2784
+ return `
2785
+ <div class="rtn-item ${isTrigger ? 'trigger ' : ''}${r.enabled ? '' : 'disabled'} ${running ? 'running' : ''}" data-id="${escapeAttr(r.id)}">
2786
+ <div class="rtn-main">
2787
+ <div class="rtn-name">${escapeHtml(r.name)}</div>
2788
+ <div class="rtn-sub"><span class="rtn-wf">${escapeHtml(r.workflow)}</span><span class="rtn-dot">·</span><span>${escapeHtml(whenText)}</span>${broken}</div>
2789
+ </div>
2790
+ <div class="rtn-meta"${isTrigger ? ' aria-live="polite" aria-atomic="true"' : ''}>
2791
+ <span class="rtn-next" data-tip="${escapeAttr(nextTip)}">${escapeHtml(nextLabel)}</span>
2792
+ ${statusEl}
2793
+ </div>
2794
+ <div class="rtn-actions">
2795
+ <label class="rtn-toggle" data-tip="${escapeAttr(toggleTip)}">
2796
+ <input type="checkbox" data-rtn-toggle="${escapeAttr(r.id)}" ${r.enabled ? 'checked' : ''}>
2797
+ <span class="rtn-toggle-track"></span>
2798
+ </label>
2799
+ ${isTrigger ? '' : `<button class="rtn-act" data-rtn-run="${escapeAttr(r.id)}" data-tip="Run now"${running ? ' disabled' : ''}>▶</button>`}
2800
+ <button class="rtn-act" data-rtn-edit="${escapeAttr(r.id)}" data-tip="Edit">✎</button>
2801
+ <button class="rtn-act rtn-del-act" data-rtn-del="${escapeAttr(r.id)}" data-tip="Delete">×</button>
2802
+ </div>
2803
+ </div>`;
2804
+ }).join('');
2805
+ $list.querySelectorAll('[data-rtn-toggle]').forEach((el) => { el.onchange = () => toggleRoutine(el.dataset.rtnToggle, el.checked); });
2806
+ $list.querySelectorAll('[data-rtn-run]').forEach((b) => { b.onclick = () => runRoutineNow(b.dataset.rtnRun); });
2807
+ $list.querySelectorAll('[data-rtn-edit]').forEach((b) => { b.onclick = () => openRoutineEdit(b.dataset.rtnEdit); });
2808
+ $list.querySelectorAll('[data-rtn-del]').forEach((b) => { b.onclick = () => deleteRoutineUi(b.dataset.rtnDel); });
2809
+ }
2810
+
2811
+ async function toggleRoutine(id, enabled) {
2812
+ try {
2813
+ await api(`/api/routines/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ enabled }) });
2814
+ } catch (e) { reportErr(e); }
2815
+ loadRoutinesData();
2816
+ }
2817
+ async function runRoutineNow(id) {
2818
+ try {
2819
+ await api(`/api/routines/${encodeURIComponent(id)}/run`, { method: 'POST' });
2820
+ runningRoutines.add(id);
2821
+ renderRoutinesList();
2822
+ showToast('Routine started', 'ok');
2823
+ } catch (e) { reportErr(e); }
2824
+ }
2825
+ // Destructive-action confirm. Cancel holds focus (so Enter/Esc/backdrop all cancel);
2826
+ // only an explicit Delete click removes the routine. Resolves true to proceed.
2827
+ function confirmDeleteRoutine(r) {
2828
+ return new Promise((resolve) => {
2829
+ const $m = document.getElementById('rtn-delete-modal');
2830
+ const $sub = document.getElementById('rtn-delete-sub');
2831
+ const $confirm = document.getElementById('rtn-delete-confirm');
2832
+ const $cancel = document.getElementById('rtn-delete-cancel');
2833
+ const name = r && r.name ? r.name : 'this routine';
2834
+ const when = r ? (r.kind === 'trigger' ? triggerWhenText(r) : scheduleText(r.schedule)) : '';
2835
+ const verb = r && r.kind === 'trigger' ? 'watches' : 'runs';
2836
+ $sub.textContent = `“${name}”${when && when !== '—' ? ` ${verb} ${when}` : ''} — this can’t be undone.`;
2837
+ showModal($m);
2838
+ setTimeout(() => $cancel.focus(), 0); // safe default: Enter activates Cancel
2839
+ const done = (result) => {
2840
+ hideModal($m);
2841
+ $confirm.onclick = $cancel.onclick = $m.onclick = null;
2842
+ document.removeEventListener('keydown', onKey, true);
2843
+ resolve(result);
2844
+ };
2845
+ const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(false); } };
2846
+ $confirm.onclick = () => done(true);
2847
+ $cancel.onclick = () => done(false);
2848
+ $m.onclick = (e) => { if (e.target === $m) done(false); };
2849
+ document.addEventListener('keydown', onKey, true);
2850
+ });
2851
+ }
2852
+
2853
+ async function deleteRoutineUi(id) {
2854
+ const r = routinesData.find((x) => x.id === id);
2855
+ if (!(await confirmDeleteRoutine(r))) return; // guard against an accidental × click
2856
+ try {
2857
+ await api(`/api/routines/${encodeURIComponent(id)}`, { method: 'DELETE' });
2858
+ await loadRoutinesData();
2859
+ showToast('Routine deleted', 'ok');
2860
+ } catch (e) { reportErr(e); }
2861
+ }
2862
+
2863
+ // Schedule builder: reveal only the fields the chosen kind needs.
2864
+ // Switch the edit form between schedule and trigger layouts. Hides the schedule
2865
+ // builder and shows the read-only trigger-source line for 'trigger' (only honored
2866
+ // when the picked workflow is actually trigger-eligible).
2867
+ function setRoutineMode(mode) {
2868
+ editRoutineMode = (mode === 'trigger' && editTriggerSource) ? 'trigger' : 'schedule';
2869
+ const trig = editRoutineMode === 'trigger';
2870
+ document.querySelectorAll('#rtn-mode-field .rtn-mode-btn').forEach((b) => {
2871
+ const on = b.dataset.mode === editRoutineMode;
2872
+ b.classList.toggle('active', on);
2873
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
2874
+ });
2875
+ const schedKind = document.getElementById('rtn-sched-kind-field');
2876
+ const schedDetail = document.getElementById('rtn-sched-detail');
2877
+ const trigField = document.getElementById('rtn-trigger-field');
2878
+ if (schedKind) schedKind.hidden = trig;
2879
+ if (schedDetail) schedDetail.hidden = trig;
2880
+ if (trigField) trigField.hidden = !trig;
2881
+ if (trig && editTriggerSource) {
2882
+ const t = editTriggerSource;
2883
+ const el = document.getElementById('rtn-trigger-src');
2884
+ if (el) el.textContent = `${t.agent || ''}${t.command ? '/' + t.command : ''}` || 'event source';
2885
+ }
2886
+ const enLabel = document.getElementById('rtn-enabled-label');
2887
+ if (enLabel) enLabel.textContent = trig ? 'Enabled — start watching now' : 'Enabled';
2888
+ }
2889
+
2890
+ // Reveal the schedule-vs-trigger chooser only when it's a real choice: a NEW
2891
+ // routine on a trigger-eligible workflow. On edit the kind is fixed (inputs are
2892
+ // coerced against the workflow), so the chooser is hidden and the saved kind shown.
2893
+ function applyRoutineModeAvailability() {
2894
+ const $modeField = document.getElementById('rtn-mode-field');
2895
+ if (editingRoutineId) {
2896
+ if ($modeField) $modeField.hidden = true; // kind fixed after create
2897
+ setRoutineMode(editRoutineMode);
2898
+ return;
2899
+ }
2900
+ const eligible = !!editTriggerSource;
2901
+ if ($modeField) $modeField.hidden = !eligible;
2902
+ setRoutineMode(eligible ? 'trigger' : 'schedule'); // event is the sensible default for a streaming source
2903
+ }
2904
+
2905
+ function applySchedKind(kind) {
2906
+ document.querySelectorAll('#rtn-sched-detail [data-sched]').forEach((el) => {
2907
+ el.hidden = !el.dataset.sched.split(' ').includes(kind);
2908
+ });
2909
+ }
2910
+ function renderDayChips(selected) {
2911
+ const $days = document.getElementById('rtn-days');
2912
+ $days.innerHTML = RTN_DAY_ORDER.map((d) =>
2913
+ `<button type="button" class="tf-chip rtn-day${selected.includes(d) ? ' active' : ''}" data-day="${d}">${RTN_DAY_LABELS[d]}</button>`).join('');
2914
+ $days.querySelectorAll('.rtn-day').forEach((b) => { b.onclick = () => b.classList.toggle('active'); });
2915
+ }
2916
+ function selectedDays() {
2917
+ return [...document.querySelectorAll('#rtn-days .rtn-day.active')].map((b) => b.dataset.day);
2918
+ }
2919
+
2920
+ // Render the chosen workflow's declared inputs (seeded from defaults / the routine's
2921
+ // saved values). Hidden when the workflow declares none.
2922
+ async function loadRoutineInputs(workflowId, prefill) {
2923
+ const $field = document.getElementById('rtn-inputs-field');
2924
+ const $inputs = document.getElementById('rtn-inputs');
2925
+ $inputs.innerHTML = '';
2926
+ $field.hidden = true;
2927
+ editTriggerSource = null;
2928
+ if (!workflowId) { applyRoutineModeAvailability(); return; }
2929
+ let app;
2930
+ try { ({ app } = await api(`/api/app/${encodeURIComponent(workflowId)}`)); } catch { applyRoutineModeAvailability(); return; }
2931
+ if (document.getElementById('rtn-workflow').value !== workflowId) return; // a newer workflow selection superseded this fetch
2932
+ editTriggerSource = (app && app.triggerSource) || null;
2933
+ applyRoutineModeAvailability(); // reveal/hide the schedule-vs-trigger chooser
2934
+ const inps = (app && app.inputs) || [];
2935
+ if (!inps.length) return;
2936
+ $field.hidden = false;
2937
+ // The DOM id is index-based (not the workflow-controlled input name) so a crafted
2938
+ // input name can't inject into the id/for attributes. The real key rides the
2939
+ // escaped data-rtn-in attribute, which collectRoutineInputs reads.
2940
+ $inputs.innerHTML = inps.map((inp, i) => {
2941
+ const id = `rtn-in-${i}`;
2942
+ const val = prefill && inp.name in prefill ? prefill[inp.name] : (inp.default != null ? inp.default : '');
2943
+ const type = inp.type === 'integer' || inp.type === 'number' ? 'number' : 'text';
2944
+ return `<div class="modal-field"><label for="${id}">${escapeHtml(inp.name)}${inp.description ? ' — ' + escapeHtml(inp.description) : ''}</label>`
2945
+ + `<input id="${id}" type="${type}" data-rtn-in="${escapeAttr(inp.name)}" value="${escapeAttr(String(val))}"></div>`;
2946
+ }).join('');
2947
+ }
2948
+ function collectRoutineInputs() {
2949
+ const out = {};
2950
+ document.querySelectorAll('#rtn-inputs [data-rtn-in]').forEach((el) => {
2951
+ const v = el.value.trim();
2952
+ if (v === '') return;
2953
+ out[el.dataset.rtnIn] = el.type === 'number' && !isNaN(Number(v)) ? Number(v) : v;
2954
+ });
2955
+ return out;
2956
+ }
2957
+
2958
+ async function openRoutineEdit(id) {
2959
+ editingRoutineId = id || null;
2960
+ const r = id ? routinesData.find((x) => x.id === id) : null;
2961
+ const $wf = document.getElementById('rtn-workflow');
2962
+ try {
2963
+ const { apps: list } = await api('/api/apps');
2964
+ editWorkflows = list;
2965
+ } catch { editWorkflows = []; }
2966
+ $wf.innerHTML = editWorkflows.map((a) => `<option value="${escapeAttr(a.id)}">${escapeHtml(a.id)}</option>`).join('');
2967
+ document.getElementById('rtn-edit-title').textContent = r ? 'Edit routine' : 'New routine';
2968
+ document.getElementById('rtn-name').value = r ? r.name : '';
2969
+ const wfId = r ? r.workflow : (editWorkflows[0] ? editWorkflows[0].id : '');
2970
+ $wf.value = wfId;
2971
+ // Workflow is fixed after create (inputs are coerced against it) — disable on edit.
2972
+ $wf.disabled = !!r;
2973
+ if (r) $wf.setAttribute('data-tip', 'Workflow is fixed — delete and recreate to use a different one');
2974
+ else $wf.removeAttribute('data-tip');
2975
+ editRoutineMode = (r && r.kind === 'trigger') ? 'trigger' : 'schedule';
2976
+ const sched = (r && r.schedule) ? r.schedule : { kind: 'daily', time: '07:00' };
2977
+ fillTimeSelects();
2978
+ document.getElementById('rtn-kind').value = sched.kind;
2979
+ document.getElementById('rtn-everyhours').value = sched.kind === 'hourly' ? String(sched.everyHours) : '2';
2980
+ setTimeSelects(sched.time || '07:00');
2981
+ document.getElementById('rtn-cron').value = sched.kind === 'cron' ? sched.expr : '';
2982
+ renderDayChips(sched.kind === 'weekly' ? (sched.days || []) : []);
2983
+ applySchedKind(sched.kind);
2984
+ document.getElementById('rtn-enabled').checked = r ? r.enabled : true;
2985
+ await loadRoutineInputs(wfId, r ? r.inputs : null);
2986
+ showModal($routineEditModal);
2987
+ setTimeout(() => document.getElementById('rtn-name').focus(), 0);
2988
+ }
2989
+
2990
+ async function saveRoutine() {
2991
+ const name = document.getElementById('rtn-name').value.trim();
2992
+ if (!name) { showToast('Name is required', 'warn'); return; }
2993
+ const workflow = document.getElementById('rtn-workflow').value;
2994
+ if (!workflow) { showToast('Pick a workflow', 'warn'); return; }
2995
+ const inputs = collectRoutineInputs();
2996
+ const enabled = document.getElementById('rtn-enabled').checked;
2997
+ const asTrigger = editRoutineMode === 'trigger' && !!editTriggerSource;
2998
+ let schedule;
2999
+ if (!asTrigger) {
3000
+ const kind = document.getElementById('rtn-kind').value;
3001
+ if (kind === 'hourly') {
3002
+ const n = Math.max(1, Math.min(24, parseInt(document.getElementById('rtn-everyhours').value, 10) || 1));
3003
+ schedule = { kind: 'hourly', everyHours: n };
3004
+ } else if (kind === 'weekly') {
3005
+ const days = selectedDays();
3006
+ if (!days.length) { showToast('Pick at least one weekday', 'warn'); return; }
3007
+ schedule = { kind: 'weekly', days, time: getTimeSelects() };
3008
+ } else if (kind === 'cron') {
3009
+ const expr = document.getElementById('rtn-cron').value.trim();
3010
+ if (!expr) { showToast('Enter a cron expression', 'warn'); return; }
3011
+ schedule = { kind: 'cron', expr };
3012
+ } else {
3013
+ schedule = { kind, time: getTimeSelects() };
3014
+ }
3015
+ }
3016
+ try {
3017
+ if (editingRoutineId) {
3018
+ const body = asTrigger ? { name, inputs, enabled } : { name, schedule, inputs, enabled };
3019
+ await api(`/api/routines/${encodeURIComponent(editingRoutineId)}`, { method: 'PATCH', body: JSON.stringify(body) });
3020
+ showToast('Routine updated', 'ok');
3021
+ } else {
3022
+ const body = asTrigger ? { kind: 'trigger', name, workflow, inputs, enabled } : { name, workflow, schedule, inputs, enabled };
3023
+ await api('/api/routines', { method: 'POST', body: JSON.stringify(body) });
3024
+ showToast('Routine created', 'ok');
3025
+ }
3026
+ hideModal($routineEditModal);
3027
+ await loadRoutinesData();
3028
+ } catch (e) { showToast(e.message || 'Could not save routine', 'err'); }
3029
+ }
3030
+
3031
+ openRoutines = function openRoutinesReal() {
3032
+ renderRoutinesList();
3033
+ showModal($routinesModal);
3034
+ loadRoutinesData();
3035
+ };
3036
+
3037
+ // Wiring (the modal elements are static in index.html, present when this runs).
3038
+ document.getElementById('routines-btn').onclick = () => openRoutines();
3039
+ document.getElementById('routines-close').onclick = () => hideModal($routinesModal);
3040
+ $routinesModal.onclick = (e) => { if (e.target === $routinesModal) hideModal($routinesModal); };
3041
+ document.getElementById('rtn-add').onclick = () => openRoutineEdit(null);
3042
+ document.getElementById('rtn-edit-cancel').onclick = () => hideModal($routineEditModal);
3043
+ document.getElementById('rtn-edit-save').onclick = () => saveRoutine();
3044
+ $routineEditModal.onclick = (e) => { if (e.target === $routineEditModal) hideModal($routineEditModal); };
3045
+ document.getElementById('rtn-kind').onchange = (e) => applySchedKind(e.target.value);
3046
+ document.querySelectorAll('#rtn-mode-field .rtn-mode-btn').forEach((b) => { b.onclick = () => setRoutineMode(b.dataset.mode); });
3047
+ document.getElementById('rtn-workflow').onchange = (e) => { if (!editingRoutineId) loadRoutineInputs(e.target.value, null); };
3048
+ document.addEventListener('keydown', (e) => {
3049
+ if (e.key !== 'Escape') return;
3050
+ if ($routineEditModal.classList.contains('show')) hideModal($routineEditModal);
3051
+ else if ($routinesModal.classList.contains('show')) hideModal($routinesModal);
3052
+ });
3053
+
3054
+ // ── init (after app.js bootstrap) ────────────────────────────────────────────
3055
+ // app.js already stored the ORIGINAL functions as .onclick/.oninput before we
3056
+ // reassigned the globals; rebind via arrows so they resolve our versions at
3057
+ // call-time (otherwise Save/library-search silently run the demo code).
3058
+ $browseBtn.onclick = () => openLibrary();
3059
+ $libSearch.oninput = () => renderLibrary();
3060
+
3061
+ // Compile-notes strip: × collapses it to a faint one-line pill; clicking the
3062
+ // collapsed pill re-expands. Delegated (the strip's innerHTML is rebuilt each
3063
+ // load). The choice is remembered per app+note-signature (see notesSig).
3064
+ $notesStrip.addEventListener('click', (e) => {
3065
+ if (e.target.closest('.notes-dismiss')) $notesStrip.classList.add('collapsed');
3066
+ else if ($notesStrip.classList.contains('collapsed')) $notesStrip.classList.remove('collapsed');
3067
+ else return; // click inside the expanded body — nothing to toggle
3068
+ const id = $notesStrip.dataset.appId, sig = $notesStrip.dataset.sig;
3069
+ if (id && sig) saveNotesCollapsed(id, sig, $notesStrip.classList.contains('collapsed'));
3070
+ });
3071
+
3072
+ // Take over the menu/keyboard Save from app.js's demo stub: in the real app the
3073
+ // UI can't author the .flo (that's the terminal AI's job), so "Save" persists the
3074
+ // current workflow's input values locally. "Save to…" (demo .flo download) is
3075
+ // retired — its menu item is removed; neutralize its Ctrl+Shift+S shortcut too.
3076
+ doSave = () => saveCurrentInputs();
3077
+ doSaveAs = () => saveCurrentInputs();
3078
+ // "Open" can't open a local .flo — a workflow is an installed AWARE app, not a
3079
+ // file the UI authors. It opens the searchable workflow picker so you can find one.
3080
+ doOpen = () => openCombo();
3081
+ const $favSave = document.getElementById('fav-save');
3082
+ if ($favSave) $favSave.onclick = () => commitFav();
3083
+
3084
+ const $tweakBtn = document.getElementById('tweak-btn');
3085
+ if ($tweakBtn) $tweakBtn.onclick = () => { tweakNode(); };
3086
+ const $debugBtn = document.getElementById('debug-btn');
3087
+ if ($debugBtn) $debugBtn.onclick = () => { if (state.selectedAgentId) runReport(state.selectedAgentId, { debug: true }); };
3088
+ const $reqStat = document.getElementById('req-stat');
3089
+ if ($reqStat) $reqStat.onclick = () => openRequests();
3090
+ const $reqModal = document.getElementById('requests-modal');
3091
+ const $reqClose = document.getElementById('requests-close');
3092
+ if ($reqClose) $reqClose.onclick = () => $reqModal.classList.remove('show');
3093
+ if ($reqModal) $reqModal.onclick = (e) => { if (e.target === $reqModal) $reqModal.classList.remove('show'); };
3094
+ const $reqClear = document.getElementById('requests-clear');
3095
+ if ($reqClear) $reqClear.onclick = () => clearAllRequests();
3096
+ const $reqCopy = document.getElementById('requests-copy');
3097
+ if ($reqCopy) $reqCopy.onclick = async () => {
3098
+ if (!pendingRequests.length) return;
3099
+ const text = pendingRequests.map(instructionFor).filter(Boolean).join('\n\n');
3100
+ const copied = await copyToClipboard(text);
3101
+ showToast(copied ? `Copied ${pendingRequests.length} request(s) — paste to your terminal AI` : 'copy failed', copied ? 'ok' : 'err');
3102
+ };
3103
+ document.addEventListener('keydown', (e) => {
3104
+ if (e.key === 'Escape' && $reqModal && $reqModal.classList.contains('show')) $reqModal.classList.remove('show');
3105
+ });
3106
+
3107
+ // Pull all workspace data (agents/templates/requests + the apps list). Factored
3108
+ // out of bootWorkspace so the health poll can re-pull it after the server comes
3109
+ // back from a boot-time outage (setServerStatus → recovery).
3110
+ function loadWorkspaceData() {
3111
+ Promise.all([
3112
+ loadAgentCatalog().catch(() => {}),
3113
+ loadTemplates().catch(() => {}),
3114
+ loadRequests().catch(() => {}),
3115
+ ]);
3116
+ return loadApps().catch((e) => {
3117
+ reportErr(e);
3118
+ setComboTriggerLabel('server unreachable');
3119
+ renderCanvasPlaceholder('offline');
3120
+ });
3121
+ }
3122
+
3123
+ // Load the workspace. Entered ONLY for a valid (or offline-grace) subscription AND
3124
+ // after AWARE is ready (see the boot sequence below). The license-independent setup
3125
+ // — overlay CSS, the health poll, and SSE — runs in the boot sequence, so a
3126
+ // setting-up/failed runtime is surfaced even to a signed-out user instead of being
3127
+ // masked by the gate.
3128
+ function bootWorkspace() {
3129
+ loadWorkspaceData();
3130
+ // The demo bootstrap scheduled narration appends (≤720ms out) before we
3131
+ // overrode renderChat; re-render once they've all fired to wipe stragglers.
3132
+ setTimeout(() => { if (currentId) renderChat(); }, 900);
3133
+ }
3134
+
3135
+ // Subscription gate (the server enforces it on every /api/* with 402; the UI
3136
+ // mirrors it so an ungated user sees a sign-in panel, not a broken workspace).
3137
+ // We fetch NO workspace data while ungated. The license endpoints stay open.
3138
+ function renderGate(status) {
3139
+ if (document.getElementById('license-gate')) return;
3140
+ const expired = status.state === 'expired';
3141
+ // signInUrl is server-generated, but escape it anyway before it lands in an
3142
+ // innerHTML href — a crafted FLOLESS_WEB_BASE shouldn't be able to break out.
3143
+ const subUrl = escapeHtml(status.signInUrl || 'https://floless.io');
3144
+ const style = document.createElement('style');
3145
+ style.textContent = `
3146
+ #license-gate{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;
3147
+ background:var(--bg,#020817);color:var(--text,#f8fafc);font-family:var(--ui,system-ui,sans-serif);}
3148
+ #license-gate .lg-card{max-width:420px;text-align:center;padding:40px 36px;border-radius:14px;
3149
+ background:var(--surface,#0b1424);border:1px solid var(--border,#1e293b);box-shadow:0 20px 60px rgba(0,0,0,.5);}
3150
+ #license-gate .lg-mark{color:var(--accent,#3b82f6);width:54px;height:54px;margin:0 auto 18px;}
3151
+ #license-gate h1{font-size:20px;margin:0 0 8px;font-weight:600;}
3152
+ #license-gate p{font-size:14px;line-height:1.5;color:var(--text-dim,#94a3b8);margin:0 0 22px;}
3153
+ #license-gate .primary{appearance:none;border:none;cursor:pointer;font-size:14px;font-weight:600;
3154
+ padding:11px 22px;border-radius:8px;background:var(--accent,#3b82f6);color:#fff;}
3155
+ #license-gate .primary:hover{filter:brightness(1.08);}
3156
+ #license-gate .lg-status{min-height:18px;margin:14px 0 0;font-size:12.5px;color:var(--text-dim,#94a3b8);}
3157
+ #license-gate .lg-sub{display:inline-block;margin-top:6px;font-size:12.5px;color:var(--accent,#3b82f6);text-decoration:none;}
3158
+ #license-gate .lg-sub:hover{text-decoration:underline;}
3159
+ #license-gate .lg-version{margin:18px 0 0;font-size:11px;font-family:var(--mono,monospace);color:var(--text-muted,#9aa5b6);}`;
3160
+ document.head.appendChild(style);
3161
+ const wrap = document.createElement('div');
3162
+ wrap.id = 'license-gate';
3163
+ wrap.innerHTML = `
3164
+ <div class="lg-card">
3165
+ <div class="lg-mark"><svg viewBox="0 0 192 181" fill="none" xmlns="http://www.w3.org/2000/svg" width="54" height="54">
3166
+ <path d="M93.4818 26.2302L32.3762 26.2313C25.5914 26.2314 19.347 32.217 15.0271 38.0025C11.2275 43.0912 8.72093 49.9322 8.72093 56.5314C8.72093 64.7036 10.9644 71.8345 15.0271 77.7851C19.2332 83.9459 26.1884 90.7551 32.3762 90.7551H159.101C165.715 90.7551 171.878 93.8886 176.177 98.8111C180.654 103.937 183.14 111.953 183.14 118.757C183.14 126.308 181.52 131.542 177.974 137.069C174.152 143.027 159.887 151.23 159.887 151.23" stroke="currentColor" stroke-width="11.6719" stroke-linecap="round"/>
3167
+ <path d="M90.1163 8.72095L106.704 26.7965" stroke="currentColor" stroke-width="11.6712" stroke-linecap="round"/>
3168
+ <path d="M90.1163 44.9346L106.704 26.859" stroke="currentColor" stroke-width="11.6712" stroke-linecap="round"/>
3169
+ <path d="M78.5336 136.746L95.1213 154.822" stroke="currentColor" stroke-width="11.6712" stroke-linecap="round"/>
3170
+ <path d="M78.5336 172.96L95.1213 154.885" stroke="currentColor" stroke-width="11.6712" stroke-linecap="round"/>
3171
+ <path d="M61.4378 154.466H82.2457" stroke="currentColor" stroke-width="11.6712" stroke-linecap="square"/>
3172
+ <path class="mark-node" d="M33.4144 174.908C43.8527 174.908 52.3146 166.446 52.3146 156.008C52.3146 145.57 43.8527 137.108 33.4144 137.108C22.9762 137.108 14.5143 145.57 14.5143 156.008C14.5143 166.446 22.9762 174.908 33.4144 174.908Z" fill="white" stroke="currentColor" stroke-width="8.72093" stroke-linecap="round" stroke-linejoin="round"/>
3173
+ <path class="mark-node" d="M138.092 174.908C148.53 174.908 156.992 166.446 156.992 156.008C156.992 145.57 148.53 137.108 138.092 137.108C127.654 137.108 119.192 145.57 119.192 156.008C119.192 166.446 127.654 174.908 138.092 174.908Z" fill="white" stroke="currentColor" stroke-width="8.72093" stroke-linecap="round" stroke-linejoin="round"/>
3174
+ <path class="mark-node" d="M149.714 43.6142C160.152 43.6142 168.614 35.1523 168.614 24.7141C168.614 14.2758 160.152 5.81396 149.714 5.81396C139.276 5.81396 130.814 14.2758 130.814 24.7141C130.814 35.1523 139.276 43.6142 149.714 43.6142Z" fill="white" stroke="currentColor" stroke-width="8.72093" stroke-linecap="round" stroke-linejoin="round"/>
3175
+ </svg></div>
3176
+ <h1>${expired ? 'Your subscription has expired' : 'Subscription required'}</h1>
3177
+ <p>${expired
3178
+ ? 'Renew your FloLess subscription to keep using floless.app.'
3179
+ : 'floless.app runs on your FloLess subscription. Sign in to continue.'}</p>
3180
+ <button id="lg-signin" class="primary">Sign in at floless.io</button>
3181
+ <p class="lg-status" id="lg-status"></p>
3182
+ <a class="lg-sub" href="${subUrl}" target="_blank" rel="noopener">${expired ? 'Manage subscription →' : 'No subscription yet? Subscribe →'}</a>
3183
+ <p class="lg-version" id="lg-version"></p>
3184
+ </div>`;
3185
+ document.body.appendChild(wrap);
3186
+ // Show the installed build (so a stale install is self-diagnosable from the
3187
+ // gate — the highest-value spot since that's where a confused user lands).
3188
+ // /api/health is un-gated, so this works even while unlicensed.
3189
+ fetch('/api/health', { cache: 'no-store' })
3190
+ .then((r) => r.json())
3191
+ .then((h) => { if (h && h.appVersion) document.getElementById('lg-version').textContent = 'v' + h.appVersion; })
3192
+ .catch(() => { /* version is a nicety; never block the gate on it */ });
3193
+ document.getElementById('lg-signin').onclick = async () => {
3194
+ const s = document.getElementById('lg-status');
3195
+ s.textContent = 'Opening sign-in in your browser…';
3196
+ try { await api('/api/license/start', { method: 'POST' }); } catch { /* ignore */ }
3197
+ s.textContent = 'Waiting for sign-in to complete…';
3198
+ const deadline = Date.now() + 185000;
3199
+ const tick = async () => {
3200
+ let st; try { st = await api('/api/license/status'); } catch { st = {}; }
3201
+ if (st.state === 'valid' || st.state === 'offline-grace') { location.reload(); return; }
3202
+ if (Date.now() > deadline) { s.textContent = 'Timed out — click to try again.'; return; }
3203
+ setTimeout(tick, 2000);
3204
+ };
3205
+ tick();
3206
+ };
3207
+ }
3208
+
3209
+ // ── boot ──────────────────────────────────────────────────────────────────────
3210
+ // AWARE setup is orthogonal to the subscription, so surface the bootstrap state and
3211
+ // run the health poll REGARDLESS of license — and show "Setting up AWARE…" FIRST.
3212
+ // The runtime must exist before a sign-in gate or workspace is meaningful, and a
3213
+ // FAILED install must be visible (with its remediation) rather than hidden behind
3214
+ // the gate. Once AWARE is ready we route: valid subscription → workspace, else gate.
3215
+ (async () => {
3216
+ initServerStatusUI(); // overlay CSS + server-status light (license-independent)
3217
+ startHealthPoll(); // drives the bootstrap overlay via the un-gated /api/health
3218
+
3219
+ let licenseStatus = null;
3220
+ let licenseFetchFailed = false;
3221
+ try { licenseStatus = await api('/api/license/status'); }
3222
+ catch { licenseFetchFailed = true; /* server unreachable → the offline-overlay path (health poll) covers it */ }
3223
+ const licensed = !!licenseStatus &&
3224
+ (licenseStatus.state === 'valid' || licenseStatus.state === 'offline-grace');
3225
+ // SSE carries the live bootstrap ticker + workspace events, but /api/events is
3226
+ // subscription-gated — an unlicensed EventSource would 402 → a false "server
3227
+ // offline". So connect only when licensed; it's safe to connect during install
3228
+ // (the handlers no-op until a workspace is loaded).
3229
+ if (licensed) connectSse();
3230
+
3231
+ let routed = false;
3232
+ onBootstrapReady = async () => {
3233
+ if (routed) return;
3234
+ routed = true; // gate re-entry before any await
3235
+ let effectiveStatus = licenseStatus;
3236
+ let effectivelyLicensed = licensed;
3237
+ // License fetch failed during boot — could be a transient blip while
3238
+ // health later recovered. Retry once before falling through to the gate
3239
+ // so a momentary 5xx on /api/license/status doesn't strand a signed-in
3240
+ // user at "no subscription". (A genuinely-down server is covered by the
3241
+ // offline overlay driven by the health poll.)
3242
+ if (licenseFetchFailed) {
3243
+ try {
3244
+ const r = await api('/api/license/status');
3245
+ effectiveStatus = r;
3246
+ effectivelyLicensed = !!r && (r.state === 'valid' || r.state === 'offline-grace');
3247
+ // The synchronous boot path skipped connectSse() because `licensed`
3248
+ // was false. Recover live updates now that we know we're licensed —
3249
+ // otherwise the workspace would render without SSE and feel stale.
3250
+ if (effectivelyLicensed && !licensed) connectSse();
3251
+ } catch { /* still failing — fall through to gate */ }
3252
+ }
3253
+ if (effectivelyLicensed) bootWorkspace();
3254
+ else renderGate(effectiveStatus || { state: 'none' });
3255
+ };
3256
+
3257
+ // Setup-first: if AWARE isn't ready, show the overlay and DEFER routing until it
3258
+ // is (the health poll + finishBootstrapReady → onBootstrapReady do the routing).
3259
+ let h = null;
3260
+ try { h = await (await fetch('/api/health', { cache: 'no-store' })).json(); }
3261
+ catch { /* health unreachable → route now; the offline overlay handles a down server */ }
3262
+ if (h && h.bootstrap && h.bootstrap !== 'ready') {
3263
+ updateBootstrap({ status: h.bootstrap, reason: h.bootstrapReason ?? null, remediation: h.bootstrapRemediation ?? null });
3264
+ } else {
3265
+ // Health says ready — but the parallel health poll may have transiently
3266
+ // observed installing/probing between this boot's two health fetches and
3267
+ // raised the overlay. finishBootstrapReady both dismisses any raised
3268
+ // overlay (with the brief Ready dwell) AND routes via onBootstrapReady,
3269
+ // collapsing the two paths into one and avoiding "workspace painted
3270
+ // behind a stuck bootstrap card" until the next poll tick.
3271
+ finishBootstrapReady();
3272
+ }
3273
+ })();
3274
+ })();