@mjasnikovs/pi-task 0.13.6 → 0.13.8

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 (43) hide show
  1. package/dist/config/register.js +1 -1
  2. package/dist/remote/push.d.ts +12 -3
  3. package/dist/remote/push.js +63 -9
  4. package/dist/remote/register.js +7 -3
  5. package/dist/remote/server.d.ts +4 -2
  6. package/dist/remote/server.js +7 -3
  7. package/dist/remote/tailscale.d.ts +8 -2
  8. package/dist/remote/tailscale.js +13 -6
  9. package/dist/remote/ui-script.d.ts +3 -0
  10. package/dist/remote/ui-script.js +804 -0
  11. package/dist/remote/ui-styles.d.ts +1 -0
  12. package/dist/remote/ui-styles.js +202 -0
  13. package/dist/remote/ui.js +4 -1000
  14. package/dist/shared/child-process.d.ts +27 -0
  15. package/dist/shared/child-process.js +151 -139
  16. package/dist/task/auto-orchestrator.js +43 -13
  17. package/dist/task/auto-prompts.d.ts +4 -3
  18. package/dist/task/auto-prompts.js +9 -6
  19. package/dist/task/child-runner.js +1 -1
  20. package/dist/task/context-usage.d.ts +16 -0
  21. package/dist/task/context-usage.js +22 -0
  22. package/dist/task/external-context.d.ts +27 -0
  23. package/dist/task/external-context.js +93 -0
  24. package/dist/task/failure-classifier.js +1 -1
  25. package/dist/task/orchestrator.js +7 -13
  26. package/dist/task/parsers.d.ts +4 -15
  27. package/dist/task/parsers.js +48 -87
  28. package/dist/task/phases.d.ts +5 -7
  29. package/dist/task/phases.js +29 -84
  30. package/dist/task/prompts.d.ts +1 -0
  31. package/dist/task/prompts.js +9 -0
  32. package/dist/task/spec-validation.d.ts +23 -0
  33. package/dist/task/spec-validation.js +90 -0
  34. package/dist/task/widget.d.ts +1 -1
  35. package/dist/task/widget.js +1 -1
  36. package/dist/workers/html-clean.js +7 -4
  37. package/dist/workers/pi-worker-docs.js +69 -58
  38. package/dist/workers/pi-worker-fetch.js +25 -21
  39. package/dist/workers/pi-worker-search.js +7 -13
  40. package/dist/workers/pi-worker.js +8 -14
  41. package/dist/workers/shared.d.ts +40 -0
  42. package/dist/workers/shared.js +31 -0
  43. package/package.json +1 -1
@@ -0,0 +1,804 @@
1
+ /** The remote web client script (vanilla JS shipped as a string).
2
+ * `wsUrl` is the LAN/Tailscale fallback baked in as FALLBACK_WS_URL. */
3
+ export function clientScript(wsUrl) {
4
+ return ` // Connect the WebSocket back to whatever host served this page (LAN or
5
+ // Tailscale), not a server-baked IP — otherwise opening the LAN URL on a
6
+ // non-Tailscale device tries to reach the Tailscale IP and hangs. Fall back
7
+ // to the server-provided URL only if location is somehow unavailable.
8
+ const FALLBACK_WS_URL = ${JSON.stringify(wsUrl)};
9
+ const WS_URL = (location && location.host)
10
+ ? (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'
11
+ : FALLBACK_WS_URL;
12
+ const chatLog = document.getElementById('chat-log');
13
+ const inputEl = document.getElementById('input');
14
+ const sendBtn = document.getElementById('send');
15
+ const contextFill = document.getElementById('context-bar-fill');
16
+ function setContextBar(usage) {
17
+ if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
18
+ }
19
+ const reconnectOverlay = document.getElementById('reconnect-overlay');
20
+ const reconnectMsg = document.getElementById('reconnect-msg');
21
+ const cmdSuggestions = document.getElementById('cmd-suggestions');
22
+ const statusPanel = document.getElementById('status-panel');
23
+ // Widgets are keyed (e.g. 'pi-tasks', 'pi-task-auto'); track them per key so a
24
+ // clear for one key can't be masked by a stale message from another.
25
+ // Single authoritative task-widget slot. The snapshot and the live 'widget'
26
+ // delta both set this; null hides the panel. (No more per-key map that could
27
+ // strand an orphaned widget on screen.)
28
+ let taskWidgetLines = null;
29
+ function renderWidgets() {
30
+ if (taskWidgetLines && taskWidgetLines.length) {
31
+ statusPanel.textContent = taskWidgetLines.join('\\n');
32
+ statusPanel.style.display = 'block';
33
+ } else {
34
+ statusPanel.style.display = 'none';
35
+ }
36
+ }
37
+ const promptCard = document.getElementById('prompt-card');
38
+ const promptQ = document.getElementById('prompt-q');
39
+ const promptRec = document.getElementById('prompt-rec');
40
+ const promptRecText = document.getElementById('prompt-rec-text');
41
+ const promptInput = document.getElementById('prompt-input');
42
+ const promptButtons = document.getElementById('prompt-buttons');
43
+ const viewer = document.getElementById('viewer');
44
+ const viewerBody = document.getElementById('viewer-body');
45
+ document.getElementById('viewer-close').onclick = function () { viewer.style.display = 'none'; };
46
+ let activePromptId = null;
47
+ let activeRecommended = '';
48
+ let activeRecommended2 = '';
49
+ let cancelArmTimer = null;
50
+ const toolCallMap = {};
51
+ let currentBubble = null;
52
+ let streamText = '';
53
+ let autoScroll = true;
54
+ let reconnectDelay = 1000;
55
+ let reconnectAnim = null;
56
+ let ws = null;
57
+
58
+ const BT = String.fromCharCode(96);
59
+ const JS_LANGS = new Set(['js','jsx','mjs','cjs','javascript','ts','tsx','typescript']);
60
+ const JS_KW = new Set(['break','case','catch','class','const','continue','debugger',
61
+ 'default','delete','do','else','export','extends','finally','for','from','function',
62
+ 'if','import','in','instanceof','let','new','of','return','static','super','switch',
63
+ 'this','throw','try','typeof','var','void','while','with','yield','async','await',
64
+ 'type','interface','enum','implements','abstract','as','declare','namespace',
65
+ 'readonly','undefined','null','true','false','override','satisfies']);
66
+
67
+ function escHtml(s) {
68
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
69
+ }
70
+
71
+ function syntaxHighlight(code, lang) {
72
+ if (!JS_LANGS.has((lang || '').toLowerCase())) return escHtml(code);
73
+ let r = '', i = 0;
74
+ while (i < code.length) {
75
+ const ch = code[i];
76
+ // Template literal
77
+ if (ch === BT) {
78
+ let j = i + 1;
79
+ while (j < code.length) {
80
+ if (code[j] === '\\\\') { j += 2; continue; }
81
+ if (code[j] === BT) { j++; break; }
82
+ j++;
83
+ }
84
+ r += '<span class="hl-str">' + escHtml(code.slice(i, j)) + '</span>';
85
+ i = j; continue;
86
+ }
87
+ // Single / double quoted string
88
+ if (ch === '"' || ch === "'") {
89
+ let j = i + 1;
90
+ while (j < code.length) {
91
+ if (code[j] === '\\\\') { j += 2; continue; }
92
+ if (code[j] === ch || code[j] === '\\n') break;
93
+ j++;
94
+ }
95
+ if (code[j] === ch) j++;
96
+ r += '<span class="hl-str">' + escHtml(code.slice(i, j)) + '</span>';
97
+ i = j; continue;
98
+ }
99
+ // Line comment
100
+ if (ch === '/' && code[i + 1] === '/') {
101
+ let j = i + 2;
102
+ while (j < code.length && code[j] !== '\\n') j++;
103
+ r += '<span class="hl-cmt">' + escHtml(code.slice(i, j)) + '</span>';
104
+ i = j; continue;
105
+ }
106
+ // Block comment
107
+ if (ch === '/' && code[i + 1] === '*') {
108
+ let j = i + 2;
109
+ while (j < code.length && !(code[j] === '*' && code[j + 1] === '/')) j++;
110
+ j += 2;
111
+ r += '<span class="hl-cmt">' + escHtml(code.slice(i, j)) + '</span>';
112
+ i = j; continue;
113
+ }
114
+ // Number
115
+ if (ch >= '0' && ch <= '9') {
116
+ let j = i;
117
+ if (code[i] === '0' && /[xXoObB]/.test(code[i + 1] || '')) {
118
+ j += 2; while (j < code.length && /[0-9a-fA-F_]/.test(code[j])) j++;
119
+ } else {
120
+ while (j < code.length && (code[j] >= '0' && code[j] <= '9' || code[j] === '_')) j++;
121
+ if (code[j] === '.') { j++; while (j < code.length && code[j] >= '0' && code[j] <= '9') j++; }
122
+ if (code[j] === 'e' || code[j] === 'E') {
123
+ j++; if (code[j] === '+' || code[j] === '-') j++;
124
+ while (j < code.length && code[j] >= '0' && code[j] <= '9') j++;
125
+ }
126
+ if (code[j] === 'n') j++;
127
+ }
128
+ r += '<span class="hl-num">' + escHtml(code.slice(i, j)) + '</span>';
129
+ i = j; continue;
130
+ }
131
+ // Identifier / keyword / function call
132
+ if (/[a-zA-Z_$]/.test(ch)) {
133
+ let j = i;
134
+ while (j < code.length && /[a-zA-Z0-9_$]/.test(code[j])) j++;
135
+ const word = code.slice(i, j);
136
+ if (JS_KW.has(word)) {
137
+ r += '<span class="hl-kw">' + word + '</span>';
138
+ } else if (code[j] === '(') {
139
+ r += '<span class="hl-fn">' + escHtml(word) + '</span>';
140
+ } else {
141
+ r += escHtml(word);
142
+ }
143
+ i = j; continue;
144
+ }
145
+ r += escHtml(ch); i++;
146
+ }
147
+ return r;
148
+ }
149
+
150
+ function setContent(el, text) {
151
+ el.innerHTML = '';
152
+ const BT3 = BT + BT + BT;
153
+ const re = new RegExp(BT3 + '([^\\n' + BT + ']*)\\n([\\s\\S]*?)' + BT3, 'g');
154
+ let last = 0, m;
155
+ while ((m = re.exec(text)) !== null) {
156
+ if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index)));
157
+ const lang = m[1].trim();
158
+ const code = m[2];
159
+ const wrap = document.createElement('div');
160
+ wrap.className = 'code-block';
161
+ if (lang) { const lb = document.createElement('div'); lb.className = 'code-lang'; lb.textContent = lang; wrap.appendChild(lb); }
162
+ const pre = document.createElement('pre');
163
+ const codeEl = document.createElement('code');
164
+ codeEl.innerHTML = syntaxHighlight(code, lang);
165
+ pre.appendChild(codeEl);
166
+ wrap.appendChild(pre);
167
+ el.appendChild(wrap);
168
+ last = m.index + m[0].length;
169
+ }
170
+ if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
171
+ }
172
+
173
+ const COMMANDS = [
174
+ { name: '/task', desc: 'Start a new task' },
175
+ { name: '/task-list', desc: 'List tasks in this project' },
176
+ { name: '/task-resume', desc: 'Resume a task' },
177
+ { name: '/task-cancel', desc: 'Cancel the currently running task' },
178
+ { name: '/task-auto', desc: 'Plan a feature into tasks and run them' },
179
+ { name: '/task-auto-resume', desc: 'Resume the active /task-auto run' },
180
+ { name: '/task-auto-cancel', desc: 'Stop the running /task-auto loop after the current task' },
181
+ { name: '/new', desc: 'Start a new session' },
182
+ { name: '/clear', desc: 'Clear the conversation' },
183
+ { name: '/compact', desc: 'Compact context to save tokens' },
184
+ { name: '/help', desc: 'Show available commands' },
185
+ { name: '/fast', desc: 'Toggle fast mode' },
186
+ { name: '/remote stop', desc: 'Stop the remote server' },
187
+ ];
188
+ let cmdActive = [];
189
+ let cmdIndex = -1;
190
+
191
+ function renderSuggestions() {
192
+ if (cmdActive.length === 0) { cmdSuggestions.style.display = 'none'; return; }
193
+ cmdSuggestions.style.display = 'block';
194
+ cmdSuggestions.innerHTML = '';
195
+ cmdActive.forEach((cmd, i) => {
196
+ const el = document.createElement('div');
197
+ el.className = 'cmd-item' + (i === cmdIndex ? ' active' : '');
198
+ el.innerHTML = '<span class="cmd-name">' + cmd.name + '</span><span class="cmd-desc">' + cmd.desc + '</span>';
199
+ el.addEventListener('mousedown', (e) => { e.preventDefault(); pickCmd(i); });
200
+ cmdSuggestions.appendChild(el);
201
+ });
202
+ }
203
+
204
+ function updateSuggestions() {
205
+ const val = inputEl.value;
206
+ if (!val.startsWith('/')) { cmdActive = []; cmdIndex = -1; renderSuggestions(); return; }
207
+ cmdActive = COMMANDS.filter(c => c.name.startsWith(val));
208
+ cmdIndex = cmdActive.length === 1 ? 0 : -1;
209
+ renderSuggestions();
210
+ }
211
+
212
+ function pickCmd(i) {
213
+ if (!cmdActive[i]) return;
214
+ inputEl.value = cmdActive[i].name + ' ';
215
+ cmdActive = []; cmdIndex = -1; renderSuggestions();
216
+ inputEl.focus();
217
+ }
218
+
219
+ chatLog.addEventListener('scroll', () => {
220
+ const { scrollTop, scrollHeight, clientHeight } = chatLog;
221
+ autoScroll = scrollTop + clientHeight >= scrollHeight - 24;
222
+ });
223
+
224
+ function scrollBottom() { if (autoScroll) chatLog.scrollTop = chatLog.scrollHeight; }
225
+
226
+ function addBubble(role, text) {
227
+ const el = document.createElement('div');
228
+ el.className = 'bubble ' + role;
229
+ setContent(el, text);
230
+ chatLog.appendChild(el);
231
+ scrollBottom();
232
+ return el;
233
+ }
234
+
235
+ let thinkingEl = null;
236
+ let spinTimer = null;
237
+ let spinIdx = 0;
238
+ const SPIN = '\\u280B\\u2819\\u2839\\u2838\\u283C\\u2834\\u2826\\u2827\\u2807\\u280F';
239
+ // One braille ticker drives every '.spin' element — the thinking bubble AND the
240
+ // trailing stream cursor — so they share the same frame and look identical.
241
+ function spinPaint() {
242
+ const g = SPIN[spinIdx % SPIN.length];
243
+ const els = document.getElementsByClassName('spin');
244
+ for (let i = 0; i < els.length; i++) els[i].textContent = g;
245
+ }
246
+ function startSpin() {
247
+ spinPaint();
248
+ if (spinTimer) return;
249
+ spinTimer = setInterval(function () {
250
+ spinIdx = (spinIdx + 1) % SPIN.length;
251
+ spinPaint();
252
+ }, 90);
253
+ }
254
+ // Stop the ticker once nothing on screen needs spinning.
255
+ function stopSpinIfIdle() {
256
+ if (spinTimer && !document.querySelector('.spin')) {
257
+ clearInterval(spinTimer); spinTimer = null;
258
+ }
259
+ }
260
+ function showThinking() {
261
+ if (!thinkingEl) {
262
+ thinkingEl = document.createElement('div');
263
+ thinkingEl.className = 'bubble assistant thinking';
264
+ thinkingEl.innerHTML = '<span class="spinner spin"></span>';
265
+ }
266
+ chatLog.appendChild(thinkingEl); // append (or move) to bottom
267
+ startSpin();
268
+ scrollBottom();
269
+ }
270
+ function hideThinking() {
271
+ if (thinkingEl) thinkingEl.remove();
272
+ stopSpinIfIdle();
273
+ }
274
+
275
+ function addToolCall(toolName, argsStr, isError) {
276
+ // argsStr can be undefined (no args / JSON.stringify(undefined)); don't let
277
+ // that render as the literal "name: undefined" in the collapsed summary.
278
+ const label = (toolName + (argsStr ? ': ' + argsStr : '')).slice(0, 64);
279
+ const d = document.createElement('details');
280
+ d.className = 'tool-call' + (isError ? ' error' : '');
281
+ const s = document.createElement('summary');
282
+ s.textContent = label;
283
+ d.appendChild(s);
284
+ chatLog.appendChild(d);
285
+ scrollBottom();
286
+ return d;
287
+ }
288
+
289
+ function setEnabled(on) {
290
+ const allow = on && activePromptId === null;
291
+ inputEl.disabled = !allow;
292
+ sendBtn.disabled = !allow;
293
+ }
294
+
295
+ // Stringify a tool result safely. A null/undefined result (e.g. a tool that
296
+ // hasn't produced output) must NOT become the JS value undefined, whose
297
+ // .slice() throws — a throw here aborts the whole snapshot rebuild after the
298
+ // log was already cleared, blanking the transcript on reconnect.
299
+ function toolResultText(result) {
300
+ if (result == null) return '';
301
+ const r = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
302
+ return (r == null ? '' : r).slice(0, 8000);
303
+ }
304
+
305
+ // Render one tool part from the ordered parts list (running or finished).
306
+ function renderToolPart(p) {
307
+ const argsStr = typeof p.args === 'string' ? p.args : JSON.stringify(p.args);
308
+ const d = addToolCall(p.toolName, argsStr, p.isError);
309
+ if (p.done) {
310
+ const pre = document.createElement('pre');
311
+ pre.textContent = toolResultText(p.result);
312
+ d.appendChild(pre);
313
+ } else {
314
+ const sp = document.createElement('span');
315
+ sp.className = 'tool-spin spin';
316
+ d.querySelector('summary').appendChild(sp);
317
+ startSpin();
318
+ toolCallMap[p.toolCallId] = d;
319
+ }
320
+ return d;
321
+ }
322
+
323
+ // A muted, centered system note (e.g. "Context compacted").
324
+ function addSystemLine(text) {
325
+ const el = document.createElement('div');
326
+ el.className = 'sysnote';
327
+ el.textContent = text;
328
+ chatLog.appendChild(el);
329
+ scrollBottom();
330
+ return el;
331
+ }
332
+
333
+ // Render one committed transcript turn. Assistant turns are an ordered list of
334
+ // parts (text segments + tool calls), so the layout matches the terminal's
335
+ // interleaving instead of one merged blob with tools dumped at the end.
336
+ function renderTurn(t) {
337
+ if (t.error) { addBubble('error', t.text); return; }
338
+ if (t.role === 'system') { addSystemLine(t.text); return; }
339
+ if (t.role === 'user') { addBubble('user', t.text); return; }
340
+ for (const p of (t.parts || [])) {
341
+ if (p.kind === 'text') { if (p.text) addBubble('assistant', p.text); }
342
+ else renderToolPart(p);
343
+ }
344
+ }
345
+
346
+ // Render the in-progress assistant turn from a snapshot, preserving order. The
347
+ // trailing OPEN text segment becomes the live streaming bubble (cursor + spin)
348
+ // so subsequent text_delta frames keep flowing into it.
349
+ function renderLiveTurn(live) {
350
+ const parts = live.parts || [];
351
+ for (let i = 0; i < parts.length; i++) {
352
+ const p = parts[i];
353
+ const last = i === parts.length - 1;
354
+ if (p.kind === 'text') {
355
+ if (last && live.textOpen) {
356
+ currentBubble = document.createElement('div');
357
+ currentBubble.className = 'bubble assistant';
358
+ const cursor = document.createElement('span');
359
+ cursor.className = 'cursor spin';
360
+ currentBubble.appendChild(cursor);
361
+ if (p.text) currentBubble.insertBefore(document.createTextNode(p.text), cursor);
362
+ chatLog.appendChild(currentBubble);
363
+ streamText = p.text || '';
364
+ startSpin();
365
+ scrollBottom();
366
+ } else if (p.text) {
367
+ addBubble('assistant', p.text);
368
+ }
369
+ } else {
370
+ renderToolPart(p);
371
+ }
372
+ }
373
+ }
374
+
375
+ function showToast(message, level) {
376
+ const t = document.createElement('div');
377
+ t.className = 'toast ' + (level || 'info');
378
+ t.textContent = message;
379
+ document.body.appendChild(t);
380
+ setTimeout(function () { t.remove(); }, 4000);
381
+ }
382
+
383
+ const bell = document.getElementById('bell');
384
+ const NOTIFY_KEY = 'piRemoteNotify';
385
+
386
+ function notifyEnabled() {
387
+ return localStorage.getItem(NOTIFY_KEY) === '1'
388
+ && typeof Notification !== 'undefined'
389
+ && Notification.permission === 'granted';
390
+ }
391
+
392
+ function updateBell() {
393
+ // ◉ (mauve) when armed, ◯ (dim) when off/unavailable.
394
+ var on = notifyEnabled();
395
+ bell.textContent = on ? '\\u25C9' : '\\u25EF';
396
+ bell.classList.toggle('on', on);
397
+ }
398
+
399
+ // Why notifications can't be enabled here, or null if they can.
400
+ function notifyEnvIssue() {
401
+ if (typeof Notification === 'undefined') return "This browser doesn't support notifications.";
402
+ if (!window.isSecureContext) return 'Notifications need HTTPS. Open the Tailscale https:// URL, or open via localhost.';
403
+ const isIOS = /iP(hone|ad|od)/i.test(navigator.userAgent);
404
+ const standalone = navigator.standalone === true
405
+ || (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches);
406
+ if (isIOS && !standalone) return 'On iOS: Share \\u2192 Add to Home Screen first, then enable notifications.';
407
+ return null;
408
+ }
409
+
410
+ bell.addEventListener('click', function () {
411
+ // Turning OFF always works regardless of environment.
412
+ if (localStorage.getItem(NOTIFY_KEY) === '1') {
413
+ localStorage.setItem(NOTIFY_KEY, '0'); updateBell(); return;
414
+ }
415
+ const issue = notifyEnvIssue();
416
+ if (issue) { showToast(issue, 'warning'); return; }
417
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
418
+ showToast('This browser doesn\\u2019t support push notifications.', 'warning'); return;
419
+ }
420
+ Notification.requestPermission().then(function (perm) {
421
+ if (perm !== 'granted') {
422
+ showToast('Notifications blocked in browser settings.', 'warning');
423
+ updateBell(); return;
424
+ }
425
+ subscribePush().then(function (ok) {
426
+ if (ok) { localStorage.setItem(NOTIFY_KEY, '1'); showToast('Notifications on.', 'info'); }
427
+ else { showToast('Could not register for notifications.', 'warning'); }
428
+ updateBell();
429
+ }).catch(function (e) {
430
+ showToast('Notification setup failed: ' + (e && e.message ? e.message : e), 'warning');
431
+ updateBell();
432
+ });
433
+ });
434
+ });
435
+
436
+ // VAPID public key (base64url) -> Uint8Array for applicationServerKey.
437
+ function urlB64ToUint8Array(base64) {
438
+ const pad = '='.repeat((4 - base64.length % 4) % 4);
439
+ const b64 = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
440
+ const raw = atob(b64);
441
+ const arr = new Uint8Array(raw.length);
442
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
443
+ return arr;
444
+ }
445
+
446
+ // Register the service worker, subscribe via the Push API, and hand the
447
+ // subscription to the server. The server (not the page) sends notifications,
448
+ // so they arrive even when this PWA is backgrounded/suspended on iOS.
449
+ function subscribePush() {
450
+ return navigator.serviceWorker.register('/sw.js')
451
+ .then(function () { return navigator.serviceWorker.ready; })
452
+ .then(function (reg) {
453
+ return fetch('/push-key').then(function (r) { return r.text(); }).then(function (key) {
454
+ return reg.pushManager.getSubscription().then(function (existing) {
455
+ return existing || reg.pushManager.subscribe({
456
+ userVisibleOnly: true,
457
+ applicationServerKey: urlB64ToUint8Array(key.trim())
458
+ });
459
+ });
460
+ });
461
+ })
462
+ .then(function (subscription) {
463
+ return fetch('/subscribe', {
464
+ method: 'POST',
465
+ headers: { 'Content-Type': 'application/json' },
466
+ body: JSON.stringify(subscription)
467
+ }).then(function (res) { return res.ok; });
468
+ });
469
+ }
470
+
471
+ updateBell();
472
+
473
+ function answer(value) {
474
+ if (activePromptId === null) return;
475
+ ws.send(JSON.stringify({ type: 'prompt_answer', id: activePromptId, value: value }));
476
+ closePrompt();
477
+ }
478
+
479
+ function closePrompt() {
480
+ activePromptId = null;
481
+ promptCard.style.display = 'none';
482
+ promptInput.value = '';
483
+ promptInput.style.display = 'none';
484
+ promptRec.style.display = 'none';
485
+ activeRecommended2 = '';
486
+ if (cancelArmTimer) { clearTimeout(cancelArmTimer); cancelArmTimer = null; }
487
+ setEnabled(true);
488
+ }
489
+
490
+ function makeBtn(label, cls, onClick) {
491
+ const btn = document.createElement('button');
492
+ btn.textContent = label;
493
+ if (cls) btn.className = cls;
494
+ btn.onclick = onClick;
495
+ return btn;
496
+ }
497
+
498
+ // "Cancel task" aborts the whole run, so it's deliberately small and needs a
499
+ // two-step confirm: first tap arms it, second tap (within 3s) confirms.
500
+ function makeCancelBtn() {
501
+ const btn = makeBtn('Cancel task', 'cancel', null);
502
+ let armed = false;
503
+ btn.onclick = function () {
504
+ if (armed) { answer(undefined); return; }
505
+ armed = true;
506
+ btn.classList.add('armed');
507
+ btn.textContent = 'Tap again to cancel';
508
+ if (cancelArmTimer) clearTimeout(cancelArmTimer);
509
+ cancelArmTimer = setTimeout(function () {
510
+ armed = false;
511
+ btn.classList.remove('armed');
512
+ btn.textContent = 'Cancel task';
513
+ cancelArmTimer = null;
514
+ }, 3000);
515
+ };
516
+ return btn;
517
+ }
518
+
519
+ function renderButtons(buttons, stacked) {
520
+ promptButtons.className = stacked ? 'row stacked' : 'row';
521
+ promptButtons.innerHTML = '';
522
+ for (let i = 0; i < buttons.length; i++) promptButtons.appendChild(buttons[i]);
523
+ promptButtons.appendChild(makeCancelBtn());
524
+ }
525
+
526
+ // Manual-entry view: empty textarea + Submit, reachable from the
527
+ // recommendation view via "Manual answer".
528
+ function showManualEntry() {
529
+ promptRec.style.display = 'none';
530
+ promptInput.style.display = 'block';
531
+ promptInput.value = '';
532
+ renderButtons([
533
+ makeBtn('Submit', 'primary', function () { answer(promptInput.value); }),
534
+ makeBtn('← Back', 'secondary', function () { showRecommendation(); })
535
+ ]);
536
+ promptInput.focus();
537
+ }
538
+
539
+ // Recommendation view: 2-button mode when both options present, panel mode for one.
540
+ function showRecommendation() {
541
+ promptInput.style.display = 'none';
542
+ const buttons = [];
543
+ if (activeRecommended2) {
544
+ // Two-option mode: each recommendation is a direct-accept button.
545
+ promptRec.style.display = 'none';
546
+ buttons.push(makeBtn(activeRecommended, 'primary', function () { answer(activeRecommended); }));
547
+ buttons.push(makeBtn(activeRecommended2, 'secondary', function () { answer(activeRecommended2); }));
548
+ buttons.push(makeBtn('✎ Manual answer', 'secondary', function () { showManualEntry(); }));
549
+ // Answer buttons hold full sentences — stack them so long text stays readable.
550
+ renderButtons(buttons, true);
551
+ return;
552
+ }
553
+ // Single recommendation: show it in the green panel.
554
+ promptRec.style.display = 'block';
555
+ buttons.push(makeBtn('✓ Accept', 'primary', function () { answer(activeRecommended); }));
556
+ buttons.push(makeBtn('✎ Manual answer', 'secondary', function () { showManualEntry(); }));
557
+ renderButtons(buttons);
558
+ }
559
+
560
+ function showPrompt(msg) {
561
+ activePromptId = msg.id;
562
+ promptQ.textContent = msg.question;
563
+ activeRecommended = msg.recommended || '';
564
+ activeRecommended2 = msg.recommended2 || '';
565
+ if (msg.recommended) {
566
+ // Mode A: recommendation(s) present.
567
+ promptRecText.textContent = msg.recommended;
568
+ showRecommendation();
569
+ } else {
570
+ // Mode B: no recommendation — the user must type an answer (or skip).
571
+ promptRec.style.display = 'none';
572
+ promptInput.style.display = 'block';
573
+ promptInput.value = '';
574
+ const buttons = [makeBtn('Submit', 'primary', function () { answer(promptInput.value); })];
575
+ if (msg.allowSkip) {
576
+ buttons.push(makeBtn('Skip', 'secondary', function () { answer(''); }));
577
+ }
578
+ renderButtons(buttons);
579
+ promptInput.focus();
580
+ }
581
+ promptCard.style.display = 'block';
582
+ setEnabled(false);
583
+ }
584
+
585
+ function handleMsg(msg) {
586
+ switch (msg.type) {
587
+ case 'snapshot': {
588
+ // Authoritative full state on every (re)connect: replace the WHOLE view.
589
+ // This is what kills duplicated transcript / stale-orphaned widgets —
590
+ // whatever was on screen is discarded and rebuilt from server truth.
591
+ chatLog.innerHTML = '';
592
+ closePrompt();
593
+ hideThinking();
594
+ currentBubble = null; streamText = '';
595
+ for (const k in toolCallMap) delete toolCallMap[k];
596
+ // Per-turn try/catch: one malformed turn must never abort the rebuild
597
+ // and leave the (already-cleared) transcript blank.
598
+ for (const t of (msg.turns || [])) { try { renderTurn(t); } catch (e) {} }
599
+ if (msg.live) { try { renderLiveTurn(msg.live); } catch (e) {} }
600
+ taskWidgetLines = (msg.taskWidget && msg.taskWidget.length) ? msg.taskWidget : null;
601
+ renderWidgets();
602
+ if (msg.context) setContextBar(msg.context); else contextFill.style.width = '0%';
603
+ if (msg.prompt) showPrompt(msg.prompt);
604
+ setEnabled(!msg.agentRunning && !msg.prompt);
605
+ if (msg.agentRunning && !msg.live) showThinking();
606
+ break;
607
+ }
608
+ case 'agent_start':
609
+ autoScroll = true;
610
+ streamText = '';
611
+ currentBubble = null;
612
+ setEnabled(false);
613
+ showThinking();
614
+ break;
615
+ case 'text_delta':
616
+ if (!currentBubble) {
617
+ hideThinking();
618
+ currentBubble = document.createElement('div');
619
+ currentBubble.className = 'bubble assistant';
620
+ const cursor = document.createElement('span');
621
+ cursor.className = 'cursor spin';
622
+ currentBubble.appendChild(cursor);
623
+ chatLog.appendChild(currentBubble);
624
+ startSpin();
625
+ }
626
+ streamText += msg.delta;
627
+ {
628
+ const c = currentBubble.querySelector('.cursor');
629
+ currentBubble.insertBefore(document.createTextNode(msg.delta), c);
630
+ }
631
+ scrollBottom();
632
+ break;
633
+ case 'text_end':
634
+ if (currentBubble) {
635
+ const c = currentBubble.querySelector('.cursor');
636
+ if (c) c.remove();
637
+ if (streamText) setContent(currentBubble, streamText);
638
+ // Close this message's bubble so the next text segment (after a tool or
639
+ // the next message) starts a fresh bubble — matching the terminal.
640
+ currentBubble = null;
641
+ streamText = '';
642
+ stopSpinIfIdle();
643
+ }
644
+ break;
645
+ case 'tool_start': {
646
+ hideThinking();
647
+ const argsStr = typeof msg.args === 'string' ? msg.args : JSON.stringify(msg.args);
648
+ const d = addToolCall(msg.toolName, argsStr, false);
649
+ const sp = document.createElement('span');
650
+ sp.className = 'tool-spin spin';
651
+ d.querySelector('summary').appendChild(sp);
652
+ startSpin();
653
+ toolCallMap[msg.toolCallId] = d;
654
+ break;
655
+ }
656
+ case 'tool_end': {
657
+ const d = toolCallMap[msg.toolCallId];
658
+ if (d) {
659
+ const sp = d.querySelector('.tool-spin');
660
+ if (sp) { sp.remove(); stopSpinIfIdle(); }
661
+ if (msg.isError) d.classList.add('error');
662
+ const pre = document.createElement('pre');
663
+ pre.textContent = toolResultText(msg.result);
664
+ d.appendChild(pre);
665
+ delete toolCallMap[msg.toolCallId];
666
+ }
667
+ // Tool finished: model is thinking about the result next.
668
+ currentBubble = null;
669
+ streamText = '';
670
+ showThinking();
671
+ break;
672
+ }
673
+ case 'user_message':
674
+ addBubble('user', msg.text);
675
+ break;
676
+ case 'system_note':
677
+ addSystemLine(msg.text);
678
+ break;
679
+ case 'agent_error':
680
+ hideThinking();
681
+ if (currentBubble) {
682
+ const c = currentBubble.querySelector('.cursor');
683
+ if (c) c.remove();
684
+ if (streamText) setContent(currentBubble, streamText);
685
+ currentBubble = null;
686
+ streamText = '';
687
+ stopSpinIfIdle();
688
+ }
689
+ addBubble('error', msg.message || 'Error');
690
+ setEnabled(true);
691
+ break;
692
+ case 'agent_end':
693
+ hideThinking();
694
+ currentBubble = null;
695
+ streamText = '';
696
+ setEnabled(true);
697
+ setContextBar(msg.contextUsage);
698
+ break;
699
+ case 'context':
700
+ // Seeds the bar for a client that joined mid-session.
701
+ setContextBar(msg.contextUsage);
702
+ break;
703
+ case 'prompt':
704
+ showPrompt(msg);
705
+ break;
706
+ case 'prompt_resolved':
707
+ if (activePromptId === msg.id) closePrompt();
708
+ break;
709
+ case 'widget':
710
+ taskWidgetLines = (msg.lines && msg.lines.length) ? msg.lines : null;
711
+ renderWidgets();
712
+ break;
713
+ case 'notify':
714
+ showToast(msg.message, msg.level);
715
+ break;
716
+ case 'viewer':
717
+ viewerBody.textContent = msg.text;
718
+ viewer.style.display = 'block';
719
+ break;
720
+ case 'reset':
721
+ // A new session started — wipe the previous session's transcript.
722
+ chatLog.innerHTML = '';
723
+ hideThinking();
724
+ currentBubble = null; streamText = '';
725
+ closePrompt();
726
+ taskWidgetLines = null;
727
+ renderWidgets();
728
+ contextFill.style.width = '0%';
729
+ break;
730
+ }
731
+ }
732
+
733
+ function sendMessage() {
734
+ const text = inputEl.value.trim();
735
+ if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
736
+ ws.send(JSON.stringify({ type: 'message', text }));
737
+ inputEl.value = '';
738
+ inputEl.style.height = 'auto';
739
+ cmdActive = []; cmdIndex = -1; renderSuggestions();
740
+ // Slash commands are handled server-side and produce no chat turn.
741
+ if (text.startsWith('/')) return;
742
+ // The server records the message via addUserTurn and broadcasts a
743
+ // user_message back to every client (us included), which renders the
744
+ // bubble. Don't render it here too, or the sender sees it twice.
745
+ setEnabled(false);
746
+ showThinking();
747
+ }
748
+
749
+ sendBtn.addEventListener('click', sendMessage);
750
+ inputEl.addEventListener('keydown', (e) => {
751
+ if (cmdActive.length > 0) {
752
+ if (e.key === 'ArrowDown') { e.preventDefault(); cmdIndex = Math.min(cmdIndex + 1, cmdActive.length - 1); renderSuggestions(); return; }
753
+ if (e.key === 'ArrowUp') { e.preventDefault(); cmdIndex = Math.max(cmdIndex - 1, 0); renderSuggestions(); return; }
754
+ if (e.key === 'Tab') { e.preventDefault(); pickCmd(cmdIndex >= 0 ? cmdIndex : 0); return; }
755
+ if (e.key === 'Escape') { cmdActive = []; cmdIndex = -1; renderSuggestions(); return; }
756
+ if (e.key === 'Enter' && !e.shiftKey && cmdIndex >= 0) { e.preventDefault(); pickCmd(cmdIndex); return; }
757
+ }
758
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
759
+ });
760
+ inputEl.addEventListener('input', () => {
761
+ inputEl.style.height = 'auto';
762
+ inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
763
+ updateSuggestions();
764
+ });
765
+
766
+ function connect() {
767
+ ws = new WebSocket(WS_URL);
768
+ ws.addEventListener('open', () => {
769
+ if (reconnectAnim) { clearInterval(reconnectAnim); reconnectAnim = null; }
770
+ reconnectOverlay.classList.remove('visible');
771
+ reconnectDelay = 1000;
772
+ setEnabled(true);
773
+ // Self-heal on every (re)connect, not just page load: if the server
774
+ // restarted it may have lost (or be rehydrating) our subscription, and
775
+ // browsers can rotate it. Re-registering here covers reconnects the
776
+ // disk-persisted server store can't see yet. The server dedupes by
777
+ // endpoint, so a redundant re-POST is harmless.
778
+ if (notifyEnabled()) { subscribePush().catch(function () {}); }
779
+ });
780
+ ws.addEventListener('message', (e) => {
781
+ try { handleMsg(JSON.parse(e.data)); } catch {}
782
+ });
783
+ ws.addEventListener('close', () => {
784
+ setEnabled(false);
785
+ reconnectOverlay.classList.add('visible');
786
+ // Animate the same braille spinner used elsewhere, with a live countdown.
787
+ const until = Date.now() + reconnectDelay;
788
+ let frame = 0;
789
+ const paint = () => {
790
+ const left = Math.max(0, Math.ceil((until - Date.now()) / 1000));
791
+ const glyph = SPIN[frame++ % SPIN.length];
792
+ reconnectMsg.textContent = left > 0
793
+ ? glyph + ' connection lost — retrying in ' + left + 's'
794
+ : glyph + ' reconnecting…';
795
+ };
796
+ if (reconnectAnim) clearInterval(reconnectAnim);
797
+ paint();
798
+ reconnectAnim = setInterval(paint, 90);
799
+ setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, 30000); connect(); }, reconnectDelay);
800
+ });
801
+ }
802
+
803
+ connect();`;
804
+ }