@mjasnikovs/pi-task 0.38.19 → 0.38.20

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.
Files changed (44) hide show
  1. package/README.md +1 -0
  2. package/dist/config/config.d.ts +19 -0
  3. package/dist/config/config.js +10 -2
  4. package/dist/config/reasoning-args.d.ts +10 -0
  5. package/dist/config/reasoning-args.js +23 -0
  6. package/dist/config/reasoning.d.ts +160 -0
  7. package/dist/config/reasoning.js +530 -0
  8. package/dist/config/register.d.ts +72 -2
  9. package/dist/config/register.js +182 -33
  10. package/dist/shared/model-endpoint.d.ts +34 -0
  11. package/dist/shared/model-endpoint.js +36 -0
  12. package/dist/shared/reasoning-capability.d.ts +86 -0
  13. package/dist/shared/reasoning-capability.js +82 -0
  14. package/dist/task/child-runner.d.ts +19 -26
  15. package/dist/task/child-runner.js +48 -6
  16. package/dist/task/decompose-fidelity.d.ts +21 -8
  17. package/dist/task/decompose-fidelity.js +98 -17
  18. package/dist/task/gate-child.d.ts +10 -0
  19. package/dist/task/gate-child.js +1 -0
  20. package/dist/task/gate-deps.js +4 -0
  21. package/dist/task/implementation-thinking.d.ts +54 -0
  22. package/dist/task/implementation-thinking.js +33 -0
  23. package/dist/task/orchestrator.d.ts +7 -0
  24. package/dist/task/orchestrator.js +42 -14
  25. package/dist/task/phases.js +47 -24
  26. package/dist/task/prompts.d.ts +0 -23
  27. package/dist/task/prompts.js +0 -25
  28. package/dist/task/reasoning-groups.d.ts +36 -0
  29. package/dist/task/reasoning-groups.js +36 -0
  30. package/dist/task/spec-validation.d.ts +28 -0
  31. package/dist/task/spec-validation.js +44 -0
  32. package/dist/task/title-label.js +2 -2
  33. package/dist/workers/docs-core.js +4 -0
  34. package/dist/workers/fetch-core.js +4 -0
  35. package/dist/workers/focused-extractor.d.ts +12 -1
  36. package/dist/workers/focused-extractor.js +6 -2
  37. package/dist/workers/index.js +2 -0
  38. package/dist/workers/pi-worker-core.d.ts +22 -0
  39. package/dist/workers/pi-worker-core.js +24 -6
  40. package/dist/workers/pi-worker-docs.js +4 -0
  41. package/dist/workers/pi-worker.js +11 -1
  42. package/dist/workers/reasoning-warning.d.ts +64 -0
  43. package/dist/workers/reasoning-warning.js +142 -0
  44. package/package.json +1 -1
@@ -15,6 +15,8 @@ import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../s
15
15
  import { readSection, setTaskSection } from './task-io.js';
16
16
  import { streamStallCause } from '../shared/stream-watchdog.js';
17
17
  import { getConfig } from '../config/config.js';
18
+ import { groupThinkingArgs } from '../config/reasoning-args.js';
19
+ import { reasoningGroupForChild } from './reasoning-groups.js';
18
20
  // ─── Loop detection constants ────────────────────────────────────────────────
19
21
  // Defined here (not in phases.ts) to avoid a circular dependency:
20
22
  // phases.ts → child-runner.ts → phases.ts
@@ -132,7 +134,14 @@ export function connectionRetryBackoffMs(attempt) {
132
134
  }
133
135
  const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
134
136
  // ─── Spawn helpers ───────────────────────────────────────────────────────────
135
- export function childArgs(tools, extensions = []) {
137
+ export function childArgs(tools, extensions = [],
138
+ /**
139
+ * An already-resolved `['--thinking', level]`, or `[]` for "emit no flag".
140
+ * Resolved by the CALLER, never here: the level is a property of the child's
141
+ * ROLE, and this function is handed tools and extensions, not a name.
142
+ * Omitted ⇒ byte-identical argv to the version before reasoning profiles.
143
+ */
144
+ thinking = []) {
136
145
  // `--mode json` puts the child into the structured event stream the
137
146
  // unified runner parses in `mode: 'json-events'`. Without it the child
138
147
  // emits plain text, every line fails JSON.parse, finalText stays empty,
@@ -153,7 +162,7 @@ export function childArgs(tools, extensions = []) {
153
162
  // one — the guards all hang off pi's `tool_call` hook.
154
163
  const toolFlags = tools === '' ? ['--no-tools'] : ['--tools', tools];
155
164
  const internal = tools === '' ? [] : extensions;
156
- return [...childBaseArgs(internal), '--mode', 'json', ...toolFlags];
165
+ return [...childBaseArgs(internal), ...thinking, '--mode', 'json', ...toolFlags];
157
166
  }
158
167
  // Sentinel error thrown when the user dismisses a grill-me dialog.
159
168
  // Defined here (not in failure-classifier.ts) to avoid circular dependency.
@@ -178,8 +187,18 @@ onToolResult,
178
187
  * one (issue #16), so the parent hands its own down — children carry no `-m`
179
188
  * and resolve the same default model. 0 / omitted = unknown, as before.
180
189
  */
181
- contextWindow) {
182
- const invocation = getPiInvocation(childArgs(tools, extensions), prompt);
190
+ contextWindow,
191
+ /**
192
+ * The resolved `['--thinking', level]` fragment for this child's reasoning
193
+ * group, or `[]`/omitted to inherit the session default as before.
194
+ *
195
+ * A 13th trailing positional is not pretty. It is how `contextWindow` (the
196
+ * 12th) was added, and an options-bag refactor of this signature is a
197
+ * separate change from wiring one flag — doing both at once would put a
198
+ * behaviour change inside a mechanical one. DEBT: convert to an options bag.
199
+ */
200
+ thinking) {
201
+ const invocation = getPiInvocation(childArgs(tools, extensions, thinking), prompt);
183
202
  let loopHit;
184
203
  const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
185
204
  mode: 'json-events',
@@ -310,9 +329,28 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
310
329
  * All three are checked BEFORE the triage ladder: we killed the child, so its
311
330
  * exit status describes our SIGTERM and says nothing about its verdict.
312
331
  */
332
+ /**
333
+ * The `--thinking` fragment for a named child, or `[]` when the name is unmapped.
334
+ *
335
+ * An unmapped name INHERITS rather than throwing: a child that reaches the model
336
+ * with today's argv is always safe, and aborting a user's task over a missing
337
+ * table row would be a worse failure than the one it reports. The guard that
338
+ * makes the table complete is `reasoning-groups.test.ts`, which fails the BUILD —
339
+ * where someone can actually fix it.
340
+ */
341
+ function thinkingForChild(name) {
342
+ const group = reasoningGroupForChild(name);
343
+ return group ? groupThinkingArgs(group) : [];
344
+ }
313
345
  export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
314
346
  if (deps.runChild)
315
347
  return await deps.runChild(name, tools, prompt);
348
+ // Resolved ONCE per call, not per attempt: a /task-config change landing
349
+ // between a loop-kill and its retry would otherwise make the two attempts
350
+ // different experiments, and the retry exists to repeat the first one with a
351
+ // hint. An unmapped name inherits, which is today's argv — the build-time
352
+ // guard for that is reasoning-groups.test.ts, not a throw in a user's run.
353
+ const thinking = thinkingForChild(name);
316
354
  const verb = opts.verb ?? 'retry';
317
355
  let hint = null;
318
356
  const loopHistory = [];
@@ -337,7 +375,7 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
337
375
  // left the churn rule permanently disarmed (issue #16).
338
376
  stall.noteContext(snapshot.contextWindow);
339
377
  deps.onContextUsage?.(snapshot);
340
- }, call => detector.record(call) ?? stall.record(call), deps.spawn, deps.childExtensions, (text, isError) => stall.noteResult(text, isError), deps.contextWindow);
378
+ }, call => detector.record(call) ?? stall.record(call), deps.spawn, deps.childExtensions, (text, isError) => stall.noteResult(text, isError), deps.contextWindow, thinking);
341
379
  }
342
380
  finally {
343
381
  clock.cleanup();
@@ -445,7 +483,11 @@ async function appendLoopEvent(cwd, taskId, phase, hit, strike, outcome) {
445
483
  async function runDegradedFinalAttempt(deps, name, prompt, hit, loopHistory) {
446
484
  deps.logDebug?.(`${name}: loop budget exhausted — degrading to a no-tools final attempt`);
447
485
  const r = await runChild(deps.cwd, '', // --no-tools: the model cannot read/grep/list, only answer
448
- prependHint(formatDegradeHint(hit), prompt), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn, undefined, undefined, deps.contextWindow);
486
+ prependHint(formatDegradeHint(hit), prompt), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn, undefined, undefined, deps.contextWindow,
487
+ // Same group as the attempts that led here. The degrade changes the
488
+ // TOOLS, not the role — running it at a different thinking level would
489
+ // make the fallback a different experiment from the thing it rescues.
490
+ thinkingForChild(name));
449
491
  if (r.exitCode !== 0 || r.modelError || r.text.trim().length === 0) {
450
492
  throw new LoopExhaustedError(name, loopHistory);
451
493
  }
@@ -1,12 +1,25 @@
1
1
  export interface SourcedTitle {
2
- /** The title with any source clause stripped. */
2
+ /** The title with every source clause stripped. */
3
3
  base: string;
4
- /** The cited spec line, when present and grounded in the source doc. */
5
- source?: string;
4
+ /** The cited spec lines, in order, keeping only those GROUNDED in the doc. */
5
+ sources: string[];
6
6
  }
7
- /** Split a decompose title into its base and its GROUNDED source citation.
8
- * An absent clause yields no source; a fabricated (ungrounded) one is stripped
9
- * and dropped — exactly like keepGroundedContracts rejects a paraphrased quote. */
7
+ /**
8
+ * Split a decompose title into its base and its GROUNDED source citations.
9
+ *
10
+ * PLURAL, because the model emits plural. The prompt asks for one trailing
11
+ * citation and a quarter of real titles carry more — 62 of 244 across the 20
12
+ * recorded runs. The old pattern was `\[source:\s*"(.+)"\]$`: greedy `.+`
13
+ * against an end anchor, so on `[source: "A"] [source: "B"]` it matched from the
14
+ * FIRST clause to the LAST quote and produced the superstring `A"] [source: "B`,
15
+ * which of course is not in the document. Two real citations became one
16
+ * fabricated one, and both were discarded. Peeling from the end with
17
+ * lastIndexOf is the fix; a lazy quantifier is NOT, because leftmost-first
18
+ * matching plus the `$` anchor expands it across the later clauses just the same.
19
+ *
20
+ * An absent clause yields no sources; a fabricated (ungrounded) one is dropped
21
+ * — exactly like keepGroundedContracts rejects a paraphrased quote.
22
+ */
10
23
  export declare function extractTitleSource(title: string, sourceDoc: string): SourcedTitle;
11
24
  /**
12
25
  * The `+`-joined trailing constraint fragments of `sourceLine` whose words are
@@ -24,8 +37,8 @@ export interface TitleRestoration {
24
37
  index: number;
25
38
  /** The verbatim fragments re-attached to the title. */
26
39
  fragments: string[];
27
- /** The grounded source line they came from. */
28
- source: string;
40
+ /** The grounded source lines they came from, in citation order. */
41
+ sources: string[];
29
42
  }
30
43
  export interface ReconciledPlan {
31
44
  titles: string[];
@@ -28,21 +28,88 @@
28
28
  * presence is exact word membership (with a singular/plural `s` allowance).
29
29
  */
30
30
  import { normalise } from './contracts.js';
31
- /** Trailing `[source: "…"]` clause a decompose line may carry (prompt asks for it last). */
32
- const SOURCE_RE = /\s*\[source:\s*"(.+)"\s*\]\s*$/i;
33
- /** Split a decompose title into its base and its GROUNDED source citation.
34
- * An absent clause yields no source; a fabricated (ungrounded) one is stripped
35
- * and dropped exactly like keepGroundedContracts rejects a paraphrased quote. */
31
+ /** One trailing `[source: "…"]` clause, anchored so it is the WHOLE remainder. */
32
+ const SOURCE_RE = /^\[source:\s*"([\s\S]*)"\s*\]$/i;
33
+ /**
34
+ * Markdown MARKUP dropped before grounding emphasis runs, list and heading
35
+ * markers, table pipes and CODE BACKTICKS. Not content: no word, number or
36
+ * punctuation inside a sentence is touched, so this cannot make an invented
37
+ * quote match.
38
+ *
39
+ * WHY. A model copies a spec line as it READS, and what it reads is rendered:
40
+ * `2. **Auth** — sessions, login/logout/me, guards + tests.` comes back as
41
+ * `Auth — sessions, login/logout/me, guards + tests.` That is a verbatim copy of
42
+ * the line's TEXT, and the exact-substring test called it fabricated and threw
43
+ * it away — including, as here, the "+ tests" line that is this module's own
44
+ * worked example.
45
+ *
46
+ * BACKTICKS ARE THE SAME CLASS and were the larger half. A code span renders as
47
+ * bare text, so `3. **Invites** — create/validate/redeem, \`/join/:token\` page.`
48
+ * comes back as `Invites — create/validate/redeem, /join/:token page.` Measured
49
+ * on the mx5 fixture, screening every spec line in its RENDERED form:
50
+ * 107/216 grounded with backticks kept, 216/216 with them dropped.
51
+ *
52
+ * MEASURED 2026-08-27 over the 20 recorded decompose runs in
53
+ * ab-grouplab/ledger-planning.jsonl, and screened both ways first:
54
+ * FLOOR real spec lines with ONE content word altered: 0/216 pass.
55
+ * CEILING real spec lines quoted without their markup: 209/257 = 81.3% passed
56
+ * before, 257/257 after. 48 genuine lines were being rejected.
57
+ * Re-screened by scripts/decompose-fidelity-screen.ts, which is the standing
58
+ * check: CEILING raw 216/216, CEILING rendered 216/216, FLOOR 0/216.
59
+ */
60
+ function demark(s) {
61
+ return s
62
+ .replace(/\*\*|__/g, '')
63
+ .replace(/^\s*(?:[-*+]|\d+\.)\s+/gm, '')
64
+ .replace(/^#+\s*/gm, '')
65
+ .replace(/`/g, '')
66
+ .replace(/\|/g, ' ');
67
+ }
68
+ /**
69
+ * Undo the backslash-escaping a model applies to a quote it is putting INSIDE a
70
+ * double-quoted clause. `[source: "… \`import { sql } from \\"bun\\"\` gotcha …"]`
71
+ * is a faithful copy of a line the document stores with plain quotes; the
72
+ * backslashes are an artefact of the delimiter, not content. Measured live: 4 of
73
+ * the 19 ungrounded clauses in the n=30/arm planning run were this and nothing
74
+ * else. Only `\"` is undone — no other escape sequence is interpreted, so this
75
+ * cannot rewrite a quote into something the document happens to contain.
76
+ */
77
+ function unescapeQuotes(s) {
78
+ return s.replace(/\\"/g, '"');
79
+ }
80
+ /**
81
+ * Split a decompose title into its base and its GROUNDED source citations.
82
+ *
83
+ * PLURAL, because the model emits plural. The prompt asks for one trailing
84
+ * citation and a quarter of real titles carry more — 62 of 244 across the 20
85
+ * recorded runs. The old pattern was `\[source:\s*"(.+)"\]$`: greedy `.+`
86
+ * against an end anchor, so on `[source: "A"] [source: "B"]` it matched from the
87
+ * FIRST clause to the LAST quote and produced the superstring `A"] [source: "B`,
88
+ * which of course is not in the document. Two real citations became one
89
+ * fabricated one, and both were discarded. Peeling from the end with
90
+ * lastIndexOf is the fix; a lazy quantifier is NOT, because leftmost-first
91
+ * matching plus the `$` anchor expands it across the later clauses just the same.
92
+ *
93
+ * An absent clause yields no sources; a fabricated (ungrounded) one is dropped
94
+ * — exactly like keepGroundedContracts rejects a paraphrased quote.
95
+ */
36
96
  export function extractTitleSource(title, sourceDoc) {
37
- const m = SOURCE_RE.exec(title);
38
- if (!m)
39
- return { base: title.trim() };
40
- const base = title.slice(0, m.index).trim();
41
- const quote = m[1].trim();
42
- if (quote.length === 0 || !normalise(sourceDoc).includes(normalise(quote))) {
43
- return { base };
97
+ const ref = normalise(demark(sourceDoc));
98
+ let base = title.trim();
99
+ const sources = [];
100
+ for (;;) {
101
+ const at = base.toLowerCase().lastIndexOf('[source:');
102
+ if (at === -1)
103
+ break;
104
+ const m = SOURCE_RE.exec(base.slice(at).trim());
105
+ if (!m)
106
+ break;
107
+ const quote = m[1].trim();
108
+ base = base.slice(0, at).trim();
109
+ if (quote.length > 0 && ref.includes(normalise(demark(unescapeQuotes(quote)))))
110
+ sources.unshift(quote);
44
111
  }
45
- return { base, source: quote };
112
+ return { base, sources };
46
113
  }
47
114
  /** Word tokens for presence checks: alphanumeric runs, lowercased. */
48
115
  function words(s) {
@@ -105,18 +172,32 @@ export function reconcileTitleSources(titles, sourceDoc) {
105
172
  const restored = [];
106
173
  let sourced = 0;
107
174
  for (let i = 0; i < titles.length; i++) {
108
- const { base, source } = extractTitleSource(titles[i], sourceDoc);
109
- if (source === undefined) {
175
+ const { base, sources } = extractTitleSource(titles[i], sourceDoc);
176
+ if (sources.length === 0) {
110
177
  out.push(base);
111
178
  continue;
112
179
  }
113
180
  sourced++;
114
- const missing = findDroppedPlusFragments(source, base);
181
+ // EVERY grounded citation is checked, not just the first. A title that
182
+ // cites three spec lines can drop a constraint from any of them, and the
183
+ // fragments are deduped because two cited lines routinely share one
184
+ // ("+ tests" appears on four §12 milestones).
185
+ const seen = new Set();
186
+ const missing = [];
187
+ for (const src of sources) {
188
+ for (const f of findDroppedPlusFragments(src, base)) {
189
+ const k = f.toLowerCase();
190
+ if (seen.has(k))
191
+ continue;
192
+ seen.add(k);
193
+ missing.push(f);
194
+ }
195
+ }
115
196
  if (missing.length === 0) {
116
197
  out.push(base);
117
198
  continue;
118
199
  }
119
- restored.push({ index: i, fragments: missing, source });
200
+ restored.push({ index: i, fragments: missing, sources });
120
201
  out.push(`${base} — MUST also cover (restored from its spec line): ${missing.join(', ')}`);
121
202
  }
122
203
  return { titles: out, restored, sourced };
@@ -75,6 +75,16 @@ export interface GateChildDeps {
75
75
  commandTimeoutMs: number;
76
76
  /** Hung-stream bound; the probe-based stall guard cannot supply it. */
77
77
  streamInactivityMs: number;
78
+ /**
79
+ * The resolved `['--thinking', level]` fragment for the `gate` reasoning
80
+ * group, or `[]` to inherit the session default.
81
+ *
82
+ * REQUIRED, like its two neighbours above: gate-child takes resolved config
83
+ * values and gate-deps supplies them. Optional-with-a-default would let a new
84
+ * gate wiring silently run at a level nobody chose, which is the failure the
85
+ * whole profile feature exists to end.
86
+ */
87
+ thinking: readonly string[];
78
88
  /**
79
89
  * The live widget state this child feeds and its loader reads. SHARED with
80
90
  * the caller — the verify gate's own loader reads the same status while this
@@ -94,6 +94,7 @@ export function makeGateChild(deps) {
94
94
  timeoutMs: 0,
95
95
  commandTimeoutMs: deps.commandTimeoutMs,
96
96
  streamInactivityMs: deps.streamInactivityMs,
97
+ thinking: deps.thinking,
97
98
  // Exact-match loop guard only: pathThreshold Infinity disables
98
99
  // the path-revisit heuristic, so revisiting one file (which IS
99
100
  // the job) never trips — only a literally-identical call
@@ -42,6 +42,7 @@ import { assessRunnerGlobs, runnerGlobVerifyFindings } from './runner-globs.js';
42
42
  import { captureGitState, reconcileGitState } from './git-state-guard.js';
43
43
  import { runWorker } from '../workers/pi-worker-core.js';
44
44
  import { getConfig } from '../config/config.js';
45
+ import { groupThinkingArgs } from '../config/reasoning-args.js';
45
46
  import { makeDebugAppender } from './debug-log.js';
46
47
  import { startAutoLoader } from './widget.js';
47
48
  import { ChildStatus } from './child-status.js';
@@ -584,6 +585,9 @@ export function buildGateDeps(params) {
584
585
  ...(opts.loader === undefined ? {} : { loader: opts.loader }),
585
586
  commandTimeoutMs: getConfig().requestTimeoutMs,
586
587
  streamInactivityMs: getConfig().streamInactivityMs,
588
+ // Read per gateChild() call, like its two neighbours, so a
589
+ // /task-config change lands on the next gate without a restart.
590
+ thinking: groupThinkingArgs('gate'),
587
591
  status,
588
592
  runWorker,
589
593
  makeDebugAppender,
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Hold the host session at the `implementation` group's thinking level for the
3
+ * duration of one implementation turn, then put it back.
4
+ *
5
+ * WHY THIS IS NOT LIKE THE OTHER SIX GROUPS
6
+ * -----------------------------------------
7
+ * Every other group is a child process, so its level is one argv flag and it
8
+ * dies with the child. The implementation turn runs in the USER'S OWN session
9
+ * (orchestrator.ts `sendSpec` → sendUserMessage → superviseImplementation), so
10
+ * the only lever is `pi.setThinkingLevel`, which is session-global and visible.
11
+ *
12
+ * THREE THINGS pi DOES that this has to survive. All three read from
13
+ * pi-coding-agent's agent-session `setThinkingLevel`:
14
+ *
15
+ * 1. IT PERSISTS. On a real change it calls
16
+ * `settingsManager.setDefaultThinkingLevel(...)`, writing
17
+ * `~/.pi/agent/settings.json`. This is not a session-local toggle — without
18
+ * the restore, running one task would silently rewrite the user's global
19
+ * default. That makes `release()` load-bearing, not tidy-up.
20
+ * 2. IT CLAMPS, to what the model declares it supports. We may ask for `medium`
21
+ * and be given `off`. So the restore writes back what was READ after
22
+ * setting, never what was asked for — otherwise a clamp would ratchet the
23
+ * stored default a little further every run.
24
+ * 3. IT IS OBSERVABLE, and the user can change it mid-turn (shift+tab cycles
25
+ * the level). Restoring blindly would clobber a choice they just made. We
26
+ * detect it by comparing the live level at release against what we applied:
27
+ * if it has moved, somebody else moved it, and we leave it alone.
28
+ *
29
+ * We compare rather than subscribe because `pi.on` returns no unsubscribe
30
+ * handle, so a per-turn listener could only ever be added, never removed. The
31
+ * comparison answers the same question with no accumulating state.
32
+ */
33
+ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
34
+ import { type GroupSetting } from '../config/reasoning.js';
35
+ /**
36
+ * The slice of the extension API this needs, named so tests can drive it without
37
+ * a live pi session. Every other dependency in `RunSingleTaskOptions` is
38
+ * injectable; this one has to be too, or the restore logic is only exercisable
39
+ * by running a real task.
40
+ */
41
+ export interface ThinkingControl {
42
+ get(): ThinkingLevel;
43
+ set(level: ThinkingLevel): void;
44
+ }
45
+ /**
46
+ * Put the session at the implementation group's level and return the function
47
+ * that puts it back. Always call the returned function — `finally`, not the
48
+ * happy path.
49
+ *
50
+ * `inherit` makes NO call at all, not even a redundant set-to-current: a set
51
+ * that happens to be a no-op still goes through pi's change detection, and the
52
+ * shipped default must not touch the user's settings file.
53
+ */
54
+ export declare function holdImplementationThinking(control: ThinkingControl, setting?: GroupSetting): () => void;
@@ -0,0 +1,33 @@
1
+ import { getConfig } from '../config/config.js';
2
+ import { resolveReasoning } from '../config/reasoning.js';
3
+ /**
4
+ * Put the session at the implementation group's level and return the function
5
+ * that puts it back. Always call the returned function — `finally`, not the
6
+ * happy path.
7
+ *
8
+ * `inherit` makes NO call at all, not even a redundant set-to-current: a set
9
+ * that happens to be a no-op still goes through pi's change detection, and the
10
+ * shipped default must not touch the user's settings file.
11
+ */
12
+ export function holdImplementationThinking(control, setting = resolveReasoning('implementation', getConfig())) {
13
+ if (setting === 'inherit')
14
+ return () => { };
15
+ const before = control.get();
16
+ control.set(setting);
17
+ // Post-clamp, so a model that cannot do `medium` does not leave us believing
18
+ // it is at `medium` and treating the user's later change as our own.
19
+ const applied = control.get();
20
+ if (applied === before)
21
+ return () => { };
22
+ let released = false;
23
+ return () => {
24
+ // Idempotent: the caller's `finally` may run alongside an outer one on an
25
+ // abort path, and a second restore would fight a user change made in
26
+ // between.
27
+ if (released)
28
+ return;
29
+ released = true;
30
+ if (control.get() === applied)
31
+ control.set(before);
32
+ };
33
+ }
@@ -20,6 +20,7 @@ import { type WidgetState } from './widget.js';
20
20
  import { type RunTaskFn } from './gate-deps.js';
21
21
  import { type GateDeps } from './task-gates.js';
22
22
  import { type PhaseDeps } from './child-runner.js';
23
+ import { type ThinkingControl } from './implementation-thinking.js';
23
24
  import { type RunEnd } from './run-end.js';
24
25
  import type { SpawnFn } from '../shared/child-process.js';
25
26
  import { type SuperviseOptions } from './implementation-turn.js';
@@ -166,6 +167,12 @@ export interface RunSingleTaskOptions extends Pick<TaskRunnerOptions, 'resumeId'
166
167
  * internal per-task runs, which must stay silent. Default false.
167
168
  */
168
169
  notifyFinish?: boolean;
170
+ /**
171
+ * How the implementation turn's thinking level is read and written. Defaults
172
+ * to the live pi session; injected by tests, which must be able to assert
173
+ * the restore without a real session to restore.
174
+ */
175
+ thinkingControl?: ThinkingControl;
169
176
  }
170
177
  export interface RunSingleTaskResult {
171
178
  taskId: string;
@@ -36,6 +36,7 @@ import { findDeliveryPhantoms, formatApiOverrideBanner } from '../workers/phanto
36
36
  import { titleForDisplay } from './parsers.js';
37
37
  import { USER_CANCELLED } from './child-runner.js';
38
38
  import { cancelCheckpoint } from './cancel-points.js';
39
+ import { holdImplementationThinking } from './implementation-thinking.js';
39
40
  import { rearmCancelListener } from './cancel-input.js';
40
41
  import { takeHeldInput } from './mid-run-input.js';
41
42
  import { withRun, announceTerminal } from './run-bracket.js';
@@ -57,6 +58,21 @@ function clearActiveTask(runner) {
57
58
  }
58
59
  // Captured from the factory so command handlers can call pi.sendUserMessage.
59
60
  let piApi = null;
61
+ /**
62
+ * The live session's thinking level, as a {@link ThinkingControl}.
63
+ *
64
+ * Goes through `piApi` rather than the command ctx because
65
+ * `ExtensionCommandContext` exposes `thinkingLevel` READ-ONLY; the setter lives
66
+ * on the extension API. Before `registerTask(pi)` has run there is nothing to
67
+ * control, so this degrades to a no-op pair rather than throwing — a task that
68
+ * cannot move the level should still run.
69
+ */
70
+ function piThinkingControl() {
71
+ const api = piApi;
72
+ if (!api)
73
+ return { get: () => 'off', set: () => { } };
74
+ return { get: () => api.getThinkingLevel(), set: level => api.setThinkingLevel(level) };
75
+ }
60
76
  // ─── TaskRunner class ────────────────────────────────────────────────────────
61
77
  /** Encapsulates the full lifecycle of a single pi-task run. */
62
78
  export class TaskRunner {
@@ -436,20 +452,32 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
436
452
  rawPrompt,
437
453
  resumeId: opts.resumeId,
438
454
  sendSpec: async (spec) => {
439
- // Queue-or-run: never throws, whatever else is on the session (issue #8).
440
- await newCtx.sendUserMessage(spec, { deliverAs: 'followUp' });
441
- if (opts.waitForImplementation) {
442
- await newCtx.waitForIdle();
443
- // A threshold auto-compaction parks the turn at idle WITHOUT
444
- // auto-continuing, and a user ESC ends it "aborted": the
445
- // first idle is not the turn's real end. superviseImplementation
446
- // resumes across compactions, steers across interrupts, and
447
- // reads how the turn ACTUALLY ended.
448
- const outcome = await superviseImplementation(newCtx, {
449
- promptSteer: opts.promptSteer
450
- });
451
- interrupted = outcome.interrupted;
452
- implError = outcome.error;
455
+ // The implementation turn is the one "child" that is not a
456
+ // child: it runs in the user's own session, so its reasoning
457
+ // group is applied by moving pi's level and moving it back.
458
+ // The autofix re-runner (gateRunTask) re-enters runSingleTask
459
+ // and so re-enters this closure, which is why the hold lives
460
+ // here rather than at either call site.
461
+ const release = holdImplementationThinking(opts.thinkingControl ?? piThinkingControl());
462
+ try {
463
+ // Queue-or-run: never throws, whatever else is on the session (issue #8).
464
+ await newCtx.sendUserMessage(spec, { deliverAs: 'followUp' });
465
+ if (opts.waitForImplementation) {
466
+ await newCtx.waitForIdle();
467
+ // A threshold auto-compaction parks the turn at idle WITHOUT
468
+ // auto-continuing, and a user ESC ends it "aborted": the
469
+ // first idle is not the turn's real end. superviseImplementation
470
+ // resumes across compactions, steers across interrupts, and
471
+ // reads how the turn ACTUALLY ended.
472
+ const outcome = await superviseImplementation(newCtx, {
473
+ promptSteer: opts.promptSteer
474
+ });
475
+ interrupted = outcome.interrupted;
476
+ implError = outcome.error;
477
+ }
478
+ }
479
+ finally {
480
+ release();
453
481
  }
454
482
  },
455
483
  spawnFn: opts.spawnFn,