@mjasnikovs/pi-task 0.38.23 → 0.38.25

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 (48) hide show
  1. package/README.md +2 -2
  2. package/dist/config/reasoning-args.d.ts +12 -1
  3. package/dist/config/reasoning-args.js +5 -2
  4. package/dist/config/reasoning.d.ts +47 -6
  5. package/dist/config/reasoning.js +84 -9
  6. package/dist/config/register.d.ts +50 -26
  7. package/dist/config/register.js +96 -80
  8. package/dist/shared/reasoning-capability.d.ts +2 -5
  9. package/dist/shared/reasoning-capability.js +31 -4
  10. package/dist/task/auto-orchestrator.d.ts +2 -0
  11. package/dist/task/auto-orchestrator.js +28 -41
  12. package/dist/task/child-runner.d.ts +89 -24
  13. package/dist/task/child-runner.js +67 -46
  14. package/dist/task/gate-child.js +11 -11
  15. package/dist/task/orchestrator.d.ts +14 -20
  16. package/dist/task/orchestrator.js +12 -9
  17. package/dist/task/phases.d.ts +0 -23
  18. package/dist/task/phases.js +48 -464
  19. package/dist/task/question-dialog.d.ts +56 -0
  20. package/dist/task/question-dialog.js +53 -0
  21. package/dist/task/research-fanout-budget.d.ts +20 -0
  22. package/dist/task/research-fanout-budget.js +29 -0
  23. package/dist/task/research-worker.d.ts +183 -0
  24. package/dist/task/research-worker.js +429 -0
  25. package/dist/workers/brave-warning.js +4 -30
  26. package/dist/workers/docs-core.d.ts +8 -4
  27. package/dist/workers/docs-core.js +30 -21
  28. package/dist/workers/docs-lookup.d.ts +72 -0
  29. package/dist/workers/docs-lookup.js +53 -0
  30. package/dist/workers/docs-project.d.ts +9 -0
  31. package/dist/workers/docs-project.js +15 -0
  32. package/dist/workers/pi-worker-core.d.ts +112 -109
  33. package/dist/workers/pi-worker-core.js +33 -48
  34. package/dist/workers/pi-worker-docs.js +27 -31
  35. package/dist/workers/pi-worker.js +6 -0
  36. package/dist/workers/reasoning-warning.d.ts +10 -16
  37. package/dist/workers/reasoning-warning.js +25 -57
  38. package/dist/workers/session-hint.d.ts +37 -0
  39. package/dist/workers/session-hint.js +82 -0
  40. package/dist/workers/worker-failure.d.ts +34 -0
  41. package/dist/workers/worker-failure.js +27 -16
  42. package/dist/workers/worker-kill.d.ts +84 -0
  43. package/dist/workers/worker-kill.js +124 -0
  44. package/dist/workers/worker-profiles.d.ts +314 -0
  45. package/dist/workers/worker-profiles.js +220 -0
  46. package/package.json +1 -1
  47. package/dist/task/reasoning-groups.d.ts +0 -36
  48. package/dist/task/reasoning-groups.js +0 -36
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![npm](https://img.shields.io/npm/v/@mjasnikovs/pi-task?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mjasnikovs/pi-task)
10
10
  [![license](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](./LICENSE)
11
11
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
12
- [![tests](https://img.shields.io/badge/tests-3771%20passing-3fb950)](#development)
12
+ [![tests](https://img.shields.io/badge/tests-4320%20passing-3fb950)](#development)
13
13
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](./tsconfig.json)
14
14
 
15
15
  </div>
@@ -243,7 +243,7 @@ them checked in.
243
243
 
244
244
  ```sh
245
245
  bun install
246
- bun run test # 3772 tests across 212 files
246
+ bun run test # 4321 tests across 242 files
247
247
  bun run lint # prettier + eslint + tsc --noEmit
248
248
  bun run build # tsc → dist/
249
249
  ```
@@ -1,3 +1,14 @@
1
+ /**
2
+ * The live-config bridge for reasoning profiles: group in, argv fragment out.
3
+ *
4
+ * Separate from reasoning.ts because that module must stay import-free — see its
5
+ * header. This file is the one hop that reads `getConfig()`, so it imports both
6
+ * and neither imports it back: a tree, not a cycle.
7
+ *
8
+ * Read PER CALL, never cached at module scope, so a /task-config change lands on
9
+ * the next child without a restart — the same contract childBaseArgs states.
10
+ */
11
+ import { type PiTaskConfig } from './config.js';
1
12
  import { type ReasoningGroup } from './reasoning.js';
2
13
  /**
3
14
  * The `['--thinking', level]` fragment for a group, or `[]` when the group is
@@ -7,4 +18,4 @@ import { type ReasoningGroup } from './reasoning.js';
7
18
  * themselves — an argv builder that resolves its own policy is one that cannot
8
19
  * be told to do something else, which is how childBaseArgs became universal.
9
20
  */
10
- export declare function groupThinkingArgs(group: ReasoningGroup): string[];
21
+ export declare function groupThinkingArgs(group: ReasoningGroup, cfg?: PiTaskConfig): string[];
@@ -18,6 +18,9 @@ import { resolveReasoning, thinkingArgs } from './reasoning.js';
18
18
  * themselves — an argv builder that resolves its own policy is one that cannot
19
19
  * be told to do something else, which is how childBaseArgs became universal.
20
20
  */
21
- export function groupThinkingArgs(group) {
22
- return thinkingArgs(resolveReasoning(group, getConfig()));
21
+ export function groupThinkingArgs(group, cfg) {
22
+ // The default is EVALUATED HERE, per call, which is the contract this module
23
+ // exists to keep. Hoisting the read to module scope would leave every test
24
+ // green, so the parameter is what makes the contract assertable at all.
25
+ return thinkingArgs(resolveReasoning(group, cfg ?? getConfig()));
23
26
  }
@@ -142,13 +142,41 @@ export declare const DEFAULT_REASONING_TABLE: Readonly<Record<ReasoningGroup, Gr
142
142
  */
143
143
  export declare function sanitizeReasoningMode(value: unknown): ReasoningMode;
144
144
  /**
145
- * The group each research worker reads its level from, keyed by the section
146
- * heading its output is assembled under (task/phases.ts `workerSpecs`).
147
- *
148
- * Exported so the wiring is one table rather than four string literals spread
149
- * through the phase code, and so a test can assert every value is a real group.
145
+ * Child NAME reasoning group, for every child that goes through
146
+ * `runPhaseChild` / `runPlanningChild`.
147
+ *
148
+ * WHY KEYED ON THE NAME
149
+ * ---------------------
150
+ * The name is the only identifier in scope at all three spawn paths (phases,
151
+ * /task-auto planning, /task-plan), it is what the loader and the debug trail
152
+ * already print, and it is the one thing a reader can check against the phase
153
+ * list without following the call graph. Threading a group parameter through
154
+ * `PhaseDeps` / `AutoDeps` instead would touch both orchestrators' dep bags to
155
+ * express something the call site already says out loud.
156
+ *
157
+ * AN UNMAPPED NAME IS A BUILD FAILURE, not a silent `inherit`.
158
+ * `reasoning-groups.test.ts` scans every literal child name in src/ and fails if
159
+ * it is missing here. A defaulting lookup would let a phase added next year opt
160
+ * itself out of a measured setting without anyone deciding to — which is exactly
161
+ * how `/no_think` ended up applied to eight prompts and read by none of them.
162
+ *
163
+ * The gate and extraction groups are NOT here: those children reach the model
164
+ * through `runWorker` / `focusedChildArgs` at a site with no name in scope, so
165
+ * the group is passed directly. The four RESEARCH workers do have a name — their
166
+ * `spec.label` — and so they are here.
167
+ */
168
+ export declare const REASONING_GROUP_BY_CHILD: Readonly<Record<string, ReasoningGroup>>;
169
+ /**
170
+ * The group a named child belongs to.
171
+ *
172
+ * Returns `undefined` for a name the table does not know, and the CALLER decides
173
+ * what that means. `runPhaseChild` treats it as `inherit` — a child that reaches
174
+ * the model with today's argv is always safe — while the test treats it as a
175
+ * failure. That split is deliberate: the guard belongs at build time, where
176
+ * someone can fix it, not at run time, where it would abort a user's task over a
177
+ * missing table row.
150
178
  */
151
- export declare const RESEARCH_WORKER_GROUPS: Readonly<Record<string, ReasoningGroup>>;
179
+ export declare function reasoningGroupForChild(name: string): ReasoningGroup | undefined;
152
180
  /**
153
181
  * Always returns a COMPLETE record, never a partial one.
154
182
  *
@@ -167,6 +195,19 @@ export declare function sanitizeReasoningLevels(value: unknown): Record<Reasonin
167
195
  * `groupThinkingArgs` from reasoning-args.ts.
168
196
  */
169
197
  export declare function resolveReasoning(group: ReasoningGroup, cfg: PiTaskConfig): GroupSetting;
198
+ /**
199
+ * The WHOLE table, as this config will actually run it.
200
+ *
201
+ * This is the question every caller has — the settings menu when it repaints,
202
+ * the mismatch warning when it scans, the custom-mode seeder when it freezes the
203
+ * table — and each of them used to write the same loop over `resolveReasoning`.
204
+ * A per-key accessor with no whole-table companion is also how a THIRD shape got
205
+ * invented (`Array<{group, setting}>`) and leaked into `reasoningMismatches`.
206
+ *
207
+ * `resolveReasoning` stays: one group is still a fair question, and it is the
208
+ * only place the four modes are interpreted.
209
+ */
210
+ export declare function effectiveReasoning(cfg: PiTaskConfig): Record<ReasoningGroup, GroupSetting>;
170
211
  /**
171
212
  * The argv fragment for a setting. `inherit` is the empty fragment — no flag at
172
213
  * all — which is what makes an all-`inherit` config byte-identical to the
@@ -711,18 +711,75 @@ export function sanitizeReasoningMode(value) {
711
711
  return REASONING_MODES.includes(value) ? value : 'default';
712
712
  }
713
713
  /**
714
- * The group each research worker reads its level from, keyed by the section
715
- * heading its output is assembled under (task/phases.ts `workerSpecs`).
714
+ * Child NAME reasoning group, for every child that goes through
715
+ * `runPhaseChild` / `runPlanningChild`.
716
716
  *
717
- * Exported so the wiring is one table rather than four string literals spread
718
- * through the phase code, and so a test can assert every value is a real group.
717
+ * WHY KEYED ON THE NAME
718
+ * ---------------------
719
+ * The name is the only identifier in scope at all three spawn paths (phases,
720
+ * /task-auto planning, /task-plan), it is what the loader and the debug trail
721
+ * already print, and it is the one thing a reader can check against the phase
722
+ * list without following the call graph. Threading a group parameter through
723
+ * `PhaseDeps` / `AutoDeps` instead would touch both orchestrators' dep bags to
724
+ * express something the call site already says out loud.
725
+ *
726
+ * AN UNMAPPED NAME IS A BUILD FAILURE, not a silent `inherit`.
727
+ * `reasoning-groups.test.ts` scans every literal child name in src/ and fails if
728
+ * it is missing here. A defaulting lookup would let a phase added next year opt
729
+ * itself out of a measured setting without anyone deciding to — which is exactly
730
+ * how `/no_think` ended up applied to eight prompts and read by none of them.
731
+ *
732
+ * The gate and extraction groups are NOT here: those children reach the model
733
+ * through `runWorker` / `focusedChildArgs` at a site with no name in scope, so
734
+ * the group is passed directly. The four RESEARCH workers do have a name — their
735
+ * `spec.label` — and so they are here.
719
736
  */
720
- export const RESEARCH_WORKER_GROUPS = {
721
- FILES: 'research:files',
722
- APIS: 'research:apis',
723
- CONTEXT: 'research:context',
724
- TOOLING: 'research:tooling'
737
+ export const REASONING_GROUP_BY_CHILD = {
738
+ // ── phase: task/phases.ts + task/title-label.ts ──────────────────────────
739
+ refine: 'phase',
740
+ 'verify-tooling': 'phase',
741
+ 'grill-auto': 'phase',
742
+ 'grill-gen': 'phase',
743
+ compose: 'phase',
744
+ critique: 'phase',
745
+ 'critique-triage': 'phase',
746
+ 'compress-label': 'phase',
747
+ // ── planning: task/auto-orchestrator.ts ──────────────────────────────────
748
+ 'clarify-triage': 'planning',
749
+ 'auto-clarify': 'planning',
750
+ 'auto-decompose': 'planning',
751
+ 'requirement-extract': 'planning',
752
+ 'decompose-coverage': 'planning',
753
+ 'coverage-map': 'planning',
754
+ 'contract-extract': 'planning',
755
+ 'launch-extract': 'planning',
756
+ // ── plan: task/plan-orchestrator.ts ──────────────────────────────────────
757
+ 'plan-question': 'plan',
758
+ 'plan-answer': 'plan',
759
+ // ── research: task/phases.ts `workerSpecs`, keyed on the spec's LABEL ─────
760
+ // These were a second table (`RESEARCH_WORKER_GROUPS`) keyed on the section
761
+ // heading, with a silent `?? 'research'` fallback and a guard that sliced
762
+ // phases.ts source between two string offsets from the config directory.
763
+ // The label is the same name the loader, the debug trail and the A/B ledgers
764
+ // already print, so they belong here with every other named child.
765
+ 'worker:files': 'research:files',
766
+ 'worker:apis': 'research:apis',
767
+ 'worker:context': 'research:context',
768
+ 'worker:tooling': 'research:tooling'
725
769
  };
770
+ /**
771
+ * The group a named child belongs to.
772
+ *
773
+ * Returns `undefined` for a name the table does not know, and the CALLER decides
774
+ * what that means. `runPhaseChild` treats it as `inherit` — a child that reaches
775
+ * the model with today's argv is always safe — while the test treats it as a
776
+ * failure. That split is deliberate: the guard belongs at build time, where
777
+ * someone can fix it, not at run time, where it would abort a user's task over a
778
+ * missing table row.
779
+ */
780
+ export function reasoningGroupForChild(name) {
781
+ return REASONING_GROUP_BY_CHILD[name];
782
+ }
726
783
  /**
727
784
  * For a `research:*` group, the group a stored config falls back to when its own
728
785
  * key is missing. Every other group maps to `undefined`.
@@ -795,6 +852,24 @@ export function resolveReasoning(group, cfg) {
795
852
  return DEFAULT_REASONING_TABLE[group];
796
853
  }
797
854
  }
855
+ /**
856
+ * The WHOLE table, as this config will actually run it.
857
+ *
858
+ * This is the question every caller has — the settings menu when it repaints,
859
+ * the mismatch warning when it scans, the custom-mode seeder when it freezes the
860
+ * table — and each of them used to write the same loop over `resolveReasoning`.
861
+ * A per-key accessor with no whole-table companion is also how a THIRD shape got
862
+ * invented (`Array<{group, setting}>`) and leaked into `reasoningMismatches`.
863
+ *
864
+ * `resolveReasoning` stays: one group is still a fair question, and it is the
865
+ * only place the four modes are interpreted.
866
+ */
867
+ export function effectiveReasoning(cfg) {
868
+ const out = {};
869
+ for (const group of REASONING_GROUPS)
870
+ out[group] = resolveReasoning(group, cfg);
871
+ return out;
872
+ }
798
873
  /**
799
874
  * The argv fragment for a setting. `inherit` is the empty fragment — no flag at
800
875
  * all — which is what makes an all-`inherit` config byte-identical to the
@@ -59,7 +59,18 @@ declare class BorderedBox implements Component {
59
59
  * because both read `format`.
60
60
  */
61
61
  export interface ConfigItem {
62
- id: keyof PiTaskConfig;
62
+ /**
63
+ * The row's id. A `keyof PiTaskConfig` for a fixed setting; a prefixed
64
+ * string (`reason:`, `tool:`, `ext:`) for a DISCOVERED one.
65
+ *
66
+ * It is `string`, not `keyof PiTaskConfig`, and that is what lets the three
67
+ * dynamic families BE rows instead of bypassing them. While the id was
68
+ * narrow, each family re-invented both directions by hand — a builder, an
69
+ * apply function, and an arm in a four-way prefix ladder 300 lines away —
70
+ * and the round-trip property `config-items.test.ts` runs over `ITEMS`
71
+ * covered none of them.
72
+ */
73
+ id: string;
63
74
  /**
64
75
  * Which titled block of the menu this row sits under. Rows are grouped by
65
76
  * section in {@link panelItems}, in the order the sections first appear in
@@ -75,6 +86,11 @@ export interface ConfigItem {
75
86
  format: (cfg: PiTaskConfig) => string;
76
87
  /** Write the chosen label back. A value it does not recognise is ignored. */
77
88
  apply: (cfg: PiTaskConfig, chosen: string) => void;
89
+ /**
90
+ * What the headless one-line rendering calls this row, when `label` reads
91
+ * only in the panel. See {@link PanelItem.headlessLabel}.
92
+ */
93
+ headlessLabel?: string;
78
94
  }
79
95
  /**
80
96
  * The titled blocks the settings menu is divided into.
@@ -102,22 +118,10 @@ export declare const SECTION_ID_PREFIX = "section:";
102
118
  * either of the two ladders this replaced.
103
119
  */
104
120
  export declare const ITEMS: ConfigItem[];
105
- export declare function extensionItems(extensions: InstalledExtension[], whitelist: readonly string[]): {
106
- id: string;
107
- label: string;
108
- description: string;
109
- currentValue: string;
110
- values: string[];
111
- }[];
121
+ export declare function extensionItems(extensions: InstalledExtension[]): ConfigItem[];
112
122
  /** Apply an extension toggle to the config's whitelist (idempotent both ways). */
113
123
  export declare function applyExtensionToggle(whitelist: readonly string[], entryPath: string, on: boolean): string[];
114
- export declare function toolItems(tools: readonly GuardableTool[], exempt: readonly string[]): {
115
- id: string;
116
- label: string;
117
- description: string;
118
- currentValue: string;
119
- values: string[];
120
- }[];
124
+ export declare function toolItems(tools: readonly GuardableTool[]): ConfigItem[];
121
125
  /** Apply a per-tool watchdog toggle to the exemption list (idempotent both ways). */
122
126
  export declare function applyToolToggle(exempt: readonly string[], toolName: string, watched: boolean): string[];
123
127
  /**
@@ -138,7 +142,7 @@ export declare function applyToolToggle(exempt: readonly string[], toolName: str
138
142
  * Leading spaces survive: SettingsList pads the label right, never trims it.
139
143
  */
140
144
  export declare function reasoningRowLabel(group: ReasoningGroup): string;
141
- export declare function reasoningItems(cfg: PiTaskConfig): PanelItem[];
145
+ export declare function reasoningItems(): ConfigItem[];
142
146
  /**
143
147
  * Apply one group row's new value.
144
148
  *
@@ -149,16 +153,6 @@ export declare function reasoningItems(cfg: PiTaskConfig): PanelItem[];
149
153
  * it, nudging `research` while in `off` would silently return the other six to
150
154
  * whatever the stored table happened to hold.
151
155
  */
152
- /**
153
- * Write every `think:` row's displayed value back from the config.
154
- *
155
- * Called after ANY change, not just a reasoning one, because the mode row and
156
- * the seven group rows are one control split across eight lines: cycling
157
- * `reasoning` to `off` changes what all seven of them run at, and cycling one
158
- * group row flips the mode, which changes the other six. A row showing a level
159
- * the run will not use is worse than no row.
160
- */
161
- export declare function refreshReasoningRows(cfg: PiTaskConfig, list: SettingsList): void;
162
156
  export declare function applyReasoningLevel(cfg: PiTaskConfig, group: ReasoningGroup, chosen: string): void;
163
157
  /**
164
158
  * Tallest body the settings list can render, so {@link BorderedBox} can pad
@@ -204,7 +198,37 @@ export declare function createSettingsPanel(items: PanelItem[], theme: Theme,
204
198
  * beside seven rows still claiming `inherit` — which is what it did.
205
199
  */
206
200
  onChange: (id: string, newValue: string, list: SettingsList) => void, onCancel: () => void): BorderedBox;
201
+ /**
202
+ * Every settings row for this session, fixed and DISCOVERED, in menu order.
203
+ *
204
+ * One list of `ConfigItem`s — so a row's display, its write-back and its section
205
+ * are one object, the dispatch is a lookup by id, and the round-trip properties
206
+ * in `config-items.test.ts` cover the reasoning, tool and extension families
207
+ * that used to bypass the row type entirely.
208
+ *
209
+ * Fixed rows come before discovered ones within a section, which is the order
210
+ * the hand-written `extra` map produced.
211
+ */
212
+ export declare function configRows(installed: InstalledExtension[], tools?: readonly GuardableTool[]): ConfigItem[];
213
+ /** Render `rows` for the current config, grouped under their section headers. */
214
+ export declare function renderRows(cfg: PiTaskConfig, rows: readonly ConfigItem[]): PanelItem[];
207
215
  /** The full settings row list for the current config, in menu order. */
208
216
  export declare function panelItems(cfg: PiTaskConfig, installed: InstalledExtension[], tools?: readonly GuardableTool[]): PanelItem[];
217
+ /**
218
+ * Re-ask every row what it now displays, and write the answers back.
219
+ *
220
+ * A row's `currentValue` in the live list is a snapshot taken when the panel was
221
+ * built, and rows describe each other: cycling `reasoning` to `off` changes what
222
+ * all seven `think:` rows run at, and cycling one group row flips the mode,
223
+ * which changes the other six.
224
+ *
225
+ * This runs after ANY change, over EVERY row. Its predecessor,
226
+ * `refreshReasoningRows`, ran after any change too — but hard-coded the seven
227
+ * reasoning ids plus `reasoningMode` and touched none of the other ~30 rows, so
228
+ * the next cross-row dependency would have needed a fifth function. Re-reading a
229
+ * `format` costs nothing, which is why there is still no list of "changes that
230
+ * need a refresh" to keep correct.
231
+ */
232
+ export declare function syncRows(cfg: PiTaskConfig, rows: readonly ConfigItem[], list: SettingsList): void;
209
233
  export declare function registerConfig(pi: ExtensionAPI): void;
210
234
  export {};
@@ -5,7 +5,7 @@ import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../w
5
5
  import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
6
6
  import { listInstalledExtensions } from './extension-list.js';
7
7
  import { listGuardableTools } from './tool-list.js';
8
- import { REASONING_GROUPS, REASONING_MODES, sanitizeReasoningMode, REASONING_GROUP_HELP, REASONING_SETTINGS, resolveReasoning } from './reasoning.js';
8
+ import { REASONING_GROUPS, REASONING_MODES, sanitizeReasoningMode, REASONING_GROUP_HELP, REASONING_SETTINGS, effectiveReasoning, resolveReasoning } from './reasoning.js';
9
9
  // Version in the title so a bug report or screenshot says which build it came
10
10
  // from without anyone having to go look it up.
11
11
  const CONFIG_TITLE = `pi-task ${readPkgVersion()} settings`;
@@ -253,16 +253,22 @@ export const ITEMS = [
253
253
  * can tell an extension toggle from a PiTaskConfig field.
254
254
  */
255
255
  const EXT_ID_PREFIX = 'ext:';
256
- export function extensionItems(extensions, whitelist) {
256
+ export function extensionItems(extensions) {
257
257
  return extensions.map(e => ({
258
258
  id: EXT_ID_PREFIX + e.path,
259
+ section: 'extensions',
259
260
  label: `ext: ${e.label}`,
260
261
  description: `Load this ${e.origin} extension in the helper sessions pi-task spawns. They run `
261
262
  + 'with extensions off by default, so turn this on when the extension provides the '
262
263
  + 'model they need (pi-lmstudio, for example). They also inherit its tools and '
263
264
  + `hooks, so only enable ones you trust. ${e.path}`,
264
- currentValue: whitelist.includes(e.path) ? 'on' : 'off',
265
- values: ['on', 'off']
265
+ values: ['on', 'off'],
266
+ format: cfg => (cfg.extensionWhitelist.includes(e.path) ? 'on' : 'off'),
267
+ apply: (cfg, chosen) => {
268
+ if (chosen !== 'on' && chosen !== 'off')
269
+ return;
270
+ cfg.extensionWhitelist = applyExtensionToggle(cfg.extensionWhitelist, e.path, chosen === 'on');
271
+ }
266
272
  }));
267
273
  }
268
274
  /** Apply an extension toggle to the config's whitelist (idempotent both ways). */
@@ -280,18 +286,24 @@ export function applyExtensionToggle(whitelist, entryPath, on) {
280
286
  * the config matches nothing.
281
287
  */
282
288
  const TOOL_ID_PREFIX = 'tool:';
283
- export function toolItems(tools, exempt) {
289
+ export function toolItems(tools) {
284
290
  return tools.map(t => ({
285
291
  id: TOOL_ID_PREFIX + t.name,
292
+ section: 'timeouts',
286
293
  label: `watch: ${t.name}`,
287
294
  description: `Apply the command timeout to this tool. Leave it on unless the tool runs its own `
288
295
  + `bounded, cancellable work for longer than the timeout — turning it off means a `
289
296
  + `genuine hang in this tool will never be caught, and nothing else is watching `
290
297
  + `while a tool runs. ${t.origin}`,
298
+ values: ['on', 'off'],
291
299
  // Stored inverted: the config records the EXEMPTIONS, so an empty list
292
300
  // (and any tool pi-task has never heard of) stays guarded by default.
293
- currentValue: exempt.includes(t.name) ? 'off' : 'on',
294
- values: ['on', 'off']
301
+ format: cfg => (cfg.commandTimeoutExemptTools.includes(t.name) ? 'off' : 'on'),
302
+ apply: (cfg, chosen) => {
303
+ if (chosen !== 'on' && chosen !== 'off')
304
+ return;
305
+ cfg.commandTimeoutExemptTools = applyToolToggle(cfg.commandTimeoutExemptTools, t.name, chosen === 'on');
306
+ }
295
307
  }));
296
308
  }
297
309
  /** Apply a per-tool watchdog toggle to the exemption list (idempotent both ways). */
@@ -339,17 +351,20 @@ export function reasoningRowLabel(group) {
339
351
  const nextIsSibling = REASONING_GROUPS[REASONING_GROUPS.indexOf(group) + 1]?.startsWith(`${parent}:`);
340
352
  return ` ${nextIsSibling ? '├─' : '└─'} ${group.slice(colon + 1)}`;
341
353
  }
342
- export function reasoningItems(cfg) {
354
+ export function reasoningItems() {
343
355
  return REASONING_GROUPS.map(group => ({
344
356
  id: REASON_ID_PREFIX + group,
357
+ section: 'reasoning',
345
358
  label: reasoningRowLabel(group),
346
359
  headlessLabel: `think: ${group}`,
347
360
  description: REASONING_GROUP_HELP[group],
361
+ values: [...REASONING_SETTINGS],
348
362
  // The EFFECTIVE level, not cfg.reasoningLevels[group]: in default/on/off
349
363
  // the stored table is not what runs, and a row that shows a value the
350
- // run does not use is worse than no row.
351
- currentValue: resolveReasoning(group, cfg),
352
- values: [...REASONING_SETTINGS]
364
+ // run does not use is worse than no row. As a FUNCTION rather than a
365
+ // snapshot, so `syncRows` can re-ask after any change.
366
+ format: cfg => resolveReasoning(group, cfg),
367
+ apply: (cfg, chosen) => applyReasoningLevel(cfg, group, chosen)
353
368
  }));
354
369
  }
355
370
  /**
@@ -362,29 +377,13 @@ export function reasoningItems(cfg) {
362
377
  * it, nudging `research` while in `off` would silently return the other six to
363
378
  * whatever the stored table happened to hold.
364
379
  */
365
- /**
366
- * Write every `think:` row's displayed value back from the config.
367
- *
368
- * Called after ANY change, not just a reasoning one, because the mode row and
369
- * the seven group rows are one control split across eight lines: cycling
370
- * `reasoning` to `off` changes what all seven of them run at, and cycling one
371
- * group row flips the mode, which changes the other six. A row showing a level
372
- * the run will not use is worse than no row.
373
- */
374
- export function refreshReasoningRows(cfg, list) {
375
- for (const group of REASONING_GROUPS) {
376
- list.updateValue(REASON_ID_PREFIX + group, resolveReasoning(group, cfg));
377
- }
378
- list.updateValue('reasoningMode', cfg.reasoningMode);
379
- }
380
380
  export function applyReasoningLevel(cfg, group, chosen) {
381
381
  if (!REASONING_SETTINGS.includes(chosen))
382
382
  return;
383
383
  if (cfg.reasoningMode !== 'custom') {
384
- const seeded = {};
385
- for (const g of REASONING_GROUPS)
386
- seeded[g] = resolveReasoning(g, cfg);
387
- cfg.reasoningLevels = seeded;
384
+ // Freeze the table exactly as it runs today, then switch to custom, so
385
+ // opening one row cannot silently move the other six.
386
+ cfg.reasoningLevels = effectiveReasoning(cfg);
388
387
  cfg.reasoningMode = 'custom';
389
388
  }
390
389
  cfg.reasoningLevels = { ...cfg.reasoningLevels, [group]: chosen };
@@ -518,38 +517,70 @@ onChange, onCancel) {
518
517
  const list = new SettingsList(items, MAX_VISIBLE, makeTheme(theme, label => headerLabels.has(label.trimEnd())), (id, newValue) => onChange(id, newValue, list), onCancel);
519
518
  return new BorderedBox(new SkipInertRows(list, items.map(i => (i.values?.length ?? 0) > 0)), CONFIG_TITLE, s => theme.fg('borderMuted', s), s => theme.fg('accent', theme.bold(s)), settingsBodyHeight(items.map(i => i.description), MAX_VISIBLE, OVERLAY_WIDTH - 8));
520
519
  }
521
- /** The full settings row list for the current config, in menu order. */
522
- export function panelItems(cfg, installed, tools = []) {
523
- // The discovered rows belong to a section too — the per-tool watchdog
524
- // exemptions under `timeouts` (they are exemptions FROM that timeout), and
525
- // the per-extension toggles under their own heading.
526
- const extra = {
527
- reasoning: reasoningItems(cfg),
528
- timeouts: toolItems(tools, cfg.commandTimeoutExemptTools),
529
- extensions: extensionItems(installed, cfg.extensionWhitelist)
530
- };
520
+ /**
521
+ * Every settings row for this session, fixed and DISCOVERED, in menu order.
522
+ *
523
+ * One list of `ConfigItem`s so a row's display, its write-back and its section
524
+ * are one object, the dispatch is a lookup by id, and the round-trip properties
525
+ * in `config-items.test.ts` cover the reasoning, tool and extension families
526
+ * that used to bypass the row type entirely.
527
+ *
528
+ * Fixed rows come before discovered ones within a section, which is the order
529
+ * the hand-written `extra` map produced.
530
+ */
531
+ export function configRows(installed, tools = []) {
532
+ // The discovered rows carry a section like every other row — the per-tool
533
+ // watchdog exemptions under `timeouts` (they are exemptions FROM that
534
+ // timeout), and the per-extension toggles under their own heading.
535
+ return [...ITEMS, ...reasoningItems(), ...toolItems(tools), ...extensionItems(installed)];
536
+ }
537
+ /** Render `rows` for the current config, grouped under their section headers. */
538
+ export function renderRows(cfg, rows) {
531
539
  const out = [];
532
540
  for (const { key, title } of SECTIONS) {
533
- const rows = [
534
- ...ITEMS.filter(i => i.section === key).map(({ id, label, description, values, format }) => ({
535
- id: id,
536
- label,
537
- description,
538
- currentValue: format(cfg),
539
- values: values ?? ['on', 'off']
540
- })),
541
- ...(extra[key] ?? [])
542
- ];
541
+ const inSection = rows
542
+ .filter(i => i.section === key)
543
+ .map(i => ({
544
+ id: i.id,
545
+ label: i.label,
546
+ description: i.description,
547
+ currentValue: i.format(cfg),
548
+ values: i.values ?? ['on', 'off'],
549
+ ...(i.headlessLabel === undefined ? {} : { headlessLabel: i.headlessLabel })
550
+ }));
543
551
  // An empty section prints no header. `extensions` has no fixed rows at
544
552
  // all, so with nothing installed the heading would otherwise sit alone.
545
- if (rows.length === 0)
553
+ if (inSection.length === 0)
546
554
  continue;
547
555
  if (out.length > 0)
548
556
  out.push(sectionGap(title));
549
- out.push(sectionHeader(title), ...rows);
557
+ out.push(sectionHeader(title), ...inSection);
550
558
  }
551
559
  return out;
552
560
  }
561
+ /** The full settings row list for the current config, in menu order. */
562
+ export function panelItems(cfg, installed, tools = []) {
563
+ return renderRows(cfg, configRows(installed, tools));
564
+ }
565
+ /**
566
+ * Re-ask every row what it now displays, and write the answers back.
567
+ *
568
+ * A row's `currentValue` in the live list is a snapshot taken when the panel was
569
+ * built, and rows describe each other: cycling `reasoning` to `off` changes what
570
+ * all seven `think:` rows run at, and cycling one group row flips the mode,
571
+ * which changes the other six.
572
+ *
573
+ * This runs after ANY change, over EVERY row. Its predecessor,
574
+ * `refreshReasoningRows`, ran after any change too — but hard-coded the seven
575
+ * reasoning ids plus `reasoningMode` and touched none of the other ~30 rows, so
576
+ * the next cross-row dependency would have needed a fifth function. Re-reading a
577
+ * `format` costs nothing, which is why there is still no list of "changes that
578
+ * need a refresh" to keep correct.
579
+ */
580
+ export function syncRows(cfg, rows, list) {
581
+ for (const row of rows)
582
+ list.updateValue(row.id, row.format(cfg));
583
+ }
553
584
  async function handleTaskConfig(_args, ctx, getTools = () => []) {
554
585
  const cfg = {
555
586
  ...getConfig(),
@@ -585,34 +616,19 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
585
616
  ctx.ui.notify(lines.join(' | '), 'info');
586
617
  return;
587
618
  }
588
- await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(panelItems(cfg, installed, tools), theme, (id, newValue, list) => {
589
- // Header rows carry no `values`, so SettingsList never
590
- // cycles them and this can only be a real setting.
591
- if (id.startsWith(SECTION_ID_PREFIX))
592
- return;
593
- if (id.startsWith(EXT_ID_PREFIX)) {
594
- cfg.extensionWhitelist = applyExtensionToggle(cfg.extensionWhitelist, id.slice(EXT_ID_PREFIX.length), newValue === 'on');
595
- }
596
- else if (id.startsWith(REASON_ID_PREFIX)) {
597
- applyReasoningLevel(cfg, id.slice(REASON_ID_PREFIX.length), newValue);
598
- }
599
- else if (id.startsWith(TOOL_ID_PREFIX)) {
600
- cfg.commandTimeoutExemptTools = applyToolToggle(cfg.commandTimeoutExemptTools, id.slice(TOOL_ID_PREFIX.length), newValue === 'on');
601
- }
602
- else {
603
- // Every setting parses its own value. There is no generic
604
- // fallback: the ladder this replaces ended in one that
605
- // wrote `newValue === 'on'` into whatever field it was
606
- // handed, so a new enum setting silently became a boolean
607
- // until someone noticed.
608
- ITEMS.find(item => item.id === id)?.apply(cfg, newValue);
609
- }
610
- // The reasoning rows describe each other, so they are
611
- // re-read from the config after every change — including
612
- // changes to unrelated rows, which costs nothing and means
613
- // there is no list of "changes that need a refresh" to keep
614
- // correct.
615
- refreshReasoningRows(cfg, list);
619
+ // Built ONCE and shared by the renderer, the dispatch and the refresh, so
620
+ // all three necessarily agree about which rows exist.
621
+ const rows = configRows(installed, tools);
622
+ await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(renderRows(cfg, rows), theme, (id, newValue, list) => {
623
+ // Every row parses its own value. There is no generic
624
+ // fallback and no prefix ladder: the ladder this replaces
625
+ // ended in one that wrote `newValue === 'on'` into whatever
626
+ // field it was handed, so a new enum setting silently became
627
+ // a boolean until someone noticed. A header row carries no
628
+ // `values`, so SettingsList never cycles it and it matches
629
+ // no row here anyway.
630
+ rows.find(row => row.id === id)?.apply(cfg, newValue);
631
+ syncRows(cfg, rows, list);
616
632
  saveConfig(cfg).catch(() => { });
617
633
  }, () => done(undefined)), { overlay: true, overlayOptions: { width: OVERLAY_WIDTH } });
618
634
  }
@@ -24,7 +24,7 @@
24
24
  * transitive package to get twenty lines of arithmetic. SOURCE OF TRUTH is that
25
25
  * module; `reasoning-capability.test.ts` is where a change upstream shows up.
26
26
  */
27
- import type { ReasoningGroup, GroupSetting } from '../config/reasoning.js';
27
+ import { type ReasoningGroup, type GroupSetting } from '../config/reasoning.js';
28
28
  /**
29
29
  * pi's own level ladder, in order. The order is the whole algorithm: an
30
30
  * unsupported level is resolved by walking UP first, then down.
@@ -80,7 +80,4 @@ export interface ReasoningMismatch {
80
80
  * about one direction while staying silent about the other would ship this
81
81
  * feature with its own measured failure mode unreported.
82
82
  */
83
- export declare function reasoningMismatches(model: ReasoningModelFacts | undefined, settings: ReadonlyArray<{
84
- group: ReasoningGroup;
85
- setting: GroupSetting;
86
- }>): ReasoningMismatch[];
83
+ export declare function reasoningMismatches(model: ReasoningModelFacts | undefined, levels: Readonly<Record<ReasoningGroup, GroupSetting>>): ReasoningMismatch[];