@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/dist/remote/server.js
CHANGED
|
@@ -2,8 +2,11 @@ import { createServer } from 'node:http';
|
|
|
2
2
|
import { networkInterfaces } from 'node:os';
|
|
3
3
|
import { WebSocketServer } from 'ws';
|
|
4
4
|
import { addClient, removeClient, clientCount, broadcast, sendTo } from './broadcast.js';
|
|
5
|
-
import {
|
|
5
|
+
import { answerPrompt } from './bridge.js';
|
|
6
|
+
import { getState, snapshot } from './session-state.js';
|
|
6
7
|
import { isClientMessage } from './protocol.js';
|
|
8
|
+
import { swJs } from './sw.js';
|
|
9
|
+
import { publicKey, addSubscription, getSubscriptions, logPush } from './push.js';
|
|
7
10
|
export function getLocalIPs(nets = networkInterfaces()) {
|
|
8
11
|
// Prefer Tailscale when available.
|
|
9
12
|
let tailscale;
|
|
@@ -57,7 +60,7 @@ async function findPort(start, max) {
|
|
|
57
60
|
}
|
|
58
61
|
throw new Error(`No free port found in range ${start}–${start + max - 1}`);
|
|
59
62
|
}
|
|
60
|
-
export async function startServer(onMessage, getHtml
|
|
63
|
+
export async function startServer(onMessage, getHtml) {
|
|
61
64
|
const port = await findPort(8800, 100);
|
|
62
65
|
const ips = getLocalIPs();
|
|
63
66
|
const ip = ips.primary;
|
|
@@ -68,6 +71,34 @@ export async function startServer(onMessage, getHtml, getHistory) {
|
|
|
68
71
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
69
72
|
res.end(body);
|
|
70
73
|
}
|
|
74
|
+
else if (req.method === 'GET' && req.url === '/sw.js') {
|
|
75
|
+
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' });
|
|
76
|
+
res.end(swJs());
|
|
77
|
+
}
|
|
78
|
+
else if (req.method === 'GET' && req.url === '/push-key') {
|
|
79
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
80
|
+
res.end(publicKey());
|
|
81
|
+
}
|
|
82
|
+
else if (req.method === 'POST' && req.url === '/subscribe') {
|
|
83
|
+
const chunks = [];
|
|
84
|
+
req.on('data', c => chunks.push(c));
|
|
85
|
+
req.on('end', () => {
|
|
86
|
+
try {
|
|
87
|
+
const sub = JSON.parse(Buffer.concat(chunks).toString());
|
|
88
|
+
if (!sub || typeof sub.endpoint !== 'string')
|
|
89
|
+
throw new Error('no endpoint');
|
|
90
|
+
addSubscription(sub);
|
|
91
|
+
logPush(`subscribe ${new URL(sub.endpoint).host} (total ${getSubscriptions().length})`);
|
|
92
|
+
res.writeHead(201);
|
|
93
|
+
res.end('ok');
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
logPush('subscribe REJECTED (malformed body)');
|
|
97
|
+
res.writeHead(400);
|
|
98
|
+
res.end('bad subscription');
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
71
102
|
else {
|
|
72
103
|
res.writeHead(404);
|
|
73
104
|
res.end('Not found');
|
|
@@ -88,16 +119,8 @@ export async function startServer(onMessage, getHtml, getHistory) {
|
|
|
88
119
|
addClient(ws);
|
|
89
120
|
handle.onFirstConnect?.();
|
|
90
121
|
handle.onFirstConnect = null;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
for (const [key, lines] of bridge.activeWidgets) {
|
|
94
|
-
sendTo(ws, { type: 'widget', key, lines });
|
|
95
|
-
}
|
|
96
|
-
if (bridge.activePrompt)
|
|
97
|
-
sendTo(ws, bridge.activePrompt);
|
|
98
|
-
if (bridge.lastContextUsage) {
|
|
99
|
-
sendTo(ws, { type: 'context', contextUsage: bridge.lastContextUsage });
|
|
100
|
-
}
|
|
122
|
+
// One authoritative snapshot — the client replaces its whole view with it.
|
|
123
|
+
sendTo(ws, snapshot());
|
|
101
124
|
broadcast({ type: 'client_count', count: clientCount() });
|
|
102
125
|
ws.on('message', data => {
|
|
103
126
|
let msg;
|
|
@@ -115,7 +138,7 @@ export async function startServer(onMessage, getHtml, getHistory) {
|
|
|
115
138
|
}
|
|
116
139
|
// type === 'message': ignore while a prompt is pending (composer is
|
|
117
140
|
// disabled in the browser; this is the server-side guard).
|
|
118
|
-
if (
|
|
141
|
+
if (getState().prompt)
|
|
119
142
|
return;
|
|
120
143
|
onMessage(msg.text);
|
|
121
144
|
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { HistoryBuffer } from './history.js';
|
|
2
|
+
import type { Turn, Part } from './history.js';
|
|
3
|
+
import type { ContextUsage, PromptMessage } from './protocol.js';
|
|
4
|
+
export interface LiveTurn {
|
|
5
|
+
/** Ordered assistant content (text segments + tool calls) for this run. */
|
|
6
|
+
parts: Part[];
|
|
7
|
+
/** Is the trailing text part still accumulating deltas? Closed by text_end /
|
|
8
|
+
* a tool start, so the next text begins a fresh segment (separate bubble). */
|
|
9
|
+
textOpen: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface SnapshotMessage {
|
|
12
|
+
type: 'snapshot';
|
|
13
|
+
turns: Turn[];
|
|
14
|
+
live: LiveTurn | null;
|
|
15
|
+
agentRunning: boolean;
|
|
16
|
+
taskWidget: string[] | null;
|
|
17
|
+
prompt: PromptMessage | null;
|
|
18
|
+
context: ContextUsage | null;
|
|
19
|
+
}
|
|
20
|
+
interface SessionState {
|
|
21
|
+
history: HistoryBuffer;
|
|
22
|
+
live: LiveTurn | null;
|
|
23
|
+
agentRunning: boolean;
|
|
24
|
+
taskWidget: string[] | null;
|
|
25
|
+
prompt: PromptMessage | null;
|
|
26
|
+
context: ContextUsage | null;
|
|
27
|
+
/** Broadcast sink — swapped in tests via _setSink. */
|
|
28
|
+
sink: (msg: unknown) => void;
|
|
29
|
+
}
|
|
30
|
+
export declare function getState(): SessionState;
|
|
31
|
+
/** @internal Test-only: swap the broadcast sink to capture emitted deltas. */
|
|
32
|
+
export declare function _setSink(fn: (msg: unknown) => void): void;
|
|
33
|
+
export declare function agentStart(): void;
|
|
34
|
+
export declare function appendText(delta: string): void;
|
|
35
|
+
export declare function textEnd(): void;
|
|
36
|
+
export declare function startTool(toolCallId: string, toolName: string, args: unknown): void;
|
|
37
|
+
export declare function updateTool(toolCallId: string, partialResult: unknown): void;
|
|
38
|
+
export declare function endTool(toolCallId: string, toolName: string, result: unknown, isError: boolean): void;
|
|
39
|
+
export declare function agentEnd(context: ContextUsage): void;
|
|
40
|
+
export declare function addUserTurn(text: string): void;
|
|
41
|
+
export declare function addError(message: string): void;
|
|
42
|
+
/** A persistent inline note (committed to the transcript so it survives reconnect)
|
|
43
|
+
* plus a live delta so connected clients render it immediately. */
|
|
44
|
+
export declare function addSystemNote(text: string): void;
|
|
45
|
+
/** The single task-widget slot. Empty/undefined lines clear it. */
|
|
46
|
+
export declare function setTaskWidget(lines: string[] | null | undefined): void;
|
|
47
|
+
export declare function setPrompt(prompt: PromptMessage): void;
|
|
48
|
+
export declare function clearPrompt(id: string): void;
|
|
49
|
+
export declare function setContext(context: ContextUsage): void;
|
|
50
|
+
/** Wipe everything (new session) and tell connected clients to clear. */
|
|
51
|
+
export declare function reset(): void;
|
|
52
|
+
/** Serialize the whole state for a (re)connecting client. */
|
|
53
|
+
export declare function snapshot(): SnapshotMessage;
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// The single authoritative mirror of what a connected browser should be showing.
|
|
2
|
+
//
|
|
3
|
+
// Every change funnels through a mutator that (1) updates this object and (2)
|
|
4
|
+
// broadcasts the matching live delta. On (re)connect the server serializes the
|
|
5
|
+
// whole object with snapshot() and the client replaces its entire view. Because
|
|
6
|
+
// the snapshot and the live deltas read/write the same object, they can never
|
|
7
|
+
// disagree — which is what kills the duplicate-transcript / orphaned-widget /
|
|
8
|
+
// two-task-widget drift the ad-hoc broadcasting used to cause.
|
|
9
|
+
//
|
|
10
|
+
// State lives on globalThis so it survives jiti module re-evaluation on session
|
|
11
|
+
// switches, the same pattern broadcast.ts and bridge.ts use.
|
|
12
|
+
import { broadcast as wsBroadcast } from './broadcast.js';
|
|
13
|
+
import { HistoryBuffer } from './history.js';
|
|
14
|
+
const g = globalThis;
|
|
15
|
+
function fresh() {
|
|
16
|
+
return {
|
|
17
|
+
history: new HistoryBuffer(20),
|
|
18
|
+
live: null,
|
|
19
|
+
agentRunning: false,
|
|
20
|
+
taskWidget: null,
|
|
21
|
+
prompt: null,
|
|
22
|
+
context: null,
|
|
23
|
+
sink: wsBroadcast
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function getState() {
|
|
27
|
+
if (!g.__piSessionState)
|
|
28
|
+
g.__piSessionState = fresh();
|
|
29
|
+
return g.__piSessionState;
|
|
30
|
+
}
|
|
31
|
+
/** @internal Test-only: swap the broadcast sink to capture emitted deltas. */
|
|
32
|
+
export function _setSink(fn) {
|
|
33
|
+
getState().sink = fn;
|
|
34
|
+
}
|
|
35
|
+
function ensureLive(s) {
|
|
36
|
+
if (!s.live)
|
|
37
|
+
s.live = { parts: [], textOpen: false };
|
|
38
|
+
return s.live;
|
|
39
|
+
}
|
|
40
|
+
// ─── Mutators ────────────────────────────────────────────────────────────────
|
|
41
|
+
export function agentStart() {
|
|
42
|
+
const s = getState();
|
|
43
|
+
s.live = { parts: [], textOpen: false };
|
|
44
|
+
s.agentRunning = true;
|
|
45
|
+
s.sink({ type: 'agent_start' });
|
|
46
|
+
}
|
|
47
|
+
export function appendText(delta) {
|
|
48
|
+
const s = getState();
|
|
49
|
+
const live = ensureLive(s);
|
|
50
|
+
const last = live.parts[live.parts.length - 1];
|
|
51
|
+
if (live.textOpen && last && last.kind === 'text') {
|
|
52
|
+
last.text += delta;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
live.parts.push({ kind: 'text', text: delta });
|
|
56
|
+
live.textOpen = true;
|
|
57
|
+
}
|
|
58
|
+
s.sink({ type: 'text_delta', delta });
|
|
59
|
+
}
|
|
60
|
+
export function textEnd() {
|
|
61
|
+
const s = getState();
|
|
62
|
+
if (s.live)
|
|
63
|
+
s.live.textOpen = false; // next text starts a new segment
|
|
64
|
+
s.sink({ type: 'text_end' });
|
|
65
|
+
}
|
|
66
|
+
export function startTool(toolCallId, toolName, args) {
|
|
67
|
+
const s = getState();
|
|
68
|
+
const live = ensureLive(s);
|
|
69
|
+
live.textOpen = false;
|
|
70
|
+
live.parts.push({
|
|
71
|
+
kind: 'tool',
|
|
72
|
+
toolCallId,
|
|
73
|
+
toolName,
|
|
74
|
+
args,
|
|
75
|
+
result: undefined,
|
|
76
|
+
isError: false,
|
|
77
|
+
done: false
|
|
78
|
+
});
|
|
79
|
+
s.sink({ type: 'tool_start', toolCallId, toolName, args });
|
|
80
|
+
}
|
|
81
|
+
export function updateTool(toolCallId, partialResult) {
|
|
82
|
+
getState().sink({ type: 'tool_update', toolCallId, partialResult });
|
|
83
|
+
}
|
|
84
|
+
export function endTool(toolCallId, toolName, result, isError) {
|
|
85
|
+
const s = getState();
|
|
86
|
+
const live = ensureLive(s);
|
|
87
|
+
const part = live.parts.find((p) => p.kind === 'tool' && p.toolCallId === toolCallId);
|
|
88
|
+
if (part) {
|
|
89
|
+
part.result = result;
|
|
90
|
+
part.isError = isError;
|
|
91
|
+
part.done = true;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// tool_end without a matching start (shouldn't happen) — append a done tool.
|
|
95
|
+
live.parts.push({
|
|
96
|
+
kind: 'tool',
|
|
97
|
+
toolCallId,
|
|
98
|
+
toolName,
|
|
99
|
+
args: undefined,
|
|
100
|
+
result,
|
|
101
|
+
isError,
|
|
102
|
+
done: true
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
s.sink({ type: 'tool_end', toolCallId, toolName, result, isError });
|
|
106
|
+
}
|
|
107
|
+
export function agentEnd(context) {
|
|
108
|
+
const s = getState();
|
|
109
|
+
if (s.live)
|
|
110
|
+
s.history.addAssistantTurn(s.live.parts);
|
|
111
|
+
s.live = null;
|
|
112
|
+
s.agentRunning = false;
|
|
113
|
+
s.context = context;
|
|
114
|
+
s.sink({ type: 'agent_end', contextUsage: context });
|
|
115
|
+
}
|
|
116
|
+
export function addUserTurn(text) {
|
|
117
|
+
const s = getState();
|
|
118
|
+
s.history.addUserMessage(text);
|
|
119
|
+
s.sink({ type: 'user_message', text });
|
|
120
|
+
}
|
|
121
|
+
export function addError(message) {
|
|
122
|
+
const s = getState();
|
|
123
|
+
s.history.addError(message);
|
|
124
|
+
s.live = null;
|
|
125
|
+
s.agentRunning = false;
|
|
126
|
+
s.sink({ type: 'agent_error', message });
|
|
127
|
+
}
|
|
128
|
+
/** A persistent inline note (committed to the transcript so it survives reconnect)
|
|
129
|
+
* plus a live delta so connected clients render it immediately. */
|
|
130
|
+
export function addSystemNote(text) {
|
|
131
|
+
const s = getState();
|
|
132
|
+
s.history.addSystemNote(text);
|
|
133
|
+
s.sink({ type: 'system_note', text });
|
|
134
|
+
}
|
|
135
|
+
/** The single task-widget slot. Empty/undefined lines clear it. */
|
|
136
|
+
export function setTaskWidget(lines) {
|
|
137
|
+
const s = getState();
|
|
138
|
+
s.taskWidget = lines && lines.length ? lines : null;
|
|
139
|
+
s.sink({ type: 'widget', lines: s.taskWidget });
|
|
140
|
+
}
|
|
141
|
+
export function setPrompt(prompt) {
|
|
142
|
+
const s = getState();
|
|
143
|
+
s.prompt = prompt;
|
|
144
|
+
s.sink(prompt);
|
|
145
|
+
}
|
|
146
|
+
export function clearPrompt(id) {
|
|
147
|
+
const s = getState();
|
|
148
|
+
s.prompt = null;
|
|
149
|
+
s.sink({ type: 'prompt_resolved', id });
|
|
150
|
+
}
|
|
151
|
+
export function setContext(context) {
|
|
152
|
+
const s = getState();
|
|
153
|
+
s.context = context;
|
|
154
|
+
s.sink({ type: 'context', contextUsage: context });
|
|
155
|
+
}
|
|
156
|
+
/** Wipe everything (new session) and tell connected clients to clear. */
|
|
157
|
+
export function reset() {
|
|
158
|
+
const s = getState();
|
|
159
|
+
s.history = new HistoryBuffer(20);
|
|
160
|
+
s.live = null;
|
|
161
|
+
s.agentRunning = false;
|
|
162
|
+
s.taskWidget = null;
|
|
163
|
+
s.prompt = null;
|
|
164
|
+
s.context = null;
|
|
165
|
+
s.sink({ type: 'reset' });
|
|
166
|
+
}
|
|
167
|
+
/** Serialize the whole state for a (re)connecting client. */
|
|
168
|
+
export function snapshot() {
|
|
169
|
+
const s = getState();
|
|
170
|
+
return {
|
|
171
|
+
type: 'snapshot',
|
|
172
|
+
turns: s.history.getEntries(),
|
|
173
|
+
live: s.live ? { parts: [...s.live.parts], textOpen: s.live.textOpen } : null,
|
|
174
|
+
agentRunning: s.agentRunning,
|
|
175
|
+
taskWidget: s.taskWidget,
|
|
176
|
+
prompt: s.prompt,
|
|
177
|
+
context: s.context
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -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
|
+
}
|