@mjasnikovs/pi-task 0.38.31 → 0.39.0

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 +70 -0
  11. package/dist/config/option-picker.js +113 -0
  12. package/dist/config/reasoning.d.ts +26 -64
  13. package/dist/config/reasoning.js +31 -115
  14. package/dist/config/register.d.ts +144 -24
  15. package/dist/config/register.js +345 -56
  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
@@ -1,11 +1,14 @@
1
1
  import { getKeybindings, SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
+ import { clampToModel, supportedThinkingLevels } from '../shared/reasoning-capability.js';
3
+ import { MODEL_INHERIT, splitSpec } from './group-models.js';
4
+ import { PairPicker } from './option-picker.js';
2
5
  import { registerBridgeCommand } from '../remote/bridge.js';
3
6
  import { readPkgVersion } from '../shared/pkg-version.js';
4
7
  import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
5
8
  import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
6
9
  import { listInstalledExtensions } from './extension-list.js';
7
10
  import { listGuardableTools } from './tool-list.js';
8
- import { REASONING_GROUPS, REASONING_MODES, sanitizeReasoningMode, REASONING_GROUP_HELP, REASONING_SETTINGS, effectiveReasoning, resolveReasoning } from './reasoning.js';
11
+ import { CHILD_GROUPS, REASONING_MODES, sanitizeReasoningMode, STEP_GROUP_HELP, REASONING_SETTINGS, effectiveReasoning, resolveReasoning } from './reasoning.js';
9
12
  // Version in the title so a bug report or screenshot says which build it came
10
13
  // from without anyone having to go look it up.
11
14
  const CONFIG_TITLE = `pi-task ${readPkgVersion()} settings`;
@@ -73,7 +76,20 @@ export const SECTIONS = [
73
76
  { key: 'session', title: 'session' },
74
77
  { key: 'checks', title: 'after each task' },
75
78
  { key: 'research', title: 'research' },
76
- { key: 'reasoning', title: 'reasoning' },
79
+ // The global override gets its own heading, so it cannot be mistaken for a
80
+ // twelfth step. It is one row, and `renderRows` drops an empty section, so
81
+ // this costs a header and a blank line and nothing else.
82
+ //
83
+ // Both titles are SHORT on purpose. `SettingsList` sizes its label column
84
+ // from the widest item it holds — headers included — so a long heading is
85
+ // taken straight out of every row's value column, and the first thing to
86
+ // fall off the right is the thinking level.
87
+ { key: 'profile', title: 'profile' },
88
+ // ONE block for both dials. They shipped as two parallel blocks of eleven,
89
+ // which put a step's two settings eleven rows apart and hid the coupling
90
+ // between them: choosing a model re-clamps that step's thinking level, and
91
+ // nobody could see it happen.
92
+ { key: 'reasoning', title: 'steps' },
77
93
  { key: 'unattended', title: 'unattended' },
78
94
  { key: 'logging', title: 'logging' },
79
95
  { key: 'extensions', title: 'child extensions' },
@@ -83,6 +99,16 @@ export const SECTIONS = [
83
99
  ];
84
100
  /** Marks a header row, so onChange can ignore one and tests can find them. */
85
101
  export const SECTION_ID_PREFIX = 'section:';
102
+ /**
103
+ * Is this row scenery rather than a setting?
104
+ *
105
+ * Reads the ID, not `values`. Those two agreed only while every real row cycled
106
+ * a list: a picker row has a `submenu` and may carry `values` purely for the
107
+ * round-trip contract, so "no values" stopped meaning "not a row". Both
108
+ * `sectionHeader` and `sectionGap` already stamp the prefix, so this is exact
109
+ * rather than a heuristic, and all three consumers ask the same question.
110
+ */
111
+ export const isSectionRow = (item) => item.id.startsWith(SECTION_ID_PREFIX);
86
112
  /**
87
113
  * An inert titled row. No `values` ⇒ SettingsList's Enter handler no-ops on it,
88
114
  * and {@link SkipInertRows} steps the cursor straight over it.
@@ -211,15 +237,17 @@ export const ITEMS = [
211
237
  + 'For throwaway projects you are not watching'),
212
238
  {
213
239
  id: 'reasoningMode',
214
- section: 'reasoning',
215
- label: 'reasoning',
216
- description: 'How much the helper sessions think before answering. "default" uses the '
217
- + 'per-step table pi-task has measured, "on" and "off" force one answer '
218
- + 'everywhere, and "custom" is whatever you set in the "think:" rows below. '
219
- + 'Those rows always show what each step actually runs at, and changing one '
220
- + 'switches this to custom. A step left on "inherit" uses whatever thinking '
221
- + 'level pi itself is set to, which is what every step did before this setting '
222
- + 'existed',
240
+ // Its OWN section, above the per-step block it governs. Sitting inside
241
+ // that block it read as a twelfth step called `reasoning` — a global
242
+ // override disguised as one more per-step row.
243
+ section: 'profile',
244
+ label: 'profile',
245
+ description: 'How much every step below thinks, in one word. "default" uses the per-step '
246
+ + 'table pi-task has measured, "on" and "off" force one answer everywhere and '
247
+ + 'IGNORE the rows below, and "custom" is whatever those rows say. They always '
248
+ + 'show what each step actually runs at, so changing one switches this to '
249
+ + 'custom. A step left on "inherit" uses whatever thinking level pi itself is '
250
+ + 'set to, which is what every step did before this setting existed',
223
251
  values: [...REASONING_MODES],
224
252
  format: cfg => String(cfg.reasoningMode),
225
253
  apply: (cfg, chosen) => {
@@ -322,9 +350,9 @@ export function applyToolToggle(exempt, toolName, watched) {
322
350
  * though the custom table underneath is untouched, which is the honest
323
351
  * answer to "what will my next child do".
324
352
  */
325
- const REASON_ID_PREFIX = 'reason:';
353
+ const STEP_ID_PREFIX = 'step:';
326
354
  /**
327
- * The label for one `think:` row.
355
+ * The label for one step row.
328
356
  *
329
357
  * A group whose name carries a colon is a CHILD of the group before the colon —
330
358
  * `research:files` is one of the four workers `research` fans out to. Rendered
@@ -333,37 +361,206 @@ const REASON_ID_PREFIX = 'reason:';
333
361
  * parent is also a row.
334
362
  *
335
363
  * So a child is drawn as a tree branch under its parent and loses the repeated
336
- * `think: research:` prefix, the same text at the head of four consecutive
337
- * lines. `└─` on the last child, `├─` on the rest, decided from the group's
338
- * position in {@link REASONING_GROUPS} rather than a hand-kept list — adding a
339
- * fifth worker moves the corner on its own.
364
+ * `research:` prefix, the same text at the head of four consecutive lines. `└─`
365
+ * on the last child, `├─` on the rest, decided from the group's position in
366
+ * {@link CHILD_GROUPS} rather than a hand-kept list — adding a fifth worker
367
+ * moves the corner on its own.
368
+ *
369
+ * A parentless group is its own bare name. It carried a `think: ` prefix while
370
+ * there were two families to tell apart; with one row per step there is nothing
371
+ * to disambiguate, and the prefix was the widest thing in the column.
340
372
  *
341
373
  * Leading spaces survive: SettingsList pads the label right, never trims it.
342
374
  */
343
- export function reasoningRowLabel(group) {
375
+ export function stepRowLabel(group) {
344
376
  const colon = group.indexOf(':');
345
377
  if (colon < 0)
346
- return `think: ${group}`;
378
+ return group;
347
379
  const parent = group.slice(0, colon);
348
- const nextIsSibling = REASONING_GROUPS[REASONING_GROUPS.indexOf(group) + 1]?.startsWith(`${parent}:`);
380
+ const nextIsSibling = CHILD_GROUPS[CHILD_GROUPS.indexOf(group) + 1]?.startsWith(`${parent}:`);
349
381
  return ` ${nextIsSibling ? '├─' : '└─'} ${group.slice(colon + 1)}`;
350
382
  }
351
- export function reasoningItems() {
352
- return REASONING_GROUPS.map(group => ({
353
- id: REASON_ID_PREFIX + group,
383
+ /** No registry reachable. Every row still renders; nothing narrows. */
384
+ export const EMPTY_CATALOG = { specs: [], facts: () => undefined };
385
+ /**
386
+ * The levels a row may offer, given the model that row's group will run on.
387
+ *
388
+ * The INTERSECTION with `REASONING_SETTINGS`, not `supportedThinkingLevels`
389
+ * directly: that returns the whole ladder including `xhigh` and `max`, which
390
+ * this menu excludes on purpose (see reasoning.ts) because pi's own UI may not
391
+ * offer them. A model declaring `xhigh` must not smuggle it in here.
392
+ */
393
+ export function offeredLevels(facts) {
394
+ if (facts === undefined)
395
+ return [...REASONING_SETTINGS];
396
+ const supported = supportedThinkingLevels(facts);
397
+ return REASONING_SETTINGS.filter(s => s === 'inherit' || supported.includes(s));
398
+ }
399
+ /** The separator between a step row's two halves. */
400
+ const PAIR_SEP = ' \u00b7 ';
401
+ /**
402
+ * `level · provider/id`, the one string a step row shows and accepts.
403
+ *
404
+ * THE LEVEL COMES FIRST, and that is a display decision with teeth.
405
+ * `SettingsList` truncates a value from the RIGHT, so whatever is last is what
406
+ * silently disappears — and a real local model id (`local/Qwen3.8-27B-UD-Q4_K_XL
407
+ * .gguf`) is wide enough to consume the whole column on its own. Level-first
408
+ * means the half that falls off is the one still identifiable from its head, and
409
+ * the levels line up as a column you can read down.
410
+ */
411
+ export function formatStepValue(spec, level) {
412
+ return `${level}${PAIR_SEP}${spec}`;
413
+ }
414
+ /**
415
+ * The two halves back out, or `undefined` for anything not of that shape.
416
+ *
417
+ * Split on the FIRST separator, because the level leads and is a closed set,
418
+ * while a `provider/id` could conceivably contain one.
419
+ */
420
+ export function parseStepValue(value) {
421
+ const at = value.indexOf(PAIR_SEP);
422
+ if (at <= 0)
423
+ return undefined;
424
+ const level = value.slice(0, at);
425
+ const spec = value.slice(at + PAIR_SEP.length);
426
+ if (spec === '' || !REASONING_SETTINGS.includes(level))
427
+ return undefined;
428
+ return { spec, level: level };
429
+ }
430
+ /**
431
+ * One row per step, carrying BOTH dials.
432
+ *
433
+ * They were two parallel blocks of eleven, and the coupling between them was
434
+ * invisible: choosing a model re-clamps that step's thinking level, but the row
435
+ * that moved was eleven rows away from the row you touched. One row shows the
436
+ * pair, and the two-step picker shows the clamp happening.
437
+ *
438
+ * `values` is the LEGAL cross product — every model against only the levels that
439
+ * model declares. Nothing renders it: the picker offers two short lists, and the
440
+ * round-trip property in config-items.test.ts is its only reader. Building it
441
+ * from `offeredLevels` rather than the full ladder is what makes the property
442
+ * true, because a pair the model cannot honour would be clamped by `apply` and
443
+ * would not round-trip.
444
+ */
445
+ export function stepItems(catalog = EMPTY_CATALOG) {
446
+ const specs = [MODEL_INHERIT, ...catalog.specs];
447
+ return CHILD_GROUPS.map(group => ({
448
+ id: STEP_ID_PREFIX + group,
354
449
  section: 'reasoning',
355
- label: reasoningRowLabel(group),
356
- headlessLabel: `think: ${group}`,
357
- description: REASONING_GROUP_HELP[group],
358
- values: [...REASONING_SETTINGS],
359
- // The EFFECTIVE level, not cfg.reasoningLevels[group]: in default/on/off
360
- // the stored table is not what runs, and a row that shows a value the
361
- // run does not use is worse than no row. As a FUNCTION rather than a
362
- // snapshot, so `syncRows` can re-ask after any change.
363
- format: cfg => resolveReasoning(group, cfg),
364
- apply: (cfg, chosen) => applyReasoningLevel(cfg, group, chosen)
450
+ label: stepRowLabel(group),
451
+ headlessLabel: `step: ${group}`,
452
+ description: STEP_GROUP_HELP[group],
453
+ values: specs.flatMap(spec => offeredLevels(catalog.facts(spec)).map(level => formatStepValue(spec, level))),
454
+ // Built at ENTER-time from the LIVE draft, not when the rows were made:
455
+ // stage two narrows to the model chosen in stage one, and the user can
456
+ // have changed another row since the panel opened.
457
+ picker: cfg => stepPicker(group, cfg, catalog),
458
+ // The model half VERBATIM even when the catalog no longer offers it — the
459
+ // vanished-model case, where the row is the only place the user can see
460
+ // what their config actually holds. The thinking half is the EFFECTIVE
461
+ // level, not the stored cell: in mode default/on/off the stored table is
462
+ // not what runs, and a row showing a value the run does not use is worse
463
+ // than no row.
464
+ format: cfg => formatStepValue(cfg.groupModels[group], resolveReasoning(group, cfg)),
465
+ apply: (cfg, chosen) => applyStepValue(cfg, group, chosen, catalog)
365
466
  }));
366
467
  }
468
+ /**
469
+ * The two lists behind one step row.
470
+ *
471
+ * Stage two is where the coupling becomes visible. It offers only the levels the
472
+ * chosen model declares, and it OPENS on the level that will actually run — the
473
+ * current one when that model can honour it, otherwise the clamp, with the
474
+ * reason written beside it. Picking a model that cannot think is therefore not a
475
+ * silent downgrade discovered later; it is the option the cursor is already on.
476
+ */
477
+ function stepPicker(group, cfg, catalog) {
478
+ const held = cfg.groupModels[group];
479
+ // The row's CURRENT model leads stage one when the catalog cannot offer it.
480
+ // Without this, opening the row to nudge only the level would silently
481
+ // rewrite the model to `inherit`: `FilterList` falls back to index 0 when
482
+ // the preselect matches nothing, and the two dials are one row now, so there
483
+ // is no way to touch the level without confirming a model. That would erase
484
+ // a spec set on the user's other machine — the one thing the loader, the
485
+ // format function and the startup hint all go out of their way to preserve.
486
+ const missing = held !== MODEL_INHERIT && !catalog.specs.includes(held);
487
+ return {
488
+ first: [
489
+ ...(missing ?
490
+ [{ value: held, label: held, description: 'not available here — kept as-is' }]
491
+ : []),
492
+ { value: MODEL_INHERIT, label: MODEL_INHERIT, description: "pi's own default" },
493
+ ...catalog.specs.map(spec => ({
494
+ value: spec,
495
+ label: spec,
496
+ ...(catalog.note?.(spec) === undefined ? {} : { description: catalog.note(spec) })
497
+ }))
498
+ ],
499
+ second: spec => {
500
+ const facts = catalog.facts(spec);
501
+ const offered = offeredLevels(facts);
502
+ const wanted = resolveReasoning(group, cfg);
503
+ const clamped = facts === undefined || wanted === 'inherit' ?
504
+ wanted
505
+ : clampToModel(facts, wanted);
506
+ // Back inside the menu's own vocabulary. `clampToModel` walks UP
507
+ // first and knows the whole ladder, so a model declaring `xhigh`
508
+ // can land on a level `offeredLevels` deliberately excludes — and
509
+ // then stage two would open on `inherit` with the explanation
510
+ // attached to no row at all.
511
+ const runs = offered.includes(clamped) ? clamped : (offered.at(-1) ?? 'inherit');
512
+ return {
513
+ options: offered.map(level => ({
514
+ value: level,
515
+ label: level,
516
+ ...(level === runs && runs !== wanted ?
517
+ { description: `${spec} cannot do ${wanted}` }
518
+ : {})
519
+ })),
520
+ preselect: runs
521
+ };
522
+ },
523
+ firstOf: value => parseStepValue(value)?.spec ?? value,
524
+ join: (spec, level) => formatStepValue(spec, level)
525
+ };
526
+ }
527
+ /**
528
+ * Write both halves of a step row, atomically.
529
+ *
530
+ * Atomically matters for the round-trip property, which starts from a FRESH
531
+ * config every iteration: a value that wrote only one half would leave the other
532
+ * at its default and render as something else.
533
+ *
534
+ * The level is re-clamped even though the picker only ever offers legal pairs.
535
+ * The picker is not the only door — `values` is built when the panel opens, and
536
+ * a registry that moved underneath it would otherwise let an unhonourable level
537
+ * through.
538
+ */
539
+ export function applyStepValue(cfg, group, chosen, catalog) {
540
+ const pair = parseStepValue(chosen);
541
+ if (pair === undefined)
542
+ return;
543
+ // MEMBERSHIP, not just shape: the panel may only ever write what the picker
544
+ // showed. That is `inherit`, the catalog's own specs, and — when the catalog
545
+ // cannot offer it — the spec this cell ALREADY holds, which stage one keeps
546
+ // at its head precisely so the level can be changed without discarding it.
547
+ // Re-writing the value that is already there is not a new unresolvable spec.
548
+ if (pair.spec !== MODEL_INHERIT
549
+ && pair.spec !== cfg.groupModels[group]
550
+ && !catalog.specs.includes(pair.spec)) {
551
+ return;
552
+ }
553
+ cfg.groupModels = { ...cfg.groupModels, [group]: pair.spec };
554
+ const facts = catalog.facts(pair.spec);
555
+ const level = facts === undefined || pair.level === 'inherit' ?
556
+ pair.level
557
+ : clampToModel(facts, pair.level);
558
+ // Only when it MOVES something: `applyReasoningLevel` flips the whole table
559
+ // to `custom`, and picking a pair the config already runs must not do that
560
+ // as a side effect.
561
+ if (level !== resolveReasoning(group, cfg))
562
+ applyReasoningLevel(cfg, group, level);
563
+ }
367
564
  /**
368
565
  * Apply one group row's new value.
369
566
  *
@@ -441,18 +638,29 @@ const DOWN_KEY = '\x1b[B';
441
638
  *
442
639
  * It drives the list through its own public `handleInput` — pressing the very
443
640
  * key the user pressed, N times — rather than reaching for the private
444
- * `selectedIndex`. The mirror it keeps cannot drift: with search off and no
445
- * submenus, up and down are the only two things that move that index.
641
+ * `selectedIndex`. With search off, up and down are the only two things that
642
+ * move that index EXCEPT while a submenu is open, when `SettingsList` forwards
643
+ * everything to the submenu and returns without moving it at all. So a picker
644
+ * suspends this entirely; see `suspended`.
446
645
  */
447
646
  class SkipInertRows {
448
647
  list;
449
648
  selectable;
649
+ suspended;
450
650
  index = 0;
451
651
  constructor(list,
452
652
  /** True where a row can be selected, in the list's own order. */
453
- selectable) {
653
+ selectable,
654
+ /**
655
+ * True while a picker is open. The list then owns every key, so this
656
+ * must not intercept — see the flag's own comment in
657
+ * {@link createSettingsPanel}. Defaulted so the constructor's own
658
+ * opening walk, and every existing test, are unaffected.
659
+ */
660
+ suspended = () => false) {
454
661
  this.list = list;
455
662
  this.selectable = selectable;
663
+ this.suspended = suspended;
456
664
  // The first row is a header, so the panel would open on it. Only
457
665
  // synthesise the keypress if it is actually bound to "down" — feeding
458
666
  // a key the list ignores would move the mirror and not the cursor.
@@ -471,9 +679,10 @@ class SkipInertRows {
471
679
  }
472
680
  handleInput(data) {
473
681
  const kb = getKeybindings();
474
- const step = kb.matches(data, 'tui.select.down') ? 1
475
- : kb.matches(data, 'tui.select.up') ? -1
476
- : 0;
682
+ const step = this.suspended() ? 0
683
+ : kb.matches(data, 'tui.select.down') ? 1
684
+ : kb.matches(data, 'tui.select.up') ? -1
685
+ : 0;
477
686
  if (step === 0) {
478
687
  this.list.handleInput(data);
479
688
  return;
@@ -502,16 +711,37 @@ export function createSettingsPanel(items, theme,
502
711
  * Called with the row's id, its new value, and the LIST ITSELF.
503
712
  *
504
713
  * The list is handed back because some rows change what OTHER rows display:
505
- * flipping `reasoning` to off means every `think:` row now runs at off, and
506
- * a row's `currentValue` is a snapshot taken when the panel was built.
507
- * Without a way to write the others back, the menu would show
508
- * `reasoning off` beside rows still claiming `inherit`.
714
+ * flipping `profile` to off means every `step:` row now runs at off, and a
715
+ * row's `currentValue` is a snapshot taken when the panel was built. Without
716
+ * a way to write the others back, the menu would show `profile off` beside
717
+ * rows still claiming `inherit`.
509
718
  */
510
719
  onChange, onCancel) {
511
- // A row with no `values` is a header or the blank line above one.
512
- const headerLabels = new Set(items.filter(i => i.values === undefined).map(i => i.label));
513
- const list = new SettingsList(items, MAX_VISIBLE, makeTheme(theme, label => headerLabels.has(label.trimEnd())), (id, newValue) => onChange(id, newValue, list), onCancel);
514
- 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));
720
+ const headerLabels = new Set(items.filter(isSectionRow).map(i => i.label));
721
+ /**
722
+ * True while a picker is open, so SkipInertRows stops intercepting arrows.
723
+ *
724
+ * `SettingsList.handleInput` delegates to an open submenu and RETURNS, so
725
+ * `selectedIndex` never moves while one is up. SkipInertRows keeps its own
726
+ * mirror of that index and replays the key once per row it skips — so
727
+ * without this flag, one arrow press inside a picker moves the mirror off
728
+ * the real cursor AND arrives in the picker two or three times when the walk
729
+ * crosses a section boundary.
730
+ */
731
+ let submenuOpen = false;
732
+ const settingItems = items.map(({ pickerOptions, ...row }) => pickerOptions === undefined ? row : ({
733
+ ...row,
734
+ submenu: (currentValue, done) => {
735
+ submenuOpen = true;
736
+ return new PairPicker(pickerOptions(), currentValue, theme, v => {
737
+ submenuOpen = false;
738
+ done(v);
739
+ });
740
+ }
741
+ }));
742
+ const list = new SettingsList(settingItems, MAX_VISIBLE, makeTheme(theme, label => headerLabels.has(label.trimEnd())), (id, newValue) => onChange(id, newValue, list), onCancel);
743
+ return new BorderedBox(new SkipInertRows(list, items.map(i => !isSectionRow(i)
744
+ && ((i.values?.length ?? 0) > 0 || i.pickerOptions !== undefined)), () => submenuOpen), 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));
515
745
  }
516
746
  /**
517
747
  * Every settings row for this session, fixed and DISCOVERED, in menu order.
@@ -524,11 +754,14 @@ onChange, onCancel) {
524
754
  * Fixed rows come before discovered ones within a section, so a freshly
525
755
  * installed extension appends rather than reshuffling the menu.
526
756
  */
527
- export function configRows(installed, tools = []) {
757
+ export function configRows(installed, tools = [],
758
+ // A third positional with a default, exactly like `tools`, so every existing
759
+ // test stays deterministic and no test has to know a registry exists.
760
+ catalog = EMPTY_CATALOG) {
528
761
  // The discovered rows carry a section like every other row — the per-tool
529
762
  // watchdog exemptions under `timeouts` (they are exemptions FROM that
530
763
  // timeout), and the per-extension toggles under their own heading.
531
- return [...ITEMS, ...reasoningItems(), ...toolItems(tools), ...extensionItems(installed)];
764
+ return [...ITEMS, ...stepItems(catalog), ...toolItems(tools), ...extensionItems(installed)];
532
765
  }
533
766
  /** Render `rows` for the current config, grouped under their section headers. */
534
767
  export function renderRows(cfg, rows) {
@@ -542,6 +775,7 @@ export function renderRows(cfg, rows) {
542
775
  description: i.description,
543
776
  currentValue: i.format(cfg),
544
777
  values: i.values ?? ['on', 'off'],
778
+ ...(i.picker === undefined ? {} : { pickerOptions: () => i.picker(cfg) }),
545
779
  ...(i.headlessLabel === undefined ? {} : { headlessLabel: i.headlessLabel })
546
780
  }));
547
781
  // An empty section prints no header. `extensions` has no fixed rows at
@@ -555,15 +789,15 @@ export function renderRows(cfg, rows) {
555
789
  return out;
556
790
  }
557
791
  /** The full settings row list for the current config, in menu order. */
558
- export function panelItems(cfg, installed, tools = []) {
559
- return renderRows(cfg, configRows(installed, tools));
792
+ export function panelItems(cfg, installed, tools = [], catalog = EMPTY_CATALOG) {
793
+ return renderRows(cfg, configRows(installed, tools, catalog));
560
794
  }
561
795
  /**
562
796
  * Re-ask every row what it now displays, and write the answers back.
563
797
  *
564
798
  * A row's `currentValue` in the live list is a snapshot taken when the panel was
565
799
  * built, and rows describe each other: cycling `reasoning` to `off` changes what
566
- * every `think:` row runs at, and cycling one group row flips the mode, which
800
+ * every `step:` row runs at, and setting one step row flips the profile, which
567
801
  * changes all the others.
568
802
  *
569
803
  * This runs after ANY change, over EVERY row. Re-reading a `format` costs
@@ -574,6 +808,59 @@ export function syncRows(cfg, rows, list) {
574
808
  for (const row of rows)
575
809
  list.updateValue(row.id, row.format(cfg));
576
810
  }
811
+ /**
812
+ * The step rows' model offer list, read live when the menu opens.
813
+ *
814
+ * `getAvailable()`, never `getAll()`: an unauthed model would spawn a child that
815
+ * exits 1 on every phase of that group, and offering it would be offering a
816
+ * config that cannot work.
817
+ *
818
+ * A provider registered by a host EXTENSION is offered with a note rather than
819
+ * hidden. Children run `--no-extensions`, which disables discovery only — pi
820
+ * still loads every explicit `-e` path, and `childBaseArgs` injects one per
821
+ * whitelisted extension. So such a model works in a child exactly when its
822
+ * extension is whitelisted, and we cannot tell which extension that is:
823
+ * `getRegisteredProviderIds()` gives ids, and the `{name, config, extensionPath}`
824
+ * triples live in the runner's internal state, drained at bind. Choosing it with
825
+ * the wrong whitelist fails LOUDLY — the child sees no such provider, so pi's
826
+ * resolver reports "not found" and exits 1 — which is why a note is enough.
827
+ */
828
+ function liveCatalog(ctx) {
829
+ // Same contract as the tool enumeration above, for the same reason: the
830
+ // model runtime is not guaranteed usable at the moment a command runs, and a
831
+ // registry that cannot answer must cost the model rows, never the menu.
832
+ // Every row below still renders; `EMPTY_CATALOG` offers only `inherit` and
833
+ // narrows nothing, which is exactly the pre-feature panel.
834
+ let registry;
835
+ let available;
836
+ let fromExtension;
837
+ try {
838
+ registry = ctx.modelRegistry;
839
+ available = registry.getAvailable();
840
+ fromExtension = new Set(registry.getRegisteredProviderIds());
841
+ }
842
+ catch {
843
+ return EMPTY_CATALOG;
844
+ }
845
+ return {
846
+ specs: available.map(m => `${m.provider}/${m.id}`),
847
+ note: spec => {
848
+ const parts = splitSpec(spec);
849
+ return parts && fromExtension.has(parts.provider) ?
850
+ 'provider comes from an extension — whitelist it under child extensions, '
851
+ + "or this group's children exit 1"
852
+ : undefined;
853
+ },
854
+ facts: spec => {
855
+ // `inherit` means the session's own model, which is what a child
856
+ // resolves today. Its facts are what the thinking row must narrow to.
857
+ if (spec === MODEL_INHERIT)
858
+ return ctx.model;
859
+ const parts = splitSpec(spec);
860
+ return parts ? registry.find(parts.provider, parts.id) : undefined;
861
+ }
862
+ };
863
+ }
577
864
  async function handleTaskConfig(_args, ctx, getTools = () => []) {
578
865
  const cfg = {
579
866
  ...getConfig(),
@@ -582,7 +869,8 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
582
869
  // Copied for the same reason as the two arrays above: the panel mutates
583
870
  // its own draft, and sharing the live object would apply half-made
584
871
  // choices to running children before the user finished choosing.
585
- reasoningLevels: { ...getConfig().reasoningLevels }
872
+ reasoningLevels: { ...getConfig().reasoningLevels },
873
+ groupModels: { ...getConfig().groupModels }
586
874
  };
587
875
  // Enumerated live at open so an installed extension appears and an
588
876
  // uninstalled one vanishes without pi-task doing any bookkeeping. A failed
@@ -592,17 +880,18 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
592
880
  // one: getAllTools() throws until the extension runtime is initialized, so
593
881
  // it can only be read here, when the menu opens, never at registration.
594
882
  const tools = getTools();
883
+ const catalog = liveCatalog(ctx);
595
884
  if (ctx.mode !== 'tui') {
596
885
  // Built from panelItems and reading the SAME `format` the panel does,
597
886
  // so the two renderings cannot disagree about what a setting says. A
598
887
  // second walk of the same tables is the one place nobody would notice
599
888
  // them drifting, because a headless run has no panel to compare against.
600
- const lines = panelItems(cfg, installed, tools)
889
+ const lines = panelItems(cfg, installed, tools, catalog)
601
890
  // The blank rows between sections are there to give the TUI air.
602
891
  // One line of `|`-joined text has none to give, and an empty label
603
892
  // would print as a stray `[]`.
604
893
  .filter(i => i.label !== '')
605
- .map(i => i.values === undefined ?
894
+ .map(i => isSectionRow(i) ?
606
895
  `[${i.label.trim()}]`
607
896
  : `${(i.headlessLabel ?? i.label).padEnd(22)} ${i.currentValue}`);
608
897
  ctx.ui.notify(lines.join(' | '), 'info');
@@ -610,7 +899,7 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
610
899
  }
611
900
  // Built ONCE and shared by the renderer, the dispatch and the refresh, so
612
901
  // all three necessarily agree about which rows exist.
613
- const rows = configRows(installed, tools);
902
+ const rows = configRows(installed, tools, catalog);
614
903
  await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(renderRows(cfg, rows), theme, (id, newValue, list) => {
615
904
  // Every row parses its own value. There is no generic
616
905
  // fallback and no prefix ladder, so a row that forgets to
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { registerRemote } from './remote/register.js';
7
7
  import { registerCommandWatchdog } from './task/command-watchdog.js';
8
8
  import { registerStreamWatchdog } from './task/stream-watchdog.js';
9
9
  import { registerImplementationGuards } from './task/implementation-guards.js';
10
+ import { registerModelHoldRestore } from './task/model-hold-stash.js';
10
11
  export default function (pi) {
11
12
  registerConfig(pi);
12
13
  registerTask(pi);
@@ -17,4 +18,5 @@ export default function (pi) {
17
18
  registerCommandWatchdog(pi);
18
19
  registerStreamWatchdog(pi);
19
20
  registerImplementationGuards(pi);
21
+ registerModelHoldRestore(pi);
20
22
  }
@@ -1,13 +1,7 @@
1
1
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import os from 'node:os';
3
2
  import path from 'node:path';
4
3
  import webpush from 'web-push';
5
- /** Resolve the XDG data-home base. Same env-or-homedir shape the docs cache uses
6
- * (workers/docs-core.ts), but rooted at data-home rather than cache-home: what
7
- * lives under here is not reconstructible by re-fetching. */
8
- function dataHome() {
9
- return process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), '.local', 'share');
10
- }
4
+ import { dataHome } from '../shared/data-home.js';
11
5
  /** Where the VAPID keypair is persisted. The browser subscribes with this
12
6
  * public key as its applicationServerKey (see ui-script.ts), so the pair has to
13
7
  * outlive the process that created it. */
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The XDG data-home base. Same env-or-homedir shape the docs cache uses
3
+ * (workers/docs-core.ts), but rooted at data-home rather than cache-home: what
4
+ * lives under here is not reconstructible by re-fetching.
5
+ */
6
+ export declare function dataHome(): string;
7
+ /** A file under this machine's pi-task state directory. */
8
+ export declare function stateFile(name: string): string;
@@ -0,0 +1,14 @@
1
+ import * as os from 'node:os';
2
+ import * as path from 'node:path';
3
+ /**
4
+ * The XDG data-home base. Same env-or-homedir shape the docs cache uses
5
+ * (workers/docs-core.ts), but rooted at data-home rather than cache-home: what
6
+ * lives under here is not reconstructible by re-fetching.
7
+ */
8
+ export function dataHome() {
9
+ return process.env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), '.local', 'share');
10
+ }
11
+ /** A file under this machine's pi-task state directory. */
12
+ export function stateFile(name) {
13
+ return path.join(dataHome(), 'pi-task', name);
14
+ }