@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.
- package/README.md +2 -1
- package/dist/config/config.d.ts +16 -2
- package/dist/config/config.js +7 -2
- package/dist/config/group-args.d.ts +52 -0
- package/dist/config/group-args.js +110 -0
- package/dist/config/group-models.d.ts +88 -0
- package/dist/config/group-models.js +117 -0
- package/dist/config/groups.d.ts +76 -0
- package/dist/config/groups.js +110 -0
- package/dist/config/option-picker.d.ts +42 -0
- package/dist/config/option-picker.js +73 -0
- package/dist/config/reasoning.d.ts +22 -63
- package/dist/config/reasoning.js +37 -108
- package/dist/config/register.d.ts +98 -12
- package/dist/config/register.js +228 -23
- package/dist/index.js +4 -0
- package/dist/remote/push.js +1 -7
- package/dist/shared/command-watchdog.d.ts +63 -0
- package/dist/shared/command-watchdog.js +87 -0
- package/dist/shared/data-home.d.ts +8 -0
- package/dist/shared/data-home.js +14 -0
- package/dist/shared/model-endpoint.d.ts +53 -0
- package/dist/shared/model-endpoint.js +98 -2
- package/dist/shared/reasoning-capability.d.ts +25 -5
- package/dist/shared/reasoning-capability.js +18 -9
- package/dist/task/auto-orchestrator.js +14 -3
- package/dist/task/child-runner.d.ts +92 -15
- package/dist/task/child-runner.js +303 -66
- package/dist/task/context-usage.d.ts +46 -0
- package/dist/task/context-usage.js +41 -0
- package/dist/task/failure-classifier.js +24 -1
- package/dist/task/gate-child.d.ts +15 -4
- package/dist/task/gate-child.js +2 -2
- package/dist/task/gate-deps.js +7 -2
- package/dist/task/implementation-guards.d.ts +26 -0
- package/dist/task/implementation-guards.js +177 -0
- package/dist/task/implementation-hold.d.ts +118 -0
- package/dist/task/implementation-hold.js +165 -0
- package/dist/task/implementation-turn.d.ts +5 -0
- package/dist/task/implementation-turn.js +12 -1
- package/dist/task/loop-detector.d.ts +18 -0
- package/dist/task/loop-detector.js +22 -2
- package/dist/task/model-hold-stash.d.ts +43 -0
- package/dist/task/model-hold-stash.js +70 -0
- package/dist/task/orchestrator.d.ts +18 -5
- package/dist/task/orchestrator.js +63 -6
- package/dist/task/phases.js +18 -5
- package/dist/task/research-worker.d.ts +2 -2
- package/dist/task/research-worker.js +1 -1
- package/dist/workers/docs-core.js +2 -2
- package/dist/workers/docs-lookup.d.ts +4 -3
- package/dist/workers/docs-lookup.js +1 -1
- package/dist/workers/fetch-core.js +2 -2
- package/dist/workers/focused-extractor.d.ts +6 -4
- package/dist/workers/focused-extractor.js +17 -5
- package/dist/workers/index.js +2 -0
- package/dist/workers/model-warning.d.ts +69 -0
- package/dist/workers/model-warning.js +113 -0
- package/dist/workers/pi-worker-core.d.ts +9 -38
- package/dist/workers/pi-worker-core.js +8 -86
- package/dist/workers/pi-worker-docs.js +2 -2
- package/dist/workers/pi-worker.js +4 -4
- package/dist/workers/reasoning-warning.d.ts +17 -9
- package/dist/workers/reasoning-warning.js +69 -22
- package/dist/workers/single-read-guard.d.ts +6 -6
- package/dist/workers/single-read-guard.js +8 -8
- package/dist/workers/worker-profiles.d.ts +11 -3
- package/dist/workers/worker-profiles.js +33 -1
- package/package.json +1 -1
- package/dist/config/reasoning-args.d.ts +0 -23
- package/dist/config/reasoning-args.js +0 -28
- package/dist/task/implementation-thinking.d.ts +0 -56
- package/dist/task/implementation-thinking.js +0 -32
package/dist/config/register.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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 =
|
|
366
|
+
const nextIsSibling = CHILD_GROUPS[CHILD_GROUPS.indexOf(group) + 1]?.startsWith(`${parent}:`);
|
|
349
367
|
return ` ${nextIsSibling ? '├─' : '└─'} ${group.slice(colon + 1)}`;
|
|
350
368
|
}
|
|
351
|
-
|
|
352
|
-
|
|
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`.
|
|
445
|
-
*
|
|
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 =
|
|
475
|
-
: kb.matches(data, 'tui.select.
|
|
476
|
-
:
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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 [
|
|
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
|
|
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
|
}
|
package/dist/remote/push.js
CHANGED
|
@@ -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
|
-
|
|
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
|