@mjasnikovs/pi-task 0.6.0 → 0.7.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.
package/dist/remote/ui.js CHANGED
@@ -85,6 +85,13 @@ export function html(wsUrl) {
85
85
  background: var(--crust); color: var(--red); align-self: stretch;
86
86
  max-width: 100%; border: 1px solid var(--red); font-size: 12px;
87
87
  }
88
+ /* Persistent inline system note (e.g. context compaction) — a muted centered
89
+ divider, distinct from chat bubbles. */
90
+ .sysnote {
91
+ align-self: center; color: var(--subtext0); font-size: 11px;
92
+ font-family: ui-monospace, monospace; letter-spacing: 0.5px;
93
+ padding: 2px 10px; opacity: 0.85;
94
+ }
88
95
  .bubble.thinking {
89
96
  display: flex; gap: 5px; align-items: center; padding: 10px 14px;
90
97
  }
@@ -172,11 +179,12 @@ export function html(wsUrl) {
172
179
  font-size: 13px; z-index: 100; letter-spacing: 0.03em;
173
180
  }
174
181
  #reconnect-overlay.visible { display: flex; }
182
+ /* Trailing stream indicator: the same braille spinner as the thinking bubble,
183
+ inline at the end of the streaming text (not a green blinking block). */
175
184
  .cursor {
176
- display: inline-block; width: 7px; height: 1em; vertical-align: text-bottom;
177
- background: var(--green); animation: blink 1s step-end infinite; margin-left: 1px;
185
+ color: var(--mauve); margin-left: 2px;
186
+ font-family: ui-monospace, monospace;
178
187
  }
179
- @keyframes blink { 50% { opacity: 0; } }
180
188
  #status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);
181
189
  color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }
182
190
  #prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);
@@ -208,7 +216,8 @@ export function html(wsUrl) {
208
216
  font-size: 12px; font-weight: 500; padding: 8px 10px; }
209
217
  #prompt-card button.cancel:hover { color: var(--red); filter: none; }
210
218
  #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }
211
- .toast { position: fixed; top: 12px; right: 12px; max-width: calc(100vw - 24px);
219
+ .toast { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 12px);
220
+ right: calc(env(safe-area-inset-right, 0px) + 12px); max-width: calc(100vw - 24px);
212
221
  padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;
213
222
  background: var(--surface1); color: var(--text); z-index: 60; }
214
223
  .toast.warning { background: var(--peach); color: var(--crust); }
@@ -224,7 +233,7 @@ export function html(wsUrl) {
224
233
  <div id="header">
225
234
  <span class="title">pi-task remote</span>
226
235
  <div class="hgroup">
227
- <span class="status" id="client-status"><span class="cdot" id="conn-dot">&#x25CB;</span><span id="client-label">connecting&#x2026;</span></span>
236
+ <span class="status" id="client-status"><span class="cdot" id="conn-dot">&#x25CB;</span></span>
228
237
  <button id="bell" aria-label="Toggle notifications" title="Notifications">&#x25EF;</button>
229
238
  </div>
230
239
  </div>
@@ -264,17 +273,29 @@ export function html(wsUrl) {
264
273
  if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
265
274
  }
266
275
  const connDot = document.getElementById('conn-dot');
267
- const clientLabel = document.getElementById('client-label');
268
276
  // state: 'connecting' (○ yellow) | 'up' (● green) | 'down' (● red)
269
- function setConn(state, label) {
277
+ function setConn(state) {
270
278
  connDot.textContent = state === 'connecting' ? '\\u25CB' : '\\u25CF';
271
279
  connDot.className = 'cdot' + (state === 'up' ? ' up' : state === 'down' ? ' down' : '');
272
- if (label !== undefined) clientLabel.textContent = label;
273
280
  }
274
281
  const reconnectOverlay = document.getElementById('reconnect-overlay');
275
282
  const reconnectMsg = document.getElementById('reconnect-msg');
276
283
  const cmdSuggestions = document.getElementById('cmd-suggestions');
277
284
  const statusPanel = document.getElementById('status-panel');
285
+ // Widgets are keyed (e.g. 'pi-tasks', 'pi-task-auto'); track them per key so a
286
+ // clear for one key can't be masked by a stale message from another.
287
+ // Single authoritative task-widget slot. The snapshot and the live 'widget'
288
+ // delta both set this; null hides the panel. (No more per-key map that could
289
+ // strand an orphaned widget on screen.)
290
+ let taskWidgetLines = null;
291
+ function renderWidgets() {
292
+ if (taskWidgetLines && taskWidgetLines.length) {
293
+ statusPanel.textContent = taskWidgetLines.join('\\n');
294
+ statusPanel.style.display = 'block';
295
+ } else {
296
+ statusPanel.style.display = 'none';
297
+ }
298
+ }
278
299
  const promptCard = document.getElementById('prompt-card');
279
300
  const promptQ = document.getElementById('prompt-q');
280
301
  const promptRec = document.getElementById('prompt-rec');
@@ -292,6 +313,7 @@ export function html(wsUrl) {
292
313
  let streamText = '';
293
314
  let autoScroll = true;
294
315
  let reconnectDelay = 1000;
316
+ let reconnectAnim = null;
295
317
  let ws = null;
296
318
 
297
319
  const BT = String.fromCharCode(96);
@@ -475,30 +497,46 @@ export function html(wsUrl) {
475
497
  let spinTimer = null;
476
498
  let spinIdx = 0;
477
499
  const SPIN = '\\u280B\\u2819\\u2839\\u2838\\u283C\\u2834\\u2826\\u2827\\u2807\\u280F';
500
+ // One braille ticker drives every '.spin' element — the thinking bubble AND the
501
+ // trailing stream cursor — so they share the same frame and look identical.
502
+ function spinPaint() {
503
+ const g = SPIN[spinIdx % SPIN.length];
504
+ const els = document.getElementsByClassName('spin');
505
+ for (let i = 0; i < els.length; i++) els[i].textContent = g;
506
+ }
507
+ function startSpin() {
508
+ spinPaint();
509
+ if (spinTimer) return;
510
+ spinTimer = setInterval(function () {
511
+ spinIdx = (spinIdx + 1) % SPIN.length;
512
+ spinPaint();
513
+ }, 90);
514
+ }
515
+ // Stop the ticker once nothing on screen needs spinning.
516
+ function stopSpinIfIdle() {
517
+ if (spinTimer && !document.querySelector('.spin')) {
518
+ clearInterval(spinTimer); spinTimer = null;
519
+ }
520
+ }
478
521
  function showThinking() {
479
522
  if (!thinkingEl) {
480
523
  thinkingEl = document.createElement('div');
481
524
  thinkingEl.className = 'bubble assistant thinking';
482
- thinkingEl.innerHTML = '<span class="spinner"></span>';
483
- }
484
- if (!spinTimer) {
485
- var sp = thinkingEl.firstChild;
486
- sp.textContent = SPIN[spinIdx % SPIN.length];
487
- spinTimer = setInterval(function () {
488
- spinIdx = (spinIdx + 1) % SPIN.length;
489
- sp.textContent = SPIN[spinIdx];
490
- }, 90);
525
+ thinkingEl.innerHTML = '<span class="spinner spin"></span>';
491
526
  }
492
527
  chatLog.appendChild(thinkingEl); // append (or move) to bottom
528
+ startSpin();
493
529
  scrollBottom();
494
530
  }
495
531
  function hideThinking() {
496
- if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
497
532
  if (thinkingEl) thinkingEl.remove();
533
+ stopSpinIfIdle();
498
534
  }
499
535
 
500
536
  function addToolCall(toolName, argsStr, isError) {
501
- const label = (toolName + ': ' + argsStr).slice(0, 64);
537
+ // argsStr can be undefined (no args / JSON.stringify(undefined)); don't let
538
+ // that render as the literal "name: undefined" in the collapsed summary.
539
+ const label = (toolName + (argsStr ? ': ' + argsStr : '')).slice(0, 64);
502
540
  const d = document.createElement('details');
503
541
  d.className = 'tool-call' + (isError ? ' error' : '');
504
542
  const s = document.createElement('summary');
@@ -515,6 +553,82 @@ export function html(wsUrl) {
515
553
  sendBtn.disabled = !allow;
516
554
  }
517
555
 
556
+ // Stringify a tool result safely. A null/undefined result (e.g. a tool that
557
+ // hasn't produced output) must NOT become the JS value undefined, whose
558
+ // .slice() throws — a throw here aborts the whole snapshot rebuild after the
559
+ // log was already cleared, blanking the transcript on reconnect.
560
+ function toolResultText(result) {
561
+ if (result == null) return '';
562
+ const r = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
563
+ return (r == null ? '' : r).slice(0, 8000);
564
+ }
565
+
566
+ // Render one tool part from the ordered parts list (running or finished).
567
+ function renderToolPart(p) {
568
+ const argsStr = typeof p.args === 'string' ? p.args : JSON.stringify(p.args);
569
+ const d = addToolCall(p.toolName, argsStr, p.isError);
570
+ if (p.done) {
571
+ const pre = document.createElement('pre');
572
+ pre.textContent = toolResultText(p.result);
573
+ d.appendChild(pre);
574
+ } else {
575
+ toolCallMap[p.toolCallId] = d; // a later tool_end delta fills the result
576
+ }
577
+ return d;
578
+ }
579
+
580
+ // A muted, centered system note (e.g. "Context compacted").
581
+ function addSystemLine(text) {
582
+ const el = document.createElement('div');
583
+ el.className = 'sysnote';
584
+ el.textContent = text;
585
+ chatLog.appendChild(el);
586
+ scrollBottom();
587
+ return el;
588
+ }
589
+
590
+ // Render one committed transcript turn. Assistant turns are an ordered list of
591
+ // parts (text segments + tool calls), so the layout matches the terminal's
592
+ // interleaving instead of one merged blob with tools dumped at the end.
593
+ function renderTurn(t) {
594
+ if (t.error) { addBubble('error', t.text); return; }
595
+ if (t.role === 'system') { addSystemLine(t.text); return; }
596
+ if (t.role === 'user') { addBubble('user', t.text); return; }
597
+ for (const p of (t.parts || [])) {
598
+ if (p.kind === 'text') { if (p.text) addBubble('assistant', p.text); }
599
+ else renderToolPart(p);
600
+ }
601
+ }
602
+
603
+ // Render the in-progress assistant turn from a snapshot, preserving order. The
604
+ // trailing OPEN text segment becomes the live streaming bubble (cursor + spin)
605
+ // so subsequent text_delta frames keep flowing into it.
606
+ function renderLiveTurn(live) {
607
+ const parts = live.parts || [];
608
+ for (let i = 0; i < parts.length; i++) {
609
+ const p = parts[i];
610
+ const last = i === parts.length - 1;
611
+ if (p.kind === 'text') {
612
+ if (last && live.textOpen) {
613
+ currentBubble = document.createElement('div');
614
+ currentBubble.className = 'bubble assistant';
615
+ const cursor = document.createElement('span');
616
+ cursor.className = 'cursor spin';
617
+ currentBubble.appendChild(cursor);
618
+ if (p.text) currentBubble.insertBefore(document.createTextNode(p.text), cursor);
619
+ chatLog.appendChild(currentBubble);
620
+ streamText = p.text || '';
621
+ startSpin();
622
+ scrollBottom();
623
+ } else if (p.text) {
624
+ addBubble('assistant', p.text);
625
+ }
626
+ } else {
627
+ renderToolPart(p);
628
+ }
629
+ }
630
+ }
631
+
518
632
  function showToast(message, level) {
519
633
  const t = document.createElement('div');
520
634
  t.className = 'toast ' + (level || 'info');
@@ -557,26 +671,65 @@ export function html(wsUrl) {
557
671
  }
558
672
  const issue = notifyEnvIssue();
559
673
  if (issue) { showToast(issue, 'warning'); return; }
674
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
675
+ showToast('This browser doesn\\u2019t support push notifications.', 'warning'); return;
676
+ }
560
677
  Notification.requestPermission().then(function (perm) {
561
- if (perm === 'granted') { localStorage.setItem(NOTIFY_KEY, '1'); }
562
- else { showToast('Notifications blocked in browser settings.', 'warning'); }
563
- updateBell();
678
+ if (perm !== 'granted') {
679
+ showToast('Notifications blocked in browser settings.', 'warning');
680
+ updateBell(); return;
681
+ }
682
+ subscribePush().then(function (ok) {
683
+ if (ok) { localStorage.setItem(NOTIFY_KEY, '1'); showToast('Notifications on.', 'info'); }
684
+ else { showToast('Could not register for notifications.', 'warning'); }
685
+ updateBell();
686
+ }).catch(function (e) {
687
+ showToast('Notification setup failed: ' + (e && e.message ? e.message : e), 'warning');
688
+ updateBell();
689
+ });
564
690
  });
565
691
  });
566
692
 
567
- // Fire a browser notification, but only when armed, in a secure context,
568
- // and the tab is backgrounded (foreground already has the in-page UI).
569
- function notify(title, body, tag) {
570
- if (!notifyEnabled()) return;
571
- if (!window.isSecureContext) return;
572
- if (!document.hidden) return;
573
- try {
574
- const n = new Notification(title, { body: body || '', tag: tag });
575
- n.onclick = function () { window.focus(); n.close(); };
576
- } catch (e) { /* constructor unsupported (e.g. iOS without SW) */ }
693
+ // VAPID public key (base64url) -> Uint8Array for applicationServerKey.
694
+ function urlB64ToUint8Array(base64) {
695
+ const pad = '='.repeat((4 - base64.length % 4) % 4);
696
+ const b64 = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
697
+ const raw = atob(b64);
698
+ const arr = new Uint8Array(raw.length);
699
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
700
+ return arr;
701
+ }
702
+
703
+ // Register the service worker, subscribe via the Push API, and hand the
704
+ // subscription to the server. The server (not the page) sends notifications,
705
+ // so they arrive even when this PWA is backgrounded/suspended on iOS.
706
+ function subscribePush() {
707
+ return navigator.serviceWorker.register('/sw.js')
708
+ .then(function () { return navigator.serviceWorker.ready; })
709
+ .then(function (reg) {
710
+ return fetch('/push-key').then(function (r) { return r.text(); }).then(function (key) {
711
+ return reg.pushManager.getSubscription().then(function (existing) {
712
+ return existing || reg.pushManager.subscribe({
713
+ userVisibleOnly: true,
714
+ applicationServerKey: urlB64ToUint8Array(key.trim())
715
+ });
716
+ });
717
+ });
718
+ })
719
+ .then(function (subscription) {
720
+ return fetch('/subscribe', {
721
+ method: 'POST',
722
+ headers: { 'Content-Type': 'application/json' },
723
+ body: JSON.stringify(subscription)
724
+ }).then(function (res) { return res.ok; });
725
+ });
577
726
  }
578
727
 
579
728
  updateBell();
729
+ // Self-heal: if notifications were enabled before, re-register the
730
+ // subscription on load (the server keeps subscriptions in memory and may
731
+ // have restarted, and browsers can rotate the subscription).
732
+ if (notifyEnabled()) { subscribePush().catch(function () {}); }
580
733
 
581
734
  function answer(value) {
582
735
  if (activePromptId === null) return;
@@ -679,19 +832,27 @@ export function html(wsUrl) {
679
832
 
680
833
  function handleMsg(msg) {
681
834
  switch (msg.type) {
682
- case 'history':
683
- for (const t of (msg.turns || [])) {
684
- if (t.error) { addBubble('error', t.text); continue; }
685
- addBubble(t.role, t.text);
686
- for (const tool of (t.tools || [])) {
687
- const d = addToolCall(tool.toolName, JSON.stringify(tool.args), tool.isError);
688
- const pre = document.createElement('pre');
689
- const r = typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2);
690
- pre.textContent = r.slice(0, 8000);
691
- d.appendChild(pre);
692
- }
693
- }
835
+ case 'snapshot': {
836
+ // Authoritative full state on every (re)connect: replace the WHOLE view.
837
+ // This is what kills duplicated transcript / stale-orphaned widgets —
838
+ // whatever was on screen is discarded and rebuilt from server truth.
839
+ chatLog.innerHTML = '';
840
+ closePrompt();
841
+ hideThinking();
842
+ currentBubble = null; streamText = '';
843
+ for (const k in toolCallMap) delete toolCallMap[k];
844
+ // Per-turn try/catch: one malformed turn must never abort the rebuild
845
+ // and leave the (already-cleared) transcript blank.
846
+ for (const t of (msg.turns || [])) { try { renderTurn(t); } catch (e) {} }
847
+ if (msg.live) { try { renderLiveTurn(msg.live); } catch (e) {} }
848
+ taskWidgetLines = (msg.taskWidget && msg.taskWidget.length) ? msg.taskWidget : null;
849
+ renderWidgets();
850
+ if (msg.context) setContextBar(msg.context); else contextFill.style.width = '0%';
851
+ if (msg.prompt) showPrompt(msg.prompt);
852
+ setEnabled(!msg.agentRunning && !msg.prompt);
853
+ if (msg.agentRunning && !msg.live) showThinking();
694
854
  break;
855
+ }
695
856
  case 'agent_start':
696
857
  autoScroll = true;
697
858
  streamText = '';
@@ -705,9 +866,10 @@ export function html(wsUrl) {
705
866
  currentBubble = document.createElement('div');
706
867
  currentBubble.className = 'bubble assistant';
707
868
  const cursor = document.createElement('span');
708
- cursor.className = 'cursor';
869
+ cursor.className = 'cursor spin';
709
870
  currentBubble.appendChild(cursor);
710
871
  chatLog.appendChild(currentBubble);
872
+ startSpin();
711
873
  }
712
874
  streamText += msg.delta;
713
875
  {
@@ -721,6 +883,11 @@ export function html(wsUrl) {
721
883
  const c = currentBubble.querySelector('.cursor');
722
884
  if (c) c.remove();
723
885
  if (streamText) setContent(currentBubble, streamText);
886
+ // Close this message's bubble so the next text segment (after a tool or
887
+ // the next message) starts a fresh bubble — matching the terminal.
888
+ currentBubble = null;
889
+ streamText = '';
890
+ stopSpinIfIdle();
724
891
  }
725
892
  break;
726
893
  case 'tool_start': {
@@ -734,8 +901,7 @@ export function html(wsUrl) {
734
901
  if (d) {
735
902
  if (msg.isError) d.classList.add('error');
736
903
  const pre = document.createElement('pre');
737
- const r = typeof msg.result === 'string' ? msg.result : JSON.stringify(msg.result, null, 2);
738
- pre.textContent = r.slice(0, 8000);
904
+ pre.textContent = toolResultText(msg.result);
739
905
  d.appendChild(pre);
740
906
  delete toolCallMap[msg.toolCallId];
741
907
  }
@@ -748,6 +914,9 @@ export function html(wsUrl) {
748
914
  case 'user_message':
749
915
  addBubble('user', msg.text);
750
916
  break;
917
+ case 'system_note':
918
+ addSystemLine(msg.text);
919
+ break;
751
920
  case 'agent_error':
752
921
  hideThinking();
753
922
  if (currentBubble) {
@@ -756,9 +925,9 @@ export function html(wsUrl) {
756
925
  if (streamText) setContent(currentBubble, streamText);
757
926
  currentBubble = null;
758
927
  streamText = '';
928
+ stopSpinIfIdle();
759
929
  }
760
930
  addBubble('error', msg.message || 'Error');
761
- notify('Agent error', msg.message || 'Error', 'pi-error');
762
931
  setEnabled(true);
763
932
  break;
764
933
  case 'agent_end':
@@ -766,7 +935,6 @@ export function html(wsUrl) {
766
935
  currentBubble = null;
767
936
  streamText = '';
768
937
  setEnabled(true);
769
- notify('Task finished', '', 'pi-end');
770
938
  setContextBar(msg.contextUsage);
771
939
  break;
772
940
  case 'context':
@@ -774,22 +942,17 @@ export function html(wsUrl) {
774
942
  setContextBar(msg.contextUsage);
775
943
  break;
776
944
  case 'client_count':
777
- setConn('up', msg.count + ' client' + (msg.count === 1 ? '' : 's') + ' connected');
945
+ setConn('up');
778
946
  break;
779
947
  case 'prompt':
780
948
  showPrompt(msg);
781
- notify('pi needs your input', msg.question, 'pi-prompt');
782
949
  break;
783
950
  case 'prompt_resolved':
784
951
  if (activePromptId === msg.id) closePrompt();
785
952
  break;
786
953
  case 'widget':
787
- if (msg.lines && msg.lines.length) {
788
- statusPanel.textContent = msg.lines.join('\\n');
789
- statusPanel.style.display = 'block';
790
- } else {
791
- statusPanel.style.display = 'none';
792
- }
954
+ taskWidgetLines = (msg.lines && msg.lines.length) ? msg.lines : null;
955
+ renderWidgets();
793
956
  break;
794
957
  case 'notify':
795
958
  showToast(msg.message, msg.level);
@@ -798,6 +961,16 @@ export function html(wsUrl) {
798
961
  viewerBody.textContent = msg.text;
799
962
  viewer.style.display = 'block';
800
963
  break;
964
+ case 'reset':
965
+ // A new session started — wipe the previous session's transcript.
966
+ chatLog.innerHTML = '';
967
+ hideThinking();
968
+ currentBubble = null; streamText = '';
969
+ closePrompt();
970
+ taskWidgetLines = null;
971
+ renderWidgets();
972
+ contextFill.style.width = '0%';
973
+ break;
801
974
  }
802
975
  }
803
976
 
@@ -837,9 +1010,10 @@ export function html(wsUrl) {
837
1010
  function connect() {
838
1011
  ws = new WebSocket(WS_URL);
839
1012
  ws.addEventListener('open', () => {
1013
+ if (reconnectAnim) { clearInterval(reconnectAnim); reconnectAnim = null; }
840
1014
  reconnectOverlay.classList.remove('visible');
841
1015
  reconnectDelay = 1000;
842
- setConn('up', 'connected');
1016
+ setConn('up');
843
1017
  setEnabled(true);
844
1018
  });
845
1019
  ws.addEventListener('message', (e) => {
@@ -847,9 +1021,21 @@ export function html(wsUrl) {
847
1021
  });
848
1022
  ws.addEventListener('close', () => {
849
1023
  setEnabled(false);
850
- setConn('down', 'disconnected');
1024
+ setConn('down');
851
1025
  reconnectOverlay.classList.add('visible');
852
- reconnectMsg.textContent = 'reconnecting in ' + (reconnectDelay / 1000) + 's…';
1026
+ // Animate the same braille spinner used elsewhere, with a live countdown.
1027
+ const until = Date.now() + reconnectDelay;
1028
+ let frame = 0;
1029
+ const paint = () => {
1030
+ const left = Math.max(0, Math.ceil((until - Date.now()) / 1000));
1031
+ const glyph = SPIN[frame++ % SPIN.length];
1032
+ reconnectMsg.textContent = left > 0
1033
+ ? glyph + ' connection lost — retrying in ' + left + 's'
1034
+ : glyph + ' reconnecting…';
1035
+ };
1036
+ if (reconnectAnim) clearInterval(reconnectAnim);
1037
+ paint();
1038
+ reconnectAnim = setInterval(paint, 90);
853
1039
  setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, 30000); connect(); }, reconnectDelay);
854
1040
  });
855
1041
  }
@@ -5,7 +5,7 @@
5
5
  * context usage, and the latest child-process line.
6
6
  */
7
7
  import { PHASE_INDEX, PHASE_ORDER } from './task-file.js';
8
- import { publishWidget } from '../remote/bridge.js';
8
+ import { setTaskWidget } from '../remote/session-state.js';
9
9
  // ─── Constants ───────────────────────────────────────────────────────────────
10
10
  export const WIDGET_KEY = 'pi-tasks';
11
11
  export const AUTO_WIDGET_KEY = 'pi-task-auto';
@@ -101,7 +101,7 @@ export function startWidget(ctx, getState) {
101
101
  catch {
102
102
  /* stale ctx */
103
103
  }
104
- publishWidget(WIDGET_KEY, plain);
104
+ setTaskWidget(plain);
105
105
  };
106
106
  render();
107
107
  const timer = setInterval(render, WIDGET_REFRESH_MS);
@@ -114,7 +114,7 @@ export function startWidget(ctx, getState) {
114
114
  catch {
115
115
  /* stale ctx */
116
116
  }
117
- publishWidget(WIDGET_KEY, undefined);
117
+ setTaskWidget(undefined);
118
118
  };
119
119
  }
120
120
  export function buildAutoLoaderLines(s, theme) {
@@ -150,7 +150,7 @@ export function startAutoLoader(ctx, getState) {
150
150
  catch {
151
151
  /* stale ctx */
152
152
  }
153
- publishWidget(AUTO_WIDGET_KEY, plain);
153
+ setTaskWidget(plain);
154
154
  };
155
155
  render();
156
156
  const timer = setInterval(render, WIDGET_REFRESH_MS);
@@ -163,7 +163,7 @@ export function startAutoLoader(ctx, getState) {
163
163
  catch {
164
164
  /* stale ctx */
165
165
  }
166
- publishWidget(AUTO_WIDGET_KEY, undefined);
166
+ setTaskWidget(undefined);
167
167
  };
168
168
  }
169
169
  export function flashTerminalWidget(ctx, state, taskId, reason) {
@@ -185,7 +185,7 @@ export function flashTerminalWidget(ctx, state, taskId, reason) {
185
185
  : `✘ ${taskId} failed${reason ? ': ' + reason : ''}`;
186
186
  try {
187
187
  ctx.ui.setWidget(WIDGET_KEY, [line]);
188
- publishWidget(WIDGET_KEY, [plainLine]);
188
+ setTaskWidget([plainLine]);
189
189
  }
190
190
  catch {
191
191
  /* stale ctx */
@@ -193,7 +193,7 @@ export function flashTerminalWidget(ctx, state, taskId, reason) {
193
193
  setTimeout(() => {
194
194
  try {
195
195
  ctx.ui.setWidget(WIDGET_KEY, undefined);
196
- publishWidget(WIDGET_KEY, undefined);
196
+ setTaskWidget(undefined);
197
197
  }
198
198
  catch {
199
199
  /* stale ctx */
@@ -20,17 +20,20 @@ export function registerPiWorkerDocs(pi, internals = {}) {
20
20
  pi.registerTool({
21
21
  name: 'pi-worker-docs',
22
22
  label: 'Pi Worker Docs',
23
- description: 'Look up an npm package locally and return a focused, version-pinned '
24
- + 'answer extracted from its .d.ts types and README. No network after install (except a small '
25
- + 'live npm registry call described below). The cache lives at ~/.cache/pi-worker/docs.sqlite '
26
- + 'and is keyed by exact installed version.\n'
27
- + 'If the package is not yet installed, it is installed automatically via bun add or npm install.\n'
28
- + '\n'
29
- + 'Live registry data: in addition to the docs answer, the tool fetches the latest published '
30
- + 'version, recent versions, and publish date from the npm registry and prepends them to the '
31
- + 'output (and exposes them in details). This is the source of truth for "what is the latest '
32
- + 'version of X" questions — training-data versions are typically months stale. The lookup is '
33
- + 'best-effort and silently absent when offline.\n'
23
+ description: 'Look up an INSTALLED npm package and return a focused, version-pinned '
24
+ + 'answer from its .d.ts types and README, PLUS the latest published version '
25
+ + 'from a live npm registry call. USE THIS BEFORE ANSWERING any question '
26
+ + 'about how to use a library, what it exports, its types/overloads/config, '
27
+ + 'or the latest published version of an npm package. Do NOT answer package '
28
+ + 'APIs from memory, do NOT run `npm view`/bash to get a package version, and '
29
+ + 'do NOT web-search for an installed package this tool is the source of '
30
+ + 'truth and is version-pinned to what is actually installed (training-data '
31
+ + 'versions and APIs are typically months stale).\n'
32
+ + 'For a non-package framework/runtime version (e.g. Node.js, Ubuntu), use '
33
+ + '`pi-worker-search` instead. If the package is not installed it is '
34
+ + 'auto-installed via bun add or npm install. The cache lives at '
35
+ + '~/.cache/pi-worker/docs.sqlite, keyed by exact installed version; the '
36
+ + 'registry lookup is best-effort and silently absent when offline.\n'
34
37
  + '\n'
35
38
  + 'Good fits:\n'
36
39
  + '- "What does library X export?" / "How does function Y work?"\n'
@@ -14,9 +14,14 @@ export function registerPiWorkerSearch(pi, internals = {}) {
14
14
  pi.registerTool({
15
15
  name: 'pi-worker-search',
16
16
  label: 'Pi Worker Search',
17
- description: 'Search the web via Brave Search. Returns a compact markdown list of '
18
- + 'up to 10 results (title, URL, snippet). Use this to find candidate '
19
- + 'URLs, then call `pi-worker-fetch` on the URL you want to read. '
17
+ description: 'Search the live web via Brave Search. CALL THIS BEFORE ANSWERING any '
18
+ + 'question about current or version-specific external facts: '
19
+ + 'library/framework versions and their APIs, latest releases, recently '
20
+ + 'shipped features, current events, prices, or who currently holds a '
21
+ + 'role. Your built-in knowledge is out of date — do NOT answer such '
22
+ + 'questions from memory and do NOT shell out with bash to guess. Returns '
23
+ + 'a compact markdown list of up to 10 results (title, URL, snippet); then '
24
+ + 'call `pi-worker-fetch` on the URL you want to read. '
20
25
  + 'Requires BRAVE_SEARCH_API_KEY env var.',
21
26
  parameters: Params,
22
27
  executionMode: 'parallel',
@@ -18,16 +18,19 @@ export function registerPiWorker(pi) {
18
18
  pi.registerTool({
19
19
  name: 'pi-worker',
20
20
  label: 'Pi Worker',
21
- description: 'Dispatch an isolated child Pi to investigate a question and return its '
22
- + 'conclusion — not the raw evidence. Use when answering requires reading '
23
- + 'multiple files, running several greps, or scanning large command output '
24
- + 'you do not need verbatim.\n'
21
+ description: 'Dispatch an isolated child Pi to investigate and return its CONCLUSION — '
22
+ + 'not the raw evidence. USE THIS FIRST, instead of running your own '
23
+ + 'ls/grep/find/read, whenever a question spans MULTIPLE files or means '
24
+ + 'searching/scanning code you have not already located. Doing it yourself '
25
+ + 'floods your context with raw file output; the worker reads in isolation '
26
+ + 'and returns only the answer. You can dispatch several in one turn for '
27
+ + 'independent questions.\n'
25
28
  + '\n'
26
29
  + 'Good fits:\n'
27
30
  + '- "Where/how is X handled in this repo?" across unfamiliar code\n'
28
31
  + '- Audits and pattern scans across many files ("every place we log PII")\n'
29
- + '- Summarising long test output, logs, or shell output\n'
30
- + '- Parallel fan-out: dispatch several workers in one turn for independent questions\n'
32
+ + '- Tracing a flow across layers (router service → database)\n'
33
+ + '- Summarising long test output, logs, or shell output you do not need verbatim\n'
31
34
  + '\n'
32
35
  + 'Skip when:\n'
33
36
  + '- You already know the exact file — call `read` directly\n'