@cordfuse/crosstalk 7.0.0-alpha.9 → 7.0.0-beta.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.
Files changed (72) hide show
  1. package/GUIDE-CLI.md +322 -0
  2. package/GUIDE-PROMPTS.md +118 -0
  3. package/LICENSE +21 -0
  4. package/README.md +402 -61
  5. package/bin/crosstalk.js +88 -54
  6. package/commands/agent.js +69 -0
  7. package/commands/auth.js +273 -0
  8. package/commands/channel.js +54 -44
  9. package/commands/chat.js +107 -71
  10. package/commands/daemon.js +120 -0
  11. package/commands/logs.js +108 -19
  12. package/commands/message.js +125 -0
  13. package/commands/server.js +153 -0
  14. package/commands/settings.js +49 -0
  15. package/commands/status.js +37 -13
  16. package/commands/token.js +136 -0
  17. package/commands/transport.js +270 -0
  18. package/commands/version.js +3 -3
  19. package/commands/workflow.js +234 -0
  20. package/deploy/crosstalk@.service +62 -0
  21. package/deploy/install.sh +82 -0
  22. package/lib/api-client.js +77 -22
  23. package/lib/credentials.js +207 -0
  24. package/lib/nativeServer.js +173 -0
  25. package/lib/resolve.js +101 -34
  26. package/package.json +27 -4
  27. package/src/activation.ts +104 -0
  28. package/src/api.ts +1716 -0
  29. package/src/auth/enforce.ts +68 -0
  30. package/src/auth/handlers.ts +266 -0
  31. package/src/auth/middleware.ts +132 -0
  32. package/src/auth/setup.ts +263 -0
  33. package/src/auth/tokens.ts +285 -0
  34. package/src/auth/users.ts +267 -0
  35. package/src/dispatch.ts +492 -0
  36. package/src/dispatchers.ts +91 -0
  37. package/src/filenames.ts +28 -0
  38. package/src/frontmatter.ts +26 -0
  39. package/src/init.ts +116 -0
  40. package/src/invoke.ts +201 -0
  41. package/src/log-buffer.ts +67 -0
  42. package/src/models.ts +283 -0
  43. package/src/resolve.ts +100 -0
  44. package/src/state.ts +190 -0
  45. package/src/stop.ts +37 -0
  46. package/src/transport.ts +243 -0
  47. package/src/web/auth-pages.ts +160 -0
  48. package/src/web/channels.ts +395 -0
  49. package/src/web/chat-page.ts +636 -0
  50. package/src/web/chat-pty.ts +254 -0
  51. package/src/web/dashboard.ts +129 -0
  52. package/src/web/layout.ts +237 -0
  53. package/src/web/stubs.ts +510 -0
  54. package/src/web/workflows.ts +490 -0
  55. package/src/workflow.ts +470 -0
  56. package/template/CLAUDE.md +10 -0
  57. package/template/CROSSTALK-VERSION +1 -0
  58. package/template/CROSSTALK.md +262 -0
  59. package/template/PROTOCOL.md +70 -0
  60. package/template/README.md +64 -0
  61. package/template/auth/.gitkeep +0 -0
  62. package/template/auth/README.md +224 -0
  63. package/template/data/crosstalk.yaml +196 -0
  64. package/template/gitignore +4 -0
  65. package/commands/down.js +0 -40
  66. package/commands/init.js +0 -243
  67. package/commands/pull.js +0 -22
  68. package/commands/replies.js +0 -40
  69. package/commands/restart.js +0 -29
  70. package/commands/rm.js +0 -109
  71. package/commands/run.js +0 -115
  72. package/commands/up.js +0 -135
@@ -0,0 +1,490 @@
1
+ // workflows.ts — workflow inspector pages.
2
+ //
3
+ // /w — list of workflows (open + completed) inferred from messages
4
+ // /w/<child> — workflow detail: marker body, PLAN.json, fanout dispatches
5
+ // + reply status, synthesis status
6
+ //
7
+ // Workflow state isn't stored as a discrete record — it's reconstructed
8
+ // each request by walking channels for `type: workflow` markers and
9
+ // inspecting the child channel's PLAN.json + dispatched messages.
10
+
11
+ import { page, escapeHtml, relativeTime, TOAST_HELPER } from './layout.js';
12
+
13
+ export interface WorkflowSummary {
14
+ /** The child-channel UUID — the natural identifier for a workflow. */
15
+ childChannelUuid: string;
16
+ /** Parent channel where the marker lives. */
17
+ parentChannelUuid: string;
18
+ parentChannelName: string | null;
19
+ markerFrom: string;
20
+ markerTimestamp: string;
21
+ /** 'pending_compile' before PLAN.json exists; 'fanout' once plan is parsed;
22
+ * 'synthesize' once all fanout replies are in; 'complete' once COMPLETE exists. */
23
+ phase: 'pending_compile' | 'fanout' | 'synthesize' | 'complete' | 'failed';
24
+ fanoutTotal: number;
25
+ fanoutReplied: number;
26
+ dispatchHost: string | null;
27
+ }
28
+
29
+ export interface WorkflowsListData {
30
+ host: string;
31
+ alias: string;
32
+ workflows: WorkflowSummary[];
33
+ /** Claimed model names (qualified `<provider>/<model>`) — compiler agent picker. */
34
+ claimedModels: string[];
35
+ /** Available parent channels for the workflow to live in. */
36
+ channels: { uuid: string; name: string | null }[];
37
+ }
38
+
39
+ function phaseBadge(p: WorkflowSummary['phase']): string {
40
+ const styles = {
41
+ pending_compile: { color: '#d29922', bg: '#574122', label: 'COMPILING' },
42
+ fanout: { color: '#7cc4ff', bg: '#2d4a66', label: 'FANOUT' },
43
+ synthesize: { color: '#a371f7', bg: '#3c2a59', label: 'SYNTHESIZE' },
44
+ complete: { color: '#7ee787', bg: '#1f4528', label: 'COMPLETE' },
45
+ failed: { color: '#f85149', bg: '#4a2329', label: 'FAILED' },
46
+ };
47
+ const s = styles[p];
48
+ return `<span style="display:inline-block;color:${s.color};background:${s.bg}33;border:1px solid ${s.bg};border-radius:10px;padding:2px 9px;font-size:10px;font-weight:600;letter-spacing:.05em">${s.label}</span>`;
49
+ }
50
+
51
+ export function workflowsListPage(d: WorkflowsListData): string {
52
+ const open = d.workflows.filter((w) => w.phase !== 'complete' && w.phase !== 'failed');
53
+ const closed = d.workflows.filter((w) => w.phase === 'complete' || w.phase === 'failed');
54
+
55
+ function renderRow(w: WorkflowSummary): string {
56
+ const parentLabel = w.parentChannelName ?? w.parentChannelUuid.slice(0, 8) + '…';
57
+ const fanout = w.phase === 'pending_compile'
58
+ ? '—'
59
+ : `${w.fanoutReplied}/${w.fanoutTotal}`;
60
+ return `<a href="/w/${encodeURIComponent(w.childChannelUuid)}" style="display:block;padding:12px 4px;border-bottom:1px solid #1f2329">
61
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
62
+ <span><span class="chip brand" style="font-size:11px">${escapeHtml(w.childChannelUuid.slice(0,8))}…</span> in <span style="color:#a371f7">${escapeHtml(parentLabel)}</span></span>
63
+ ${phaseBadge(w.phase)}
64
+ </div>
65
+ <div style="margin-top:6px;font-size:11px;color:#7a7f87">
66
+ from <span style="color:#7cc4ff">${escapeHtml(w.markerFrom)}</span> · ${escapeHtml(relativeTime(w.markerTimestamp))}
67
+ · fanout ${fanout}${w.dispatchHost ? ` · host <span style="color:#a371f7">${escapeHtml(w.dispatchHost)}</span>` : ''}
68
+ </div>
69
+ </a>`;
70
+ }
71
+
72
+ const canCompose = d.claimedModels.length > 0 && d.channels.length > 0;
73
+ const channelOptions = d.channels.length === 0
74
+ ? '<option value="" disabled>(no channels — create one first)</option>'
75
+ : d.channels.map((c) => {
76
+ const label = c.name ?? c.uuid.slice(0, 8) + '…';
77
+ return `<option value="${escapeHtml(c.name ?? c.uuid)}">${escapeHtml(label)}</option>`;
78
+ }).join('');
79
+ const compilerOptions = d.claimedModels.length === 0
80
+ ? '<option value="" disabled>(no claimed models — claim one first)</option>'
81
+ : d.claimedModels.map((m) => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join('');
82
+
83
+ const warnings: string[] = [];
84
+ if (d.claimedModels.length === 0) {
85
+ warnings.push('No models are claimed by this engine — the compiler can\'t run. Add real models (e.g. <code>claude</code>, <code>codex</code>) under <code>providers:</code> in <code>data/crosstalk.yaml</code>, then restart. Current installed CLIs are listed in <a href="/agents">/agents</a>.');
86
+ }
87
+ if (d.channels.length === 0) {
88
+ warnings.push('No channels exist yet — create one in <a href="/c">Channels</a> first.');
89
+ }
90
+ const warningBlock = warnings.length === 0 ? '' : warnings.map((w) =>
91
+ `<p style="background:#3c2a10;color:#d29922;border:1px solid #574122;border-radius:6px;padding:8px 10px;margin:0 0 12px;font-size:11px">⚠ ${w}</p>`,
92
+ ).join('');
93
+
94
+ const composeCard = `
95
+ <section class="card" id="compose-card">
96
+ <h3>Compose new workflow</h3>
97
+ <p class="sub">Describe what you want in plain English; the compiler agent turns it into a fanout/synthesize plan you can review and edit before submitting. The dispatcher then runs the plan deterministically.</p>
98
+ ${warningBlock}
99
+
100
+ <div id="compose-step-prompt">
101
+ <div class="field">
102
+ <label for="cw-prompt">prompt</label>
103
+ <textarea id="cw-prompt" rows="4" placeholder="e.g. compare how claude, codex, and gemini would refactor src/auth/users.ts to use bun's built-in crypto instead of node:crypto"></textarea>
104
+ </div>
105
+ <div class="field">
106
+ <label for="cw-compiler">compiler agent</label>
107
+ <select id="cw-compiler"${d.claimedModels.length === 0 ? ' disabled' : ''}>${compilerOptions}</select>
108
+ </div>
109
+ <div class="field">
110
+ <label for="cw-channel">target channel</label>
111
+ <select id="cw-channel"${d.channels.length === 0 ? ' disabled' : ''}>${channelOptions}</select>
112
+ </div>
113
+ <div class="actions">
114
+ <button type="button" class="primary" id="cw-compile"${!canCompose ? ' disabled title="resolve the warnings above first"' : ''}>Compile preview</button>
115
+ </div>
116
+ </div>
117
+
118
+ <div id="compose-step-preview" style="display:none">
119
+ <p class="sub">Review the compiled plan. Edit the YAML if anything looks off — common fixes: the <code>to:</code> model identity, the <code>count:</code> integer, or the <code>body:</code> instructions. On submit, the engine writes <code>PLAN.json</code> verbatim and dispatches without re-compiling.</p>
120
+ <div id="cw-summary" style="background:#0b0c10;border:1px solid #1f2329;border-radius:6px;padding:10px;margin-bottom:10px;font-size:12px"></div>
121
+ <div class="field">
122
+ <label for="cw-prompt-readback">your prompt (committed as the workflow marker body)</label>
123
+ <textarea id="cw-prompt-readback" rows="3" readonly style="font:12px ui-monospace,monospace;background:#0b0c10;color:#c9d1d9;opacity:.85;cursor:default"></textarea>
124
+ </div>
125
+ <div class="field">
126
+ <label for="cw-yaml">plan (yaml)</label>
127
+ <textarea id="cw-yaml" rows="12" spellcheck="false" style="font:12px ui-monospace,monospace"></textarea>
128
+ </div>
129
+ <div class="actions" style="justify-content:space-between">
130
+ <button type="button" id="cw-cancel">← Cancel</button>
131
+ <button type="button" class="primary" id="cw-submit">Submit workflow →</button>
132
+ </div>
133
+ </div>
134
+
135
+ <div id="compose-status" style="margin-top:10px;font-size:12px;color:#7a7f87;min-height:18px"></div>
136
+ </section>`;
137
+
138
+ const body = `${composeCard}
139
+ <section class="card">
140
+ <h3>Open workflows (${open.length})</h3>
141
+ <p class="sub">Compile → fanout → synthesize is in flight. The runtime advances these deterministically per dispatcher tick (no LLM orchestrator).</p>
142
+ ${open.length === 0
143
+ ? '<p class="empty">No open workflows yet.</p>'
144
+ : open.map(renderRow).join('')}
145
+ </section>
146
+
147
+ <section class="card">
148
+ <h3>Recent (${closed.length})</h3>
149
+ <p class="sub">Completed (synthesis replied successfully) or failed (compile error, dispatcher crash, etc.).</p>
150
+ ${closed.length === 0
151
+ ? '<p class="empty">No completed workflows yet.</p>'
152
+ : closed.slice(0, 20).map(renderRow).join('')}
153
+ </section>
154
+ `;
155
+ return page({
156
+ title: `crosstalk on ${d.host} · Workflows`,
157
+ host: d.host,
158
+ alias: d.alias,
159
+ activeNav: 'workflows',
160
+ pageTitle: 'Workflows',
161
+ body,
162
+ inlineScript: `
163
+ const promptStep = document.getElementById('compose-step-prompt');
164
+ const previewStep = document.getElementById('compose-step-preview');
165
+ const statusEl = document.getElementById('compose-status');
166
+ const yamlInput = document.getElementById('cw-yaml');
167
+ const summaryEl = document.getElementById('cw-summary');
168
+ let lastPrompt = '';
169
+ let lastChannel = '';
170
+
171
+ function setStatus(msg, isErr){
172
+ statusEl.textContent = msg || '';
173
+ statusEl.style.color = isErr ? '#f85149' : '#7a7f87';
174
+ }
175
+ function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
176
+ function renderSummary(plan){
177
+ summaryEl.innerHTML =
178
+ '<div><span style="color:#9aa0a6">fanout:</span> <span style="color:#7cc4ff">' + escapeHtml(plan.fanout.to) + '</span> &times; <strong>' + plan.fanout.count + '</strong></div>' +
179
+ '<div style="margin-top:4px"><span style="color:#9aa0a6">synthesize:</span> <span style="color:#a371f7">' + escapeHtml(plan.synthesize.to) + '</span></div>';
180
+ }
181
+
182
+ const compileBtn = document.getElementById('cw-compile');
183
+ let compileInFlight = false;
184
+ compileBtn.addEventListener('click', async () => {
185
+ // Guard against double-fire — claude's compile output is
186
+ // non-deterministic across retries, so a second click during an
187
+ // in-flight request would let the slower (failing) response
188
+ // overwrite the faster (succeeding) one. Caught 2026-06-22.
189
+ if (compileInFlight) return;
190
+ const prompt = document.getElementById('cw-prompt').value.trim();
191
+ const compiler = document.getElementById('cw-compiler').value;
192
+ const channel = document.getElementById('cw-channel').value;
193
+ if (!prompt){ setStatus('prompt required', true); return; }
194
+ lastPrompt = prompt;
195
+ lastChannel = channel;
196
+ compileInFlight = true;
197
+ compileBtn.disabled = true;
198
+ const originalLabel = compileBtn.textContent;
199
+ compileBtn.textContent = 'Compiling…';
200
+ setStatus('compiling — this may take a moment…');
201
+ try {
202
+ const r = await fetch('/api/workflows/compose', {
203
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
204
+ body: JSON.stringify({ prompt, compilerAgent: compiler }),
205
+ });
206
+ const body = await r.json().catch(() => ({}));
207
+ if (!r.ok){
208
+ setStatus(body.error || 'compile failed', true);
209
+ if (body.rawOutput) console.log('compiler raw output:', body.rawOutput);
210
+ return;
211
+ }
212
+ yamlInput.value = body.yaml;
213
+ document.getElementById('cw-prompt-readback').value = lastPrompt;
214
+ renderSummary(body.plan);
215
+ promptStep.style.display = 'none';
216
+ previewStep.style.display = 'block';
217
+ setStatus('compiled by ' + body.compiler + ' — review and edit before submitting');
218
+ } catch (e){ setStatus('error: ' + e.message, true); }
219
+ finally {
220
+ compileInFlight = false;
221
+ compileBtn.disabled = false;
222
+ compileBtn.textContent = originalLabel;
223
+ }
224
+ });
225
+
226
+ document.getElementById('cw-cancel').addEventListener('click', () => {
227
+ previewStep.style.display = 'none';
228
+ promptStep.style.display = 'block';
229
+ setStatus('');
230
+ });
231
+
232
+ const submitBtn = document.getElementById('cw-submit');
233
+ const cancelBtn = document.getElementById('cw-cancel');
234
+ let submitInFlight = false;
235
+ submitBtn.addEventListener('click', async () => {
236
+ // Guard against double-fire — workflow submit triggers a git commit
237
+ // + remote push retry chain (~10s on a misconfigured remote). Two
238
+ // clicks would create duplicate workflow markers + duplicate child
239
+ // channels.
240
+ if (submitInFlight) return;
241
+ const yaml = yamlInput.value;
242
+ if (!yaml.trim()){ setStatus('yaml is empty', true); return; }
243
+ submitInFlight = true;
244
+ submitBtn.disabled = true;
245
+ cancelBtn.disabled = true;
246
+ yamlInput.readOnly = true;
247
+ const originalLabel = submitBtn.textContent;
248
+ submitBtn.textContent = 'Submitting…';
249
+ setStatus('submitting — committing workflow marker + PLAN.json…');
250
+ try {
251
+ const r = await fetch('/api/workflows/submit', {
252
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
253
+ body: JSON.stringify({ yaml, channel: lastChannel, prompt: lastPrompt }),
254
+ });
255
+ const body = await r.json().catch(() => ({}));
256
+ if (!r.ok){
257
+ setStatus(body.error || 'submit failed', true);
258
+ submitInFlight = false;
259
+ submitBtn.disabled = false;
260
+ cancelBtn.disabled = false;
261
+ yamlInput.readOnly = false;
262
+ submitBtn.textContent = originalLabel;
263
+ return;
264
+ }
265
+ // Success — navigate to the new workflow detail page. We do NOT
266
+ // re-enable the button here; the page is about to unload.
267
+ location.href = '/w/' + encodeURIComponent(body.childUuid);
268
+ } catch (e){
269
+ setStatus('error: ' + e.message, true);
270
+ submitInFlight = false;
271
+ submitBtn.disabled = false;
272
+ cancelBtn.disabled = false;
273
+ yamlInput.readOnly = false;
274
+ submitBtn.textContent = originalLabel;
275
+ }
276
+ });
277
+ `,
278
+ });
279
+ }
280
+
281
+ export interface WorkflowDetailData {
282
+ host: string;
283
+ alias: string;
284
+ childUuid: string;
285
+ parentChannelUuid: string;
286
+ parentChannelName: string | null;
287
+ markerFrom: string;
288
+ markerTimestamp: string;
289
+ markerBody: string;
290
+ plan: unknown | null; // PLAN.json contents (any shape; renders pretty)
291
+ phase: WorkflowSummary['phase'];
292
+ fanoutDispatches: { relPath: string; to: string; body: string }[];
293
+ fanoutReplies: { relPath: string; from: string; re: string | string[]; body: string; failed: boolean }[];
294
+ synthesisDispatch: { relPath: string; to: string; body: string } | null;
295
+ synthesisReply: { relPath: string; from: string; body: string; failed: boolean } | null;
296
+ dispatchHost: string | null;
297
+ }
298
+
299
+ export function workflowDetailPage(d: WorkflowDetailData): string {
300
+ const parentLabel = d.parentChannelName ?? d.parentChannelUuid.slice(0, 8) + '…';
301
+ const planJson = d.plan ? JSON.stringify(d.plan, null, 2) : null;
302
+
303
+ const fanoutRows = d.fanoutDispatches.map((dispatch) => {
304
+ const reply = d.fanoutReplies.find((r) => {
305
+ const re = Array.isArray(r.re) ? r.re : [r.re];
306
+ return re.includes(dispatch.relPath);
307
+ });
308
+ const status = reply
309
+ ? (reply.failed ? '<span style="color:#f85149">FAILED</span>' : '<span style="color:#7ee787">REPLIED</span>')
310
+ : '<span style="color:#d29922">PENDING</span>';
311
+ const replyFrom = reply ? `· from <span style="color:#7cc4ff">${escapeHtml(reply.from)}</span>` : '';
312
+ return `<div class="msg" style="margin-bottom:8px">
313
+ <div class="msg-head">
314
+ <span class="msg-route"><span class="msg-from">workflow</span><span class="msg-arrow">→</span><span class="msg-to">${escapeHtml(dispatch.to)}</span></span>
315
+ <span class="msg-id">${escapeHtml(dispatch.relPath.split('/').pop() || '')}</span>
316
+ </div>
317
+ <div style="margin-bottom:6px;font-size:11px">${status} ${replyFrom}</div>
318
+ <div class="msg-body">${escapeHtml(dispatch.body)}</div>
319
+ ${reply ? `<div style="margin-top:8px;padding-top:8px;border-top:1px solid #1f2329"><div style="font-size:10px;color:#7a7f87;margin-bottom:4px;text-transform:uppercase">REPLY</div><div class="msg-body">${escapeHtml(reply.body)}</div></div>` : ''}
320
+ </div>`;
321
+ }).join('');
322
+
323
+ const isDone = d.phase === 'complete' || d.phase === 'failed';
324
+ const completionBanner = d.phase === 'complete'
325
+ ? `<section style="background:#0f2a17;color:#7ee787;border:1px solid #1f4528;border-radius:8px;padding:14px 16px;margin-bottom:14px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
326
+ <div>
327
+ <strong style="font-size:14px">✓ Workflow complete</strong>
328
+ <div style="font-size:11px;color:#9aa0a6;margin-top:2px">Synthesis reply is ready below.</div>
329
+ </div>
330
+ <button type="button" id="scroll-to-synthesis" class="primary" style="color:#7ee787;border-color:#1f4528">Jump to synthesis ↓</button>
331
+ </section>`
332
+ : d.phase === 'failed'
333
+ ? `<section style="background:#2a1717;color:#f85149;border:1px solid #4a2329;border-radius:8px;padding:14px 16px;margin-bottom:14px">
334
+ <strong style="font-size:14px">✗ Workflow failed</strong>
335
+ <div style="font-size:11px;color:#9aa0a6;margin-top:2px">Check the fanout or synthesis sections below for the failed reply.</div>
336
+ </section>`
337
+ : '';
338
+
339
+ const refreshIndicator = isDone
340
+ ? '<span style="color:#7a7f87">live · stopped</span>'
341
+ : '<span style="color:#7a7f87">refresh in <span id="refresh-countdown">8</span>s · <button type="button" id="refresh-now" style="background:transparent;border:none;color:#7cc4ff;cursor:pointer;font:11px ui-monospace,monospace;padding:0;text-decoration:underline">refresh now</button></span>';
342
+
343
+ const body = `
344
+ ${completionBanner}
345
+ <section class="card">
346
+ <h3 style="display:flex;align-items:center;justify-content:space-between">
347
+ <span>Workflow ${escapeHtml(d.childUuid.slice(0,8))}…</span>
348
+ ${phaseBadge(d.phase)}
349
+ </h3>
350
+ <div class="kv"><span class="key">parent channel</span><span class="val brand"><a href="/c/${encodeURIComponent(d.parentChannelName ?? d.parentChannelUuid)}">${escapeHtml(parentLabel)}</a></span></div>
351
+ <div class="kv"><span class="key">child channel</span><span class="val dim">${escapeHtml(d.childUuid)}</span></div>
352
+ <div class="kv"><span class="key">from</span><span class="val">${escapeHtml(d.markerFrom)}</span></div>
353
+ <div class="kv"><span class="key">dispatched</span><span class="val">${escapeHtml(relativeTime(d.markerTimestamp))}</span></div>
354
+ ${d.dispatchHost ? `<div class="kv"><span class="key">dispatch host</span><span class="val">${escapeHtml(d.dispatchHost)}</span></div>` : ''}
355
+ <div class="kv"><span class="key">progress</span><span class="val">${refreshIndicator}${isDone ? '' : ' · <button type="button" id="enable-notifs" style="background:transparent;border:none;color:#7cc4ff;cursor:pointer;font:11px ui-monospace,monospace;padding:0;text-decoration:underline">notify when done</button>'}</span></div>
356
+ </section>
357
+
358
+ <section class="card">
359
+ <h3>Marker body</h3>
360
+ <p class="sub">The prose description the operator submitted. The compile phase reads this + a system prompt to produce the JSON plan.</p>
361
+ <div class="msg-body">${escapeHtml(d.markerBody)}</div>
362
+ </section>
363
+
364
+ <section class="card">
365
+ <h3>Plan${planJson ? '' : ' (not yet compiled)'}</h3>
366
+ ${planJson
367
+ ? `<div class="msg-body" style="max-height:none">${escapeHtml(planJson)}</div>`
368
+ : '<p class="empty">Compile phase has not completed. Once an LLM produces a valid JSON plan, it lands at <code>data/channels/' + escapeHtml(d.childUuid.slice(0,8)) + '…/PLAN.json</code>.</p>'}
369
+ </section>
370
+
371
+ <section class="card">
372
+ <h3>Fanout (${d.fanoutDispatches.length} dispatches · ${d.fanoutReplies.length} replies)</h3>
373
+ ${d.fanoutDispatches.length === 0
374
+ ? '<p class="empty">No fanout sub-primitives yet. Will fire once the plan is parsed.</p>'
375
+ : fanoutRows}
376
+ </section>
377
+
378
+ <section class="card">
379
+ <h3>Synthesis</h3>
380
+ ${!d.synthesisDispatch
381
+ ? '<p class="empty">Synthesis dispatch will fire once all fanout replies are in.</p>'
382
+ : `<div class="msg">
383
+ <div class="msg-head">
384
+ <span class="msg-route"><span class="msg-from">workflow</span><span class="msg-arrow">→</span><span class="msg-to">${escapeHtml(d.synthesisDispatch.to)}</span></span>
385
+ </div>
386
+ <div class="msg-body">${escapeHtml(d.synthesisDispatch.body)}</div>
387
+ ${d.synthesisReply
388
+ ? `<div style="margin-top:8px;padding-top:8px;border-top:1px solid #1f2329"><div style="font-size:10px;color:${d.synthesisReply.failed ? '#f85149' : '#7ee787'};margin-bottom:4px;text-transform:uppercase">${d.synthesisReply.failed ? 'FAILED REPLY' : 'SYNTHESIS REPLY'} · ${escapeHtml(d.synthesisReply.from)}</div><div class="msg-body">${escapeHtml(d.synthesisReply.body)}</div></div>`
389
+ : '<div style="margin-top:8px;color:#d29922;font-size:11px">awaiting synthesis reply</div>'}
390
+ </div>`}
391
+ </section>
392
+ `;
393
+ return page({
394
+ title: `crosstalk · workflow ${d.childUuid.slice(0,8)}`,
395
+ host: d.host,
396
+ alias: d.alias,
397
+ activeNav: 'workflows',
398
+ pageTitle: `Workflow ${d.childUuid.slice(0,8)}…`,
399
+ body,
400
+ inlineScript: `${TOAST_HELPER}
401
+ const PHASE = ${JSON.stringify(d.phase)};
402
+ const CHILD_UUID = ${JSON.stringify(d.childUuid)};
403
+ const SYNTHESIS_BODY = ${JSON.stringify(d.synthesisReply?.body ?? '')};
404
+
405
+ // 1. Tab title carries the phase so it's visible in the tab list /
406
+ // desktop notification area even when you're not on this tab.
407
+ const phaseLabel = {
408
+ pending_compile: 'COMPILING',
409
+ fanout: 'FANOUT',
410
+ synthesize: 'SYNTHESIZE',
411
+ complete: '✓ COMPLETE',
412
+ failed: '✗ FAILED',
413
+ }[PHASE] || PHASE;
414
+ document.title = '[' + phaseLabel + '] workflow ' + CHILD_UUID.slice(0, 8);
415
+
416
+ // 2. Browser notification when phase lands on complete/failed.
417
+ // Stored across reloads via sessionStorage so we notify exactly
418
+ // once per page-visit-that-saw-the-transition.
419
+ function maybeNotifyComplete(){
420
+ if (PHASE !== 'complete' && PHASE !== 'failed') return;
421
+ const key = 'crosstalk_notified_' + CHILD_UUID;
422
+ if (sessionStorage.getItem(key)) return;
423
+ if (!('Notification' in window)) return;
424
+ if (Notification.permission !== 'granted') return;
425
+ sessionStorage.setItem(key, '1');
426
+ const title = PHASE === 'complete' ? 'Workflow complete' : 'Workflow failed';
427
+ const body = PHASE === 'complete' && SYNTHESIS_BODY
428
+ ? SYNTHESIS_BODY.slice(0, 140) + (SYNTHESIS_BODY.length > 140 ? '…' : '')
429
+ : 'Workflow ' + CHILD_UUID.slice(0, 8) + ' · tap to open';
430
+ try {
431
+ const n = new Notification(title, { body, tag: 'crosstalk-' + CHILD_UUID });
432
+ n.onclick = () => { window.focus(); n.close(); };
433
+ } catch (e) { /* notification spawn failed; harmless */ }
434
+ }
435
+ maybeNotifyComplete();
436
+
437
+ // 3. "Enable notifications" button. One-time permission grant.
438
+ const enableBtn = document.getElementById('enable-notifs');
439
+ if (enableBtn){
440
+ if (!('Notification' in window)){ enableBtn.style.display = 'none'; }
441
+ else if (Notification.permission === 'granted'){ enableBtn.textContent = 'notifications on ✓'; enableBtn.disabled = true; }
442
+ else if (Notification.permission === 'denied'){ enableBtn.textContent = 'notifications blocked'; enableBtn.disabled = true; }
443
+ else {
444
+ enableBtn.addEventListener('click', async () => {
445
+ try {
446
+ const p = await Notification.requestPermission();
447
+ if (p === 'granted'){ enableBtn.textContent = 'notifications on ✓'; enableBtn.disabled = true; }
448
+ else { enableBtn.textContent = 'notifications denied'; enableBtn.disabled = true; }
449
+ } catch(_) {}
450
+ });
451
+ }
452
+ }
453
+
454
+ // 4. Auto-refresh countdown (1s tick) — replaces the silent setTimeout
455
+ // so the operator knows the page is live + how long until next poll.
456
+ // Includes "refresh now" button for impatient operators.
457
+ if (PHASE !== 'complete' && PHASE !== 'failed') {
458
+ let secondsLeft = 8;
459
+ const cd = document.getElementById('refresh-countdown');
460
+ const tick = setInterval(() => {
461
+ secondsLeft -= 1;
462
+ if (cd) cd.textContent = String(Math.max(0, secondsLeft));
463
+ if (secondsLeft <= 0){
464
+ clearInterval(tick);
465
+ location.reload();
466
+ }
467
+ }, 1000);
468
+ const refreshBtn = document.getElementById('refresh-now');
469
+ if (refreshBtn) refreshBtn.addEventListener('click', () => { clearInterval(tick); location.reload(); });
470
+ }
471
+
472
+ // 5. Completion banner button: jump to synthesis card.
473
+ const scrollBtn = document.getElementById('scroll-to-synthesis');
474
+ if (scrollBtn){
475
+ scrollBtn.addEventListener('click', () => {
476
+ const cards = document.querySelectorAll('.card h3');
477
+ for (const h of cards){
478
+ if (h.textContent && h.textContent.trim().toLowerCase() === 'synthesis'){
479
+ h.closest('.card').scrollIntoView({ behavior: 'smooth', block: 'start' });
480
+ return;
481
+ }
482
+ }
483
+ });
484
+ // Auto-scroll on first paint so the synthesis reply is immediately
485
+ // visible without the operator hunting for it.
486
+ requestAnimationFrame(() => scrollBtn.click());
487
+ }
488
+ `,
489
+ });
490
+ }