@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 CHANGED
@@ -7,7 +7,7 @@
7
7
  [![npm](https://img.shields.io/npm/v/@mjasnikovs/pi-task?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mjasnikovs/pi-task)
8
8
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
9
9
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
10
- [![tests](https://img.shields.io/badge/tests-326%20passing-3fb950)](#development)
10
+ [![tests](https://img.shields.io/badge/tests-533%20passing-3fb950)](#development)
11
11
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](./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/ # 326 tests across 28 files
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
  ```
@@ -1,14 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
- import type { ContextUsage, PromptMessage, ServerMessage } from './protocol.js';
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
  /**
@@ -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
- b.activePrompt = prompt;
59
- b.broadcast(prompt);
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
- b.activePrompt = null;
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
  }
@@ -1,3 +1,4 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
- import type { HistoryBuffer } from './history.js';
3
- export declare function setupEvents(pi: ExtensionAPI, history: HistoryBuffer, broadcastFn: (msg: unknown) => void): void;
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;
@@ -1,21 +1,18 @@
1
1
  import { setAgentIdle } from './state.js';
2
- import { getBridge } from './bridge.js';
3
- export function setupEvents(pi, history, broadcastFn) {
4
- let currentText = '';
5
- const currentTools = [];
6
- const pendingArgs = new Map();
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
- broadcastFn({ type: 'agent_start' });
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
- currentText += ae.delta;
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
- history.addError(message);
28
- broadcastFn({ type: 'agent_error', message });
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
- broadcastFn({ type: 'text_end' });
30
+ textEnd();
34
31
  });
35
32
  pi.on('tool_execution_start', (event, _ctx) => {
36
- pendingArgs.set(event.toolCallId, event.args);
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
- broadcastFn({
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
- const args = pendingArgs.get(event.toolCallId);
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
- // Remember it so a browser that connects mid-session gets its bar seeded.
72
- getBridge().lastContextUsage = contextUsage;
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
- history.addUserMessage(event.text);
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
  }
@@ -1,13 +1,24 @@
1
- export interface ToolSummary {
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: string;
10
- tools: ToolSummary[];
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(text: string, tools: ToolSummary[]): void;
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
  }
@@ -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, tools: [] });
11
+ this._push({ role: 'user', text });
9
12
  }
10
- addAssistantTurn(text, tools) {
11
- this._push({ role: 'assistant', text, tools });
13
+ addAssistantTurn(parts) {
14
+ this._push({ role: 'assistant', parts });
12
15
  }
13
16
  addError(text) {
14
- this._push({ role: 'assistant', text, tools: [], error: true });
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
- /** Server browser messages added by the integration. The existing
39
- * history / text_delta / tool_* / agent_* / client_count / user_message messages are
40
- * emitted ad hoc by events.ts / server.ts and are not enumerated here. */
41
- export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage;
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
+ }
@@ -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 { HistoryBuffer } from './history.js';
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
- history.addUserMessage(plain);
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), () => history.getEntries());
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
- setupEvents(pi, history, broadcast);
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
- const b = getBridge();
57
- if (!b.currentCtx
58
- || b.currentCtx['__piRemoteShimmed'] === true) {
59
- b.currentCtx = makeShimmedCtx(ctx);
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
  });
@@ -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, getHistory: () => Turn[]): Promise<ServerHandle>;
29
+ export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string): Promise<ServerHandle>;
31
30
  export {};