@mjasnikovs/pi-task 0.7.0 → 0.7.2
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 +37 -39
- package/assets/pipeline.svg +90 -0
- package/assets/task-auto.svg +65 -0
- 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/auto-io.d.ts +7 -0
- package/dist/task/auto-io.js +24 -13
- package/dist/task/auto-orchestrator.d.ts +6 -1
- package/dist/task/auto-orchestrator.js +15 -3
- package/dist/task/orchestrator.d.ts +6 -1
- package/dist/task/orchestrator.js +9 -2
- package/dist/task/widget.js +7 -7
- package/dist/workers/html-clean.js +77 -9
- package/dist/workers/pi-worker-docs.js +14 -11
- package/dist/workers/pi-worker-fetch.js +5 -4
- package/dist/workers/pi-worker-search.js +8 -3
- package/dist/workers/pi-worker.js +9 -6
- package/package.json +2 -1
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/auto-io.d.ts
CHANGED
|
@@ -13,5 +13,12 @@ export declare function parseTaskList(body: string): TaskEntry[];
|
|
|
13
13
|
export declare function buildAutoBody(feature: string, clarifications: string, titles: string[]): string;
|
|
14
14
|
/** Check off the Nth checkbox line, stamping the produced TASK_NNNN id. */
|
|
15
15
|
export declare function checkOffTask(cwd: string, id: string, index: number, producedId: string, title: string): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Stamp the inner TASK_NNNN id onto the Nth (still-unchecked) entry the moment
|
|
18
|
+
* the inner task is allocated. This links the AUTO entry to its in-progress
|
|
19
|
+
* inner task so /task-auto-resume can continue it from its saved phase instead
|
|
20
|
+
* of starting a brand-new task — matching how /task-resume behaves.
|
|
21
|
+
*/
|
|
22
|
+
export declare function stampTaskInProgress(cwd: string, id: string, index: number, producedId: string, title: string): Promise<void>;
|
|
16
23
|
/** Find the most-recently-updated resumable TASK_AUTO_* file, or null. */
|
|
17
24
|
export declare function findResumableAuto(cwd: string): Promise<string | null>;
|