@mjasnikovs/pi-task 0.5.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,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>;
@@ -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) {
@@ -6,6 +7,7 @@ export function getBridge() {
6
7
  pending: new Map(),
7
8
  activePrompt: null,
8
9
  activeWidgets: new Map(),
10
+ lastContextUsage: null,
9
11
  nextId: 0,
10
12
  commands: new Map(),
11
13
  currentCtx: null,
@@ -56,6 +58,8 @@ export class SessionUI {
56
58
  };
57
59
  b.activePrompt = prompt;
58
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(() => { });
59
63
  // Local: resolves to a value/undefined, or undefined on abort. Swallow
60
64
  // the rejection some implementations throw on abort so it never leaks.
61
65
  const local = this.ctx.hasUI ?
@@ -1,4 +1,6 @@
1
1
  import { setAgentIdle } from './state.js';
2
+ import { getBridge } from './bridge.js';
3
+ import { pushNotify } from './push.js';
2
4
  export function setupEvents(pi, history, broadcastFn) {
3
5
  let currentText = '';
4
6
  const currentTools = [];
@@ -25,6 +27,7 @@ export function setupEvents(pi, history, broadcastFn) {
25
27
  const message = errorMessage || 'Request failed';
26
28
  history.addError(message);
27
29
  broadcastFn({ type: 'agent_error', message });
30
+ void pushNotify('Agent error', message, 'pi-error').catch(() => { });
28
31
  }
29
32
  }
30
33
  });
@@ -67,8 +70,11 @@ export function setupEvents(pi, history, broadcastFn) {
67
70
  pi.on('agent_end', (_event, ctx) => {
68
71
  const contextUsage = ctx.getContextUsage();
69
72
  setAgentIdle(true);
73
+ // Remember it so a browser that connects mid-session gets its bar seeded.
74
+ getBridge().lastContextUsage = contextUsage;
70
75
  history.addAssistantTurn(currentText, [...currentTools]);
71
76
  broadcastFn({ type: 'agent_end', contextUsage });
77
+ void pushNotify('Task finished', '', 'pi-end').catch(() => { });
72
78
  currentText = '';
73
79
  currentTools.length = 0;
74
80
  });
@@ -24,10 +24,26 @@ 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
+ }
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
+ }
27
43
  /** Server → browser messages added by the integration. The existing
28
44
  * history / text_delta / tool_* / agent_* / client_count / user_message messages are
29
45
  * emitted ad hoc by events.ts / server.ts and are not enumerated here. */
30
- export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage;
46
+ export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage | ResetMessage;
31
47
  /** Browser → server messages. */
32
48
  export interface ClientChatMessage {
33
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
+ }
@@ -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,10 +39,20 @@ 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) => {
44
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' });
45
56
  setupEvents(pi, history, broadcast);
46
57
  // Seed a shimmed ctx so commands that don't need newSession (/task-list,
47
58
  // /task-cancel, /task-auto-cancel) work immediately from the remote without
@@ -49,17 +60,21 @@ export function registerRemote(pi) {
49
60
  // a real command ctx captured from a prior terminal command must survive
50
61
  // session_start (it's updated via withSession or registerBridgeCommand,
51
62
  // not replaced here).
52
- const b = getBridge();
53
- if (!b.currentCtx || b.currentCtx['__piRemoteShimmed'] === true) {
54
- b.currentCtx = makeShimmedCtx(ctx);
63
+ if (!bridge.currentCtx
64
+ || bridge.currentCtx['__piRemoteShimmed']
65
+ === true) {
66
+ bridge.currentCtx = makeShimmedCtx(ctx);
55
67
  }
56
68
  void ensureServer().catch(err => ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error'));
57
69
  });
58
70
  pi.on('session_shutdown', (event, _ctx) => {
59
71
  if (event.reason === 'quit') {
60
72
  if (S.server) {
73
+ const port = S.server.port;
61
74
  S.server.stop();
62
75
  S.server = null;
76
+ S.serveResult = null;
77
+ void teardownTailscaleServe(port).catch(() => { });
63
78
  }
64
79
  S.send = null;
65
80
  }
@@ -69,8 +84,11 @@ export function registerRemote(pi) {
69
84
  handler: async (args, ctx) => {
70
85
  if (args.trim() === 'stop') {
71
86
  if (S.server) {
87
+ const port = S.server.port;
72
88
  S.server.stop();
73
89
  S.server = null;
90
+ S.serveResult = null;
91
+ void teardownTailscaleServe(port).catch(() => { });
74
92
  ctx.ui.notify('Remote server stopped', 'info');
75
93
  }
76
94
  else {
@@ -82,11 +100,17 @@ export function registerRemote(pi) {
82
100
  getBridge().currentCtx = ctx;
83
101
  try {
84
102
  const server = await ensureServer();
85
- const primaryUrl = `http://${server.ip}:${server.port}`;
103
+ const httpPrimary = `http://${server.ip}:${server.port}`;
104
+ const result = S.serveResult ?? { state: 'unavailable' };
105
+ const plan = planRemoteUrls(httpPrimary, result);
106
+ const primaryUrl = plan.primaryUrl;
86
107
  const qr = await qrLines(primaryUrl);
87
- const addrs = formatAddresses(server.ips, server.port);
108
+ const addrs = [...plan.urlLines, ...formatAddresses(server.ips, server.port)];
88
109
  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);
110
+ const addrLines = [
111
+ ...addrs.map(a => (a.label ? `${a.label.padEnd(labelW)} ${a.url}` : a.url)),
112
+ ...plan.hintLines
113
+ ];
90
114
  const addrWidth = addrLines.reduce((m, l) => Math.max(m, l.length), 0);
91
115
  if (ctx.mode === 'tui') {
92
116
  // eslint-disable-next-line no-control-regex -- strip ANSI SGR escapes to measure visible width
@@ -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');
@@ -95,6 +125,9 @@ export async function startServer(onMessage, getHtml, getHistory) {
95
125
  }
96
126
  if (bridge.activePrompt)
97
127
  sendTo(ws, bridge.activePrompt);
128
+ if (bridge.lastContextUsage) {
129
+ sendTo(ws, { type: 'context', contextUsage: bridge.lastContextUsage });
130
+ }
98
131
  broadcast({ type: 'client_count', count: clientCount() });
99
132
  ws.on('message', data => {
100
133
  let msg;
@@ -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
+ }
@@ -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,9 @@ 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: calc(env(safe-area-inset-top, 0px) + 12px);
212
+ right: calc(env(safe-area-inset-right, 0px) + 12px); max-width: calc(100vw - 24px);
213
+ padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;
198
214
  background: var(--surface1); color: var(--text); z-index: 60; }
199
215
  .toast.warning { background: var(--peach); color: var(--crust); }
200
216
  .toast.error { background: var(--red); color: var(--crust); }
@@ -208,7 +224,10 @@ export function html(wsUrl) {
208
224
  <div id="context-bar"><div id="context-bar-fill"></div></div>
209
225
  <div id="header">
210
226
  <span class="title">pi-task remote</span>
211
- <span class="status" id="client-status">connecting…</span>
227
+ <div class="hgroup">
228
+ <span class="status" id="client-status"><span class="cdot" id="conn-dot">&#x25CB;</span></span>
229
+ <button id="bell" aria-label="Toggle notifications" title="Notifications">&#x25EF;</button>
230
+ </div>
212
231
  </div>
213
232
  <div id="chat-log"></div>
214
233
  <div id="status-panel"></div>
@@ -242,11 +261,32 @@ export function html(wsUrl) {
242
261
  const inputEl = document.getElementById('input');
243
262
  const sendBtn = document.getElementById('send');
244
263
  const contextFill = document.getElementById('context-bar-fill');
245
- const clientStatus = document.getElementById('client-status');
264
+ function setContextBar(usage) {
265
+ if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
266
+ }
267
+ const connDot = document.getElementById('conn-dot');
268
+ // state: 'connecting' (○ yellow) | 'up' (● green) | 'down' (● red)
269
+ function setConn(state) {
270
+ connDot.textContent = state === 'connecting' ? '\\u25CB' : '\\u25CF';
271
+ connDot.className = 'cdot' + (state === 'up' ? ' up' : state === 'down' ? ' down' : '');
272
+ }
246
273
  const reconnectOverlay = document.getElementById('reconnect-overlay');
247
274
  const reconnectMsg = document.getElementById('reconnect-msg');
248
275
  const cmdSuggestions = document.getElementById('cmd-suggestions');
249
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
+ }
250
290
  const promptCard = document.getElementById('prompt-card');
251
291
  const promptQ = document.getElementById('prompt-q');
252
292
  const promptRec = document.getElementById('prompt-rec');
@@ -264,6 +304,7 @@ export function html(wsUrl) {
264
304
  let streamText = '';
265
305
  let autoScroll = true;
266
306
  let reconnectDelay = 1000;
307
+ let reconnectAnim = null;
267
308
  let ws = null;
268
309
 
269
310
  const BT = String.fromCharCode(96);
@@ -444,16 +485,28 @@ export function html(wsUrl) {
444
485
  }
445
486
 
446
487
  let thinkingEl = null;
488
+ let spinTimer = null;
489
+ let spinIdx = 0;
490
+ const SPIN = '\\u280B\\u2819\\u2839\\u2838\\u283C\\u2834\\u2826\\u2827\\u2807\\u280F';
447
491
  function showThinking() {
448
492
  if (!thinkingEl) {
449
493
  thinkingEl = document.createElement('div');
450
494
  thinkingEl.className = 'bubble assistant thinking';
451
- thinkingEl.innerHTML = '<span class="dot"></span><span class="dot"></span><span class="dot"></span>';
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);
452
504
  }
453
505
  chatLog.appendChild(thinkingEl); // append (or move) to bottom
454
506
  scrollBottom();
455
507
  }
456
508
  function hideThinking() {
509
+ if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
457
510
  if (thinkingEl) thinkingEl.remove();
458
511
  }
459
512
 
@@ -483,6 +536,100 @@ export function html(wsUrl) {
483
536
  setTimeout(function () { t.remove(); }, 4000);
484
537
  }
485
538
 
539
+ const bell = document.getElementById('bell');
540
+ const NOTIFY_KEY = 'piRemoteNotify';
541
+
542
+ function notifyEnabled() {
543
+ return localStorage.getItem(NOTIFY_KEY) === '1'
544
+ && typeof Notification !== 'undefined'
545
+ && Notification.permission === 'granted';
546
+ }
547
+
548
+ function updateBell() {
549
+ // ◉ (mauve) when armed, ◯ (dim) when off/unavailable.
550
+ var on = notifyEnabled();
551
+ bell.textContent = on ? '\\u25C9' : '\\u25EF';
552
+ bell.classList.toggle('on', on);
553
+ }
554
+
555
+ // Why notifications can't be enabled here, or null if they can.
556
+ function notifyEnvIssue() {
557
+ if (typeof Notification === 'undefined') return "This browser doesn't support notifications.";
558
+ if (!window.isSecureContext) return 'Notifications need HTTPS. Open the Tailscale https:// URL, or open via localhost.';
559
+ const isIOS = /iP(hone|ad|od)/i.test(navigator.userAgent);
560
+ const standalone = navigator.standalone === true
561
+ || (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches);
562
+ if (isIOS && !standalone) return 'On iOS: Share \\u2192 Add to Home Screen first, then enable notifications.';
563
+ return null;
564
+ }
565
+
566
+ bell.addEventListener('click', function () {
567
+ // Turning OFF always works regardless of environment.
568
+ if (localStorage.getItem(NOTIFY_KEY) === '1') {
569
+ localStorage.setItem(NOTIFY_KEY, '0'); updateBell(); return;
570
+ }
571
+ const issue = notifyEnvIssue();
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
+ }
576
+ Notification.requestPermission().then(function (perm) {
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
+ });
589
+ });
590
+ });
591
+
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
+ });
625
+ }
626
+
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 () {}); }
632
+
486
633
  function answer(value) {
487
634
  if (activePromptId === null) return;
488
635
  ws.send(JSON.stringify({ type: 'prompt_answer', id: activePromptId, value: value }));
@@ -670,12 +817,14 @@ export function html(wsUrl) {
670
817
  currentBubble = null;
671
818
  streamText = '';
672
819
  setEnabled(true);
673
- if (msg.contextUsage && msg.contextUsage.percent != null) {
674
- contextFill.style.width = msg.contextUsage.percent + '%';
675
- }
820
+ setContextBar(msg.contextUsage);
821
+ break;
822
+ case 'context':
823
+ // Seeds the bar for a client that joined mid-session.
824
+ setContextBar(msg.contextUsage);
676
825
  break;
677
826
  case 'client_count':
678
- clientStatus.textContent = msg.count + ' client' + (msg.count === 1 ? '' : 's') + ' connected';
827
+ setConn('up');
679
828
  break;
680
829
  case 'prompt':
681
830
  showPrompt(msg);
@@ -684,12 +833,9 @@ export function html(wsUrl) {
684
833
  if (activePromptId === msg.id) closePrompt();
685
834
  break;
686
835
  case 'widget':
687
- if (msg.lines && msg.lines.length) {
688
- statusPanel.textContent = msg.lines.join('\\n');
689
- statusPanel.style.display = 'block';
690
- } else {
691
- statusPanel.style.display = 'none';
692
- }
836
+ if (msg.lines && msg.lines.length) widgets[msg.key] = msg.lines;
837
+ else delete widgets[msg.key];
838
+ renderWidgets();
693
839
  break;
694
840
  case 'notify':
695
841
  showToast(msg.message, msg.level);
@@ -698,6 +844,16 @@ export function html(wsUrl) {
698
844
  viewerBody.textContent = msg.text;
699
845
  viewer.style.display = 'block';
700
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;
701
857
  }
702
858
  }
703
859
 
@@ -737,9 +893,10 @@ export function html(wsUrl) {
737
893
  function connect() {
738
894
  ws = new WebSocket(WS_URL);
739
895
  ws.addEventListener('open', () => {
896
+ if (reconnectAnim) { clearInterval(reconnectAnim); reconnectAnim = null; }
740
897
  reconnectOverlay.classList.remove('visible');
741
898
  reconnectDelay = 1000;
742
- clientStatus.textContent = 'connected';
899
+ setConn('up');
743
900
  setEnabled(true);
744
901
  });
745
902
  ws.addEventListener('message', (e) => {
@@ -747,8 +904,21 @@ export function html(wsUrl) {
747
904
  });
748
905
  ws.addEventListener('close', () => {
749
906
  setEnabled(false);
907
+ setConn('down');
750
908
  reconnectOverlay.classList.add('visible');
751
- 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);
752
922
  setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, 30000); connect(); }, reconnectDelay);
753
923
  });
754
924
  }
@@ -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.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",