@mjasnikovs/pi-task 0.4.3 → 0.5.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 +16 -12
- package/dist/remote/bridge.js +30 -15
- package/dist/remote/register.js +12 -31
- package/dist/remote/ui.js +118 -25
- 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 +1 -1
- 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,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import type { PromptMessage, ServerMessage } from './protocol.js';
|
|
3
3
|
export interface BridgeState {
|
|
4
4
|
/** promptId → resolver that settles the remote side of an ask() race. */
|
|
@@ -46,6 +46,19 @@ export declare class SessionUI {
|
|
|
46
46
|
export declare function publishWidget(key: string, lines: string[] | undefined): void;
|
|
47
47
|
export declare function publishNotify(message: string, level: 'info' | 'warning' | 'error'): void;
|
|
48
48
|
export declare function publishViewer(title: string, text: string): void;
|
|
49
|
+
/**
|
|
50
|
+
* Wraps an event-scoped ExtensionContext so it can be used as a command ctx
|
|
51
|
+
* for commands that don't need newSession (e.g. /task-resume, /task-list,
|
|
52
|
+
* /task-cancel, /task-auto-cancel).
|
|
53
|
+
*
|
|
54
|
+
* - waitForIdle: polls ctx.isIdle() instead of the real runtime hook.
|
|
55
|
+
* - newSession: throws a clear error so /task, /task-auto, /task-auto-resume
|
|
56
|
+
* show a helpful message rather than a confusing TypeError.
|
|
57
|
+
*
|
|
58
|
+
* The shim is replaced by a real ExtensionCommandContext the first time the
|
|
59
|
+
* user runs any /task* or /remote command in the terminal.
|
|
60
|
+
*/
|
|
61
|
+
export declare function makeShimmedCtx(ctx: ExtensionContext): ExtensionCommandContext;
|
|
49
62
|
interface BridgeCommandDef {
|
|
50
63
|
description: string;
|
|
51
64
|
handler: (args: string, ctx: ExtensionCommandContext) => unknown;
|
|
@@ -55,17 +68,8 @@ interface BridgeCommandDef {
|
|
|
55
68
|
* can invoke it. Use in place of pi.registerCommand for task commands. */
|
|
56
69
|
export declare function registerBridgeCommand(pi: ExtensionAPI, name: string, def: BridgeCommandDef): void;
|
|
57
70
|
/** 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. */
|
|
71
|
+
* command-capable ctx (currentCtx) at call time. If only a shimmed ctx is
|
|
72
|
+
* available, the newSession shim throws a clear error. */
|
|
69
73
|
type NewSessionOptions = NonNullable<Parameters<ExtensionCommandContext['newSession']>[0]>;
|
|
70
74
|
type ReplacedSessionContext = Parameters<NonNullable<NewSessionOptions['withSession']>>[0];
|
|
71
75
|
export declare function dispatchRemoteNewSession(rebind: (ctx: ReplacedSessionContext) => void): void;
|
package/dist/remote/bridge.js
CHANGED
|
@@ -97,6 +97,33 @@ export function publishNotify(message, level) {
|
|
|
97
97
|
export function publishViewer(title, text) {
|
|
98
98
|
getBridge().broadcast({ type: 'viewer', title, text });
|
|
99
99
|
}
|
|
100
|
+
// ─── Shimmed ctx ─────────────────────────────────────────────────────────────
|
|
101
|
+
const SHIMMED_MARKER = '__piRemoteShimmed';
|
|
102
|
+
/**
|
|
103
|
+
* Wraps an event-scoped ExtensionContext so it can be used as a command ctx
|
|
104
|
+
* for commands that don't need newSession (e.g. /task-resume, /task-list,
|
|
105
|
+
* /task-cancel, /task-auto-cancel).
|
|
106
|
+
*
|
|
107
|
+
* - waitForIdle: polls ctx.isIdle() instead of the real runtime hook.
|
|
108
|
+
* - newSession: throws a clear error so /task, /task-auto, /task-auto-resume
|
|
109
|
+
* show a helpful message rather than a confusing TypeError.
|
|
110
|
+
*
|
|
111
|
+
* The shim is replaced by a real ExtensionCommandContext the first time the
|
|
112
|
+
* user runs any /task* or /remote command in the terminal.
|
|
113
|
+
*/
|
|
114
|
+
export function makeShimmedCtx(ctx) {
|
|
115
|
+
const shim = Object.create(ctx);
|
|
116
|
+
shim[SHIMMED_MARKER] = true;
|
|
117
|
+
shim.waitForIdle = async () => {
|
|
118
|
+
while (!ctx.isIdle()) {
|
|
119
|
+
await new Promise(r => setTimeout(r, 100));
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
shim.newSession = () => {
|
|
123
|
+
throw new Error('Run /remote in the terminal once to enable /task, /task-auto, and /new from remote.');
|
|
124
|
+
};
|
|
125
|
+
return shim;
|
|
126
|
+
}
|
|
100
127
|
/** Register a command with pi AND record it in the bridge so remote slash lines
|
|
101
128
|
* can invoke it. Use in place of pi.registerCommand for task commands. */
|
|
102
129
|
export function registerBridgeCommand(pi, name, def) {
|
|
@@ -115,13 +142,7 @@ export function dispatchRemoteNewSession(rebind) {
|
|
|
115
142
|
const b = getBridge();
|
|
116
143
|
const ctx = b.currentCtx;
|
|
117
144
|
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');
|
|
145
|
+
publishNotify('No session context available — restart pi and try again.', 'warning');
|
|
125
146
|
return;
|
|
126
147
|
}
|
|
127
148
|
const toastErr = (err) => publishNotify(`/new failed: ${err.message}`, 'error');
|
|
@@ -158,14 +179,8 @@ export function dispatchRemoteLine(text, opts) {
|
|
|
158
179
|
return true;
|
|
159
180
|
}
|
|
160
181
|
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');
|
|
182
|
+
// Shouldn't happen after session_start seeds a shimmed ctx, but guard anyway.
|
|
183
|
+
publishNotify(`/${name}: no session context yet — restart pi.`, 'warning');
|
|
169
184
|
return true;
|
|
170
185
|
}
|
|
171
186
|
// Invoke synchronously so the call happens immediately, but surface both
|
package/dist/remote/register.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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';
|
|
@@ -12,20 +12,12 @@ if (!_g.__piRemote)
|
|
|
12
12
|
const S = _g.__piRemote;
|
|
13
13
|
export function registerRemote(pi) {
|
|
14
14
|
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
15
|
async function ensureServer() {
|
|
19
16
|
if (S.server)
|
|
20
17
|
return S.server;
|
|
21
18
|
S.server = await startServer(text => {
|
|
22
19
|
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
20
|
dispatchRemoteNewSession(newCtx => {
|
|
27
|
-
// newCtx.sendUserMessage bypasses the stale runtime check
|
|
28
|
-
// (returns a Promise we deliberately discard).
|
|
29
21
|
S.send = (msg, opts) => {
|
|
30
22
|
void (opts ?
|
|
31
23
|
newCtx.sendUserMessage(msg, opts)
|
|
@@ -34,14 +26,8 @@ export function registerRemote(pi) {
|
|
|
34
26
|
});
|
|
35
27
|
return;
|
|
36
28
|
}
|
|
37
|
-
// Slash commands route to the bridge's command registry;
|
|
38
|
-
// plain lines fall through to onPlain and go to the agent.
|
|
39
29
|
dispatchRemoteLine(text, {
|
|
40
30
|
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
31
|
history.addUserMessage(plain);
|
|
46
32
|
if (isAgentIdle()) {
|
|
47
33
|
S.send?.(plain);
|
|
@@ -55,18 +41,18 @@ export function registerRemote(pi) {
|
|
|
55
41
|
return S.server;
|
|
56
42
|
}
|
|
57
43
|
pi.on('session_start', (_event, ctx) => {
|
|
58
|
-
// Update shared send with fresh pi on each session (survives /new re-evaluation)
|
|
59
44
|
S.send = (text, opts) => (opts ? pi.sendUserMessage(text, opts) : pi.sendUserMessage(text));
|
|
60
45
|
setupEvents(pi, history, broadcast);
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
46
|
+
// Seed a shimmed ctx so commands that don't need newSession (/task-list,
|
|
47
|
+
// /task-cancel, /task-auto-cancel) work immediately from the remote without
|
|
48
|
+
// any terminal interaction. Only overwrite if null or already shimmed —
|
|
49
|
+
// a real command ctx captured from a prior terminal command must survive
|
|
50
|
+
// session_start (it's updated via withSession or registerBridgeCommand,
|
|
51
|
+
// not replaced here).
|
|
52
|
+
const b = getBridge();
|
|
53
|
+
if (!b.currentCtx || b.currentCtx['__piRemoteShimmed'] === true) {
|
|
54
|
+
b.currentCtx = makeShimmedCtx(ctx);
|
|
55
|
+
}
|
|
70
56
|
void ensureServer().catch(err => ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error'));
|
|
71
57
|
});
|
|
72
58
|
pi.on('session_shutdown', (event, _ctx) => {
|
|
@@ -92,15 +78,12 @@ export function registerRemote(pi) {
|
|
|
92
78
|
}
|
|
93
79
|
return;
|
|
94
80
|
}
|
|
95
|
-
//
|
|
96
|
-
// work (the always-on auto-start can only bind plain chat).
|
|
81
|
+
// Upgrade from shimmed ctx to a real command-capable ctx.
|
|
97
82
|
getBridge().currentCtx = ctx;
|
|
98
83
|
try {
|
|
99
84
|
const server = await ensureServer();
|
|
100
|
-
// QR keeps encoding only the primary (Tailscale-preferred) URL.
|
|
101
85
|
const primaryUrl = `http://${server.ip}:${server.port}`;
|
|
102
86
|
const qr = await qrLines(primaryUrl);
|
|
103
|
-
// Labeled, block-aligned address lines: Tailscale + LAN when present.
|
|
104
87
|
const addrs = formatAddresses(server.ips, server.port);
|
|
105
88
|
const labelW = addrs.reduce((m, a) => Math.max(m, a.label.length), 0);
|
|
106
89
|
const addrLines = addrs.map(a => a.label ? `${a.label.padEnd(labelW)} ${a.url}` : a.url);
|
|
@@ -110,8 +93,6 @@ export function registerRemote(pi) {
|
|
|
110
93
|
const stripAnsi = (s) => s.replace(/\x1b\[[^m]*m/g, '');
|
|
111
94
|
const visWidth = qr.reduce((max, l) => Math.max(max, stripAnsi(l).length), 0);
|
|
112
95
|
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
96
|
ctx.ui
|
|
116
97
|
.custom((_tui, _theme, _kb, done) => ({
|
|
117
98
|
focused: false,
|
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;
|
|
@@ -50,7 +50,7 @@ export function html(wsUrl) {
|
|
|
50
50
|
#header .title { font-weight: bold; color: var(--mauve); letter-spacing: 0.05em; }
|
|
51
51
|
#header .status { color: var(--subtext0); font-size: 11px; }
|
|
52
52
|
#chat-log {
|
|
53
|
-
flex: 1; overflow-y: auto; padding: 16px;
|
|
53
|
+
flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 16px;
|
|
54
54
|
display: flex; flex-direction: column; gap: 8px;
|
|
55
55
|
}
|
|
56
56
|
#chat-log::-webkit-scrollbar { width: 6px; }
|
|
@@ -92,9 +92,10 @@ export function html(wsUrl) {
|
|
|
92
92
|
.tool-call[open] > summary::before { content: "▼ "; }
|
|
93
93
|
.tool-call.error > summary { color: var(--red); }
|
|
94
94
|
.tool-call pre {
|
|
95
|
-
padding: 8px 12px; overflow-
|
|
95
|
+
padding: 8px 12px; overflow-y: auto;
|
|
96
96
|
color: var(--subtext1); font-size: 11px; max-height: 280px;
|
|
97
97
|
border-top: 1px solid var(--surface0);
|
|
98
|
+
white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;
|
|
98
99
|
}
|
|
99
100
|
.code-block {
|
|
100
101
|
background: var(--crust); border: 1px solid var(--surface0);
|
|
@@ -165,25 +166,48 @@ export function html(wsUrl) {
|
|
|
165
166
|
#status-panel { padding: 6px 12px; border-bottom: 1px solid var(--surface1);
|
|
166
167
|
color: var(--subtext1); white-space: pre-wrap; font-size: 13px; display: none; }
|
|
167
168
|
#prompt-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--mantle);
|
|
168
|
-
border-top: 2px solid var(--mauve); padding: 14px
|
|
169
|
-
|
|
169
|
+
border-top: 2px solid var(--mauve); padding: 16px 14px calc(16px + env(safe-area-inset-bottom, 0px));
|
|
170
|
+
display: none; z-index: 50; max-height: 80dvh; overflow-y: auto; }
|
|
171
|
+
#prompt-card .q-label { color: var(--mauve); font-size: 11px; font-weight: 700;
|
|
172
|
+
text-transform: uppercase; letter-spacing: .6px; margin-bottom: 6px; }
|
|
173
|
+
#prompt-card .q { color: var(--text); margin-bottom: 12px; white-space: pre-wrap;
|
|
174
|
+
font-size: 15px; line-height: 1.5; }
|
|
175
|
+
#prompt-card .rec-panel { background: var(--surface0); border-left: 3px solid var(--green);
|
|
176
|
+
border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }
|
|
177
|
+
#prompt-card .rec-label { color: var(--green); font-size: 11px; font-weight: 700;
|
|
178
|
+
text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }
|
|
179
|
+
#prompt-card .rec-text { color: var(--text); font-size: 15px; line-height: 1.5;
|
|
180
|
+
white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
170
181
|
#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
|
|
182
|
+
border: 1px solid var(--surface2); border-radius: 6px; padding: 10px; font-size: 15px;
|
|
183
|
+
font-family: inherit; line-height: 1.5; resize: vertical; margin-bottom: 4px; }
|
|
184
|
+
#prompt-card .row { display: flex; gap: 8px; margin-top: 12px; align-items: center;
|
|
185
|
+
flex-wrap: wrap; }
|
|
186
|
+
#prompt-card button { padding: 11px 16px; border-radius: 8px; border: none; cursor: pointer;
|
|
187
|
+
font-family: inherit; font-size: 14px; font-weight: 600; transition: filter .15s ease; }
|
|
188
|
+
#prompt-card button:hover { filter: brightness(1.08); }
|
|
189
|
+
#prompt-card button.primary { background: var(--green); color: var(--crust);
|
|
190
|
+
font-weight: 700; flex: 1; min-width: 160px; }
|
|
191
|
+
#prompt-card button.secondary { background: var(--surface1); color: var(--text);
|
|
192
|
+
flex: 1; min-width: 160px; }
|
|
193
|
+
#prompt-card button.cancel { margin-left: auto; background: transparent; color: var(--subtext0);
|
|
194
|
+
font-size: 12px; font-weight: 500; padding: 8px 10px; }
|
|
195
|
+
#prompt-card button.cancel:hover { color: var(--red); filter: none; }
|
|
196
|
+
#prompt-card button.cancel.armed { background: var(--red); color: var(--crust); font-weight: 700; }
|
|
174
197
|
.toast { position: fixed; top: 12px; right: 12px; padding: 8px 12px; border-radius: 6px;
|
|
175
198
|
background: var(--surface1); color: var(--text); z-index: 60; }
|
|
176
199
|
.toast.warning { background: var(--peach); color: var(--crust); }
|
|
177
200
|
.toast.error { background: var(--red); color: var(--crust); }
|
|
178
201
|
#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;
|
|
202
|
+
border-radius: 8px; padding: 16px; overflow: auto; white-space: pre-wrap;
|
|
203
|
+
overflow-wrap: anywhere; word-break: break-word; display: none; z-index: 70; }
|
|
180
204
|
#viewer .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: var(--subtext0); }
|
|
181
205
|
</style>
|
|
182
206
|
</head>
|
|
183
207
|
<body>
|
|
184
208
|
<div id="context-bar"><div id="context-bar-fill"></div></div>
|
|
185
209
|
<div id="header">
|
|
186
|
-
<span class="title">pi remote</span>
|
|
210
|
+
<span class="title">pi-task remote</span>
|
|
187
211
|
<span class="status" id="client-status">connecting…</span>
|
|
188
212
|
</div>
|
|
189
213
|
<div id="chat-log"></div>
|
|
@@ -195,8 +219,13 @@ export function html(wsUrl) {
|
|
|
195
219
|
</div>
|
|
196
220
|
<div id="reconnect-overlay"><span id="reconnect-msg">reconnecting…</span></div>
|
|
197
221
|
<div id="prompt-card">
|
|
222
|
+
<div class="q-label">pi needs your input</div>
|
|
198
223
|
<div class="q" id="prompt-q"></div>
|
|
199
|
-
<
|
|
224
|
+
<div class="rec-panel" id="prompt-rec" style="display:none">
|
|
225
|
+
<div class="rec-label">Recommended answer</div>
|
|
226
|
+
<div class="rec-text" id="prompt-rec-text"></div>
|
|
227
|
+
</div>
|
|
228
|
+
<textarea id="prompt-input" rows="3" placeholder="Type your answer…" style="display:none"></textarea>
|
|
200
229
|
<div class="row" id="prompt-buttons"></div>
|
|
201
230
|
</div>
|
|
202
231
|
<div id="viewer"><span class="close" id="viewer-close">✕</span><div id="viewer-body"></div></div>
|
|
@@ -220,12 +249,16 @@ export function html(wsUrl) {
|
|
|
220
249
|
const statusPanel = document.getElementById('status-panel');
|
|
221
250
|
const promptCard = document.getElementById('prompt-card');
|
|
222
251
|
const promptQ = document.getElementById('prompt-q');
|
|
252
|
+
const promptRec = document.getElementById('prompt-rec');
|
|
253
|
+
const promptRecText = document.getElementById('prompt-rec-text');
|
|
223
254
|
const promptInput = document.getElementById('prompt-input');
|
|
224
255
|
const promptButtons = document.getElementById('prompt-buttons');
|
|
225
256
|
const viewer = document.getElementById('viewer');
|
|
226
257
|
const viewerBody = document.getElementById('viewer-body');
|
|
227
258
|
document.getElementById('viewer-close').onclick = function () { viewer.style.display = 'none'; };
|
|
228
259
|
let activePromptId = null;
|
|
260
|
+
let activeRecommended = '';
|
|
261
|
+
let cancelArmTimer = null;
|
|
229
262
|
const toolCallMap = {};
|
|
230
263
|
let currentBubble = null;
|
|
231
264
|
let streamText = '';
|
|
@@ -460,33 +493,93 @@ export function html(wsUrl) {
|
|
|
460
493
|
activePromptId = null;
|
|
461
494
|
promptCard.style.display = 'none';
|
|
462
495
|
promptInput.value = '';
|
|
496
|
+
promptInput.style.display = 'none';
|
|
497
|
+
promptRec.style.display = 'none';
|
|
498
|
+
if (cancelArmTimer) { clearTimeout(cancelArmTimer); cancelArmTimer = null; }
|
|
463
499
|
setEnabled(true);
|
|
464
500
|
}
|
|
465
501
|
|
|
466
|
-
function makeBtn(label,
|
|
502
|
+
function makeBtn(label, cls, onClick) {
|
|
467
503
|
const btn = document.createElement('button');
|
|
468
504
|
btn.textContent = label;
|
|
469
|
-
btn.
|
|
505
|
+
if (cls) btn.className = cls;
|
|
470
506
|
btn.onclick = onClick;
|
|
471
507
|
return btn;
|
|
472
508
|
}
|
|
473
509
|
|
|
510
|
+
// "Cancel task" aborts the whole run, so it's deliberately small and needs a
|
|
511
|
+
// two-step confirm: first tap arms it, second tap (within 3s) confirms.
|
|
512
|
+
function makeCancelBtn() {
|
|
513
|
+
const btn = makeBtn('Cancel task', 'cancel', null);
|
|
514
|
+
let armed = false;
|
|
515
|
+
btn.onclick = function () {
|
|
516
|
+
if (armed) { answer(undefined); return; }
|
|
517
|
+
armed = true;
|
|
518
|
+
btn.classList.add('armed');
|
|
519
|
+
btn.textContent = 'Tap again to cancel';
|
|
520
|
+
if (cancelArmTimer) clearTimeout(cancelArmTimer);
|
|
521
|
+
cancelArmTimer = setTimeout(function () {
|
|
522
|
+
armed = false;
|
|
523
|
+
btn.classList.remove('armed');
|
|
524
|
+
btn.textContent = 'Cancel task';
|
|
525
|
+
cancelArmTimer = null;
|
|
526
|
+
}, 3000);
|
|
527
|
+
};
|
|
528
|
+
return btn;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function renderButtons(buttons) {
|
|
532
|
+
promptButtons.innerHTML = '';
|
|
533
|
+
for (let i = 0; i < buttons.length; i++) promptButtons.appendChild(buttons[i]);
|
|
534
|
+
promptButtons.appendChild(makeCancelBtn());
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Manual-entry view: editable textarea + Submit, reachable from the
|
|
538
|
+
// recommendation view via "Answer manually".
|
|
539
|
+
function showManualEntry(prefill) {
|
|
540
|
+
promptRec.style.display = 'none';
|
|
541
|
+
promptInput.style.display = 'block';
|
|
542
|
+
promptInput.value = prefill || '';
|
|
543
|
+
renderButtons([
|
|
544
|
+
makeBtn('Submit answer', 'primary', function () { answer(promptInput.value); }),
|
|
545
|
+
makeBtn('← Back', 'secondary', function () { showRecommendation(); })
|
|
546
|
+
]);
|
|
547
|
+
promptInput.focus();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Recommendation view (Mode A): show the suggested answer and let the user
|
|
551
|
+
// accept it in one tap or switch to manual entry.
|
|
552
|
+
function showRecommendation() {
|
|
553
|
+
promptInput.style.display = 'none';
|
|
554
|
+
promptRec.style.display = 'block';
|
|
555
|
+
renderButtons([
|
|
556
|
+
makeBtn('✓ Accept recommended', 'primary', function () { answer(''); }),
|
|
557
|
+
makeBtn('✎ Answer manually', 'secondary', function () { showManualEntry(activeRecommended); })
|
|
558
|
+
]);
|
|
559
|
+
}
|
|
560
|
+
|
|
474
561
|
function showPrompt(msg) {
|
|
475
562
|
activePromptId = msg.id;
|
|
476
563
|
promptQ.textContent = msg.question;
|
|
477
|
-
|
|
478
|
-
promptButtons.innerHTML = '';
|
|
479
|
-
promptButtons.appendChild(makeBtn('Submit', 'var(--green)', function () { answer(promptInput.value); }));
|
|
564
|
+
activeRecommended = msg.recommended || '';
|
|
480
565
|
if (msg.recommended) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
566
|
+
// Mode A: there's a recommendation — lead with "Accept recommended".
|
|
567
|
+
promptRecText.textContent = msg.recommended;
|
|
568
|
+
showRecommendation();
|
|
569
|
+
} else {
|
|
570
|
+
// Mode B: no recommendation — the user must type an answer (or skip).
|
|
571
|
+
promptRec.style.display = 'none';
|
|
572
|
+
promptInput.style.display = 'block';
|
|
573
|
+
promptInput.value = '';
|
|
574
|
+
const buttons = [makeBtn('Submit answer', 'primary', function () { answer(promptInput.value); })];
|
|
575
|
+
if (msg.allowSkip) {
|
|
576
|
+
buttons.push(makeBtn('Skip question', 'secondary', function () { answer(''); }));
|
|
577
|
+
}
|
|
578
|
+
renderButtons(buttons);
|
|
579
|
+
promptInput.focus();
|
|
485
580
|
}
|
|
486
|
-
promptButtons.appendChild(makeBtn('Cancel task', 'var(--red)', function () { answer(undefined); }));
|
|
487
581
|
promptCard.style.display = 'block';
|
|
488
582
|
setEnabled(false);
|
|
489
|
-
promptInput.focus();
|
|
490
583
|
}
|
|
491
584
|
|
|
492
585
|
function handleMsg(msg) {
|
|
@@ -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
|
@@ -231,7 +231,7 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
231
231
|
throw new Error(`Research ${name} worker failed (exit ${result.exitCode}): ${result.stderr.slice(-500)}`);
|
|
232
232
|
}
|
|
233
233
|
if (result.text.trim().length === 0) {
|
|
234
|
-
throw new Error(`Research ${name} worker produced no output
|
|
234
|
+
throw new Error(`Research ${name} worker produced no output`);
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
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.5.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",
|