@mjasnikovs/pi-task 0.6.0 → 0.7.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,4 +1,5 @@
1
1
  import { broadcast as wsBroadcast } from './broadcast.js';
2
+ import { pushNotify } from './push.js';
2
3
  const g = globalThis;
3
4
  export function getBridge() {
4
5
  if (!g.__piBridge) {
@@ -57,6 +58,8 @@ export class SessionUI {
57
58
  };
58
59
  b.activePrompt = prompt;
59
60
  b.broadcast(prompt);
61
+ // Reaches a backgrounded/suspended phone, which the in-page UI can't.
62
+ void pushNotify('pi needs your input', spec.question, 'pi-prompt').catch(() => { });
60
63
  // Local: resolves to a value/undefined, or undefined on abort. Swallow
61
64
  // the rejection some implementations throw on abort so it never leaks.
62
65
  const local = this.ctx.hasUI ?
@@ -1,5 +1,6 @@
1
1
  import { setAgentIdle } from './state.js';
2
2
  import { getBridge } from './bridge.js';
3
+ import { pushNotify } from './push.js';
3
4
  export function setupEvents(pi, history, broadcastFn) {
4
5
  let currentText = '';
5
6
  const currentTools = [];
@@ -26,6 +27,7 @@ export function setupEvents(pi, history, broadcastFn) {
26
27
  const message = errorMessage || 'Request failed';
27
28
  history.addError(message);
28
29
  broadcastFn({ type: 'agent_error', message });
30
+ void pushNotify('Agent error', message, 'pi-error').catch(() => { });
29
31
  }
30
32
  }
31
33
  });
@@ -72,6 +74,7 @@ export function setupEvents(pi, history, broadcastFn) {
72
74
  getBridge().lastContextUsage = contextUsage;
73
75
  history.addAssistantTurn(currentText, [...currentTools]);
74
76
  broadcastFn({ type: 'agent_end', contextUsage });
77
+ void pushNotify('Task finished', '', 'pi-end').catch(() => { });
75
78
  currentText = '';
76
79
  currentTools.length = 0;
77
80
  });
@@ -35,10 +35,15 @@ export interface ContextMessage {
35
35
  type: 'context';
36
36
  contextUsage: ContextUsage;
37
37
  }
38
+ /** Tells the browser to wipe the transcript/widgets/prompt when a new session
39
+ * starts, so it reflects the fresh session instead of the previous one. */
40
+ export interface ResetMessage {
41
+ type: 'reset';
42
+ }
38
43
  /** Server → browser messages added by the integration. The existing
39
44
  * history / text_delta / tool_* / agent_* / client_count / user_message messages are
40
45
  * emitted ad hoc by events.ts / server.ts and are not enumerated here. */
41
- export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage;
46
+ export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage | ResetMessage;
42
47
  /** Browser → server messages. */
43
48
  export interface ClientChatMessage {
44
49
  type: 'message';
@@ -0,0 +1,52 @@
1
+ /** Minimal shape of a browser PushSubscription serialized to JSON. */
2
+ export interface PushSubscriptionJSON {
3
+ endpoint?: string;
4
+ expirationTime?: number | null;
5
+ keys?: {
6
+ p256dh: string;
7
+ auth: string;
8
+ };
9
+ }
10
+ export interface VapidKeys {
11
+ publicKey: string;
12
+ privateKey: string;
13
+ }
14
+ /** Where the VAPID keypair is persisted. Follows the repo's XDG convention
15
+ * (see workers/docs-core.ts), but under data-home so it survives cache clears —
16
+ * losing these keys invalidates every existing browser subscription. */
17
+ export declare function vapidStorePath(): string;
18
+ /** Diagnostic log file. Defaults to /tmp for easy tailing; override with
19
+ * PI_REMOTE_PUSH_LOG. (The VAPID key stays in its durable XDG location.) */
20
+ export declare function pushLogPath(): string;
21
+ /** VAPID JWT `sub` claim. Apple (unlike Chrome/FCM) validates this and rejects
22
+ * tokens with an unroutable subject like `mailto:...@localhost` as BadJwtToken,
23
+ * so the default is a real https URL. Override with PI_REMOTE_PUSH_SUBJECT
24
+ * (e.g. your own `mailto:you@domain.com`). */
25
+ export declare function pushSubject(): string;
26
+ /** Append a timestamped diagnostic line — but only when PI_REMOTE_PUSH_DEBUG is
27
+ * set, so it stays silent (and test-safe) by default. Push failures are
28
+ * otherwise swallowed, so this is the way to see Apple's HTTP status codes. */
29
+ export declare function logPush(line: string): void;
30
+ /** Load the persisted VAPID keypair, generating and saving one on first use or
31
+ * if the stored file is missing/corrupt. Stable across restarts. */
32
+ export declare function loadOrCreateVapidKeys(file?: string): VapidKeys;
33
+ export declare function addSubscription(sub: PushSubscriptionJSON): void;
34
+ export declare function removeSubscription(endpoint: string): void;
35
+ export declare function getSubscriptions(): PushSubscriptionJSON[];
36
+ export declare function clearSubscriptions(): void;
37
+ type SendFn = (sub: PushSubscriptionJSON, payload: string) => Promise<{
38
+ statusCode: number;
39
+ }>;
40
+ /** Fan a payload out to every subscription via `send`, pruning any the push
41
+ * service reports as permanently gone. Network-free and fully testable; the
42
+ * real web-push call is injected by pushNotify. */
43
+ export declare function deliver(targets: PushSubscriptionJSON[], payload: string, send: SendFn): Promise<{
44
+ removed: string[];
45
+ }>;
46
+ /** The VAPID public key the browser needs as its applicationServerKey. */
47
+ export declare function publicKey(): string;
48
+ /** Send a notification to all subscribed devices. Best-effort: delivery is
49
+ * server→push-service→device, so it reaches a suspended iOS PWA that the
50
+ * in-page Notification API never could. */
51
+ export declare function pushNotify(title: string, body: string, tag?: string): Promise<void>;
52
+ export {};
@@ -0,0 +1,156 @@
1
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import webpush from 'web-push';
5
+ /** Where the VAPID keypair is persisted. Follows the repo's XDG convention
6
+ * (see workers/docs-core.ts), but under data-home so it survives cache clears —
7
+ * losing these keys invalidates every existing browser subscription. */
8
+ export function vapidStorePath() {
9
+ const base = process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), '.local', 'share');
10
+ return path.join(base, 'pi-task', 'vapid.json');
11
+ }
12
+ /** Diagnostic log file. Defaults to /tmp for easy tailing; override with
13
+ * PI_REMOTE_PUSH_LOG. (The VAPID key stays in its durable XDG location.) */
14
+ export function pushLogPath() {
15
+ return process.env.PI_REMOTE_PUSH_LOG?.trim() || '/tmp/pi-task-push.log';
16
+ }
17
+ /** VAPID JWT `sub` claim. Apple (unlike Chrome/FCM) validates this and rejects
18
+ * tokens with an unroutable subject like `mailto:...@localhost` as BadJwtToken,
19
+ * so the default is a real https URL. Override with PI_REMOTE_PUSH_SUBJECT
20
+ * (e.g. your own `mailto:you@domain.com`). */
21
+ export function pushSubject() {
22
+ return process.env.PI_REMOTE_PUSH_SUBJECT?.trim() || 'https://github.com/mjasnikovs/pi-task';
23
+ }
24
+ /** Append a timestamped diagnostic line — but only when PI_REMOTE_PUSH_DEBUG is
25
+ * set, so it stays silent (and test-safe) by default. Push failures are
26
+ * otherwise swallowed, so this is the way to see Apple's HTTP status codes. */
27
+ export function logPush(line) {
28
+ if (!process.env.PI_REMOTE_PUSH_DEBUG)
29
+ return;
30
+ try {
31
+ const file = pushLogPath();
32
+ mkdirSync(path.dirname(file), { recursive: true });
33
+ appendFileSync(file, `${new Date().toISOString()} ${line}\n`);
34
+ }
35
+ catch {
36
+ // diagnostics are best-effort; never let logging break a push
37
+ }
38
+ }
39
+ /** Short host label for an endpoint, for readable logs (full endpoints are long
40
+ * and contain device tokens). */
41
+ function endpointHost(sub) {
42
+ try {
43
+ return new URL(sub.endpoint ?? '').host;
44
+ }
45
+ catch {
46
+ return '?';
47
+ }
48
+ }
49
+ function isVapidKeys(x) {
50
+ if (typeof x !== 'object' || x === null)
51
+ return false;
52
+ const k = x;
53
+ return typeof k.publicKey === 'string' && typeof k.privateKey === 'string';
54
+ }
55
+ /** Load the persisted VAPID keypair, generating and saving one on first use or
56
+ * if the stored file is missing/corrupt. Stable across restarts. */
57
+ export function loadOrCreateVapidKeys(file = vapidStorePath()) {
58
+ try {
59
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
60
+ if (isVapidKeys(parsed))
61
+ return { publicKey: parsed.publicKey, privateKey: parsed.privateKey };
62
+ }
63
+ catch {
64
+ // missing or corrupt — fall through and regenerate
65
+ }
66
+ const keys = webpush.generateVAPIDKeys();
67
+ mkdirSync(path.dirname(file), { recursive: true });
68
+ writeFileSync(file, JSON.stringify(keys), 'utf8');
69
+ return keys;
70
+ }
71
+ // In-memory subscription store, keyed by endpoint. Persisted on globalThis so it
72
+ // survives jiti re-evaluation on session switches (same pattern as broadcast.ts).
73
+ // Subscriptions themselves are not written to disk: the browser re-subscribes on
74
+ // every page load, so an in-memory set is sufficient and self-healing.
75
+ const g = globalThis;
76
+ if (!g.__piRemoteSubs)
77
+ g.__piRemoteSubs = new Map();
78
+ const subs = g.__piRemoteSubs;
79
+ export function addSubscription(sub) {
80
+ if (!sub.endpoint)
81
+ return;
82
+ subs.set(sub.endpoint, sub);
83
+ }
84
+ export function removeSubscription(endpoint) {
85
+ subs.delete(endpoint);
86
+ }
87
+ export function getSubscriptions() {
88
+ return [...subs.values()];
89
+ }
90
+ export function clearSubscriptions() {
91
+ subs.clear();
92
+ }
93
+ /** True when the push service says the subscription is permanently gone and
94
+ * should be dropped (404 Not Found, 410 Gone). */
95
+ function isGone(err) {
96
+ const code = err?.statusCode;
97
+ return code === 404 || code === 410;
98
+ }
99
+ /** Fan a payload out to every subscription via `send`, pruning any the push
100
+ * service reports as permanently gone. Network-free and fully testable; the
101
+ * real web-push call is injected by pushNotify. */
102
+ export async function deliver(targets, payload, send) {
103
+ const removed = [];
104
+ await Promise.all(targets.map(async (sub) => {
105
+ try {
106
+ await send(sub, payload);
107
+ }
108
+ catch (err) {
109
+ if (sub.endpoint && isGone(err)) {
110
+ removeSubscription(sub.endpoint);
111
+ removed.push(sub.endpoint);
112
+ }
113
+ // transient errors: keep the subscription, drop this push
114
+ }
115
+ }));
116
+ return { removed };
117
+ }
118
+ let configured = false;
119
+ function ensureConfigured() {
120
+ const keys = loadOrCreateVapidKeys();
121
+ if (!configured) {
122
+ webpush.setVapidDetails(pushSubject(), keys.publicKey, keys.privateKey);
123
+ configured = true;
124
+ }
125
+ return keys;
126
+ }
127
+ /** The VAPID public key the browser needs as its applicationServerKey. */
128
+ export function publicKey() {
129
+ return ensureConfigured().publicKey;
130
+ }
131
+ /** Send a notification to all subscribed devices. Best-effort: delivery is
132
+ * server→push-service→device, so it reaches a suspended iOS PWA that the
133
+ * in-page Notification API never could. */
134
+ export async function pushNotify(title, body, tag) {
135
+ const targets = getSubscriptions();
136
+ logPush(`event "${title}" -> ${targets.length} subscription(s)`);
137
+ if (targets.length === 0)
138
+ return; // nobody subscribed — skip all VAPID/disk work
139
+ ensureConfigured();
140
+ const payload = JSON.stringify({ title, body, tag });
141
+ const { removed } = await deliver(targets, payload, async (sub, p) => {
142
+ try {
143
+ const res = await webpush.sendNotification(sub, p);
144
+ logPush(`send ${endpointHost(sub)} -> ${res.statusCode}`);
145
+ return res;
146
+ }
147
+ catch (err) {
148
+ const e = err;
149
+ logPush(`send ${endpointHost(sub)} -> ERROR ${e.statusCode ?? '?'} `
150
+ + `${(e.body || e.message || '').toString().trim().slice(0, 200)}`);
151
+ throw err; // rethrow so deliver can prune permanently-gone subscriptions
152
+ }
153
+ });
154
+ if (removed.length > 0)
155
+ logPush(`pruned ${removed.length} gone subscription(s)`);
156
+ }
@@ -46,6 +46,13 @@ export function registerRemote(pi) {
46
46
  }
47
47
  pi.on('session_start', (_event, ctx) => {
48
48
  S.send = (text, opts) => (opts ? pi.sendUserMessage(text, opts) : pi.sendUserMessage(text));
49
+ // A new session (incl. /new and the /task handoff's newSession) means the
50
+ // browser is showing a stale transcript/widgets — wipe both ends.
51
+ const bridge = getBridge();
52
+ bridge.activeWidgets.clear();
53
+ bridge.activePrompt = null;
54
+ bridge.lastContextUsage = null;
55
+ broadcast({ type: 'reset' });
49
56
  setupEvents(pi, history, broadcast);
50
57
  // Seed a shimmed ctx so commands that don't need newSession (/task-list,
51
58
  // /task-cancel, /task-auto-cancel) work immediately from the remote without
@@ -53,10 +60,10 @@ export function registerRemote(pi) {
53
60
  // a real command ctx captured from a prior terminal command must survive
54
61
  // session_start (it's updated via withSession or registerBridgeCommand,
55
62
  // not replaced here).
56
- const b = getBridge();
57
- if (!b.currentCtx
58
- || b.currentCtx['__piRemoteShimmed'] === true) {
59
- b.currentCtx = makeShimmedCtx(ctx);
63
+ if (!bridge.currentCtx
64
+ || bridge.currentCtx['__piRemoteShimmed']
65
+ === true) {
66
+ bridge.currentCtx = makeShimmedCtx(ctx);
60
67
  }
61
68
  void ensureServer().catch(err => ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error'));
62
69
  });
@@ -4,6 +4,8 @@ import { WebSocketServer } from 'ws';
4
4
  import { addClient, removeClient, clientCount, broadcast, sendTo } from './broadcast.js';
5
5
  import { getBridge, answerPrompt } from './bridge.js';
6
6
  import { isClientMessage } from './protocol.js';
7
+ import { swJs } from './sw.js';
8
+ import { publicKey, addSubscription, getSubscriptions, logPush } from './push.js';
7
9
  export function getLocalIPs(nets = networkInterfaces()) {
8
10
  // Prefer Tailscale when available.
9
11
  let tailscale;
@@ -68,6 +70,34 @@ export async function startServer(onMessage, getHtml, getHistory) {
68
70
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
69
71
  res.end(body);
70
72
  }
73
+ else if (req.method === 'GET' && req.url === '/sw.js') {
74
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' });
75
+ res.end(swJs());
76
+ }
77
+ else if (req.method === 'GET' && req.url === '/push-key') {
78
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
79
+ res.end(publicKey());
80
+ }
81
+ else if (req.method === 'POST' && req.url === '/subscribe') {
82
+ const chunks = [];
83
+ req.on('data', c => chunks.push(c));
84
+ req.on('end', () => {
85
+ try {
86
+ const sub = JSON.parse(Buffer.concat(chunks).toString());
87
+ if (!sub || typeof sub.endpoint !== 'string')
88
+ throw new Error('no endpoint');
89
+ addSubscription(sub);
90
+ logPush(`subscribe ${new URL(sub.endpoint).host} (total ${getSubscriptions().length})`);
91
+ res.writeHead(201);
92
+ res.end('ok');
93
+ }
94
+ catch {
95
+ logPush('subscribe REJECTED (malformed body)');
96
+ res.writeHead(400);
97
+ res.end('bad subscription');
98
+ }
99
+ });
100
+ }
71
101
  else {
72
102
  res.writeHead(404);
73
103
  res.end('Not found');
@@ -0,0 +1,8 @@
1
+ /** Service worker source served at /sw.js. It must be a real same-origin URL
2
+ * (a SW cannot be registered from a data: URL), and serving it at the root path
3
+ * gives it root scope so it controls the whole app.
4
+ *
5
+ * iOS home-screen PWAs deliver notifications ONLY through a service worker's
6
+ * push event — the in-page `new Notification()` constructor is unavailable — so
7
+ * this is the only path that reaches a backgrounded iPhone. */
8
+ export declare function swJs(): string;
@@ -0,0 +1,40 @@
1
+ /** Service worker source served at /sw.js. It must be a real same-origin URL
2
+ * (a SW cannot be registered from a data: URL), and serving it at the root path
3
+ * gives it root scope so it controls the whole app.
4
+ *
5
+ * iOS home-screen PWAs deliver notifications ONLY through a service worker's
6
+ * push event — the in-page `new Notification()` constructor is unavailable — so
7
+ * this is the only path that reaches a backgrounded iPhone. */
8
+ export function swJs() {
9
+ return `self.addEventListener('install', function () { self.skipWaiting(); });
10
+ self.addEventListener('activate', function (e) { e.waitUntil(self.clients.claim()); });
11
+
12
+ self.addEventListener('push', function (event) {
13
+ var data = {};
14
+ try { data = event.data ? event.data.json() : {}; } catch (e) { data = {}; }
15
+ var title = data.title || 'pi-task remote';
16
+ var options = { body: data.body || '', tag: data.tag, renotify: !!data.tag };
17
+ event.waitUntil(
18
+ self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (cs) {
19
+ // Skip the banner if a window is already focused — the in-page UI shows it.
20
+ for (var i = 0; i < cs.length; i++) {
21
+ if (cs[i].visibilityState === 'visible' && cs[i].focused) return;
22
+ }
23
+ return self.registration.showNotification(title, options);
24
+ })
25
+ );
26
+ });
27
+
28
+ self.addEventListener('notificationclick', function (event) {
29
+ event.notification.close();
30
+ event.waitUntil(
31
+ self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (cs) {
32
+ for (var i = 0; i < cs.length; i++) {
33
+ if ('focus' in cs[i]) return cs[i].focus();
34
+ }
35
+ if (self.clients.openWindow) return self.clients.openWindow('/');
36
+ })
37
+ );
38
+ });
39
+ `;
40
+ }
package/dist/remote/ui.js CHANGED
@@ -208,7 +208,8 @@ export function html(wsUrl) {
208
208
  font-size: 12px; font-weight: 500; padding: 8px 10px; }
209
209
  #prompt-card button.cancel:hover { color: var(--red); filter: none; }
210
210
  #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);
211
+ .toast { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 12px);
212
+ right: calc(env(safe-area-inset-right, 0px) + 12px); max-width: calc(100vw - 24px);
212
213
  padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;
213
214
  background: var(--surface1); color: var(--text); z-index: 60; }
214
215
  .toast.warning { background: var(--peach); color: var(--crust); }
@@ -224,7 +225,7 @@ export function html(wsUrl) {
224
225
  <div id="header">
225
226
  <span class="title">pi-task remote</span>
226
227
  <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
+ <span class="status" id="client-status"><span class="cdot" id="conn-dot">&#x25CB;</span></span>
228
229
  <button id="bell" aria-label="Toggle notifications" title="Notifications">&#x25EF;</button>
229
230
  </div>
230
231
  </div>
@@ -264,17 +265,28 @@ export function html(wsUrl) {
264
265
  if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
265
266
  }
266
267
  const connDot = document.getElementById('conn-dot');
267
- const clientLabel = document.getElementById('client-label');
268
268
  // state: 'connecting' (○ yellow) | 'up' (● green) | 'down' (● red)
269
- function setConn(state, label) {
269
+ function setConn(state) {
270
270
  connDot.textContent = state === 'connecting' ? '\\u25CB' : '\\u25CF';
271
271
  connDot.className = 'cdot' + (state === 'up' ? ' up' : state === 'down' ? ' down' : '');
272
- if (label !== undefined) clientLabel.textContent = label;
273
272
  }
274
273
  const reconnectOverlay = document.getElementById('reconnect-overlay');
275
274
  const reconnectMsg = document.getElementById('reconnect-msg');
276
275
  const cmdSuggestions = document.getElementById('cmd-suggestions');
277
276
  const statusPanel = document.getElementById('status-panel');
277
+ // Widgets are keyed (e.g. 'pi-tasks', 'pi-task-auto'); track them per key so a
278
+ // clear for one key can't be masked by a stale message from another.
279
+ const widgets = {};
280
+ 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');
285
+ statusPanel.style.display = 'block';
286
+ } else {
287
+ statusPanel.style.display = 'none';
288
+ }
289
+ }
278
290
  const promptCard = document.getElementById('prompt-card');
279
291
  const promptQ = document.getElementById('prompt-q');
280
292
  const promptRec = document.getElementById('prompt-rec');
@@ -292,6 +304,7 @@ export function html(wsUrl) {
292
304
  let streamText = '';
293
305
  let autoScroll = true;
294
306
  let reconnectDelay = 1000;
307
+ let reconnectAnim = null;
295
308
  let ws = null;
296
309
 
297
310
  const BT = String.fromCharCode(96);
@@ -557,26 +570,65 @@ export function html(wsUrl) {
557
570
  }
558
571
  const issue = notifyEnvIssue();
559
572
  if (issue) { showToast(issue, 'warning'); return; }
573
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
574
+ showToast('This browser doesn\\u2019t support push notifications.', 'warning'); return;
575
+ }
560
576
  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();
577
+ if (perm !== 'granted') {
578
+ showToast('Notifications blocked in browser settings.', 'warning');
579
+ updateBell(); return;
580
+ }
581
+ subscribePush().then(function (ok) {
582
+ if (ok) { localStorage.setItem(NOTIFY_KEY, '1'); showToast('Notifications on.', 'info'); }
583
+ else { showToast('Could not register for notifications.', 'warning'); }
584
+ updateBell();
585
+ }).catch(function (e) {
586
+ showToast('Notification setup failed: ' + (e && e.message ? e.message : e), 'warning');
587
+ updateBell();
588
+ });
564
589
  });
565
590
  });
566
591
 
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) */ }
592
+ // VAPID public key (base64url) -> Uint8Array for applicationServerKey.
593
+ function urlB64ToUint8Array(base64) {
594
+ const pad = '='.repeat((4 - base64.length % 4) % 4);
595
+ const b64 = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
596
+ const raw = atob(b64);
597
+ const arr = new Uint8Array(raw.length);
598
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
599
+ return arr;
600
+ }
601
+
602
+ // Register the service worker, subscribe via the Push API, and hand the
603
+ // subscription to the server. The server (not the page) sends notifications,
604
+ // so they arrive even when this PWA is backgrounded/suspended on iOS.
605
+ function subscribePush() {
606
+ return navigator.serviceWorker.register('/sw.js')
607
+ .then(function () { return navigator.serviceWorker.ready; })
608
+ .then(function (reg) {
609
+ return fetch('/push-key').then(function (r) { return r.text(); }).then(function (key) {
610
+ return reg.pushManager.getSubscription().then(function (existing) {
611
+ return existing || reg.pushManager.subscribe({
612
+ userVisibleOnly: true,
613
+ applicationServerKey: urlB64ToUint8Array(key.trim())
614
+ });
615
+ });
616
+ });
617
+ })
618
+ .then(function (subscription) {
619
+ return fetch('/subscribe', {
620
+ method: 'POST',
621
+ headers: { 'Content-Type': 'application/json' },
622
+ body: JSON.stringify(subscription)
623
+ }).then(function (res) { return res.ok; });
624
+ });
577
625
  }
578
626
 
579
627
  updateBell();
628
+ // Self-heal: if notifications were enabled before, re-register the
629
+ // subscription on load (the server keeps subscriptions in memory and may
630
+ // have restarted, and browsers can rotate the subscription).
631
+ if (notifyEnabled()) { subscribePush().catch(function () {}); }
580
632
 
581
633
  function answer(value) {
582
634
  if (activePromptId === null) return;
@@ -758,7 +810,6 @@ export function html(wsUrl) {
758
810
  streamText = '';
759
811
  }
760
812
  addBubble('error', msg.message || 'Error');
761
- notify('Agent error', msg.message || 'Error', 'pi-error');
762
813
  setEnabled(true);
763
814
  break;
764
815
  case 'agent_end':
@@ -766,7 +817,6 @@ export function html(wsUrl) {
766
817
  currentBubble = null;
767
818
  streamText = '';
768
819
  setEnabled(true);
769
- notify('Task finished', '', 'pi-end');
770
820
  setContextBar(msg.contextUsage);
771
821
  break;
772
822
  case 'context':
@@ -774,22 +824,18 @@ export function html(wsUrl) {
774
824
  setContextBar(msg.contextUsage);
775
825
  break;
776
826
  case 'client_count':
777
- setConn('up', msg.count + ' client' + (msg.count === 1 ? '' : 's') + ' connected');
827
+ setConn('up');
778
828
  break;
779
829
  case 'prompt':
780
830
  showPrompt(msg);
781
- notify('pi needs your input', msg.question, 'pi-prompt');
782
831
  break;
783
832
  case 'prompt_resolved':
784
833
  if (activePromptId === msg.id) closePrompt();
785
834
  break;
786
835
  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
- }
836
+ if (msg.lines && msg.lines.length) widgets[msg.key] = msg.lines;
837
+ else delete widgets[msg.key];
838
+ renderWidgets();
793
839
  break;
794
840
  case 'notify':
795
841
  showToast(msg.message, msg.level);
@@ -798,6 +844,16 @@ export function html(wsUrl) {
798
844
  viewerBody.textContent = msg.text;
799
845
  viewer.style.display = 'block';
800
846
  break;
847
+ case 'reset':
848
+ // A new session started — wipe the previous session's transcript.
849
+ chatLog.innerHTML = '';
850
+ hideThinking();
851
+ currentBubble = null; streamText = '';
852
+ closePrompt();
853
+ for (const k in widgets) delete widgets[k];
854
+ renderWidgets();
855
+ contextFill.style.width = '0%';
856
+ break;
801
857
  }
802
858
  }
803
859
 
@@ -837,9 +893,10 @@ export function html(wsUrl) {
837
893
  function connect() {
838
894
  ws = new WebSocket(WS_URL);
839
895
  ws.addEventListener('open', () => {
896
+ if (reconnectAnim) { clearInterval(reconnectAnim); reconnectAnim = null; }
840
897
  reconnectOverlay.classList.remove('visible');
841
898
  reconnectDelay = 1000;
842
- setConn('up', 'connected');
899
+ setConn('up');
843
900
  setEnabled(true);
844
901
  });
845
902
  ws.addEventListener('message', (e) => {
@@ -847,9 +904,21 @@ export function html(wsUrl) {
847
904
  });
848
905
  ws.addEventListener('close', () => {
849
906
  setEnabled(false);
850
- setConn('down', 'disconnected');
907
+ setConn('down');
851
908
  reconnectOverlay.classList.add('visible');
852
- reconnectMsg.textContent = 'reconnecting in ' + (reconnectDelay / 1000) + 's…';
909
+ // Animate the same braille spinner used elsewhere, with a live countdown.
910
+ const until = Date.now() + reconnectDelay;
911
+ let frame = 0;
912
+ const paint = () => {
913
+ const left = Math.max(0, Math.ceil((until - Date.now()) / 1000));
914
+ const glyph = SPIN[frame++ % SPIN.length];
915
+ reconnectMsg.textContent = left > 0
916
+ ? glyph + ' connection lost — retrying in ' + left + 's'
917
+ : glyph + ' reconnecting…';
918
+ };
919
+ if (reconnectAnim) clearInterval(reconnectAnim);
920
+ paint();
921
+ reconnectAnim = setInterval(paint, 90);
853
922
  setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, 30000); connect(); }, reconnectDelay);
854
923
  });
855
924
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -27,19 +27,21 @@
27
27
  "jsdom": "^29.1.1",
28
28
  "qrcode": "^1.5.4",
29
29
  "turndown": "^7.2.4",
30
+ "web-push": "^3.6.7",
30
31
  "ws": "^8.18.0"
31
32
  },
32
33
  "devDependencies": {
33
- "@earendil-works/pi-coding-agent": "0.78.1",
34
34
  "@earendil-works/pi-agent-core": "0.78.1",
35
+ "@earendil-works/pi-coding-agent": "0.78.1",
35
36
  "@earendil-works/pi-tui": "0.78.1",
36
- "@types/qrcode": "^1.5.5",
37
- "@types/ws": "^8.5.14",
38
37
  "@eslint/js": "10.0.1",
39
38
  "@sinclair/typebox": "0.34.49",
40
39
  "@types/bun": "1.3.12",
41
40
  "@types/jsdom": "^28.0.3",
41
+ "@types/qrcode": "^1.5.5",
42
42
  "@types/turndown": "^5.0.6",
43
+ "@types/web-push": "^3.6.4",
44
+ "@types/ws": "^8.5.14",
43
45
  "eslint": "10.2.1",
44
46
  "globals": "17.5.0",
45
47
  "prettier": "3.8.3",