@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.
- package/dist/remote/bridge.d.ts +3 -1
- package/dist/remote/bridge.js +4 -0
- package/dist/remote/events.js +6 -0
- package/dist/remote/protocol.d.ts +17 -1
- package/dist/remote/push.d.ts +52 -0
- package/dist/remote/push.js +156 -0
- package/dist/remote/register.js +31 -7
- package/dist/remote/server.js +33 -0
- package/dist/remote/sw.d.ts +8 -0
- package/dist/remote/sw.js +40 -0
- package/dist/remote/tailscale.d.ts +45 -0
- package/dist/remote/tailscale.js +108 -0
- package/dist/remote/ui.js +200 -30
- package/dist/workers/pi-worker-core.js +1 -1
- package/package.json +6 -4
package/dist/remote/bridge.d.ts
CHANGED
|
@@ -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>;
|
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) {
|
|
@@ -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 ?
|
package/dist/remote/events.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/remote/register.js
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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 =
|
|
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
|
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');
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
89
|
+
display: flex; gap: 5px; align-items: center; padding: 10px 14px;
|
|
71
90
|
}
|
|
72
|
-
.bubble.thinking .
|
|
73
|
-
|
|
74
|
-
|
|
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:
|
|
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
|
-
<
|
|
227
|
+
<div class="hgroup">
|
|
228
|
+
<span class="status" id="client-status"><span class="cdot" id="conn-dot">○</span></span>
|
|
229
|
+
<button id="bell" aria-label="Toggle notifications" title="Notifications">◯</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
|
-
|
|
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="
|
|
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
|
-
|
|
674
|
-
|
|
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
|
-
|
|
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
|
-
|
|
689
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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",
|