@mjasnikovs/pi-task 0.13.6 → 0.13.8
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/config/register.js +1 -1
- package/dist/remote/push.d.ts +12 -3
- package/dist/remote/push.js +63 -9
- package/dist/remote/register.js +7 -3
- package/dist/remote/server.d.ts +4 -2
- package/dist/remote/server.js +7 -3
- package/dist/remote/tailscale.d.ts +8 -2
- package/dist/remote/tailscale.js +13 -6
- package/dist/remote/ui-script.d.ts +3 -0
- package/dist/remote/ui-script.js +804 -0
- package/dist/remote/ui-styles.d.ts +1 -0
- package/dist/remote/ui-styles.js +202 -0
- package/dist/remote/ui.js +4 -1000
- package/dist/shared/child-process.d.ts +27 -0
- package/dist/shared/child-process.js +151 -139
- package/dist/task/auto-orchestrator.js +43 -13
- package/dist/task/auto-prompts.d.ts +4 -3
- package/dist/task/auto-prompts.js +9 -6
- package/dist/task/child-runner.js +1 -1
- package/dist/task/context-usage.d.ts +16 -0
- package/dist/task/context-usage.js +22 -0
- package/dist/task/external-context.d.ts +27 -0
- package/dist/task/external-context.js +93 -0
- package/dist/task/failure-classifier.js +1 -1
- package/dist/task/orchestrator.js +7 -13
- package/dist/task/parsers.d.ts +4 -15
- package/dist/task/parsers.js +48 -87
- package/dist/task/phases.d.ts +5 -7
- package/dist/task/phases.js +29 -84
- package/dist/task/prompts.d.ts +1 -0
- package/dist/task/prompts.js +9 -0
- package/dist/task/spec-validation.d.ts +23 -0
- package/dist/task/spec-validation.js +90 -0
- package/dist/task/widget.d.ts +1 -1
- package/dist/task/widget.js +1 -1
- package/dist/workers/html-clean.js +7 -4
- package/dist/workers/pi-worker-docs.js +69 -58
- package/dist/workers/pi-worker-fetch.js +25 -21
- package/dist/workers/pi-worker-search.js +7 -13
- package/dist/workers/pi-worker.js +8 -14
- package/dist/workers/shared.d.ts +40 -0
- package/dist/workers/shared.js +31 -0
- package/package.json +1 -1
|
@@ -60,6 +60,33 @@ export interface RunChildJsonEventsOptions {
|
|
|
60
60
|
onFirstByte?: () => void;
|
|
61
61
|
}
|
|
62
62
|
export type RunChildOptions = RunChildTextOptions | RunChildJsonEventsOptions;
|
|
63
|
+
/**
|
|
64
|
+
* Parses a child's `--mode json` event stream into assistant text plus side
|
|
65
|
+
* effects (caller callbacks, loop-kill). It holds the cross-chunk line buffer
|
|
66
|
+
* and the text-assembly state, so event interpretation is independent of the
|
|
67
|
+
* spawn/kill machinery in runChild — and therefore unit-testable without a real
|
|
68
|
+
* child: construct one, `feed()` raw lines, assert on `text` / the callbacks /
|
|
69
|
+
* the onLoopKill signal.
|
|
70
|
+
*/
|
|
71
|
+
export declare class JsonEventSink {
|
|
72
|
+
private readonly opts;
|
|
73
|
+
/** Invoked when onToolCall reports a loop hit — runChild kills the child. */
|
|
74
|
+
private readonly onLoopKill;
|
|
75
|
+
/** Final assistant text from the agent_end event, if one arrived. */
|
|
76
|
+
finalText: string;
|
|
77
|
+
private textDeltaAccum;
|
|
78
|
+
private buf;
|
|
79
|
+
constructor(opts: RunChildJsonEventsOptions,
|
|
80
|
+
/** Invoked when onToolCall reports a loop hit — runChild kills the child. */
|
|
81
|
+
onLoopKill: () => void);
|
|
82
|
+
/** Feed a raw stdout chunk: parse every complete line, buffer the partial tail. */
|
|
83
|
+
feed(chunk: string): void;
|
|
84
|
+
/** Flush a trailing event that wasn't newline-terminated (call on close). */
|
|
85
|
+
flush(): void;
|
|
86
|
+
/** Extracted assistant text: the agent_end text if present, else the deltas. */
|
|
87
|
+
get text(): string;
|
|
88
|
+
private handleEvent;
|
|
89
|
+
}
|
|
63
90
|
export declare function runChild(spawn: SpawnFn, invocation: {
|
|
64
91
|
command: string;
|
|
65
92
|
args: ReadonlyArray<string>;
|
|
@@ -10,31 +10,162 @@ export const CHILD_BASE_ARGS = [
|
|
|
10
10
|
'--no-context-files',
|
|
11
11
|
'--no-session'
|
|
12
12
|
];
|
|
13
|
+
// ─── JSON event-stream sink ──────────────────────────────────────────────────
|
|
14
|
+
/**
|
|
15
|
+
* Parses a child's `--mode json` event stream into assistant text plus side
|
|
16
|
+
* effects (caller callbacks, loop-kill). It holds the cross-chunk line buffer
|
|
17
|
+
* and the text-assembly state, so event interpretation is independent of the
|
|
18
|
+
* spawn/kill machinery in runChild — and therefore unit-testable without a real
|
|
19
|
+
* child: construct one, `feed()` raw lines, assert on `text` / the callbacks /
|
|
20
|
+
* the onLoopKill signal.
|
|
21
|
+
*/
|
|
22
|
+
export class JsonEventSink {
|
|
23
|
+
opts;
|
|
24
|
+
onLoopKill;
|
|
25
|
+
/** Final assistant text from the agent_end event, if one arrived. */
|
|
26
|
+
finalText = '';
|
|
27
|
+
textDeltaAccum = '';
|
|
28
|
+
// json-events lines can split across data chunks; this holds the trailing
|
|
29
|
+
// partial line between feeds so events spanning a boundary still parse. We
|
|
30
|
+
// deliberately do NOT accumulate the full raw stream: a long-running child
|
|
31
|
+
// emits hundreds of MB of events and buffering it would overflow V8's max
|
|
32
|
+
// string length (≈512MB). We keep only the parsed text.
|
|
33
|
+
buf = '';
|
|
34
|
+
constructor(opts,
|
|
35
|
+
/** Invoked when onToolCall reports a loop hit — runChild kills the child. */
|
|
36
|
+
onLoopKill) {
|
|
37
|
+
this.opts = opts;
|
|
38
|
+
this.onLoopKill = onLoopKill;
|
|
39
|
+
}
|
|
40
|
+
/** Feed a raw stdout chunk: parse every complete line, buffer the partial tail. */
|
|
41
|
+
feed(chunk) {
|
|
42
|
+
this.buf += chunk;
|
|
43
|
+
let nl;
|
|
44
|
+
while ((nl = this.buf.indexOf('\n')) !== -1) {
|
|
45
|
+
const line = this.buf.slice(0, nl).trim();
|
|
46
|
+
this.buf = this.buf.slice(nl + 1);
|
|
47
|
+
if (line.length === 0)
|
|
48
|
+
continue;
|
|
49
|
+
try {
|
|
50
|
+
const evt = JSON.parse(line);
|
|
51
|
+
if (evt && typeof evt === 'object')
|
|
52
|
+
this.handleEvent(evt);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Non-JSON line (startup banner, etc.) — ignore.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Flush a trailing event that wasn't newline-terminated (call on close). */
|
|
60
|
+
flush() {
|
|
61
|
+
if (this.buf.trim().length > 0)
|
|
62
|
+
this.feed('\n');
|
|
63
|
+
this.buf = '';
|
|
64
|
+
}
|
|
65
|
+
/** Extracted assistant text: the agent_end text if present, else the deltas. */
|
|
66
|
+
get text() {
|
|
67
|
+
return (this.finalText || this.textDeltaAccum).trim();
|
|
68
|
+
}
|
|
69
|
+
handleEvent(evt) {
|
|
70
|
+
const opts = this.opts;
|
|
71
|
+
const t = typeof evt.type === 'string' ? evt.type : '';
|
|
72
|
+
if (t === 'context_usage' && opts.onContextUsage) {
|
|
73
|
+
const tokens = Number(evt.tokens ?? 0);
|
|
74
|
+
const contextWindow = Number(evt.contextWindow ?? 0);
|
|
75
|
+
const percent = Number(evt.percent ?? 0);
|
|
76
|
+
if (tokens > 0 || contextWindow > 0) {
|
|
77
|
+
opts.onContextUsage({ tokens, contextWindow, percent });
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (t === 'message_end' && opts.onContextUsage) {
|
|
82
|
+
const msg = evt.message;
|
|
83
|
+
if (msg?.role === 'assistant') {
|
|
84
|
+
const usage = msg.usage;
|
|
85
|
+
if (usage) {
|
|
86
|
+
const tokens = Number(usage.input ?? 0)
|
|
87
|
+
+ Number(usage.cacheRead ?? 0)
|
|
88
|
+
+ Number(usage.cacheWrite ?? 0)
|
|
89
|
+
+ Number(usage.output ?? 0);
|
|
90
|
+
if (tokens > 0) {
|
|
91
|
+
opts.onContextUsage({ tokens, contextWindow: 0, percent: 0 });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (t === 'agent_end' && Array.isArray(evt.messages)) {
|
|
98
|
+
for (let i = evt.messages.length - 1; i >= 0; i--) {
|
|
99
|
+
const m = evt.messages[i];
|
|
100
|
+
if (m && m.role === 'assistant' && Array.isArray(m.content)) {
|
|
101
|
+
const texts = [];
|
|
102
|
+
for (const c of m.content) {
|
|
103
|
+
if (c?.type === 'text' && typeof c.text === 'string') {
|
|
104
|
+
texts.push(c.text);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (texts.length > 0) {
|
|
108
|
+
this.finalText = texts.join('');
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (t === 'message_update') {
|
|
116
|
+
const ame = evt.assistantMessageEvent;
|
|
117
|
+
const ameType = ame && typeof ame.type === 'string' ? ame.type : '';
|
|
118
|
+
if (ameType === 'text_start') {
|
|
119
|
+
this.textDeltaAccum = '';
|
|
120
|
+
if (opts.onLine)
|
|
121
|
+
opts.onLine('writing answer…');
|
|
122
|
+
}
|
|
123
|
+
else if (ameType === 'text_delta' && typeof ame.delta === 'string') {
|
|
124
|
+
this.textDeltaAccum += ame.delta;
|
|
125
|
+
}
|
|
126
|
+
else if (ameType === 'thinking_start' && opts.onLine) {
|
|
127
|
+
opts.onLine('thinking…');
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (t === 'tool_execution_start') {
|
|
132
|
+
const tn = typeof evt.toolName === 'string' ? evt.toolName : 'tool';
|
|
133
|
+
if (opts.onLine) {
|
|
134
|
+
const detail = summarizeToolArgs(tn, evt.args);
|
|
135
|
+
opts.onLine(detail ? `${tn}: ${detail}` : tn);
|
|
136
|
+
}
|
|
137
|
+
if (opts.onToolCall) {
|
|
138
|
+
const hit = opts.onToolCall({ name: tn, args: evt.args });
|
|
139
|
+
if (hit)
|
|
140
|
+
this.onLoopKill();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
13
145
|
// ─── Unified runChild ────────────────────────────────────────────────────────
|
|
14
146
|
export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
15
147
|
return new Promise(resolve => {
|
|
16
148
|
let stdout = '';
|
|
17
149
|
let stderr = '';
|
|
18
150
|
let aborted = false;
|
|
19
|
-
const isJsonEvents = opts?.mode === 'json-events';
|
|
20
151
|
const discardStdout = opts?.mode === 'text' && opts.discardStdout === true;
|
|
21
|
-
// State for json-events mode
|
|
22
|
-
let finalText = '';
|
|
23
|
-
let textDeltaAccum = '';
|
|
24
152
|
const proc = spawn(invocation.command, invocation.args, {
|
|
25
153
|
cwd,
|
|
26
154
|
shell: false,
|
|
27
155
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
28
156
|
});
|
|
157
|
+
// One kill path, shared by user-abort and loop-kill: SIGTERM, then
|
|
158
|
+
// SIGKILL after a grace period if the child ignored the term.
|
|
159
|
+
const killProc = () => {
|
|
160
|
+
aborted = true;
|
|
161
|
+
proc.kill('SIGTERM');
|
|
162
|
+
setTimeout(() => {
|
|
163
|
+
if (!proc.killed)
|
|
164
|
+
proc.kill('SIGKILL');
|
|
165
|
+
}, KILL_GRACE_MS);
|
|
166
|
+
};
|
|
167
|
+
const sink = opts?.mode === 'json-events' ? new JsonEventSink(opts, killProc) : null;
|
|
29
168
|
let firstByteFired = false;
|
|
30
|
-
// json-events lines can split across data chunks; this holds the trailing
|
|
31
|
-
// partial line between chunks so events spanning a boundary still parse.
|
|
32
|
-
// In json-events mode we deliberately do NOT accumulate the full raw
|
|
33
|
-
// stream: a long-running child emits hundreds of MB of events and
|
|
34
|
-
// `stdout += chunk` would overflow V8's max string length (≈512MB),
|
|
35
|
-
// crashing the process. We only need the parsed text, so we drop the raw
|
|
36
|
-
// bytes once drained.
|
|
37
|
-
let lineBuffer = '';
|
|
38
169
|
proc.stdout?.on('data', (d) => {
|
|
39
170
|
if (!firstByteFired) {
|
|
40
171
|
firstByteFired = true;
|
|
@@ -43,147 +174,28 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
43
174
|
if (discardStdout)
|
|
44
175
|
return;
|
|
45
176
|
const chunk = d.toString();
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
else {
|
|
177
|
+
if (sink)
|
|
178
|
+
sink.feed(chunk);
|
|
179
|
+
else
|
|
50
180
|
stdout += chunk;
|
|
51
|
-
}
|
|
52
181
|
});
|
|
53
182
|
proc.stderr?.on('data', (d) => {
|
|
54
183
|
stderr += d.toString();
|
|
55
184
|
});
|
|
56
185
|
proc.on('close', (code) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
lineBuffer = '';
|
|
61
|
-
}
|
|
62
|
-
const text = isJsonEvents ? (finalText || textDeltaAccum).trim() : undefined;
|
|
186
|
+
if (sink)
|
|
187
|
+
sink.flush();
|
|
188
|
+
const text = sink ? sink.text : undefined;
|
|
63
189
|
resolve({ stdout, stderr, exitCode: code ?? 0, aborted, text });
|
|
64
190
|
});
|
|
65
191
|
proc.on('error', () => {
|
|
66
192
|
resolve({ stdout, stderr, exitCode: 1, aborted });
|
|
67
193
|
});
|
|
68
194
|
if (signal) {
|
|
69
|
-
const kill = () => {
|
|
70
|
-
aborted = true;
|
|
71
|
-
proc.kill('SIGTERM');
|
|
72
|
-
setTimeout(() => {
|
|
73
|
-
if (!proc.killed)
|
|
74
|
-
proc.kill('SIGKILL');
|
|
75
|
-
}, KILL_GRACE_MS);
|
|
76
|
-
};
|
|
77
195
|
if (signal.aborted)
|
|
78
|
-
|
|
196
|
+
killProc();
|
|
79
197
|
else
|
|
80
|
-
signal.addEventListener('abort',
|
|
81
|
-
}
|
|
82
|
-
// ─── JSON event-stream processing ──────────────────────────────────
|
|
83
|
-
/**
|
|
84
|
-
* Parse every complete (newline-terminated) JSON line in `buf` and
|
|
85
|
-
* return the trailing partial line, which the caller carries into the
|
|
86
|
-
* next chunk. This avoids both losing events that span a chunk boundary
|
|
87
|
-
* and buffering the entire stream.
|
|
88
|
-
*/
|
|
89
|
-
function drainJsonEvents(buf, jsonOpts) {
|
|
90
|
-
let nl;
|
|
91
|
-
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
92
|
-
const line = buf.slice(0, nl).trim();
|
|
93
|
-
buf = buf.slice(nl + 1);
|
|
94
|
-
if (line.length === 0)
|
|
95
|
-
continue;
|
|
96
|
-
try {
|
|
97
|
-
const evt = JSON.parse(line);
|
|
98
|
-
if (evt && typeof evt === 'object') {
|
|
99
|
-
handleEvent(evt, jsonOpts);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
// Non-JSON line (startup banner, etc.) — ignore.
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return buf;
|
|
107
|
-
}
|
|
108
|
-
function handleEvent(evt, jsonOpts) {
|
|
109
|
-
const t = typeof evt.type === 'string' ? evt.type : '';
|
|
110
|
-
if (t === 'context_usage' && jsonOpts.onContextUsage) {
|
|
111
|
-
const tokens = Number(evt.tokens ?? 0);
|
|
112
|
-
const contextWindow = Number(evt.contextWindow ?? 0);
|
|
113
|
-
const percent = Number(evt.percent ?? 0);
|
|
114
|
-
if (tokens > 0 || contextWindow > 0) {
|
|
115
|
-
jsonOpts.onContextUsage({ tokens, contextWindow, percent });
|
|
116
|
-
}
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
if (t === 'message_end' && jsonOpts.onContextUsage) {
|
|
120
|
-
const msg = evt.message;
|
|
121
|
-
if (msg?.role === 'assistant') {
|
|
122
|
-
const usage = msg.usage;
|
|
123
|
-
if (usage) {
|
|
124
|
-
const tokens = Number(usage.input ?? 0)
|
|
125
|
-
+ Number(usage.cacheRead ?? 0)
|
|
126
|
-
+ Number(usage.cacheWrite ?? 0)
|
|
127
|
-
+ Number(usage.output ?? 0);
|
|
128
|
-
if (tokens > 0) {
|
|
129
|
-
jsonOpts.onContextUsage({ tokens, contextWindow: 0, percent: 0 });
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
if (t === 'agent_end' && Array.isArray(evt.messages)) {
|
|
136
|
-
for (let i = evt.messages.length - 1; i >= 0; i--) {
|
|
137
|
-
const m = evt.messages[i];
|
|
138
|
-
if (m && m.role === 'assistant' && Array.isArray(m.content)) {
|
|
139
|
-
const texts = [];
|
|
140
|
-
for (const c of m.content) {
|
|
141
|
-
if (c?.type === 'text' && typeof c.text === 'string') {
|
|
142
|
-
texts.push(c.text);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
if (texts.length > 0) {
|
|
146
|
-
finalText = texts.join('');
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
if (t === 'message_update') {
|
|
154
|
-
const ame = evt.assistantMessageEvent;
|
|
155
|
-
const ameType = ame && typeof ame.type === 'string' ? ame.type : '';
|
|
156
|
-
if (ameType === 'text_start') {
|
|
157
|
-
textDeltaAccum = '';
|
|
158
|
-
if (jsonOpts.onLine)
|
|
159
|
-
jsonOpts.onLine('writing answer…');
|
|
160
|
-
}
|
|
161
|
-
else if (ameType === 'text_delta' && typeof ame.delta === 'string') {
|
|
162
|
-
textDeltaAccum += ame.delta;
|
|
163
|
-
}
|
|
164
|
-
else if (ameType === 'thinking_start' && jsonOpts.onLine) {
|
|
165
|
-
jsonOpts.onLine('thinking…');
|
|
166
|
-
}
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
if (t === 'tool_execution_start') {
|
|
170
|
-
const tn = typeof evt.toolName === 'string' ? evt.toolName : 'tool';
|
|
171
|
-
if (jsonOpts.onLine) {
|
|
172
|
-
const detail = summarizeToolArgs(tn, evt.args);
|
|
173
|
-
jsonOpts.onLine(detail ? `${tn}: ${detail}` : tn);
|
|
174
|
-
}
|
|
175
|
-
if (jsonOpts.onToolCall) {
|
|
176
|
-
const hit = jsonOpts.onToolCall({ name: tn, args: evt.args });
|
|
177
|
-
if (hit) {
|
|
178
|
-
aborted = true;
|
|
179
|
-
proc.kill('SIGTERM');
|
|
180
|
-
setTimeout(() => {
|
|
181
|
-
if (!proc.killed)
|
|
182
|
-
proc.kill('SIGKILL');
|
|
183
|
-
}, KILL_GRACE_MS);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
198
|
+
signal.addEventListener('abort', killProc, { once: true });
|
|
187
199
|
}
|
|
188
200
|
});
|
|
189
201
|
}
|
|
@@ -18,6 +18,7 @@ import { runPhaseChild, USER_CANCELLED } from './child-runner.js';
|
|
|
18
18
|
import { SessionUI, registerBridgeCommand } from '../remote/bridge.js';
|
|
19
19
|
import { getConfig } from '../config/config.js';
|
|
20
20
|
import { startAutoLoader } from './widget.js';
|
|
21
|
+
import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
|
|
21
22
|
// Matches pi's @-file completion token (a path after @, until whitespace).
|
|
22
23
|
const MENTION_RE = /(?:^|\s)@([^\s]+)/g;
|
|
23
24
|
/**
|
|
@@ -54,8 +55,12 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
54
55
|
// clarify — sequential & adaptive: ask one question at a time, feeding every
|
|
55
56
|
// answer back into the next call so later questions react to earlier ones
|
|
56
57
|
// (e.g. a framework choice reshapes what gets asked). Each question is shown
|
|
57
|
-
//
|
|
58
|
-
//
|
|
58
|
+
// exactly like /task's grill dialog: a binary fork offers two options (A/B),
|
|
59
|
+
// otherwise the model's recommendation is shown as the input placeholder and
|
|
60
|
+
// in the title. Nothing is pre-filled into the editor — submitting an empty
|
|
61
|
+
// field is what accepts the recommendation (see the typed.length === 0 branch
|
|
62
|
+
// below); typing overrides it. We never auto-answer; the model emits NONE when
|
|
63
|
+
// nothing remains.
|
|
59
64
|
const theme = ctx.ui.theme;
|
|
60
65
|
const ui = new SessionUI(ctx);
|
|
61
66
|
// Inline any @file spec the user referenced so clarify/decompose reason over
|
|
@@ -68,26 +73,38 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
68
73
|
const parsed = parseClarifyList(qRaw);
|
|
69
74
|
if (parsed.length === 0)
|
|
70
75
|
break; // NONE / nothing left to ask
|
|
71
|
-
const { question, suggested } = parsed[0];
|
|
76
|
+
const { question, suggested, alt } = parsed[0];
|
|
72
77
|
// Render markdown (bold/code) for the displayed prompt; keep plain text
|
|
73
78
|
// for the editable default and the persisted file.
|
|
74
79
|
const shownQ = renderInlineMarkdown(question, theme);
|
|
75
80
|
const plainQ = stripInlineMarkdown(question);
|
|
76
81
|
const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
82
|
+
const plainAlt = alt === undefined ? undefined : stripInlineMarkdown(alt);
|
|
83
|
+
// Compact A/B presentation, identical to /task's grill dialog: a binary
|
|
84
|
+
// fork shows both options labelled A/B; a single recommendation shows just
|
|
85
|
+
// the default; an open question shows the bare prompt. No verbose
|
|
86
|
+
// "Recommended:" / "press Enter to accept" scaffolding.
|
|
87
|
+
const title = plainSuggested ?
|
|
88
|
+
plainAlt ?
|
|
89
|
+
`${shownQ}\nA: ${renderInlineMarkdown(suggested, theme)}\nB: ${renderInlineMarkdown(alt, theme)}`
|
|
90
|
+
: `${shownQ}\n${renderInlineMarkdown(suggested, theme)}`
|
|
91
|
+
: shownQ;
|
|
80
92
|
const a = await ui.ask({
|
|
81
93
|
localTitle: title,
|
|
82
94
|
question: plainQ,
|
|
83
95
|
recommended: plainSuggested,
|
|
84
|
-
|
|
96
|
+
...(plainAlt !== undefined && { recommended2: plainAlt }),
|
|
97
|
+
allowSkip: plainSuggested === undefined && plainAlt === undefined
|
|
85
98
|
});
|
|
86
99
|
if (a === undefined) {
|
|
87
100
|
ctx.ui.notify('/task-auto cancelled.', 'warning');
|
|
88
101
|
return null;
|
|
89
102
|
}
|
|
90
103
|
const typed = a.trim();
|
|
104
|
+
// Two-option mode labels the choices "A:"/"B:", so a user (local TUI or
|
|
105
|
+
// remote "Manual answer") naturally types the bare letter to pick. Map it
|
|
106
|
+
// back to the option's full text. Mirrors phaseGrill's answer mapping.
|
|
107
|
+
const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
|
|
91
108
|
let answer;
|
|
92
109
|
if (typed.length === 0 && plainSuggested) {
|
|
93
110
|
answer = `${plainSuggested} (accepted recommendation)`;
|
|
@@ -95,6 +112,12 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
95
112
|
else if (typed.length === 0) {
|
|
96
113
|
answer = '(skipped)';
|
|
97
114
|
}
|
|
115
|
+
else if (twoOption && /^a[.)]?$/i.test(typed)) {
|
|
116
|
+
answer = plainSuggested;
|
|
117
|
+
}
|
|
118
|
+
else if (twoOption && /^b[.)]?$/i.test(typed)) {
|
|
119
|
+
answer = plainAlt;
|
|
120
|
+
}
|
|
98
121
|
else {
|
|
99
122
|
answer = typed;
|
|
100
123
|
}
|
|
@@ -136,7 +159,7 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
136
159
|
// output line and context usage, exactly like the single-task phase widget.
|
|
137
160
|
let lastLine;
|
|
138
161
|
let contextUsage;
|
|
139
|
-
const parentContextWindow = ctx
|
|
162
|
+
const parentContextWindow = getParentContextWindow(ctx);
|
|
140
163
|
const phaseDeps = {
|
|
141
164
|
cwd,
|
|
142
165
|
taskId: '',
|
|
@@ -145,11 +168,7 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
145
168
|
lastLine = line;
|
|
146
169
|
},
|
|
147
170
|
onContextUsage: snapshot => {
|
|
148
|
-
|
|
149
|
-
snapshot.contextWindow
|
|
150
|
-
: contextUsage?.contextWindow || parentContextWindow;
|
|
151
|
-
const percent = cw > 0 ? Math.min(100, (snapshot.tokens / cw) * 100) : snapshot.percent;
|
|
152
|
-
contextUsage = { tokens: snapshot.tokens, contextWindow: cw, percent };
|
|
171
|
+
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
153
172
|
}
|
|
154
173
|
};
|
|
155
174
|
return {
|
|
@@ -233,6 +252,17 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
233
252
|
resumeId = undefined;
|
|
234
253
|
}
|
|
235
254
|
}
|
|
255
|
+
// Before starting, fold any uncommitted work into its own checkpoint
|
|
256
|
+
// commit so a dirty tree at the start of the run — or edits left behind
|
|
257
|
+
// by an interrupted/failed task — land separately instead of being swept
|
|
258
|
+
// into this task's snapshot. Best-effort and a no-op on a clean tree
|
|
259
|
+
// (gitCommitAll commits nothing), so it only ever produces a commit when
|
|
260
|
+
// there is stray work; the matching post-task commit below is the "after"
|
|
261
|
+
// half. Only the success path is announced to keep the common no-op quiet.
|
|
262
|
+
const checkpoint = await deps.commit(cwd, `chore: checkpoint before "${next.title}"`);
|
|
263
|
+
if (checkpoint.committed) {
|
|
264
|
+
active.ui.notify(`${id}: checkpointed uncommitted work before "${next.title}".`, 'info');
|
|
265
|
+
}
|
|
236
266
|
const res = await deps.runTask(active, cwd, next.title, {
|
|
237
267
|
resumeId,
|
|
238
268
|
onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
6
|
* Clarify: asks ONE question at a time. Output MUST match parseClarifyList — a
|
|
7
|
-
* single numbered question followed by a "SUGGESTED: <default>" line,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* single numbered question followed by a "SUGGESTED: <default>" line, an optional
|
|
8
|
+
* "ALT: <alternative>" line for binary "A or B?" forks, or the literal token NONE
|
|
9
|
+
* when no clarification remains. priorQA carries the questions already answered so
|
|
10
|
+
* each next question adapts to them.
|
|
10
11
|
*/
|
|
11
12
|
export declare const AUTO_CLARIFY_PROMPT: (feature: string, priorQA: string) => string;
|
|
12
13
|
/**
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
/**
|
|
6
6
|
* Clarify: asks ONE question at a time. Output MUST match parseClarifyList — a
|
|
7
|
-
* single numbered question followed by a "SUGGESTED: <default>" line,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* single numbered question followed by a "SUGGESTED: <default>" line, an optional
|
|
8
|
+
* "ALT: <alternative>" line for binary "A or B?" forks, or the literal token NONE
|
|
9
|
+
* when no clarification remains. priorQA carries the questions already answered so
|
|
10
|
+
* each next question adapts to them.
|
|
10
11
|
*/
|
|
11
12
|
export const AUTO_CLARIFY_PROMPT = (feature, priorQA) => `You are planning how to split a feature into separate implementation tasks, one clarifying question at a time.
|
|
12
13
|
|
|
@@ -33,14 +34,16 @@ fork the breakdown). Account for the answers so far:
|
|
|
33
34
|
real-time vs polling transport, search, deployment).
|
|
34
35
|
- Skip anything /task will naturally resolve per-task during its own research.
|
|
35
36
|
|
|
36
|
-
Also propose the
|
|
37
|
-
|
|
37
|
+
Also propose the most sensible default answer for this question, inferred from
|
|
38
|
+
the repo, the referenced docs, and any stated philosophy or constraints —
|
|
38
39
|
concrete and decisive, shown to the user as a recommendation they can accept or
|
|
39
|
-
override.
|
|
40
|
+
override. When the question is a genuine binary "A or B?" fork, also give the
|
|
41
|
+
single best alternative as a second option; otherwise offer only the one default.
|
|
40
42
|
|
|
41
43
|
OUTPUT FORMAT (exact):
|
|
42
44
|
- One clarifying question as a single numbered line: "1. ...".
|
|
43
45
|
- On the NEXT line (never inline), a line that begins with "SUGGESTED: <your recommended default>".
|
|
46
|
+
- ONLY for a binary "A or B?" fork, on the line after that, a line beginning with "ALT: <the alternative option>". Omit the ALT line entirely for open-ended questions.
|
|
44
47
|
- Put the core question in **bold**, followed by a short one-line rationale in plain prose. Backticks around code/identifiers are fine. Avoid other markdown (headings, bullet lists, links).
|
|
45
48
|
- Only when the spec already pins down every choice that would change the task breakdown — nothing decision-changing is left to ask — output exactly:
|
|
46
49
|
NONE`;
|
|
@@ -10,7 +10,7 @@ import { getPiInvocation } from '../shared/pi-invocation.js';
|
|
|
10
10
|
import { runChild as runChildUnified, CHILD_BASE_ARGS } from '../shared/child-process.js';
|
|
11
11
|
import { LoopDetector } from './loop-detector.js';
|
|
12
12
|
import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
|
|
13
|
-
import { readSection, setTaskSection } from './task-
|
|
13
|
+
import { readSection, setTaskSection } from './task-io.js';
|
|
14
14
|
// ─── Loop detection constants ────────────────────────────────────────────────
|
|
15
15
|
// Defined here (not in phases.ts) to avoid a circular dependency:
|
|
16
16
|
// phases.ts → child-runner.ts → phases.ts
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-usage resolution — shared by the single-task widget (TaskRunner) and
|
|
3
|
+
* the /task-auto planning loader (defaultDeps), which both mirror a child's
|
|
4
|
+
* context_usage events into a display snapshot with identical math.
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
7
|
+
import type { ContextSnapshot } from '../shared/child-process.js';
|
|
8
|
+
/** The parent session's context window, or 0 when the model doesn't expose it. */
|
|
9
|
+
export declare function getParentContextWindow(ctx: ExtensionCommandContext): number;
|
|
10
|
+
/**
|
|
11
|
+
* Fold a raw context_usage snapshot into a display snapshot: prefer the child's
|
|
12
|
+
* own contextWindow, else the last known one, else the parent session's; then
|
|
13
|
+
* derive percent against it — falling back to the child's reported percent when
|
|
14
|
+
* no window is known at all.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveContextUsage(snapshot: ContextSnapshot, prev: ContextSnapshot | undefined, parentContextWindow: number): ContextSnapshot;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-usage resolution — shared by the single-task widget (TaskRunner) and
|
|
3
|
+
* the /task-auto planning loader (defaultDeps), which both mirror a child's
|
|
4
|
+
* context_usage events into a display snapshot with identical math.
|
|
5
|
+
*/
|
|
6
|
+
/** The parent session's context window, or 0 when the model doesn't expose it. */
|
|
7
|
+
export function getParentContextWindow(ctx) {
|
|
8
|
+
return (ctx.model?.contextWindow ?? 0);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Fold a raw context_usage snapshot into a display snapshot: prefer the child's
|
|
12
|
+
* own contextWindow, else the last known one, else the parent session's; then
|
|
13
|
+
* derive percent against it — falling back to the child's reported percent when
|
|
14
|
+
* no window is known at all.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveContextUsage(snapshot, prev, parentContextWindow) {
|
|
17
|
+
const cw = snapshot.contextWindow > 0 ?
|
|
18
|
+
snapshot.contextWindow
|
|
19
|
+
: prev?.contextWindow || parentContextWindow;
|
|
20
|
+
const percent = cw > 0 ? Math.min(100, (snapshot.tokens / cw) * 100) : snapshot.percent;
|
|
21
|
+
return { tokens: snapshot.tokens, contextWindow: cw, percent };
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External-context enrichment — extract packages / URLs / services from the
|
|
3
|
+
* refined spec, fan out to docs / fetch / search workers, and assemble the
|
|
4
|
+
* `EXTERNAL CONTEXT` block the research phase prepends to every worker prompt.
|
|
5
|
+
*
|
|
6
|
+
* Split out of phases.ts so the research phase reads as "gather context → run
|
|
7
|
+
* probes → assemble", and so this fan-out has its own test surface separate
|
|
8
|
+
* from the four research workers. `enrichment.ts` stays a pure parser; the I/O
|
|
9
|
+
* lives here.
|
|
10
|
+
*/
|
|
11
|
+
import { docsRaw } from '../workers/docs-core.js';
|
|
12
|
+
import { fetchRaw } from '../workers/fetch-core.js';
|
|
13
|
+
import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
|
|
14
|
+
import type { PhaseDeps } from './child-runner.js';
|
|
15
|
+
/** Injectable workers so enrichment is testable without spawning real lookups. */
|
|
16
|
+
export interface ExternalContextDeps {
|
|
17
|
+
docsRaw?: typeof docsRaw;
|
|
18
|
+
fetchRaw?: typeof fetchRaw;
|
|
19
|
+
searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
|
|
20
|
+
}
|
|
21
|
+
type GatherDeps = Pick<PhaseDeps, 'cwd' | 'signal' | 'recordSubStep'>;
|
|
22
|
+
/**
|
|
23
|
+
* Returns the `EXTERNAL CONTEXT\n…\n\n` block for the refined spec, or `''` when
|
|
24
|
+
* there is nothing to enrich (no targets, or every lookup failed).
|
|
25
|
+
*/
|
|
26
|
+
export declare function gatherExternalContext(refined: string, deps: GatherDeps, researchDeps?: ExternalContextDeps): Promise<string>;
|
|
27
|
+
export {};
|