@mjasnikovs/pi-task 0.15.1 → 0.16.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/task/child-runner.d.ts +22 -1
- package/dist/task/child-runner.js +42 -8
- package/dist/task/impl-widget.d.ts +40 -0
- package/dist/task/impl-widget.js +131 -0
- package/dist/task/orchestrator.d.ts +6 -1
- package/dist/task/orchestrator.js +28 -3
- package/dist/task/phases.js +9 -1
- package/dist/task/widget.d.ts +10 -0
- package/dist/task/widget.js +15 -0
- package/package.json +1 -1
|
@@ -60,13 +60,34 @@ export type { PhaseDeps };
|
|
|
60
60
|
*/
|
|
61
61
|
export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string): Promise<string>;
|
|
62
62
|
export declare function formatLoopHint(hit: LoopHit): string;
|
|
63
|
+
/**
|
|
64
|
+
* Terminal hint for the degrade attempt: the model has thrashed through the whole
|
|
65
|
+
* strike budget re-reading files without converging, so we strip its tools and
|
|
66
|
+
* order it to emit the deliverable NOW from what it already has. Used only by
|
|
67
|
+
* read-only analysis phases (refine) whose output is a text rewrite that never
|
|
68
|
+
* strictly required a successful read — far better to ship a best-effort spec
|
|
69
|
+
* than to hard-fail the whole /task-auto run. See countRevisits / LoopExhausted.
|
|
70
|
+
*/
|
|
71
|
+
export declare function formatDegradeHint(hit: LoopHit): string;
|
|
63
72
|
export declare function prependHint(hint: string | null, prompt: string): string;
|
|
64
73
|
/**
|
|
65
74
|
* Run a phase child with loop detection. On a detected loop, kill and re-spawn
|
|
66
75
|
* with a hint that names the offending call. Cap at MAX_LOOP_RESTARTS restarts;
|
|
67
76
|
* the (MAX_LOOP_RESTARTS+1)th loop throws LoopExhaustedError.
|
|
68
77
|
*/
|
|
69
|
-
export
|
|
78
|
+
export interface LoopGuardOptions {
|
|
79
|
+
/**
|
|
80
|
+
* When the strike budget is exhausted by loops, do NOT fail the phase. Run
|
|
81
|
+
* ONE final attempt with NO tools and a terminal hint ordering the model to
|
|
82
|
+
* emit its output from what it already has. Only safe for phases whose
|
|
83
|
+
* deliverable is a pure text rewrite that never strictly required a read
|
|
84
|
+
* (refine) — a hard-fail there kills the whole /task-auto run for a model
|
|
85
|
+
* that simply over-explored. Research/location phases must NOT enable this:
|
|
86
|
+
* their output depends on real reads, so a no-tools fallback would fabricate.
|
|
87
|
+
*/
|
|
88
|
+
degradeOnExhaustion?: boolean;
|
|
89
|
+
}
|
|
90
|
+
export declare function runPhaseWithLoopGuard(deps: PhaseDeps, name: string, tools: string, buildPrompt: (loopHint: string | null) => string, opts?: LoopGuardOptions): Promise<string>;
|
|
70
91
|
/**
|
|
71
92
|
* Run a child up to twice; the second attempt gets `emphasized=true` to escalate
|
|
72
93
|
* the prompt. On success, return the validator's value; on two failures, throw
|
|
@@ -160,6 +160,22 @@ export function formatLoopHint(hit) {
|
|
|
160
160
|
+ `stuck in a loop. Avoid repeating that exact call; if you've already seen its result, `
|
|
161
161
|
+ `work from memory or pick a different angle.]`);
|
|
162
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Terminal hint for the degrade attempt: the model has thrashed through the whole
|
|
165
|
+
* strike budget re-reading files without converging, so we strip its tools and
|
|
166
|
+
* order it to emit the deliverable NOW from what it already has. Used only by
|
|
167
|
+
* read-only analysis phases (refine) whose output is a text rewrite that never
|
|
168
|
+
* strictly required a successful read — far better to ship a best-effort spec
|
|
169
|
+
* than to hard-fail the whole /task-auto run. See countRevisits / LoopExhausted.
|
|
170
|
+
*/
|
|
171
|
+
export function formatDegradeHint(hit) {
|
|
172
|
+
return (`[SYSTEM NOTE: You called ${hit.call.name}(${JSON.stringify(hit.call.args)}) `
|
|
173
|
+
+ `repeatedly and made no forward progress — you are stuck re-reading the same files. `
|
|
174
|
+
+ `You now have NO tools: you cannot read, grep, list, or open anything further. `
|
|
175
|
+
+ `STOP exploring. Produce the COMPLETE required output IMMEDIATELY, using only the task `
|
|
176
|
+
+ `description and what you have already seen. Emit the full structured result now — do not `
|
|
177
|
+
+ `ask to read more, do not explain, just output the final answer.]`);
|
|
178
|
+
}
|
|
163
179
|
export function prependHint(hint, prompt) {
|
|
164
180
|
return hint === null ? prompt : `${hint}\n\n${prompt}`;
|
|
165
181
|
}
|
|
@@ -172,12 +188,7 @@ async function appendLoopEvent(cwd, taskId, phase, hit, strike, outcome) {
|
|
|
172
188
|
const next = existing ? `${existing}\n${line}` : line;
|
|
173
189
|
await setTaskSection(cwd, taskId, 'loop events', next);
|
|
174
190
|
}
|
|
175
|
-
|
|
176
|
-
* Run a phase child with loop detection. On a detected loop, kill and re-spawn
|
|
177
|
-
* with a hint that names the offending call. Cap at MAX_LOOP_RESTARTS restarts;
|
|
178
|
-
* the (MAX_LOOP_RESTARTS+1)th loop throws LoopExhaustedError.
|
|
179
|
-
*/
|
|
180
|
-
export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt) {
|
|
191
|
+
export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts = {}) {
|
|
181
192
|
const loopHistory = [];
|
|
182
193
|
// Carries the correction hint (loop OR leaked-tool-call) into the next strike.
|
|
183
194
|
let nextHint = null;
|
|
@@ -192,9 +203,14 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt) {
|
|
|
192
203
|
if (r.loopHit) {
|
|
193
204
|
const isLastStrike = strike === MAX_LOOP_RESTARTS;
|
|
194
205
|
loopHistory.push(r.loopHit);
|
|
195
|
-
|
|
196
|
-
|
|
206
|
+
const lastOutcome = opts.degradeOnExhaustion ? 'degraded — no-tools final attempt' : 'phase failed';
|
|
207
|
+
await appendLoopEvent(deps.cwd, deps.taskId, name, r.loopHit, strike + 1, isLastStrike ? lastOutcome : 'restarted with hint');
|
|
208
|
+
if (isLastStrike) {
|
|
209
|
+
if (opts.degradeOnExhaustion) {
|
|
210
|
+
return await runDegradedFinalAttempt(deps, name, buildPrompt, r.loopHit, loopHistory);
|
|
211
|
+
}
|
|
197
212
|
throw new LoopExhaustedError(name, loopHistory);
|
|
213
|
+
}
|
|
198
214
|
nextHint = formatLoopHint(r.loopHit);
|
|
199
215
|
continue;
|
|
200
216
|
}
|
|
@@ -236,6 +252,24 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt) {
|
|
|
236
252
|
}
|
|
237
253
|
throw new LoopExhaustedError(name, loopHistory);
|
|
238
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Final degrade attempt after the loop budget is spent: re-spawn the child with
|
|
257
|
+
* NO tools and a terminal hint, so a model that thrashed re-reading files is
|
|
258
|
+
* forced to emit its deliverable from what it already has. With no tools there
|
|
259
|
+
* are no tool calls, so no loop can recur — the only failure modes left are a
|
|
260
|
+
* dead turn (non-zero exit, model error) or empty output, any of which fall back
|
|
261
|
+
* to the original LoopExhaustedError so the phase still fails honestly when even
|
|
262
|
+
* the degrade produces nothing.
|
|
263
|
+
*/
|
|
264
|
+
async function runDegradedFinalAttempt(deps, name, buildPrompt, hit, loopHistory) {
|
|
265
|
+
deps.logDebug?.(`${name}: loop budget exhausted — degrading to a no-tools final attempt`);
|
|
266
|
+
const r = await runChild(deps.cwd, '', // --no-tools: the model cannot read/grep/list, only answer
|
|
267
|
+
buildPrompt(formatDegradeHint(hit)), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn);
|
|
268
|
+
if (r.exitCode !== 0 || r.modelError || r.text.trim().length === 0) {
|
|
269
|
+
throw new LoopExhaustedError(name, loopHistory);
|
|
270
|
+
}
|
|
271
|
+
return r.text;
|
|
272
|
+
}
|
|
239
273
|
/**
|
|
240
274
|
* Run a child up to twice; the second attempt gets `emphasized=true` to escalate
|
|
241
275
|
* the prompt. On success, return the validator's value; on two failures, throw
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implementation-turn status widget.
|
|
3
|
+
*
|
|
4
|
+
* The phase widget (widget.ts) is disposed at spec-handoff, so the host agent's
|
|
5
|
+
* implementation turn — the longest, most visible part of a run — otherwise shows
|
|
6
|
+
* only pi's bare "⠸ Working…" indicator. This module keeps the SAME rich status
|
|
7
|
+
* block alive across that turn (task id · implementing/elapsed/context bar · ↳ last
|
|
8
|
+
* tool), driven by the host's own live `ctx.getContextUsage()`.
|
|
9
|
+
*
|
|
10
|
+
* It is event-driven rather than poll-wrapped because the two delivery paths differ:
|
|
11
|
+
*
|
|
12
|
+
* • /task (fire-and-forget): the command returns right after `sendUserMessage`,
|
|
13
|
+
* so the host runs the impl turn AFTER our code is gone — only an `agent_start`
|
|
14
|
+
* handler can pick it up. Armed one-shot: `agent_end` disarms it.
|
|
15
|
+
* • /task-auto (awaited): the caller blocks across `waitForIdle` plus any
|
|
16
|
+
* compaction-resume / steer turns. Armed sticky: each `agent_start` re-shows the
|
|
17
|
+
* widget, `agent_end` only hides it between turns, and the caller disarms once
|
|
18
|
+
* the whole implementation phase has settled.
|
|
19
|
+
*
|
|
20
|
+
* Only one task runs at a time (single active task), so a single module-level slot
|
|
21
|
+
* is sufficient.
|
|
22
|
+
*/
|
|
23
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
24
|
+
export interface ImplWidgetMeta {
|
|
25
|
+
taskId: string;
|
|
26
|
+
title: string;
|
|
27
|
+
label?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Arm the implementation widget just before the spec is handed off. `oneShot`
|
|
31
|
+
* true (fire-and-forget /task) lets `agent_end` disarm it after the single turn;
|
|
32
|
+
* false (awaited /task-auto) keeps it armed until `disarmImplWidget` is called.
|
|
33
|
+
*/
|
|
34
|
+
export declare function armImplWidget(meta: ImplWidgetMeta, opts: {
|
|
35
|
+
oneShot: boolean;
|
|
36
|
+
}): void;
|
|
37
|
+
/** Tear down the widget and clear the armed slot (sticky/awaited path). */
|
|
38
|
+
export declare function disarmImplWidget(): void;
|
|
39
|
+
/** Wire the agent-lifecycle handlers that drive the widget. Call once at setup. */
|
|
40
|
+
export declare function setupImplWidget(pi: ExtensionAPI): void;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implementation-turn status widget.
|
|
3
|
+
*
|
|
4
|
+
* The phase widget (widget.ts) is disposed at spec-handoff, so the host agent's
|
|
5
|
+
* implementation turn — the longest, most visible part of a run — otherwise shows
|
|
6
|
+
* only pi's bare "⠸ Working…" indicator. This module keeps the SAME rich status
|
|
7
|
+
* block alive across that turn (task id · implementing/elapsed/context bar · ↳ last
|
|
8
|
+
* tool), driven by the host's own live `ctx.getContextUsage()`.
|
|
9
|
+
*
|
|
10
|
+
* It is event-driven rather than poll-wrapped because the two delivery paths differ:
|
|
11
|
+
*
|
|
12
|
+
* • /task (fire-and-forget): the command returns right after `sendUserMessage`,
|
|
13
|
+
* so the host runs the impl turn AFTER our code is gone — only an `agent_start`
|
|
14
|
+
* handler can pick it up. Armed one-shot: `agent_end` disarms it.
|
|
15
|
+
* • /task-auto (awaited): the caller blocks across `waitForIdle` plus any
|
|
16
|
+
* compaction-resume / steer turns. Armed sticky: each `agent_start` re-shows the
|
|
17
|
+
* widget, `agent_end` only hides it between turns, and the caller disarms once
|
|
18
|
+
* the whole implementation phase has settled.
|
|
19
|
+
*
|
|
20
|
+
* Only one task runs at a time (single active task), so a single module-level slot
|
|
21
|
+
* is sufficient.
|
|
22
|
+
*/
|
|
23
|
+
import { WIDGET_KEY, WIDGET_REFRESH_MS, buildImplLines } from './widget.js';
|
|
24
|
+
import { setTaskWidget } from '../remote/session-state.js';
|
|
25
|
+
let armed = null;
|
|
26
|
+
let lastLine;
|
|
27
|
+
let timer = null;
|
|
28
|
+
let activeCtx = null;
|
|
29
|
+
/** Map the host's live context usage to the widget snapshot, or undefined when the
|
|
30
|
+
* token count is unknown (e.g. right after compaction, before the next response). */
|
|
31
|
+
function snapshot(ctx) {
|
|
32
|
+
const u = ctx.getContextUsage?.();
|
|
33
|
+
if (!u || u.tokens == null)
|
|
34
|
+
return undefined;
|
|
35
|
+
return { tokens: u.tokens, contextWindow: u.contextWindow, percent: u.percent ?? 0 };
|
|
36
|
+
}
|
|
37
|
+
function render() {
|
|
38
|
+
if (!armed || !activeCtx)
|
|
39
|
+
return;
|
|
40
|
+
const ctx = activeCtx;
|
|
41
|
+
const state = {
|
|
42
|
+
taskId: armed.meta.taskId,
|
|
43
|
+
title: armed.meta.title,
|
|
44
|
+
label: armed.meta.label,
|
|
45
|
+
startedAt: armed.startedAt,
|
|
46
|
+
lastLine,
|
|
47
|
+
contextUsage: snapshot(ctx)
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
ctx.ui.setWidget(WIDGET_KEY, buildImplLines(state, ctx.ui.theme));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* stale ctx */
|
|
54
|
+
}
|
|
55
|
+
setTaskWidget(buildImplLines(state, undefined)); // un-themed for the wire
|
|
56
|
+
}
|
|
57
|
+
function startTimer() {
|
|
58
|
+
if (timer)
|
|
59
|
+
return;
|
|
60
|
+
render();
|
|
61
|
+
timer = setInterval(render, WIDGET_REFRESH_MS);
|
|
62
|
+
timer.unref?.();
|
|
63
|
+
}
|
|
64
|
+
function stopTimer() {
|
|
65
|
+
if (timer) {
|
|
66
|
+
clearInterval(timer);
|
|
67
|
+
timer = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function clearWidget() {
|
|
71
|
+
try {
|
|
72
|
+
activeCtx?.ui.setWidget(WIDGET_KEY, undefined);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* stale ctx */
|
|
76
|
+
}
|
|
77
|
+
setTaskWidget(undefined);
|
|
78
|
+
}
|
|
79
|
+
/** One-line summary of a host tool call for the `↳` trailer (best-effort). */
|
|
80
|
+
function formatToolLine(toolName, args) {
|
|
81
|
+
const a = (args ?? {});
|
|
82
|
+
const field = a.path ?? a.file_path ?? a.command ?? a.pattern ?? a.prompt ?? a.query ?? a.url;
|
|
83
|
+
const detail = typeof field === 'string' ? ' ' + field.replace(/\s+/g, ' ').trim() : '';
|
|
84
|
+
return `${toolName}${detail}`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Arm the implementation widget just before the spec is handed off. `oneShot`
|
|
88
|
+
* true (fire-and-forget /task) lets `agent_end` disarm it after the single turn;
|
|
89
|
+
* false (awaited /task-auto) keeps it armed until `disarmImplWidget` is called.
|
|
90
|
+
*/
|
|
91
|
+
export function armImplWidget(meta, opts) {
|
|
92
|
+
armed = { meta, oneShot: opts.oneShot, startedAt: Date.now() };
|
|
93
|
+
lastLine = undefined;
|
|
94
|
+
}
|
|
95
|
+
/** Tear down the widget and clear the armed slot (sticky/awaited path). */
|
|
96
|
+
export function disarmImplWidget() {
|
|
97
|
+
stopTimer();
|
|
98
|
+
clearWidget();
|
|
99
|
+
armed = null;
|
|
100
|
+
activeCtx = null;
|
|
101
|
+
lastLine = undefined;
|
|
102
|
+
}
|
|
103
|
+
/** Wire the agent-lifecycle handlers that drive the widget. Call once at setup. */
|
|
104
|
+
export function setupImplWidget(pi) {
|
|
105
|
+
pi.on('agent_start', (_event, ctx) => {
|
|
106
|
+
if (!armed)
|
|
107
|
+
return;
|
|
108
|
+
activeCtx = ctx;
|
|
109
|
+
lastLine = undefined;
|
|
110
|
+
startTimer();
|
|
111
|
+
});
|
|
112
|
+
pi.on('tool_execution_start', (event, _ctx) => {
|
|
113
|
+
if (!armed)
|
|
114
|
+
return;
|
|
115
|
+
lastLine = formatToolLine(event.toolName, event.args);
|
|
116
|
+
});
|
|
117
|
+
pi.on('agent_end', (_event, _ctx) => {
|
|
118
|
+
if (!armed)
|
|
119
|
+
return;
|
|
120
|
+
stopTimer();
|
|
121
|
+
clearWidget();
|
|
122
|
+
// Fire-and-forget /task: the single impl turn is over, so disarm. Awaited
|
|
123
|
+
// /task-auto leaves the slot armed so the next compaction-resume / steer
|
|
124
|
+
// turn re-shows it; the caller disarms when the phase truly settles.
|
|
125
|
+
if (armed.oneShot) {
|
|
126
|
+
armed = null;
|
|
127
|
+
activeCtx = null;
|
|
128
|
+
lastLine = undefined;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -28,6 +28,11 @@ export declare class TaskRunner {
|
|
|
28
28
|
private readonly _onStart;
|
|
29
29
|
private readonly _planContext;
|
|
30
30
|
private readonly _fixInstruction;
|
|
31
|
+
/** True when the caller awaits the implementation turn (waitForImplementation):
|
|
32
|
+
* the impl widget stays armed across the whole impl phase (incl. compaction /
|
|
33
|
+
* steer turns) and is disarmed here. False for fire-and-forget /task, where the
|
|
34
|
+
* widget is armed one-shot and its own agent_end disarms it. */
|
|
35
|
+
private readonly _implAwaited;
|
|
31
36
|
private readonly _abort;
|
|
32
37
|
private readonly _startedAt;
|
|
33
38
|
private readonly _widgetState;
|
|
@@ -43,7 +48,7 @@ export declare class TaskRunner {
|
|
|
43
48
|
*/
|
|
44
49
|
private readonly _timings;
|
|
45
50
|
private _currentPhaseChildren;
|
|
46
|
-
constructor(ctx: ExtensionCommandContext, cwd: string, rawPrompt: string, resumeId?: string, sendSpec?: (spec: string) => Promise<void>, spawnFn?: SpawnFn, onStart?: (taskId: string) => void | Promise<void>, planContext?: string, fixInstruction?: string);
|
|
51
|
+
constructor(ctx: ExtensionCommandContext, cwd: string, rawPrompt: string, resumeId?: string, sendSpec?: (spec: string) => Promise<void>, spawnFn?: SpawnFn, onStart?: (taskId: string) => void | Promise<void>, planContext?: string, fixInstruction?: string, implAwaited?: boolean);
|
|
47
52
|
get taskId(): string;
|
|
48
53
|
get signal(): AbortSignal;
|
|
49
54
|
/** Return the current widget state, or null if not started. */
|
|
@@ -23,6 +23,7 @@ import { PHASE_INDEX, PHASE_ORDER, RESUMABLE_STATES } from './task-types.js';
|
|
|
23
23
|
import { normaliseTaskId, parseFrontMatter, extractSection } from './task-parsers.js';
|
|
24
24
|
import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
|
|
25
25
|
import { startWidget } from './widget.js';
|
|
26
|
+
import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.js';
|
|
26
27
|
import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
|
|
27
28
|
import { pushNotify } from '../remote/push.js';
|
|
28
29
|
import { parseVerifyBlock } from './spec-validation.js';
|
|
@@ -54,6 +55,11 @@ export class TaskRunner {
|
|
|
54
55
|
_onStart;
|
|
55
56
|
_planContext;
|
|
56
57
|
_fixInstruction;
|
|
58
|
+
/** True when the caller awaits the implementation turn (waitForImplementation):
|
|
59
|
+
* the impl widget stays armed across the whole impl phase (incl. compaction /
|
|
60
|
+
* steer turns) and is disarmed here. False for fire-and-forget /task, where the
|
|
61
|
+
* widget is armed one-shot and its own agent_end disarms it. */
|
|
62
|
+
_implAwaited;
|
|
57
63
|
_abort = new AbortController();
|
|
58
64
|
_startedAt;
|
|
59
65
|
_widgetState;
|
|
@@ -69,7 +75,7 @@ export class TaskRunner {
|
|
|
69
75
|
*/
|
|
70
76
|
_timings = [];
|
|
71
77
|
_currentPhaseChildren = null;
|
|
72
|
-
constructor(ctx, cwd, rawPrompt, resumeId, sendSpec, spawnFn, onStart, planContext, fixInstruction) {
|
|
78
|
+
constructor(ctx, cwd, rawPrompt, resumeId, sendSpec, spawnFn, onStart, planContext, fixInstruction, implAwaited) {
|
|
73
79
|
this._ctx = ctx;
|
|
74
80
|
this._cwd = cwd;
|
|
75
81
|
this._rawPrompt = rawPrompt;
|
|
@@ -78,6 +84,7 @@ export class TaskRunner {
|
|
|
78
84
|
this._onStart = onStart;
|
|
79
85
|
this._planContext = planContext;
|
|
80
86
|
this._fixInstruction = fixInstruction;
|
|
87
|
+
this._implAwaited = implAwaited ?? false;
|
|
81
88
|
this._startedAt = Date.now();
|
|
82
89
|
// We'll populate id/title/phase lazily in run().
|
|
83
90
|
// Placeholder — real values set in run().
|
|
@@ -265,13 +272,30 @@ export class TaskRunner {
|
|
|
265
272
|
}
|
|
266
273
|
async _deliverSpec(ctx) {
|
|
267
274
|
const spec = this._specForDelivery();
|
|
275
|
+
// Keep the rich status block alive across the implementation turn (the phase
|
|
276
|
+
// widget was disposed at handoff). Awaited (/task-auto) stays armed across all
|
|
277
|
+
// sub-turns and is disarmed here; fire-and-forget (/task) arms one-shot and its
|
|
278
|
+
// own agent_end disarms it after the single turn.
|
|
279
|
+
const meta = {
|
|
280
|
+
taskId: this._widgetState.taskId,
|
|
281
|
+
title: this._widgetState.title,
|
|
282
|
+
label: this._widgetState.label
|
|
283
|
+
};
|
|
268
284
|
if (this._sendSpec) {
|
|
269
|
-
|
|
285
|
+
armImplWidget(meta, { oneShot: !this._implAwaited });
|
|
286
|
+
try {
|
|
287
|
+
await this._sendSpec(spec);
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
if (this._implAwaited)
|
|
291
|
+
disarmImplWidget();
|
|
292
|
+
}
|
|
270
293
|
return;
|
|
271
294
|
}
|
|
272
295
|
if (!piApi) {
|
|
273
296
|
throw new Error('extension not initialised (no ExtensionAPI captured)');
|
|
274
297
|
}
|
|
298
|
+
armImplWidget(meta, { oneShot: true });
|
|
275
299
|
if (ctx.isIdle()) {
|
|
276
300
|
piApi.sendUserMessage(spec);
|
|
277
301
|
}
|
|
@@ -494,7 +518,7 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
|
|
|
494
518
|
if (!interrupted)
|
|
495
519
|
implError = implementationError(newCtx);
|
|
496
520
|
}
|
|
497
|
-
}, opts.spawnFn, opts.onStart, opts.planContext, opts.fixInstruction);
|
|
521
|
+
}, opts.spawnFn, opts.onStart, opts.planContext, opts.fixInstruction, opts.waitForImplementation);
|
|
498
522
|
await runner.run();
|
|
499
523
|
taskId = runner.taskId;
|
|
500
524
|
}
|
|
@@ -643,6 +667,7 @@ async function handleTaskCancel(_args, ctx) {
|
|
|
643
667
|
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
644
668
|
export function registerTask(pi) {
|
|
645
669
|
piApi = pi;
|
|
670
|
+
setupImplWidget(pi);
|
|
646
671
|
registerBridgeCommand(pi, 'task', {
|
|
647
672
|
description: 'Start a new task. Usage: /task <prompt>',
|
|
648
673
|
handler: handleTask
|
package/dist/task/phases.js
CHANGED
|
@@ -62,7 +62,15 @@ export function replaceToolingWithVerified(research, verifiedCommands) {
|
|
|
62
62
|
return replaced;
|
|
63
63
|
}
|
|
64
64
|
// ─── Phase functions ─────────────────────────────────────────────────────────
|
|
65
|
-
export const phaseRefine = (deps, raw, planContext) => runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw, planContext)))
|
|
65
|
+
export const phaseRefine = (deps, raw, planContext) => runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw, planContext))),
|
|
66
|
+
// refine's deliverable is a 4-section text rewrite that never strictly
|
|
67
|
+
// needs a successful read — on a test-writing task against a large
|
|
68
|
+
// existing codebase the model over-explores (re-reads source hunting for
|
|
69
|
+
// the impl) and burns the loop budget. Degrade to a no-tools final
|
|
70
|
+
// attempt instead of hard-failing the whole run. See TASK_0016 (mx5):
|
|
71
|
+
// refine looped 3×/resume forever; the deliverable was always producible
|
|
72
|
+
// from the title + design doc alone.
|
|
73
|
+
{ degradeOnExhaustion: true });
|
|
66
74
|
export async function phaseVerifyTooling(deps, research) {
|
|
67
75
|
const commands = extractToolingCommands(research);
|
|
68
76
|
if (!commands || commands.length === 0) {
|
package/dist/task/widget.d.ts
CHANGED
|
@@ -63,4 +63,14 @@ export declare function buildAutoLoaderLines(s: AutoLoaderState, theme?: WidgetT
|
|
|
63
63
|
* (returns a no-op disposer) when there's no UI.
|
|
64
64
|
*/
|
|
65
65
|
export declare function startAutoLoader(ctx: ExtensionCommandContext, getState: () => AutoLoaderState | null): () => void;
|
|
66
|
+
export interface ImplState {
|
|
67
|
+
taskId: string;
|
|
68
|
+
title: string;
|
|
69
|
+
/** Short display label compressed from `title`; falls back to a truncation when absent. */
|
|
70
|
+
label?: string;
|
|
71
|
+
startedAt: number;
|
|
72
|
+
lastLine?: string;
|
|
73
|
+
contextUsage?: ContextSnapshot;
|
|
74
|
+
}
|
|
75
|
+
export declare function buildImplLines(s: ImplState, theme?: WidgetTheme): string[];
|
|
66
76
|
export declare function flashTerminalWidget(ctx: ExtensionCommandContext, state: Exclude<TaskState, 'pending' | 'in_progress' | 'completed'>, taskId: string, reason: string | undefined): void;
|
package/dist/task/widget.js
CHANGED
|
@@ -170,6 +170,21 @@ export function startAutoLoader(ctx, getState) {
|
|
|
170
170
|
setTaskWidget(undefined);
|
|
171
171
|
};
|
|
172
172
|
}
|
|
173
|
+
export function buildImplLines(s, theme) {
|
|
174
|
+
const elapsed = formatDuration(Date.now() - s.startedAt);
|
|
175
|
+
const head = `${s.taskId} · ${titleForDisplay(s)}`;
|
|
176
|
+
let detail = `implementing · ${elapsed}`;
|
|
177
|
+
if (s.contextUsage) {
|
|
178
|
+
const ctxDetail = formatContextDetail(s.contextUsage, theme);
|
|
179
|
+
if (ctxDetail)
|
|
180
|
+
detail += ` · ${ctxDetail}`;
|
|
181
|
+
}
|
|
182
|
+
const lines = [head, detail];
|
|
183
|
+
const trailer = lastLineTrailer(s.lastLine, theme);
|
|
184
|
+
if (trailer)
|
|
185
|
+
lines.push(trailer);
|
|
186
|
+
return lines;
|
|
187
|
+
}
|
|
173
188
|
export function flashTerminalWidget(ctx, state, taskId, reason) {
|
|
174
189
|
if (!ctx.hasUI)
|
|
175
190
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|