@mjasnikovs/pi-task 0.4.4 → 0.6.0
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/dist/remote/bridge.d.ts +19 -13
- package/dist/remote/bridge.js +31 -15
- package/dist/remote/events.js +3 -0
- package/dist/remote/protocol.d.ts +12 -1
- package/dist/remote/register.js +33 -35
- package/dist/remote/server.js +3 -0
- package/dist/remote/tailscale.d.ts +45 -0
- package/dist/remote/tailscale.js +108 -0
- package/dist/remote/ui.js +242 -48
- package/dist/shared/child-process.js +1 -9
- package/dist/task/orchestrator.d.ts +7 -0
- package/dist/task/orchestrator.js +19 -14
- package/dist/task/phases.js +13 -11
- package/dist/task/widget.js +10 -1
- package/dist/workers/pi-worker-core.js +8 -2
- package/package.json +1 -1
package/dist/remote/bridge.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import type { PromptMessage, ServerMessage } from './protocol.js';
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { ContextUsage, PromptMessage, 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>;
|
|
@@ -7,6 +7,8 @@ export interface BridgeState {
|
|
|
7
7
|
activePrompt: PromptMessage | null;
|
|
8
8
|
/** Last lines pushed per widget key (replayed to late joiners). */
|
|
9
9
|
activeWidgets: Map<string, string[]>;
|
|
10
|
+
/** Most recent context-window usage (replayed to seed late joiners' bar), or null. */
|
|
11
|
+
lastContextUsage: ContextUsage | null;
|
|
10
12
|
nextId: number;
|
|
11
13
|
/** Command name → handler, populated as pi-task registers its commands. */
|
|
12
14
|
commands: Map<string, (args: string, ctx: ExtensionCommandContext) => unknown>;
|
|
@@ -46,6 +48,19 @@ export declare class SessionUI {
|
|
|
46
48
|
export declare function publishWidget(key: string, lines: string[] | undefined): void;
|
|
47
49
|
export declare function publishNotify(message: string, level: 'info' | 'warning' | 'error'): void;
|
|
48
50
|
export declare function publishViewer(title: string, text: string): void;
|
|
51
|
+
/**
|
|
52
|
+
* Wraps an event-scoped ExtensionContext so it can be used as a command ctx
|
|
53
|
+
* for commands that don't need newSession (e.g. /task-resume, /task-list,
|
|
54
|
+
* /task-cancel, /task-auto-cancel).
|
|
55
|
+
*
|
|
56
|
+
* - waitForIdle: polls ctx.isIdle() instead of the real runtime hook.
|
|
57
|
+
* - newSession: throws a clear error so /task, /task-auto, /task-auto-resume
|
|
58
|
+
* show a helpful message rather than a confusing TypeError.
|
|
59
|
+
*
|
|
60
|
+
* The shim is replaced by a real ExtensionCommandContext the first time the
|
|
61
|
+
* user runs any /task* or /remote command in the terminal.
|
|
62
|
+
*/
|
|
63
|
+
export declare function makeShimmedCtx(ctx: ExtensionContext): ExtensionCommandContext;
|
|
49
64
|
interface BridgeCommandDef {
|
|
50
65
|
description: string;
|
|
51
66
|
handler: (args: string, ctx: ExtensionCommandContext) => unknown;
|
|
@@ -55,17 +70,8 @@ interface BridgeCommandDef {
|
|
|
55
70
|
* can invoke it. Use in place of pi.registerCommand for task commands. */
|
|
56
71
|
export declare function registerBridgeCommand(pi: ExtensionAPI, name: string, def: BridgeCommandDef): void;
|
|
57
72
|
/** Start a new session in response to a remote `/new`. Reads the freshest
|
|
58
|
-
* command-capable ctx (currentCtx) at call time
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* The crash this prevents: a captured ctx goes stale after a *local* /new (a
|
|
62
|
-
* replacement we don't initiate, so we never get a withSession to rebind), and
|
|
63
|
-
* ctx.newSession() then throws *synchronously* from assertActive — a sync throw
|
|
64
|
-
* that a bare `.catch()` on the result would miss. `rebind` runs as withSession
|
|
65
|
-
* with the fresh post-replacement ctx. */
|
|
66
|
-
/** The richer context the runtime passes to a `newSession` withSession callback
|
|
67
|
-
* (it carries sendUserMessage, unlike a plain command ctx). Derived from the
|
|
68
|
-
* newSession signature since the package root doesn't re-export the type. */
|
|
73
|
+
* command-capable ctx (currentCtx) at call time. If only a shimmed ctx is
|
|
74
|
+
* available, the newSession shim throws a clear error. */
|
|
69
75
|
type NewSessionOptions = NonNullable<Parameters<ExtensionCommandContext['newSession']>[0]>;
|
|
70
76
|
type ReplacedSessionContext = Parameters<NonNullable<NewSessionOptions['withSession']>>[0];
|
|
71
77
|
export declare function dispatchRemoteNewSession(rebind: (ctx: ReplacedSessionContext) => void): void;
|
package/dist/remote/bridge.js
CHANGED
|
@@ -6,6 +6,7 @@ export function getBridge() {
|
|
|
6
6
|
pending: new Map(),
|
|
7
7
|
activePrompt: null,
|
|
8
8
|
activeWidgets: new Map(),
|
|
9
|
+
lastContextUsage: null,
|
|
9
10
|
nextId: 0,
|
|
10
11
|
commands: new Map(),
|
|
11
12
|
currentCtx: null,
|
|
@@ -97,6 +98,33 @@ export function publishNotify(message, level) {
|
|
|
97
98
|
export function publishViewer(title, text) {
|
|
98
99
|
getBridge().broadcast({ type: 'viewer', title, text });
|
|
99
100
|
}
|
|
101
|
+
// ─── Shimmed ctx ─────────────────────────────────────────────────────────────
|
|
102
|
+
const SHIMMED_MARKER = '__piRemoteShimmed';
|
|
103
|
+
/**
|
|
104
|
+
* Wraps an event-scoped ExtensionContext so it can be used as a command ctx
|
|
105
|
+
* for commands that don't need newSession (e.g. /task-resume, /task-list,
|
|
106
|
+
* /task-cancel, /task-auto-cancel).
|
|
107
|
+
*
|
|
108
|
+
* - waitForIdle: polls ctx.isIdle() instead of the real runtime hook.
|
|
109
|
+
* - newSession: throws a clear error so /task, /task-auto, /task-auto-resume
|
|
110
|
+
* show a helpful message rather than a confusing TypeError.
|
|
111
|
+
*
|
|
112
|
+
* The shim is replaced by a real ExtensionCommandContext the first time the
|
|
113
|
+
* user runs any /task* or /remote command in the terminal.
|
|
114
|
+
*/
|
|
115
|
+
export function makeShimmedCtx(ctx) {
|
|
116
|
+
const shim = Object.create(ctx);
|
|
117
|
+
shim[SHIMMED_MARKER] = true;
|
|
118
|
+
shim.waitForIdle = async () => {
|
|
119
|
+
while (!ctx.isIdle()) {
|
|
120
|
+
await new Promise(r => setTimeout(r, 100));
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
shim.newSession = () => {
|
|
124
|
+
throw new Error('Run /remote in the terminal once to enable /task, /task-auto, and /new from remote.');
|
|
125
|
+
};
|
|
126
|
+
return shim;
|
|
127
|
+
}
|
|
100
128
|
/** Register a command with pi AND record it in the bridge so remote slash lines
|
|
101
129
|
* can invoke it. Use in place of pi.registerCommand for task commands. */
|
|
102
130
|
export function registerBridgeCommand(pi, name, def) {
|
|
@@ -115,13 +143,7 @@ export function dispatchRemoteNewSession(rebind) {
|
|
|
115
143
|
const b = getBridge();
|
|
116
144
|
const ctx = b.currentCtx;
|
|
117
145
|
if (!ctx) {
|
|
118
|
-
publishNotify('
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
// A bare event-scoped ctx lacks newSession; a stale command ctx still has it
|
|
122
|
-
// but throws on call — the try/catch below covers that case.
|
|
123
|
-
if (typeof ctx.waitForIdle !== 'function') {
|
|
124
|
-
publishNotify('Session changed locally — run any task command in the terminal once to re-enable remote /new.', 'warning');
|
|
146
|
+
publishNotify('No session context available — restart pi and try again.', 'warning');
|
|
125
147
|
return;
|
|
126
148
|
}
|
|
127
149
|
const toastErr = (err) => publishNotify(`/new failed: ${err.message}`, 'error');
|
|
@@ -158,14 +180,8 @@ export function dispatchRemoteLine(text, opts) {
|
|
|
158
180
|
return true;
|
|
159
181
|
}
|
|
160
182
|
if (!b.currentCtx) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
// Command handlers need a command-capable ctx (waitForIdle/newSession). A bare
|
|
165
|
-
// event-scoped ExtensionContext lacks those; if currentCtx is one (e.g. nothing
|
|
166
|
-
// command-capable has been captured yet), toast instead of throwing a TypeError.
|
|
167
|
-
if (typeof b.currentCtx.waitForIdle !== 'function') {
|
|
168
|
-
publishNotify('Session changed locally — run any task command in the terminal once to re-enable remote commands.', 'warning');
|
|
183
|
+
// Shouldn't happen after session_start seeds a shimmed ctx, but guard anyway.
|
|
184
|
+
publishNotify(`/${name}: no session context yet — restart pi.`, 'warning');
|
|
169
185
|
return true;
|
|
170
186
|
}
|
|
171
187
|
// Invoke synchronously so the call happens immediately, but surface both
|
package/dist/remote/events.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { setAgentIdle } from './state.js';
|
|
2
|
+
import { getBridge } from './bridge.js';
|
|
2
3
|
export function setupEvents(pi, history, broadcastFn) {
|
|
3
4
|
let currentText = '';
|
|
4
5
|
const currentTools = [];
|
|
@@ -67,6 +68,8 @@ export function setupEvents(pi, history, broadcastFn) {
|
|
|
67
68
|
pi.on('agent_end', (_event, ctx) => {
|
|
68
69
|
const contextUsage = ctx.getContextUsage();
|
|
69
70
|
setAgentIdle(true);
|
|
71
|
+
// Remember it so a browser that connects mid-session gets its bar seeded.
|
|
72
|
+
getBridge().lastContextUsage = contextUsage;
|
|
70
73
|
history.addAssistantTurn(currentText, [...currentTools]);
|
|
71
74
|
broadcastFn({ type: 'agent_end', contextUsage });
|
|
72
75
|
currentText = '';
|
|
@@ -24,10 +24,21 @@ export interface ViewerMessage {
|
|
|
24
24
|
title: string;
|
|
25
25
|
text: string;
|
|
26
26
|
}
|
|
27
|
+
export interface ContextUsage {
|
|
28
|
+
tokens?: number;
|
|
29
|
+
contextWindow?: number;
|
|
30
|
+
percent?: number;
|
|
31
|
+
}
|
|
32
|
+
/** Seeds the context-usage bar for a freshly-connected client (the live value is
|
|
33
|
+
* otherwise only carried on agent_end). */
|
|
34
|
+
export interface ContextMessage {
|
|
35
|
+
type: 'context';
|
|
36
|
+
contextUsage: ContextUsage;
|
|
37
|
+
}
|
|
27
38
|
/** Server → browser messages added by the integration. The existing
|
|
28
39
|
* history / text_delta / tool_* / agent_* / client_count / user_message messages are
|
|
29
40
|
* emitted ad hoc by events.ts / server.ts and are not enumerated here. */
|
|
30
|
-
export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage;
|
|
41
|
+
export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | ViewerMessage | ContextMessage;
|
|
31
42
|
/** Browser → server messages. */
|
|
32
43
|
export interface ClientChatMessage {
|
|
33
44
|
type: 'message';
|
package/dist/remote/register.js
CHANGED
|
@@ -1,31 +1,24 @@
|
|
|
1
1
|
import { broadcast } from './broadcast.js';
|
|
2
|
-
import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession } from './bridge.js';
|
|
2
|
+
import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx } from './bridge.js';
|
|
3
3
|
import { setupEvents } from './events.js';
|
|
4
4
|
import { HistoryBuffer } from './history.js';
|
|
5
5
|
import { html } from './ui.js';
|
|
6
6
|
import { qrLines } from './qr.js';
|
|
7
7
|
import { startServer, formatAddresses } from './server.js';
|
|
8
|
+
import { ensureTailscaleServe, teardownTailscaleServe, planRemoteUrls } from './tailscale.js';
|
|
8
9
|
import { isAgentIdle } from './state.js';
|
|
9
10
|
const _g = globalThis;
|
|
10
11
|
if (!_g.__piRemote)
|
|
11
|
-
_g.__piRemote = { server: null, send: null };
|
|
12
|
+
_g.__piRemote = { server: null, send: null, serveResult: null };
|
|
12
13
|
const S = _g.__piRemote;
|
|
13
14
|
export function registerRemote(pi) {
|
|
14
15
|
const history = new HistoryBuffer(20);
|
|
15
|
-
/** Start the always-on remote server once; later calls return the live handle.
|
|
16
|
-
* Plain chat works immediately via S.send; remote slash commands and /new
|
|
17
|
-
* bind once any command supplies a command-capable ctx (see dispatchRemoteLine). */
|
|
18
16
|
async function ensureServer() {
|
|
19
17
|
if (S.server)
|
|
20
18
|
return S.server;
|
|
21
19
|
S.server = await startServer(text => {
|
|
22
20
|
if (text === '/new') {
|
|
23
|
-
// Route through the bridge's freshest command ctx with the same
|
|
24
|
-
// crash-safe guards as remote slash commands. The withSession
|
|
25
|
-
// rebind keeps S.send pointed at the new session's ctx.
|
|
26
21
|
dispatchRemoteNewSession(newCtx => {
|
|
27
|
-
// newCtx.sendUserMessage bypasses the stale runtime check
|
|
28
|
-
// (returns a Promise we deliberately discard).
|
|
29
22
|
S.send = (msg, opts) => {
|
|
30
23
|
void (opts ?
|
|
31
24
|
newCtx.sendUserMessage(msg, opts)
|
|
@@ -34,14 +27,8 @@ export function registerRemote(pi) {
|
|
|
34
27
|
});
|
|
35
28
|
return;
|
|
36
29
|
}
|
|
37
|
-
// Slash commands route to the bridge's command registry;
|
|
38
|
-
// plain lines fall through to onPlain and go to the agent.
|
|
39
30
|
dispatchRemoteLine(text, {
|
|
40
31
|
onPlain: plain => {
|
|
41
|
-
// Persist remote-typed messages: they arrive via
|
|
42
|
-
// sendUserMessage with source "extension", which the
|
|
43
|
-
// interactive input handler skips, so record them
|
|
44
|
-
// here for reconnect/history.
|
|
45
32
|
history.addUserMessage(plain);
|
|
46
33
|
if (isAgentIdle()) {
|
|
47
34
|
S.send?.(plain);
|
|
@@ -52,28 +39,35 @@ export function registerRemote(pi) {
|
|
|
52
39
|
}
|
|
53
40
|
});
|
|
54
41
|
}, wsUrl => html(wsUrl), () => history.getEntries());
|
|
42
|
+
// Hands-off HTTPS: point Tailscale serve at our port so phones get a
|
|
43
|
+
// secure context. Best-effort — any failure degrades to the http URL.
|
|
44
|
+
S.serveResult = await ensureTailscaleServe(S.server.port).catch(() => ({ state: 'unavailable' }));
|
|
55
45
|
return S.server;
|
|
56
46
|
}
|
|
57
47
|
pi.on('session_start', (_event, ctx) => {
|
|
58
|
-
// Update shared send with fresh pi on each session (survives /new re-evaluation)
|
|
59
48
|
S.send = (text, opts) => (opts ? pi.sendUserMessage(text, opts) : pi.sendUserMessage(text));
|
|
60
49
|
setupEvents(pi, history, broadcast);
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
50
|
+
// Seed a shimmed ctx so commands that don't need newSession (/task-list,
|
|
51
|
+
// /task-cancel, /task-auto-cancel) work immediately from the remote without
|
|
52
|
+
// any terminal interaction. Only overwrite if null or already shimmed —
|
|
53
|
+
// a real command ctx captured from a prior terminal command must survive
|
|
54
|
+
// session_start (it's updated via withSession or registerBridgeCommand,
|
|
55
|
+
// not replaced here).
|
|
56
|
+
const b = getBridge();
|
|
57
|
+
if (!b.currentCtx
|
|
58
|
+
|| b.currentCtx['__piRemoteShimmed'] === true) {
|
|
59
|
+
b.currentCtx = makeShimmedCtx(ctx);
|
|
60
|
+
}
|
|
70
61
|
void ensureServer().catch(err => ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error'));
|
|
71
62
|
});
|
|
72
63
|
pi.on('session_shutdown', (event, _ctx) => {
|
|
73
64
|
if (event.reason === 'quit') {
|
|
74
65
|
if (S.server) {
|
|
66
|
+
const port = S.server.port;
|
|
75
67
|
S.server.stop();
|
|
76
68
|
S.server = null;
|
|
69
|
+
S.serveResult = null;
|
|
70
|
+
void teardownTailscaleServe(port).catch(() => { });
|
|
77
71
|
}
|
|
78
72
|
S.send = null;
|
|
79
73
|
}
|
|
@@ -83,8 +77,11 @@ export function registerRemote(pi) {
|
|
|
83
77
|
handler: async (args, ctx) => {
|
|
84
78
|
if (args.trim() === 'stop') {
|
|
85
79
|
if (S.server) {
|
|
80
|
+
const port = S.server.port;
|
|
86
81
|
S.server.stop();
|
|
87
82
|
S.server = null;
|
|
83
|
+
S.serveResult = null;
|
|
84
|
+
void teardownTailscaleServe(port).catch(() => { });
|
|
88
85
|
ctx.ui.notify('Remote server stopped', 'info');
|
|
89
86
|
}
|
|
90
87
|
else {
|
|
@@ -92,26 +89,27 @@ export function registerRemote(pi) {
|
|
|
92
89
|
}
|
|
93
90
|
return;
|
|
94
91
|
}
|
|
95
|
-
//
|
|
96
|
-
// work (the always-on auto-start can only bind plain chat).
|
|
92
|
+
// Upgrade from shimmed ctx to a real command-capable ctx.
|
|
97
93
|
getBridge().currentCtx = ctx;
|
|
98
94
|
try {
|
|
99
95
|
const server = await ensureServer();
|
|
100
|
-
|
|
101
|
-
const
|
|
96
|
+
const httpPrimary = `http://${server.ip}:${server.port}`;
|
|
97
|
+
const result = S.serveResult ?? { state: 'unavailable' };
|
|
98
|
+
const plan = planRemoteUrls(httpPrimary, result);
|
|
99
|
+
const primaryUrl = plan.primaryUrl;
|
|
102
100
|
const qr = await qrLines(primaryUrl);
|
|
103
|
-
|
|
104
|
-
const addrs = formatAddresses(server.ips, server.port);
|
|
101
|
+
const addrs = [...plan.urlLines, ...formatAddresses(server.ips, server.port)];
|
|
105
102
|
const labelW = addrs.reduce((m, a) => Math.max(m, a.label.length), 0);
|
|
106
|
-
const addrLines =
|
|
103
|
+
const addrLines = [
|
|
104
|
+
...addrs.map(a => (a.label ? `${a.label.padEnd(labelW)} ${a.url}` : a.url)),
|
|
105
|
+
...plan.hintLines
|
|
106
|
+
];
|
|
107
107
|
const addrWidth = addrLines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
108
108
|
if (ctx.mode === 'tui') {
|
|
109
109
|
// eslint-disable-next-line no-control-regex -- strip ANSI SGR escapes to measure visible width
|
|
110
110
|
const stripAnsi = (s) => s.replace(/\x1b\[[^m]*m/g, '');
|
|
111
111
|
const visWidth = qr.reduce((max, l) => Math.max(max, stripAnsi(l).length), 0);
|
|
112
112
|
const overlayWidth = Math.max(visWidth, addrWidth + 4, 36);
|
|
113
|
-
// Fire-and-forget: don't await so the command handler returns immediately
|
|
114
|
-
// (avoids "Working" lock if user runs /new before dismissing)
|
|
115
113
|
ctx.ui
|
|
116
114
|
.custom((_tui, _theme, _kb, done) => ({
|
|
117
115
|
focused: false,
|
package/dist/remote/server.js
CHANGED
|
@@ -95,6 +95,9 @@ export async function startServer(onMessage, getHtml, getHistory) {
|
|
|
95
95
|
}
|
|
96
96
|
if (bridge.activePrompt)
|
|
97
97
|
sendTo(ws, bridge.activePrompt);
|
|
98
|
+
if (bridge.lastContextUsage) {
|
|
99
|
+
sendTo(ws, { type: 'context', contextUsage: bridge.lastContextUsage });
|
|
100
|
+
}
|
|
98
101
|
broadcast({ type: 'client_count', count: clientCount() });
|
|
99
102
|
ws.on('message', data => {
|
|
100
103
|
let msg;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Injectable command runner so serve orchestration is unit-testable without a real CLI. */
|
|
2
|
+
export interface Run {
|
|
3
|
+
(cmd: string, args: string[]): Promise<{
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
exitCode: number;
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
9
|
+
export type ServeResult = {
|
|
10
|
+
state: 'served';
|
|
11
|
+
url: string;
|
|
12
|
+
} | {
|
|
13
|
+
state: 'foreign-conflict';
|
|
14
|
+
host: string;
|
|
15
|
+
} | {
|
|
16
|
+
state: 'certs-disabled';
|
|
17
|
+
host: string;
|
|
18
|
+
} | {
|
|
19
|
+
state: 'unavailable';
|
|
20
|
+
};
|
|
21
|
+
export interface RemoteUrlPlan {
|
|
22
|
+
/** URL to encode in the QR and announce; the https one when serve is live. */
|
|
23
|
+
primaryUrl: string;
|
|
24
|
+
/** Labeled URL lines to add to the address list (e.g. the HTTPS URL when served). */
|
|
25
|
+
urlLines: {
|
|
26
|
+
label: string;
|
|
27
|
+
url: string;
|
|
28
|
+
}[];
|
|
29
|
+
/** Extra lines to print under the URLs (empty when nothing to suggest). */
|
|
30
|
+
hintLines: string[];
|
|
31
|
+
}
|
|
32
|
+
/** The "/" proxy target of the first :443 Web handler in `tailscale serve status --json`,
|
|
33
|
+
* or undefined when there is no such handler / the output isn't valid serve JSON
|
|
34
|
+
* (e.g. the literal "No serve config"). */
|
|
35
|
+
export declare function serve443Target(json: string): string | undefined;
|
|
36
|
+
/** Ensure Tailscale serves https://<host> → http://127.0.0.1:<port>. Mutates only
|
|
37
|
+
* when needed and never touches a serve config we didn't create. Best-effort:
|
|
38
|
+
* returns a non-served state rather than throwing. */
|
|
39
|
+
export declare function ensureTailscaleServe(port: number, run?: Run): Promise<ServeResult>;
|
|
40
|
+
/** Tear down the :443 serve handler ONLY if it currently points at our port.
|
|
41
|
+
* Best-effort; callers should additionally `.catch()` since it may run during
|
|
42
|
+
* shutdown. */
|
|
43
|
+
export declare function teardownTailscaleServe(port: number, run?: Run): Promise<void>;
|
|
44
|
+
/** Pure: pick the primary URL and any hint lines from a serve result. */
|
|
45
|
+
export declare function planRemoteUrls(httpPrimary: string, result: ServeResult): RemoteUrlPlan;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
const defaultRun = (cmd, args) => new Promise(resolve => {
|
|
3
|
+
execFile(cmd, args, { timeout: 5000 }, (err, stdout, stderr) => {
|
|
4
|
+
const exitCode = err && typeof err.code === 'number' ?
|
|
5
|
+
err.code
|
|
6
|
+
: err ? 1
|
|
7
|
+
: 0;
|
|
8
|
+
resolve({ stdout: stdout ?? '', stderr: stderr ?? '', exitCode });
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
/** The "/" proxy target of the first :443 Web handler in `tailscale serve status --json`,
|
|
12
|
+
* or undefined when there is no such handler / the output isn't valid serve JSON
|
|
13
|
+
* (e.g. the literal "No serve config"). */
|
|
14
|
+
export function serve443Target(json) {
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(json);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const web = parsed
|
|
23
|
+
.Web;
|
|
24
|
+
if (!web)
|
|
25
|
+
return undefined;
|
|
26
|
+
for (const key of Object.keys(web)) {
|
|
27
|
+
if (key.endsWith(':443'))
|
|
28
|
+
return web[key]?.Handlers?.['/']?.Proxy;
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
/** Tailscale MagicDNS name (trailing dot stripped), or undefined if the daemon
|
|
33
|
+
* isn't reachable / has no name. */
|
|
34
|
+
async function tailscaleHost(run) {
|
|
35
|
+
const status = await run('tailscale', ['status', '--json']);
|
|
36
|
+
if (status.exitCode !== 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(status.stdout);
|
|
40
|
+
return (parsed.Self?.DNSName ?? '').replace(/\.$/, '') || undefined;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Ensure Tailscale serves https://<host> → http://127.0.0.1:<port>. Mutates only
|
|
47
|
+
* when needed and never touches a serve config we didn't create. Best-effort:
|
|
48
|
+
* returns a non-served state rather than throwing. */
|
|
49
|
+
export async function ensureTailscaleServe(port, run = defaultRun) {
|
|
50
|
+
const host = await tailscaleHost(run);
|
|
51
|
+
if (!host)
|
|
52
|
+
return { state: 'unavailable' };
|
|
53
|
+
const ours = `http://127.0.0.1:${port}`;
|
|
54
|
+
const serve = await run('tailscale', ['serve', 'status', '--json']);
|
|
55
|
+
const target = serve443Target(serve.stdout);
|
|
56
|
+
if (target === ours)
|
|
57
|
+
return { state: 'served', url: `https://${host}` };
|
|
58
|
+
if (target !== undefined)
|
|
59
|
+
return { state: 'foreign-conflict', host };
|
|
60
|
+
// No :443 handler yet — check cert capability before trying to create one.
|
|
61
|
+
const cert = await run('tailscale', ['cert']);
|
|
62
|
+
if (/HTTPS cert support is not enabled/i.test(cert.stdout + cert.stderr)) {
|
|
63
|
+
return { state: 'certs-disabled', host };
|
|
64
|
+
}
|
|
65
|
+
const set = await run('tailscale', ['serve', '--bg', '--https=443', ours]);
|
|
66
|
+
if (set.exitCode === 0)
|
|
67
|
+
return { state: 'served', url: `https://${host}` };
|
|
68
|
+
return { state: 'certs-disabled', host };
|
|
69
|
+
}
|
|
70
|
+
/** Tear down the :443 serve handler ONLY if it currently points at our port.
|
|
71
|
+
* Best-effort; callers should additionally `.catch()` since it may run during
|
|
72
|
+
* shutdown. */
|
|
73
|
+
export async function teardownTailscaleServe(port, run = defaultRun) {
|
|
74
|
+
const serve = await run('tailscale', ['serve', 'status', '--json']);
|
|
75
|
+
if (serve443Target(serve.stdout) === `http://127.0.0.1:${port}`) {
|
|
76
|
+
await run('tailscale', ['serve', '--https=443', 'off']);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Pure: pick the primary URL and any hint lines from a serve result. */
|
|
80
|
+
export function planRemoteUrls(httpPrimary, result) {
|
|
81
|
+
switch (result.state) {
|
|
82
|
+
case 'served':
|
|
83
|
+
return {
|
|
84
|
+
primaryUrl: result.url,
|
|
85
|
+
urlLines: [{ label: 'HTTPS', url: result.url }],
|
|
86
|
+
hintLines: []
|
|
87
|
+
};
|
|
88
|
+
case 'foreign-conflict':
|
|
89
|
+
return {
|
|
90
|
+
primaryUrl: httpPrimary,
|
|
91
|
+
urlLines: [],
|
|
92
|
+
hintLines: [
|
|
93
|
+
'HTTPS: port 443 is already used by another tailscale serve config; not touching it.',
|
|
94
|
+
' Free it (tailscale serve --https=443 off) to enable phone notifications.'
|
|
95
|
+
]
|
|
96
|
+
};
|
|
97
|
+
case 'certs-disabled':
|
|
98
|
+
return {
|
|
99
|
+
primaryUrl: httpPrimary,
|
|
100
|
+
urlLines: [],
|
|
101
|
+
hintLines: [
|
|
102
|
+
'HTTPS (for phone notifications): enable HTTPS in the Tailscale admin console, then restart the remote.'
|
|
103
|
+
]
|
|
104
|
+
};
|
|
105
|
+
case 'unavailable':
|
|
106
|
+
return { primaryUrl: httpPrimary, urlLines: [], hintLines: [] };
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/remote/ui.js
CHANGED
|
@@ -5,8 +5,8 @@ export function html(wsUrl) {
|
|
|
5
5
|
+ `text-anchor="middle" fill="#cba6f7">π</text></svg>`);
|
|
6
6
|
const iconUrl = `data:image/svg+xml,${iconSvg}`;
|
|
7
7
|
const manifest = encodeURIComponent(JSON.stringify({
|
|
8
|
-
name: 'pi remote',
|
|
9
|
-
short_name: 'pi remote',
|
|
8
|
+
name: 'pi-task remote',
|
|
9
|
+
short_name: 'pi-task remote',
|
|
10
10
|
display: 'standalone',
|
|
11
11
|
background_color: '#1e1e2e',
|
|
12
12
|
theme_color: '#1e1e2e',
|
|
@@ -19,11 +19,11 @@ export function html(wsUrl) {
|
|
|
19
19
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
20
20
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
21
21
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
22
|
-
<meta name="apple-mobile-web-app-title" content="pi remote">
|
|
22
|
+
<meta name="apple-mobile-web-app-title" content="pi-task remote">
|
|
23
23
|
<meta name="theme-color" content="#1e1e2e">
|
|
24
24
|
<link rel="apple-touch-icon" href="${iconUrl}">
|
|
25
25
|
<link rel="manifest" href="data:application/manifest+json,${manifest}">
|
|
26
|
-
<title>pi remote</title>
|
|
26
|
+
<title>pi-task remote</title>
|
|
27
27
|
<style>
|
|
28
28
|
:root {
|
|
29
29
|
--base: #1e1e2e; --mantle: #181825; --crust: #11111b;
|
|
@@ -38,7 +38,7 @@ export function html(wsUrl) {
|
|
|
38
38
|
font-family: ui-monospace, monospace; height: 100dvh;
|
|
39
39
|
display: flex; flex-direction: column; overflow: hidden;
|
|
40
40
|
padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px)
|
|
41
|
-
|
|
41
|
+
0px env(safe-area-inset-left, 0px);
|
|
42
42
|
}
|
|
43
43
|
#context-bar { height: 4px; background: var(--surface0); flex-shrink: 0; }
|
|
44
44
|
#context-bar-fill { height: 100%; background: var(--mauve); width: 0%; transition: width 0.4s ease; }
|
|
@@ -47,10 +47,29 @@ export function html(wsUrl) {
|
|
|
47
47
|
display: flex; justify-content: space-between; align-items: center;
|
|
48
48
|
font-size: 13px; flex-shrink: 0; border-bottom: 1px solid var(--surface0);
|
|
49
49
|
}
|
|
50
|
-
#header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;
|
|
51
|
-
|
|
50
|
+
#header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em;
|
|
51
|
+
position: relative; animation: glitch 5s steps(1) infinite; }
|
|
52
|
+
@keyframes glitch {
|
|
53
|
+
0%, 88%, 100% { text-shadow: none; transform: translate(0, 0); }
|
|
54
|
+
90% { text-shadow: -1px 0 var(--red), 1px 0 var(--teal); transform: translate(1px, -1px); }
|
|
55
|
+
92% { text-shadow: 1px 0 var(--red), -1px 0 var(--blue); transform: translate(-1px, 1px); }
|
|
56
|
+
94% { text-shadow: -1px 0 var(--blue), 1px 0 var(--red); transform: translate(1px, 0); }
|
|
57
|
+
96% { text-shadow: 1px 0 var(--teal), -1px 0 var(--red); transform: translate(-1px, 0); }
|
|
58
|
+
}
|
|
59
|
+
@media (prefers-reduced-motion: reduce) { #header .title { animation: none; } }
|
|
60
|
+
#header .status { color: var(--subtext0); font-size: 11px; display: inline-flex; align-items: center; gap: 5px; }
|
|
61
|
+
#header .cdot { color: var(--yellow); }
|
|
62
|
+
#header .cdot.up { color: var(--green); }
|
|
63
|
+
#header .cdot.down { color: var(--red); }
|
|
64
|
+
#header .hgroup { display: flex; align-items: center; gap: 10px; }
|
|
65
|
+
#bell {
|
|
66
|
+
background: none; border: none; color: var(--subtext1); cursor: pointer;
|
|
67
|
+
font-size: 15px; line-height: 1; padding: 2px; font-family: inherit;
|
|
68
|
+
}
|
|
69
|
+
#bell:hover { color: var(--text); }
|
|
70
|
+
#bell.on { color: var(--mauve); }
|
|
52
71
|
#chat-log {
|
|
53
|
-
flex: 1; overflow-y: auto; padding: 16px;
|
|
72
|
+
flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;
|
|
54
73
|
display: flex; flex-direction: column; gap: 8px;
|
|
55
74
|
}
|
|
56
75
|
#chat-log::-webkit-scrollbar { width: 6px; }
|
|
@@ -67,17 +86,11 @@ export function html(wsUrl) {
|
|
|
67
86
|
max-width: 100%; border: 1px solid var(--red); font-size: 12px;
|
|
68
87
|
}
|
|
69
88
|
.bubble.thinking {
|
|
70
|
-
display: flex; gap: 5px; align-items: center; padding:
|
|
89
|
+
display: flex; gap: 5px; align-items: center; padding: 10px 14px;
|
|
71
90
|
}
|
|
72
|
-
.bubble.thinking .
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
.bubble.thinking .dot:nth-child(1) { animation-delay: -0.24s; }
|
|
77
|
-
.bubble.thinking .dot:nth-child(2) { animation-delay: -0.12s; }
|
|
78
|
-
@keyframes thinking-bounce {
|
|
79
|
-
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
|
80
|
-
40% { transform: scale(1); opacity: 1; }
|
|
91
|
+
.bubble.thinking .spinner {
|
|
92
|
+
color: var(--mauve); font-size: 15px; line-height: 1;
|
|
93
|
+
font-family: ui-monospace, monospace;
|
|
81
94
|
}
|
|
82
95
|
.tool-call {
|
|
83
96
|
background: var(--crust); border-radius: 6px; align-self: flex-start;
|
|
@@ -86,15 +99,17 @@ export function html(wsUrl) {
|
|
|
86
99
|
.tool-call summary {
|
|
87
100
|
padding: 6px 10px; color: var(--subtext1); cursor: pointer;
|
|
88
101
|
user-select: none; list-style: none;
|
|
102
|
+
overflow-wrap: anywhere; word-break: break-word;
|
|
89
103
|
}
|
|
90
104
|
.tool-call summary::-webkit-details-marker { display: none; }
|
|
91
105
|
.tool-call summary::before { content: "▶ "; }
|
|
92
106
|
.tool-call[open] > summary::before { content: "▼ "; }
|
|
93
107
|
.tool-call.error > summary { color: var(--red); }
|
|
94
108
|
.tool-call pre {
|
|
95
|
-
padding: 8px 12px; overflow-
|
|
109
|
+
padding: 8px 12px; overflow-y: auto;
|
|
96
110
|
color: var(--subtext1); font-size: 11px; max-height: 280px;
|
|
97
111
|
border-top: 1px solid var(--surface0);
|
|
112
|
+
white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
|
|
98
113
|
}
|
|
99
114
|
.code-block {
|
|
100
115
|
background: var(--crust); border: 1px solid var(--surface0);
|
|
@@ -115,7 +130,7 @@ export function html(wsUrl) {
|
|
|
115
130
|
.hl-num { color: var(--blue); }
|
|
116
131
|
.hl-fn { color: var(--yellow); }
|
|
117
132
|
#input-bar {
|
|
118
|
-
background: var(--mantle); padding: 10px 16px;
|
|
133
|
+
background: var(--mantle); padding: 10px 16px calc(10px + env(safe-area-inset-bottom, 0px));
|
|
119
134
|
display: flex; gap: 8px; flex-shrink: 0;
|
|
120
135
|
border-top: 1px solid var(--surface0);
|
|
121
136
|
position: relative;
|
|
@@ -165,26 +180,53 @@ export function html(wsUrl) {
|
|
|
165
180
|
#status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);
|
|
166
181
|
color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }
|
|
167
182
|
#prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);
|
|
168
|
-
border-top: 2px solid var(--mauve); padding: 14px
|
|
169
|
-
|
|
183
|
+
border-top: 2px solid var(--mauve); padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));
|
|
184
|
+
display: none; z-index: 50; max-height: 80dvh; overflow-y: auto; }
|
|
185
|
+
#prompt-card .q-label { color: var(--mauve); font-size: 11px; font-weight: 700;
|
|
186
|
+
text-transform: uppercase; letter-spacing: .6px; margin-bottom: 6px; }
|
|
187
|
+
#prompt-card .q { color: var(--text); margin-bottom: 12px; white-space: pre-wrap;
|
|
188
|
+
font-size: 15px; line-height: 1.5; }
|
|
189
|
+
#prompt-card .rec-panel { background: var(--surface0); border-left: 3px solid var(--green);
|
|
190
|
+
border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }
|
|
191
|
+
#prompt-card .rec-label { color: var(--green); font-size: 11px; font-weight: 700;
|
|
192
|
+
text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }
|
|
193
|
+
#prompt-card .rec-text { color: var(--text); font-size: 15px; line-height: 1.5;
|
|
194
|
+
white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
170
195
|
#prompt-card textarea { width: 100%; background: var(--surface0); color: var(--text);
|
|
171
|
-
border: 1px solid var(--surface2); border-radius: 6px; padding:
|
|
172
|
-
|
|
173
|
-
#prompt-card
|
|
174
|
-
|
|
196
|
+
border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;
|
|
197
|
+
font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }
|
|
198
|
+
#prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: center;
|
|
199
|
+
flex-wrap: wrap; }
|
|
200
|
+
#prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;
|
|
201
|
+
font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }
|
|
202
|
+
#prompt-card button:hover { filter: brightness(1.08); }
|
|
203
|
+
#prompt-card button.primary { background: var(--green); color: var(--crust);
|
|
204
|
+
font-weight: 700; flex: 1; min-width: 160px; }
|
|
205
|
+
#prompt-card button.secondary { background: var(--surface1); color: var(--text);
|
|
206
|
+
flex: 1; min-width: 160px; }
|
|
207
|
+
#prompt-card button.cancel { margin-left: auto; background: transparent; color: var(--subtext0);
|
|
208
|
+
font-size: 12px; font-weight: 500; padding: 8px 10px; }
|
|
209
|
+
#prompt-card button.cancel:hover { color: var(--red); filter: none; }
|
|
210
|
+
#prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }
|
|
211
|
+
.toast { position: fixed; top: 12px; right: 12px; max-width: calc(100vw - 24px);
|
|
212
|
+
padding: 8px 12px; border-radius: 6px; overflow-wrap: anywhere; word-break: break-word;
|
|
175
213
|
background: var(--surface1); color: var(--text); z-index: 60; }
|
|
176
214
|
.toast.warning { background: var(--peach); color: var(--crust); }
|
|
177
215
|
.toast.error { background: var(--red); color: var(--crust); }
|
|
178
216
|
#viewer { position: fixed; inset: 24px; background: var(--mantle); border: 1px solid var(--surface2);
|
|
179
|
-
border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;
|
|
217
|
+
border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;
|
|
218
|
+
overflow-wrap: anywhere; word-break: break-word; display: none; z-index: 70; }
|
|
180
219
|
#viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }
|
|
181
220
|
</style>
|
|
182
221
|
</head>
|
|
183
222
|
<body>
|
|
184
223
|
<div id="context-bar"><div id="context-bar-fill"></div></div>
|
|
185
224
|
<div id="header">
|
|
186
|
-
<span class="title">pi remote</span>
|
|
187
|
-
<
|
|
225
|
+
<span class="title">pi-task remote</span>
|
|
226
|
+
<div class="hgroup">
|
|
227
|
+
<span class="status" id="client-status"><span class="cdot" id="conn-dot">○</span><span id="client-label">connecting…</span></span>
|
|
228
|
+
<button id="bell" aria-label="Toggle notifications" title="Notifications">◯</button>
|
|
229
|
+
</div>
|
|
188
230
|
</div>
|
|
189
231
|
<div id="chat-log"></div>
|
|
190
232
|
<div id="status-panel"></div>
|
|
@@ -195,8 +237,13 @@ export function html(wsUrl) {
|
|
|
195
237
|
</div>
|
|
196
238
|
<div id="reconnect-overlay"><span id="reconnect-msg">reconnecting…</span></div>
|
|
197
239
|
<div id="prompt-card">
|
|
240
|
+
<div class="q-label">pi needs your input</div>
|
|
198
241
|
<div class="q" id="prompt-q"></div>
|
|
199
|
-
<
|
|
242
|
+
<div class="rec-panel" id="prompt-rec" style="display:none">
|
|
243
|
+
<div class="rec-label">Recommended answer</div>
|
|
244
|
+
<div class="rec-text" id="prompt-rec-text"></div>
|
|
245
|
+
</div>
|
|
246
|
+
<textarea id="prompt-input" rows="3" placeholder="Type your answer…" style="display:none"></textarea>
|
|
200
247
|
<div class="row" id="prompt-buttons"></div>
|
|
201
248
|
</div>
|
|
202
249
|
<div id="viewer"><span class="close" id="viewer-close">✕</span><div id="viewer-body"></div></div>
|
|
@@ -213,19 +260,33 @@ export function html(wsUrl) {
|
|
|
213
260
|
const inputEl = document.getElementById('input');
|
|
214
261
|
const sendBtn = document.getElementById('send');
|
|
215
262
|
const contextFill = document.getElementById('context-bar-fill');
|
|
216
|
-
|
|
263
|
+
function setContextBar(usage) {
|
|
264
|
+
if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
|
|
265
|
+
}
|
|
266
|
+
const connDot = document.getElementById('conn-dot');
|
|
267
|
+
const clientLabel = document.getElementById('client-label');
|
|
268
|
+
// state: 'connecting' (○ yellow) | 'up' (● green) | 'down' (● red)
|
|
269
|
+
function setConn(state, label) {
|
|
270
|
+
connDot.textContent = state === 'connecting' ? '\\u25CB' : '\\u25CF';
|
|
271
|
+
connDot.className = 'cdot' + (state === 'up' ? ' up' : state === 'down' ? ' down' : '');
|
|
272
|
+
if (label !== undefined) clientLabel.textContent = label;
|
|
273
|
+
}
|
|
217
274
|
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
|
218
275
|
const reconnectMsg = document.getElementById('reconnect-msg');
|
|
219
276
|
const cmdSuggestions = document.getElementById('cmd-suggestions');
|
|
220
277
|
const statusPanel = document.getElementById('status-panel');
|
|
221
278
|
const promptCard = document.getElementById('prompt-card');
|
|
222
279
|
const promptQ = document.getElementById('prompt-q');
|
|
280
|
+
const promptRec = document.getElementById('prompt-rec');
|
|
281
|
+
const promptRecText = document.getElementById('prompt-rec-text');
|
|
223
282
|
const promptInput = document.getElementById('prompt-input');
|
|
224
283
|
const promptButtons = document.getElementById('prompt-buttons');
|
|
225
284
|
const viewer = document.getElementById('viewer');
|
|
226
285
|
const viewerBody = document.getElementById('viewer-body');
|
|
227
286
|
document.getElementById('viewer-close').onclick = function () { viewer.style.display = 'none'; };
|
|
228
287
|
let activePromptId = null;
|
|
288
|
+
let activeRecommended = '';
|
|
289
|
+
let cancelArmTimer = null;
|
|
229
290
|
const toolCallMap = {};
|
|
230
291
|
let currentBubble = null;
|
|
231
292
|
let streamText = '';
|
|
@@ -411,16 +472,28 @@ export function html(wsUrl) {
|
|
|
411
472
|
}
|
|
412
473
|
|
|
413
474
|
let thinkingEl = null;
|
|
475
|
+
let spinTimer = null;
|
|
476
|
+
let spinIdx = 0;
|
|
477
|
+
const SPIN = '\\u280B\\u2819\\u2839\\u2838\\u283C\\u2834\\u2826\\u2827\\u2807\\u280F';
|
|
414
478
|
function showThinking() {
|
|
415
479
|
if (!thinkingEl) {
|
|
416
480
|
thinkingEl = document.createElement('div');
|
|
417
481
|
thinkingEl.className = 'bubble assistant thinking';
|
|
418
|
-
thinkingEl.innerHTML = '<span class="
|
|
482
|
+
thinkingEl.innerHTML = '<span class="spinner"></span>';
|
|
483
|
+
}
|
|
484
|
+
if (!spinTimer) {
|
|
485
|
+
var sp = thinkingEl.firstChild;
|
|
486
|
+
sp.textContent = SPIN[spinIdx % SPIN.length];
|
|
487
|
+
spinTimer = setInterval(function () {
|
|
488
|
+
spinIdx = (spinIdx + 1) % SPIN.length;
|
|
489
|
+
sp.textContent = SPIN[spinIdx];
|
|
490
|
+
}, 90);
|
|
419
491
|
}
|
|
420
492
|
chatLog.appendChild(thinkingEl); // append (or move) to bottom
|
|
421
493
|
scrollBottom();
|
|
422
494
|
}
|
|
423
495
|
function hideThinking() {
|
|
496
|
+
if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
|
|
424
497
|
if (thinkingEl) thinkingEl.remove();
|
|
425
498
|
}
|
|
426
499
|
|
|
@@ -450,6 +523,61 @@ export function html(wsUrl) {
|
|
|
450
523
|
setTimeout(function () { t.remove(); }, 4000);
|
|
451
524
|
}
|
|
452
525
|
|
|
526
|
+
const bell = document.getElementById('bell');
|
|
527
|
+
const NOTIFY_KEY = 'piRemoteNotify';
|
|
528
|
+
|
|
529
|
+
function notifyEnabled() {
|
|
530
|
+
return localStorage.getItem(NOTIFY_KEY) === '1'
|
|
531
|
+
&& typeof Notification !== 'undefined'
|
|
532
|
+
&& Notification.permission === 'granted';
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function updateBell() {
|
|
536
|
+
// ◉ (mauve) when armed, ◯ (dim) when off/unavailable.
|
|
537
|
+
var on = notifyEnabled();
|
|
538
|
+
bell.textContent = on ? '\\u25C9' : '\\u25EF';
|
|
539
|
+
bell.classList.toggle('on', on);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Why notifications can't be enabled here, or null if they can.
|
|
543
|
+
function notifyEnvIssue() {
|
|
544
|
+
if (typeof Notification === 'undefined') return "This browser doesn't support notifications.";
|
|
545
|
+
if (!window.isSecureContext) return 'Notifications need HTTPS. Open the Tailscale https:// URL, or open via localhost.';
|
|
546
|
+
const isIOS = /iP(hone|ad|od)/i.test(navigator.userAgent);
|
|
547
|
+
const standalone = navigator.standalone === true
|
|
548
|
+
|| (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches);
|
|
549
|
+
if (isIOS && !standalone) return 'On iOS: Share \\u2192 Add to Home Screen first, then enable notifications.';
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
bell.addEventListener('click', function () {
|
|
554
|
+
// Turning OFF always works regardless of environment.
|
|
555
|
+
if (localStorage.getItem(NOTIFY_KEY) === '1') {
|
|
556
|
+
localStorage.setItem(NOTIFY_KEY, '0'); updateBell(); return;
|
|
557
|
+
}
|
|
558
|
+
const issue = notifyEnvIssue();
|
|
559
|
+
if (issue) { showToast(issue, 'warning'); return; }
|
|
560
|
+
Notification.requestPermission().then(function (perm) {
|
|
561
|
+
if (perm === 'granted') { localStorage.setItem(NOTIFY_KEY, '1'); }
|
|
562
|
+
else { showToast('Notifications blocked in browser settings.', 'warning'); }
|
|
563
|
+
updateBell();
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// Fire a browser notification, but only when armed, in a secure context,
|
|
568
|
+
// and the tab is backgrounded (foreground already has the in-page UI).
|
|
569
|
+
function notify(title, body, tag) {
|
|
570
|
+
if (!notifyEnabled()) return;
|
|
571
|
+
if (!window.isSecureContext) return;
|
|
572
|
+
if (!document.hidden) return;
|
|
573
|
+
try {
|
|
574
|
+
const n = new Notification(title, { body: body || '', tag: tag });
|
|
575
|
+
n.onclick = function () { window.focus(); n.close(); };
|
|
576
|
+
} catch (e) { /* constructor unsupported (e.g. iOS without SW) */ }
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
updateBell();
|
|
580
|
+
|
|
453
581
|
function answer(value) {
|
|
454
582
|
if (activePromptId === null) return;
|
|
455
583
|
ws.send(JSON.stringify({ type: 'prompt_answer', id: activePromptId, value: value }));
|
|
@@ -460,33 +588,93 @@ export function html(wsUrl) {
|
|
|
460
588
|
activePromptId = null;
|
|
461
589
|
promptCard.style.display = 'none';
|
|
462
590
|
promptInput.value = '';
|
|
591
|
+
promptInput.style.display = 'none';
|
|
592
|
+
promptRec.style.display = 'none';
|
|
593
|
+
if (cancelArmTimer) { clearTimeout(cancelArmTimer); cancelArmTimer = null; }
|
|
463
594
|
setEnabled(true);
|
|
464
595
|
}
|
|
465
596
|
|
|
466
|
-
function makeBtn(label,
|
|
597
|
+
function makeBtn(label, cls, onClick) {
|
|
467
598
|
const btn = document.createElement('button');
|
|
468
599
|
btn.textContent = label;
|
|
469
|
-
btn.
|
|
600
|
+
if (cls) btn.className = cls;
|
|
470
601
|
btn.onclick = onClick;
|
|
471
602
|
return btn;
|
|
472
603
|
}
|
|
473
604
|
|
|
605
|
+
// "Cancel task" aborts the whole run, so it's deliberately small and needs a
|
|
606
|
+
// two-step confirm: first tap arms it, second tap (within 3s) confirms.
|
|
607
|
+
function makeCancelBtn() {
|
|
608
|
+
const btn = makeBtn('Cancel task', 'cancel', null);
|
|
609
|
+
let armed = false;
|
|
610
|
+
btn.onclick = function () {
|
|
611
|
+
if (armed) { answer(undefined); return; }
|
|
612
|
+
armed = true;
|
|
613
|
+
btn.classList.add('armed');
|
|
614
|
+
btn.textContent = 'Tap again to cancel';
|
|
615
|
+
if (cancelArmTimer) clearTimeout(cancelArmTimer);
|
|
616
|
+
cancelArmTimer = setTimeout(function () {
|
|
617
|
+
armed = false;
|
|
618
|
+
btn.classList.remove('armed');
|
|
619
|
+
btn.textContent = 'Cancel task';
|
|
620
|
+
cancelArmTimer = null;
|
|
621
|
+
}, 3000);
|
|
622
|
+
};
|
|
623
|
+
return btn;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function renderButtons(buttons) {
|
|
627
|
+
promptButtons.innerHTML = '';
|
|
628
|
+
for (let i = 0; i < buttons.length; i++) promptButtons.appendChild(buttons[i]);
|
|
629
|
+
promptButtons.appendChild(makeCancelBtn());
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Manual-entry view: editable textarea + Submit, reachable from the
|
|
633
|
+
// recommendation view via "Answer manually".
|
|
634
|
+
function showManualEntry(prefill) {
|
|
635
|
+
promptRec.style.display = 'none';
|
|
636
|
+
promptInput.style.display = 'block';
|
|
637
|
+
promptInput.value = prefill || '';
|
|
638
|
+
renderButtons([
|
|
639
|
+
makeBtn('Submit answer', 'primary', function () { answer(promptInput.value); }),
|
|
640
|
+
makeBtn('← Back', 'secondary', function () { showRecommendation(); })
|
|
641
|
+
]);
|
|
642
|
+
promptInput.focus();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Recommendation view (Mode A): show the suggested answer and let the user
|
|
646
|
+
// accept it in one tap or switch to manual entry.
|
|
647
|
+
function showRecommendation() {
|
|
648
|
+
promptInput.style.display = 'none';
|
|
649
|
+
promptRec.style.display = 'block';
|
|
650
|
+
renderButtons([
|
|
651
|
+
makeBtn('✓ Accept recommended', 'primary', function () { answer(''); }),
|
|
652
|
+
makeBtn('✎ Answer manually', 'secondary', function () { showManualEntry(activeRecommended); })
|
|
653
|
+
]);
|
|
654
|
+
}
|
|
655
|
+
|
|
474
656
|
function showPrompt(msg) {
|
|
475
657
|
activePromptId = msg.id;
|
|
476
658
|
promptQ.textContent = msg.question;
|
|
477
|
-
|
|
478
|
-
promptButtons.innerHTML = '';
|
|
479
|
-
promptButtons.appendChild(makeBtn('Submit', 'var(--green)', function () { answer(promptInput.value); }));
|
|
659
|
+
activeRecommended = msg.recommended || '';
|
|
480
660
|
if (msg.recommended) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
661
|
+
// Mode A: there's a recommendation — lead with "Accept recommended".
|
|
662
|
+
promptRecText.textContent = msg.recommended;
|
|
663
|
+
showRecommendation();
|
|
664
|
+
} else {
|
|
665
|
+
// Mode B: no recommendation — the user must type an answer (or skip).
|
|
666
|
+
promptRec.style.display = 'none';
|
|
667
|
+
promptInput.style.display = 'block';
|
|
668
|
+
promptInput.value = '';
|
|
669
|
+
const buttons = [makeBtn('Submit answer', 'primary', function () { answer(promptInput.value); })];
|
|
670
|
+
if (msg.allowSkip) {
|
|
671
|
+
buttons.push(makeBtn('Skip question', 'secondary', function () { answer(''); }));
|
|
672
|
+
}
|
|
673
|
+
renderButtons(buttons);
|
|
674
|
+
promptInput.focus();
|
|
485
675
|
}
|
|
486
|
-
promptButtons.appendChild(makeBtn('Cancel task', 'var(--red)', function () { answer(undefined); }));
|
|
487
676
|
promptCard.style.display = 'block';
|
|
488
677
|
setEnabled(false);
|
|
489
|
-
promptInput.focus();
|
|
490
678
|
}
|
|
491
679
|
|
|
492
680
|
function handleMsg(msg) {
|
|
@@ -570,6 +758,7 @@ export function html(wsUrl) {
|
|
|
570
758
|
streamText = '';
|
|
571
759
|
}
|
|
572
760
|
addBubble('error', msg.message || 'Error');
|
|
761
|
+
notify('Agent error', msg.message || 'Error', 'pi-error');
|
|
573
762
|
setEnabled(true);
|
|
574
763
|
break;
|
|
575
764
|
case 'agent_end':
|
|
@@ -577,15 +766,19 @@ export function html(wsUrl) {
|
|
|
577
766
|
currentBubble = null;
|
|
578
767
|
streamText = '';
|
|
579
768
|
setEnabled(true);
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
769
|
+
notify('Task finished', '', 'pi-end');
|
|
770
|
+
setContextBar(msg.contextUsage);
|
|
771
|
+
break;
|
|
772
|
+
case 'context':
|
|
773
|
+
// Seeds the bar for a client that joined mid-session.
|
|
774
|
+
setContextBar(msg.contextUsage);
|
|
583
775
|
break;
|
|
584
776
|
case 'client_count':
|
|
585
|
-
|
|
777
|
+
setConn('up', msg.count + ' client' + (msg.count === 1 ? '' : 's') + ' connected');
|
|
586
778
|
break;
|
|
587
779
|
case 'prompt':
|
|
588
780
|
showPrompt(msg);
|
|
781
|
+
notify('pi needs your input', msg.question, 'pi-prompt');
|
|
589
782
|
break;
|
|
590
783
|
case 'prompt_resolved':
|
|
591
784
|
if (activePromptId === msg.id) closePrompt();
|
|
@@ -646,7 +839,7 @@ export function html(wsUrl) {
|
|
|
646
839
|
ws.addEventListener('open', () => {
|
|
647
840
|
reconnectOverlay.classList.remove('visible');
|
|
648
841
|
reconnectDelay = 1000;
|
|
649
|
-
|
|
842
|
+
setConn('up', 'connected');
|
|
650
843
|
setEnabled(true);
|
|
651
844
|
});
|
|
652
845
|
ws.addEventListener('message', (e) => {
|
|
@@ -654,6 +847,7 @@ export function html(wsUrl) {
|
|
|
654
847
|
});
|
|
655
848
|
ws.addEventListener('close', () => {
|
|
656
849
|
setEnabled(false);
|
|
850
|
+
setConn('down', 'disconnected');
|
|
657
851
|
reconnectOverlay.classList.add('visible');
|
|
658
852
|
reconnectMsg.textContent = 'reconnecting in ' + (reconnectDelay / 1000) + 's…';
|
|
659
853
|
setTimeout(() => { reconnectDelay = Math.min(reconnectDelay * 2, 30000); connect(); }, reconnectDelay);
|
|
@@ -59,15 +59,7 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
59
59
|
drainJsonEvents(lineBuffer + '\n', opts);
|
|
60
60
|
lineBuffer = '';
|
|
61
61
|
}
|
|
62
|
-
|
|
63
|
-
if (isJsonEvents) {
|
|
64
|
-
const extracted = (finalText || textDeltaAccum).trim();
|
|
65
|
-
// When json-events mode extracts no text but stderr has content,
|
|
66
|
-
// surface the stderr as the text so the caller sees the real error
|
|
67
|
-
// instead of the generic "X child produced no output". Without this,
|
|
68
|
-
// a model API crash that exits 0 with an error on stderr is invisible.
|
|
69
|
-
text = extracted.length > 0 ? extracted : (stderr.trim() || undefined);
|
|
70
|
-
}
|
|
62
|
+
const text = isJsonEvents ? (finalText || textDeltaAccum).trim() : undefined;
|
|
71
63
|
resolve({ stdout, stderr, exitCode: code ?? 0, aborted, text });
|
|
72
64
|
});
|
|
73
65
|
proc.on('error', () => {
|
|
@@ -49,12 +49,19 @@ export declare class TaskRunner {
|
|
|
49
49
|
cancel(): void;
|
|
50
50
|
/** Execute the full task lifecycle. */
|
|
51
51
|
run(): Promise<void>;
|
|
52
|
+
/** Stop the phase widget — clearing both the terminal and remote surfaces —
|
|
53
|
+
* exactly once. Nulling the disposer makes repeat calls no-ops, so the
|
|
54
|
+
* failure flash that handleFailure sets after the catch isn't wiped by the
|
|
55
|
+
* finally block's call. */
|
|
56
|
+
private _disposeWidget;
|
|
52
57
|
private _deliverSpec;
|
|
53
58
|
}
|
|
54
59
|
export interface RunSingleTaskOptions {
|
|
55
60
|
/** Await the session going idle after the spec is delivered, so the caller
|
|
56
61
|
* blocks until the agent has implemented it. Default false. */
|
|
57
62
|
waitForImplementation?: boolean;
|
|
63
|
+
/** Resume an existing task by ID instead of starting a new one. */
|
|
64
|
+
resumeId?: string;
|
|
58
65
|
/** Test seam: spawn function forwarded to TaskRunner. */
|
|
59
66
|
spawnFn?: SpawnFn;
|
|
60
67
|
}
|
|
@@ -20,8 +20,8 @@ import * as path from 'node:path';
|
|
|
20
20
|
import { PHASES, postCommitPhase } from './phases.js';
|
|
21
21
|
import { handleFailure } from './failure-classifier.js';
|
|
22
22
|
import { PHASE_INDEX, PHASE_ORDER, allocateTaskId, ensureTasksDir, normaliseTaskId, parseFrontMatter, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile, extractSection, RESUMABLE_STATES } from './task-file.js';
|
|
23
|
-
import { startWidget
|
|
24
|
-
import { publishViewer, publishNotify, registerBridgeCommand } from '../remote/bridge.js';
|
|
23
|
+
import { startWidget } from './widget.js';
|
|
24
|
+
import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
|
|
25
25
|
import { parseVerifyBlock } from './parsers.js';
|
|
26
26
|
import { formatTimings } from './timings.js';
|
|
27
27
|
// ─── Module-level state ──────────────────────────────────────────────────────
|
|
@@ -211,19 +211,13 @@ export class TaskRunner {
|
|
|
211
211
|
if (parseVerifyBlock(this._pc.spec) === null)
|
|
212
212
|
throw new Error('no_verify_block');
|
|
213
213
|
await updateTaskFrontMatter(cwd, id, { state: 'completed', phase: 'done' });
|
|
214
|
-
this.
|
|
215
|
-
try {
|
|
216
|
-
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
/* stale ctx */
|
|
220
|
-
}
|
|
214
|
+
this._disposeWidget();
|
|
221
215
|
await setTaskSection(cwd, id, 'phase timings', formatTimings(this._timings));
|
|
222
216
|
await setTaskSection(cwd, id, 'handoff', `handoff_at: ${new Date().toISOString()}`);
|
|
223
217
|
await this._deliverSpec(ctx);
|
|
224
218
|
}
|
|
225
219
|
catch (err) {
|
|
226
|
-
this.
|
|
220
|
+
this._disposeWidget();
|
|
227
221
|
// Persist whatever timings we collected so failed runs are still
|
|
228
222
|
// useful for analysis. Best-effort — never mask the original error.
|
|
229
223
|
if (this._timings.length > 0) {
|
|
@@ -237,10 +231,18 @@ export class TaskRunner {
|
|
|
237
231
|
await handleFailure(err, ctx, cwd, id, this._abort.signal.aborted);
|
|
238
232
|
}
|
|
239
233
|
finally {
|
|
240
|
-
this.
|
|
234
|
+
this._disposeWidget();
|
|
241
235
|
clearActiveTask(this);
|
|
242
236
|
}
|
|
243
237
|
}
|
|
238
|
+
/** Stop the phase widget — clearing both the terminal and remote surfaces —
|
|
239
|
+
* exactly once. Nulling the disposer makes repeat calls no-ops, so the
|
|
240
|
+
* failure flash that handleFailure sets after the catch isn't wiped by the
|
|
241
|
+
* finally block's call. */
|
|
242
|
+
_disposeWidget() {
|
|
243
|
+
this._stopWidget?.();
|
|
244
|
+
this._stopWidget = null;
|
|
245
|
+
}
|
|
244
246
|
async _deliverSpec(ctx) {
|
|
245
247
|
if (this._sendSpec) {
|
|
246
248
|
await this._sendSpec(this._pc.spec);
|
|
@@ -272,7 +274,8 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
|
|
|
272
274
|
const result = await ctx.newSession({
|
|
273
275
|
withSession: async (newCtx) => {
|
|
274
276
|
freshCtx = newCtx;
|
|
275
|
-
|
|
277
|
+
getBridge().currentCtx = newCtx; // keep remote dispatch ctx fresh across session replacement
|
|
278
|
+
const runner = new TaskRunner(newCtx, cwd, rawPrompt, opts.resumeId, async (spec) => {
|
|
276
279
|
await newCtx.sendUserMessage(spec);
|
|
277
280
|
if (opts.waitForImplementation)
|
|
278
281
|
await newCtx.waitForIdle();
|
|
@@ -390,8 +393,10 @@ async function handleTaskResume(args, ctx) {
|
|
|
390
393
|
}
|
|
391
394
|
id = candidates[0].id;
|
|
392
395
|
}
|
|
393
|
-
const
|
|
394
|
-
|
|
396
|
+
const { sessionCancelled } = await runSingleTask(ctx, cwd, '', { resumeId: id });
|
|
397
|
+
if (sessionCancelled) {
|
|
398
|
+
ctx.ui.notify('Could not start a fresh session for /task-resume.', 'warning');
|
|
399
|
+
}
|
|
395
400
|
}
|
|
396
401
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
397
402
|
async function handleTaskCancel(_args, ctx) {
|
package/dist/task/phases.js
CHANGED
|
@@ -177,23 +177,25 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
177
177
|
// is gone, but 4 concurrent streams still split the one GPU and slow
|
|
178
178
|
// each other ~4x (context worker measured 27s solo vs 128s under load),
|
|
179
179
|
// so summed-but-fast (~100s) beats max-of-slowed (~130s).
|
|
180
|
+
// Every worker runs /no_think (below), so sequential is the faster regime.
|
|
180
181
|
// Do NOT switch this back to Promise.all without re-running that A/B.
|
|
181
182
|
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
// Result order (files, apis, context,
|
|
183
|
+
// `/no_think` is the big win: these are agentic exploration loops, and on a
|
|
184
|
+
// reasoning model the child would otherwise emit a full <think> trace at
|
|
185
|
+
// every tool step ("let me read X next…") — the single largest decode sink
|
|
186
|
+
// in the pipeline. Stripping it cut each worker's decode 3-8x in the A/B.
|
|
187
|
+
// The worker still calls as many tools as it wants; it just stops narrating
|
|
188
|
+
// between them. See appendNoThink. Result order (files, apis, context,
|
|
189
|
+
// tooling) is preserved for assembly.
|
|
188
190
|
const workerSpecs = [
|
|
189
191
|
{
|
|
190
192
|
label: 'worker:files',
|
|
191
|
-
prompt: promptHeader + RESEARCH_FILES_PROMPT(refined)
|
|
193
|
+
prompt: appendNoThink(promptHeader + RESEARCH_FILES_PROMPT(refined))
|
|
192
194
|
},
|
|
193
|
-
{ label: 'worker:apis', prompt: promptHeader + RESEARCH_APIS_PROMPT(refined) },
|
|
195
|
+
{ label: 'worker:apis', prompt: appendNoThink(promptHeader + RESEARCH_APIS_PROMPT(refined)) },
|
|
194
196
|
{
|
|
195
197
|
label: 'worker:context',
|
|
196
|
-
prompt: promptHeader + RESEARCH_CONTEXT_PROMPT(refined),
|
|
198
|
+
prompt: appendNoThink(promptHeader + RESEARCH_CONTEXT_PROMPT(refined)),
|
|
197
199
|
// Context owns architectural understanding, not path discovery —
|
|
198
200
|
// FILES handles that. Dropping `find`/`ls` keeps the worker from
|
|
199
201
|
// spawning long enumeration loops whose output then inflates
|
|
@@ -202,7 +204,7 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
202
204
|
},
|
|
203
205
|
{
|
|
204
206
|
label: 'worker:tooling',
|
|
205
|
-
prompt: promptHeader + RESEARCH_TOOLING_PROMPT(refined)
|
|
207
|
+
prompt: appendNoThink(promptHeader + RESEARCH_TOOLING_PROMPT(refined))
|
|
206
208
|
}
|
|
207
209
|
];
|
|
208
210
|
const workerResults = [];
|
|
@@ -229,7 +231,7 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
229
231
|
throw new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`);
|
|
230
232
|
}
|
|
231
233
|
if (result.text.trim().length === 0) {
|
|
232
|
-
throw new Error(`Research ${name} worker produced no output
|
|
234
|
+
throw new Error(`Research ${name} worker produced no output`);
|
|
233
235
|
}
|
|
234
236
|
}
|
|
235
237
|
return `FILES\n${files.text}\n\nAPIS\n${apis.text}\n\nCONTEXT\n${context.text}\n\nTOOLING\n${tooling.text}`;
|
package/dist/task/widget.js
CHANGED
|
@@ -106,7 +106,16 @@ export function startWidget(ctx, getState) {
|
|
|
106
106
|
render();
|
|
107
107
|
const timer = setInterval(render, WIDGET_REFRESH_MS);
|
|
108
108
|
timer.unref?.();
|
|
109
|
-
return () =>
|
|
109
|
+
return () => {
|
|
110
|
+
clearInterval(timer);
|
|
111
|
+
try {
|
|
112
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* stale ctx */
|
|
116
|
+
}
|
|
117
|
+
publishWidget(WIDGET_KEY, undefined);
|
|
118
|
+
};
|
|
110
119
|
}
|
|
111
120
|
export function buildAutoLoaderLines(s, theme) {
|
|
112
121
|
const elapsed = formatDuration(Date.now() - s.startedAt);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getPiInvocation } from '../shared/pi-invocation.js';
|
|
2
2
|
import { CHILD_BASE_ARGS, runChildDefault } from '../shared/child-process.js';
|
|
3
|
+
import { LoopDetector } from '../task/loop-detector.js';
|
|
3
4
|
// `--mode json` makes pi emit structured events as they happen instead of
|
|
4
5
|
// buffering the assistant text and flushing on exit. That matters for the
|
|
5
6
|
// wait/work timing split: in text mode the first stdout chunk only arrives at
|
|
@@ -14,12 +15,17 @@ export async function runWorker(input) {
|
|
|
14
15
|
const invocation = getPiInvocation([...childArgs, input.prompt]);
|
|
15
16
|
const tStart = Date.now();
|
|
16
17
|
let tFirstByte = null;
|
|
17
|
-
const
|
|
18
|
+
const loopDetector = new LoopDetector(20, 5);
|
|
19
|
+
const result = await runChildDefault(invocation, input.cwd, input.signal, {
|
|
20
|
+
mode: 'json-events',
|
|
21
|
+
onFirstByte: () => (tFirstByte = Date.now()),
|
|
22
|
+
onToolCall: call => loopDetector.record(call)
|
|
23
|
+
}, input.spawn);
|
|
18
24
|
const tEnd = Date.now();
|
|
19
25
|
const waitMs = tFirstByte === null ? tEnd - tStart : tFirstByte - tStart;
|
|
20
26
|
const workMs = tFirstByte === null ? 0 : tEnd - tFirstByte;
|
|
21
27
|
return {
|
|
22
|
-
text: result.text
|
|
28
|
+
text: result.text ?? '',
|
|
23
29
|
exitCode: result.exitCode,
|
|
24
30
|
stderr: result.stderr.trim(),
|
|
25
31
|
aborted: result.aborted,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|