@mjasnikovs/pi-task 0.38.30 → 0.38.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +2 -1
  2. package/dist/config/config.d.ts +16 -2
  3. package/dist/config/config.js +7 -2
  4. package/dist/config/group-args.d.ts +52 -0
  5. package/dist/config/group-args.js +110 -0
  6. package/dist/config/group-models.d.ts +88 -0
  7. package/dist/config/group-models.js +117 -0
  8. package/dist/config/groups.d.ts +76 -0
  9. package/dist/config/groups.js +110 -0
  10. package/dist/config/option-picker.d.ts +42 -0
  11. package/dist/config/option-picker.js +73 -0
  12. package/dist/config/reasoning.d.ts +22 -63
  13. package/dist/config/reasoning.js +37 -108
  14. package/dist/config/register.d.ts +98 -12
  15. package/dist/config/register.js +228 -23
  16. package/dist/index.js +4 -0
  17. package/dist/remote/push.js +1 -7
  18. package/dist/shared/command-watchdog.d.ts +63 -0
  19. package/dist/shared/command-watchdog.js +87 -0
  20. package/dist/shared/data-home.d.ts +8 -0
  21. package/dist/shared/data-home.js +14 -0
  22. package/dist/shared/model-endpoint.d.ts +53 -0
  23. package/dist/shared/model-endpoint.js +98 -2
  24. package/dist/shared/reasoning-capability.d.ts +25 -5
  25. package/dist/shared/reasoning-capability.js +18 -9
  26. package/dist/task/auto-orchestrator.js +14 -3
  27. package/dist/task/child-runner.d.ts +92 -15
  28. package/dist/task/child-runner.js +303 -66
  29. package/dist/task/context-usage.d.ts +46 -0
  30. package/dist/task/context-usage.js +41 -0
  31. package/dist/task/failure-classifier.js +24 -1
  32. package/dist/task/gate-child.d.ts +15 -4
  33. package/dist/task/gate-child.js +2 -2
  34. package/dist/task/gate-deps.js +7 -2
  35. package/dist/task/implementation-guards.d.ts +26 -0
  36. package/dist/task/implementation-guards.js +177 -0
  37. package/dist/task/implementation-hold.d.ts +118 -0
  38. package/dist/task/implementation-hold.js +165 -0
  39. package/dist/task/implementation-turn.d.ts +5 -0
  40. package/dist/task/implementation-turn.js +12 -1
  41. package/dist/task/loop-detector.d.ts +18 -0
  42. package/dist/task/loop-detector.js +22 -2
  43. package/dist/task/model-hold-stash.d.ts +43 -0
  44. package/dist/task/model-hold-stash.js +70 -0
  45. package/dist/task/orchestrator.d.ts +18 -5
  46. package/dist/task/orchestrator.js +63 -6
  47. package/dist/task/phases.js +18 -5
  48. package/dist/task/research-worker.d.ts +2 -2
  49. package/dist/task/research-worker.js +1 -1
  50. package/dist/workers/docs-core.js +2 -2
  51. package/dist/workers/docs-lookup.d.ts +4 -3
  52. package/dist/workers/docs-lookup.js +1 -1
  53. package/dist/workers/fetch-core.js +2 -2
  54. package/dist/workers/focused-extractor.d.ts +6 -4
  55. package/dist/workers/focused-extractor.js +17 -5
  56. package/dist/workers/index.js +2 -0
  57. package/dist/workers/model-warning.d.ts +69 -0
  58. package/dist/workers/model-warning.js +113 -0
  59. package/dist/workers/pi-worker-core.d.ts +9 -38
  60. package/dist/workers/pi-worker-core.js +8 -86
  61. package/dist/workers/pi-worker-docs.js +2 -2
  62. package/dist/workers/pi-worker.js +4 -4
  63. package/dist/workers/reasoning-warning.d.ts +17 -9
  64. package/dist/workers/reasoning-warning.js +69 -22
  65. package/dist/workers/single-read-guard.d.ts +6 -6
  66. package/dist/workers/single-read-guard.js +8 -8
  67. package/dist/workers/worker-profiles.d.ts +11 -3
  68. package/dist/workers/worker-profiles.js +33 -1
  69. package/package.json +1 -1
  70. package/dist/config/reasoning-args.d.ts +0 -23
  71. package/dist/config/reasoning-args.js +0 -28
  72. package/dist/task/implementation-thinking.d.ts +0 -56
  73. package/dist/task/implementation-thinking.js +0 -32
@@ -5,13 +5,36 @@
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 { LoopExhaustedError, LeakedToolCallError, ModelError, USER_CANCELLED } from './child-runner.js';
8
+ import { BackendDownError, CommandTimeoutError, LoopExhaustedError, LeakedToolCallError, ModelError, USER_CANCELLED } from './child-runner.js';
9
9
  // ─── Classifier ──────────────────────────────────────────────────────────────
10
10
  export function classifyFailure(err, aborted) {
11
11
  const msg = err instanceof Error ? err.message : String(err);
12
12
  if (aborted || msg === USER_CANCELLED) {
13
13
  return { state: 'cancelled', notify: 'cancelled.', level: 'warning' };
14
14
  }
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
+ }
15
38
  if (err instanceof LoopExhaustedError) {
16
39
  return {
17
40
  state: 'failed',
@@ -75,15 +75,26 @@ export interface GateChildDeps {
75
75
  /** Hung-stream bound; the probe-based stall guard cannot supply it. */
76
76
  streamInactivityMs: number;
77
77
  /**
78
- * The resolved `['--thinking', level]` fragment for the `gate` reasoning
79
- * group, or `[]` to inherit the session default.
78
+ * The resolved argv fragment for the `gate` group — its model and its
79
+ * thinking level — or `[]` to inherit both.
80
80
  *
81
81
  * REQUIRED, like its two neighbours above: gate-child takes resolved config
82
82
  * values and gate-deps supplies them. Optional-with-a-default would let a new
83
- * gate wiring silently run at a level nobody chose, which is the failure the
83
+ * gate wiring silently run on a model nobody chose, which is the failure the
84
84
  * whole profile feature exists to end.
85
85
  */
86
- thinking: readonly string[];
86
+ groupArgs: readonly string[];
87
+ /**
88
+ * The context window of the model THESE children run on, for the churn rule.
89
+ *
90
+ * Not `status.parentContextWindow`, which is a per-RUN value and a run spans
91
+ * several groups. The direction matters: a window smaller than the child's
92
+ * real one makes churn fire early and kill a healthy child, so this follows
93
+ * the `gate` group's model and falls back to the host's.
94
+ *
95
+ * REQUIRED, like its neighbours: gate-child takes resolved config values.
96
+ */
97
+ contextWindow: number;
87
98
  /**
88
99
  * The live widget state this child feeds and its loader reads. SHARED with
89
100
  * the caller — the verify gate's own loader reads the same status while this
@@ -98,7 +98,7 @@ export function makeGateChild(deps) {
98
98
  commandTimeoutMs: deps.commandTimeoutMs,
99
99
  streamInactivityMs: deps.streamInactivityMs
100
100
  },
101
- thinking: deps.thinking,
101
+ groupArgs: deps.groupArgs,
102
102
  // A discarded attempt is otherwise invisible: the returned
103
103
  // exitCode/text describe the FINAL attempt, so a child that
104
104
  // burned two attempts reads exactly like one that ran clean.
@@ -125,7 +125,7 @@ export function makeGateChild(deps) {
125
125
  // stream — what `--mode json` emits — carries token counts but
126
126
  // no context window; `contextWindow` appears nowhere in
127
127
  // agent-session.d.ts.
128
- contextWindow: deps.status.parentContextWindow
128
+ contextWindow: deps.contextWindow
129
129
  });
130
130
  }
131
131
  finally {
@@ -48,7 +48,8 @@ import { assessRunnerGlobs, runnerGlobVerifyFindings } from './runner-globs.js';
48
48
  import { captureGitState, reconcileGitState } from './git-state-guard.js';
49
49
  import { runWorker } from '../workers/pi-worker-core.js';
50
50
  import { getConfig } from '../config/config.js';
51
- import { groupThinkingArgs } from '../config/reasoning-args.js';
51
+ import { groupChildArgs } from '../config/group-args.js';
52
+ import { contextWindowForGroup } from './context-usage.js';
52
53
  import { makeDebugAppender } from './debug-log.js';
53
54
  import { startAutoLoader } from './widget.js';
54
55
  import { ChildStatus } from './child-status.js';
@@ -602,7 +603,11 @@ export function buildGateDeps(params) {
602
603
  streamInactivityMs: getConfig().streamInactivityMs,
603
604
  // Read per gateChild() call, like its two neighbours, so a
604
605
  // /task-config change lands on the next gate without a restart.
605
- thinking: groupThinkingArgs('gate'),
606
+ groupArgs: groupChildArgs('gate'),
607
+ // `gateCtx` rather than the run's own window: a run spans several
608
+ // groups, and a window smaller than the child's real one makes the
609
+ // churn rule fire early and kill a healthy child.
610
+ contextWindow: contextWindowForGroup(gateCtx, 'gate'),
606
611
  status,
607
612
  runWorker,
608
613
  makeDebugAppender,
@@ -0,0 +1,26 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ /** `oneShot` mirrors the impl widget's split: fire-and-forget lets the settle
3
+ * event disarm; an awaited run spans resume/steer turns and disarms in its finally. */
4
+ export declare function armImplementationGuard(opts: {
5
+ oneShot: boolean;
6
+ }): void;
7
+ export declare function disarmImplementationGuard(): void;
8
+ /** @internal Test seam: is a turn currently guarded? */
9
+ export declare function implementationGuardArmed(): boolean;
10
+ /**
11
+ * Present tense, unlike `formatLoopHint`, which addresses a re-spawned child
12
+ * about an attempt that does not exist here.
13
+ *
14
+ * It claims nothing about the call's RESULT, which this hook fires too early to
15
+ * see, and it does not offer "change the call" — that is an escape, not advice:
16
+ * one altered byte is a new key and a clean slate on both counters.
17
+ */
18
+ export declare function blockedCallReason(toolName: string, count: number): string;
19
+ /** The reason on the final block, which also ends the turn. */
20
+ export declare function terminalCallReason(): string;
21
+ export declare function consumeGuardTermination(): boolean;
22
+ /**
23
+ * Inert until armed. Registering ANY `tool_call` handler switches on pi's
24
+ * `beforeToolCall` for every call in the session, so the armed check comes first.
25
+ */
26
+ export declare function registerImplementationGuards(pi: ExtensionAPI): void;
@@ -0,0 +1,177 @@
1
+ import { LoopDetector, loopKey, LOOP_THRESHOLD, LOOP_WINDOW, MAX_LOOP_RESTARTS } from './loop-detector.js';
2
+ /**
3
+ * Runaway guard for the IMPLEMENTATION TURN — the one model surface with none.
4
+ *
5
+ * MEASURED: one turn ran 5h16m and 6,760 tool calls, alternating two
6
+ * byte-identical bash commands 3,300 times each with its output frozen and zero
7
+ * edits. The command watchdog is per call (each took ~2.5s), the stream was never
8
+ * silent, and MAX_COMPACTION_RESUMES counts only compactions that PARK at idle,
9
+ * while all 18 of these were inside the turn. The two detectors are built only in
10
+ * the CHILD spawn paths, and this turn runs in the user's own session.
11
+ *
12
+ * IT BLOCKS RATHER THAN KILLS because there is no re-spawn here — the argument
13
+ * single-read-extension.ts already makes: "detect-and-kill only re-spawns a model
14
+ * that deterministically re-thrashes". pi's ctx.abort() would also empty the
15
+ * queued-message list into the user's editor. That also raises the bar on false
16
+ * positives: a `gate` child killed by mistake costs one attempt of three, this
17
+ * costs the user their turn.
18
+ *
19
+ * WHY PROGRESS IS READ OFF THE CALL, NOT THE RESULT. The gate profile pairs its
20
+ * LoopDetector with a StallDetector, which judges results. That cannot work here:
21
+ * pi's edit tool returns the constant `Successfully replaced N block(s) in <path>.`
22
+ * and puts the diff in `details`, not `content`, so every real edit to one file is
23
+ * byte-identical result text and scores as dead ground. An edit's ARGUMENTS carry
24
+ * the progress its result throws away, so an edit is what resets the window.
25
+ *
26
+ * Known blind spots, all deliberate: a bash-driven mutation (`sed -i`, `>`,
27
+ * `git apply`) does not reset; one varying `write` per iteration buys unlimited
28
+ * immunity; a repeat cycle of LOOP_WINDOW/LOOP_THRESHOLD or longer never fills the
29
+ * window; polling a booting server with an identical curl trips at five; and
30
+ * schema-invalid calls never reach this hook at all, since pi validates first.
31
+ */
32
+ /** Tool names that mutate the tree. pi ships exactly seven core tools, and
33
+ * pi-task's own four (pi-worker, -search, -fetch, -docs) are all read-only. */
34
+ const MUTATING_TOOLS = new Set(['edit', 'write']);
35
+ /**
36
+ * Path-revisit is OFF for both detectors (that is the Infinity). MEASURED: at the
37
+ * default threshold, six DISTINCT edits to one file trip the path rule, because
38
+ * an edit names a `file_path` and no `limit`, so the first one sets the
39
+ * high-water mark and every later one scores as already-covered ground. Six edits
40
+ * to one file is the most ordinary thing an implementation turn does. mx5
41
+ * TASK_0002 is the same lesson from the other side: the rule killed an enforce
42
+ * child that was editing one file as its job.
43
+ */
44
+ function freshDetector() {
45
+ return new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD, Number.POSITIVE_INFINITY);
46
+ }
47
+ /** The armed turn's state, or null outside one. One slot: one task runs at a time. */
48
+ let armed = null;
49
+ /** Built, never spread from the previous state: a leaked `terminating` would
50
+ * block every call for the rest of an awaited run. */
51
+ function freshArmedState(oneShot) {
52
+ return {
53
+ loop: freshDetector(),
54
+ edits: freshDetector(),
55
+ strikes: new Map(),
56
+ terminating: false,
57
+ oneShot
58
+ };
59
+ }
60
+ /** `oneShot` mirrors the impl widget's split: fire-and-forget lets the settle
61
+ * event disarm; an awaited run spans resume/steer turns and disarms in its finally. */
62
+ export function armImplementationGuard(opts) {
63
+ armed = freshArmedState(opts.oneShot);
64
+ }
65
+ export function disarmImplementationGuard() {
66
+ armed = null;
67
+ }
68
+ /** @internal Test seam: is a turn currently guarded? */
69
+ export function implementationGuardArmed() {
70
+ return armed !== null;
71
+ }
72
+ /**
73
+ * Present tense, unlike `formatLoopHint`, which addresses a re-spawned child
74
+ * about an attempt that does not exist here.
75
+ *
76
+ * It claims nothing about the call's RESULT, which this hook fires too early to
77
+ * see, and it does not offer "change the call" — that is an escape, not advice:
78
+ * one altered byte is a new key and a clean slate on both counters.
79
+ */
80
+ export function blockedCallReason(toolName, count) {
81
+ return (`Blocked: this is the ${count}th identical ${toolName} call in this turn. `
82
+ + `Use what you already have, or do something different, then continue the task.`);
83
+ }
84
+ /** The reason on the final block, which also ends the turn. */
85
+ export function terminalCallReason() {
86
+ return (`Blocked: this turn repeated one call past every warning, so it is being stopped `
87
+ + `here. Nothing further will run.`);
88
+ }
89
+ /**
90
+ * One-shot: the guard ended a turn, and nothing in the session state says so.
91
+ *
92
+ * `terminate` lets the agent loop finish normally — the last assistant message
93
+ * keeps `stopReason: "toolUse"`, so `classifyTurnEnd` reads `'stop'` and the run
94
+ * reports a clean finish over work that was cut off mid-task. Verified against a
95
+ * live model: a real guard-terminated turn ends exactly that way. Same shape as
96
+ * `consumeWatchdogAbort`, and consumed for the same reason — one reader, then it
97
+ * is gone.
98
+ */
99
+ let terminatedTurn = false;
100
+ export function consumeGuardTermination() {
101
+ const hit = terminatedTurn;
102
+ terminatedTurn = false;
103
+ return hit;
104
+ }
105
+ /**
106
+ * Inert until armed. Registering ANY `tool_call` handler switches on pi's
107
+ * `beforeToolCall` for every call in the session, so the armed check comes first.
108
+ */
109
+ export function registerImplementationGuards(pi) {
110
+ pi.on('tool_call', event => {
111
+ const state = armed;
112
+ if (!state)
113
+ return;
114
+ try {
115
+ // Every call, whatever it is: pi terminates only when EVERY finalized
116
+ // result in the batch carries the flag (agent-loop.js
117
+ // shouldTerminateToolBatch). The batch that trips it has already
118
+ // finalized its earlier calls without it and so survives; the next one
119
+ // ends. One batch, and it is the only bound this path has.
120
+ if (state.terminating) {
121
+ return { block: true, terminate: true, reason: terminalCallReason() };
122
+ }
123
+ const call = { name: event.toolName, args: event.input };
124
+ const mutating = MUTATING_TOOLS.has(event.toolName);
125
+ const hit = mutating ? state.edits.record(call) : state.loop.record(call);
126
+ if (!hit) {
127
+ if (mutating) {
128
+ state.loop = freshDetector();
129
+ // Strikes go with the window. Keeping them made a LATER episode
130
+ // terminate on its first hit, skipping both warnings, because an
131
+ // earlier one had part-spent the budget. MEASURED over 494 real
132
+ // turns: this loses no catch — the incident still ends at call
133
+ // 173 of 6,760, both live-model loops still end — and drops one
134
+ // termination of a turn that was editing between episodes.
135
+ // A determined model is still bounded: three hits inside ONE
136
+ // episode terminate, which is the runaway shape (it makes no
137
+ // edits at all).
138
+ state.strikes.clear();
139
+ }
140
+ return;
141
+ }
142
+ const key = loopKey(call);
143
+ const strikes = (state.strikes.get(key) ?? 0) + 1;
144
+ state.strikes.set(key, strikes);
145
+ // Blocking alone does not stop a determined model: nothing prevents the
146
+ // next identical call.
147
+ if (strikes > MAX_LOOP_RESTARTS) {
148
+ state.terminating = true;
149
+ terminatedTurn = true;
150
+ return { block: true, terminate: true, reason: terminalCallReason() };
151
+ }
152
+ return { block: true, reason: blockedCallReason(event.toolName, hit.count) };
153
+ }
154
+ catch {
155
+ // pi does not guard this hook, and a throw here would block a
156
+ // legitimate call. A broken guard must cost nothing.
157
+ return;
158
+ }
159
+ });
160
+ // NOT `agent_end`, which fires again for every auto-retry, every threshold
161
+ // compaction and every queued message — pi drives those with `agent.continue()`,
162
+ // each a fresh agent loop. The measured runaway compacted 18 times INSIDE its
163
+ // turn, so a one-shot disarm on agent_end would have retired the guard after the
164
+ // first ~375 of its 6,760 calls. `agent_settled` is the boundary that means what
165
+ // this needs: no retry, compaction or queued continuation left to run.
166
+ pi.on('agent_settled', () => {
167
+ if (!armed)
168
+ return;
169
+ if (armed.oneShot)
170
+ disarmImplementationGuard();
171
+ // An awaited run spans resume and steer turns. Counters are per TURN, so a
172
+ // fresh one starts clean rather than inheriting the last one's strikes.
173
+ else
174
+ armed = freshArmedState(false);
175
+ });
176
+ pi.on('session_shutdown', disarmImplementationGuard);
177
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Hold the host session on the `implementation` group's model AND thinking level
3
+ * for one implementation turn, then put both back.
4
+ *
5
+ * WHY THIS GROUP IS NOT LIKE THE OTHERS
6
+ * -------------------------------------
7
+ * Every other group runs in a child process, so its settings are two argv flags
8
+ * (`groupChildArgs` in config/group-args.ts) that die with the child. The
9
+ * implementation turn runs in the USER'S OWN session (orchestrator.ts `sendSpec`
10
+ * -> `sendUserMessage` -> `superviseImplementation`), so the only levers are
11
+ * `pi.setThinkingLevel` and `pi.setModel`, and both are session-global.
12
+ *
13
+ * WHAT pi DOES that this has to survive:
14
+ *
15
+ * 1. BOTH PERSIST. `setThinkingLevel` writes `defaultThinkingLevel` and
16
+ * `setModel` writes `defaultProvider`/`defaultModel`, into pi's global
17
+ * `~/.pi/agent/settings.json`. Without the restore, running one task would
18
+ * silently rewrite the user's global defaults — and since children carry no
19
+ * `-m` and resolve exactly those defaults, it would re-point every future
20
+ * child in every project. That makes `release()` load-bearing, not tidy-up.
21
+ * 2. THEY CLAMP. A model with no reasoning support offers only `off`, so asking
22
+ * for `medium` yields `off`. The restore writes back what was READ after
23
+ * setting, never what was asked for, or a clamp would ratchet the stored
24
+ * default further every run.
25
+ * 3. `setModel` RE-CLAMPS THINKING as part of switching. So the level must be
26
+ * read before any model move, and written after the model is back.
27
+ * 4. THE USER CAN CHANGE EITHER MID-TURN — `shift+tab` cycles thinking. We
28
+ * detect it by comparing the live value at release against what we applied:
29
+ * if it has moved, somebody else moved it, and we leave it alone.
30
+ *
31
+ * We compare rather than subscribe because the extension API's `on(...)` returns
32
+ * `void` — there is no unsubscribe handle — so a per-turn listener could only
33
+ * ever be added, never removed.
34
+ *
35
+ * WHAT THIS COSTS, so nobody has to rediscover it
36
+ * -----------------------------------------------
37
+ * A model switch re-bills the whole prompt. pi counts that deliberately —
38
+ * `core/cache-stats.js` says "Model switches are NOT exempt: they re-bill the
39
+ * full prompt and should be counted" — and prints `Cache miss after model
40
+ * switch: N tokens re-billed` once the miss clears 20k tokens or $0.10, which an
41
+ * implementation prompt does. It happens twice: once acquiring, once releasing.
42
+ * On a local server that is a full prompt reprocess, not a bill. This is why the
43
+ * cell ships `inherit`, and why the target-equals-current degrade below is the
44
+ * main guard rather than an optimisation.
45
+ */
46
+ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
47
+ import { type GroupSetting } from '../config/reasoning.js';
48
+ import { type HoldStash } from './model-hold-stash.js';
49
+ /**
50
+ * The slice of the extension API this needs, named so tests can drive the
51
+ * hold-and-restore with a fake object instead of a live pi session.
52
+ */
53
+ export interface ThinkingControl {
54
+ get(): ThinkingLevel;
55
+ set(level: ThinkingLevel): void;
56
+ }
57
+ /**
58
+ * The model half, generic in the HANDLE so tests can use a literal.
59
+ *
60
+ * `current()` returns the spec AND the handle: the comparison is on a plain
61
+ * string, and the restore uses the handle captured at acquire rather than
62
+ * re-resolving against a registry that may have moved underneath us.
63
+ *
64
+ * `apply` may return `false` OR REJECT. pi's `setModel` returns false only when
65
+ * `hasConfiguredAuth` — a cached snapshot Set — is false; it then calls the
66
+ * session's own `setModel`, which awaits a live `checkAuth` and throws when the
67
+ * two disagree, as they do for an expired OAuth token. Callers here treat a
68
+ * throw and a `false` identically, because nothing useful differs between them.
69
+ */
70
+ export interface ModelControl<H = unknown> {
71
+ current(): {
72
+ spec: string;
73
+ handle: H;
74
+ } | undefined;
75
+ resolve(spec: string): H | undefined;
76
+ apply(handle: H): Promise<boolean>;
77
+ }
78
+ export interface ImplementationControls<H = unknown> {
79
+ thinking: ThinkingControl;
80
+ model: ModelControl<H>;
81
+ }
82
+ /**
83
+ * The whole hold: model, then thinking. Returns the release, which is async and
84
+ * idempotent. Always call it from a `finally`, never the happy path.
85
+ *
86
+ * ONE function rather than two composable holds, because two independent holds
87
+ * acquired in the wrong order fail SILENTLY — `setModel` re-clamps thinking, so
88
+ * a thinking hold taken first is erased and a thinking restore taken last is
89
+ * clamped by the wrong model's ladder. A composition that can only be assembled
90
+ * one way belongs in one function.
91
+ */
92
+ export declare function holdImplementation<H>(controls: ImplementationControls<H>, setting?: GroupSetting, spec?: string, stash?: HoldStash): Promise<() => Promise<void>>;
93
+ /**
94
+ * Put back a model a crashed session left applied. Runs at `session_start`.
95
+ *
96
+ * THE GUARDS are the whole design, because this runs in a session that knows
97
+ * nothing about the one that crashed. Four cases, and only the last writes:
98
+ *
99
+ * 1. pi's saved default is still the note's `before` — the file is already
100
+ * right. Either a live hold has written its note but not yet switched, or a
101
+ * crash landed in that same gap. Decline, and KEEP the note: clearing here
102
+ * is what would let an unrelated session start delete a live hold's only
103
+ * crash record, in the millisecond before it applies.
104
+ * 2. the saved default is neither value — somebody moved on. Clear, decline.
105
+ * 3. the saved default matches, but THIS session is on a different model — it
106
+ * was launched with an explicit `--model`, or resumed onto one. Restoring
107
+ * would silently override a choice made on the command line. Decline, and
108
+ * keep the note so a later ordinary start still repairs the file.
109
+ * 4. everything agrees. Restore, and clear.
110
+ *
111
+ * The note is also cleared on a failed restore: one that cannot happen must not
112
+ * re-fire on every subsequent startup.
113
+ *
114
+ * Thinking is deliberately NOT restored here. `setModel` re-clamps it to the
115
+ * model we are restoring TO, which is the level that model was running at
116
+ * before the crashed session touched anything.
117
+ */
118
+ export declare function restoreHeldModel<H>(model: ModelControl<H>, savedDefaultSpec: () => string | undefined, stash?: HoldStash): Promise<'restored' | 'declined' | 'nothing'>;
@@ -0,0 +1,165 @@
1
+ import { getConfig } from '../config/config.js';
2
+ import { MODEL_INHERIT } from '../config/group-models.js';
3
+ import { resolveReasoning } from '../config/reasoning.js';
4
+ import { readHoldStash, writeHoldStash, clearHoldStash } from './model-hold-stash.js';
5
+ /**
6
+ * `before` is the level read BEFORE any model move; `applied` is what is really
7
+ * in force after both moves.
8
+ *
9
+ * `inherit` writes nothing but still RECORDS, because the model switch may have
10
+ * moved the level on its own. `applied === before` means nothing moved, and then
11
+ * there is nothing to restore — a write there would be a settings.json write for
12
+ * no reason.
13
+ */
14
+ function acquireThinking(control, setting, before) {
15
+ if (setting !== 'inherit')
16
+ control.set(setting);
17
+ // Post-clamp, so a model that cannot do `medium` does not leave us believing
18
+ // it is at `medium` and treating the user's later change as our own.
19
+ const applied = control.get();
20
+ return applied === before ? undefined : { before, applied };
21
+ }
22
+ const userMovedThinking = (control, hold) => control.get() !== hold.applied;
23
+ /**
24
+ * The whole hold: model, then thinking. Returns the release, which is async and
25
+ * idempotent. Always call it from a `finally`, never the happy path.
26
+ *
27
+ * ONE function rather than two composable holds, because two independent holds
28
+ * acquired in the wrong order fail SILENTLY — `setModel` re-clamps thinking, so
29
+ * a thinking hold taken first is erased and a thinking restore taken last is
30
+ * clamped by the wrong model's ladder. A composition that can only be assembled
31
+ * one way belongs in one function.
32
+ */
33
+ export async function holdImplementation(controls, setting = resolveReasoning('implementation', getConfig()), spec = getConfig().groupModels.implementation, stash = { read: readHoldStash, write: writeHoldStash, clear: clearHoldStash }) {
34
+ const { thinking, model } = controls;
35
+ // BEFORE any model move: `setModel` re-clamps, so this is the only moment
36
+ // the pre-hold level is readable.
37
+ const beforeThinking = thinking.get();
38
+ const modelHold = await acquireModel(model, spec, stash);
39
+ // A model move that was ASKED FOR and failed no-ops the whole hold. Running
40
+ // the implementation turn on the wrong model at the right level is worse
41
+ // than running it exactly as it ran last week.
42
+ if (modelHold === 'failed')
43
+ return async () => { };
44
+ // A MODEL move alone moves the level, even with the thinking cell on
45
+ // `inherit`: pi's `setModel` re-clamps to the target's ladder and PERSISTS
46
+ // the result. So the thinking hold is taken whenever either half moved
47
+ // something, not only when a level was asked for — otherwise a session at
48
+ // `high` switched onto an off/medium model is left globally at `medium`
49
+ // with nothing to put it back, which is the one thing release() exists for.
50
+ const thinkingHold = setting === 'inherit' && modelHold === undefined ?
51
+ undefined
52
+ : acquireThinking(thinking, setting, beforeThinking);
53
+ let released = false;
54
+ return async () => {
55
+ if (released)
56
+ return;
57
+ released = true;
58
+ // Read the thinking comparison BEFORE restoring the model. A read taken
59
+ // after it is post-clamp, and the mid-turn-change detector then answers
60
+ // wrongly in both directions.
61
+ const moved = thinkingHold !== undefined && userMovedThinking(thinking, thinkingHold);
62
+ if (modelHold !== undefined) {
63
+ try {
64
+ await model.apply(modelHold.before);
65
+ }
66
+ catch {
67
+ // A failed model restore is still followed by the thinking
68
+ // restore. Restoring what we can beats restoring nothing.
69
+ }
70
+ stash.clear();
71
+ }
72
+ // LAST. Writing `before` while still on the target model has pi clamp it
73
+ // to the TARGET's ladder, and the model restore then re-clamps from that
74
+ // already-wrong value.
75
+ if (thinkingHold !== undefined && !moved)
76
+ thinking.set(thinkingHold.before);
77
+ };
78
+ }
79
+ /**
80
+ * `undefined` = no move was needed or possible, and today's behaviour stands.
81
+ * `'failed'` = a move was asked for and did not happen, which voids the hold.
82
+ */
83
+ async function acquireModel(model, spec, stash) {
84
+ if (spec === MODEL_INHERIT)
85
+ return undefined;
86
+ const cur = model.current();
87
+ if (!cur)
88
+ return undefined;
89
+ // Not an optimisation. `setDefaultModelAndProvider` runs unconditionally
90
+ // inside pi's `setModel`, so a redundant call rewrites the user's global
91
+ // default, appends a model change to their session and re-bills the whole
92
+ // prompt as a cache miss — all to arrive where we already were.
93
+ if (cur.spec === spec)
94
+ return undefined;
95
+ const handle = model.resolve(spec);
96
+ // Model gone, or its provider unauthed. The session hint names it; the turn
97
+ // runs where it already was.
98
+ if (handle === undefined)
99
+ return undefined;
100
+ // Written BEFORE the apply, so a crash between the two costs an unnecessary
101
+ // restore attempt rather than a missed one.
102
+ stash.write({ before: cur.spec, applied: spec });
103
+ try {
104
+ if (await model.apply(handle))
105
+ return { before: cur.handle, beforeSpec: cur.spec, appliedSpec: spec };
106
+ }
107
+ catch {
108
+ // Identical to `false`: see ModelControl.apply.
109
+ }
110
+ stash.clear();
111
+ return 'failed';
112
+ }
113
+ /**
114
+ * Put back a model a crashed session left applied. Runs at `session_start`.
115
+ *
116
+ * THE GUARDS are the whole design, because this runs in a session that knows
117
+ * nothing about the one that crashed. Four cases, and only the last writes:
118
+ *
119
+ * 1. pi's saved default is still the note's `before` — the file is already
120
+ * right. Either a live hold has written its note but not yet switched, or a
121
+ * crash landed in that same gap. Decline, and KEEP the note: clearing here
122
+ * is what would let an unrelated session start delete a live hold's only
123
+ * crash record, in the millisecond before it applies.
124
+ * 2. the saved default is neither value — somebody moved on. Clear, decline.
125
+ * 3. the saved default matches, but THIS session is on a different model — it
126
+ * was launched with an explicit `--model`, or resumed onto one. Restoring
127
+ * would silently override a choice made on the command line. Decline, and
128
+ * keep the note so a later ordinary start still repairs the file.
129
+ * 4. everything agrees. Restore, and clear.
130
+ *
131
+ * The note is also cleared on a failed restore: one that cannot happen must not
132
+ * re-fire on every subsequent startup.
133
+ *
134
+ * Thinking is deliberately NOT restored here. `setModel` re-clamps it to the
135
+ * model we are restoring TO, which is the level that model was running at
136
+ * before the crashed session touched anything.
137
+ */
138
+ export async function restoreHeldModel(model, savedDefaultSpec, stash = { read: readHoldStash, write: writeHoldStash, clear: clearHoldStash }) {
139
+ const note = stash.read();
140
+ if (!note)
141
+ return 'nothing';
142
+ const saved = savedDefaultSpec();
143
+ if (saved === note.before)
144
+ return 'declined';
145
+ if (saved !== note.applied) {
146
+ stash.clear();
147
+ return 'declined';
148
+ }
149
+ if (model.current()?.spec !== note.applied)
150
+ return 'declined';
151
+ const handle = model.resolve(note.before);
152
+ if (handle === undefined) {
153
+ stash.clear();
154
+ return 'declined';
155
+ }
156
+ try {
157
+ return (await model.apply(handle)) ? 'restored' : 'declined';
158
+ }
159
+ catch {
160
+ return 'declined';
161
+ }
162
+ finally {
163
+ stash.clear();
164
+ }
165
+ }
@@ -96,6 +96,8 @@ export interface SteerWatchdogDeps {
96
96
  export interface ImplementationTurnDeps {
97
97
  /** The live session entries — the only thing the classifier reads. */
98
98
  entries: () => ReadonlyArray<SessionEntryLike>;
99
+ /** Test seam over the module-level one-shot; the real reader is the default. */
100
+ consumeGuardTermination?: () => boolean;
99
101
  /** Queue a follow-up user turn on the (idle) session. */
100
102
  send: (text: string) => Promise<void>;
101
103
  /** Wait for the session to go idle again. */
@@ -139,6 +141,9 @@ export declare const CONTINUE_AFTER_COMPACTION: string;
139
141
  * lets the verify gate and `/task-auto-resume` catch any leftover incompleteness.
140
142
  */
141
143
  export declare const MAX_COMPACTION_RESUMES = 20;
144
+ /** How a guard-stopped turn is reported. Named so a caller can tell it from a
145
+ * provider error: the fix is a different task, not a retry of this one. */
146
+ export declare const GUARD_TERMINATED = "the runaway guard stopped this turn: one tool call was repeated past every warning";
142
147
  /**
143
148
  * Resume an implementation turn that went idle at a threshold-compaction boundary.
144
149
  * The runtime compacts and parks at idle without auto-continuing; we send a
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { SessionUI } from '../remote/bridge.js';
19
19
  import { consumeWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
20
+ import { consumeGuardTermination } from './implementation-guards.js';
20
21
  const isAssistant = (e) => e.message !== undefined && e.message.role === 'assistant';
21
22
  /** Index of the last assistant message and of the last compaction boundary. */
22
23
  function tailPositions(entries) {
@@ -160,6 +161,9 @@ export const CONTINUE_AFTER_COMPACTION = 'Your context was automatically compact
160
161
  * lets the verify gate and `/task-auto-resume` catch any leftover incompleteness.
161
162
  */
162
163
  export const MAX_COMPACTION_RESUMES = 20;
164
+ /** How a guard-stopped turn is reported. Named so a caller can tell it from a
165
+ * provider error: the fix is a different task, not a retry of this one. */
166
+ export const GUARD_TERMINATED = 'the runaway guard stopped this turn: one tool call was repeated past every warning';
163
167
  /**
164
168
  * Resume an implementation turn that went idle at a threshold-compaction boundary.
165
169
  * The runtime compacts and parks at idle without auto-continuing; we send a
@@ -260,6 +264,13 @@ export async function superviseWith(deps) {
260
264
  const interrupted = await steerUntilDone(deps);
261
265
  // A user-declined steer (interrupted) is its own paused path; otherwise
262
266
  // inspect how the turn actually ended.
263
- const error = interrupted ? undefined : turnErrorMessage(deps.entries());
267
+ // The runaway guard ends a turn WITHOUT an error stopReason, so classifyTurnEnd
268
+ // reads `'stop'` and the caller would verify a half-done implementation and
269
+ // re-deliver to a model that deterministically re-thrashes. Consumed here
270
+ // because this is the one place that reports how the turn really ended.
271
+ const guardEnded = deps.consumeGuardTermination?.() ?? consumeGuardTermination();
272
+ const error = interrupted ? undefined
273
+ : guardEnded ? GUARD_TERMINATED
274
+ : turnErrorMessage(deps.entries());
264
275
  return { interrupted, error, resumes };
265
276
  }