@mjasnikovs/pi-task 0.7.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/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![npm](https://img.shields.io/npm/v/@mjasnikovs/pi-task?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mjasnikovs/pi-task)
8
8
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
9
9
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
10
- [![tests](https://img.shields.io/badge/tests-326%20passing-3fb950)](#development)
10
+ [![tests](https://img.shields.io/badge/tests-533%20passing-3fb950)](#development)
11
11
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](./tsconfig.json)
12
12
 
13
13
  </div>
@@ -114,6 +114,26 @@ The remote server is **always on** — it starts automatically with each session
114
114
 
115
115
  Prompts use a **first-answer-wins race**: the same question shows in the local TUI *and* every connected browser, and whoever answers first wins — the other surfaces dismiss. With nobody connected, `/task` behaves exactly as before; the remote path is purely additive.
116
116
 
117
+ ### Push notifications
118
+
119
+ Tap the bell (◯ → ◉) in the remote header to get pushed a notification — even with the app backgrounded or the phone locked — when:
120
+
121
+ - a **grill / clarify question** needs answering (*"pi needs your input"*),
122
+ - a **task finishes** (*"Task finished"*), or
123
+ - the agent hits an **error** (*"Agent error"*).
124
+
125
+ Delivery is **server → push service → device** over the [Web Push](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) standard (service worker + VAPID), so it reaches a suspended device. It works on desktop browsers and on iOS home-screen PWAs.
126
+
127
+ **iOS setup** (these are Apple's requirements, not ours):
128
+
129
+ 1. Open the **HTTPS** Tailscale URL (`/remote` lists it). iOS only allows push from a secure context — the plain `http://` LAN URL won't work.
130
+ 2. **Share → Add to Home Screen**, then open the app from that icon. iOS only permits notifications for installed PWAs.
131
+ 3. Launch the app, **tap the bell**, and **Allow** when prompted.
132
+
133
+ The subscription is kept in memory, so after restarting `pi` just reload the app (it re-subscribes automatically) or tap the bell again. Notifications are suppressed while the app is focused in the foreground — the in-page UI already shows the prompt there.
134
+
135
+ VAPID keys are generated once and persisted to `${XDG_DATA_HOME:-~/.local/share}/pi-task/vapid.json` (deleting them invalidates existing subscriptions). The JWT contact (`sub`) defaults to the project URL; override it with `PI_REMOTE_PUSH_SUBJECT` (e.g. your own `mailto:you@domain.com`). To debug delivery, set `PI_REMOTE_PUSH_DEBUG=1` and tail `/tmp/pi-task-push.log` — it records each push and the **push-service HTTP status** (`201` delivered, `403`/`400` token/key problem, `410` stale subscription).
136
+
117
137
  `/remote stop` shuts the server down for the rest of the session (it comes back on the next session start). There is **no authentication** — it's a personal LAN/Tailscale tool. Don't expose the port to untrusted networks.
118
138
 
119
139
  ## Bundled tools
@@ -148,6 +168,10 @@ Resolves an installed npm package, indexes its `.d.ts` files and README into a l
148
168
  | --- | --- | --- |
149
169
  | `BRAVE_SEARCH_API_KEY` / `BRAVE_API_KEY` | `pi-worker-search`, research enrichment | Required for web search. |
150
170
  | `XDG_CACHE_HOME` | `pi-worker-docs` | Overrides the docs cache location (defaults to `~/.cache`). |
171
+ | `XDG_DATA_HOME` | remote push | Where the VAPID keypair is stored (defaults to `~/.local/share`). |
172
+ | `PI_REMOTE_PUSH_SUBJECT` | remote push | VAPID JWT `sub` contact. Defaults to the project URL; set your own `mailto:you@domain.com` or `https://…`. |
173
+ | `PI_REMOTE_PUSH_DEBUG` | remote push | When set (e.g. `1`), logs push delivery and push-service HTTP status. Off by default. |
174
+ | `PI_REMOTE_PUSH_LOG` | remote push | Path for the debug log (defaults to `/tmp/pi-task-push.log`). |
151
175
 
152
176
  Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your `.gitignore` if you don't want them checked in.
153
177
 
@@ -155,7 +179,7 @@ Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your
155
179
 
156
180
  ```sh
157
181
  bun install
158
- bun test src/ # 326 tests across 28 files
182
+ bun test src/ # 533 tests across 45 files
159
183
  bun run lint # prettier + eslint + tsc --noEmit
160
184
  bun run build # tsc → dist/
161
185
  ```
@@ -1,14 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
- import type { ContextUsage, PromptMessage, ServerMessage } from './protocol.js';
2
+ import type { ServerMessage } from './protocol.js';
3
3
  export interface BridgeState {
4
4
  /** promptId → resolver that settles the remote side of an ask() race. */
5
5
  pending: Map<string, (value: string | undefined) => void>;
6
- /** The prompt currently awaiting an answer (replayed to late joiners), or null. */
7
- activePrompt: PromptMessage | null;
8
- /** Last lines pushed per widget key (replayed to late joiners). */
9
- activeWidgets: Map<string, string[]>;
10
- /** Most recent context-window usage (replayed to seed late joiners' bar), or null. */
11
- lastContextUsage: ContextUsage | null;
12
6
  nextId: number;
13
7
  /** Command name → handler, populated as pi-task registers its commands. */
14
8
  commands: Map<string, (args: string, ctx: ExtensionCommandContext) => unknown>;
@@ -43,9 +37,6 @@ export declare class SessionUI {
43
37
  /** Race the local input against a remote answer; first to settle wins. */
44
38
  ask(spec: AskSpec): Promise<string | undefined>;
45
39
  }
46
- /** Mirror a status widget to browsers and remember it for late joiners.
47
- * `lines === undefined` clears the widget (broadcast as `lines: null`). */
48
- export declare function publishWidget(key: string, lines: string[] | undefined): void;
49
40
  export declare function publishNotify(message: string, level: 'info' | 'warning' | 'error'): void;
50
41
  export declare function publishViewer(title: string, text: string): void;
51
42
  /**
@@ -1,13 +1,11 @@
1
1
  import { broadcast as wsBroadcast } from './broadcast.js';
2
2
  import { pushNotify } from './push.js';
3
+ import { setPrompt, clearPrompt } from './session-state.js';
3
4
  const g = globalThis;
4
5
  export function getBridge() {
5
6
  if (!g.__piBridge) {
6
7
  g.__piBridge = {
7
8
  pending: new Map(),
8
- activePrompt: null,
9
- activeWidgets: new Map(),
10
- lastContextUsage: null,
11
9
  nextId: 0,
12
10
  commands: new Map(),
13
11
  currentCtx: null,
@@ -56,8 +54,7 @@ export class SessionUI {
56
54
  recommended: spec.recommended,
57
55
  allowSkip: spec.allowSkip
58
56
  };
59
- b.activePrompt = prompt;
60
- b.broadcast(prompt);
57
+ setPrompt(prompt);
61
58
  // Reaches a backgrounded/suspended phone, which the in-page UI can't.
62
59
  void pushNotify('pi needs your input', spec.question, 'pi-prompt').catch(() => { });
63
60
  // Local: resolves to a value/undefined, or undefined on abort. Swallow
@@ -78,23 +75,10 @@ export class SessionUI {
78
75
  }
79
76
  finally {
80
77
  b.pending.delete(id);
81
- b.activePrompt = null;
82
- b.broadcast({ type: 'prompt_resolved', id });
78
+ clearPrompt(id);
83
79
  }
84
80
  }
85
81
  }
86
- /** Mirror a status widget to browsers and remember it for late joiners.
87
- * `lines === undefined` clears the widget (broadcast as `lines: null`). */
88
- export function publishWidget(key, lines) {
89
- const b = getBridge();
90
- if (lines === undefined) {
91
- b.activeWidgets.delete(key);
92
- b.broadcast({ type: 'widget', key, lines: null });
93
- return;
94
- }
95
- b.activeWidgets.set(key, lines);
96
- b.broadcast({ type: 'widget', key, lines });
97
- }
98
82
  export function publishNotify(message, level) {
99
83
  getBridge().broadcast({ type: 'notify', message, level });
100
84
  }
@@ -1,3 +1,4 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
- import type { HistoryBuffer } from './history.js';
3
- export declare function setupEvents(pi: ExtensionAPI, history: HistoryBuffer, broadcastFn: (msg: unknown) => void): void;
2
+ /** Mirror pi agent events into the authoritative SessionState. Each handler
3
+ * drives a mutator, which updates the snapshot AND broadcasts the live delta. */
4
+ export declare function setupEvents(pi: ExtensionAPI): void;
@@ -1,22 +1,18 @@
1
1
  import { setAgentIdle } from './state.js';
2
- import { getBridge } from './bridge.js';
3
2
  import { pushNotify } from './push.js';
4
- export function setupEvents(pi, history, broadcastFn) {
5
- let currentText = '';
6
- const currentTools = [];
7
- const pendingArgs = new Map();
3
+ import { publishNotify } from './bridge.js';
4
+ import { agentStart, appendText, textEnd, startTool, updateTool, endTool, agentEnd, addUserTurn, addError, addSystemNote } from './session-state.js';
5
+ /** Mirror pi agent events into the authoritative SessionState. Each handler
6
+ * drives a mutator, which updates the snapshot AND broadcasts the live delta. */
7
+ export function setupEvents(pi) {
8
8
  pi.on('agent_start', (_event, _ctx) => {
9
- currentText = '';
10
- currentTools.length = 0;
11
- pendingArgs.clear();
12
9
  setAgentIdle(false);
13
- broadcastFn({ type: 'agent_start' });
10
+ agentStart();
14
11
  });
15
12
  pi.on('message_update', (event, _ctx) => {
16
13
  const ae = event.assistantMessageEvent;
17
14
  if (ae.type === 'text_delta' && 'delta' in ae && typeof ae.delta === 'string') {
18
- currentText += ae.delta;
19
- broadcastFn({ type: 'text_delta', delta: ae.delta });
15
+ appendText(ae.delta);
20
16
  }
21
17
  else if (ae.type === 'error') {
22
18
  const errorMessage = 'error' in ae && ae.error && typeof ae.error.errorMessage === 'string' ?
@@ -25,63 +21,42 @@ export function setupEvents(pi, history, broadcastFn) {
25
21
  // Skip silent user aborts (no message); only surface genuine failures.
26
22
  if (errorMessage || ae.reason === 'error') {
27
23
  const message = errorMessage || 'Request failed';
28
- history.addError(message);
29
- broadcastFn({ type: 'agent_error', message });
24
+ addError(message);
30
25
  void pushNotify('Agent error', message, 'pi-error').catch(() => { });
31
26
  }
32
27
  }
33
28
  });
34
29
  pi.on('message_end', (_event, _ctx) => {
35
- broadcastFn({ type: 'text_end' });
30
+ textEnd();
36
31
  });
37
32
  pi.on('tool_execution_start', (event, _ctx) => {
38
- pendingArgs.set(event.toolCallId, event.args);
39
- broadcastFn({
40
- type: 'tool_start',
41
- toolCallId: event.toolCallId,
42
- toolName: event.toolName,
43
- args: event.args
44
- });
33
+ startTool(event.toolCallId, event.toolName, event.args);
45
34
  });
46
35
  pi.on('tool_execution_update', (event, _ctx) => {
47
- broadcastFn({
48
- type: 'tool_update',
49
- toolCallId: event.toolCallId,
50
- partialResult: event.partialResult
51
- });
36
+ updateTool(event.toolCallId, event.partialResult);
52
37
  });
53
38
  pi.on('tool_execution_end', (event, _ctx) => {
54
- const args = pendingArgs.get(event.toolCallId);
55
- pendingArgs.delete(event.toolCallId);
56
- currentTools.push({
57
- toolName: event.toolName,
58
- args,
59
- result: event.result,
60
- isError: event.isError
61
- });
62
- broadcastFn({
63
- type: 'tool_end',
64
- toolCallId: event.toolCallId,
65
- toolName: event.toolName,
66
- result: event.result,
67
- isError: event.isError
68
- });
39
+ endTool(event.toolCallId, event.toolName, event.result, event.isError);
69
40
  });
70
41
  pi.on('agent_end', (_event, ctx) => {
71
- const contextUsage = ctx.getContextUsage();
72
42
  setAgentIdle(true);
73
- // Remember it so a browser that connects mid-session gets its bar seeded.
74
- getBridge().lastContextUsage = contextUsage;
75
- history.addAssistantTurn(currentText, [...currentTools]);
76
- broadcastFn({ type: 'agent_end', contextUsage });
43
+ agentEnd(ctx.getContextUsage());
77
44
  void pushNotify('Task finished', '', 'pi-end').catch(() => { });
78
- currentText = '';
79
- currentTools.length = 0;
80
45
  });
81
46
  pi.on('input', (event, _ctx) => {
82
47
  if (event.source === 'interactive' && typeof event.text === 'string') {
83
- history.addUserMessage(event.text);
84
- broadcastFn({ type: 'user_message', text: event.text });
48
+ addUserTurn(event.text);
85
49
  }
86
50
  });
51
+ // Context-window compaction (incl. the auto-compaction triggered by a context
52
+ // overflow) is invisible to a remote viewer otherwise — mirror it as a toast so
53
+ // they see the same "compacting…" status the terminal shows.
54
+ pi.on('session_before_compact', (_event, _ctx) => {
55
+ publishNotify('Context full — compacting…', 'warning');
56
+ });
57
+ pi.on('session_compact', (_event, _ctx) => {
58
+ // Persistent inline note so it's still visible after a reconnect, not just a
59
+ // transient toast.
60
+ addSystemNote('Context compacted');
61
+ });
87
62
  }
@@ -1,13 +1,24 @@
1
- export interface ToolSummary {
1
+ export interface TextPart {
2
+ kind: 'text';
3
+ text: string;
4
+ }
5
+ export interface ToolPart {
6
+ kind: 'tool';
7
+ toolCallId: string;
2
8
  toolName: string;
3
9
  args: unknown;
4
10
  result: unknown;
5
11
  isError: boolean;
12
+ /** false while the tool is still running (result not in yet). */
13
+ done: boolean;
6
14
  }
15
+ export type Part = TextPart | ToolPart;
7
16
  export interface Turn {
8
- role: 'user' | 'assistant';
9
- text: string;
10
- tools: ToolSummary[];
17
+ role: 'user' | 'assistant' | 'system';
18
+ /** User text, error text, or a system note. Assistant content lives in `parts`. */
19
+ text?: string;
20
+ /** Ordered assistant content (text + tools). */
21
+ parts?: Part[];
11
22
  error?: boolean;
12
23
  }
13
24
  export declare class HistoryBuffer {
@@ -15,8 +26,10 @@ export declare class HistoryBuffer {
15
26
  private readonly limit;
16
27
  constructor(limit?: number);
17
28
  addUserMessage(text: string): void;
18
- addAssistantTurn(text: string, tools: ToolSummary[]): void;
29
+ addAssistantTurn(parts: Part[]): void;
19
30
  addError(text: string): void;
31
+ /** A persistent, inline system note (e.g. "Context compacted"). */
32
+ addSystemNote(text: string): void;
20
33
  getEntries(): Turn[];
21
34
  private _push;
22
35
  }
@@ -1,3 +1,6 @@
1
+ // An assistant turn is an ORDERED list of parts — text segments and tool calls
2
+ // interleaved exactly as they happened — so the remote transcript reproduces the
3
+ // terminal's layout instead of collapsing a whole run into one text blob.
1
4
  export class HistoryBuffer {
2
5
  entries = [];
3
6
  limit;
@@ -5,13 +8,17 @@ export class HistoryBuffer {
5
8
  this.limit = limit;
6
9
  }
7
10
  addUserMessage(text) {
8
- this._push({ role: 'user', text, tools: [] });
11
+ this._push({ role: 'user', text });
9
12
  }
10
- addAssistantTurn(text, tools) {
11
- this._push({ role: 'assistant', text, tools });
13
+ addAssistantTurn(parts) {
14
+ this._push({ role: 'assistant', parts });
12
15
  }
13
16
  addError(text) {
14
- this._push({ role: 'assistant', text, tools: [], error: true });
17
+ this._push({ role: 'assistant', text, error: true });
18
+ }
19
+ /** A persistent, inline system note (e.g. "Context compacted"). */
20
+ addSystemNote(text) {
21
+ this._push({ role: 'system', text });
15
22
  }
16
23
  getEntries() {
17
24
  return [...this.entries];
@@ -9,9 +9,9 @@ export interface PromptResolvedMessage {
9
9
  type: 'prompt_resolved';
10
10
  id: string;
11
11
  }
12
+ /** The single task-widget slot. `lines: null` clears it. */
12
13
  export interface WidgetMessage {
13
14
  type: 'widget';
14
- key: string;
15
15
  lines: string[] | null;
16
16
  }
17
17
  export interface NotifyMessage {
@@ -40,10 +40,13 @@ export interface ContextMessage {
40
40
  export interface ResetMessage {
41
41
  type: 'reset';
42
42
  }
43
- /** Server browser messages added by the integration. The existing
44
- * history / text_delta / tool_* / agent_* / client_count / user_message messages are
45
- * emitted ad hoc by events.ts / server.ts and are not enumerated here. */
46
- export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage | ResetMessage;
43
+ /** The full authoritative state sent to a (re)connecting client. Defined in
44
+ * session-state.ts (its serializer); re-exported here as part of the wire type. */
45
+ export type { SnapshotMessage } from './session-state.js';
46
+ /** Server browser messages. The live text_delta / tool_* / agent_* /
47
+ * client_count / user_message deltas are emitted by the SessionState mutators
48
+ * and not all enumerated here; the snapshot below carries the full state. */
49
+ export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage | ResetMessage | import('./session-state.js').SnapshotMessage;
47
50
  /** Browser → server messages. */
48
51
  export interface ClientChatMessage {
49
52
  type: 'message';
@@ -1,7 +1,6 @@
1
- import { broadcast } from './broadcast.js';
2
1
  import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx } from './bridge.js';
3
2
  import { setupEvents } from './events.js';
4
- import { HistoryBuffer } from './history.js';
3
+ import { reset, addUserTurn } from './session-state.js';
5
4
  import { html } from './ui.js';
6
5
  import { qrLines } from './qr.js';
7
6
  import { startServer, formatAddresses } from './server.js';
@@ -12,7 +11,6 @@ if (!_g.__piRemote)
12
11
  _g.__piRemote = { server: null, send: null, serveResult: null };
13
12
  const S = _g.__piRemote;
14
13
  export function registerRemote(pi) {
15
- const history = new HistoryBuffer(20);
16
14
  async function ensureServer() {
17
15
  if (S.server)
18
16
  return S.server;
@@ -29,7 +27,7 @@ export function registerRemote(pi) {
29
27
  }
30
28
  dispatchRemoteLine(text, {
31
29
  onPlain: plain => {
32
- history.addUserMessage(plain);
30
+ addUserTurn(plain);
33
31
  if (isAgentIdle()) {
34
32
  S.send?.(plain);
35
33
  }
@@ -38,7 +36,7 @@ export function registerRemote(pi) {
38
36
  }
39
37
  }
40
38
  });
41
- }, wsUrl => html(wsUrl), () => history.getEntries());
39
+ }, wsUrl => html(wsUrl));
42
40
  // Hands-off HTTPS: point Tailscale serve at our port so phones get a
43
41
  // secure context. Best-effort — any failure degrades to the http URL.
44
42
  S.serveResult = await ensureTailscaleServe(S.server.port).catch(() => ({ state: 'unavailable' }));
@@ -47,13 +45,11 @@ export function registerRemote(pi) {
47
45
  pi.on('session_start', (_event, ctx) => {
48
46
  S.send = (text, opts) => (opts ? pi.sendUserMessage(text, opts) : pi.sendUserMessage(text));
49
47
  // A new session (incl. /new and the /task handoff's newSession) means the
50
- // browser is showing a stale transcript/widgets — wipe both ends.
48
+ // browser is showing a stale transcript/widgets — wipe the authoritative
49
+ // state and tell connected clients to clear.
51
50
  const bridge = getBridge();
52
- bridge.activeWidgets.clear();
53
- bridge.activePrompt = null;
54
- bridge.lastContextUsage = null;
55
- broadcast({ type: 'reset' });
56
- setupEvents(pi, history, broadcast);
51
+ reset();
52
+ setupEvents(pi);
57
53
  // Seed a shimmed ctx so commands that don't need newSession (/task-list,
58
54
  // /task-cancel, /task-auto-cancel) work immediately from the remote without
59
55
  // any terminal interaction. Only overwrite if null or already shimmed —
@@ -1,4 +1,3 @@
1
- import type { Turn } from './history.js';
2
1
  export interface LocalIPs {
3
2
  /** Tailscale (tailscale0) IPv4 address, if the interface is up. */
4
3
  tailscale?: string;
@@ -27,5 +26,5 @@ export declare function getLocalIPs(nets?: NodeJS.Dict<import("node:os").Network
27
26
  /** Build the labeled URL lines shown under the QR code. Both Tailscale and LAN
28
27
  * when present; a single unlabeled primary URL when neither resolves. */
29
28
  export declare function formatAddresses(ips: LocalIPs, port: number): AddressLine[];
30
- export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string, getHistory: () => Turn[]): Promise<ServerHandle>;
29
+ export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string): Promise<ServerHandle>;
31
30
  export {};
@@ -2,7 +2,8 @@ import { createServer } from 'node:http';
2
2
  import { networkInterfaces } from 'node:os';
3
3
  import { WebSocketServer } from 'ws';
4
4
  import { addClient, removeClient, clientCount, broadcast, sendTo } from './broadcast.js';
5
- import { getBridge, answerPrompt } from './bridge.js';
5
+ import { answerPrompt } from './bridge.js';
6
+ import { getState, snapshot } from './session-state.js';
6
7
  import { isClientMessage } from './protocol.js';
7
8
  import { swJs } from './sw.js';
8
9
  import { publicKey, addSubscription, getSubscriptions, logPush } from './push.js';
@@ -59,7 +60,7 @@ async function findPort(start, max) {
59
60
  }
60
61
  throw new Error(`No free port found in range ${start}–${start + max - 1}`);
61
62
  }
62
- export async function startServer(onMessage, getHtml, getHistory) {
63
+ export async function startServer(onMessage, getHtml) {
63
64
  const port = await findPort(8800, 100);
64
65
  const ips = getLocalIPs();
65
66
  const ip = ips.primary;
@@ -118,16 +119,8 @@ export async function startServer(onMessage, getHtml, getHistory) {
118
119
  addClient(ws);
119
120
  handle.onFirstConnect?.();
120
121
  handle.onFirstConnect = null;
121
- sendTo(ws, { type: 'history', turns: getHistory() });
122
- const bridge = getBridge();
123
- for (const [key, lines] of bridge.activeWidgets) {
124
- sendTo(ws, { type: 'widget', key, lines });
125
- }
126
- if (bridge.activePrompt)
127
- sendTo(ws, bridge.activePrompt);
128
- if (bridge.lastContextUsage) {
129
- sendTo(ws, { type: 'context', contextUsage: bridge.lastContextUsage });
130
- }
122
+ // One authoritative snapshot the client replaces its whole view with it.
123
+ sendTo(ws, snapshot());
131
124
  broadcast({ type: 'client_count', count: clientCount() });
132
125
  ws.on('message', data => {
133
126
  let msg;
@@ -145,7 +138,7 @@ export async function startServer(onMessage, getHtml, getHistory) {
145
138
  }
146
139
  // type === 'message': ignore while a prompt is pending (composer is
147
140
  // disabled in the browser; this is the server-side guard).
148
- if (getBridge().activePrompt)
141
+ if (getState().prompt)
149
142
  return;
150
143
  onMessage(msg.text);
151
144
  });
@@ -0,0 +1,54 @@
1
+ import { HistoryBuffer } from './history.js';
2
+ import type { Turn, Part } from './history.js';
3
+ import type { ContextUsage, PromptMessage } from './protocol.js';
4
+ export interface LiveTurn {
5
+ /** Ordered assistant content (text segments + tool calls) for this run. */
6
+ parts: Part[];
7
+ /** Is the trailing text part still accumulating deltas? Closed by text_end /
8
+ * a tool start, so the next text begins a fresh segment (separate bubble). */
9
+ textOpen: boolean;
10
+ }
11
+ export interface SnapshotMessage {
12
+ type: 'snapshot';
13
+ turns: Turn[];
14
+ live: LiveTurn | null;
15
+ agentRunning: boolean;
16
+ taskWidget: string[] | null;
17
+ prompt: PromptMessage | null;
18
+ context: ContextUsage | null;
19
+ }
20
+ interface SessionState {
21
+ history: HistoryBuffer;
22
+ live: LiveTurn | null;
23
+ agentRunning: boolean;
24
+ taskWidget: string[] | null;
25
+ prompt: PromptMessage | null;
26
+ context: ContextUsage | null;
27
+ /** Broadcast sink — swapped in tests via _setSink. */
28
+ sink: (msg: unknown) => void;
29
+ }
30
+ export declare function getState(): SessionState;
31
+ /** @internal Test-only: swap the broadcast sink to capture emitted deltas. */
32
+ export declare function _setSink(fn: (msg: unknown) => void): void;
33
+ export declare function agentStart(): void;
34
+ export declare function appendText(delta: string): void;
35
+ export declare function textEnd(): void;
36
+ export declare function startTool(toolCallId: string, toolName: string, args: unknown): void;
37
+ export declare function updateTool(toolCallId: string, partialResult: unknown): void;
38
+ export declare function endTool(toolCallId: string, toolName: string, result: unknown, isError: boolean): void;
39
+ export declare function agentEnd(context: ContextUsage): void;
40
+ export declare function addUserTurn(text: string): void;
41
+ export declare function addError(message: string): void;
42
+ /** A persistent inline note (committed to the transcript so it survives reconnect)
43
+ * plus a live delta so connected clients render it immediately. */
44
+ export declare function addSystemNote(text: string): void;
45
+ /** The single task-widget slot. Empty/undefined lines clear it. */
46
+ export declare function setTaskWidget(lines: string[] | null | undefined): void;
47
+ export declare function setPrompt(prompt: PromptMessage): void;
48
+ export declare function clearPrompt(id: string): void;
49
+ export declare function setContext(context: ContextUsage): void;
50
+ /** Wipe everything (new session) and tell connected clients to clear. */
51
+ export declare function reset(): void;
52
+ /** Serialize the whole state for a (re)connecting client. */
53
+ export declare function snapshot(): SnapshotMessage;
54
+ export {};
@@ -0,0 +1,179 @@
1
+ // The single authoritative mirror of what a connected browser should be showing.
2
+ //
3
+ // Every change funnels through a mutator that (1) updates this object and (2)
4
+ // broadcasts the matching live delta. On (re)connect the server serializes the
5
+ // whole object with snapshot() and the client replaces its entire view. Because
6
+ // the snapshot and the live deltas read/write the same object, they can never
7
+ // disagree — which is what kills the duplicate-transcript / orphaned-widget /
8
+ // two-task-widget drift the ad-hoc broadcasting used to cause.
9
+ //
10
+ // State lives on globalThis so it survives jiti module re-evaluation on session
11
+ // switches, the same pattern broadcast.ts and bridge.ts use.
12
+ import { broadcast as wsBroadcast } from './broadcast.js';
13
+ import { HistoryBuffer } from './history.js';
14
+ const g = globalThis;
15
+ function fresh() {
16
+ return {
17
+ history: new HistoryBuffer(20),
18
+ live: null,
19
+ agentRunning: false,
20
+ taskWidget: null,
21
+ prompt: null,
22
+ context: null,
23
+ sink: wsBroadcast
24
+ };
25
+ }
26
+ export function getState() {
27
+ if (!g.__piSessionState)
28
+ g.__piSessionState = fresh();
29
+ return g.__piSessionState;
30
+ }
31
+ /** @internal Test-only: swap the broadcast sink to capture emitted deltas. */
32
+ export function _setSink(fn) {
33
+ getState().sink = fn;
34
+ }
35
+ function ensureLive(s) {
36
+ if (!s.live)
37
+ s.live = { parts: [], textOpen: false };
38
+ return s.live;
39
+ }
40
+ // ─── Mutators ────────────────────────────────────────────────────────────────
41
+ export function agentStart() {
42
+ const s = getState();
43
+ s.live = { parts: [], textOpen: false };
44
+ s.agentRunning = true;
45
+ s.sink({ type: 'agent_start' });
46
+ }
47
+ export function appendText(delta) {
48
+ const s = getState();
49
+ const live = ensureLive(s);
50
+ const last = live.parts[live.parts.length - 1];
51
+ if (live.textOpen && last && last.kind === 'text') {
52
+ last.text += delta;
53
+ }
54
+ else {
55
+ live.parts.push({ kind: 'text', text: delta });
56
+ live.textOpen = true;
57
+ }
58
+ s.sink({ type: 'text_delta', delta });
59
+ }
60
+ export function textEnd() {
61
+ const s = getState();
62
+ if (s.live)
63
+ s.live.textOpen = false; // next text starts a new segment
64
+ s.sink({ type: 'text_end' });
65
+ }
66
+ export function startTool(toolCallId, toolName, args) {
67
+ const s = getState();
68
+ const live = ensureLive(s);
69
+ live.textOpen = false;
70
+ live.parts.push({
71
+ kind: 'tool',
72
+ toolCallId,
73
+ toolName,
74
+ args,
75
+ result: undefined,
76
+ isError: false,
77
+ done: false
78
+ });
79
+ s.sink({ type: 'tool_start', toolCallId, toolName, args });
80
+ }
81
+ export function updateTool(toolCallId, partialResult) {
82
+ getState().sink({ type: 'tool_update', toolCallId, partialResult });
83
+ }
84
+ export function endTool(toolCallId, toolName, result, isError) {
85
+ const s = getState();
86
+ const live = ensureLive(s);
87
+ const part = live.parts.find((p) => p.kind === 'tool' && p.toolCallId === toolCallId);
88
+ if (part) {
89
+ part.result = result;
90
+ part.isError = isError;
91
+ part.done = true;
92
+ }
93
+ else {
94
+ // tool_end without a matching start (shouldn't happen) — append a done tool.
95
+ live.parts.push({
96
+ kind: 'tool',
97
+ toolCallId,
98
+ toolName,
99
+ args: undefined,
100
+ result,
101
+ isError,
102
+ done: true
103
+ });
104
+ }
105
+ s.sink({ type: 'tool_end', toolCallId, toolName, result, isError });
106
+ }
107
+ export function agentEnd(context) {
108
+ const s = getState();
109
+ if (s.live)
110
+ s.history.addAssistantTurn(s.live.parts);
111
+ s.live = null;
112
+ s.agentRunning = false;
113
+ s.context = context;
114
+ s.sink({ type: 'agent_end', contextUsage: context });
115
+ }
116
+ export function addUserTurn(text) {
117
+ const s = getState();
118
+ s.history.addUserMessage(text);
119
+ s.sink({ type: 'user_message', text });
120
+ }
121
+ export function addError(message) {
122
+ const s = getState();
123
+ s.history.addError(message);
124
+ s.live = null;
125
+ s.agentRunning = false;
126
+ s.sink({ type: 'agent_error', message });
127
+ }
128
+ /** A persistent inline note (committed to the transcript so it survives reconnect)
129
+ * plus a live delta so connected clients render it immediately. */
130
+ export function addSystemNote(text) {
131
+ const s = getState();
132
+ s.history.addSystemNote(text);
133
+ s.sink({ type: 'system_note', text });
134
+ }
135
+ /** The single task-widget slot. Empty/undefined lines clear it. */
136
+ export function setTaskWidget(lines) {
137
+ const s = getState();
138
+ s.taskWidget = lines && lines.length ? lines : null;
139
+ s.sink({ type: 'widget', lines: s.taskWidget });
140
+ }
141
+ export function setPrompt(prompt) {
142
+ const s = getState();
143
+ s.prompt = prompt;
144
+ s.sink(prompt);
145
+ }
146
+ export function clearPrompt(id) {
147
+ const s = getState();
148
+ s.prompt = null;
149
+ s.sink({ type: 'prompt_resolved', id });
150
+ }
151
+ export function setContext(context) {
152
+ const s = getState();
153
+ s.context = context;
154
+ s.sink({ type: 'context', contextUsage: context });
155
+ }
156
+ /** Wipe everything (new session) and tell connected clients to clear. */
157
+ export function reset() {
158
+ const s = getState();
159
+ s.history = new HistoryBuffer(20);
160
+ s.live = null;
161
+ s.agentRunning = false;
162
+ s.taskWidget = null;
163
+ s.prompt = null;
164
+ s.context = null;
165
+ s.sink({ type: 'reset' });
166
+ }
167
+ /** Serialize the whole state for a (re)connecting client. */
168
+ export function snapshot() {
169
+ const s = getState();
170
+ return {
171
+ type: 'snapshot',
172
+ turns: s.history.getEntries(),
173
+ live: s.live ? { parts: [...s.live.parts], textOpen: s.live.textOpen } : null,
174
+ agentRunning: s.agentRunning,
175
+ taskWidget: s.taskWidget,
176
+ prompt: s.prompt,
177
+ context: s.context
178
+ };
179
+ }
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);
@@ -276,12 +284,13 @@ export function html(wsUrl) {
276
284
  const statusPanel = document.getElementById('status-panel');
277
285
  // Widgets are keyed (e.g. 'pi-tasks', 'pi-task-auto'); track them per key so a
278
286
  // clear for one key can't be masked by a stale message from another.
279
- const widgets = {};
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;
280
291
  function renderWidgets() {
281
- let all = [];
282
- for (const k in widgets) if (widgets[k] && widgets[k].length) all = all.concat(widgets[k]);
283
- if (all.length) {
284
- statusPanel.textContent = all.join('\\n');
292
+ if (taskWidgetLines && taskWidgetLines.length) {
293
+ statusPanel.textContent = taskWidgetLines.join('\\n');
285
294
  statusPanel.style.display = 'block';
286
295
  } else {
287
296
  statusPanel.style.display = 'none';
@@ -488,30 +497,46 @@ export function html(wsUrl) {
488
497
  let spinTimer = null;
489
498
  let spinIdx = 0;
490
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
+ }
491
521
  function showThinking() {
492
522
  if (!thinkingEl) {
493
523
  thinkingEl = document.createElement('div');
494
524
  thinkingEl.className = 'bubble assistant thinking';
495
- thinkingEl.innerHTML = '<span class="spinner"></span>';
496
- }
497
- if (!spinTimer) {
498
- var sp = thinkingEl.firstChild;
499
- sp.textContent = SPIN[spinIdx % SPIN.length];
500
- spinTimer = setInterval(function () {
501
- spinIdx = (spinIdx + 1) % SPIN.length;
502
- sp.textContent = SPIN[spinIdx];
503
- }, 90);
525
+ thinkingEl.innerHTML = '<span class="spinner spin"></span>';
504
526
  }
505
527
  chatLog.appendChild(thinkingEl); // append (or move) to bottom
528
+ startSpin();
506
529
  scrollBottom();
507
530
  }
508
531
  function hideThinking() {
509
- if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
510
532
  if (thinkingEl) thinkingEl.remove();
533
+ stopSpinIfIdle();
511
534
  }
512
535
 
513
536
  function addToolCall(toolName, argsStr, isError) {
514
- 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);
515
540
  const d = document.createElement('details');
516
541
  d.className = 'tool-call' + (isError ? ' error' : '');
517
542
  const s = document.createElement('summary');
@@ -528,6 +553,82 @@ export function html(wsUrl) {
528
553
  sendBtn.disabled = !allow;
529
554
  }
530
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
+
531
632
  function showToast(message, level) {
532
633
  const t = document.createElement('div');
533
634
  t.className = 'toast ' + (level || 'info');
@@ -731,19 +832,27 @@ export function html(wsUrl) {
731
832
 
732
833
  function handleMsg(msg) {
733
834
  switch (msg.type) {
734
- case 'history':
735
- for (const t of (msg.turns || [])) {
736
- if (t.error) { addBubble('error', t.text); continue; }
737
- addBubble(t.role, t.text);
738
- for (const tool of (t.tools || [])) {
739
- const d = addToolCall(tool.toolName, JSON.stringify(tool.args), tool.isError);
740
- const pre = document.createElement('pre');
741
- const r = typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2);
742
- pre.textContent = r.slice(0, 8000);
743
- d.appendChild(pre);
744
- }
745
- }
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();
746
854
  break;
855
+ }
747
856
  case 'agent_start':
748
857
  autoScroll = true;
749
858
  streamText = '';
@@ -757,9 +866,10 @@ export function html(wsUrl) {
757
866
  currentBubble = document.createElement('div');
758
867
  currentBubble.className = 'bubble assistant';
759
868
  const cursor = document.createElement('span');
760
- cursor.className = 'cursor';
869
+ cursor.className = 'cursor spin';
761
870
  currentBubble.appendChild(cursor);
762
871
  chatLog.appendChild(currentBubble);
872
+ startSpin();
763
873
  }
764
874
  streamText += msg.delta;
765
875
  {
@@ -773,6 +883,11 @@ export function html(wsUrl) {
773
883
  const c = currentBubble.querySelector('.cursor');
774
884
  if (c) c.remove();
775
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();
776
891
  }
777
892
  break;
778
893
  case 'tool_start': {
@@ -786,8 +901,7 @@ export function html(wsUrl) {
786
901
  if (d) {
787
902
  if (msg.isError) d.classList.add('error');
788
903
  const pre = document.createElement('pre');
789
- const r = typeof msg.result === 'string' ? msg.result : JSON.stringify(msg.result, null, 2);
790
- pre.textContent = r.slice(0, 8000);
904
+ pre.textContent = toolResultText(msg.result);
791
905
  d.appendChild(pre);
792
906
  delete toolCallMap[msg.toolCallId];
793
907
  }
@@ -800,6 +914,9 @@ export function html(wsUrl) {
800
914
  case 'user_message':
801
915
  addBubble('user', msg.text);
802
916
  break;
917
+ case 'system_note':
918
+ addSystemLine(msg.text);
919
+ break;
803
920
  case 'agent_error':
804
921
  hideThinking();
805
922
  if (currentBubble) {
@@ -808,6 +925,7 @@ export function html(wsUrl) {
808
925
  if (streamText) setContent(currentBubble, streamText);
809
926
  currentBubble = null;
810
927
  streamText = '';
928
+ stopSpinIfIdle();
811
929
  }
812
930
  addBubble('error', msg.message || 'Error');
813
931
  setEnabled(true);
@@ -833,8 +951,7 @@ export function html(wsUrl) {
833
951
  if (activePromptId === msg.id) closePrompt();
834
952
  break;
835
953
  case 'widget':
836
- if (msg.lines && msg.lines.length) widgets[msg.key] = msg.lines;
837
- else delete widgets[msg.key];
954
+ taskWidgetLines = (msg.lines && msg.lines.length) ? msg.lines : null;
838
955
  renderWidgets();
839
956
  break;
840
957
  case 'notify':
@@ -850,7 +967,7 @@ export function html(wsUrl) {
850
967
  hideThinking();
851
968
  currentBubble = null; streamText = '';
852
969
  closePrompt();
853
- for (const k in widgets) delete widgets[k];
970
+ taskWidgetLines = null;
854
971
  renderWidgets();
855
972
  contextFill.style.width = '0%';
856
973
  break;
@@ -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'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",