@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
@@ -5,7 +5,7 @@
5
5
  * directly, because its state is the whole-run `WidgetState`, not one child's.
6
6
  */
7
7
  import { getConfig } from '../config/config.js';
8
- import { MODEL_INHERIT, splitSpec } from '../config/group-models.js';
8
+ import { resolveModel } from '../shared/model-resolve.js';
9
9
  /**
10
10
  * The parent session's context window, or 0 when the model doesn't expose it.
11
11
  *
@@ -53,12 +53,7 @@ export function contextWindowForGroup(ctx, group, cfg = getConfig()) {
53
53
  * from the one it just checked.
54
54
  */
55
55
  export function contextWindowForSpec(ctx, spec) {
56
- if (spec === MODEL_INHERIT)
57
- return getParentContextWindow(ctx);
58
- const parts = splitSpec(spec);
59
- const found = parts && ctx.modelRegistry?.find(parts.provider, parts.id);
60
- const window = found?.contextWindow ?? 0;
61
- return window > 0 ? window : getParentContextWindow(ctx);
56
+ return resolveModel(ctx, spec)?.contextWindow || getParentContextWindow(ctx);
62
57
  }
63
58
  /**
64
59
  * Fold a raw context snapshot into a display snapshot: prefer the child's own
@@ -5,97 +5,69 @@
5
5
  import { updateTaskFrontMatter } from './task-io.js';
6
6
  import { flashTerminalWidget } from './widget.js';
7
7
  import { publishLifecycleNotice } from '../remote/bridge.js';
8
- import { BackendDownError, CommandTimeoutError, LoopExhaustedError, LeakedToolCallError, ModelError, USER_CANCELLED } from './child-runner.js';
8
+ import { ChildFailureError, USER_CANCELLED } from './child-runner.js';
9
+ import { streamStallCause } from '../shared/stream-watchdog.js';
9
10
  // ─── Classifier ──────────────────────────────────────────────────────────────
11
+ const failed = (reason, flash, notify) => ({
12
+ state: 'failed',
13
+ reason,
14
+ flash,
15
+ notify,
16
+ level: 'error'
17
+ });
18
+ /**
19
+ * What one phase child failure says to the user. A switch over the cause, so a
20
+ * new arm cannot be added to `ChildFailure` without a notice.
21
+ */
22
+ function classifyChildFailure(e) {
23
+ const f = e.failure;
24
+ switch (f.kind) {
25
+ case 'stalled':
26
+ return failed(`model_unreachable: ${e.message}`, 'model_unreachable', 'failed: model unreachable — restart the model, then resume.');
27
+ // The fix is in the SPEC, not the model, so the notify says which command.
28
+ case 'command-timeout':
29
+ return failed(e.message.slice(0, 200), 'command_timeout', `failed: \`${f.toolName}\` never returned on any attempt. Resume to bound it in VERIFY.`);
30
+ case 'loop':
31
+ return failed(`loop detected ${f.strikes}× in ${e.phase}`, 'loop_detected', `failed: ${e.phase} loop detected ${f.strikes}×. Resume to retry.`);
32
+ case 'leaked-tool-call':
33
+ return failed(`leaked tool call in ${e.phase}: ${f.text.trim()}`, 'leaked_tool_call', `failed: ${e.phase} wrote a tool call as text instead of running it — it never executed. Resume to retry.`);
34
+ case 'model-error':
35
+ case 'stream-stall': {
36
+ const cause = f.kind === 'model-error' ? f.cause : streamStallCause(f.idleMs);
37
+ return failed(`model_error in ${e.phase}: ${cause.slice(0, 160)}`, 'model_error', `failed: ${e.phase} — model error: ${cause.slice(0, 120)}. Restart the model, then resume.`);
38
+ }
39
+ case 'worker-timeout':
40
+ return failed(e.message.slice(0, 200), 'child_timeout', `failed: ${e.phase} ran out of time on every attempt. Resume to retry.`);
41
+ case 'aborted':
42
+ case 'exit':
43
+ case 'empty-answer':
44
+ return unreachable(e.message) ?? generic(e.message);
45
+ }
46
+ }
47
+ const generic = (msg) => failed(msg.slice(0, 200), msg.slice(0, 80), `failed: ${msg.slice(0, 120)}`);
48
+ /**
49
+ * A failure whose only evidence of a dead backend is an errno in its text: a
50
+ * child's stderr, the research phase's own network calls, a probe.
51
+ */
52
+ function unreachable(msg) {
53
+ if (!/ECONNREFUSED|fetch failed|connect/i.test(msg))
54
+ return undefined;
55
+ return failed(`model_unreachable: ${msg.slice(0, 120)}`, 'model_unreachable', 'failed: model unreachable.');
56
+ }
10
57
  export function classifyFailure(err, aborted) {
11
58
  const msg = err instanceof Error ? err.message : String(err);
12
59
  if (aborted || msg === USER_CANCELLED) {
13
60
  return { state: 'cancelled', notify: 'cancelled.', level: 'warning' };
14
61
  }
15
- // Classified by TYPE, above the message-sniffing branch below: this is the one
16
- // case where the probe positively established the endpoint did not answer, and
17
- // its message names no errno for that branch to match.
18
- if (err instanceof BackendDownError) {
19
- return {
20
- state: 'failed',
21
- reason: `model_unreachable: ${err.message}`,
22
- flash: 'model_unreachable',
23
- notify: 'failed: model unreachable — restart the model, then resume.',
24
- level: 'error'
25
- };
26
- }
27
- // The fix is in the SPEC, not the model, so the notify says which command.
28
- if (err instanceof CommandTimeoutError) {
29
- return {
30
- state: 'failed',
31
- reason: err.message.slice(0, 200),
32
- flash: 'command_timeout',
33
- notify: `failed: \`${err.kill.toolName}\` never returned on any attempt. `
34
- + `Resume to bound it in VERIFY.`,
35
- level: 'error'
36
- };
37
- }
38
- if (err instanceof LoopExhaustedError) {
39
- return {
40
- state: 'failed',
41
- reason: `loop detected ${err.history.length}× in ${err.phase}`,
42
- flash: 'loop_detected',
43
- notify: `failed: ${err.phase} loop detected ${err.history.length}×. Resume to retry.`,
44
- level: 'error'
45
- };
46
- }
47
- if (err instanceof LeakedToolCallError) {
48
- return {
49
- state: 'failed',
50
- reason: `leaked tool call in ${err.phase}: ${err.marker.trim()}`,
51
- flash: 'leaked_tool_call',
52
- notify: `failed: ${err.phase} wrote a tool call as text instead of running it — it never executed. Resume to retry.`,
53
- level: 'error'
54
- };
55
- }
56
- if (err instanceof ModelError) {
57
- return {
58
- state: 'failed',
59
- reason: `model_error in ${err.phase}: ${err.cause.slice(0, 160)}`,
60
- flash: 'model_error',
61
- notify: `failed: ${err.phase} — model error: ${err.cause.slice(0, 120)}. Restart the model, then resume.`,
62
- level: 'error'
63
- };
64
- }
62
+ if (err instanceof ChildFailureError)
63
+ return classifyChildFailure(err);
65
64
  if (msg === 'no_verify_block') {
66
- return {
67
- state: 'failed',
68
- reason: 'no_verify_block',
69
- flash: 'no_verify_block',
70
- notify: 'failed: spec has no VERIFY block. Resume to edit and try again.',
71
- level: 'error'
72
- };
65
+ return failed('no_verify_block', 'no_verify_block', 'failed: spec has no VERIFY block. Resume to edit and try again.');
73
66
  }
74
67
  if (msg.startsWith('compose_invalid')) {
75
- return {
76
- state: 'failed',
77
- reason: msg.slice(0, 200),
78
- flash: 'compose_invalid',
79
- notify: `failed: compose produced malformed spec (${msg.replace(/^compose_invalid:\s*/, '')}). Resume to retry.`,
80
- level: 'error'
81
- };
82
- }
83
- if (/ECONNREFUSED|fetch failed|connect/i.test(msg)) {
84
- return {
85
- state: 'failed',
86
- reason: `model_unreachable: ${msg.slice(0, 120)}`,
87
- flash: 'model_unreachable',
88
- notify: 'failed: model unreachable.',
89
- level: 'error'
90
- };
68
+ return failed(msg.slice(0, 200), 'compose_invalid', `failed: compose produced malformed spec (${msg.replace(/^compose_invalid:\s*/, '')}). Resume to retry.`);
91
69
  }
92
- return {
93
- state: 'failed',
94
- reason: msg.slice(0, 200),
95
- flash: msg.slice(0, 80),
96
- notify: `failed: ${msg.slice(0, 120)}`,
97
- level: 'error'
98
- };
70
+ return unreachable(msg) ?? generic(msg);
99
71
  }
100
72
  /**
101
73
  * Persist, flash and announce a failure, and return the classification.
@@ -24,7 +24,7 @@
24
24
  * `runWorker` and the git helpers are injected, so the ordering, the trail lines
25
25
  * and the throwing-child path are directly assertable.
26
26
  */
27
- import { formatLoopHint } from './child-runner.js';
27
+ import { formatLoopHint } from './loop-detector.js';
28
28
  import { classifyEnforceChildFailure } from './enforce-guidelines.js';
29
29
  /**
30
30
  * What each kind may do. Adding a child is a row; it cannot be added without
@@ -42,6 +42,8 @@ export declare function armImplWidget(meta: ImplWidgetMeta, opts: {
42
42
  }): void;
43
43
  /** Tear down the widget and clear the armed slot (sticky/awaited path). */
44
44
  export declare function disarmImplWidget(): void;
45
+ /** @internal Test seam: is a turn currently showing the widget? */
46
+ export declare function implWidgetArmed(): boolean;
45
47
  /** Wire the agent-lifecycle handlers that drive the widget. Call once at setup.
46
48
  * All three events are pi's own: `agent_start`, `tool_execution_start` and
47
49
  * `agent_end` each have an `on()` overload, and ToolExecutionStartEvent carries
@@ -107,6 +107,10 @@ export function disarmImplWidget() {
107
107
  activeCtx = null;
108
108
  lastLine = undefined;
109
109
  }
110
+ /** @internal Test seam: is a turn currently showing the widget? */
111
+ export function implWidgetArmed() {
112
+ return armed !== null;
113
+ }
110
114
  /** Wire the agent-lifecycle handlers that drive the widget. Call once at setup.
111
115
  * All three events are pi's own: `agent_start`, `tool_execution_start` and
112
116
  * `agent_end` each have an `on()` overload, and ToolExecutionStartEvent carries
@@ -45,6 +45,7 @@
45
45
  */
46
46
  import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
47
47
  import { type GroupSetting } from '../config/reasoning.js';
48
+ import { type ModelContext, type PiModel } from '../shared/model-resolve.js';
48
49
  import { type HoldStash } from './model-hold-stash.js';
49
50
  /**
50
51
  * The slice of the extension API this needs, named so tests can drive the
@@ -79,6 +80,16 @@ export interface ImplementationControls<H = unknown> {
79
80
  thinking: ThinkingControl;
80
81
  model: ModelControl<H>;
81
82
  }
83
+ /**
84
+ * The live session's model as a {@link ModelControl}, for both the turn's hold
85
+ * and the crash restore at session_start.
86
+ *
87
+ * `current()` reads `ctx.model`, which is a live GETTER on the extension
88
+ * context (pi's `core/extensions/runner.js`), so a read after a set is the new
89
+ * value. `apply` is the caller's because the setter lives on `ExtensionAPI`,
90
+ * which the two callers hold differently.
91
+ */
92
+ export declare function liveModelControl(ctx: ModelContext, apply: (handle: PiModel) => Promise<boolean>): ModelControl<PiModel>;
82
93
  /**
83
94
  * The whole hold: model, then thinking. Returns the release, which is async and
84
95
  * idempotent. Always call it from a `finally`, never the happy path.
@@ -1,7 +1,27 @@
1
1
  import { getConfig } from '../config/config.js';
2
2
  import { MODEL_INHERIT } from '../config/group-models.js';
3
3
  import { resolveReasoning } from '../config/reasoning.js';
4
+ import { resolveModel } from '../shared/model-resolve.js';
4
5
  import { readHoldStash, writeHoldStash, clearHoldStash } from './model-hold-stash.js';
6
+ /**
7
+ * The live session's model as a {@link ModelControl}, for both the turn's hold
8
+ * and the crash restore at session_start.
9
+ *
10
+ * `current()` reads `ctx.model`, which is a live GETTER on the extension
11
+ * context (pi's `core/extensions/runner.js`), so a read after a set is the new
12
+ * value. `apply` is the caller's because the setter lives on `ExtensionAPI`,
13
+ * which the two callers hold differently.
14
+ */
15
+ export function liveModelControl(ctx, apply) {
16
+ return {
17
+ current: () => {
18
+ const m = resolveModel(ctx, MODEL_INHERIT);
19
+ return m && { spec: m.spec, handle: m.handle };
20
+ },
21
+ resolve: spec => resolveModel(ctx, spec)?.handle,
22
+ apply
23
+ };
24
+ }
5
25
  /**
6
26
  * `before` is the level read BEFORE any model move; `applied` is what is really
7
27
  * in force after both moves.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The implementation-turn bracket: one entry arms both the status widget and the
3
+ * runaway guard, one `leave` disarms both.
4
+ *
5
+ * The two modules keep their own lifecycle handlers, and they draw the turn
6
+ * boundary differently on purpose: the widget hides on `agent_end` (a sub-turn is
7
+ * over, the screen should say so), the guard survives until `agent_settled`
8
+ * (compactions and retries fire `agent_end` INSIDE a turn — see its header). What
9
+ * they share is the caller's decision — when to arm, and on which paths to disarm
10
+ * — and a missed disarm on either one outlives the turn: commit ebac475 added a
11
+ * third disarm site for one path that was covered on the widget and not the guard.
12
+ */
13
+ import { type ImplWidgetMeta } from './impl-widget.js';
14
+ /**
15
+ * `oneShot` true (fire-and-forget /task) lets each module's own settle handler
16
+ * disarm after the single turn; false (awaited /task-auto) keeps both armed
17
+ * across resume and steer turns until `leave` is called.
18
+ *
19
+ * `leave` is idempotent: a second call is a no-op, so a caller can put it in a
20
+ * `finally` and a `catch` without disarming a bracket entered since.
21
+ */
22
+ export declare function enterImplementationTurn(meta: ImplWidgetMeta, opts: {
23
+ oneShot: boolean;
24
+ }): () => void;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The implementation-turn bracket: one entry arms both the status widget and the
3
+ * runaway guard, one `leave` disarms both.
4
+ *
5
+ * The two modules keep their own lifecycle handlers, and they draw the turn
6
+ * boundary differently on purpose: the widget hides on `agent_end` (a sub-turn is
7
+ * over, the screen should say so), the guard survives until `agent_settled`
8
+ * (compactions and retries fire `agent_end` INSIDE a turn — see its header). What
9
+ * they share is the caller's decision — when to arm, and on which paths to disarm
10
+ * — and a missed disarm on either one outlives the turn: commit ebac475 added a
11
+ * third disarm site for one path that was covered on the widget and not the guard.
12
+ */
13
+ import { armImplWidget, disarmImplWidget } from './impl-widget.js';
14
+ import { armImplementationGuard, disarmImplementationGuard } from './implementation-guards.js';
15
+ /**
16
+ * `oneShot` true (fire-and-forget /task) lets each module's own settle handler
17
+ * disarm after the single turn; false (awaited /task-auto) keeps both armed
18
+ * across resume and steer turns until `leave` is called.
19
+ *
20
+ * `leave` is idempotent: a second call is a no-op, so a caller can put it in a
21
+ * `finally` and a `catch` without disarming a bracket entered since.
22
+ */
23
+ export function enterImplementationTurn(meta, opts) {
24
+ armImplWidget(meta, opts);
25
+ armImplementationGuard(opts);
26
+ let left = false;
27
+ return () => {
28
+ if (left)
29
+ return;
30
+ left = true;
31
+ disarmImplWidget();
32
+ disarmImplementationGuard();
33
+ };
34
+ }
@@ -13,11 +13,9 @@
13
13
  * Either pattern returns a LoopHit so the caller can kill the child and re-spawn
14
14
  * with a hint. No I/O. No imports from index.ts. Trivially unit-testable.
15
15
  *
16
- * The three tuning constants live here rather than in child-runner.ts because
17
- * worker-profiles.ts reads them at module top level to build DEFAULT_LOOP_DETECTOR.
18
- * From child-runner.ts that is a cycle child-runner worker-profiles →
19
- * child-runner — and the failure is a TDZ ReferenceError on import order, which
20
- * no compile step catches. This module imports nothing, so it cannot close one.
16
+ * This module imports nothing: worker-profiles.ts reads the tuning constants at
17
+ * module top level, and a dependency from here on any runner would close a cycle
18
+ * whose only symptom is a TDZ ReferenceError on import order.
21
19
  */
22
20
  /** Recent tool calls the exact-repeat rule looks back over. */
23
21
  export declare const LOOP_WINDOW = 20;
@@ -33,6 +31,14 @@ export interface LoopHit {
33
31
  call: ToolCall;
34
32
  count: number;
35
33
  windowSize: number;
34
+ /**
35
+ * Set when the kill came from the whole-run StallDetector rather than this
36
+ * short-window detector, naming which of its two rules tripped
37
+ * (stall-detector.ts). Absent for an ordinary loop hit. Carried here so a
38
+ * stall rides the kill/restart plumbing the loop hit already has instead of
39
+ * needing a second channel.
40
+ */
41
+ stall?: 'no-new-ground' | 'context-churn';
36
42
  }
37
43
  /**
38
44
  * JSON.stringify with sorted object keys so {a:1,b:2} and {b:2,a:1} hash equal.
@@ -78,3 +84,5 @@ export declare class LoopDetector {
78
84
  */
79
85
  private countRevisits;
80
86
  }
87
+ /** The restart hint a re-spawned child gets after a loop kill: names the call. */
88
+ export declare function formatLoopHint(hit: LoopHit): string;
@@ -13,11 +13,9 @@
13
13
  * Either pattern returns a LoopHit so the caller can kill the child and re-spawn
14
14
  * with a hint. No I/O. No imports from index.ts. Trivially unit-testable.
15
15
  *
16
- * The three tuning constants live here rather than in child-runner.ts because
17
- * worker-profiles.ts reads them at module top level to build DEFAULT_LOOP_DETECTOR.
18
- * From child-runner.ts that is a cycle child-runner worker-profiles →
19
- * child-runner — and the failure is a TDZ ReferenceError on import order, which
20
- * no compile step catches. This module imports nothing, so it cannot close one.
16
+ * This module imports nothing: worker-profiles.ts reads the tuning constants at
17
+ * module top level, and a dependency from here on any runner would close a cycle
18
+ * whose only symptom is a TDZ ReferenceError on import order.
21
19
  */
22
20
  /** Recent tool calls the exact-repeat rule looks back over. */
23
21
  export const LOOP_WINDOW = 20;
@@ -159,3 +157,11 @@ export class LoopDetector {
159
157
  return revisits;
160
158
  }
161
159
  }
160
+ /** The restart hint a re-spawned child gets after a loop kill: names the call. */
161
+ export function formatLoopHint(hit) {
162
+ const argsStr = JSON.stringify(hit.call.args);
163
+ return (`[SYSTEM NOTE: Your prior attempt called ${hit.call.name}(${argsStr}) `
164
+ + `${hit.count} times in the last ${hit.windowSize} tool calls — you appeared to be `
165
+ + `stuck in a loop. Avoid repeating that exact call; if you've already seen its result, `
166
+ + `work from memory or pick a different angle.]`);
167
+ }
@@ -1,9 +1,9 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { splitSpec } from '../config/group-models.js';
4
3
  import { defaultModelRef } from '../shared/model-endpoint.js';
4
+ import { specOf } from '../shared/model-resolve.js';
5
5
  import { stateFile } from '../shared/data-home.js';
6
- import { restoreHeldModel } from './implementation-hold.js';
6
+ import { liveModelControl, restoreHeldModel } from './implementation-hold.js';
7
7
  const stashPath = () => stateFile('model-hold.json');
8
8
  export function readHoldStash() {
9
9
  try {
@@ -47,23 +47,13 @@ export function clearHoldStash() {
47
47
  */
48
48
  export function registerModelHoldRestore(pi) {
49
49
  pi.on('session_start', (_event, ctx) => {
50
- const model = {
51
- current: () => {
52
- const m = ctx.model;
53
- return m ? { spec: `${m.provider}/${m.id}`, handle: m } : undefined;
54
- },
55
- resolve: spec => {
56
- const parts = splitSpec(spec);
57
- return parts ? ctx.modelRegistry.find(parts.provider, parts.id) : undefined;
58
- },
59
- apply: handle => pi.setModel(handle)
60
- };
50
+ const model = liveModelControl(ctx, handle => pi.setModel(handle));
61
51
  // The saved default, read from the file the crash left wrong — not from
62
52
  // `ctx.model`, which a `--model` flag or a resumed session can make say
63
53
  // something else entirely.
64
54
  const saved = () => {
65
55
  const ref = defaultModelRef();
66
- return ref && `${ref.provider}/${ref.id}`;
56
+ return ref && specOf(ref);
67
57
  };
68
58
  void restoreHeldModel(model, saved).catch(() => { });
69
59
  });
@@ -21,16 +21,10 @@ import { type WidgetState } from './widget.js';
21
21
  import { type RunTaskFn } from './gate-deps.js';
22
22
  import { type GateDeps } from './task-gates.js';
23
23
  import { type PhaseSeams } from './child-runner.js';
24
+ import type { PiModel } from '../shared/model-resolve.js';
24
25
  import { type ImplementationControls } from './implementation-hold.js';
25
26
  import { type RunEnd } from './run-end.js';
26
27
  import { type SuperviseOptions } from './implementation-turn.js';
27
- /**
28
- * pi's own `Model`, named WITHOUT importing `@earendil-works/pi-ai` — which is
29
- * neither a dependency, a devDependency nor a peerDependency of this package
30
- * (see shared/model-endpoint.ts's header). The context already carries the type,
31
- * so deriving it costs nothing and adds no edge to the dependency graph.
32
- */
33
- type PiModel = NonNullable<ExtensionCommandContext['model']>;
34
28
  /** Both halves of the implementation hold, over the live session. */
35
29
  export declare function piImplementationControls(ctx: ExtensionCommandContext): ImplementationControls<PiModel>;
36
30
  /**
@@ -235,4 +229,3 @@ export declare function runGatedTask(ctx: ExtensionCommandContext, cwd: string,
235
229
  deps?: GateDeps;
236
230
  }): Promise<void>;
237
231
  export declare function registerTask(pi: ExtensionAPI): void;
238
- export {};
@@ -25,8 +25,8 @@ import { normaliseTaskId, parseFrontMatter, extractSection } from './task-parser
25
25
  import { readTextFile } from '../shared/fs-text.js';
26
26
  import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
27
27
  import { startWidget } from './widget.js';
28
- import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.js';
29
- import { armImplementationGuard, disarmImplementationGuard } from './implementation-guards.js';
28
+ import { setupImplWidget } from './impl-widget.js';
29
+ import { enterImplementationTurn } from './implementation-scope.js';
30
30
  import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
31
31
  import { pushNotify } from '../remote/push.js';
32
32
  import { getConfig } from '../config/config.js';
@@ -38,8 +38,7 @@ import { findDeliveryPhantoms, formatApiOverrideBanner } from '../workers/phanto
38
38
  import { titleForDisplay } from './parsers.js';
39
39
  import { USER_CANCELLED } from './child-runner.js';
40
40
  import { cancelCheckpoint } from './cancel-points.js';
41
- import { splitSpec } from '../config/group-models.js';
42
- import { holdImplementation } from './implementation-hold.js';
41
+ import { holdImplementation, liveModelControl } from './implementation-hold.js';
43
42
  import { rearmCancelListener } from './cancel-input.js';
44
43
  import { takeHeldInput } from './mid-run-input.js';
45
44
  import { withRun, announceTerminal } from './run-bracket.js';
@@ -82,27 +81,12 @@ function piThinkingControl() {
82
81
  }
83
82
  /**
84
83
  * The live session's model, as a {@link ModelControl} over pi's own `Model`.
85
- *
86
- * `current()` reads `ctx.model`, which is a live GETTER on the extension context
87
- * (pi's `core/extensions/runner.js`), so a read after a set is the new value.
88
- * `resolve` goes through `find(provider, id)` — EXACT, deliberately stricter
89
- * than pi's own CLI, which also substring-matches. We store a canonical
90
- * `provider/id`, so exact is the only match that should ever count, and being
91
- * stricter here can only cost us a hold we then decline to take.
84
+ * Before `registerTask(pi)` has run there is nothing to apply to, so the move
85
+ * reports failure and the hold declines same degrade as the thinking half.
92
86
  */
93
87
  function piModelControl(ctx) {
94
88
  const api = piApi;
95
- return {
96
- current: () => {
97
- const m = ctx.model;
98
- return m ? { spec: `${m.provider}/${m.id}`, handle: m } : undefined;
99
- },
100
- resolve: spec => {
101
- const parts = splitSpec(spec);
102
- return parts ? ctx.modelRegistry.find(parts.provider, parts.id) : undefined;
103
- },
104
- apply: async (handle) => (api ? api.setModel(handle) : false)
105
- };
89
+ return liveModelControl(ctx, async (handle) => (api ? api.setModel(handle) : false));
106
90
  }
107
91
  /** Both halves of the implementation hold, over the live session. */
108
92
  export function piImplementationControls(ctx) {
@@ -396,10 +380,7 @@ export class TaskRunner {
396
380
  label: this._widgetState.label
397
381
  };
398
382
  if (this._sendSpec) {
399
- armImplWidget(meta, { oneShot: !this._implAwaited });
400
- // Same lifetime as the widget, and for the same reason: an awaited run
401
- // spans resume and steer turns, a fire-and-forget one does not.
402
- armImplementationGuard({ oneShot: !this._implAwaited });
383
+ const leave = enterImplementationTurn(meta, { oneShot: !this._implAwaited });
403
384
  let delivered = false;
404
385
  try {
405
386
  await this._sendSpec(spec);
@@ -411,18 +392,15 @@ export class TaskRunner {
411
392
  // run: pi's `prompt` rejects on a compaction already in progress, a
412
393
  // missing model, or a failed auth. The next unrelated turn would
413
394
  // inherit it, and this guard can end a turn outright.
414
- if (this._implAwaited || !delivered) {
415
- disarmImplWidget();
416
- disarmImplementationGuard();
417
- }
395
+ if (this._implAwaited || !delivered)
396
+ leave();
418
397
  }
419
398
  return;
420
399
  }
421
400
  if (!piApi) {
422
401
  throw new Error('extension not initialised (no ExtensionAPI captured)');
423
402
  }
424
- armImplWidget(meta, { oneShot: true });
425
- armImplementationGuard({ oneShot: true });
403
+ const leave = enterImplementationTurn(meta, { oneShot: true });
426
404
  // Same reason as the awaited path's `delivered` flag: this send can throw
427
405
  // SYNCHRONOUSLY — the loader gates every ExtensionAPI action behind
428
406
  // `assertActive()` — and a guard left armed over a turn that never starts
@@ -436,8 +414,7 @@ export class TaskRunner {
436
414
  piApi.sendUserMessage(spec, { deliverAs: 'followUp' });
437
415
  }
438
416
  catch (e) {
439
- disarmImplWidget();
440
- disarmImplementationGuard();
417
+ leave();
441
418
  throw e;
442
419
  }
443
420
  }
@@ -38,7 +38,7 @@ import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from '.
38
38
  import { readRequirements, buildRequirementsBlock, buildOwnedRequirementsBlock, readOwnedRequirements, writeOwnedRequirements, ownedForTitle, appendOwnedConstraints } from './requirements.js';
39
39
  import { detachUnsatisfiableRequirements, claimPendingRequirements, unclaimedPendingRequirements, formatReassignActions } from './owned-freeze-reassign.js';
40
40
  import { trackedSourceOracle } from './owned-freeze-conflict.js';
41
- import { groupArgsForChild, runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED, CommandTimeoutError, isFatalChildCause } from './child-runner.js';
41
+ import { groupArgsForChild, runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED, ChildFailureError, isFatalChildCause } from './child-runner.js';
42
42
  import { runResearchWorker, researchWorkerCacheHeading } from './research-worker.js';
43
43
  import { SessionUI } from '../remote/bridge.js';
44
44
  import { isYoloMode, yoloPickAutoAnswer } from './yolo.js';
@@ -308,7 +308,7 @@ export async function phaseVerifyTooling(deps, research) {
308
308
  // degrade for a child that merely failed. A hung command is different: it
309
309
  // cost the ceiling on every strike and says the SPEC named something
310
310
  // unbounded, so it is the one cause worth a trail line rather than silence.
311
- if (e instanceof CommandTimeoutError) {
311
+ if (e instanceof ChildFailureError && e.failure.kind === 'command-timeout') {
312
312
  deps.logDebug?.(`verify-tooling: ${e.message} — shipping the list unverified`);
313
313
  }
314
314
  return replaceToolingWithVerified(research, commands);
@@ -6,7 +6,7 @@
6
6
  * takes, and that is not a property of the pathology — it is a property of the
7
7
  * model, its sampler settings, the reasoning budget and the size of the document
8
8
  * being read. Any of those moving turns the margin into a killer of good runs.
9
- * `PHASE_CHILD_TIMEOUT_MS` is 0 (off) for exactly that reason.
9
+ * The `phase` profile arms no wall clock (worker-profiles.ts) for exactly that reason.
10
10
  *
11
11
  * WHAT REPLACES IT. Two bounds, both dimensionless — invariant to model speed,
12
12
  * project size and reasoning budget:
@@ -6,7 +6,7 @@
6
6
  * takes, and that is not a property of the pathology — it is a property of the
7
7
  * model, its sampler settings, the reasoning budget and the size of the document
8
8
  * being read. Any of those moving turns the margin into a killer of good runs.
9
- * `PHASE_CHILD_TIMEOUT_MS` is 0 (off) for exactly that reason.
9
+ * The `phase` profile arms no wall clock (worker-profiles.ts) for exactly that reason.
10
10
  *
11
11
  * WHAT REPLACES IT. Two bounds, both dimensionless — invariant to model speed,
12
12
  * project size and reasoning budget:
@@ -23,8 +23,9 @@
23
23
  * different fixes (`models.json` vs `/task-config`) and that line is already at
24
24
  * its length budget.
25
25
  */
26
- import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
26
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
27
27
  import { type ChildGroup } from '../config/groups.js';
28
+ import { type GroupModelSnapshot } from '../shared/model-resolve.js';
28
29
  /** One cell that will not do what it says. */
29
30
  export interface ModelProblem {
30
31
  group: ChildGroup;
@@ -42,12 +43,8 @@ export interface ModelProblem {
42
43
  */
43
44
  why: 'unresolved' | 'extension';
44
45
  }
45
- /** The registry questions this needs, so a test can answer them with a literal. */
46
- export interface ModelLookup {
47
- find: (provider: string, id: string) => unknown;
48
- extensionProviders: ReadonlySet<string>;
49
- }
50
- export declare function findModelProblems(lookup: ModelLookup, specs: Readonly<Record<ChildGroup, string>>): ModelProblem[];
46
+ /** The cells worth a line, in group order. */
47
+ export declare function modelProblems(snapshot: Readonly<Record<ChildGroup, GroupModelSnapshot>>): ModelProblem[];
51
48
  /**
52
49
  * The hint line, or null when every cell is fine.
53
50
  *
@@ -58,12 +55,3 @@ export declare function formatModelWarning(problems: readonly ModelProblem[]): s
58
55
  export declare function registerModelWarning(pi: ExtensionAPI,
59
56
  /** Injected by tests, which must not depend on the developer's saved config. */
60
57
  readSpecs?: () => Readonly<Record<ChildGroup, string>>): void;
61
- /**
62
- * Answer, once, what every model cell resolves to — and leave both answers where
63
- * the code that has no `ctx` can read them.
64
- *
65
- * `unresolved` only feeds the argv drop: an extension-provided model may well
66
- * work, since children load explicitly whitelisted extensions, and dropping its
67
- * flag would break a config that is merely fragile.
68
- */
69
- export declare function resolveModelCells(ctx: ExtensionContext, specs: Readonly<Record<ChildGroup, string>>): ModelProblem[];