@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.
- package/dist/remote/bridge.js +3 -0
- package/dist/remote/events.js +3 -0
- package/dist/remote/protocol.d.ts +6 -1
- package/dist/remote/push.d.ts +52 -0
- package/dist/remote/push.js +156 -0
- package/dist/remote/register.js +11 -4
- package/dist/remote/server.js +30 -0
- package/dist/remote/sw.d.ts +8 -0
- package/dist/remote/sw.js +40 -0
- package/dist/remote/ui.js +100 -31
- package/package.json +6 -4
package/dist/remote/bridge.js
CHANGED
|
@@ -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 ?
|
package/dist/remote/events.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/remote/register.js
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
});
|
package/dist/remote/server.js
CHANGED
|
@@ -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:
|
|
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">○</span
|
|
228
|
+
<span class="status" id="client-status"><span class="cdot" id="conn-dot">○</span></span>
|
|
228
229
|
<button id="bell" aria-label="Toggle notifications" title="Notifications">◯</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
|
|
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
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
//
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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'
|
|
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
|
-
|
|
789
|
-
|
|
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'
|
|
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'
|
|
907
|
+
setConn('down');
|
|
851
908
|
reconnectOverlay.classList.add('visible');
|
|
852
|
-
|
|
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.
|
|
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",
|