@mjasnikovs/pi-task 0.38.31 → 0.38.32

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 (59) hide show
  1. package/README.md +2 -1
  2. package/dist/config/config.d.ts +16 -2
  3. package/dist/config/config.js +7 -2
  4. package/dist/config/group-args.d.ts +52 -0
  5. package/dist/config/group-args.js +110 -0
  6. package/dist/config/group-models.d.ts +88 -0
  7. package/dist/config/group-models.js +117 -0
  8. package/dist/config/groups.d.ts +76 -0
  9. package/dist/config/groups.js +110 -0
  10. package/dist/config/option-picker.d.ts +42 -0
  11. package/dist/config/option-picker.js +73 -0
  12. package/dist/config/reasoning.d.ts +22 -63
  13. package/dist/config/reasoning.js +37 -108
  14. package/dist/config/register.d.ts +98 -12
  15. package/dist/config/register.js +228 -23
  16. package/dist/index.js +2 -0
  17. package/dist/remote/push.js +1 -7
  18. package/dist/shared/data-home.d.ts +8 -0
  19. package/dist/shared/data-home.js +14 -0
  20. package/dist/shared/model-endpoint.d.ts +53 -0
  21. package/dist/shared/model-endpoint.js +98 -2
  22. package/dist/shared/reasoning-capability.d.ts +25 -5
  23. package/dist/shared/reasoning-capability.js +18 -9
  24. package/dist/task/child-runner.d.ts +19 -16
  25. package/dist/task/child-runner.js +64 -36
  26. package/dist/task/context-usage.d.ts +46 -0
  27. package/dist/task/context-usage.js +41 -0
  28. package/dist/task/gate-child.d.ts +15 -4
  29. package/dist/task/gate-child.js +2 -2
  30. package/dist/task/gate-deps.js +7 -2
  31. package/dist/task/implementation-hold.d.ts +118 -0
  32. package/dist/task/implementation-hold.js +165 -0
  33. package/dist/task/model-hold-stash.d.ts +43 -0
  34. package/dist/task/model-hold-stash.js +70 -0
  35. package/dist/task/orchestrator.d.ts +18 -5
  36. package/dist/task/orchestrator.js +36 -4
  37. package/dist/task/phases.js +2 -2
  38. package/dist/task/research-worker.d.ts +2 -2
  39. package/dist/task/research-worker.js +1 -1
  40. package/dist/workers/docs-core.js +2 -2
  41. package/dist/workers/docs-lookup.d.ts +4 -3
  42. package/dist/workers/docs-lookup.js +1 -1
  43. package/dist/workers/fetch-core.js +2 -2
  44. package/dist/workers/focused-extractor.d.ts +4 -3
  45. package/dist/workers/focused-extractor.js +5 -4
  46. package/dist/workers/index.js +2 -0
  47. package/dist/workers/model-warning.d.ts +69 -0
  48. package/dist/workers/model-warning.js +113 -0
  49. package/dist/workers/pi-worker-core.d.ts +7 -7
  50. package/dist/workers/pi-worker-core.js +4 -3
  51. package/dist/workers/pi-worker-docs.js +2 -2
  52. package/dist/workers/pi-worker.js +4 -4
  53. package/dist/workers/reasoning-warning.d.ts +17 -9
  54. package/dist/workers/reasoning-warning.js +69 -22
  55. package/package.json +1 -1
  56. package/dist/config/reasoning-args.d.ts +0 -23
  57. package/dist/config/reasoning-args.js +0 -28
  58. package/dist/task/implementation-thinking.d.ts +0 -56
  59. package/dist/task/implementation-thinking.js +0 -32
@@ -30,7 +30,7 @@
30
30
  * line-for-line identical to it, ladder included. Nothing here imports pi-ai, so
31
31
  * an upstream change will not fail a test — the two have to be re-compared.
32
32
  */
33
- import { type ReasoningGroup, type GroupSetting } from '../config/reasoning.js';
33
+ import { type ChildGroup, type GroupSetting } from '../config/reasoning.js';
34
34
  /**
35
35
  * pi's own level ladder, in order — the same seven names, in the same sequence,
36
36
  * as `EXTENDED_THINKING_LEVELS` in pi-ai's models.js. The order IS the algorithm:
@@ -67,16 +67,36 @@ export declare function supportedThinkingLevels(model: ReasoningModelFacts): Lad
67
67
  * the model supports it — which is what makes the inequality a mismatch test.
68
68
  */
69
69
  export declare function clampToModel(model: ReasoningModelFacts, level: LadderLevel): LadderLevel;
70
- /** One group whose configured setting the connected model will not honour. */
70
+ /** One group whose configured setting the model it runs on will not honour. */
71
71
  export interface ReasoningMismatch {
72
- group: ReasoningGroup;
72
+ group: ChildGroup;
73
+ /**
74
+ * The model THIS GROUP runs on. Per-item, not per-warning, because groups no
75
+ * longer share one model: a line that opens `model "X" will not run …` while
76
+ * listing groups that run on Y is itself a lie about what it checked.
77
+ */
78
+ modelName: string;
73
79
  /** What /task-config says. Never `inherit` — an inherited group asks for nothing. */
74
80
  wanted: LadderLevel;
75
81
  /** What pi will send instead. */
76
82
  actual: LadderLevel;
77
83
  }
84
+ /** What a group runs on, as much of it as this check needs. */
85
+ export interface GroupModelFacts extends ReasoningModelFacts {
86
+ /** What to call it in the warning. */
87
+ name: string;
88
+ /** Where it is served from, for the `/props` probe. Absent ⇒ not probeable. */
89
+ baseUrl?: string;
90
+ }
78
91
  /**
79
- * Every group whose setting the model will silently change.
92
+ * Every group whose setting the model IT RUNS ON will silently change.
93
+ *
94
+ * `modelFor` is a FUNCTION rather than a `Record`, for two reasons: a record
95
+ * would build eleven identical entries for the overwhelmingly common
96
+ * all-`inherit` case, and a function is drivable from a test with two literals.
97
+ * It answers `undefined` for a group whose model cannot be resolved — nothing is
98
+ * reported for those, because the run degrades to the session default and the
99
+ * separate model hint is what names them.
80
100
  *
81
101
  * `inherit` groups are skipped entirely, because an inherited group asks for
82
102
  * nothing. That is not the same as a quiet default: the shipped table is mostly
@@ -91,4 +111,4 @@ export interface ReasoningMismatch {
91
111
  * comparison. Warning about one direction while staying silent about the other
92
112
  * would ship this feature unable to see its own failure mode.
93
113
  */
94
- export declare function reasoningMismatches(model: ReasoningModelFacts | undefined, levels: Readonly<Record<ReasoningGroup, GroupSetting>>): ReasoningMismatch[];
114
+ export declare function reasoningMismatches(modelFor: (group: ChildGroup) => GroupModelFacts | undefined, levels: Readonly<Record<ChildGroup, GroupSetting>>): ReasoningMismatch[];
@@ -30,7 +30,7 @@
30
30
  * line-for-line identical to it, ladder included. Nothing here imports pi-ai, so
31
31
  * an upstream change will not fail a test — the two have to be re-compared.
32
32
  */
33
- import { REASONING_GROUPS } from '../config/reasoning.js';
33
+ import { CHILD_GROUPS } from '../config/reasoning.js';
34
34
  /**
35
35
  * pi's own level ladder, in order — the same seven names, in the same sequence,
36
36
  * as `EXTENDED_THINKING_LEVELS` in pi-ai's models.js. The order IS the algorithm:
@@ -87,7 +87,14 @@ export function clampToModel(model, level) {
87
87
  return available[0] ?? 'off';
88
88
  }
89
89
  /**
90
- * Every group whose setting the model will silently change.
90
+ * Every group whose setting the model IT RUNS ON will silently change.
91
+ *
92
+ * `modelFor` is a FUNCTION rather than a `Record`, for two reasons: a record
93
+ * would build eleven identical entries for the overwhelmingly common
94
+ * all-`inherit` case, and a function is drivable from a test with two literals.
95
+ * It answers `undefined` for a group whose model cannot be resolved — nothing is
96
+ * reported for those, because the run degrades to the session default and the
97
+ * separate model hint is what names them.
91
98
  *
92
99
  * `inherit` groups are skipped entirely, because an inherited group asks for
93
100
  * nothing. That is not the same as a quiet default: the shipped table is mostly
@@ -102,19 +109,21 @@ export function clampToModel(model, level) {
102
109
  * comparison. Warning about one direction while staying silent about the other
103
110
  * would ship this feature unable to see its own failure mode.
104
111
  */
105
- export function reasoningMismatches(model, levels) {
106
- // No model resolved yet (session still starting, or none selected): say
107
- // nothing. A warning naming no model is noise, not information.
108
- if (!model)
109
- return [];
112
+ export function reasoningMismatches(modelFor, levels) {
110
113
  const out = [];
111
- for (const group of REASONING_GROUPS) {
114
+ for (const group of CHILD_GROUPS) {
112
115
  const wanted = levels[group];
113
116
  if (wanted === 'inherit')
114
117
  continue;
118
+ // No model resolved for this group (session still starting, none
119
+ // selected, or a spec this machine cannot resolve): say nothing. A
120
+ // warning naming no model is noise, not information.
121
+ const model = modelFor(group);
122
+ if (!model)
123
+ continue;
115
124
  const actual = clampToModel(model, wanted);
116
125
  if (actual !== wanted)
117
- out.push({ group, wanted, actual });
126
+ out.push({ group, modelName: model.name, wanted, actual });
118
127
  }
119
128
  return out;
120
129
  }
@@ -63,10 +63,9 @@ export declare function guardKillError(name: string, r: PhaseRunResult, opts?: {
63
63
  * The dead-backend probe killed a phase child on its LAST attempt.
64
64
  *
65
65
  * Reaching this means every attempt found no endpoint answering, not one. The
66
- * single-probe verdict is not trusted on its own: `discoverModelEndpoints` reads
67
- * every provider in models.json rather than the one this child's model uses, so a
68
- * stopped local server can condemn a run against a healthy cloud backend. Three
69
- * failed probes cost ~15s; one wrong verdict costs the run.
66
+ * single-probe verdict is not trusted on its own, because one sample cannot tell
67
+ * a dead server from a blip. Three failed probes cost ~15s; one wrong verdict
68
+ * costs the run.
70
69
  */
71
70
  export declare class BackendDownError extends Error {
72
71
  readonly childName: string;
@@ -125,12 +124,16 @@ export interface PhaseRunResult {
125
124
  }
126
125
  export declare function childArgs(tools: string, extensions?: readonly string[],
127
126
  /**
128
- * An already-resolved `['--thinking', level]`, or `[]` for "emit no flag".
129
- * Resolved by the CALLER, never here: the level is a property of the child's
130
- * ROLE, and this function is handed tools and extensions, not a name.
131
- * Omitted ⇒ byte-identical argv to the version before reasoning profiles.
127
+ * This child's group fragment: `--model` then `--thinking`, either half
128
+ * possibly absent. Resolved by the CALLER, never here both are properties
129
+ * of the child's ROLE, and this function is handed tools and extensions, not
130
+ * a name. Omitted ⇒ byte-identical argv to the version before group profiles.
131
+ *
132
+ * ONE field rather than a `model` beside a `thinking`, because nothing may
133
+ * compose the two halves by hand: `groupChildArgs` is the only producer, so a
134
+ * doubled `--thinking` is unreachable rather than merely unlikely.
132
135
  */
133
- thinking?: readonly string[]): string[];
136
+ groupArgs?: readonly string[]): string[];
134
137
  export declare const USER_CANCELLED = "__user_cancelled__";
135
138
  /**
136
139
  * Run a child pi process with JSON event-stream output, loop detection, and
@@ -175,10 +178,10 @@ export interface ChildRun {
175
178
  */
176
179
  contextWindow?: number;
177
180
  /**
178
- * The resolved `['--thinking', level]` fragment for this child's reasoning
179
- * group, or `[]`/omitted to inherit the session default as before.
181
+ * The resolved argv fragment for this child's group — `--model` then
182
+ * `--thinking` or `[]`/omitted to inherit both defaults as before.
180
183
  */
181
- thinking?: readonly string[];
184
+ groupArgs?: readonly string[];
182
185
  /**
183
186
  * This attempt's per-command ceiling, already halved for prior hangs by the
184
187
  * caller's strike loop. Omitted -> the `phase` row's full configured ceiling,
@@ -195,7 +198,7 @@ export interface ChildRun {
195
198
  * one — the mislabel class runDegradedFinalAttempt's own comment warns about.
196
199
  */
197
200
  export declare function phasePolicy(): WorkerGuardPolicy;
198
- export declare function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, thinking, commandCeilingMs }: ChildRun): Promise<PhaseRunResult>;
201
+ export declare function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, groupArgs, commandCeilingMs }: ChildRun): Promise<PhaseRunResult>;
199
202
  export interface PhaseDeps {
200
203
  cwd: string;
201
204
  taskId: string;
@@ -337,15 +340,15 @@ export type PhaseSeams = Omit<PhaseDeps, 'cwd' | 'taskId' | 'signal' | 'onChildO
337
340
  * exit status describes our SIGTERM and says nothing about its verdict.
338
341
  */
339
342
  /**
340
- * The `--thinking` fragment for a named child, or `[]` when the name is unmapped.
343
+ * The group fragment for a named child, or `[]` when the name is unmapped.
341
344
  *
342
345
  * An unmapped name INHERITS rather than throwing: a child that reaches the model
343
346
  * with today's argv is always safe, and aborting a user's task over a missing
344
347
  * table row would be a worse failure than the one it reports. The guard that
345
- * makes the table complete is `reasoning-groups.test.ts`, which fails the BUILD —
348
+ * makes the table complete is `config/groups.test.ts`, which fails the BUILD —
346
349
  * where someone can actually fix it.
347
350
  */
348
- export declare function thinkingForChild(name: string): string[];
351
+ export declare function groupArgsForChild(name: string): string[];
349
352
  export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string, opts?: PhaseChildOptions): Promise<string>;
350
353
  export declare function formatLoopHint(hit: LoopHit): string;
351
354
  /**
@@ -15,14 +15,15 @@ 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 { commandCeilingForAttempt, commandTimeoutHint, commandWatch } from '../shared/command-watchdog.js';
18
- import { discoverModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
18
+ import { childModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
19
+ import { modelSpecFromArgs } from '../config/group-models.js';
19
20
  // VALUE import, and it is only safe because worker-profiles.ts reads its loop
20
21
  // constants from loop-detector.ts. Point those back at this file and the graph
21
22
  // closes into a TDZ ReferenceError that no compile step catches.
22
23
  import { workerPolicy } from '../workers/worker-profiles.js';
23
24
  import { getConfig } from '../config/config.js';
24
- import { groupThinkingArgs } from '../config/reasoning-args.js';
25
- import { reasoningGroupForChild } from '../config/reasoning.js';
25
+ import { groupChildArgs, groupWindow } from '../config/group-args.js';
26
+ import { groupForChild } from '../config/groups.js';
26
27
  // ─── Phase-child wall-clock cap ──────────────────────────────────────────────
27
28
  /**
28
29
  * Optional wall-clock bound on ONE spawn of a phase child. DEFAULT: OFF.
@@ -115,10 +116,11 @@ export function guardKillError(name, r, opts = {}) {
115
116
  if (r.commandKill)
116
117
  return new CommandTimeoutError(name, r.commandKill);
117
118
  // A dead-backend verdict is only trusted once every attempt has produced it.
118
- // `discoverModelEndpoints` reads EVERY provider in models.json, not the one
119
- // this child's model uses, so a stopped local server can condemn a run against
120
- // a healthy cloud backend. The asymmetry settles it: a backend that really is
121
- // down costs three 5s probes, a wrong verdict costs the whole run.
119
+ // The probe now asks about this child's own endpoint rather than ORing over
120
+ // every provider, so it is exact but it is still one network call at one
121
+ // instant, and a blip is indistinguishable from a death in a single sample.
122
+ // The asymmetry settles it: a backend that really is down costs three 5s
123
+ // probes, a wrong verdict costs the whole run.
122
124
  if (r.stalled)
123
125
  return opts.finalAttempt === false ? null : new BackendDownError(name);
124
126
  return null;
@@ -127,10 +129,9 @@ export function guardKillError(name, r, opts = {}) {
127
129
  * The dead-backend probe killed a phase child on its LAST attempt.
128
130
  *
129
131
  * Reaching this means every attempt found no endpoint answering, not one. The
130
- * single-probe verdict is not trusted on its own: `discoverModelEndpoints` reads
131
- * every provider in models.json rather than the one this child's model uses, so a
132
- * stopped local server can condemn a run against a healthy cloud backend. Three
133
- * failed probes cost ~15s; one wrong verdict costs the run.
132
+ * single-probe verdict is not trusted on its own, because one sample cannot tell
133
+ * a dead server from a blip. Three failed probes cost ~15s; one wrong verdict
134
+ * costs the run.
134
135
  */
135
136
  export class BackendDownError extends Error {
136
137
  childName;
@@ -246,12 +247,16 @@ const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
246
247
  // ─── Spawn helpers ───────────────────────────────────────────────────────────
247
248
  export function childArgs(tools, extensions = [],
248
249
  /**
249
- * An already-resolved `['--thinking', level]`, or `[]` for "emit no flag".
250
- * Resolved by the CALLER, never here: the level is a property of the child's
251
- * ROLE, and this function is handed tools and extensions, not a name.
252
- * Omitted ⇒ byte-identical argv to the version before reasoning profiles.
250
+ * This child's group fragment: `--model` then `--thinking`, either half
251
+ * possibly absent. Resolved by the CALLER, never here both are properties
252
+ * of the child's ROLE, and this function is handed tools and extensions, not
253
+ * a name. Omitted ⇒ byte-identical argv to the version before group profiles.
254
+ *
255
+ * ONE field rather than a `model` beside a `thinking`, because nothing may
256
+ * compose the two halves by hand: `groupChildArgs` is the only producer, so a
257
+ * doubled `--thinking` is unreachable rather than merely unlikely.
253
258
  */
254
- thinking = []) {
259
+ groupArgs = []) {
255
260
  // `--mode json` puts the child into the structured event stream the
256
261
  // unified runner parses in `mode: 'json-events'`. Without it the child
257
262
  // emits plain text, every line fails JSON.parse, finalText stays empty,
@@ -273,7 +278,7 @@ thinking = []) {
273
278
  // one — the guards all hang off pi's `tool_call` hook.
274
279
  const toolFlags = tools === '' ? ['--no-tools'] : ['--tools', tools];
275
280
  const internal = tools === '' ? [] : extensions;
276
- return [...childBaseArgs(internal), ...thinking, '--mode', 'json', ...toolFlags];
281
+ return [...childBaseArgs(internal), ...groupArgs, '--mode', 'json', ...toolFlags];
277
282
  }
278
283
  // Sentinel error thrown when the user dismisses a grill-me dialog.
279
284
  // Defined here (not in failure-classifier.ts) to avoid circular dependency.
@@ -292,8 +297,8 @@ export function phasePolicy() {
292
297
  streamInactivityMs: getConfig().streamInactivityMs
293
298
  });
294
299
  }
295
- export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, thinking, commandCeilingMs }) {
296
- const invocation = getPiInvocation(childArgs(tools, extensions, thinking), prompt);
300
+ export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, groupArgs, commandCeilingMs }) {
301
+ const invocation = getPiInvocation(childArgs(tools, extensions, groupArgs), prompt);
297
302
  let loopHit;
298
303
  const guards = phasePolicy().guards;
299
304
  // Null when the user set the ceiling to `off`. Why a phase child needs this at
@@ -315,7 +320,7 @@ export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUs
315
320
  stall: {
316
321
  afterMs: guards.stalled.afterMs,
317
322
  probe: guards.stalled.probe
318
- ?? (() => probeModelEndpoints(discoverModelEndpoints()))
323
+ ?? (() => probeModelEndpoints(childModelEndpoints(modelSpecFromArgs(groupArgs ?? []))))
319
324
  }
320
325
  }),
321
326
  onLine,
@@ -461,17 +466,17 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
461
466
  * exit status describes our SIGTERM and says nothing about its verdict.
462
467
  */
463
468
  /**
464
- * The `--thinking` fragment for a named child, or `[]` when the name is unmapped.
469
+ * The group fragment for a named child, or `[]` when the name is unmapped.
465
470
  *
466
471
  * An unmapped name INHERITS rather than throwing: a child that reaches the model
467
472
  * with today's argv is always safe, and aborting a user's task over a missing
468
473
  * table row would be a worse failure than the one it reports. The guard that
469
- * makes the table complete is `reasoning-groups.test.ts`, which fails the BUILD —
474
+ * makes the table complete is `config/groups.test.ts`, which fails the BUILD —
470
475
  * where someone can actually fix it.
471
476
  */
472
- export function thinkingForChild(name) {
473
- const group = reasoningGroupForChild(name);
474
- return group ? groupThinkingArgs(group) : [];
477
+ export function groupArgsForChild(name) {
478
+ const group = groupForChild(name);
479
+ return group ? groupChildArgs(group) : [];
475
480
  }
476
481
  /**
477
482
  * What a PHASE child's invocation carries, said once.
@@ -482,14 +487,29 @@ export function thinkingForChild(name) {
482
487
  * which is what the degrade's own comment ("the degrade changes the TOOLS, not
483
488
  * the role") claimed while three bare `undefined`s quietly made it false.
484
489
  */
485
- function phaseChildRun(deps, over) {
490
+ /**
491
+ * The context window a child of this group runs against.
492
+ *
493
+ * The GROUP's window when this session resolved one, else the run's. Too small a
494
+ * window makes the churn rule fire early and kill a healthy child, so an
495
+ * `inherit` or unresolved group keeps the parent's number rather than a guess.
496
+ */
497
+ function childContextWindow(deps, group) {
498
+ return (group === undefined ? undefined : groupWindow(group)) ?? deps.contextWindow;
499
+ }
500
+ function phaseChildRun(deps,
501
+ /** This child's group, for the window. Undefined ⇒ the run's own window. */
502
+ group, over) {
486
503
  return {
487
504
  cwd: deps.cwd,
488
505
  onLine: deps.onChildOutput,
489
506
  onContextUsage: deps.onContextUsage,
490
507
  spawn: deps.spawn,
491
508
  extensions: deps.childExtensions,
492
- contextWindow: deps.contextWindow,
509
+ // The GROUP's window when this session resolved one, else the run's.
510
+ // Too small a window makes the churn rule fire early and kill a healthy
511
+ // child, so an unresolved group keeps the parent's number.
512
+ contextWindow: (group === undefined ? undefined : groupWindow(group)) ?? deps.contextWindow,
493
513
  ...over
494
514
  };
495
515
  }
@@ -500,8 +520,9 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
500
520
  // between a loop-kill and its retry would otherwise make the two attempts
501
521
  // different experiments, and the retry exists to repeat the first one with a
502
522
  // hint. An unmapped name inherits, which is today's argv — the build-time
503
- // guard for that is reasoning-groups.test.ts, not a throw in a user's run.
504
- const thinking = thinkingForChild(name);
523
+ // guard for that is config/groups.test.ts, not a throw in a user's run.
524
+ const groupArgs = groupArgsForChild(name);
525
+ const group = groupForChild(name);
505
526
  const verb = opts.verb ?? 'retry';
506
527
  let hint = null;
507
528
  const loopHistory = [];
@@ -528,15 +549,21 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
528
549
  // context WINDOW, so a detector that waited to be told one would sit at 0,
529
550
  // and the churn rule returns false on a non-positive window. The parent
530
551
  // knows the value at spawn time — say it then, not later.
531
- stall?.noteContext(deps.contextWindow ?? 0);
552
+ //
553
+ // The SAME number the child is handed below. Arming it from the run's
554
+ // window while the child runs on a bigger model's would judge the child
555
+ // against a window it does not have, for exactly the stretch before the
556
+ // first `context_usage` event corrects it — which is the stretch this
557
+ // line exists to cover.
558
+ stall?.noteContext(childContextWindow(deps, group) ?? 0);
532
559
  const clock = phaseTimeout(deps.signal, budgetMs);
533
560
  let r;
534
561
  try {
535
- r = await runChild(phaseChildRun(deps, {
562
+ r = await runChild(phaseChildRun(deps, group, {
536
563
  tools,
537
564
  prompt: prependHint(hint, prompt),
538
565
  signal: clock.signal,
539
- thinking,
566
+ groupArgs,
540
567
  onContextUsage: snapshot => {
541
568
  // Real window or nothing: `noteContext` ignores 0, which is
542
569
  // why the parent's value must be supplied at spawn — a
@@ -691,14 +718,15 @@ async function runDegradedFinalAttempt(deps, name, prompt, hit, loopHistory) {
691
718
  const clock = phaseTimeout(deps.signal, deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS);
692
719
  let r;
693
720
  try {
694
- r = await runChild(phaseChildRun(deps, {
721
+ r = await runChild(phaseChildRun(deps, groupForChild(name), {
695
722
  tools: '', // --no-tools: the model cannot read/grep/list, only answer
696
723
  prompt: prependHint(formatDegradeHint(hit), prompt),
697
724
  signal: clock.signal,
698
725
  // Same group as the attempts that led here. The degrade changes the
699
- // TOOLS, not the role — running it at a different thinking level would
700
- // make the fallback a different experiment from the thing it rescues.
701
- thinking: thinkingForChild(name)
726
+ // TOOLS, not the role — running it on a different model, or at a
727
+ // different thinking level, would make the fallback a different
728
+ // experiment from the thing it rescues.
729
+ groupArgs: groupArgsForChild(name)
702
730
  }));
703
731
  }
704
732
  finally {
@@ -5,6 +5,8 @@
5
5
  * directly, because its state is the whole-run `WidgetState`, not one child's.
6
6
  */
7
7
  import type { ContextSnapshot } from '../shared/child-process.js';
8
+ import { type PiTaskConfig } from '../config/config.js';
9
+ import type { ChildGroup } from '../config/groups.js';
8
10
  /**
9
11
  * The parent session's context window, or 0 when the model doesn't expose it.
10
12
  *
@@ -22,6 +24,50 @@ export declare function getParentContextWindow(ctx: {
22
24
  contextWindow?: number;
23
25
  };
24
26
  }): number;
27
+ /**
28
+ * The window for the model ONE GROUP's children will actually run on.
29
+ *
30
+ * This number drives the widget and, more importantly, `StallDetector`'s
31
+ * context-churn rule, and the two error directions are not symmetric. A parent
32
+ * window LARGER than the child's makes churn fire late — degraded, and the
33
+ * no-new-ground rule still covers it. A parent window SMALLER makes churn fire
34
+ * early and KILL A HEALTHY CHILD. A big-context research model under a small
35
+ * host model is a real false positive, which is why this exists at all.
36
+ *
37
+ * For the same reason there is no `min(parent, group)`: that would import the
38
+ * dangerous direction on purpose.
39
+ *
40
+ * `inherit`, an unresolvable spec, or a model with no declared window all return
41
+ * exactly `getParentContextWindow(ctx)` — byte-identical to the behaviour before
42
+ * per-group models existed.
43
+ *
44
+ * Callers WITHOUT a ctx read `groupWindow` from config/group-args.ts instead,
45
+ * which the session pass fills from this. One producer, so the two views cannot
46
+ * describe different models.
47
+ */
48
+ export declare function contextWindowForGroup(ctx: {
49
+ model?: {
50
+ contextWindow?: number;
51
+ };
52
+ modelRegistry?: {
53
+ find: (p: string, i: string) => unknown;
54
+ };
55
+ }, group: ChildGroup, cfg?: PiTaskConfig): number;
56
+ /**
57
+ * The same answer for a spec the caller already has.
58
+ *
59
+ * The session pass needs this: it walks an INJECTED spec table, and reaching for
60
+ * `getConfig()` here would let the window it stores describe a different model
61
+ * from the one it just checked.
62
+ */
63
+ export declare function contextWindowForSpec(ctx: {
64
+ model?: {
65
+ contextWindow?: number;
66
+ };
67
+ modelRegistry?: {
68
+ find: (p: string, i: string) => unknown;
69
+ };
70
+ }, spec: string): number;
25
71
  /**
26
72
  * Fold a raw context snapshot into a display snapshot: prefer the child's own
27
73
  * contextWindow, else the last known one, else the parent session's; then derive
@@ -4,6 +4,8 @@
4
4
  * the planning and gate children; the single-task widget (TaskRunner) calls it
5
5
  * directly, because its state is the whole-run `WidgetState`, not one child's.
6
6
  */
7
+ import { getConfig } from '../config/config.js';
8
+ import { MODEL_INHERIT, splitSpec } from '../config/group-models.js';
7
9
  /**
8
10
  * The parent session's context window, or 0 when the model doesn't expose it.
9
11
  *
@@ -19,6 +21,45 @@
19
21
  export function getParentContextWindow(ctx) {
20
22
  return ctx.model?.contextWindow ?? 0;
21
23
  }
24
+ /**
25
+ * The window for the model ONE GROUP's children will actually run on.
26
+ *
27
+ * This number drives the widget and, more importantly, `StallDetector`'s
28
+ * context-churn rule, and the two error directions are not symmetric. A parent
29
+ * window LARGER than the child's makes churn fire late — degraded, and the
30
+ * no-new-ground rule still covers it. A parent window SMALLER makes churn fire
31
+ * early and KILL A HEALTHY CHILD. A big-context research model under a small
32
+ * host model is a real false positive, which is why this exists at all.
33
+ *
34
+ * For the same reason there is no `min(parent, group)`: that would import the
35
+ * dangerous direction on purpose.
36
+ *
37
+ * `inherit`, an unresolvable spec, or a model with no declared window all return
38
+ * exactly `getParentContextWindow(ctx)` — byte-identical to the behaviour before
39
+ * per-group models existed.
40
+ *
41
+ * Callers WITHOUT a ctx read `groupWindow` from config/group-args.ts instead,
42
+ * which the session pass fills from this. One producer, so the two views cannot
43
+ * describe different models.
44
+ */
45
+ export function contextWindowForGroup(ctx, group, cfg = getConfig()) {
46
+ return contextWindowForSpec(ctx, cfg.groupModels[group]);
47
+ }
48
+ /**
49
+ * The same answer for a spec the caller already has.
50
+ *
51
+ * The session pass needs this: it walks an INJECTED spec table, and reaching for
52
+ * `getConfig()` here would let the window it stores describe a different model
53
+ * from the one it just checked.
54
+ */
55
+ export function contextWindowForSpec(ctx, spec) {
56
+ if (spec === MODEL_INHERIT)
57
+ return getParentContextWindow(ctx);
58
+ const parts = splitSpec(spec);
59
+ const found = parts && ctx.modelRegistry?.find(parts.provider, parts.id);
60
+ const window = found?.contextWindow ?? 0;
61
+ return window > 0 ? window : getParentContextWindow(ctx);
62
+ }
22
63
  /**
23
64
  * Fold a raw context snapshot into a display snapshot: prefer the child's own
24
65
  * contextWindow, else the last known one, else the parent session's; then derive
@@ -75,15 +75,26 @@ export interface GateChildDeps {
75
75
  /** Hung-stream bound; the probe-based stall guard cannot supply it. */
76
76
  streamInactivityMs: number;
77
77
  /**
78
- * The resolved `['--thinking', level]` fragment for the `gate` reasoning
79
- * group, or `[]` to inherit the session default.
78
+ * The resolved argv fragment for the `gate` group — its model and its
79
+ * thinking level — or `[]` to inherit both.
80
80
  *
81
81
  * REQUIRED, like its two neighbours above: gate-child takes resolved config
82
82
  * values and gate-deps supplies them. Optional-with-a-default would let a new
83
- * gate wiring silently run at a level nobody chose, which is the failure the
83
+ * gate wiring silently run on a model nobody chose, which is the failure the
84
84
  * whole profile feature exists to end.
85
85
  */
86
- thinking: readonly string[];
86
+ groupArgs: readonly string[];
87
+ /**
88
+ * The context window of the model THESE children run on, for the churn rule.
89
+ *
90
+ * Not `status.parentContextWindow`, which is a per-RUN value and a run spans
91
+ * several groups. The direction matters: a window smaller than the child's
92
+ * real one makes churn fire early and kill a healthy child, so this follows
93
+ * the `gate` group's model and falls back to the host's.
94
+ *
95
+ * REQUIRED, like its neighbours: gate-child takes resolved config values.
96
+ */
97
+ contextWindow: number;
87
98
  /**
88
99
  * The live widget state this child feeds and its loader reads. SHARED with
89
100
  * the caller — the verify gate's own loader reads the same status while this
@@ -98,7 +98,7 @@ export function makeGateChild(deps) {
98
98
  commandTimeoutMs: deps.commandTimeoutMs,
99
99
  streamInactivityMs: deps.streamInactivityMs
100
100
  },
101
- thinking: deps.thinking,
101
+ groupArgs: deps.groupArgs,
102
102
  // A discarded attempt is otherwise invisible: the returned
103
103
  // exitCode/text describe the FINAL attempt, so a child that
104
104
  // burned two attempts reads exactly like one that ran clean.
@@ -125,7 +125,7 @@ export function makeGateChild(deps) {
125
125
  // stream — what `--mode json` emits — carries token counts but
126
126
  // no context window; `contextWindow` appears nowhere in
127
127
  // agent-session.d.ts.
128
- contextWindow: deps.status.parentContextWindow
128
+ contextWindow: deps.contextWindow
129
129
  });
130
130
  }
131
131
  finally {
@@ -48,7 +48,8 @@ import { assessRunnerGlobs, runnerGlobVerifyFindings } from './runner-globs.js';
48
48
  import { captureGitState, reconcileGitState } from './git-state-guard.js';
49
49
  import { runWorker } from '../workers/pi-worker-core.js';
50
50
  import { getConfig } from '../config/config.js';
51
- import { groupThinkingArgs } from '../config/reasoning-args.js';
51
+ import { groupChildArgs } from '../config/group-args.js';
52
+ import { contextWindowForGroup } from './context-usage.js';
52
53
  import { makeDebugAppender } from './debug-log.js';
53
54
  import { startAutoLoader } from './widget.js';
54
55
  import { ChildStatus } from './child-status.js';
@@ -602,7 +603,11 @@ export function buildGateDeps(params) {
602
603
  streamInactivityMs: getConfig().streamInactivityMs,
603
604
  // Read per gateChild() call, like its two neighbours, so a
604
605
  // /task-config change lands on the next gate without a restart.
605
- thinking: groupThinkingArgs('gate'),
606
+ groupArgs: groupChildArgs('gate'),
607
+ // `gateCtx` rather than the run's own window: a run spans several
608
+ // groups, and a window smaller than the child's real one makes the
609
+ // churn rule fire early and kill a healthy child.
610
+ contextWindow: contextWindowForGroup(gateCtx, 'gate'),
606
611
  status,
607
612
  runWorker,
608
613
  makeDebugAppender,