@mjasnikovs/pi-task 0.38.32 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +2 -2
  2. package/dist/config/group-args.d.ts +24 -9
  3. package/dist/config/group-args.js +38 -28
  4. package/dist/config/option-picker.d.ts +39 -11
  5. package/dist/config/option-picker.js +52 -12
  6. package/dist/config/reasoning.d.ts +10 -7
  7. package/dist/config/reasoning.js +19 -32
  8. package/dist/config/register.d.ts +66 -41
  9. package/dist/config/register.js +201 -162
  10. package/dist/shared/child-process.d.ts +34 -32
  11. package/dist/shared/child-process.js +44 -58
  12. package/dist/shared/command-watchdog.d.ts +12 -4
  13. package/dist/shared/command-watchdog.js +6 -7
  14. package/dist/shared/connection-error.d.ts +7 -0
  15. package/dist/shared/connection-error.js +65 -0
  16. package/dist/shared/model-endpoint.d.ts +12 -24
  17. package/dist/shared/model-endpoint.js +32 -82
  18. package/dist/shared/model-resolve.d.ts +105 -0
  19. package/dist/shared/model-resolve.js +97 -0
  20. package/dist/shared/reasoning-capability.d.ts +20 -0
  21. package/dist/shared/reasoning-capability.js +32 -1
  22. package/dist/shared/stall-probe.d.ts +51 -0
  23. package/dist/shared/stall-probe.js +79 -0
  24. package/dist/task/child-runner.d.ts +76 -278
  25. package/dist/task/child-runner.js +186 -722
  26. package/dist/task/context-usage.js +2 -7
  27. package/dist/task/failure-classifier.js +53 -81
  28. package/dist/task/gate-child.js +1 -1
  29. package/dist/task/impl-widget.d.ts +2 -0
  30. package/dist/task/impl-widget.js +4 -0
  31. package/dist/task/implementation-hold.d.ts +11 -0
  32. package/dist/task/implementation-hold.js +20 -0
  33. package/dist/task/implementation-scope.d.ts +24 -0
  34. package/dist/task/implementation-scope.js +34 -0
  35. package/dist/task/loop-detector.d.ts +13 -5
  36. package/dist/task/loop-detector.js +11 -5
  37. package/dist/task/model-hold-stash.js +4 -14
  38. package/dist/task/orchestrator.d.ts +1 -8
  39. package/dist/task/orchestrator.js +11 -34
  40. package/dist/task/phases.js +2 -2
  41. package/dist/task/stall-detector.d.ts +1 -1
  42. package/dist/task/stall-detector.js +1 -1
  43. package/dist/workers/model-warning.d.ts +4 -16
  44. package/dist/workers/model-warning.js +14 -70
  45. package/dist/workers/pi-worker-core.d.ts +65 -20
  46. package/dist/workers/pi-worker-core.js +109 -50
  47. package/dist/workers/reasoning-warning.js +2 -24
  48. package/dist/workers/worker-failure.d.ts +2 -0
  49. package/dist/workers/worker-failure.js +2 -1
  50. package/dist/workers/worker-kill.d.ts +30 -11
  51. package/dist/workers/worker-kill.js +68 -20
  52. package/dist/workers/worker-profiles.d.ts +20 -0
  53. package/dist/workers/worker-profiles.js +22 -9
  54. package/package.json +1 -1
@@ -1,14 +1,15 @@
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
+ import { effectiveSetting, offeredLevels } from '../shared/reasoning-capability.js';
3
+ import { resolveModel, specOf } from '../shared/model-resolve.js';
4
+ import { MODEL_INHERIT } from './group-models.js';
5
+ import { PairPicker } from './option-picker.js';
5
6
  import { registerBridgeCommand } from '../remote/bridge.js';
6
7
  import { readPkgVersion } from '../shared/pkg-version.js';
7
8
  import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
8
9
  import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
9
10
  import { listInstalledExtensions } from './extension-list.js';
10
11
  import { listGuardableTools } from './tool-list.js';
11
- import { CHILD_GROUPS, REASONING_MODES, sanitizeReasoningMode, REASONING_GROUP_HELP, MODEL_GROUP_HELP, REASONING_SETTINGS, effectiveReasoning, resolveReasoning } from './reasoning.js';
12
+ import { CHILD_GROUPS, REASONING_MODES, sanitizeReasoningMode, STEP_GROUP_HELP, REASONING_SETTINGS, effectiveReasoning, resolveReasoning } from './reasoning.js';
12
13
  // Version in the title so a bug report or screenshot says which build it came
13
14
  // from without anyone having to go look it up.
14
15
  const CONFIG_TITLE = `pi-task ${readPkgVersion()} settings`;
@@ -76,12 +77,20 @@ export const SECTIONS = [
76
77
  { key: 'session', title: 'session' },
77
78
  { key: 'checks', title: 'after each task' },
78
79
  { 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' },
84
- { key: 'reasoning', title: 'reasoning' },
80
+ // The global override gets its own heading, so it cannot be mistaken for a
81
+ // twelfth step. It is one row, and `renderRows` drops an empty section, so
82
+ // this costs a header and a blank line and nothing else.
83
+ //
84
+ // Both titles are SHORT on purpose. `SettingsList` sizes its label column
85
+ // from the widest item it holds — headers included — so a long heading is
86
+ // taken straight out of every row's value column, and the first thing to
87
+ // fall off the right is the thinking level.
88
+ { key: 'profile', title: 'profile' },
89
+ // ONE block for both dials. They shipped as two parallel blocks of eleven,
90
+ // which put a step's two settings eleven rows apart and hid the coupling
91
+ // between them: choosing a model re-clamps that step's thinking level, and
92
+ // nobody could see it happen.
93
+ { key: 'reasoning', title: 'steps' },
85
94
  { key: 'unattended', title: 'unattended' },
86
95
  { key: 'logging', title: 'logging' },
87
96
  { key: 'extensions', title: 'child extensions' },
@@ -229,15 +238,17 @@ export const ITEMS = [
229
238
  + 'For throwaway projects you are not watching'),
230
239
  {
231
240
  id: 'reasoningMode',
232
- section: 'reasoning',
233
- label: 'reasoning',
234
- description: 'How much the helper sessions think before answering. "default" uses the '
235
- + 'per-step table pi-task has measured, "on" and "off" force one answer '
236
- + 'everywhere, and "custom" is whatever you set in the "think:" rows below. '
237
- + 'Those rows always show what each step actually runs at, and changing one '
238
- + 'switches this to custom. A step left on "inherit" uses whatever thinking '
239
- + 'level pi itself is set to, which is what every step did before this setting '
240
- + 'existed',
241
+ // Its OWN section, above the per-step block it governs. Sitting inside
242
+ // that block it read as a twelfth step called `reasoning` — a global
243
+ // override disguised as one more per-step row.
244
+ section: 'profile',
245
+ label: 'profile',
246
+ description: 'How much every step below thinks, in one word. "default" uses the per-step '
247
+ + 'table pi-task has measured, "on" and "off" force one answer everywhere and '
248
+ + 'IGNORE the rows below, and "custom" is whatever those rows say. They always '
249
+ + 'show what each step actually runs at, so changing one switches this to '
250
+ + 'custom. A step left on "inherit" uses whatever thinking level pi itself is '
251
+ + 'set to, which is what every step did before this setting existed',
241
252
  values: [...REASONING_MODES],
242
253
  format: cfg => String(cfg.reasoningMode),
243
254
  apply: (cfg, chosen) => {
@@ -340,9 +351,9 @@ export function applyToolToggle(exempt, toolName, watched) {
340
351
  * though the custom table underneath is untouched, which is the honest
341
352
  * answer to "what will my next child do".
342
353
  */
343
- const REASON_ID_PREFIX = 'reason:';
354
+ const STEP_ID_PREFIX = 'step:';
344
355
  /**
345
- * The label for one `think:` row.
356
+ * The label for one step row.
346
357
  *
347
358
  * A group whose name carries a colon is a CHILD of the group before the colon —
348
359
  * `research:files` is one of the four workers `research` fans out to. Rendered
@@ -351,36 +362,120 @@ const REASON_ID_PREFIX = 'reason:';
351
362
  * parent is also a row.
352
363
  *
353
364
  * So a child is drawn as a tree branch under its parent and loses the repeated
354
- * `think: research:` prefix, the same text at the head of four consecutive
355
- * lines. `└─` on the last child, `├─` on the rest, decided from the group's
356
- * position in {@link CHILD_GROUPS} rather than a hand-kept list — adding a
357
- * fifth worker moves the corner on its own.
365
+ * `research:` prefix, the same text at the head of four consecutive lines. `└─`
366
+ * on the last child, `├─` on the rest, decided from the group's position in
367
+ * {@link CHILD_GROUPS} rather than a hand-kept list — adding a fifth worker
368
+ * moves the corner on its own.
369
+ *
370
+ * A parentless group is its own bare name. It carried a `think: ` prefix while
371
+ * there were two families to tell apart; with one row per step there is nothing
372
+ * to disambiguate, and the prefix was the widest thing in the column.
358
373
  *
359
374
  * Leading spaces survive: SettingsList pads the label right, never trims it.
360
375
  */
361
- export function reasoningRowLabel(group) {
376
+ export function stepRowLabel(group) {
362
377
  const colon = group.indexOf(':');
363
378
  if (colon < 0)
364
- return `think: ${group}`;
379
+ return group;
365
380
  const parent = group.slice(0, colon);
366
381
  const nextIsSibling = CHILD_GROUPS[CHILD_GROUPS.indexOf(group) + 1]?.startsWith(`${parent}:`);
367
382
  return ` ${nextIsSibling ? '├─' : '└─'} ${group.slice(colon + 1)}`;
368
383
  }
369
- const MODEL_ID_PREFIX = 'model:';
370
384
  /** No registry reachable. Every row still renders; nothing narrows. */
371
385
  export const EMPTY_CATALOG = { specs: [], facts: () => undefined };
372
- export function modelItems(catalog) {
386
+ /** The separator between a step row's two halves. */
387
+ const PAIR_SEP = ' \u00b7 ';
388
+ /**
389
+ * `level · provider/id`, the one string a step row shows and accepts.
390
+ *
391
+ * THE LEVEL COMES FIRST, and that is a display decision with teeth.
392
+ * `SettingsList` truncates a value from the RIGHT, so whatever is last is what
393
+ * silently disappears — and a real local model id (`local/Qwen3.8-27B-UD-Q4_K_XL
394
+ * .gguf`) is wide enough to consume the whole column on its own. Level-first
395
+ * means the half that falls off is the one still identifiable from its head, and
396
+ * the levels line up as a column you can read down.
397
+ */
398
+ export function formatStepValue(spec, level) {
399
+ return `${level}${PAIR_SEP}${spec}`;
400
+ }
401
+ /**
402
+ * The two halves back out, or `undefined` for anything not of that shape.
403
+ *
404
+ * Split on the FIRST separator, because the level leads and is a closed set,
405
+ * while a `provider/id` could conceivably contain one.
406
+ */
407
+ export function parseStepValue(value) {
408
+ const at = value.indexOf(PAIR_SEP);
409
+ if (at <= 0)
410
+ return undefined;
411
+ const level = value.slice(0, at);
412
+ const spec = value.slice(at + PAIR_SEP.length);
413
+ if (spec === '' || !REASONING_SETTINGS.includes(level))
414
+ return undefined;
415
+ return { spec, level: level };
416
+ }
417
+ /**
418
+ * One row per step, carrying BOTH dials.
419
+ *
420
+ * They were two parallel blocks of eleven, and the coupling between them was
421
+ * invisible: choosing a model re-clamps that step's thinking level, but the row
422
+ * that moved was eleven rows away from the row you touched. One row shows the
423
+ * pair, and the two-step picker shows the clamp happening.
424
+ *
425
+ * `values` is the LEGAL cross product — every model against only the levels that
426
+ * model declares. Nothing renders it: the picker offers two short lists, and the
427
+ * round-trip property in config-items.test.ts is its only reader. Building it
428
+ * from `offeredLevels` rather than the full ladder is what makes the property
429
+ * true, because a pair the model cannot honour would be clamped by `apply` and
430
+ * would not round-trip.
431
+ */
432
+ export function stepItems(catalog = EMPTY_CATALOG) {
433
+ const specs = [MODEL_INHERIT, ...catalog.specs];
373
434
  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: () => [
435
+ id: STEP_ID_PREFIX + group,
436
+ section: 'reasoning',
437
+ label: stepRowLabel(group),
438
+ headlessLabel: `step: ${group}`,
439
+ description: STEP_GROUP_HELP[group],
440
+ values: specs.flatMap(spec => offeredLevels(catalog.facts(spec)).map(level => formatStepValue(spec, level))),
441
+ // Built at ENTER-time from the LIVE draft, not when the rows were made:
442
+ // stage two narrows to the model chosen in stage one, and the user can
443
+ // have changed another row since the panel opened.
444
+ picker: cfg => stepPicker(group, cfg, catalog),
445
+ // The model half VERBATIM even when the catalog no longer offers it — the
446
+ // vanished-model case, where the row is the only place the user can see
447
+ // what their config actually holds. The thinking half is the EFFECTIVE
448
+ // level, not the stored cell: in mode default/on/off the stored table is
449
+ // not what runs, and a row showing a value the run does not use is worse
450
+ // than no row.
451
+ format: cfg => formatStepValue(cfg.groupModels[group], resolveReasoning(group, cfg)),
452
+ apply: (cfg, chosen) => applyStepValue(cfg, group, chosen, catalog)
453
+ }));
454
+ }
455
+ /**
456
+ * The two lists behind one step row.
457
+ *
458
+ * Stage two is where the coupling becomes visible. It offers only the levels the
459
+ * chosen model declares, and it OPENS on the level that will actually run — the
460
+ * current one when that model can honour it, otherwise the clamp, with the
461
+ * reason written beside it. Picking a model that cannot think is therefore not a
462
+ * silent downgrade discovered later; it is the option the cursor is already on.
463
+ */
464
+ function stepPicker(group, cfg, catalog) {
465
+ const held = cfg.groupModels[group];
466
+ // The row's CURRENT model leads stage one when the catalog cannot offer it.
467
+ // Without this, opening the row to nudge only the level would silently
468
+ // rewrite the model to `inherit`: `FilterList` falls back to index 0 when
469
+ // the preselect matches nothing, and the two dials are one row now, so there
470
+ // is no way to touch the level without confirming a model. That would erase
471
+ // a spec set on the user's other machine — the one thing the loader, the
472
+ // format function and the startup hint all go out of their way to preserve.
473
+ const missing = held !== MODEL_INHERIT && !catalog.specs.includes(held);
474
+ return {
475
+ first: [
476
+ ...(missing ?
477
+ [{ value: held, label: held, description: 'not available here — kept as-is' }]
478
+ : []),
384
479
  { value: MODEL_INHERIT, label: MODEL_INHERIT, description: "pi's own default" },
385
480
  ...catalog.specs.map(spec => ({
386
481
  value: spec,
@@ -388,88 +483,58 @@ export function modelItems(catalog) {
388
483
  ...(catalog.note?.(spec) === undefined ? {} : { description: catalog.note(spec) })
389
484
  }))
390
485
  ],
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
- }));
486
+ second: spec => {
487
+ const facts = catalog.facts(spec);
488
+ const wanted = resolveReasoning(group, cfg);
489
+ const runs = effectiveSetting(facts, wanted);
490
+ return {
491
+ options: offeredLevels(facts).map(level => ({
492
+ value: level,
493
+ label: level,
494
+ ...(level === runs && runs !== wanted ?
495
+ { description: `${spec} cannot do ${wanted}` }
496
+ : {})
497
+ })),
498
+ preselect: runs
499
+ };
500
+ },
501
+ firstOf: value => parseStepValue(value)?.spec ?? value,
502
+ join: (spec, level) => formatStepValue(spec, level)
503
+ };
397
504
  }
398
505
  /**
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.
506
+ * Write both halves of a step row, atomically.
405
507
  *
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.
508
+ * Atomically matters for the round-trip property, which starts from a FRESH
509
+ * config every iteration: a value that wrote only one half would leave the other
510
+ * at its default and render as something else.
411
511
  *
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.
512
+ * The level is re-clamped even though the picker only ever offers legal pairs.
513
+ * The picker is not the only door `values` is built when the panel opens, and
514
+ * a registry that moved underneath it would otherwise let an unhonourable level
515
+ * through.
415
516
  */
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))
517
+ export function applyStepValue(cfg, group, chosen, catalog) {
518
+ const pair = parseStepValue(chosen);
519
+ if (pair === undefined)
422
520
  return;
423
- if (chosen !== MODEL_INHERIT && !catalog.specs.includes(chosen))
521
+ // MEMBERSHIP, not just shape: the panel may only ever write what the picker
522
+ // showed. That is `inherit`, the catalog's own specs, and — when the catalog
523
+ // cannot offer it — the spec this cell ALREADY holds, which stage one keeps
524
+ // at its head precisely so the level can be changed without discarding it.
525
+ // Re-writing the value that is already there is not a new unresolvable spec.
526
+ if (pair.spec !== MODEL_INHERIT
527
+ && pair.spec !== cfg.groupModels[group]
528
+ && !catalog.specs.includes(pair.spec)) {
424
529
  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 => ({
452
- id: REASON_ID_PREFIX + group,
453
- section: 'reasoning',
454
- label: reasoningRowLabel(group),
455
- headlessLabel: `think: ${group}`,
456
- description: REASONING_GROUP_HELP[group],
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
- })),
466
- // The EFFECTIVE level, not cfg.reasoningLevels[group]: in default/on/off
467
- // the stored table is not what runs, and a row that shows a value the
468
- // run does not use is worse than no row. As a FUNCTION rather than a
469
- // snapshot, so `syncRows` can re-ask after any change.
470
- format: cfg => resolveReasoning(group, cfg),
471
- apply: (cfg, chosen) => applyReasoningLevel(cfg, group, chosen)
472
- }));
530
+ }
531
+ cfg.groupModels = { ...cfg.groupModels, [group]: pair.spec };
532
+ const level = effectiveSetting(catalog.facts(pair.spec), pair.level);
533
+ // Only when it MOVES something: `applyReasoningLevel` flips the whole table
534
+ // to `custom`, and picking a pair the config already runs must not do that
535
+ // as a side effect.
536
+ if (level !== resolveReasoning(group, cfg))
537
+ applyReasoningLevel(cfg, group, level);
473
538
  }
474
539
  /**
475
540
  * Apply one group row's new value.
@@ -621,10 +686,10 @@ export function createSettingsPanel(items, theme,
621
686
  * Called with the row's id, its new value, and the LIST ITSELF.
622
687
  *
623
688
  * The list is handed back because some rows change what OTHER rows display:
624
- * flipping `reasoning` to off means every `think:` row now runs at off, and
625
- * a row's `currentValue` is a snapshot taken when the panel was built.
626
- * Without a way to write the others back, the menu would show
627
- * `reasoning off` beside rows still claiming `inherit`.
689
+ * flipping `profile` to off means every `step:` row now runs at off, and a
690
+ * row's `currentValue` is a snapshot taken when the panel was built. Without
691
+ * a way to write the others back, the menu would show `profile off` beside
692
+ * rows still claiming `inherit`.
628
693
  */
629
694
  onChange, onCancel) {
630
695
  const headerLabels = new Set(items.filter(isSectionRow).map(i => i.label));
@@ -639,11 +704,11 @@ onChange, onCancel) {
639
704
  * crosses a section boundary.
640
705
  */
641
706
  let submenuOpen = false;
642
- const settingItems = items.map(({ submenuOptions, ...row }) => submenuOptions === undefined ? row : ({
707
+ const settingItems = items.map(({ pickerOptions, ...row }) => pickerOptions === undefined ? row : ({
643
708
  ...row,
644
709
  submenu: (currentValue, done) => {
645
710
  submenuOpen = true;
646
- return new OptionPicker(submenuOptions(), currentValue, theme, v => {
711
+ return new PairPicker(pickerOptions(), currentValue, theme, v => {
647
712
  submenuOpen = false;
648
713
  done(v);
649
714
  });
@@ -651,7 +716,7 @@ onChange, onCancel) {
651
716
  }));
652
717
  const list = new SettingsList(settingItems, MAX_VISIBLE, makeTheme(theme, label => headerLabels.has(label.trimEnd())), (id, newValue) => onChange(id, newValue, list), onCancel);
653
718
  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));
719
+ && ((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));
655
720
  }
656
721
  /**
657
722
  * Every settings row for this session, fixed and DISCOVERED, in menu order.
@@ -671,13 +736,7 @@ catalog = EMPTY_CATALOG) {
671
736
  // The discovered rows carry a section like every other row — the per-tool
672
737
  // watchdog exemptions under `timeouts` (they are exemptions FROM that
673
738
  // timeout), and the per-extension toggles under their own heading.
674
- return [
675
- ...ITEMS,
676
- ...modelItems(catalog),
677
- ...reasoningItems(catalog),
678
- ...toolItems(tools),
679
- ...extensionItems(installed)
680
- ];
739
+ return [...ITEMS, ...stepItems(catalog), ...toolItems(tools), ...extensionItems(installed)];
681
740
  }
682
741
  /** Render `rows` for the current config, grouped under their section headers. */
683
742
  export function renderRows(cfg, rows) {
@@ -691,7 +750,7 @@ export function renderRows(cfg, rows) {
691
750
  description: i.description,
692
751
  currentValue: i.format(cfg),
693
752
  values: i.values ?? ['on', 'off'],
694
- ...(i.submenu === undefined ? {} : { submenuOptions: () => i.submenu(cfg) }),
753
+ ...(i.picker === undefined ? {} : { pickerOptions: () => i.picker(cfg) }),
695
754
  ...(i.headlessLabel === undefined ? {} : { headlessLabel: i.headlessLabel })
696
755
  }));
697
756
  // An empty section prints no header. `extensions` has no fixed rows at
@@ -713,7 +772,7 @@ export function panelItems(cfg, installed, tools = [], catalog = EMPTY_CATALOG)
713
772
  *
714
773
  * A row's `currentValue` in the live list is a snapshot taken when the panel was
715
774
  * built, and rows describe each other: cycling `reasoning` to `off` changes what
716
- * every `think:` row runs at, and cycling one group row flips the mode, which
775
+ * every `step:` row runs at, and setting one step row flips the profile, which
717
776
  * changes all the others.
718
777
  *
719
778
  * This runs after ANY change, over EVERY row. Re-reading a `format` costs
@@ -725,7 +784,7 @@ export function syncRows(cfg, rows, list) {
725
784
  list.updateValue(row.id, row.format(cfg));
726
785
  }
727
786
  /**
728
- * The model rows' offer list, read live when the menu opens.
787
+ * The step rows' model offer list, read live when the menu opens.
729
788
  *
730
789
  * `getAvailable()`, never `getAll()`: an unauthed model would spawn a child that
731
790
  * exits 1 on every phase of that group, and offering it would be offering a
@@ -747,47 +806,27 @@ function liveCatalog(ctx) {
747
806
  // registry that cannot answer must cost the model rows, never the menu.
748
807
  // Every row below still renders; `EMPTY_CATALOG` offers only `inherit` and
749
808
  // narrows nothing, which is exactly the pre-feature panel.
750
- let registry;
751
809
  let available;
752
- let fromExtension;
753
810
  try {
754
- registry = ctx.modelRegistry;
755
- available = registry.getAvailable();
756
- fromExtension = new Set(registry.getRegisteredProviderIds());
811
+ available = ctx.modelRegistry.getAvailable();
757
812
  }
758
813
  catch {
759
814
  return EMPTY_CATALOG;
760
815
  }
761
816
  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
- }
817
+ specs: available.map(specOf),
818
+ note: spec => resolveModel(ctx, spec)?.fromExtension ?
819
+ 'provider comes from an extension — whitelist it under child extensions, '
820
+ + "or this group's children exit 1"
821
+ : undefined,
822
+ facts: spec => resolveModel(ctx, spec)
778
823
  };
779
824
  }
780
825
  async function handleTaskConfig(_args, ctx, getTools = () => []) {
781
- const cfg = {
782
- ...getConfig(),
783
- extensionWhitelist: [...getConfig().extensionWhitelist],
784
- commandTimeoutExemptTools: [...getConfig().commandTimeoutExemptTools],
785
- // Copied for the same reason as the two arrays above: the panel mutates
786
- // its own draft, and sharing the live object would apply half-made
787
- // choices to running children before the user finished choosing.
788
- reasoningLevels: { ...getConfig().reasoningLevels },
789
- groupModels: { ...getConfig().groupModels }
790
- };
826
+ // A deep copy: the panel mutates its own draft, and sharing the live object
827
+ // would apply half-made choices to running children before the user
828
+ // finished choosing.
829
+ const cfg = structuredClone(getConfig());
791
830
  // Enumerated live at open so an installed extension appears and an
792
831
  // uninstalled one vanishes without pi-task doing any bookkeeping. A failed
793
832
  // enumeration only costs the extension toggles, never the whole menu.
@@ -1,4 +1,6 @@
1
1
  import type { EventEmitter } from 'node:events';
2
+ import type { CommandKillReason } from './command-watchdog.js';
3
+ import type { LoopHit } from '../task/loop-detector.js';
2
4
  /** Grace period between SIGTERM and SIGKILL (ms). */
3
5
  export declare const KILL_GRACE_MS = 5000;
4
6
  /** Base flags shared by all child pi invocations. */
@@ -36,11 +38,41 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
36
38
  * model children (json-events); plumbing stays in-group. */
37
39
  detached?: boolean;
38
40
  }) => ProcLike;
41
+ /**
42
+ * Why runChild killed the child. Five sources converge on one kill path, and
43
+ * each names itself here rather than in its own flag — so a consumer reads ONE
44
+ * field, and a sixth source is one more member, not a fourth boolean that every
45
+ * consumer has to remember to test before `aborted`.
46
+ *
47
+ * `aborted` is the caller's signal with no cause attached: a user cancel, or a
48
+ * wall clock that aborts without saying so. `command-timeout` also arrives
49
+ * through the signal, but the watchdog aborts WITH its kill as the reason
50
+ * (command-watchdog.ts), which is how it stays a member here instead of an
51
+ * out-of-band query.
52
+ */
53
+ export type ChildKill = {
54
+ by: 'aborted';
55
+ } | {
56
+ by: 'loop';
57
+ hit: LoopHit;
58
+ } | {
59
+ by: 'stream-stall';
60
+ idleMs: number;
61
+ } | {
62
+ by: 'stalled';
63
+ } | CommandKillReason;
39
64
  export interface ChildResult {
40
65
  stdout: string;
41
66
  stderr: string;
42
67
  exitCode: number;
68
+ /** true exactly when `kill` is set. Kept as a flag for the text-mode callers. */
43
69
  aborted: boolean;
70
+ /**
71
+ * Set when WE ended the child. Its exit status then describes our SIGTERM
72
+ * and says nothing about the child's verdict, so a consumer must read this
73
+ * before the exit code.
74
+ */
75
+ kill?: ChildKill;
44
76
  /** Extracted assistant text (only populated in json-events mode). */
45
77
  text?: string;
46
78
  /**
@@ -51,24 +83,6 @@ export interface ChildResult {
51
83
  * Only populated in json-events mode.
52
84
  */
53
85
  modelError?: string;
54
- /**
55
- * true when the stall guard killed the child: no output for the stall
56
- * window AND the model endpoint probe found the backend unreachable.
57
- * Callers must check this BEFORE `aborted` — the kill sets aborted too,
58
- * and without the flag it would mislabel as a user cancel.
59
- */
60
- stalled?: boolean;
61
- /**
62
- * true when the STREAM watchdog killed the child: no output at all for the
63
- * configured inactivity window, regardless of whether the backend answers a
64
- * probe. Distinct from `stalled`, which requires an UNREACHABLE endpoint —
65
- * these hangs have a perfectly healthy server and a dead stream, so the
66
- * probe path could never fire. Callers must check this BEFORE `aborted`
67
- * (the kill sets aborted too) and route it into the connection-error retry.
68
- */
69
- streamStalled?: {
70
- idleMs: number;
71
- };
72
86
  }
73
87
  export interface ToolCall {
74
88
  name: string;
@@ -83,19 +97,7 @@ export interface ToolCall {
83
97
  */
84
98
  toolCallId?: string;
85
99
  }
86
- export interface LoopHit {
87
- call: ToolCall;
88
- count: number;
89
- windowSize: number;
90
- /**
91
- * Set when the kill came from the whole-run StallDetector rather than the
92
- * short-window LoopDetector, naming which of its two rules tripped
93
- * (task/stall-detector.ts). Absent for an ordinary loop hit. Carried here so
94
- * a stall rides the kill/restart plumbing the loop hit already has instead of
95
- * needing a second channel.
96
- */
97
- stall?: 'no-new-ground' | 'context-churn';
98
- }
100
+ export type { LoopHit } from '../task/loop-detector.js';
99
101
  export interface ContextSnapshot {
100
102
  tokens: number;
101
103
  contextWindow: number;
@@ -215,7 +217,7 @@ export declare class JsonEventSink {
215
217
  private buf;
216
218
  constructor(opts: RunChildJsonEventsOptions,
217
219
  /** Invoked when onToolCall reports a loop hit — runChild kills the child. */
218
- onLoopKill: () => void);
220
+ onLoopKill: (hit: LoopHit) => void);
219
221
  /** Feed a raw stdout chunk: parse every complete line, buffer the partial tail. */
220
222
  feed(chunk: string): void;
221
223
  /** Flush a trailing event that wasn't newline-terminated (call on close). */