@mjasnikovs/pi-task 0.38.30 → 0.38.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +2 -1
  2. package/dist/config/config.d.ts +16 -2
  3. package/dist/config/config.js +7 -2
  4. package/dist/config/group-args.d.ts +52 -0
  5. package/dist/config/group-args.js +110 -0
  6. package/dist/config/group-models.d.ts +88 -0
  7. package/dist/config/group-models.js +117 -0
  8. package/dist/config/groups.d.ts +76 -0
  9. package/dist/config/groups.js +110 -0
  10. package/dist/config/option-picker.d.ts +42 -0
  11. package/dist/config/option-picker.js +73 -0
  12. package/dist/config/reasoning.d.ts +22 -63
  13. package/dist/config/reasoning.js +37 -108
  14. package/dist/config/register.d.ts +98 -12
  15. package/dist/config/register.js +228 -23
  16. package/dist/index.js +4 -0
  17. package/dist/remote/push.js +1 -7
  18. package/dist/shared/command-watchdog.d.ts +63 -0
  19. package/dist/shared/command-watchdog.js +87 -0
  20. package/dist/shared/data-home.d.ts +8 -0
  21. package/dist/shared/data-home.js +14 -0
  22. package/dist/shared/model-endpoint.d.ts +53 -0
  23. package/dist/shared/model-endpoint.js +98 -2
  24. package/dist/shared/reasoning-capability.d.ts +25 -5
  25. package/dist/shared/reasoning-capability.js +18 -9
  26. package/dist/task/auto-orchestrator.js +14 -3
  27. package/dist/task/child-runner.d.ts +92 -15
  28. package/dist/task/child-runner.js +303 -66
  29. package/dist/task/context-usage.d.ts +46 -0
  30. package/dist/task/context-usage.js +41 -0
  31. package/dist/task/failure-classifier.js +24 -1
  32. package/dist/task/gate-child.d.ts +15 -4
  33. package/dist/task/gate-child.js +2 -2
  34. package/dist/task/gate-deps.js +7 -2
  35. package/dist/task/implementation-guards.d.ts +26 -0
  36. package/dist/task/implementation-guards.js +177 -0
  37. package/dist/task/implementation-hold.d.ts +118 -0
  38. package/dist/task/implementation-hold.js +165 -0
  39. package/dist/task/implementation-turn.d.ts +5 -0
  40. package/dist/task/implementation-turn.js +12 -1
  41. package/dist/task/loop-detector.d.ts +18 -0
  42. package/dist/task/loop-detector.js +22 -2
  43. package/dist/task/model-hold-stash.d.ts +43 -0
  44. package/dist/task/model-hold-stash.js +70 -0
  45. package/dist/task/orchestrator.d.ts +18 -5
  46. package/dist/task/orchestrator.js +63 -6
  47. package/dist/task/phases.js +18 -5
  48. package/dist/task/research-worker.d.ts +2 -2
  49. package/dist/task/research-worker.js +1 -1
  50. package/dist/workers/docs-core.js +2 -2
  51. package/dist/workers/docs-lookup.d.ts +4 -3
  52. package/dist/workers/docs-lookup.js +1 -1
  53. package/dist/workers/fetch-core.js +2 -2
  54. package/dist/workers/focused-extractor.d.ts +6 -4
  55. package/dist/workers/focused-extractor.js +17 -5
  56. package/dist/workers/index.js +2 -0
  57. package/dist/workers/model-warning.d.ts +69 -0
  58. package/dist/workers/model-warning.js +113 -0
  59. package/dist/workers/pi-worker-core.d.ts +9 -38
  60. package/dist/workers/pi-worker-core.js +8 -86
  61. package/dist/workers/pi-worker-docs.js +2 -2
  62. package/dist/workers/pi-worker.js +4 -4
  63. package/dist/workers/reasoning-warning.d.ts +17 -9
  64. package/dist/workers/reasoning-warning.js +69 -22
  65. package/dist/workers/single-read-guard.d.ts +6 -6
  66. package/dist/workers/single-read-guard.js +8 -8
  67. package/dist/workers/worker-profiles.d.ts +11 -3
  68. package/dist/workers/worker-profiles.js +33 -1
  69. package/package.json +1 -1
  70. package/dist/config/reasoning-args.d.ts +0 -23
  71. package/dist/config/reasoning-args.js +0 -28
  72. package/dist/task/implementation-thinking.d.ts +0 -56
  73. 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 { isModelSpec, MODEL_INHERIT, splitSpec } from './group-models.js';
4
+ import { OptionPicker } 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, REASONING_GROUP_HELP, MODEL_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,6 +76,11 @@ export const SECTIONS = [
73
76
  { key: 'session', title: 'session' },
74
77
  { key: 'checks', title: 'after each task' },
75
78
  { key: 'research', title: 'research' },
79
+ // Immediately before `reasoning`, and a BLOCK of its own rather than a
80
+ // second column on the think: rows. Interleaving would double one block to
81
+ // 22 rows and print the `├─ files` tree branches twice, meaning two
82
+ // different things a line apart.
83
+ { key: 'models', title: 'models' },
76
84
  { key: 'reasoning', title: 'reasoning' },
77
85
  { key: 'unattended', title: 'unattended' },
78
86
  { key: 'logging', title: 'logging' },
@@ -83,6 +91,16 @@ export const SECTIONS = [
83
91
  ];
84
92
  /** Marks a header row, so onChange can ignore one and tests can find them. */
85
93
  export const SECTION_ID_PREFIX = 'section:';
94
+ /**
95
+ * Is this row scenery rather than a setting?
96
+ *
97
+ * Reads the ID, not `values`. Those two agreed only while every real row cycled
98
+ * a list: a picker row has a `submenu` and may carry `values` purely for the
99
+ * round-trip contract, so "no values" stopped meaning "not a row". Both
100
+ * `sectionHeader` and `sectionGap` already stamp the prefix, so this is exact
101
+ * rather than a heuristic, and all three consumers ask the same question.
102
+ */
103
+ export const isSectionRow = (item) => item.id.startsWith(SECTION_ID_PREFIX);
86
104
  /**
87
105
  * An inert titled row. No `values` ⇒ SettingsList's Enter handler no-ops on it,
88
106
  * and {@link SkipInertRows} steps the cursor straight over it.
@@ -335,7 +353,7 @@ const REASON_ID_PREFIX = 'reason:';
335
353
  * So a child is drawn as a tree branch under its parent and loses the repeated
336
354
  * `think: research:` prefix, the same text at the head of four consecutive
337
355
  * 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
356
+ * position in {@link CHILD_GROUPS} rather than a hand-kept list — adding a
339
357
  * fifth worker moves the corner on its own.
340
358
  *
341
359
  * Leading spaces survive: SettingsList pads the label right, never trims it.
@@ -345,17 +363,106 @@ export function reasoningRowLabel(group) {
345
363
  if (colon < 0)
346
364
  return `think: ${group}`;
347
365
  const parent = group.slice(0, colon);
348
- const nextIsSibling = REASONING_GROUPS[REASONING_GROUPS.indexOf(group) + 1]?.startsWith(`${parent}:`);
366
+ const nextIsSibling = CHILD_GROUPS[CHILD_GROUPS.indexOf(group) + 1]?.startsWith(`${parent}:`);
349
367
  return ` ${nextIsSibling ? '├─' : '└─'} ${group.slice(colon + 1)}`;
350
368
  }
351
- export function reasoningItems() {
352
- return REASONING_GROUPS.map(group => ({
369
+ const MODEL_ID_PREFIX = 'model:';
370
+ /** No registry reachable. Every row still renders; nothing narrows. */
371
+ export const EMPTY_CATALOG = { specs: [], facts: () => undefined };
372
+ export function modelItems(catalog) {
373
+ return CHILD_GROUPS.map(group => ({
374
+ id: MODEL_ID_PREFIX + group,
375
+ section: 'models',
376
+ label: reasoningRowLabel(group).replace('think: ', 'model: '),
377
+ headlessLabel: `model: ${group}`,
378
+ description: MODEL_GROUP_HELP[group],
379
+ // The COMPLETE accepted vocabulary, which is what the round-trip
380
+ // property quantifies over. With nothing discovered it is `['inherit']`:
381
+ // length 1, still selectable, still not a header. No degenerate case.
382
+ values: [MODEL_INHERIT, ...catalog.specs],
383
+ submenu: () => [
384
+ { value: MODEL_INHERIT, label: MODEL_INHERIT, description: "pi's own default" },
385
+ ...catalog.specs.map(spec => ({
386
+ value: spec,
387
+ label: spec,
388
+ ...(catalog.note?.(spec) === undefined ? {} : { description: catalog.note(spec) })
389
+ }))
390
+ ],
391
+ // VERBATIM, even when absent from `values` — the vanished-model case,
392
+ // where the row must still say what the config holds so the hint that
393
+ // names it has something to point at.
394
+ format: cfg => cfg.groupModels[group],
395
+ apply: (cfg, chosen) => applyGroupModel(cfg, group, chosen, catalog)
396
+ }));
397
+ }
398
+ /**
399
+ * Write a group's model, and RE-CLAMP its thinking cell in the same write.
400
+ *
401
+ * Narrowing the picker does nothing about a level already stored from before the
402
+ * model was chosen: on its own it would freeze a lie into a cell it has just
403
+ * made unconfigurable. `syncRows` re-renders every row, so the user sees the
404
+ * level move.
405
+ *
406
+ * The clamp reads `resolveReasoning`, NOT `cfg.reasoningLevels[group]`. In mode
407
+ * `on` or `off` the stored table is ignored entirely, so a user in `on` who
408
+ * picks a non-reasoning model has a stored cell that says nothing and an
409
+ * effective `medium` the model will erase — and comparing the stored cell would
410
+ * see no clamp and stay silent about a real lie.
411
+ *
412
+ * Guarded on the clamp actually MOVING something, because `applyReasoningLevel`
413
+ * flips the whole table to `custom`, and picking a fully-capable model must not
414
+ * do that as a side effect.
415
+ */
416
+ export function applyGroupModel(cfg, group, chosen, catalog) {
417
+ // MEMBERSHIP, not just shape. A stored spec naming a vanished model must
418
+ // survive — that is the sanitizer's job, and `format` still renders it — but
419
+ // it may only ever ARRIVE here from the picker, which offers exactly these.
420
+ // Without this, the panel could write a spec this machine cannot resolve.
421
+ if (!isModelSpec(chosen))
422
+ return;
423
+ if (chosen !== MODEL_INHERIT && !catalog.specs.includes(chosen))
424
+ return;
425
+ cfg.groupModels = { ...cfg.groupModels, [group]: chosen };
426
+ const facts = catalog.facts(chosen);
427
+ if (facts === undefined)
428
+ return;
429
+ const wanted = resolveReasoning(group, cfg);
430
+ if (wanted === 'inherit')
431
+ return;
432
+ const clamped = clampToModel(facts, wanted);
433
+ if (clamped !== wanted)
434
+ applyReasoningLevel(cfg, group, clamped);
435
+ }
436
+ /**
437
+ * The levels a row may offer, given the model that row's group will run on.
438
+ *
439
+ * The INTERSECTION with `REASONING_SETTINGS`, not `supportedThinkingLevels`
440
+ * directly: that returns the whole ladder including `xhigh` and `max`, which
441
+ * this menu excludes on purpose (see reasoning.ts) because pi's own UI may not
442
+ * offer them. A model declaring `xhigh` must not smuggle it in here.
443
+ */
444
+ export function offeredLevels(facts) {
445
+ if (facts === undefined)
446
+ return [...REASONING_SETTINGS];
447
+ const supported = supportedThinkingLevels(facts);
448
+ return REASONING_SETTINGS.filter(s => s === 'inherit' || supported.includes(s));
449
+ }
450
+ export function reasoningItems(catalog = EMPTY_CATALOG) {
451
+ return CHILD_GROUPS.map(group => ({
353
452
  id: REASON_ID_PREFIX + group,
354
453
  section: 'reasoning',
355
454
  label: reasoningRowLabel(group),
356
455
  headlessLabel: `think: ${group}`,
357
456
  description: REASONING_GROUP_HELP[group],
358
457
  values: [...REASONING_SETTINGS],
458
+ // A submenu, not a cycle, because what this row may offer depends on the
459
+ // model row above it — which the user can change while the panel is
460
+ // open. `values` is static and computed when the rows are built, and
461
+ // `syncRows` can only re-ask `format`; a factory runs at Enter-time.
462
+ submenu: (cfg) => offeredLevels(catalog.facts(cfg.groupModels[group])).map(level => ({
463
+ value: level,
464
+ label: level
465
+ })),
359
466
  // The EFFECTIVE level, not cfg.reasoningLevels[group]: in default/on/off
360
467
  // the stored table is not what runs, and a row that shows a value the
361
468
  // run does not use is worse than no row. As a FUNCTION rather than a
@@ -441,18 +548,29 @@ const DOWN_KEY = '\x1b[B';
441
548
  *
442
549
  * It drives the list through its own public `handleInput` — pressing the very
443
550
  * 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.
551
+ * `selectedIndex`. With search off, up and down are the only two things that
552
+ * move that index EXCEPT while a submenu is open, when `SettingsList` forwards
553
+ * everything to the submenu and returns without moving it at all. So a picker
554
+ * suspends this entirely; see `suspended`.
446
555
  */
447
556
  class SkipInertRows {
448
557
  list;
449
558
  selectable;
559
+ suspended;
450
560
  index = 0;
451
561
  constructor(list,
452
562
  /** True where a row can be selected, in the list's own order. */
453
- selectable) {
563
+ selectable,
564
+ /**
565
+ * True while a picker is open. The list then owns every key, so this
566
+ * must not intercept — see the flag's own comment in
567
+ * {@link createSettingsPanel}. Defaulted so the constructor's own
568
+ * opening walk, and every existing test, are unaffected.
569
+ */
570
+ suspended = () => false) {
454
571
  this.list = list;
455
572
  this.selectable = selectable;
573
+ this.suspended = suspended;
456
574
  // The first row is a header, so the panel would open on it. Only
457
575
  // synthesise the keypress if it is actually bound to "down" — feeding
458
576
  // a key the list ignores would move the mirror and not the cursor.
@@ -471,9 +589,10 @@ class SkipInertRows {
471
589
  }
472
590
  handleInput(data) {
473
591
  const kb = getKeybindings();
474
- const step = kb.matches(data, 'tui.select.down') ? 1
475
- : kb.matches(data, 'tui.select.up') ? -1
476
- : 0;
592
+ const step = this.suspended() ? 0
593
+ : kb.matches(data, 'tui.select.down') ? 1
594
+ : kb.matches(data, 'tui.select.up') ? -1
595
+ : 0;
477
596
  if (step === 0) {
478
597
  this.list.handleInput(data);
479
598
  return;
@@ -508,10 +627,31 @@ export function createSettingsPanel(items, theme,
508
627
  * `reasoning off` beside rows still claiming `inherit`.
509
628
  */
510
629
  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));
630
+ const headerLabels = new Set(items.filter(isSectionRow).map(i => i.label));
631
+ /**
632
+ * True while a picker is open, so SkipInertRows stops intercepting arrows.
633
+ *
634
+ * `SettingsList.handleInput` delegates to an open submenu and RETURNS, so
635
+ * `selectedIndex` never moves while one is up. SkipInertRows keeps its own
636
+ * mirror of that index and replays the key once per row it skips — so
637
+ * without this flag, one arrow press inside a picker moves the mirror off
638
+ * the real cursor AND arrives in the picker two or three times when the walk
639
+ * crosses a section boundary.
640
+ */
641
+ let submenuOpen = false;
642
+ const settingItems = items.map(({ submenuOptions, ...row }) => submenuOptions === undefined ? row : ({
643
+ ...row,
644
+ submenu: (currentValue, done) => {
645
+ submenuOpen = true;
646
+ return new OptionPicker(submenuOptions(), currentValue, theme, v => {
647
+ submenuOpen = false;
648
+ done(v);
649
+ });
650
+ }
651
+ }));
652
+ const list = new SettingsList(settingItems, MAX_VISIBLE, makeTheme(theme, label => headerLabels.has(label.trimEnd())), (id, newValue) => onChange(id, newValue, list), onCancel);
653
+ return new BorderedBox(new SkipInertRows(list, items.map(i => !isSectionRow(i)
654
+ && ((i.values?.length ?? 0) > 0 || i.submenuOptions !== 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
655
  }
516
656
  /**
517
657
  * Every settings row for this session, fixed and DISCOVERED, in menu order.
@@ -524,11 +664,20 @@ onChange, onCancel) {
524
664
  * Fixed rows come before discovered ones within a section, so a freshly
525
665
  * installed extension appends rather than reshuffling the menu.
526
666
  */
527
- export function configRows(installed, tools = []) {
667
+ export function configRows(installed, tools = [],
668
+ // A third positional with a default, exactly like `tools`, so every existing
669
+ // test stays deterministic and no test has to know a registry exists.
670
+ catalog = EMPTY_CATALOG) {
528
671
  // The discovered rows carry a section like every other row — the per-tool
529
672
  // watchdog exemptions under `timeouts` (they are exemptions FROM that
530
673
  // timeout), and the per-extension toggles under their own heading.
531
- return [...ITEMS, ...reasoningItems(), ...toolItems(tools), ...extensionItems(installed)];
674
+ return [
675
+ ...ITEMS,
676
+ ...modelItems(catalog),
677
+ ...reasoningItems(catalog),
678
+ ...toolItems(tools),
679
+ ...extensionItems(installed)
680
+ ];
532
681
  }
533
682
  /** Render `rows` for the current config, grouped under their section headers. */
534
683
  export function renderRows(cfg, rows) {
@@ -542,6 +691,7 @@ export function renderRows(cfg, rows) {
542
691
  description: i.description,
543
692
  currentValue: i.format(cfg),
544
693
  values: i.values ?? ['on', 'off'],
694
+ ...(i.submenu === undefined ? {} : { submenuOptions: () => i.submenu(cfg) }),
545
695
  ...(i.headlessLabel === undefined ? {} : { headlessLabel: i.headlessLabel })
546
696
  }));
547
697
  // An empty section prints no header. `extensions` has no fixed rows at
@@ -555,8 +705,8 @@ export function renderRows(cfg, rows) {
555
705
  return out;
556
706
  }
557
707
  /** 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));
708
+ export function panelItems(cfg, installed, tools = [], catalog = EMPTY_CATALOG) {
709
+ return renderRows(cfg, configRows(installed, tools, catalog));
560
710
  }
561
711
  /**
562
712
  * Re-ask every row what it now displays, and write the answers back.
@@ -574,6 +724,59 @@ export function syncRows(cfg, rows, list) {
574
724
  for (const row of rows)
575
725
  list.updateValue(row.id, row.format(cfg));
576
726
  }
727
+ /**
728
+ * The model rows' offer list, read live when the menu opens.
729
+ *
730
+ * `getAvailable()`, never `getAll()`: an unauthed model would spawn a child that
731
+ * exits 1 on every phase of that group, and offering it would be offering a
732
+ * config that cannot work.
733
+ *
734
+ * A provider registered by a host EXTENSION is offered with a note rather than
735
+ * hidden. Children run `--no-extensions`, which disables discovery only — pi
736
+ * still loads every explicit `-e` path, and `childBaseArgs` injects one per
737
+ * whitelisted extension. So such a model works in a child exactly when its
738
+ * extension is whitelisted, and we cannot tell which extension that is:
739
+ * `getRegisteredProviderIds()` gives ids, and the `{name, config, extensionPath}`
740
+ * triples live in the runner's internal state, drained at bind. Choosing it with
741
+ * the wrong whitelist fails LOUDLY — the child sees no such provider, so pi's
742
+ * resolver reports "not found" and exits 1 — which is why a note is enough.
743
+ */
744
+ function liveCatalog(ctx) {
745
+ // Same contract as the tool enumeration above, for the same reason: the
746
+ // model runtime is not guaranteed usable at the moment a command runs, and a
747
+ // registry that cannot answer must cost the model rows, never the menu.
748
+ // Every row below still renders; `EMPTY_CATALOG` offers only `inherit` and
749
+ // narrows nothing, which is exactly the pre-feature panel.
750
+ let registry;
751
+ let available;
752
+ let fromExtension;
753
+ try {
754
+ registry = ctx.modelRegistry;
755
+ available = registry.getAvailable();
756
+ fromExtension = new Set(registry.getRegisteredProviderIds());
757
+ }
758
+ catch {
759
+ return EMPTY_CATALOG;
760
+ }
761
+ return {
762
+ specs: available.map(m => `${m.provider}/${m.id}`),
763
+ note: spec => {
764
+ const parts = splitSpec(spec);
765
+ return parts && fromExtension.has(parts.provider) ?
766
+ 'provider comes from an extension — whitelist it under child extensions, '
767
+ + "or this group's children exit 1"
768
+ : undefined;
769
+ },
770
+ facts: spec => {
771
+ // `inherit` means the session's own model, which is what a child
772
+ // resolves today. Its facts are what the thinking row must narrow to.
773
+ if (spec === MODEL_INHERIT)
774
+ return ctx.model;
775
+ const parts = splitSpec(spec);
776
+ return parts ? registry.find(parts.provider, parts.id) : undefined;
777
+ }
778
+ };
779
+ }
577
780
  async function handleTaskConfig(_args, ctx, getTools = () => []) {
578
781
  const cfg = {
579
782
  ...getConfig(),
@@ -582,7 +785,8 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
582
785
  // Copied for the same reason as the two arrays above: the panel mutates
583
786
  // its own draft, and sharing the live object would apply half-made
584
787
  // choices to running children before the user finished choosing.
585
- reasoningLevels: { ...getConfig().reasoningLevels }
788
+ reasoningLevels: { ...getConfig().reasoningLevels },
789
+ groupModels: { ...getConfig().groupModels }
586
790
  };
587
791
  // Enumerated live at open so an installed extension appears and an
588
792
  // uninstalled one vanishes without pi-task doing any bookkeeping. A failed
@@ -592,17 +796,18 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
592
796
  // one: getAllTools() throws until the extension runtime is initialized, so
593
797
  // it can only be read here, when the menu opens, never at registration.
594
798
  const tools = getTools();
799
+ const catalog = liveCatalog(ctx);
595
800
  if (ctx.mode !== 'tui') {
596
801
  // Built from panelItems and reading the SAME `format` the panel does,
597
802
  // so the two renderings cannot disagree about what a setting says. A
598
803
  // second walk of the same tables is the one place nobody would notice
599
804
  // them drifting, because a headless run has no panel to compare against.
600
- const lines = panelItems(cfg, installed, tools)
805
+ const lines = panelItems(cfg, installed, tools, catalog)
601
806
  // The blank rows between sections are there to give the TUI air.
602
807
  // One line of `|`-joined text has none to give, and an empty label
603
808
  // would print as a stray `[]`.
604
809
  .filter(i => i.label !== '')
605
- .map(i => i.values === undefined ?
810
+ .map(i => isSectionRow(i) ?
606
811
  `[${i.label.trim()}]`
607
812
  : `${(i.headlessLabel ?? i.label).padEnd(22)} ${i.currentValue}`);
608
813
  ctx.ui.notify(lines.join(' | '), 'info');
@@ -610,7 +815,7 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
610
815
  }
611
816
  // Built ONCE and shared by the renderer, the dispatch and the refresh, so
612
817
  // all three necessarily agree about which rows exist.
613
- const rows = configRows(installed, tools);
818
+ const rows = configRows(installed, tools, catalog);
614
819
  await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(renderRows(cfg, rows), theme, (id, newValue, list) => {
615
820
  // Every row parses its own value. There is no generic
616
821
  // fallback and no prefix ladder, so a row that forgets to
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ import { registerWorkers } from './workers/index.js';
6
6
  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
+ import { registerImplementationGuards } from './task/implementation-guards.js';
10
+ import { registerModelHoldRestore } from './task/model-hold-stash.js';
9
11
  export default function (pi) {
10
12
  registerConfig(pi);
11
13
  registerTask(pi);
@@ -15,4 +17,6 @@ export default function (pi) {
15
17
  registerRemote(pi);
16
18
  registerCommandWatchdog(pi);
17
19
  registerStreamWatchdog(pi);
20
+ registerImplementationGuards(pi);
21
+ registerModelHoldRestore(pi);
18
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. */
@@ -122,3 +122,66 @@ export declare class CommandWatchdog {
122
122
  * the call it watches.
123
123
  */
124
124
  export declare const realTimerDeps: Pick<WatchdogDeps, 'schedule' | 'cancel'>;
125
+ /** What the command watchdog recorded when it killed a child attempt. */
126
+ export interface CommandKill {
127
+ toolName: string;
128
+ timeoutMs: number;
129
+ /** The command line itself, when the tool carried one — quoted into the hint
130
+ * so the fresh child knows which call it must not repeat unbounded. */
131
+ detail?: string;
132
+ }
133
+ /**
134
+ * The tool-call fields the child-side watchdog reads. Structural rather than
135
+ * `ToolCall` from child-process.ts, so this module keeps its zero imports and a
136
+ * caller cannot be forced to reach for the runner's types to arm a timer.
137
+ */
138
+ export interface WatchedToolCall {
139
+ name: string;
140
+ toolCallId?: string;
141
+ args: unknown;
142
+ }
143
+ /**
144
+ * Build the child-side command watchdog for ONE attempt: a per-tool-call timer
145
+ * machine whose `onFire` aborts `signal`, which runChild turns into a
146
+ * process-GROUP kill — reaping the hung command itself, not just the pi child
147
+ * holding it.
148
+ *
149
+ * LIMIT: the group kill only reaches processes still IN the group. A hung command
150
+ * that detached a daemon (setsid, nohup, a background dev server) leaves it
151
+ * running, so the fresh attempt can hit a port the dead attempt's escapee still
152
+ * holds. There is no cheap fix from here; the restart hint's "check current state"
153
+ * line is the mitigation.
154
+ *
155
+ * Returns null when the watchdog is off, so the caller keeps the plain timeout
156
+ * signal and no per-call bookkeeping happens at all.
157
+ */
158
+ export declare function commandWatch(timeoutMs: number): {
159
+ onStart: (call: WatchedToolCall) => void;
160
+ onEnd: (toolCallId: string | undefined) => void;
161
+ killed: () => CommandKill | undefined;
162
+ signal: AbortSignal;
163
+ clear: () => void;
164
+ } | null;
165
+ /**
166
+ * The per-command ceiling for attempt N, halving each time a hang recurs.
167
+ *
168
+ * The first attempt gets the full configured ceiling — a genuinely slow build or
169
+ * test suite deserves it. But every hang-caused restart carries
170
+ * commandTimeoutHint, which tells the model in as many words to bound its
171
+ * command; a SECOND hang means it ignored an explicit instruction, and a third
172
+ * means it ignored it twice. Giving a non-complying child the full ceiling again
173
+ * makes the worst case three times the ceiling, resting entirely on the model
174
+ * obeying prose. Halving bounds it at under twice the ceiling while costing a
175
+ * complying child nothing.
176
+ *
177
+ * `priorHangs` counts watchdog kills specifically, NOT total restarts — the
178
+ * restart budget is shared with loop kills, and a child restarted for LOOPING
179
+ * never received the bound-your-command hint, so its first hang still deserves
180
+ * the full ceiling. Only a hang after a hang is defiance.
181
+ *
182
+ * Floored at 30s so repeated halving cannot shrink the ceiling to something no
183
+ * real command could finish inside — but the floor is `min(base, 30s)`, never
184
+ * above the configured ceiling, so a caller asking for 10s keeps 10s at every
185
+ * hang count. A base of 0 or less disables the watchdog and stays 0.
186
+ */
187
+ export declare function commandCeilingForAttempt(baseMs: number, priorHangs: number): number;
@@ -172,3 +172,90 @@ export const realTimerDeps = {
172
172
  schedule: (fn, ms) => setTimeout(fn, ms),
173
173
  cancel: handle => clearTimeout(handle)
174
174
  };
175
+ /**
176
+ * Build the child-side command watchdog for ONE attempt: a per-tool-call timer
177
+ * machine whose `onFire` aborts `signal`, which runChild turns into a
178
+ * process-GROUP kill — reaping the hung command itself, not just the pi child
179
+ * holding it.
180
+ *
181
+ * LIMIT: the group kill only reaches processes still IN the group. A hung command
182
+ * that detached a daemon (setsid, nohup, a background dev server) leaves it
183
+ * running, so the fresh attempt can hit a port the dead attempt's escapee still
184
+ * holds. There is no cheap fix from here; the restart hint's "check current state"
185
+ * line is the mitigation.
186
+ *
187
+ * Returns null when the watchdog is off, so the caller keeps the plain timeout
188
+ * signal and no per-call bookkeeping happens at all.
189
+ */
190
+ export function commandWatch(timeoutMs) {
191
+ if (!(timeoutMs > 0))
192
+ return null;
193
+ const ctrl = new AbortController();
194
+ // pi's toolCallId pairs start↔end. When it is absent (a fake stream in a
195
+ // test, an older pi), fall back to one shared slot: tool executions in a
196
+ // child are sequential, so a single slot is still correctly paired.
197
+ const key = (id) => id ?? 'anon';
198
+ const details = new Map();
199
+ let killed;
200
+ const watchdog = new CommandWatchdog({
201
+ getTimeoutMs: () => timeoutMs,
202
+ ...realTimerDeps,
203
+ onFire: (toolCallId, toolName, ms) => {
204
+ killed = {
205
+ toolName,
206
+ timeoutMs: ms,
207
+ ...(details.has(toolCallId) ? { detail: details.get(toolCallId) } : {})
208
+ };
209
+ ctrl.abort();
210
+ }
211
+ });
212
+ return {
213
+ onStart: call => {
214
+ const id = key(call.toolCallId);
215
+ const args = call.args;
216
+ if (typeof args?.command === 'string') {
217
+ details.set(id, args.command.slice(0, 120));
218
+ }
219
+ watchdog.onStart(id, call.name);
220
+ },
221
+ onEnd: id => {
222
+ // Drop the command line with its call. The `'anon'` fallback above is a
223
+ // SHARED slot, so a stale entry would be attributed to whatever ran
224
+ // next: a `read` that later overran would be reported as
225
+ // "ran a `read` command (bun run dev)".
226
+ details.delete(key(id));
227
+ watchdog.onEnd(key(id));
228
+ },
229
+ killed: () => killed,
230
+ signal: ctrl.signal,
231
+ clear: () => watchdog.clearAll()
232
+ };
233
+ }
234
+ /**
235
+ * The per-command ceiling for attempt N, halving each time a hang recurs.
236
+ *
237
+ * The first attempt gets the full configured ceiling — a genuinely slow build or
238
+ * test suite deserves it. But every hang-caused restart carries
239
+ * commandTimeoutHint, which tells the model in as many words to bound its
240
+ * command; a SECOND hang means it ignored an explicit instruction, and a third
241
+ * means it ignored it twice. Giving a non-complying child the full ceiling again
242
+ * makes the worst case three times the ceiling, resting entirely on the model
243
+ * obeying prose. Halving bounds it at under twice the ceiling while costing a
244
+ * complying child nothing.
245
+ *
246
+ * `priorHangs` counts watchdog kills specifically, NOT total restarts — the
247
+ * restart budget is shared with loop kills, and a child restarted for LOOPING
248
+ * never received the bound-your-command hint, so its first hang still deserves
249
+ * the full ceiling. Only a hang after a hang is defiance.
250
+ *
251
+ * Floored at 30s so repeated halving cannot shrink the ceiling to something no
252
+ * real command could finish inside — but the floor is `min(base, 30s)`, never
253
+ * above the configured ceiling, so a caller asking for 10s keeps 10s at every
254
+ * hang count. A base of 0 or less disables the watchdog and stays 0.
255
+ */
256
+ export function commandCeilingForAttempt(baseMs, priorHangs) {
257
+ if (!(baseMs > 0))
258
+ return 0;
259
+ const floor = Math.min(baseMs, 30_000);
260
+ return Math.max(floor, Math.round(baseMs / 2 ** priorHangs));
261
+ }
@@ -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
+ }
@@ -1,5 +1,58 @@
1
1
  /** Base URLs of every custom provider pi is configured with (possibly none). */
2
2
  export declare function discoverModelEndpoints(agentDir?: string): string[];
3
+ /**
4
+ * The provider/id a child pi process will actually resolve.
5
+ *
6
+ * Children carry no `-m` (CHILD_BASE_ARGS), so they fall back to pi's saved
7
+ * default — which is this, not the host session's model. `undefined` means we
8
+ * could not read it, and every caller here treats that as "do not guess".
9
+ */
10
+ export interface ModelRef {
11
+ provider: string;
12
+ id: string;
13
+ }
14
+ export declare function defaultModelRef(agentDir?: string): ModelRef | undefined;
15
+ /**
16
+ * The base URL one model is served from, or `undefined` for "not configured on
17
+ * this machine".
18
+ *
19
+ * Two files, two shapes, and the difference is not cosmetic. `models.json` is
20
+ * hand-written and hangs `baseUrl` off the PROVIDER; `models-store.json` is the
21
+ * cached remote catalogue and hangs it off each MODEL — its provider objects are
22
+ * `{models, checkedAt, lastModified, etag}` with no `baseUrl` key at all. A
23
+ * reader that knows only the first shape sees one endpoint on a machine that has
24
+ * fifteen.
25
+ *
26
+ * `undefined` is the honest answer for a model pi serves from `@earendil-works/
27
+ * pi-ai`'s built-in catalogue, whose URLs live in that package rather than on
28
+ * disk. We do not import it — see the header of this file — so we say we do not
29
+ * know, and the caller declines to probe rather than probing something else.
30
+ */
31
+ export declare function modelBaseUrl(ref: ModelRef, agentDir?: string): string | undefined;
32
+ /**
33
+ * What to probe on behalf of one child — the endpoint that child's own model
34
+ * uses, not every endpoint on the machine.
35
+ *
36
+ * The bug this closes is `probeModelEndpoints`'s `.some(Boolean)`: with a live
37
+ * cloud provider and a dead local one, the OR answers "reachable" and the stall
38
+ * guard is disarmed for a child that will never speak again. Handing it ONE url
39
+ * makes the OR a no-op and the verdict exact.
40
+ *
41
+ * `spec` is the child's OWN `provider/id`, as carried by its argv. `undefined`
42
+ * means the child carries no `--model` and will resolve pi's saved default,
43
+ * which is then the right thing to probe. Reading the saved default for a child
44
+ * that IS pinned asks about the wrong server in BOTH directions: it can kill a
45
+ * child whose own backend is healthy, and it can leave the guard disarmed for
46
+ * one whose backend is dead.
47
+ *
48
+ * Two escapes, both toward never killing:
49
+ * - model known, endpoint not on disk (a built-in provider) → `[]`, which
50
+ * `probeModelEndpoints` reads as reachable. We cannot see that server, so we
51
+ * do not get a vote. Probing some OTHER provider's url instead would import a
52
+ * false positive, which is the one thing today's blind OR never does.
53
+ * - no readable default → today's behaviour, unchanged.
54
+ */
55
+ export declare function childModelEndpoints(spec?: string, agentDir?: string): string[];
3
56
  /**
4
57
  * true → at least one endpoint ANSWERED. Any HTTP status counts, because the
5
58
  * question is liveness, not correctness: probing a path that 404s still returns