@mjasnikovs/pi-task 0.38.32 → 0.39.1

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 (54) hide show
  1. package/README.md +2 -2
  2. package/dist/config/group-args.d.ts +24 -9
  3. package/dist/config/group-args.js +38 -28
  4. package/dist/config/option-picker.d.ts +39 -11
  5. package/dist/config/option-picker.js +52 -12
  6. package/dist/config/reasoning.d.ts +10 -7
  7. package/dist/config/reasoning.js +19 -32
  8. package/dist/config/register.d.ts +66 -41
  9. package/dist/config/register.js +201 -162
  10. package/dist/shared/child-process.d.ts +34 -32
  11. package/dist/shared/child-process.js +44 -58
  12. package/dist/shared/command-watchdog.d.ts +12 -4
  13. package/dist/shared/command-watchdog.js +6 -7
  14. package/dist/shared/connection-error.d.ts +7 -0
  15. package/dist/shared/connection-error.js +65 -0
  16. package/dist/shared/model-endpoint.d.ts +12 -24
  17. package/dist/shared/model-endpoint.js +32 -82
  18. package/dist/shared/model-resolve.d.ts +105 -0
  19. package/dist/shared/model-resolve.js +97 -0
  20. package/dist/shared/reasoning-capability.d.ts +20 -0
  21. package/dist/shared/reasoning-capability.js +32 -1
  22. package/dist/shared/stall-probe.d.ts +51 -0
  23. package/dist/shared/stall-probe.js +79 -0
  24. package/dist/task/child-runner.d.ts +76 -278
  25. package/dist/task/child-runner.js +186 -722
  26. package/dist/task/context-usage.js +2 -7
  27. package/dist/task/failure-classifier.js +53 -81
  28. package/dist/task/gate-child.js +1 -1
  29. package/dist/task/impl-widget.d.ts +2 -0
  30. package/dist/task/impl-widget.js +4 -0
  31. package/dist/task/implementation-hold.d.ts +11 -0
  32. package/dist/task/implementation-hold.js +20 -0
  33. package/dist/task/implementation-scope.d.ts +24 -0
  34. package/dist/task/implementation-scope.js +34 -0
  35. package/dist/task/loop-detector.d.ts +13 -5
  36. package/dist/task/loop-detector.js +11 -5
  37. package/dist/task/model-hold-stash.js +4 -14
  38. package/dist/task/orchestrator.d.ts +1 -8
  39. package/dist/task/orchestrator.js +11 -34
  40. package/dist/task/phases.js +2 -2
  41. package/dist/task/stall-detector.d.ts +1 -1
  42. package/dist/task/stall-detector.js +1 -1
  43. package/dist/workers/model-warning.d.ts +4 -16
  44. package/dist/workers/model-warning.js +14 -70
  45. package/dist/workers/pi-worker-core.d.ts +65 -20
  46. package/dist/workers/pi-worker-core.js +109 -50
  47. package/dist/workers/reasoning-warning.js +2 -24
  48. package/dist/workers/worker-failure.d.ts +2 -0
  49. package/dist/workers/worker-failure.js +2 -1
  50. package/dist/workers/worker-kill.d.ts +30 -11
  51. package/dist/workers/worker-kill.js +68 -20
  52. package/dist/workers/worker-profiles.d.ts +20 -0
  53. package/dist/workers/worker-profiles.js +22 -9
  54. package/package.json +1 -1
package/README.md CHANGED
@@ -214,8 +214,8 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
214
214
  | **command timeout** | 15 min | Wall-clock ceiling on a **single** tool execution. Local models routinely run a command that never returns (a hung build, a dev server, a check with no timeout) and the run wedges until you abort by hand — pi's bash tool has an optional timeout with no default, so this is the missing one. One knob, two surfaces: in the main session the overrun call is cancelled (killing the tool's whole process tree) plus a reminder turn; in the verify/fix gate children the child is killed and re-spawned with a hint, halving the ceiling on repeat hangs. Choices: 5/10/15/30 min or **off** — off unguards both surfaces, gates included. |
215
215
  | **stuck reply retry** | 10 min | Inactivity ceiling on the **model stream**. A hung or silently-dropped stream throws nothing at all, so neither the connection-error retry (it needs a reported error) nor the **command timeout** (tool calls only) nor the dead-backend stall guard (a reachable endpoint reads as proof of life) can see it — an mx5 run lost ~2.9h to three of them while the model server stayed healthy. Measured as time since the **last stream event of any kind**, so a slow model emitting one token every 30s is never touched, and it pauses while a tool runs. On expiry the main session aborts the turn (through the same channel the command watchdog uses) and posts a resume reminder; a child is killed and routed into the existing connection-error retry. Choices: 5/10/20/30 min or **off**. Keep it generous on local backends — prompt processing on a large context legitimately emits nothing for minutes. |
216
216
  | **yolo mode** | off | **Unattended runs.** Wherever pi-task would stop and ask, it takes the option already marked RECOMMENDED, stamps the artifact `(YOLO)` so an audit can tell a machine decided, and shows no prompt at all — clarify/grill answers, the verify-FAIL picker (auto-**Accept**, recorded as a yolo debt), and the final-gate picker (autofix while the budget lasts, then leave the run FAILED). A question with no recommendation is **skipped**, never invented. For throwaway/test projects nobody is watching; a real run should decide these itself. |
217
- | **model: …** | all inherit | One row per group of steps, choosing which model that group's children run on — research and planning on a strong model, the rest on a cheap one. Offered from `pi.modelRegistry.getAvailable()`, stored as the canonical `provider/id` that pi's own `--model` takes, and picked from a filterable list rather than cycled. **inherit** emits no flag at all, so an all-inherit table is byte-identical to a build without this feature; that is the shipped default, because which models exist is a property of your machine and nothing here can be measured for you. Choosing a model NARROWS that group's **think:** row to the levels it declares, and re-clamps a level already stored pi erases a level a model cannot do, silently, so a cell offering one would be a lie. A stored model this machine cannot resolve is never erased (you may have set it on another machine): the flag is dropped, the step runs on pi's default, and a startup hint names the cell. Two rows deserve care: a provider registered by a host **extension** needs that extension enabled under **ext: …** or those children exit 1; and **model: implementation** is not free it is *your* session, switched for the turn and switched back, and a model switch re-bills the whole prompt as a cache miss, twice per task. |
218
- | **reasoning** | default | How much the helper sessions think before answering. Local models differ sharply here: some break without reasoning, some waste minutes with it, and some cannot do it at all. The eleven **think:** rows below set one level per group of steps the four research workers and their shared fallback, spec phases, /task-auto planning, /task-plan, the checking gates, the small extraction children, and the implementation turn itself. **default** uses the table pi-task has measured, **on** and **off** force one answer everywhere, and **custom** is whatever the think: rows say (changing any of them switches to custom). A step on **inherit** passes no flag at all, so it uses whatever thinking level pi itself is set to which is what every step did before this setting existed, and is what every cell of the measured table currently holds. The think: rows always show what a step *actually* runs at, not what the stored table says. Three tiers of model exist and pi-task tells them apart: full levels (`reasoning_effort` in the chat template, e.g. Qwen3.8), on/off only (e.g. Qwen3.6, Gemma 4), and none at all. pi silently CLAMPS a level a model does not declare — a level you set can be erased, and an `off` can be clamped back up to `medium` so a startup warning names each step, the model IT runs on, and the level that model will not honour. It never fires while every step is on inherit. |
217
+ | **profile** | default | How much the helper sessions think, in one word, for every step at once. Local models differ sharply here: some break without reasoning, some waste minutes with it, and some cannot do it at all. **default** uses the per-step table pi-task has measured, **on** and **off** force one answer everywhere and ignore that table, and **custom** is whatever the step rows saychanging any of them switches this to custom. A step on **inherit** passes no flag at all, so it uses whatever thinking level pi itself is set to, which is what every step did before this setting existed. |
218
+ | **steps: …** | all `inherit` | One row per group of steps, carrying BOTH dials: the model those children run on and the level they think at, shown as `level · model`. Enter walks a two-step picker model first, then level and **the level step offers only what that model declares, opening on the one that will actually run**. That is the whole point of the merge: pi silently CLAMPS a level a model cannot do (a level you set can be erased, and an `off` can be clamped back up to `medium`), so instead of discovering that later you watch the cursor land on the level you are really getting. Models are offered from `pi.modelRegistry.getAvailable()` and stored as the canonical `provider/id` that pi's own `--model` takes. **inherit** on the model half emits no flag, so an all-inherit table is byte-identical to a build without this feature; that is the shipped default, because which models exist is a property of your machine and nothing here can be measured for you. A stored model this machine cannot resolve is never erased (you may have set it on another machine): the flag is dropped, the step runs on pi's default, and a startup hint names the step. Two need care — a provider registered by a host **extension** needs that extension enabled under **ext: …** or those children exit 1; and **implementation** is not free, because it is *your* session moved for the turn and moved back, and a model switch re-bills the whole prompt as a cache miss, twice per task. |
219
219
  | **debug logs** | events | How much of a run is written to `.pi-tasks/*-debug.log`. **`events`** keeps decisions and guard actions — which phase ran, why a worker was retried, what the git-state guard restored, what a write-capable child changed on disk, why a gate returned FAIL — a few lines per task. **`full`** adds every line the child model emitted and every tool result; that's ~85% of the bytes (a real 247 KB `verify-debug.log` is 1315 lines, 521 of them tool dumps) and is what you want while actively debugging. **`off`** writes nothing. Nothing in pi-task ever reads these files back, so the setting cannot change how a run behaves — only whether you can explain it afterwards, and a log not written can't be recovered later. |
220
220
  | **watch: …** | all on | One toggle per tool in the live session, deciding whether **command timeout** applies to it. The list is discovered from `pi.getAllTools()` when the menu opens — built-ins first, then each extension's tools with the owning entry-point path in the description — so nothing is typed by hand and an uninstalled tool just stops being listed. Turn one **off** only for a tool that already owns a longer bounded, cancellable contract of its own (the guard exists because pi's `bash` has an optional timeout with *no* default — that reasoning doesn't transfer to a tool that has one). Two things to know before you do: a genuine hang in an unwatched tool is caught by nothing, since **stuck reply retry** is paused for the whole time any tool runs; and an unwatched tool is still killed as collateral if a *watched* sibling in the same turn overruns, because pi runs sibling tool calls concurrently and the abort ends the whole turn. Stored as exemptions, so the default and every tool pi-task has never seen stay guarded. |
221
221
  | **ext: …** | all off | One toggle per installed host `pi` extension, loading it into every child session by explicit path. Children otherwise run with extensions off, so a provider registered by an extension (e.g. `pi-lmstudio`) doesn't exist in them and they can't resolve the default model. Children also inherit the extension's tools and hooks, so only enable ones you trust. The list is strictly additive (discovery stays off), and an entry whose file is gone is skipped at spawn time, never fatal. |
@@ -1,21 +1,33 @@
1
1
  /**
2
- * The live-config bridge for per-group child settings: group in, argv fragment out.
2
+ * The live-config bridge for per-group child settings: group in, argv fragment
3
+ * out — plus the session's model snapshot, which the argv needs and which only a
4
+ * session_start with a `ctx` can produce.
3
5
  *
4
6
  * Separate from reasoning.ts and group-models.ts because those must take no
5
7
  * import with a runtime side effect — see their headers. The `getConfig()` read
6
8
  * lives here instead: this file imports them and nothing in config/ imports it
7
9
  * back, so the graph stays a tree.
8
10
  *
9
- * Read PER CALL, never cached at module scope, so a /task-config change lands on
10
- * the next child without a restart. Same contract `childBaseArgs` keeps.
11
+ * Config is read PER CALL, never cached at module scope, so a /task-config
12
+ * change lands on the next child without a restart. Same contract
13
+ * `childBaseArgs` keeps. The snapshot is the one deliberate exception, and it is
14
+ * a snapshot of the REGISTRY, not of config.
11
15
  */
12
16
  import { type PiTaskConfig } from './config.js';
13
17
  import type { ChildGroup } from './groups.js';
14
- export declare function setUnusableSpecs(specs: Iterable<string>): void;
15
- export declare function isSpecUsable(spec: string): boolean;
16
- export declare function setGroupWindows(windows: Readonly<Partial<Record<ChildGroup, number>>>): void;
17
- /** The group's own window, or `undefined` for "caller keeps its fallback". */
18
+ import type { GroupModelSnapshot } from '../shared/model-resolve.js';
19
+ export declare function setGroupModels(snapshot: Readonly<Partial<Record<ChildGroup, GroupModelSnapshot>>>): void;
20
+ /**
21
+ * The group's own window, or `undefined` for "caller keeps its fallback".
22
+ *
23
+ * The number drives `StallDetector`'s churn rule, where the two error directions
24
+ * are NOT symmetric: too large fires late (degraded, and the no-new-ground rule
25
+ * still covers it), too small fires early and KILLS A HEALTHY CHILD. So an
26
+ * absent answer means "use the parent's", never a guess.
27
+ */
18
28
  export declare function groupWindow(group: ChildGroup): number | undefined;
29
+ export declare function setModelEndpoints(endpoints: ReadonlyMap<string, string>): void;
30
+ export declare function modelEndpoint(spec: string): string | undefined;
19
31
  /**
20
32
  * The `['--model', spec]` fragment for a group, or `[]` for `inherit` and for a
21
33
  * spec this session proved unresolvable.
@@ -26,14 +38,17 @@ export declare function groupWindow(group: ChildGroup): number | undefined;
26
38
  * `reasoning: true` onto it, inherits the provider's default baseUrl and answers
27
39
  * at exit 0. Dropping the flag runs the same child the user got last week and
28
40
  * says so out loud; passing it runs a model nobody chose and says nothing.
41
+ *
42
+ * The verdict applies to the spec it was proven on: a cell changed since
43
+ * session_start is emitted, and pi decides.
29
44
  */
30
45
  export declare function groupModelArgs(group: ChildGroup, cfg?: PiTaskConfig): string[];
31
46
  /**
32
47
  * The `['--thinking', level]` fragment for a group, or `[]` when the group is
33
48
  * `inherit` and the child should keep falling back to settings.json.
34
49
  *
35
- * Still exported on its own: the host-session turn (implementation-hold.ts) and
36
- * the settings UI (register.ts) need the level rather than a whole fragment.
50
+ * Exported for its own tests only: the per-call config read is a contract on
51
+ * each half, and only the half on its own can assert it.
37
52
  */
38
53
  export declare function groupThinkingArgs(group: ChildGroup, cfg?: PiTaskConfig): string[];
39
54
  /**
@@ -1,22 +1,26 @@
1
1
  /**
2
- * The live-config bridge for per-group child settings: group in, argv fragment out.
2
+ * The live-config bridge for per-group child settings: group in, argv fragment
3
+ * out — plus the session's model snapshot, which the argv needs and which only a
4
+ * session_start with a `ctx` can produce.
3
5
  *
4
6
  * Separate from reasoning.ts and group-models.ts because those must take no
5
7
  * import with a runtime side effect — see their headers. The `getConfig()` read
6
8
  * lives here instead: this file imports them and nothing in config/ imports it
7
9
  * back, so the graph stays a tree.
8
10
  *
9
- * Read PER CALL, never cached at module scope, so a /task-config change lands on
10
- * the next child without a restart. Same contract `childBaseArgs` keeps.
11
+ * Config is read PER CALL, never cached at module scope, so a /task-config
12
+ * change lands on the next child without a restart. Same contract
13
+ * `childBaseArgs` keeps. The snapshot is the one deliberate exception, and it is
14
+ * a snapshot of the REGISTRY, not of config.
11
15
  */
12
16
  import { getConfig } from './config.js';
13
17
  import { MODEL_INHERIT, modelArgs } from './group-models.js';
14
18
  import { resolveReasoning, thinkingArgs } from './reasoning.js';
15
19
  /**
16
- * Specs this session has proven a child cannot resolve.
20
+ * What this session resolved each group's model cell to.
17
21
  *
18
- * WHY A SESSION-SCOPED SET AND NOT A LOOKUP
19
- * -----------------------------------------
22
+ * WHY A SESSION-SCOPED SNAPSHOT AND NOT A LOOKUP
23
+ * ----------------------------------------------
20
24
  * The honest question is "can a `--no-extensions` child resolve this spec?", and
21
25
  * only `ctx.modelRegistry` can answer it. Five of the six argv producers have no
22
26
  * `ctx` — `pi-worker`, `pi-worker-docs`, `docs-core`, `fetch-core` and
@@ -26,41 +30,43 @@ import { resolveReasoning, thinkingArgs } from './reasoning.js';
26
30
  * this project does not depend on pi-ai.
27
31
  *
28
32
  * So it is answered ONCE, at session_start, where ctx exists and every task is
29
- * still in the future, and the verdict is left here.
33
+ * still in the future, and the whole verdict is left here — usability and the
34
+ * context window from ONE walk, so the argv and the churn rule can never
35
+ * disagree about which model a group runs on.
30
36
  *
31
37
  * EMPTY MEANS EMIT. A host that never fires session_start therefore behaves
32
38
  * exactly as it does today — the failure direction is "pi decides", never "we
33
39
  * silently dropped a flag nobody checked".
34
40
  */
35
- let unusableSpecs = new Set();
36
- export function setUnusableSpecs(specs) {
37
- unusableSpecs = new Set(specs);
38
- }
39
- export function isSpecUsable(spec) {
40
- return !unusableSpecs.has(spec);
41
+ let groupModels = {};
42
+ export function setGroupModels(snapshot) {
43
+ groupModels = { ...snapshot };
41
44
  }
42
45
  /**
43
- * The context window of each group's model, resolved in the SAME session pass
44
- * that filled {@link setUnusableSpecs}.
45
- *
46
- * It lives here for the same reason that set does — `child-runner` and the
47
- * workers have no `ctx`, so they cannot ask a registry — and it is filled by the
48
- * same walk, so the two can never disagree about which model a group runs on.
46
+ * The group's own window, or `undefined` for "caller keeps its fallback".
49
47
  *
50
48
  * The number drives `StallDetector`'s churn rule, where the two error directions
51
49
  * are NOT symmetric: too large fires late (degraded, and the no-new-ground rule
52
50
  * still covers it), too small fires early and KILLS A HEALTHY CHILD. So an
53
51
  * absent answer means "use the parent's", never a guess.
54
52
  */
55
- let groupWindows = {};
56
- export function setGroupWindows(windows) {
57
- groupWindows = { ...windows };
58
- }
59
- /** The group's own window, or `undefined` for "caller keeps its fallback". */
60
53
  export function groupWindow(group) {
61
- const w = groupWindows[group];
54
+ const w = groupModels[group]?.contextWindow;
62
55
  return w !== undefined && w > 0 ? w : undefined;
63
56
  }
57
+ /**
58
+ * `spec → baseUrl` for every model the session can use, from the same
59
+ * session_start pass. Read by the dead-backend probe (shared/model-endpoint.ts),
60
+ * which runs where no `ctx` exists. Empty until a session starts, and the probe
61
+ * reads "no url" as "cannot see this server, so never kill".
62
+ */
63
+ let modelEndpoints = new Map();
64
+ export function setModelEndpoints(endpoints) {
65
+ modelEndpoints = new Map(endpoints);
66
+ }
67
+ export function modelEndpoint(spec) {
68
+ return modelEndpoints.get(spec);
69
+ }
64
70
  /**
65
71
  * The `['--model', spec]` fragment for a group, or `[]` for `inherit` and for a
66
72
  * spec this session proved unresolvable.
@@ -71,19 +77,23 @@ export function groupWindow(group) {
71
77
  * `reasoning: true` onto it, inherits the provider's default baseUrl and answers
72
78
  * at exit 0. Dropping the flag runs the same child the user got last week and
73
79
  * says so out loud; passing it runs a model nobody chose and says nothing.
80
+ *
81
+ * The verdict applies to the spec it was proven on: a cell changed since
82
+ * session_start is emitted, and pi decides.
74
83
  */
75
84
  export function groupModelArgs(group, cfg) {
76
85
  const spec = (cfg ?? getConfig()).groupModels[group];
77
86
  if (spec === undefined || spec === MODEL_INHERIT)
78
87
  return [];
79
- return isSpecUsable(spec) ? modelArgs(spec) : [];
88
+ const proven = groupModels[group];
89
+ return proven?.spec === spec && !proven.usable ? [] : modelArgs(spec);
80
90
  }
81
91
  /**
82
92
  * The `['--thinking', level]` fragment for a group, or `[]` when the group is
83
93
  * `inherit` and the child should keep falling back to settings.json.
84
94
  *
85
- * Still exported on its own: the host-session turn (implementation-hold.ts) and
86
- * the settings UI (register.ts) need the level rather than a whole fragment.
95
+ * Exported for its own tests only: the per-call config read is a contract on
96
+ * each half, and only the half on its own can assert it.
87
97
  */
88
98
  export function groupThinkingArgs(group, cfg) {
89
99
  // The default is evaluated HERE, per call. Hoisting the read to module scope
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The submenu a /task-config row opens on Enter, when cycling is the wrong verb.
2
+ * The two-step picker a /task-config step row opens on Enter.
3
3
  *
4
4
  * WHY A SUBMENU AND NOT `ctx.ui.select`
5
5
  * -------------------------------------
@@ -9,8 +9,8 @@
9
9
  * in place of the list and delegates input to it. And its `done(v)` calls
10
10
  * `onChange(id, v)` with exactly the returned value while `done(undefined)`
11
11
  * writes nothing — the same one-value contract every cycling row uses, so the
12
- * panel's dispatch needs no branch and a model list of 200 costs one write
13
- * instead of 200.
12
+ * panel's dispatch needs no branch and a pair chosen out of two long lists costs
13
+ * ONE write.
14
14
  *
15
15
  * WHY NOT A BARE `SelectList`
16
16
  * ---------------------------
@@ -26,17 +26,45 @@ import { Container } from '@earendil-works/pi-tui';
26
26
  import type { SelectItem } from '@earendil-works/pi-tui';
27
27
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
28
28
  type Theme = ExtensionCommandContext['ui']['theme'];
29
+ /** What the second stage offers, once the first has been answered. */
30
+ export interface PairStage {
31
+ options: readonly SelectItem[];
32
+ /**
33
+ * The option to open on — the value that will ACTUALLY run.
34
+ *
35
+ * Supplied by the caller rather than derived here, because deciding it is
36
+ * the clamp, and the clamp belongs to the config layer that owns the model's
37
+ * declared ladder. It is what makes the auto-switch visible: pick a model
38
+ * that cannot do `medium` and this stage opens on `off`.
39
+ */
40
+ preselect: string;
41
+ }
42
+ export interface PairOptions {
43
+ first: readonly SelectItem[];
44
+ second: (firstValue: string) => PairStage;
45
+ /** Which half of the stored value stage one opens on. */
46
+ firstOf: (value: string) => string;
47
+ /** How the two answers become the one string the row stores. */
48
+ join: (first: string, second: string) => string;
49
+ }
29
50
  /**
30
- * A filterable one-of picker.
51
+ * Model, then thinking level, as one choice.
52
+ *
53
+ * Two stages rather than one flat list of every legal pair: the pair space is
54
+ * models × levels, and the common edit is "change the model, keep the level" —
55
+ * a flat list would make that re-pick both every time.
31
56
  *
32
- * `currentValue` may match NO option that is the vanished-model case, where
33
- * the stored spec is still shown by the row and must still be openable. It
34
- * simply starts at the top rather than refusing to render.
57
+ * Escape at EITHER stage cancels the whole thing. Going back one step would be
58
+ * friendlier, but a TUI select owns up, down, enter and escape and has no fifth
59
+ * key to spare, and a half-answered pair must never reach `done`.
35
60
  */
36
- export declare class OptionPicker extends Container {
37
- private readonly input;
38
- private readonly list;
39
- constructor(options: readonly SelectItem[], currentValue: string, theme: Theme, done: (value?: string) => void);
61
+ export declare class PairPicker extends Container {
62
+ private readonly options;
63
+ private readonly theme;
64
+ private readonly done;
65
+ private stage;
66
+ constructor(options: PairOptions, currentValue: string, theme: Theme, done: (value?: string) => void);
67
+ private openSecond;
40
68
  handleInput(data: string): void;
41
69
  }
42
70
  export {};
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The submenu a /task-config row opens on Enter, when cycling is the wrong verb.
2
+ * The two-step picker a /task-config step row opens on Enter.
3
3
  *
4
4
  * WHY A SUBMENU AND NOT `ctx.ui.select`
5
5
  * -------------------------------------
@@ -9,8 +9,8 @@
9
9
  * in place of the list and delegates input to it. And its `done(v)` calls
10
10
  * `onChange(id, v)` with exactly the returned value while `done(undefined)`
11
11
  * writes nothing — the same one-value contract every cycling row uses, so the
12
- * panel's dispatch needs no branch and a model list of 200 costs one write
13
- * instead of 200.
12
+ * panel's dispatch needs no branch and a pair chosen out of two long lists costs
13
+ * ONE write.
14
14
  *
15
15
  * WHY NOT A BARE `SelectList`
16
16
  * ---------------------------
@@ -35,23 +35,29 @@ function selectTheme(theme) {
35
35
  };
36
36
  }
37
37
  /**
38
- * A filterable one-of picker.
38
+ * A filterable list with a text filter above it.
39
39
  *
40
- * `currentValue` may match NO option that is the vanished-model case, where
41
- * the stored spec is still shown by the row and must still be openable. It
42
- * simply starts at the top rather than refusing to render.
40
+ * Split out because both stages are one of these and the routing is the part
41
+ * that is easy to get wrong: `SelectList` owns four keys and drops the rest, so
42
+ * everything else has to be handed to the `Input` and the filter re-applied.
43
43
  */
44
- export class OptionPicker extends Container {
44
+ class FilterList extends Container {
45
+ onPick;
46
+ onCancel;
45
47
  input = new Input();
46
48
  list;
47
- constructor(options, currentValue, theme, done) {
49
+ constructor(options, preselect, theme, onPick, onCancel) {
48
50
  super();
51
+ this.onPick = onPick;
52
+ this.onCancel = onCancel;
49
53
  this.list = new SelectList([...options], PICKER_VISIBLE, selectTheme(theme));
50
- const at = options.findIndex(o => o.value === currentValue);
54
+ // A `preselect` matching no option is normal, not an error: a row can
55
+ // hold a spec the catalog no longer offers, and it must still open.
56
+ const at = options.findIndex(o => o.value === preselect);
51
57
  if (at !== -1)
52
58
  this.list.setSelectedIndex(at);
53
- this.list.onSelect = item => done(item.value);
54
- this.list.onCancel = () => done(undefined);
59
+ this.list.onSelect = item => this.onPick(item.value);
60
+ this.list.onCancel = () => this.onCancel();
55
61
  this.addChild(this.input);
56
62
  this.addChild(this.list);
57
63
  }
@@ -71,3 +77,37 @@ export class OptionPicker extends Container {
71
77
  this.list.setFilter(this.input.getValue());
72
78
  }
73
79
  }
80
+ /**
81
+ * Model, then thinking level, as one choice.
82
+ *
83
+ * Two stages rather than one flat list of every legal pair: the pair space is
84
+ * models × levels, and the common edit is "change the model, keep the level" —
85
+ * a flat list would make that re-pick both every time.
86
+ *
87
+ * Escape at EITHER stage cancels the whole thing. Going back one step would be
88
+ * friendlier, but a TUI select owns up, down, enter and escape and has no fifth
89
+ * key to spare, and a half-answered pair must never reach `done`.
90
+ */
91
+ export class PairPicker extends Container {
92
+ options;
93
+ theme;
94
+ done;
95
+ stage;
96
+ constructor(options, currentValue, theme, done) {
97
+ super();
98
+ this.options = options;
99
+ this.theme = theme;
100
+ this.done = done;
101
+ this.stage = new FilterList(options.first, options.firstOf(currentValue), theme, first => this.openSecond(first), () => done(undefined));
102
+ this.addChild(this.stage);
103
+ }
104
+ openSecond(first) {
105
+ const { options: second, preselect } = this.options.second(first);
106
+ this.stage = new FilterList(second, preselect, this.theme, level => this.done(this.options.join(first, level)), () => this.done(undefined));
107
+ this.clear();
108
+ this.addChild(this.stage);
109
+ }
110
+ handleInput(data) {
111
+ this.stage.handleInput(data);
112
+ }
113
+ }
@@ -104,13 +104,16 @@ export declare function effectiveReasoning(cfg: PiTaskConfig): Record<ChildGroup
104
104
  * version before this feature existed.
105
105
  */
106
106
  export declare function thinkingArgs(setting: GroupSetting): string[];
107
- /** One honest sentence per group, for the /task-config rows. */
108
- export declare const REASONING_GROUP_HELP: Readonly<Record<ChildGroup, string>>;
109
107
  /**
110
- * One honest sentence per model row.
108
+ * One honest sentence per step, for the merged /task-config rows.
111
109
  *
112
- * They are NOT the reasoning help reworded. A model cell answers a different
113
- * question which machine does this work and one of them costs money every
114
- * turn rather than once per change, which is a thing the row has to say.
110
+ * ONE table, not the model help and the reasoning help concatenated. The panel
111
+ * sizes every frame from its tallest description (`settingsBodyHeight`), so
112
+ * gluing two paragraphs together for `implementation` would permanently add
113
+ * about six rows to the whole menu — for a row most users never open.
114
+ *
115
+ * What survives the merge is the part that is not guessable: which children the
116
+ * step actually spawns, what was MEASURED about its thinking level, and the one
117
+ * cost that is paid per turn rather than per change.
115
118
  */
116
- export declare const MODEL_GROUP_HELP: Readonly<Record<ChildGroup, string>>;
119
+ export declare const STEP_GROUP_HELP: Readonly<Record<ChildGroup, string>>;
@@ -105,12 +105,23 @@ export function effectiveReasoning(cfg) {
105
105
  export function thinkingArgs(setting) {
106
106
  return setting === 'inherit' ? [] : ['--thinking', setting];
107
107
  }
108
- /** One honest sentence per group, for the /task-config rows. */
109
- export const REASONING_GROUP_HELP = {
110
- research: 'The pi-worker subagent tool, and the fallback for any research worker below '
111
- + 'whose own level is unset. Read-only exploration loops.',
108
+ /**
109
+ * One honest sentence per step, for the merged /task-config rows.
110
+ *
111
+ * ONE table, not the model help and the reasoning help concatenated. The panel
112
+ * sizes every frame from its tallest description (`settingsBodyHeight`), so
113
+ * gluing two paragraphs together for `implementation` would permanently add
114
+ * about six rows to the whole menu — for a row most users never open.
115
+ *
116
+ * What survives the merge is the part that is not guessable: which children the
117
+ * step actually spawns, what was MEASURED about its thinking level, and the one
118
+ * cost that is paid per turn rather than per change.
119
+ */
120
+ export const STEP_GROUP_HELP = {
121
+ research: 'The pi-worker subagent tool, and the fallback for any research worker below. '
122
+ + 'Long read-only loops, where a cheap fast model pays off.',
112
123
  'research:files': 'Research worker 1 of 4: maps which files the task will touch. Read-heavy. '
113
- + 'Measured: the two arms tie, so it runs without thinking.',
124
+ + 'Measured: the two thinking arms tie, so it runs without.',
114
125
  'research:apis': 'Research worker 2 of 4: the symbols and signatures the task must call. '
115
126
  + 'Read-heavy, docs- and search-capable.',
116
127
  'research:context': 'Research worker 3 of 4: how the project is put together. One of the two that '
@@ -125,31 +136,7 @@ export const REASONING_GROUP_HELP = {
125
136
  gate: 'The checks that run after code is written: verify, enforce, lint-fix, autofix.',
126
137
  extraction: 'The small no-tools children that pull one answer out of a fetched page or '
127
138
  + 'a docs chunk.',
128
- implementation: 'The main session turn that actually writes the code. Changing this briefly '
129
- + "changes pi's own thinking level, and puts it back afterwards."
130
- };
131
- /**
132
- * One honest sentence per model row.
133
- *
134
- * They are NOT the reasoning help reworded. A model cell answers a different
135
- * question — which machine does this work — and one of them costs money every
136
- * turn rather than once per change, which is a thing the row has to say.
137
- */
138
- export const MODEL_GROUP_HELP = {
139
- research: 'The pi-worker subagent tool, and the fallback for any research worker below. '
140
- + 'Long read-only loops: a cheap fast model pays off here.',
141
- 'research:files': 'Research worker 1 of 4: maps which files the task will touch.',
142
- 'research:apis': 'Research worker 2 of 4: the symbols and signatures the task must call.',
143
- 'research:context': 'Research worker 3 of 4: how the project is put together.',
144
- 'research:tooling': 'Research worker 4 of 4: the commands that build, test and run it.',
145
- phase: 'Refining your request, generating and answering the clarifying questions, '
146
- + 'writing the spec, and critiquing it.',
147
- planning: "/task-auto's planners: splitting a design document into tasks.",
148
- plan: "/task-plan's interactive question-and-answer children.",
149
- gate: 'The checks that run after code is written: verify, enforce, lint-fix, autofix.',
150
- extraction: 'The small no-tools children that pull one answer out of a page or a docs chunk.',
151
- implementation: 'The main session turn that writes the code — YOUR session, switched for the turn and '
152
- + 'switched back. Unlike every row above, this one is not free: a model switch re-bills '
153
- + 'the whole prompt as a cache miss, twice per task. Leave it on inherit unless you '
154
- + 'want a different model than the one you are reading this in.'
139
+ implementation: 'The main session turn that writes the code YOUR session, moved for the turn and '
140
+ + 'moved back. Unlike every step above, the model half here is not free: a switch '
141
+ + 're-bills the whole prompt as a cache miss, twice per task.'
155
142
  };