@mjasnikovs/pi-task 0.6.0 → 0.7.1
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/README.md +26 -2
- package/dist/remote/bridge.d.ts +1 -10
- package/dist/remote/bridge.js +6 -19
- package/dist/remote/events.d.ts +3 -2
- package/dist/remote/events.js +28 -50
- package/dist/remote/history.d.ts +18 -5
- package/dist/remote/history.js +11 -4
- package/dist/remote/protocol.d.ts +13 -5
- package/dist/remote/push.d.ts +52 -0
- package/dist/remote/push.js +156 -0
- package/dist/remote/register.js +13 -10
- package/dist/remote/server.d.ts +1 -2
- package/dist/remote/server.js +36 -13
- package/dist/remote/session-state.d.ts +54 -0
- package/dist/remote/session-state.js +179 -0
- package/dist/remote/sw.d.ts +8 -0
- package/dist/remote/sw.js +40 -0
- package/dist/remote/ui.js +246 -60
- package/dist/task/widget.js +7 -7
- package/dist/workers/pi-worker-docs.js +14 -11
- package/dist/workers/pi-worker-search.js +8 -3
- package/dist/workers/pi-worker.js +9 -6
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://www.npmjs.com/package/@mjasnikovs/pi-task)
|
|
8
8
|
[](./LICENSE)
|
|
9
9
|
[](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
|
|
10
|
-
[](#development)
|
|
11
11
|
[](./tsconfig.json)
|
|
12
12
|
|
|
13
13
|
</div>
|
|
@@ -114,6 +114,26 @@ The remote server is **always on** — it starts automatically with each session
|
|
|
114
114
|
|
|
115
115
|
Prompts use a **first-answer-wins race**: the same question shows in the local TUI *and* every connected browser, and whoever answers first wins — the other surfaces dismiss. With nobody connected, `/task` behaves exactly as before; the remote path is purely additive.
|
|
116
116
|
|
|
117
|
+
### Push notifications
|
|
118
|
+
|
|
119
|
+
Tap the bell (◯ → ◉) in the remote header to get pushed a notification — even with the app backgrounded or the phone locked — when:
|
|
120
|
+
|
|
121
|
+
- a **grill / clarify question** needs answering (*"pi needs your input"*),
|
|
122
|
+
- a **task finishes** (*"Task finished"*), or
|
|
123
|
+
- the agent hits an **error** (*"Agent error"*).
|
|
124
|
+
|
|
125
|
+
Delivery is **server → push service → device** over the [Web Push](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) standard (service worker + VAPID), so it reaches a suspended device. It works on desktop browsers and on iOS home-screen PWAs.
|
|
126
|
+
|
|
127
|
+
**iOS setup** (these are Apple's requirements, not ours):
|
|
128
|
+
|
|
129
|
+
1. Open the **HTTPS** Tailscale URL (`/remote` lists it). iOS only allows push from a secure context — the plain `http://` LAN URL won't work.
|
|
130
|
+
2. **Share → Add to Home Screen**, then open the app from that icon. iOS only permits notifications for installed PWAs.
|
|
131
|
+
3. Launch the app, **tap the bell**, and **Allow** when prompted.
|
|
132
|
+
|
|
133
|
+
The subscription is kept in memory, so after restarting `pi` just reload the app (it re-subscribes automatically) or tap the bell again. Notifications are suppressed while the app is focused in the foreground — the in-page UI already shows the prompt there.
|
|
134
|
+
|
|
135
|
+
VAPID keys are generated once and persisted to `${XDG_DATA_HOME:-~/.local/share}/pi-task/vapid.json` (deleting them invalidates existing subscriptions). The JWT contact (`sub`) defaults to the project URL; override it with `PI_REMOTE_PUSH_SUBJECT` (e.g. your own `mailto:you@domain.com`). To debug delivery, set `PI_REMOTE_PUSH_DEBUG=1` and tail `/tmp/pi-task-push.log` — it records each push and the **push-service HTTP status** (`201` delivered, `403`/`400` token/key problem, `410` stale subscription).
|
|
136
|
+
|
|
117
137
|
`/remote stop` shuts the server down for the rest of the session (it comes back on the next session start). There is **no authentication** — it's a personal LAN/Tailscale tool. Don't expose the port to untrusted networks.
|
|
118
138
|
|
|
119
139
|
## Bundled tools
|
|
@@ -148,6 +168,10 @@ Resolves an installed npm package, indexes its `.d.ts` files and README into a l
|
|
|
148
168
|
| --- | --- | --- |
|
|
149
169
|
| `BRAVE_SEARCH_API_KEY` / `BRAVE_API_KEY` | `pi-worker-search`, research enrichment | Required for web search. |
|
|
150
170
|
| `XDG_CACHE_HOME` | `pi-worker-docs` | Overrides the docs cache location (defaults to `~/.cache`). |
|
|
171
|
+
| `XDG_DATA_HOME` | remote push | Where the VAPID keypair is stored (defaults to `~/.local/share`). |
|
|
172
|
+
| `PI_REMOTE_PUSH_SUBJECT` | remote push | VAPID JWT `sub` contact. Defaults to the project URL; set your own `mailto:you@domain.com` or `https://…`. |
|
|
173
|
+
| `PI_REMOTE_PUSH_DEBUG` | remote push | When set (e.g. `1`), logs push delivery and push-service HTTP status. Off by default. |
|
|
174
|
+
| `PI_REMOTE_PUSH_LOG` | remote push | Path for the debug log (defaults to `/tmp/pi-task-push.log`). |
|
|
151
175
|
|
|
152
176
|
Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your `.gitignore` if you don't want them checked in.
|
|
153
177
|
|
|
@@ -155,7 +179,7 @@ Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your
|
|
|
155
179
|
|
|
156
180
|
```sh
|
|
157
181
|
bun install
|
|
158
|
-
bun test src/ #
|
|
182
|
+
bun test src/ # 533 tests across 45 files
|
|
159
183
|
bun run lint # prettier + eslint + tsc --noEmit
|
|
160
184
|
bun run build # tsc → dist/
|
|
161
185
|
```
|
package/dist/remote/bridge.d.ts
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import type {
|
|
2
|
+
import type { 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>;
|
|
6
|
-
/** The prompt currently awaiting an answer (replayed to late joiners), or null. */
|
|
7
|
-
activePrompt: PromptMessage | null;
|
|
8
|
-
/** Last lines pushed per widget key (replayed to late joiners). */
|
|
9
|
-
activeWidgets: Map<string, string[]>;
|
|
10
|
-
/** Most recent context-window usage (replayed to seed late joiners' bar), or null. */
|
|
11
|
-
lastContextUsage: ContextUsage | null;
|
|
12
6
|
nextId: number;
|
|
13
7
|
/** Command name → handler, populated as pi-task registers its commands. */
|
|
14
8
|
commands: Map<string, (args: string, ctx: ExtensionCommandContext) => unknown>;
|
|
@@ -43,9 +37,6 @@ export declare class SessionUI {
|
|
|
43
37
|
/** Race the local input against a remote answer; first to settle wins. */
|
|
44
38
|
ask(spec: AskSpec): Promise<string | undefined>;
|
|
45
39
|
}
|
|
46
|
-
/** Mirror a status widget to browsers and remember it for late joiners.
|
|
47
|
-
* `lines === undefined` clears the widget (broadcast as `lines: null`). */
|
|
48
|
-
export declare function publishWidget(key: string, lines: string[] | undefined): void;
|
|
49
40
|
export declare function publishNotify(message: string, level: 'info' | 'warning' | 'error'): void;
|
|
50
41
|
export declare function publishViewer(title: string, text: string): void;
|
|
51
42
|
/**
|
package/dist/remote/bridge.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { broadcast as wsBroadcast } from './broadcast.js';
|
|
2
|
+
import { pushNotify } from './push.js';
|
|
3
|
+
import { setPrompt, clearPrompt } from './session-state.js';
|
|
2
4
|
const g = globalThis;
|
|
3
5
|
export function getBridge() {
|
|
4
6
|
if (!g.__piBridge) {
|
|
5
7
|
g.__piBridge = {
|
|
6
8
|
pending: new Map(),
|
|
7
|
-
activePrompt: null,
|
|
8
|
-
activeWidgets: new Map(),
|
|
9
|
-
lastContextUsage: null,
|
|
10
9
|
nextId: 0,
|
|
11
10
|
commands: new Map(),
|
|
12
11
|
currentCtx: null,
|
|
@@ -55,8 +54,9 @@ export class SessionUI {
|
|
|
55
54
|
recommended: spec.recommended,
|
|
56
55
|
allowSkip: spec.allowSkip
|
|
57
56
|
};
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
setPrompt(prompt);
|
|
58
|
+
// Reaches a backgrounded/suspended phone, which the in-page UI can't.
|
|
59
|
+
void pushNotify('pi needs your input', spec.question, 'pi-prompt').catch(() => { });
|
|
60
60
|
// Local: resolves to a value/undefined, or undefined on abort. Swallow
|
|
61
61
|
// the rejection some implementations throw on abort so it never leaks.
|
|
62
62
|
const local = this.ctx.hasUI ?
|
|
@@ -75,23 +75,10 @@ export class SessionUI {
|
|
|
75
75
|
}
|
|
76
76
|
finally {
|
|
77
77
|
b.pending.delete(id);
|
|
78
|
-
|
|
79
|
-
b.broadcast({ type: 'prompt_resolved', id });
|
|
78
|
+
clearPrompt(id);
|
|
80
79
|
}
|
|
81
80
|
}
|
|
82
81
|
}
|
|
83
|
-
/** Mirror a status widget to browsers and remember it for late joiners.
|
|
84
|
-
* `lines === undefined` clears the widget (broadcast as `lines: null`). */
|
|
85
|
-
export function publishWidget(key, lines) {
|
|
86
|
-
const b = getBridge();
|
|
87
|
-
if (lines === undefined) {
|
|
88
|
-
b.activeWidgets.delete(key);
|
|
89
|
-
b.broadcast({ type: 'widget', key, lines: null });
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
b.activeWidgets.set(key, lines);
|
|
93
|
-
b.broadcast({ type: 'widget', key, lines });
|
|
94
|
-
}
|
|
95
82
|
export function publishNotify(message, level) {
|
|
96
83
|
getBridge().broadcast({ type: 'notify', message, level });
|
|
97
84
|
}
|
package/dist/remote/events.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/** Mirror pi agent events into the authoritative SessionState. Each handler
|
|
3
|
+
* drives a mutator, which updates the snapshot AND broadcasts the live delta. */
|
|
4
|
+
export declare function setupEvents(pi: ExtensionAPI): void;
|
package/dist/remote/events.js
CHANGED
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
import { setAgentIdle } from './state.js';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
import { pushNotify } from './push.js';
|
|
3
|
+
import { publishNotify } from './bridge.js';
|
|
4
|
+
import { agentStart, appendText, textEnd, startTool, updateTool, endTool, agentEnd, addUserTurn, addError, addSystemNote } from './session-state.js';
|
|
5
|
+
/** Mirror pi agent events into the authoritative SessionState. Each handler
|
|
6
|
+
* drives a mutator, which updates the snapshot AND broadcasts the live delta. */
|
|
7
|
+
export function setupEvents(pi) {
|
|
7
8
|
pi.on('agent_start', (_event, _ctx) => {
|
|
8
|
-
currentText = '';
|
|
9
|
-
currentTools.length = 0;
|
|
10
|
-
pendingArgs.clear();
|
|
11
9
|
setAgentIdle(false);
|
|
12
|
-
|
|
10
|
+
agentStart();
|
|
13
11
|
});
|
|
14
12
|
pi.on('message_update', (event, _ctx) => {
|
|
15
13
|
const ae = event.assistantMessageEvent;
|
|
16
14
|
if (ae.type === 'text_delta' && 'delta' in ae && typeof ae.delta === 'string') {
|
|
17
|
-
|
|
18
|
-
broadcastFn({ type: 'text_delta', delta: ae.delta });
|
|
15
|
+
appendText(ae.delta);
|
|
19
16
|
}
|
|
20
17
|
else if (ae.type === 'error') {
|
|
21
18
|
const errorMessage = 'error' in ae && ae.error && typeof ae.error.errorMessage === 'string' ?
|
|
@@ -24,61 +21,42 @@ export function setupEvents(pi, history, broadcastFn) {
|
|
|
24
21
|
// Skip silent user aborts (no message); only surface genuine failures.
|
|
25
22
|
if (errorMessage || ae.reason === 'error') {
|
|
26
23
|
const message = errorMessage || 'Request failed';
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
addError(message);
|
|
25
|
+
void pushNotify('Agent error', message, 'pi-error').catch(() => { });
|
|
29
26
|
}
|
|
30
27
|
}
|
|
31
28
|
});
|
|
32
29
|
pi.on('message_end', (_event, _ctx) => {
|
|
33
|
-
|
|
30
|
+
textEnd();
|
|
34
31
|
});
|
|
35
32
|
pi.on('tool_execution_start', (event, _ctx) => {
|
|
36
|
-
|
|
37
|
-
broadcastFn({
|
|
38
|
-
type: 'tool_start',
|
|
39
|
-
toolCallId: event.toolCallId,
|
|
40
|
-
toolName: event.toolName,
|
|
41
|
-
args: event.args
|
|
42
|
-
});
|
|
33
|
+
startTool(event.toolCallId, event.toolName, event.args);
|
|
43
34
|
});
|
|
44
35
|
pi.on('tool_execution_update', (event, _ctx) => {
|
|
45
|
-
|
|
46
|
-
type: 'tool_update',
|
|
47
|
-
toolCallId: event.toolCallId,
|
|
48
|
-
partialResult: event.partialResult
|
|
49
|
-
});
|
|
36
|
+
updateTool(event.toolCallId, event.partialResult);
|
|
50
37
|
});
|
|
51
38
|
pi.on('tool_execution_end', (event, _ctx) => {
|
|
52
|
-
|
|
53
|
-
pendingArgs.delete(event.toolCallId);
|
|
54
|
-
currentTools.push({
|
|
55
|
-
toolName: event.toolName,
|
|
56
|
-
args,
|
|
57
|
-
result: event.result,
|
|
58
|
-
isError: event.isError
|
|
59
|
-
});
|
|
60
|
-
broadcastFn({
|
|
61
|
-
type: 'tool_end',
|
|
62
|
-
toolCallId: event.toolCallId,
|
|
63
|
-
toolName: event.toolName,
|
|
64
|
-
result: event.result,
|
|
65
|
-
isError: event.isError
|
|
66
|
-
});
|
|
39
|
+
endTool(event.toolCallId, event.toolName, event.result, event.isError);
|
|
67
40
|
});
|
|
68
41
|
pi.on('agent_end', (_event, ctx) => {
|
|
69
|
-
const contextUsage = ctx.getContextUsage();
|
|
70
42
|
setAgentIdle(true);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
history.addAssistantTurn(currentText, [...currentTools]);
|
|
74
|
-
broadcastFn({ type: 'agent_end', contextUsage });
|
|
75
|
-
currentText = '';
|
|
76
|
-
currentTools.length = 0;
|
|
43
|
+
agentEnd(ctx.getContextUsage());
|
|
44
|
+
void pushNotify('Task finished', '', 'pi-end').catch(() => { });
|
|
77
45
|
});
|
|
78
46
|
pi.on('input', (event, _ctx) => {
|
|
79
47
|
if (event.source === 'interactive' && typeof event.text === 'string') {
|
|
80
|
-
|
|
81
|
-
broadcastFn({ type: 'user_message', text: event.text });
|
|
48
|
+
addUserTurn(event.text);
|
|
82
49
|
}
|
|
83
50
|
});
|
|
51
|
+
// Context-window compaction (incl. the auto-compaction triggered by a context
|
|
52
|
+
// overflow) is invisible to a remote viewer otherwise — mirror it as a toast so
|
|
53
|
+
// they see the same "compacting…" status the terminal shows.
|
|
54
|
+
pi.on('session_before_compact', (_event, _ctx) => {
|
|
55
|
+
publishNotify('Context full — compacting…', 'warning');
|
|
56
|
+
});
|
|
57
|
+
pi.on('session_compact', (_event, _ctx) => {
|
|
58
|
+
// Persistent inline note so it's still visible after a reconnect, not just a
|
|
59
|
+
// transient toast.
|
|
60
|
+
addSystemNote('Context compacted');
|
|
61
|
+
});
|
|
84
62
|
}
|
package/dist/remote/history.d.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface TextPart {
|
|
2
|
+
kind: 'text';
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
export interface ToolPart {
|
|
6
|
+
kind: 'tool';
|
|
7
|
+
toolCallId: string;
|
|
2
8
|
toolName: string;
|
|
3
9
|
args: unknown;
|
|
4
10
|
result: unknown;
|
|
5
11
|
isError: boolean;
|
|
12
|
+
/** false while the tool is still running (result not in yet). */
|
|
13
|
+
done: boolean;
|
|
6
14
|
}
|
|
15
|
+
export type Part = TextPart | ToolPart;
|
|
7
16
|
export interface Turn {
|
|
8
|
-
role: 'user' | 'assistant';
|
|
9
|
-
text
|
|
10
|
-
|
|
17
|
+
role: 'user' | 'assistant' | 'system';
|
|
18
|
+
/** User text, error text, or a system note. Assistant content lives in `parts`. */
|
|
19
|
+
text?: string;
|
|
20
|
+
/** Ordered assistant content (text + tools). */
|
|
21
|
+
parts?: Part[];
|
|
11
22
|
error?: boolean;
|
|
12
23
|
}
|
|
13
24
|
export declare class HistoryBuffer {
|
|
@@ -15,8 +26,10 @@ export declare class HistoryBuffer {
|
|
|
15
26
|
private readonly limit;
|
|
16
27
|
constructor(limit?: number);
|
|
17
28
|
addUserMessage(text: string): void;
|
|
18
|
-
addAssistantTurn(
|
|
29
|
+
addAssistantTurn(parts: Part[]): void;
|
|
19
30
|
addError(text: string): void;
|
|
31
|
+
/** A persistent, inline system note (e.g. "Context compacted"). */
|
|
32
|
+
addSystemNote(text: string): void;
|
|
20
33
|
getEntries(): Turn[];
|
|
21
34
|
private _push;
|
|
22
35
|
}
|
package/dist/remote/history.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// An assistant turn is an ORDERED list of parts — text segments and tool calls
|
|
2
|
+
// interleaved exactly as they happened — so the remote transcript reproduces the
|
|
3
|
+
// terminal's layout instead of collapsing a whole run into one text blob.
|
|
1
4
|
export class HistoryBuffer {
|
|
2
5
|
entries = [];
|
|
3
6
|
limit;
|
|
@@ -5,13 +8,17 @@ export class HistoryBuffer {
|
|
|
5
8
|
this.limit = limit;
|
|
6
9
|
}
|
|
7
10
|
addUserMessage(text) {
|
|
8
|
-
this._push({ role: 'user', text
|
|
11
|
+
this._push({ role: 'user', text });
|
|
9
12
|
}
|
|
10
|
-
addAssistantTurn(
|
|
11
|
-
this._push({ role: 'assistant',
|
|
13
|
+
addAssistantTurn(parts) {
|
|
14
|
+
this._push({ role: 'assistant', parts });
|
|
12
15
|
}
|
|
13
16
|
addError(text) {
|
|
14
|
-
this._push({ role: 'assistant', text,
|
|
17
|
+
this._push({ role: 'assistant', text, error: true });
|
|
18
|
+
}
|
|
19
|
+
/** A persistent, inline system note (e.g. "Context compacted"). */
|
|
20
|
+
addSystemNote(text) {
|
|
21
|
+
this._push({ role: 'system', text });
|
|
15
22
|
}
|
|
16
23
|
getEntries() {
|
|
17
24
|
return [...this.entries];
|
|
@@ -9,9 +9,9 @@ export interface PromptResolvedMessage {
|
|
|
9
9
|
type: 'prompt_resolved';
|
|
10
10
|
id: string;
|
|
11
11
|
}
|
|
12
|
+
/** The single task-widget slot. `lines: null` clears it. */
|
|
12
13
|
export interface WidgetMessage {
|
|
13
14
|
type: 'widget';
|
|
14
|
-
key: string;
|
|
15
15
|
lines: string[] | null;
|
|
16
16
|
}
|
|
17
17
|
export interface NotifyMessage {
|
|
@@ -35,10 +35,18 @@ export interface ContextMessage {
|
|
|
35
35
|
type: 'context';
|
|
36
36
|
contextUsage: ContextUsage;
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
}
|
|
43
|
+
/** The full authoritative state sent to a (re)connecting client. Defined in
|
|
44
|
+
* session-state.ts (its serializer); re-exported here as part of the wire type. */
|
|
45
|
+
export type { SnapshotMessage } from './session-state.js';
|
|
46
|
+
/** Server → browser messages. The live text_delta / tool_* / agent_* /
|
|
47
|
+
* client_count / user_message deltas are emitted by the SessionState mutators
|
|
48
|
+
* and not all enumerated here; the snapshot below carries the full state. */
|
|
49
|
+
export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage | ResetMessage | import('./session-state.js').SnapshotMessage;
|
|
42
50
|
/** Browser → server messages. */
|
|
43
51
|
export interface ClientChatMessage {
|
|
44
52
|
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
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { broadcast } from './broadcast.js';
|
|
2
1
|
import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx } from './bridge.js';
|
|
3
2
|
import { setupEvents } from './events.js';
|
|
4
|
-
import {
|
|
3
|
+
import { reset, addUserTurn } from './session-state.js';
|
|
5
4
|
import { html } from './ui.js';
|
|
6
5
|
import { qrLines } from './qr.js';
|
|
7
6
|
import { startServer, formatAddresses } from './server.js';
|
|
@@ -12,7 +11,6 @@ if (!_g.__piRemote)
|
|
|
12
11
|
_g.__piRemote = { server: null, send: null, serveResult: null };
|
|
13
12
|
const S = _g.__piRemote;
|
|
14
13
|
export function registerRemote(pi) {
|
|
15
|
-
const history = new HistoryBuffer(20);
|
|
16
14
|
async function ensureServer() {
|
|
17
15
|
if (S.server)
|
|
18
16
|
return S.server;
|
|
@@ -29,7 +27,7 @@ export function registerRemote(pi) {
|
|
|
29
27
|
}
|
|
30
28
|
dispatchRemoteLine(text, {
|
|
31
29
|
onPlain: plain => {
|
|
32
|
-
|
|
30
|
+
addUserTurn(plain);
|
|
33
31
|
if (isAgentIdle()) {
|
|
34
32
|
S.send?.(plain);
|
|
35
33
|
}
|
|
@@ -38,7 +36,7 @@ export function registerRemote(pi) {
|
|
|
38
36
|
}
|
|
39
37
|
}
|
|
40
38
|
});
|
|
41
|
-
}, wsUrl => html(wsUrl)
|
|
39
|
+
}, wsUrl => html(wsUrl));
|
|
42
40
|
// Hands-off HTTPS: point Tailscale serve at our port so phones get a
|
|
43
41
|
// secure context. Best-effort — any failure degrades to the http URL.
|
|
44
42
|
S.serveResult = await ensureTailscaleServe(S.server.port).catch(() => ({ state: 'unavailable' }));
|
|
@@ -46,17 +44,22 @@ export function registerRemote(pi) {
|
|
|
46
44
|
}
|
|
47
45
|
pi.on('session_start', (_event, ctx) => {
|
|
48
46
|
S.send = (text, opts) => (opts ? pi.sendUserMessage(text, opts) : pi.sendUserMessage(text));
|
|
49
|
-
|
|
47
|
+
// A new session (incl. /new and the /task handoff's newSession) means the
|
|
48
|
+
// browser is showing a stale transcript/widgets — wipe the authoritative
|
|
49
|
+
// state and tell connected clients to clear.
|
|
50
|
+
const bridge = getBridge();
|
|
51
|
+
reset();
|
|
52
|
+
setupEvents(pi);
|
|
50
53
|
// Seed a shimmed ctx so commands that don't need newSession (/task-list,
|
|
51
54
|
// /task-cancel, /task-auto-cancel) work immediately from the remote without
|
|
52
55
|
// any terminal interaction. Only overwrite if null or already shimmed —
|
|
53
56
|
// a real command ctx captured from a prior terminal command must survive
|
|
54
57
|
// session_start (it's updated via withSession or registerBridgeCommand,
|
|
55
58
|
// not replaced here).
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
if (!bridge.currentCtx
|
|
60
|
+
|| bridge.currentCtx['__piRemoteShimmed']
|
|
61
|
+
=== true) {
|
|
62
|
+
bridge.currentCtx = makeShimmedCtx(ctx);
|
|
60
63
|
}
|
|
61
64
|
void ensureServer().catch(err => ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error'));
|
|
62
65
|
});
|
package/dist/remote/server.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { Turn } from './history.js';
|
|
2
1
|
export interface LocalIPs {
|
|
3
2
|
/** Tailscale (tailscale0) IPv4 address, if the interface is up. */
|
|
4
3
|
tailscale?: string;
|
|
@@ -27,5 +26,5 @@ export declare function getLocalIPs(nets?: NodeJS.Dict<import("node:os").Network
|
|
|
27
26
|
/** Build the labeled URL lines shown under the QR code. Both Tailscale and LAN
|
|
28
27
|
* when present; a single unlabeled primary URL when neither resolves. */
|
|
29
28
|
export declare function formatAddresses(ips: LocalIPs, port: number): AddressLine[];
|
|
30
|
-
export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string
|
|
29
|
+
export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string): Promise<ServerHandle>;
|
|
31
30
|
export {};
|