@mjasnikovs/pi-task 0.7.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 +3 -19
- package/dist/remote/events.d.ts +3 -2
- package/dist/remote/events.js +25 -50
- package/dist/remote/history.d.ts +18 -5
- package/dist/remote/history.js +11 -4
- package/dist/remote/protocol.d.ts +8 -5
- package/dist/remote/register.js +7 -11
- package/dist/remote/server.d.ts +1 -2
- package/dist/remote/server.js +6 -13
- package/dist/remote/session-state.d.ts +54 -0
- package/dist/remote/session-state.js +179 -0
- package/dist/remote/ui.js +154 -37
- 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 +1 -1
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,13 +1,11 @@
|
|
|
1
1
|
import { broadcast as wsBroadcast } from './broadcast.js';
|
|
2
2
|
import { pushNotify } from './push.js';
|
|
3
|
+
import { setPrompt, clearPrompt } from './session-state.js';
|
|
3
4
|
const g = globalThis;
|
|
4
5
|
export function getBridge() {
|
|
5
6
|
if (!g.__piBridge) {
|
|
6
7
|
g.__piBridge = {
|
|
7
8
|
pending: new Map(),
|
|
8
|
-
activePrompt: null,
|
|
9
|
-
activeWidgets: new Map(),
|
|
10
|
-
lastContextUsage: null,
|
|
11
9
|
nextId: 0,
|
|
12
10
|
commands: new Map(),
|
|
13
11
|
currentCtx: null,
|
|
@@ -56,8 +54,7 @@ export class SessionUI {
|
|
|
56
54
|
recommended: spec.recommended,
|
|
57
55
|
allowSkip: spec.allowSkip
|
|
58
56
|
};
|
|
59
|
-
|
|
60
|
-
b.broadcast(prompt);
|
|
57
|
+
setPrompt(prompt);
|
|
61
58
|
// Reaches a backgrounded/suspended phone, which the in-page UI can't.
|
|
62
59
|
void pushNotify('pi needs your input', spec.question, 'pi-prompt').catch(() => { });
|
|
63
60
|
// Local: resolves to a value/undefined, or undefined on abort. Swallow
|
|
@@ -78,23 +75,10 @@ export class SessionUI {
|
|
|
78
75
|
}
|
|
79
76
|
finally {
|
|
80
77
|
b.pending.delete(id);
|
|
81
|
-
|
|
82
|
-
b.broadcast({ type: 'prompt_resolved', id });
|
|
78
|
+
clearPrompt(id);
|
|
83
79
|
}
|
|
84
80
|
}
|
|
85
81
|
}
|
|
86
|
-
/** Mirror a status widget to browsers and remember it for late joiners.
|
|
87
|
-
* `lines === undefined` clears the widget (broadcast as `lines: null`). */
|
|
88
|
-
export function publishWidget(key, lines) {
|
|
89
|
-
const b = getBridge();
|
|
90
|
-
if (lines === undefined) {
|
|
91
|
-
b.activeWidgets.delete(key);
|
|
92
|
-
b.broadcast({ type: 'widget', key, lines: null });
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
b.activeWidgets.set(key, lines);
|
|
96
|
-
b.broadcast({ type: 'widget', key, lines });
|
|
97
|
-
}
|
|
98
82
|
export function publishNotify(message, level) {
|
|
99
83
|
getBridge().broadcast({ type: 'notify', message, level });
|
|
100
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,22 +1,18 @@
|
|
|
1
1
|
import { setAgentIdle } from './state.js';
|
|
2
|
-
import { getBridge } from './bridge.js';
|
|
3
2
|
import { pushNotify } from './push.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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) {
|
|
8
8
|
pi.on('agent_start', (_event, _ctx) => {
|
|
9
|
-
currentText = '';
|
|
10
|
-
currentTools.length = 0;
|
|
11
|
-
pendingArgs.clear();
|
|
12
9
|
setAgentIdle(false);
|
|
13
|
-
|
|
10
|
+
agentStart();
|
|
14
11
|
});
|
|
15
12
|
pi.on('message_update', (event, _ctx) => {
|
|
16
13
|
const ae = event.assistantMessageEvent;
|
|
17
14
|
if (ae.type === 'text_delta' && 'delta' in ae && typeof ae.delta === 'string') {
|
|
18
|
-
|
|
19
|
-
broadcastFn({ type: 'text_delta', delta: ae.delta });
|
|
15
|
+
appendText(ae.delta);
|
|
20
16
|
}
|
|
21
17
|
else if (ae.type === 'error') {
|
|
22
18
|
const errorMessage = 'error' in ae && ae.error && typeof ae.error.errorMessage === 'string' ?
|
|
@@ -25,63 +21,42 @@ export function setupEvents(pi, history, broadcastFn) {
|
|
|
25
21
|
// Skip silent user aborts (no message); only surface genuine failures.
|
|
26
22
|
if (errorMessage || ae.reason === 'error') {
|
|
27
23
|
const message = errorMessage || 'Request failed';
|
|
28
|
-
|
|
29
|
-
broadcastFn({ type: 'agent_error', message });
|
|
24
|
+
addError(message);
|
|
30
25
|
void pushNotify('Agent error', message, 'pi-error').catch(() => { });
|
|
31
26
|
}
|
|
32
27
|
}
|
|
33
28
|
});
|
|
34
29
|
pi.on('message_end', (_event, _ctx) => {
|
|
35
|
-
|
|
30
|
+
textEnd();
|
|
36
31
|
});
|
|
37
32
|
pi.on('tool_execution_start', (event, _ctx) => {
|
|
38
|
-
|
|
39
|
-
broadcastFn({
|
|
40
|
-
type: 'tool_start',
|
|
41
|
-
toolCallId: event.toolCallId,
|
|
42
|
-
toolName: event.toolName,
|
|
43
|
-
args: event.args
|
|
44
|
-
});
|
|
33
|
+
startTool(event.toolCallId, event.toolName, event.args);
|
|
45
34
|
});
|
|
46
35
|
pi.on('tool_execution_update', (event, _ctx) => {
|
|
47
|
-
|
|
48
|
-
type: 'tool_update',
|
|
49
|
-
toolCallId: event.toolCallId,
|
|
50
|
-
partialResult: event.partialResult
|
|
51
|
-
});
|
|
36
|
+
updateTool(event.toolCallId, event.partialResult);
|
|
52
37
|
});
|
|
53
38
|
pi.on('tool_execution_end', (event, _ctx) => {
|
|
54
|
-
|
|
55
|
-
pendingArgs.delete(event.toolCallId);
|
|
56
|
-
currentTools.push({
|
|
57
|
-
toolName: event.toolName,
|
|
58
|
-
args,
|
|
59
|
-
result: event.result,
|
|
60
|
-
isError: event.isError
|
|
61
|
-
});
|
|
62
|
-
broadcastFn({
|
|
63
|
-
type: 'tool_end',
|
|
64
|
-
toolCallId: event.toolCallId,
|
|
65
|
-
toolName: event.toolName,
|
|
66
|
-
result: event.result,
|
|
67
|
-
isError: event.isError
|
|
68
|
-
});
|
|
39
|
+
endTool(event.toolCallId, event.toolName, event.result, event.isError);
|
|
69
40
|
});
|
|
70
41
|
pi.on('agent_end', (_event, ctx) => {
|
|
71
|
-
const contextUsage = ctx.getContextUsage();
|
|
72
42
|
setAgentIdle(true);
|
|
73
|
-
|
|
74
|
-
getBridge().lastContextUsage = contextUsage;
|
|
75
|
-
history.addAssistantTurn(currentText, [...currentTools]);
|
|
76
|
-
broadcastFn({ type: 'agent_end', contextUsage });
|
|
43
|
+
agentEnd(ctx.getContextUsage());
|
|
77
44
|
void pushNotify('Task finished', '', 'pi-end').catch(() => { });
|
|
78
|
-
currentText = '';
|
|
79
|
-
currentTools.length = 0;
|
|
80
45
|
});
|
|
81
46
|
pi.on('input', (event, _ctx) => {
|
|
82
47
|
if (event.source === 'interactive' && typeof event.text === 'string') {
|
|
83
|
-
|
|
84
|
-
broadcastFn({ type: 'user_message', text: event.text });
|
|
48
|
+
addUserTurn(event.text);
|
|
85
49
|
}
|
|
86
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
|
+
});
|
|
87
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 {
|
|
@@ -40,10 +40,13 @@ export interface ContextMessage {
|
|
|
40
40
|
export interface ResetMessage {
|
|
41
41
|
type: 'reset';
|
|
42
42
|
}
|
|
43
|
-
/**
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
|
|
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;
|
|
47
50
|
/** Browser → server messages. */
|
|
48
51
|
export interface ClientChatMessage {
|
|
49
52
|
type: 'message';
|
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' }));
|
|
@@ -47,13 +45,11 @@ export function registerRemote(pi) {
|
|
|
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
|
|
50
|
-
// browser is showing a stale transcript/widgets — wipe
|
|
48
|
+
// browser is showing a stale transcript/widgets — wipe the authoritative
|
|
49
|
+
// state and tell connected clients to clear.
|
|
51
50
|
const bridge = getBridge();
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
bridge.lastContextUsage = null;
|
|
55
|
-
broadcast({ type: 'reset' });
|
|
56
|
-
setupEvents(pi, history, broadcast);
|
|
51
|
+
reset();
|
|
52
|
+
setupEvents(pi);
|
|
57
53
|
// Seed a shimmed ctx so commands that don't need newSession (/task-list,
|
|
58
54
|
// /task-cancel, /task-auto-cancel) work immediately from the remote without
|
|
59
55
|
// any terminal interaction. Only overwrite if null or already shimmed —
|
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 {};
|
package/dist/remote/server.js
CHANGED
|
@@ -2,7 +2,8 @@ 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';
|
|
7
8
|
import { swJs } from './sw.js';
|
|
8
9
|
import { publicKey, addSubscription, getSubscriptions, logPush } from './push.js';
|
|
@@ -59,7 +60,7 @@ async function findPort(start, max) {
|
|
|
59
60
|
}
|
|
60
61
|
throw new Error(`No free port found in range ${start}–${start + max - 1}`);
|
|
61
62
|
}
|
|
62
|
-
export async function startServer(onMessage, getHtml
|
|
63
|
+
export async function startServer(onMessage, getHtml) {
|
|
63
64
|
const port = await findPort(8800, 100);
|
|
64
65
|
const ips = getLocalIPs();
|
|
65
66
|
const ip = ips.primary;
|
|
@@ -118,16 +119,8 @@ export async function startServer(onMessage, getHtml, getHistory) {
|
|
|
118
119
|
addClient(ws);
|
|
119
120
|
handle.onFirstConnect?.();
|
|
120
121
|
handle.onFirstConnect = null;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
for (const [key, lines] of bridge.activeWidgets) {
|
|
124
|
-
sendTo(ws, { type: 'widget', key, lines });
|
|
125
|
-
}
|
|
126
|
-
if (bridge.activePrompt)
|
|
127
|
-
sendTo(ws, bridge.activePrompt);
|
|
128
|
-
if (bridge.lastContextUsage) {
|
|
129
|
-
sendTo(ws, { type: 'context', contextUsage: bridge.lastContextUsage });
|
|
130
|
-
}
|
|
122
|
+
// One authoritative snapshot — the client replaces its whole view with it.
|
|
123
|
+
sendTo(ws, snapshot());
|
|
131
124
|
broadcast({ type: 'client_count', count: clientCount() });
|
|
132
125
|
ws.on('message', data => {
|
|
133
126
|
let msg;
|
|
@@ -145,7 +138,7 @@ export async function startServer(onMessage, getHtml, getHistory) {
|
|
|
145
138
|
}
|
|
146
139
|
// type === 'message': ignore while a prompt is pending (composer is
|
|
147
140
|
// disabled in the browser; this is the server-side guard).
|
|
148
|
-
if (
|
|
141
|
+
if (getState().prompt)
|
|
149
142
|
return;
|
|
150
143
|
onMessage(msg.text);
|
|
151
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
|
+
}
|
package/dist/remote/ui.js
CHANGED
|
@@ -85,6 +85,13 @@ export function html(wsUrl) {
|
|
|
85
85
|
background: var(--crust); color: var(--red); align-self: stretch;
|
|
86
86
|
max-width: 100%; border: 1px solid var(--red); font-size: 12px;
|
|
87
87
|
}
|
|
88
|
+
/* Persistent inline system note (e.g. context compaction) — a muted centered
|
|
89
|
+
divider, distinct from chat bubbles. */
|
|
90
|
+
.sysnote {
|
|
91
|
+
align-self: center; color: var(--subtext0); font-size: 11px;
|
|
92
|
+
font-family: ui-monospace, monospace; letter-spacing: 0.5px;
|
|
93
|
+
padding: 2px 10px; opacity: 0.85;
|
|
94
|
+
}
|
|
88
95
|
.bubble.thinking {
|
|
89
96
|
display: flex; gap: 5px; align-items: center; padding: 10px 14px;
|
|
90
97
|
}
|
|
@@ -172,11 +179,12 @@ export function html(wsUrl) {
|
|
|
172
179
|
font-size: 13px; z-index: 100; letter-spacing: 0.03em;
|
|
173
180
|
}
|
|
174
181
|
#reconnect-overlay.visible { display: flex; }
|
|
182
|
+
/* Trailing stream indicator: the same braille spinner as the thinking bubble,
|
|
183
|
+
inline at the end of the streaming text (not a green blinking block). */
|
|
175
184
|
.cursor {
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
color: var(--mauve); margin-left: 2px;
|
|
186
|
+
font-family: ui-monospace, monospace;
|
|
178
187
|
}
|
|
179
|
-
@keyframes blink { 50% { opacity: 0; } }
|
|
180
188
|
#status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);
|
|
181
189
|
color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }
|
|
182
190
|
#prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);
|
|
@@ -276,12 +284,13 @@ export function html(wsUrl) {
|
|
|
276
284
|
const statusPanel = document.getElementById('status-panel');
|
|
277
285
|
// Widgets are keyed (e.g. 'pi-tasks', 'pi-task-auto'); track them per key so a
|
|
278
286
|
// clear for one key can't be masked by a stale message from another.
|
|
279
|
-
|
|
287
|
+
// Single authoritative task-widget slot. The snapshot and the live 'widget'
|
|
288
|
+
// delta both set this; null hides the panel. (No more per-key map that could
|
|
289
|
+
// strand an orphaned widget on screen.)
|
|
290
|
+
let taskWidgetLines = null;
|
|
280
291
|
function renderWidgets() {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (all.length) {
|
|
284
|
-
statusPanel.textContent = all.join('\\n');
|
|
292
|
+
if (taskWidgetLines && taskWidgetLines.length) {
|
|
293
|
+
statusPanel.textContent = taskWidgetLines.join('\\n');
|
|
285
294
|
statusPanel.style.display = 'block';
|
|
286
295
|
} else {
|
|
287
296
|
statusPanel.style.display = 'none';
|
|
@@ -488,30 +497,46 @@ export function html(wsUrl) {
|
|
|
488
497
|
let spinTimer = null;
|
|
489
498
|
let spinIdx = 0;
|
|
490
499
|
const SPIN = '\\u280B\\u2819\\u2839\\u2838\\u283C\\u2834\\u2826\\u2827\\u2807\\u280F';
|
|
500
|
+
// One braille ticker drives every '.spin' element — the thinking bubble AND the
|
|
501
|
+
// trailing stream cursor — so they share the same frame and look identical.
|
|
502
|
+
function spinPaint() {
|
|
503
|
+
const g = SPIN[spinIdx % SPIN.length];
|
|
504
|
+
const els = document.getElementsByClassName('spin');
|
|
505
|
+
for (let i = 0; i < els.length; i++) els[i].textContent = g;
|
|
506
|
+
}
|
|
507
|
+
function startSpin() {
|
|
508
|
+
spinPaint();
|
|
509
|
+
if (spinTimer) return;
|
|
510
|
+
spinTimer = setInterval(function () {
|
|
511
|
+
spinIdx = (spinIdx + 1) % SPIN.length;
|
|
512
|
+
spinPaint();
|
|
513
|
+
}, 90);
|
|
514
|
+
}
|
|
515
|
+
// Stop the ticker once nothing on screen needs spinning.
|
|
516
|
+
function stopSpinIfIdle() {
|
|
517
|
+
if (spinTimer && !document.querySelector('.spin')) {
|
|
518
|
+
clearInterval(spinTimer); spinTimer = null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
491
521
|
function showThinking() {
|
|
492
522
|
if (!thinkingEl) {
|
|
493
523
|
thinkingEl = document.createElement('div');
|
|
494
524
|
thinkingEl.className = 'bubble assistant thinking';
|
|
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);
|
|
525
|
+
thinkingEl.innerHTML = '<span class="spinner spin"></span>';
|
|
504
526
|
}
|
|
505
527
|
chatLog.appendChild(thinkingEl); // append (or move) to bottom
|
|
528
|
+
startSpin();
|
|
506
529
|
scrollBottom();
|
|
507
530
|
}
|
|
508
531
|
function hideThinking() {
|
|
509
|
-
if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
|
|
510
532
|
if (thinkingEl) thinkingEl.remove();
|
|
533
|
+
stopSpinIfIdle();
|
|
511
534
|
}
|
|
512
535
|
|
|
513
536
|
function addToolCall(toolName, argsStr, isError) {
|
|
514
|
-
|
|
537
|
+
// argsStr can be undefined (no args / JSON.stringify(undefined)); don't let
|
|
538
|
+
// that render as the literal "name: undefined" in the collapsed summary.
|
|
539
|
+
const label = (toolName + (argsStr ? ': ' + argsStr : '')).slice(0, 64);
|
|
515
540
|
const d = document.createElement('details');
|
|
516
541
|
d.className = 'tool-call' + (isError ? ' error' : '');
|
|
517
542
|
const s = document.createElement('summary');
|
|
@@ -528,6 +553,82 @@ export function html(wsUrl) {
|
|
|
528
553
|
sendBtn.disabled = !allow;
|
|
529
554
|
}
|
|
530
555
|
|
|
556
|
+
// Stringify a tool result safely. A null/undefined result (e.g. a tool that
|
|
557
|
+
// hasn't produced output) must NOT become the JS value undefined, whose
|
|
558
|
+
// .slice() throws — a throw here aborts the whole snapshot rebuild after the
|
|
559
|
+
// log was already cleared, blanking the transcript on reconnect.
|
|
560
|
+
function toolResultText(result) {
|
|
561
|
+
if (result == null) return '';
|
|
562
|
+
const r = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
|
563
|
+
return (r == null ? '' : r).slice(0, 8000);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Render one tool part from the ordered parts list (running or finished).
|
|
567
|
+
function renderToolPart(p) {
|
|
568
|
+
const argsStr = typeof p.args === 'string' ? p.args : JSON.stringify(p.args);
|
|
569
|
+
const d = addToolCall(p.toolName, argsStr, p.isError);
|
|
570
|
+
if (p.done) {
|
|
571
|
+
const pre = document.createElement('pre');
|
|
572
|
+
pre.textContent = toolResultText(p.result);
|
|
573
|
+
d.appendChild(pre);
|
|
574
|
+
} else {
|
|
575
|
+
toolCallMap[p.toolCallId] = d; // a later tool_end delta fills the result
|
|
576
|
+
}
|
|
577
|
+
return d;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// A muted, centered system note (e.g. "Context compacted").
|
|
581
|
+
function addSystemLine(text) {
|
|
582
|
+
const el = document.createElement('div');
|
|
583
|
+
el.className = 'sysnote';
|
|
584
|
+
el.textContent = text;
|
|
585
|
+
chatLog.appendChild(el);
|
|
586
|
+
scrollBottom();
|
|
587
|
+
return el;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Render one committed transcript turn. Assistant turns are an ordered list of
|
|
591
|
+
// parts (text segments + tool calls), so the layout matches the terminal's
|
|
592
|
+
// interleaving instead of one merged blob with tools dumped at the end.
|
|
593
|
+
function renderTurn(t) {
|
|
594
|
+
if (t.error) { addBubble('error', t.text); return; }
|
|
595
|
+
if (t.role === 'system') { addSystemLine(t.text); return; }
|
|
596
|
+
if (t.role === 'user') { addBubble('user', t.text); return; }
|
|
597
|
+
for (const p of (t.parts || [])) {
|
|
598
|
+
if (p.kind === 'text') { if (p.text) addBubble('assistant', p.text); }
|
|
599
|
+
else renderToolPart(p);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Render the in-progress assistant turn from a snapshot, preserving order. The
|
|
604
|
+
// trailing OPEN text segment becomes the live streaming bubble (cursor + spin)
|
|
605
|
+
// so subsequent text_delta frames keep flowing into it.
|
|
606
|
+
function renderLiveTurn(live) {
|
|
607
|
+
const parts = live.parts || [];
|
|
608
|
+
for (let i = 0; i < parts.length; i++) {
|
|
609
|
+
const p = parts[i];
|
|
610
|
+
const last = i === parts.length - 1;
|
|
611
|
+
if (p.kind === 'text') {
|
|
612
|
+
if (last && live.textOpen) {
|
|
613
|
+
currentBubble = document.createElement('div');
|
|
614
|
+
currentBubble.className = 'bubble assistant';
|
|
615
|
+
const cursor = document.createElement('span');
|
|
616
|
+
cursor.className = 'cursor spin';
|
|
617
|
+
currentBubble.appendChild(cursor);
|
|
618
|
+
if (p.text) currentBubble.insertBefore(document.createTextNode(p.text), cursor);
|
|
619
|
+
chatLog.appendChild(currentBubble);
|
|
620
|
+
streamText = p.text || '';
|
|
621
|
+
startSpin();
|
|
622
|
+
scrollBottom();
|
|
623
|
+
} else if (p.text) {
|
|
624
|
+
addBubble('assistant', p.text);
|
|
625
|
+
}
|
|
626
|
+
} else {
|
|
627
|
+
renderToolPart(p);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
531
632
|
function showToast(message, level) {
|
|
532
633
|
const t = document.createElement('div');
|
|
533
634
|
t.className = 'toast ' + (level || 'info');
|
|
@@ -731,19 +832,27 @@ export function html(wsUrl) {
|
|
|
731
832
|
|
|
732
833
|
function handleMsg(msg) {
|
|
733
834
|
switch (msg.type) {
|
|
734
|
-
case '
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
}
|
|
835
|
+
case 'snapshot': {
|
|
836
|
+
// Authoritative full state on every (re)connect: replace the WHOLE view.
|
|
837
|
+
// This is what kills duplicated transcript / stale-orphaned widgets —
|
|
838
|
+
// whatever was on screen is discarded and rebuilt from server truth.
|
|
839
|
+
chatLog.innerHTML = '';
|
|
840
|
+
closePrompt();
|
|
841
|
+
hideThinking();
|
|
842
|
+
currentBubble = null; streamText = '';
|
|
843
|
+
for (const k in toolCallMap) delete toolCallMap[k];
|
|
844
|
+
// Per-turn try/catch: one malformed turn must never abort the rebuild
|
|
845
|
+
// and leave the (already-cleared) transcript blank.
|
|
846
|
+
for (const t of (msg.turns || [])) { try { renderTurn(t); } catch (e) {} }
|
|
847
|
+
if (msg.live) { try { renderLiveTurn(msg.live); } catch (e) {} }
|
|
848
|
+
taskWidgetLines = (msg.taskWidget && msg.taskWidget.length) ? msg.taskWidget : null;
|
|
849
|
+
renderWidgets();
|
|
850
|
+
if (msg.context) setContextBar(msg.context); else contextFill.style.width = '0%';
|
|
851
|
+
if (msg.prompt) showPrompt(msg.prompt);
|
|
852
|
+
setEnabled(!msg.agentRunning && !msg.prompt);
|
|
853
|
+
if (msg.agentRunning && !msg.live) showThinking();
|
|
746
854
|
break;
|
|
855
|
+
}
|
|
747
856
|
case 'agent_start':
|
|
748
857
|
autoScroll = true;
|
|
749
858
|
streamText = '';
|
|
@@ -757,9 +866,10 @@ export function html(wsUrl) {
|
|
|
757
866
|
currentBubble = document.createElement('div');
|
|
758
867
|
currentBubble.className = 'bubble assistant';
|
|
759
868
|
const cursor = document.createElement('span');
|
|
760
|
-
cursor.className = 'cursor';
|
|
869
|
+
cursor.className = 'cursor spin';
|
|
761
870
|
currentBubble.appendChild(cursor);
|
|
762
871
|
chatLog.appendChild(currentBubble);
|
|
872
|
+
startSpin();
|
|
763
873
|
}
|
|
764
874
|
streamText += msg.delta;
|
|
765
875
|
{
|
|
@@ -773,6 +883,11 @@ export function html(wsUrl) {
|
|
|
773
883
|
const c = currentBubble.querySelector('.cursor');
|
|
774
884
|
if (c) c.remove();
|
|
775
885
|
if (streamText) setContent(currentBubble, streamText);
|
|
886
|
+
// Close this message's bubble so the next text segment (after a tool or
|
|
887
|
+
// the next message) starts a fresh bubble — matching the terminal.
|
|
888
|
+
currentBubble = null;
|
|
889
|
+
streamText = '';
|
|
890
|
+
stopSpinIfIdle();
|
|
776
891
|
}
|
|
777
892
|
break;
|
|
778
893
|
case 'tool_start': {
|
|
@@ -786,8 +901,7 @@ export function html(wsUrl) {
|
|
|
786
901
|
if (d) {
|
|
787
902
|
if (msg.isError) d.classList.add('error');
|
|
788
903
|
const pre = document.createElement('pre');
|
|
789
|
-
|
|
790
|
-
pre.textContent = r.slice(0, 8000);
|
|
904
|
+
pre.textContent = toolResultText(msg.result);
|
|
791
905
|
d.appendChild(pre);
|
|
792
906
|
delete toolCallMap[msg.toolCallId];
|
|
793
907
|
}
|
|
@@ -800,6 +914,9 @@ export function html(wsUrl) {
|
|
|
800
914
|
case 'user_message':
|
|
801
915
|
addBubble('user', msg.text);
|
|
802
916
|
break;
|
|
917
|
+
case 'system_note':
|
|
918
|
+
addSystemLine(msg.text);
|
|
919
|
+
break;
|
|
803
920
|
case 'agent_error':
|
|
804
921
|
hideThinking();
|
|
805
922
|
if (currentBubble) {
|
|
@@ -808,6 +925,7 @@ export function html(wsUrl) {
|
|
|
808
925
|
if (streamText) setContent(currentBubble, streamText);
|
|
809
926
|
currentBubble = null;
|
|
810
927
|
streamText = '';
|
|
928
|
+
stopSpinIfIdle();
|
|
811
929
|
}
|
|
812
930
|
addBubble('error', msg.message || 'Error');
|
|
813
931
|
setEnabled(true);
|
|
@@ -833,8 +951,7 @@ export function html(wsUrl) {
|
|
|
833
951
|
if (activePromptId === msg.id) closePrompt();
|
|
834
952
|
break;
|
|
835
953
|
case 'widget':
|
|
836
|
-
|
|
837
|
-
else delete widgets[msg.key];
|
|
954
|
+
taskWidgetLines = (msg.lines && msg.lines.length) ? msg.lines : null;
|
|
838
955
|
renderWidgets();
|
|
839
956
|
break;
|
|
840
957
|
case 'notify':
|
|
@@ -850,7 +967,7 @@ export function html(wsUrl) {
|
|
|
850
967
|
hideThinking();
|
|
851
968
|
currentBubble = null; streamText = '';
|
|
852
969
|
closePrompt();
|
|
853
|
-
|
|
970
|
+
taskWidgetLines = null;
|
|
854
971
|
renderWidgets();
|
|
855
972
|
contextFill.style.width = '0%';
|
|
856
973
|
break;
|
package/dist/task/widget.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* context usage, and the latest child-process line.
|
|
6
6
|
*/
|
|
7
7
|
import { PHASE_INDEX, PHASE_ORDER } from './task-file.js';
|
|
8
|
-
import {
|
|
8
|
+
import { setTaskWidget } from '../remote/session-state.js';
|
|
9
9
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
10
10
|
export const WIDGET_KEY = 'pi-tasks';
|
|
11
11
|
export const AUTO_WIDGET_KEY = 'pi-task-auto';
|
|
@@ -101,7 +101,7 @@ export function startWidget(ctx, getState) {
|
|
|
101
101
|
catch {
|
|
102
102
|
/* stale ctx */
|
|
103
103
|
}
|
|
104
|
-
|
|
104
|
+
setTaskWidget(plain);
|
|
105
105
|
};
|
|
106
106
|
render();
|
|
107
107
|
const timer = setInterval(render, WIDGET_REFRESH_MS);
|
|
@@ -114,7 +114,7 @@ export function startWidget(ctx, getState) {
|
|
|
114
114
|
catch {
|
|
115
115
|
/* stale ctx */
|
|
116
116
|
}
|
|
117
|
-
|
|
117
|
+
setTaskWidget(undefined);
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
120
|
export function buildAutoLoaderLines(s, theme) {
|
|
@@ -150,7 +150,7 @@ export function startAutoLoader(ctx, getState) {
|
|
|
150
150
|
catch {
|
|
151
151
|
/* stale ctx */
|
|
152
152
|
}
|
|
153
|
-
|
|
153
|
+
setTaskWidget(plain);
|
|
154
154
|
};
|
|
155
155
|
render();
|
|
156
156
|
const timer = setInterval(render, WIDGET_REFRESH_MS);
|
|
@@ -163,7 +163,7 @@ export function startAutoLoader(ctx, getState) {
|
|
|
163
163
|
catch {
|
|
164
164
|
/* stale ctx */
|
|
165
165
|
}
|
|
166
|
-
|
|
166
|
+
setTaskWidget(undefined);
|
|
167
167
|
};
|
|
168
168
|
}
|
|
169
169
|
export function flashTerminalWidget(ctx, state, taskId, reason) {
|
|
@@ -185,7 +185,7 @@ export function flashTerminalWidget(ctx, state, taskId, reason) {
|
|
|
185
185
|
: `✘ ${taskId} failed${reason ? ': ' + reason : ''}`;
|
|
186
186
|
try {
|
|
187
187
|
ctx.ui.setWidget(WIDGET_KEY, [line]);
|
|
188
|
-
|
|
188
|
+
setTaskWidget([plainLine]);
|
|
189
189
|
}
|
|
190
190
|
catch {
|
|
191
191
|
/* stale ctx */
|
|
@@ -193,7 +193,7 @@ export function flashTerminalWidget(ctx, state, taskId, reason) {
|
|
|
193
193
|
setTimeout(() => {
|
|
194
194
|
try {
|
|
195
195
|
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
196
|
-
|
|
196
|
+
setTaskWidget(undefined);
|
|
197
197
|
}
|
|
198
198
|
catch {
|
|
199
199
|
/* stale ctx */
|
|
@@ -20,17 +20,20 @@ export function registerPiWorkerDocs(pi, internals = {}) {
|
|
|
20
20
|
pi.registerTool({
|
|
21
21
|
name: 'pi-worker-docs',
|
|
22
22
|
label: 'Pi Worker Docs',
|
|
23
|
-
description: 'Look up an npm package
|
|
24
|
-
+ 'answer
|
|
25
|
-
+ 'live npm registry call
|
|
26
|
-
+ '
|
|
27
|
-
+ '
|
|
28
|
-
+ '
|
|
29
|
-
+ '
|
|
30
|
-
+ '
|
|
31
|
-
+ '
|
|
32
|
-
+ '
|
|
33
|
-
+ '
|
|
23
|
+
description: 'Look up an INSTALLED npm package and return a focused, version-pinned '
|
|
24
|
+
+ 'answer from its .d.ts types and README, PLUS the latest published version '
|
|
25
|
+
+ 'from a live npm registry call. USE THIS BEFORE ANSWERING any question '
|
|
26
|
+
+ 'about how to use a library, what it exports, its types/overloads/config, '
|
|
27
|
+
+ 'or the latest published version of an npm package. Do NOT answer package '
|
|
28
|
+
+ 'APIs from memory, do NOT run `npm view`/bash to get a package version, and '
|
|
29
|
+
+ 'do NOT web-search for an installed package — this tool is the source of '
|
|
30
|
+
+ 'truth and is version-pinned to what is actually installed (training-data '
|
|
31
|
+
+ 'versions and APIs are typically months stale).\n'
|
|
32
|
+
+ 'For a non-package framework/runtime version (e.g. Node.js, Ubuntu), use '
|
|
33
|
+
+ '`pi-worker-search` instead. If the package is not installed it is '
|
|
34
|
+
+ 'auto-installed via bun add or npm install. The cache lives at '
|
|
35
|
+
+ '~/.cache/pi-worker/docs.sqlite, keyed by exact installed version; the '
|
|
36
|
+
+ 'registry lookup is best-effort and silently absent when offline.\n'
|
|
34
37
|
+ '\n'
|
|
35
38
|
+ 'Good fits:\n'
|
|
36
39
|
+ '- "What does library X export?" / "How does function Y work?"\n'
|
|
@@ -14,9 +14,14 @@ export function registerPiWorkerSearch(pi, internals = {}) {
|
|
|
14
14
|
pi.registerTool({
|
|
15
15
|
name: 'pi-worker-search',
|
|
16
16
|
label: 'Pi Worker Search',
|
|
17
|
-
description: 'Search the web via Brave Search.
|
|
18
|
-
+ '
|
|
19
|
-
+ '
|
|
17
|
+
description: 'Search the live web via Brave Search. CALL THIS BEFORE ANSWERING any '
|
|
18
|
+
+ 'question about current or version-specific external facts: '
|
|
19
|
+
+ 'library/framework versions and their APIs, latest releases, recently '
|
|
20
|
+
+ 'shipped features, current events, prices, or who currently holds a '
|
|
21
|
+
+ 'role. Your built-in knowledge is out of date — do NOT answer such '
|
|
22
|
+
+ 'questions from memory and do NOT shell out with bash to guess. Returns '
|
|
23
|
+
+ 'a compact markdown list of up to 10 results (title, URL, snippet); then '
|
|
24
|
+
+ 'call `pi-worker-fetch` on the URL you want to read. '
|
|
20
25
|
+ 'Requires BRAVE_SEARCH_API_KEY env var.',
|
|
21
26
|
parameters: Params,
|
|
22
27
|
executionMode: 'parallel',
|
|
@@ -18,16 +18,19 @@ export function registerPiWorker(pi) {
|
|
|
18
18
|
pi.registerTool({
|
|
19
19
|
name: 'pi-worker',
|
|
20
20
|
label: 'Pi Worker',
|
|
21
|
-
description: 'Dispatch an isolated child Pi to investigate
|
|
22
|
-
+ '
|
|
23
|
-
+ '
|
|
24
|
-
+ 'you
|
|
21
|
+
description: 'Dispatch an isolated child Pi to investigate and return its CONCLUSION — '
|
|
22
|
+
+ 'not the raw evidence. USE THIS FIRST, instead of running your own '
|
|
23
|
+
+ 'ls/grep/find/read, whenever a question spans MULTIPLE files or means '
|
|
24
|
+
+ 'searching/scanning code you have not already located. Doing it yourself '
|
|
25
|
+
+ 'floods your context with raw file output; the worker reads in isolation '
|
|
26
|
+
+ 'and returns only the answer. You can dispatch several in one turn for '
|
|
27
|
+
+ 'independent questions.\n'
|
|
25
28
|
+ '\n'
|
|
26
29
|
+ 'Good fits:\n'
|
|
27
30
|
+ '- "Where/how is X handled in this repo?" across unfamiliar code\n'
|
|
28
31
|
+ '- Audits and pattern scans across many files ("every place we log PII")\n'
|
|
29
|
-
+ '-
|
|
30
|
-
+ '-
|
|
32
|
+
+ '- Tracing a flow across layers (router → service → database)\n'
|
|
33
|
+
+ '- Summarising long test output, logs, or shell output you do not need verbatim\n'
|
|
31
34
|
+ '\n'
|
|
32
35
|
+ 'Skip when:\n'
|
|
33
36
|
+ '- You already know the exact file — call `read` directly\n'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|