@mjasnikovs/pi-task 0.5.0 → 0.6.0

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.
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
- import type { PromptMessage, ServerMessage } from './protocol.js';
2
+ import type { ContextUsage, PromptMessage, 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>;
@@ -7,6 +7,8 @@ export interface BridgeState {
7
7
  activePrompt: PromptMessage | null;
8
8
  /** Last lines pushed per widget key (replayed to late joiners). */
9
9
  activeWidgets: Map<string, string[]>;
10
+ /** Most recent context-window usage (replayed to seed late joiners' bar), or null. */
11
+ lastContextUsage: ContextUsage | null;
10
12
  nextId: number;
11
13
  /** Command name → handler, populated as pi-task registers its commands. */
12
14
  commands: Map<string, (args: string, ctx: ExtensionCommandContext) => unknown>;
@@ -6,6 +6,7 @@ export function getBridge() {
6
6
  pending: new Map(),
7
7
  activePrompt: null,
8
8
  activeWidgets: new Map(),
9
+ lastContextUsage: null,
9
10
  nextId: 0,
10
11
  commands: new Map(),
11
12
  currentCtx: null,
@@ -1,4 +1,5 @@
1
1
  import { setAgentIdle } from './state.js';
2
+ import { getBridge } from './bridge.js';
2
3
  export function setupEvents(pi, history, broadcastFn) {
3
4
  let currentText = '';
4
5
  const currentTools = [];
@@ -67,6 +68,8 @@ export function setupEvents(pi, history, broadcastFn) {
67
68
  pi.on('agent_end', (_event, ctx) => {
68
69
  const contextUsage = ctx.getContextUsage();
69
70
  setAgentIdle(true);
71
+ // Remember it so a browser that connects mid-session gets its bar seeded.
72
+ getBridge().lastContextUsage = contextUsage;
70
73
  history.addAssistantTurn(currentText, [...currentTools]);
71
74
  broadcastFn({ type: 'agent_end', contextUsage });
72
75
  currentText = '';
@@ -24,10 +24,21 @@ export interface ViewerMessage {
24
24
  title: string;
25
25
  text: string;
26
26
  }
27
+ export interface ContextUsage {
28
+ tokens?: number;
29
+ contextWindow?: number;
30
+ percent?: number;
31
+ }
32
+ /** Seeds the context-usage bar for a freshly-connected client (the live value is
33
+ * otherwise only carried on agent_end). */
34
+ export interface ContextMessage {
35
+ type: 'context';
36
+ contextUsage: ContextUsage;
37
+ }
27
38
  /** Server → browser messages added by the integration. The existing
28
39
  * history / text_delta / tool_* / agent_* / client_count / user_message messages are
29
40
  * emitted ad hoc by events.ts / server.ts and are not enumerated here. */
30
- export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage;
41
+ export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage;
31
42
  /** Browser → server messages. */
32
43
  export interface ClientChatMessage {
33
44
  type: 'message';
@@ -5,10 +5,11 @@ import { HistoryBuffer } from './history.js';
5
5
  import { html } from './ui.js';
6
6
  import { qrLines } from './qr.js';
7
7
  import { startServer, formatAddresses } from './server.js';
8
+ import { ensureTailscaleServe, teardownTailscaleServe, planRemoteUrls } from './tailscale.js';
8
9
  import { isAgentIdle } from './state.js';
9
10
  const _g = globalThis;
10
11
  if (!_g.__piRemote)
11
- _g.__piRemote = { server: null, send: null };
12
+ _g.__piRemote = { server: null, send: null, serveResult: null };
12
13
  const S = _g.__piRemote;
13
14
  export function registerRemote(pi) {
14
15
  const history = new HistoryBuffer(20);
@@ -38,6 +39,9 @@ export function registerRemote(pi) {
38
39
  }
39
40
  });
40
41
  }, wsUrl => html(wsUrl), () => history.getEntries());
42
+ // Hands-off HTTPS: point Tailscale serve at our port so phones get a
43
+ // secure context. Best-effort — any failure degrades to the http URL.
44
+ S.serveResult = await ensureTailscaleServe(S.server.port).catch(() => ({ state: 'unavailable' }));
41
45
  return S.server;
42
46
  }
43
47
  pi.on('session_start', (_event, ctx) => {
@@ -50,7 +54,8 @@ export function registerRemote(pi) {
50
54
  // session_start (it's updated via withSession or registerBridgeCommand,
51
55
  // not replaced here).
52
56
  const b = getBridge();
53
- if (!b.currentCtx || b.currentCtx['__piRemoteShimmed'] === true) {
57
+ if (!b.currentCtx
58
+ || b.currentCtx['__piRemoteShimmed'] === true) {
54
59
  b.currentCtx = makeShimmedCtx(ctx);
55
60
  }
56
61
  void ensureServer().catch(err => ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error'));
@@ -58,8 +63,11 @@ export function registerRemote(pi) {
58
63
  pi.on('session_shutdown', (event, _ctx) => {
59
64
  if (event.reason === 'quit') {
60
65
  if (S.server) {
66
+ const port = S.server.port;
61
67
  S.server.stop();
62
68
  S.server = null;
69
+ S.serveResult = null;
70
+ void teardownTailscaleServe(port).catch(() => { });
63
71
  }
64
72
  S.send = null;
65
73
  }
@@ -69,8 +77,11 @@ export function registerRemote(pi) {
69
77
  handler: async (args, ctx) => {
70
78
  if (args.trim() === 'stop') {
71
79
  if (S.server) {
80
+ const port = S.server.port;
72
81
  S.server.stop();
73
82
  S.server = null;
83
+ S.serveResult = null;
84
+ void teardownTailscaleServe(port).catch(() => { });
74
85
  ctx.ui.notify('Remote server stopped', 'info');
75
86
  }
76
87
  else {
@@ -82,11 +93,17 @@ export function registerRemote(pi) {
82
93
  getBridge().currentCtx = ctx;
83
94
  try {
84
95
  const server = await ensureServer();
85
- const primaryUrl = `http://${server.ip}:${server.port}`;
96
+ const httpPrimary = `http://${server.ip}:${server.port}`;
97
+ const result = S.serveResult ?? { state: 'unavailable' };
98
+ const plan = planRemoteUrls(httpPrimary, result);
99
+ const primaryUrl = plan.primaryUrl;
86
100
  const qr = await qrLines(primaryUrl);
87
- const addrs = formatAddresses(server.ips, server.port);
101
+ const addrs = [...plan.urlLines, ...formatAddresses(server.ips, server.port)];
88
102
  const labelW = addrs.reduce((m, a) => Math.max(m, a.label.length), 0);
89
- const addrLines = addrs.map(a => a.label ? `${a.label.padEnd(labelW)} ${a.url}` : a.url);
103
+ const addrLines = [
104
+ ...addrs.map(a => (a.label ? `${a.label.padEnd(labelW)} ${a.url}` : a.url)),
105
+ ...plan.hintLines
106
+ ];
90
107
  const addrWidth = addrLines.reduce((m, l) => Math.max(m, l.length), 0);
91
108
  if (ctx.mode === 'tui') {
92
109
  // eslint-disable-next-line no-control-regex -- strip ANSI SGR escapes to measure visible width
@@ -95,6 +95,9 @@ export async function startServer(onMessage, getHtml, getHistory) {
95
95
  }
96
96
  if (bridge.activePrompt)
97
97
  sendTo(ws, bridge.activePrompt);
98
+ if (bridge.lastContextUsage) {
99
+ sendTo(ws, { type: 'context', contextUsage: bridge.lastContextUsage });
100
+ }
98
101
  broadcast({ type: 'client_count', count: clientCount() });
99
102
  ws.on('message', data => {
100
103
  let msg;
@@ -0,0 +1,45 @@
1
+ /** Injectable command runner so serve orchestration is unit-testable without a real CLI. */
2
+ export interface Run {
3
+ (cmd: string, args: string[]): Promise<{
4
+ stdout: string;
5
+ stderr: string;
6
+ exitCode: number;
7
+ }>;
8
+ }
9
+ export type ServeResult = {
10
+ state: 'served';
11
+ url: string;
12
+ } | {
13
+ state: 'foreign-conflict';
14
+ host: string;
15
+ } | {
16
+ state: 'certs-disabled';
17
+ host: string;
18
+ } | {
19
+ state: 'unavailable';
20
+ };
21
+ export interface RemoteUrlPlan {
22
+ /** URL to encode in the QR and announce; the https one when serve is live. */
23
+ primaryUrl: string;
24
+ /** Labeled URL lines to add to the address list (e.g. the HTTPS URL when served). */
25
+ urlLines: {
26
+ label: string;
27
+ url: string;
28
+ }[];
29
+ /** Extra lines to print under the URLs (empty when nothing to suggest). */
30
+ hintLines: string[];
31
+ }
32
+ /** The "/" proxy target of the first :443 Web handler in `tailscale serve status --json`,
33
+ * or undefined when there is no such handler / the output isn't valid serve JSON
34
+ * (e.g. the literal "No serve config"). */
35
+ export declare function serve443Target(json: string): string | undefined;
36
+ /** Ensure Tailscale serves https://<host> → http://127.0.0.1:<port>. Mutates only
37
+ * when needed and never touches a serve config we didn't create. Best-effort:
38
+ * returns a non-served state rather than throwing. */
39
+ export declare function ensureTailscaleServe(port: number, run?: Run): Promise<ServeResult>;
40
+ /** Tear down the :443 serve handler ONLY if it currently points at our port.
41
+ * Best-effort; callers should additionally `.catch()` since it may run during
42
+ * shutdown. */
43
+ export declare function teardownTailscaleServe(port: number, run?: Run): Promise<void>;
44
+ /** Pure: pick the primary URL and any hint lines from a serve result. */
45
+ export declare function planRemoteUrls(httpPrimary: string, result: ServeResult): RemoteUrlPlan;
@@ -0,0 +1,108 @@
1
+ import { execFile } from 'node:child_process';
2
+ const defaultRun = (cmd, args) => new Promise(resolve => {
3
+ execFile(cmd, args, { timeout: 5000 }, (err, stdout, stderr) => {
4
+ const exitCode = err && typeof err.code === 'number' ?
5
+ err.code
6
+ : err ? 1
7
+ : 0;
8
+ resolve({ stdout: stdout ?? '', stderr: stderr ?? '', exitCode });
9
+ });
10
+ });
11
+ /** The "/" proxy target of the first :443 Web handler in `tailscale serve status --json`,
12
+ * or undefined when there is no such handler / the output isn't valid serve JSON
13
+ * (e.g. the literal "No serve config"). */
14
+ export function serve443Target(json) {
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(json);
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ const web = parsed
23
+ .Web;
24
+ if (!web)
25
+ return undefined;
26
+ for (const key of Object.keys(web)) {
27
+ if (key.endsWith(':443'))
28
+ return web[key]?.Handlers?.['/']?.Proxy;
29
+ }
30
+ return undefined;
31
+ }
32
+ /** Tailscale MagicDNS name (trailing dot stripped), or undefined if the daemon
33
+ * isn't reachable / has no name. */
34
+ async function tailscaleHost(run) {
35
+ const status = await run('tailscale', ['status', '--json']);
36
+ if (status.exitCode !== 0)
37
+ return undefined;
38
+ try {
39
+ const parsed = JSON.parse(status.stdout);
40
+ return (parsed.Self?.DNSName ?? '').replace(/\.$/, '') || undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ /** Ensure Tailscale serves https://<host> → http://127.0.0.1:<port>. Mutates only
47
+ * when needed and never touches a serve config we didn't create. Best-effort:
48
+ * returns a non-served state rather than throwing. */
49
+ export async function ensureTailscaleServe(port, run = defaultRun) {
50
+ const host = await tailscaleHost(run);
51
+ if (!host)
52
+ return { state: 'unavailable' };
53
+ const ours = `http://127.0.0.1:${port}`;
54
+ const serve = await run('tailscale', ['serve', 'status', '--json']);
55
+ const target = serve443Target(serve.stdout);
56
+ if (target === ours)
57
+ return { state: 'served', url: `https://${host}` };
58
+ if (target !== undefined)
59
+ return { state: 'foreign-conflict', host };
60
+ // No :443 handler yet — check cert capability before trying to create one.
61
+ const cert = await run('tailscale', ['cert']);
62
+ if (/HTTPS cert support is not enabled/i.test(cert.stdout + cert.stderr)) {
63
+ return { state: 'certs-disabled', host };
64
+ }
65
+ const set = await run('tailscale', ['serve', '--bg', '--https=443', ours]);
66
+ if (set.exitCode === 0)
67
+ return { state: 'served', url: `https://${host}` };
68
+ return { state: 'certs-disabled', host };
69
+ }
70
+ /** Tear down the :443 serve handler ONLY if it currently points at our port.
71
+ * Best-effort; callers should additionally `.catch()` since it may run during
72
+ * shutdown. */
73
+ export async function teardownTailscaleServe(port, run = defaultRun) {
74
+ const serve = await run('tailscale', ['serve', 'status', '--json']);
75
+ if (serve443Target(serve.stdout) === `http://127.0.0.1:${port}`) {
76
+ await run('tailscale', ['serve', '--https=443', 'off']);
77
+ }
78
+ }
79
+ /** Pure: pick the primary URL and any hint lines from a serve result. */
80
+ export function planRemoteUrls(httpPrimary, result) {
81
+ switch (result.state) {
82
+ case 'served':
83
+ return {
84
+ primaryUrl: result.url,
85
+ urlLines: [{ label: 'HTTPS', url: result.url }],
86
+ hintLines: []
87
+ };
88
+ case 'foreign-conflict':
89
+ return {
90
+ primaryUrl: httpPrimary,
91
+ urlLines: [],
92
+ hintLines: [
93
+ 'HTTPS: port 443 is already used by another tailscale serve config; not touching it.',
94
+ ' Free it (tailscale serve --https=443 off) to enable phone notifications.'
95
+ ]
96
+ };
97
+ case 'certs-disabled':
98
+ return {
99
+ primaryUrl: httpPrimary,
100
+ urlLines: [],
101
+ hintLines: [
102
+ 'HTTPS (for phone notifications): enable HTTPS in the Tailscale admin console, then restart the remote.'
103
+ ]
104
+ };
105
+ case 'unavailable':
106
+ return { primaryUrl: httpPrimary, urlLines: [], hintLines: [] };
107
+ }
108
+ }
package/dist/remote/ui.js CHANGED
@@ -38,7 +38,7 @@ export function html(wsUrl) {
38
38
  font-family: ui-monospace, monospace; height: 100dvh;
39
39
  display: flex; flex-direction: column; overflow: hidden;
40
40
  padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)
41
- env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
41
+ 0px env(safe-area-inset-left, 0px);
42
42
  }
43
43
  #context-bar { height: 4px; background: var(--surface0); flex-shrink: 0; }
44
44
  #context-bar-fill { height: 100%; background: var(--mauve); width: 0%; transition: width 0.4s ease; }
@@ -47,8 +47,27 @@ export function html(wsUrl) {
47
47
  display: flex; justify-content: space-between; align-items: center;
48
48
  font-size: 13px; flex-shrink: 0; border-bottom: 1px solid var(--surface0);
49
49
  }
50
- #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em; }
51
- #header .status { color: var(--subtext0); font-size: 11px; }
50
+ #header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;
51
+ position: relative; animation: glitch 5s steps(1) infinite; }
52
+ @keyframes glitch {
53
+ 0%, 88%, 100% { text-shadow: none; transform: translate(0, 0); }
54
+ 90% { text-shadow: -1px 0 var(--red), 1px 0 var(--teal); transform: translate(1px, -1px); }
55
+ 92% { text-shadow: 1px 0 var(--red), -1px 0 var(--blue); transform: translate(-1px, 1px); }
56
+ 94% { text-shadow: -1px 0 var(--blue), 1px 0 var(--red); transform: translate(1px, 0); }
57
+ 96% { text-shadow: 1px 0 var(--teal), -1px 0 var(--red); transform: translate(-1px, 0); }
58
+ }
59
+ @media (prefers-reduced-motion: reduce) { #header .title { animation: none; } }
60
+ #header .status { color: var(--subtext0); font-size: 11px; display: inline-flex; align-items: center; gap: 5px; }
61
+ #header .cdot { color: var(--yellow); }
62
+ #header .cdot.up { color: var(--green); }
63
+ #header .cdot.down { color: var(--red); }
64
+ #header .hgroup { display: flex; align-items: center; gap: 10px; }
65
+ #bell {
66
+ background: none; border: none; color: var(--subtext1); cursor: pointer;
67
+ font-size: 15px; line-height: 1; padding: 2px; font-family: inherit;
68
+ }
69
+ #bell:hover { color: var(--text); }
70
+ #bell.on { color: var(--mauve); }
52
71
  #chat-log {
53
72
  flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;
54
73
  display: flex; flex-direction: column; gap: 8px;
@@ -67,17 +86,11 @@ export function html(wsUrl) {
67
86
  max-width: 100%; border: 1px solid var(--red); font-size: 12px;
68
87
  }
69
88
  .bubble.thinking {
70
- display: flex; gap: 5px; align-items: center; padding: 12px 14px;
89
+ display: flex; gap: 5px; align-items: center; padding: 10px 14px;
71
90
  }
72
- .bubble.thinking .dot {
73
- width: 7px; height: 7px; border-radius: 50%; background: var(--subtext0);
74
- animation: thinking-bounce 1.2s infinite ease-in-out both;
75
- }
76
- .bubble.thinking .dot:nth-child(1) { animation-delay: -0.24s; }
77
- .bubble.thinking .dot:nth-child(2) { animation-delay: -0.12s; }
78
- @keyframes thinking-bounce {
79
- 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
80
- 40% { transform: scale(1); opacity: 1; }
91
+ .bubble.thinking .spinner {
92
+ color: var(--mauve); font-size: 15px; line-height: 1;
93
+ font-family: ui-monospace, monospace;
81
94
  }
82
95
  .tool-call {
83
96
  background: var(--crust); border-radius: 6px; align-self: flex-start;
@@ -86,6 +99,7 @@ export function html(wsUrl) {
86
99
  .tool-call summary {
87
100
  padding: 6px 10px; color: var(--subtext1); cursor: pointer;
88
101
  user-select: none; list-style: none;
102
+ overflow-wrap: anywhere; word-break: break-word;
89
103
  }
90
104
  .tool-call summary::-webkit-details-marker { display: none; }
91
105
  .tool-call summary::before { content: "▶ "; }
@@ -116,7 +130,7 @@ export function html(wsUrl) {
116
130
  .hl-num { color: var(--blue); }
117
131
  .hl-fn { color: var(--yellow); }
118
132
  #input-bar {
119
- background: var(--mantle); padding: 10px 16px;
133
+ background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));
120
134
  display: flex; gap: 8px; flex-shrink: 0;
121
135
  border-top: 1px solid var(--surface0);
122
136
  position: relative;
@@ -194,7 +208,8 @@ export function html(wsUrl) {
194
208
  font-size: 12px; font-weight: 500; padding: 8px 10px; }
195
209
  #prompt-card button.cancel:hover { color: var(--red); filter: none; }
196
210
  #prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }
197
- .toast { position: fixed; top: 12px; right: 12px; padding: 8px 12px; border-radius: 6px;
211
+ .toast { position: fixed; top: 12px; right: 12px; max-width: calc(100vw - 24px);
212
+ padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;
198
213
  background: var(--surface1); color: var(--text); z-index: 60; }
199
214
  .toast.warning { background: var(--peach); color: var(--crust); }
200
215
  .toast.error { background: var(--red); color: var(--crust); }
@@ -208,7 +223,10 @@ export function html(wsUrl) {
208
223
  <div id="context-bar"><div id="context-bar-fill"></div></div>
209
224
  <div id="header">
210
225
  <span class="title">pi-task remote</span>
211
- <span class="status" id="client-status">connecting…</span>
226
+ <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>
228
+ <button id="bell" aria-label="Toggle notifications" title="Notifications">&#x25EF;</button>
229
+ </div>
212
230
  </div>
213
231
  <div id="chat-log"></div>
214
232
  <div id="status-panel"></div>
@@ -242,7 +260,17 @@ export function html(wsUrl) {
242
260
  const inputEl = document.getElementById('input');
243
261
  const sendBtn = document.getElementById('send');
244
262
  const contextFill = document.getElementById('context-bar-fill');
245
- const clientStatus = document.getElementById('client-status');
263
+ function setContextBar(usage) {
264
+ if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
265
+ }
266
+ const connDot = document.getElementById('conn-dot');
267
+ const clientLabel = document.getElementById('client-label');
268
+ // state: 'connecting' (○ yellow) | 'up' (● green) | 'down' (● red)
269
+ function setConn(state, label) {
270
+ connDot.textContent = state === 'connecting' ? '\\u25CB' : '\\u25CF';
271
+ connDot.className = 'cdot' + (state === 'up' ? ' up' : state === 'down' ? ' down' : '');
272
+ if (label !== undefined) clientLabel.textContent = label;
273
+ }
246
274
  const reconnectOverlay = document.getElementById('reconnect-overlay');
247
275
  const reconnectMsg = document.getElementById('reconnect-msg');
248
276
  const cmdSuggestions = document.getElementById('cmd-suggestions');
@@ -444,16 +472,28 @@ export function html(wsUrl) {
444
472
  }
445
473
 
446
474
  let thinkingEl = null;
475
+ let spinTimer = null;
476
+ let spinIdx = 0;
477
+ const SPIN = '\\u280B\\u2819\\u2839\\u2838\\u283C\\u2834\\u2826\\u2827\\u2807\\u280F';
447
478
  function showThinking() {
448
479
  if (!thinkingEl) {
449
480
  thinkingEl = document.createElement('div');
450
481
  thinkingEl.className = 'bubble assistant thinking';
451
- thinkingEl.innerHTML = '<span class="dot"></span><span class="dot"></span><span class="dot"></span>';
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);
452
491
  }
453
492
  chatLog.appendChild(thinkingEl); // append (or move) to bottom
454
493
  scrollBottom();
455
494
  }
456
495
  function hideThinking() {
496
+ if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
457
497
  if (thinkingEl) thinkingEl.remove();
458
498
  }
459
499
 
@@ -483,6 +523,61 @@ export function html(wsUrl) {
483
523
  setTimeout(function () { t.remove(); }, 4000);
484
524
  }
485
525
 
526
+ const bell = document.getElementById('bell');
527
+ const NOTIFY_KEY = 'piRemoteNotify';
528
+
529
+ function notifyEnabled() {
530
+ return localStorage.getItem(NOTIFY_KEY) === '1'
531
+ && typeof Notification !== 'undefined'
532
+ && Notification.permission === 'granted';
533
+ }
534
+
535
+ function updateBell() {
536
+ // ◉ (mauve) when armed, ◯ (dim) when off/unavailable.
537
+ var on = notifyEnabled();
538
+ bell.textContent = on ? '\\u25C9' : '\\u25EF';
539
+ bell.classList.toggle('on', on);
540
+ }
541
+
542
+ // Why notifications can't be enabled here, or null if they can.
543
+ function notifyEnvIssue() {
544
+ if (typeof Notification === 'undefined') return "This browser doesn't support notifications.";
545
+ if (!window.isSecureContext) return 'Notifications need HTTPS. Open the Tailscale https:// URL, or open via localhost.';
546
+ const isIOS = /iP(hone|ad|od)/i.test(navigator.userAgent);
547
+ const standalone = navigator.standalone === true
548
+ || (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches);
549
+ if (isIOS && !standalone) return 'On iOS: Share \\u2192 Add to Home Screen first, then enable notifications.';
550
+ return null;
551
+ }
552
+
553
+ bell.addEventListener('click', function () {
554
+ // Turning OFF always works regardless of environment.
555
+ if (localStorage.getItem(NOTIFY_KEY) === '1') {
556
+ localStorage.setItem(NOTIFY_KEY, '0'); updateBell(); return;
557
+ }
558
+ const issue = notifyEnvIssue();
559
+ if (issue) { showToast(issue, 'warning'); return; }
560
+ 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();
564
+ });
565
+ });
566
+
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) */ }
577
+ }
578
+
579
+ updateBell();
580
+
486
581
  function answer(value) {
487
582
  if (activePromptId === null) return;
488
583
  ws.send(JSON.stringify({ type: 'prompt_answer', id: activePromptId, value: value }));
@@ -663,6 +758,7 @@ export function html(wsUrl) {
663
758
  streamText = '';
664
759
  }
665
760
  addBubble('error', msg.message || 'Error');
761
+ notify('Agent error', msg.message || 'Error', 'pi-error');
666
762
  setEnabled(true);
667
763
  break;
668
764
  case 'agent_end':
@@ -670,15 +766,19 @@ export function html(wsUrl) {
670
766
  currentBubble = null;
671
767
  streamText = '';
672
768
  setEnabled(true);
673
- if (msg.contextUsage && msg.contextUsage.percent != null) {
674
- contextFill.style.width = msg.contextUsage.percent + '%';
675
- }
769
+ notify('Task finished', '', 'pi-end');
770
+ setContextBar(msg.contextUsage);
771
+ break;
772
+ case 'context':
773
+ // Seeds the bar for a client that joined mid-session.
774
+ setContextBar(msg.contextUsage);
676
775
  break;
677
776
  case 'client_count':
678
- clientStatus.textContent = msg.count + ' client' + (msg.count === 1 ? '' : 's') + ' connected';
777
+ setConn('up', msg.count + ' client' + (msg.count === 1 ? '' : 's') + ' connected');
679
778
  break;
680
779
  case 'prompt':
681
780
  showPrompt(msg);
781
+ notify('pi needs your input', msg.question, 'pi-prompt');
682
782
  break;
683
783
  case 'prompt_resolved':
684
784
  if (activePromptId === msg.id) closePrompt();
@@ -739,7 +839,7 @@ export function html(wsUrl) {
739
839
  ws.addEventListener('open', () => {
740
840
  reconnectOverlay.classList.remove('visible');
741
841
  reconnectDelay = 1000;
742
- clientStatus.textContent = 'connected';
842
+ setConn('up', 'connected');
743
843
  setEnabled(true);
744
844
  });
745
845
  ws.addEventListener('message', (e) => {
@@ -747,6 +847,7 @@ export function html(wsUrl) {
747
847
  });
748
848
  ws.addEventListener('close', () => {
749
849
  setEnabled(false);
850
+ setConn('down', 'disconnected');
750
851
  reconnectOverlay.classList.add('visible');
751
852
  reconnectMsg.textContent = 'reconnecting in ' + (reconnectDelay / 1000) + 's…';
752
853
  setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, 30000); connect(); }, reconnectDelay);
@@ -19,7 +19,7 @@ export async function runWorker(input) {
19
19
  const result = await runChildDefault(invocation, input.cwd, input.signal, {
20
20
  mode: 'json-events',
21
21
  onFirstByte: () => (tFirstByte = Date.now()),
22
- onToolCall: (call) => loopDetector.record(call)
22
+ onToolCall: call => loopDetector.record(call)
23
23
  }, input.spawn);
24
24
  const tEnd = Date.now();
25
25
  const waitMs = tFirstByte === null ? tEnd - tStart : tFirstByte - tStart;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",