@mjasnikovs/pi-task 0.39.0 → 0.39.2

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 (50) 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/register.d.ts +0 -9
  5. package/dist/config/register.js +17 -62
  6. package/dist/shared/child-process.d.ts +34 -32
  7. package/dist/shared/child-process.js +44 -58
  8. package/dist/shared/command-watchdog.d.ts +12 -4
  9. package/dist/shared/command-watchdog.js +6 -7
  10. package/dist/shared/connection-error.d.ts +7 -0
  11. package/dist/shared/connection-error.js +65 -0
  12. package/dist/shared/model-endpoint.d.ts +12 -24
  13. package/dist/shared/model-endpoint.js +32 -82
  14. package/dist/shared/model-resolve.d.ts +105 -0
  15. package/dist/shared/model-resolve.js +97 -0
  16. package/dist/shared/reasoning-capability.d.ts +20 -0
  17. package/dist/shared/reasoning-capability.js +32 -1
  18. package/dist/shared/stall-probe.d.ts +51 -0
  19. package/dist/shared/stall-probe.js +79 -0
  20. package/dist/task/child-runner.d.ts +76 -278
  21. package/dist/task/child-runner.js +186 -722
  22. package/dist/task/context-usage.js +2 -7
  23. package/dist/task/failure-classifier.js +53 -81
  24. package/dist/task/gate-child.js +1 -1
  25. package/dist/task/impl-widget.d.ts +2 -0
  26. package/dist/task/impl-widget.js +4 -0
  27. package/dist/task/implementation-hold.d.ts +11 -0
  28. package/dist/task/implementation-hold.js +20 -0
  29. package/dist/task/implementation-scope.d.ts +24 -0
  30. package/dist/task/implementation-scope.js +34 -0
  31. package/dist/task/loop-detector.d.ts +13 -5
  32. package/dist/task/loop-detector.js +11 -5
  33. package/dist/task/model-hold-stash.js +4 -14
  34. package/dist/task/orchestrator.d.ts +1 -8
  35. package/dist/task/orchestrator.js +11 -34
  36. package/dist/task/phases.js +2 -2
  37. package/dist/task/stall-detector.d.ts +1 -1
  38. package/dist/task/stall-detector.js +1 -1
  39. package/dist/workers/model-warning.d.ts +4 -16
  40. package/dist/workers/model-warning.js +14 -70
  41. package/dist/workers/pi-worker-core.d.ts +65 -20
  42. package/dist/workers/pi-worker-core.js +109 -50
  43. package/dist/workers/reasoning-warning.js +2 -24
  44. package/dist/workers/worker-failure.d.ts +2 -0
  45. package/dist/workers/worker-failure.js +2 -1
  46. package/dist/workers/worker-kill.d.ts +30 -11
  47. package/dist/workers/worker-kill.js +68 -20
  48. package/dist/workers/worker-profiles.d.ts +20 -0
  49. package/dist/workers/worker-profiles.js +22 -9
  50. package/package.json +1 -1
@@ -130,6 +130,15 @@ export interface CommandKill {
130
130
  * so the fresh child knows which call it must not repeat unbounded. */
131
131
  detail?: string;
132
132
  }
133
+ /**
134
+ * The kill, as the REASON the watchdog aborts its signal with. runChild reads
135
+ * it off the combined signal, so a command kill reaches `ChildResult.kill` by
136
+ * the same road every other guard's kill does — not through a side query the
137
+ * caller has to remember to make.
138
+ */
139
+ export interface CommandKillReason extends CommandKill {
140
+ by: 'command-timeout';
141
+ }
133
142
  /**
134
143
  * The tool-call fields the child-side watchdog reads. Structural rather than
135
144
  * `ToolCall` from child-process.ts, so this module keeps its zero imports and a
@@ -142,9 +151,9 @@ export interface WatchedToolCall {
142
151
  }
143
152
  /**
144
153
  * 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.
154
+ * machine whose `onFire` aborts `signal` WITH the kill as its reason, which
155
+ * runChild turns into a process-GROUP kill — reaping the hung command itself,
156
+ * not just the pi child holding it — and reports as `kill.by === 'command-timeout'`.
148
157
  *
149
158
  * LIMIT: the group kill only reaches processes still IN the group. A hung command
150
159
  * that detached a daemon (setsid, nohup, a background dev server) leaves it
@@ -158,7 +167,6 @@ export interface WatchedToolCall {
158
167
  export declare function commandWatch(timeoutMs: number): {
159
168
  onStart: (call: WatchedToolCall) => void;
160
169
  onEnd: (toolCallId: string | undefined) => void;
161
- killed: () => CommandKill | undefined;
162
170
  signal: AbortSignal;
163
171
  clear: () => void;
164
172
  } | null;
@@ -174,9 +174,9 @@ export const realTimerDeps = {
174
174
  };
175
175
  /**
176
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.
177
+ * machine whose `onFire` aborts `signal` WITH the kill as its reason, which
178
+ * runChild turns into a process-GROUP kill — reaping the hung command itself,
179
+ * not just the pi child holding it — and reports as `kill.by === 'command-timeout'`.
180
180
  *
181
181
  * LIMIT: the group kill only reaches processes still IN the group. A hung command
182
182
  * that detached a daemon (setsid, nohup, a background dev server) leaves it
@@ -196,17 +196,17 @@ export function commandWatch(timeoutMs) {
196
196
  // child are sequential, so a single slot is still correctly paired.
197
197
  const key = (id) => id ?? 'anon';
198
198
  const details = new Map();
199
- let killed;
200
199
  const watchdog = new CommandWatchdog({
201
200
  getTimeoutMs: () => timeoutMs,
202
201
  ...realTimerDeps,
203
202
  onFire: (toolCallId, toolName, ms) => {
204
- killed = {
203
+ const reason = {
204
+ by: 'command-timeout',
205
205
  toolName,
206
206
  timeoutMs: ms,
207
207
  ...(details.has(toolCallId) ? { detail: details.get(toolCallId) } : {})
208
208
  };
209
- ctrl.abort();
209
+ ctrl.abort(reason);
210
210
  }
211
211
  });
212
212
  return {
@@ -226,7 +226,6 @@ export function commandWatch(timeoutMs) {
226
226
  details.delete(key(id));
227
227
  watchdog.onEnd(key(id));
228
228
  },
229
- killed: () => killed,
230
229
  signal: ctrl.signal,
231
230
  clear: () => watchdog.clearAll()
232
231
  };
@@ -0,0 +1,7 @@
1
+ export declare function isConnectionError(cause: string): boolean;
2
+ /**
3
+ * Exponential backoff before a connection-error retry: 500ms, 1s, 2s — three
4
+ * requests over 3.5s, which is not a storm even against a throttle. pi's own
5
+ * ladder is three at 2s/4s/8s.
6
+ */
7
+ export declare function connectionRetryBackoffMs(attempt: number): number;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Which model errors are worth another attempt.
3
+ *
4
+ * A connection-class model error is transient: a single dropped fetch to a live
5
+ * endpoint, not a repeatable mistake. On a local single-slot server (e.g.
6
+ * llama-server with `--parallel 1`) pi-task's own concurrent fan-out can briefly
7
+ * saturate the slot, and one request fails to connect even though the model is
8
+ * up and the next request succeeds. pi already retries internally, but those
9
+ * retries don't always absorb it on a saturated local server — and a fail-fast
10
+ * then kills the whole task for a single blip.
11
+ *
12
+ * A NON-connection model error (bad request, context-length overflow, auth,
13
+ * provider 5xx that names a real fault) still fails fast: re-spawning against
14
+ * the same request won't fix it, so burning the budget only delays the report.
15
+ *
16
+ * SCOPE, and it is deliberate: connection classes only. pi's own
17
+ * `isRetryableAssistantError` (@earendil-works/pi-ai, `dist/utils/retry.js`) also
18
+ * retries the provider-LOAD family — `429`, `5xx`, `rate limit`, `overloaded` —
19
+ * which `does NOT match real, non-transient faults` in child-runner.test.ts
20
+ * explicitly rejects. That disagreement is real and OPEN; it is not settled here,
21
+ * because this backoff starts at 500ms and a 429 answered that fast is a retry
22
+ * storm, not a recovery.
23
+ *
24
+ * MEASURED against pi before widening: the transport entries here — a bare
25
+ * `timed out`, `getaddrinfo ENOTFOUND`, `upstream connect`, `reset before
26
+ * headers`, a truncated Anthropic stream and a closed websocket — were all
27
+ * MISSES. Every one is a REMOTE-provider failure, which is why a local llama.cpp
28
+ * setup never surfaced the gap. The errno spellings are pi-task's own: a child
29
+ * reports them through stderr, and pi never sees them.
30
+ *
31
+ * pi's bare `timeout` is deliberately NOT reproduced. It matched a provider 400
32
+ * that merely echoed a `timeout` field back, turning a fail-fast into a full
33
+ * retry budget, and it caught nothing the `timed out` spellings above miss.
34
+ */
35
+ const CONNECTION_ERROR_RE = /\b(?:connection error|connection (?:lost|closed|reset|refused|aborted)|econnreset|econnrefused|econnaborted|epipe|etimedout|enetunreach|enetdown|eai_again|socket hang up|socket connection was closed|fetch failed|network (?:error|timeout)|premature close|terminated|unreachable|getaddrinfo|enotfound|upstream.?connect|reset before headers|timed? out|ended without|stream ended before message_stop|websocket.?(?:closed|error))\b/i;
36
+ /**
37
+ * Provider LOAD, which is transient in a different way: the server is up and
38
+ * saying "not now". pi retries all of these; 53f0488 did not, but its own message
39
+ * names only "context overflow, bad request, auth" as the fail-fast set — a
40
+ * throttle was never argued for, it just rode along in a list written for a LOCAL
41
+ * server, where none of these can occur.
42
+ *
43
+ * Words carry no trailing \b (`overloaded_error` joins on `_`, which is a word
44
+ * character); the bare status codes carry one, or `500` matches inside `15000`.
45
+ */
46
+ const PROVIDER_LOAD_RE = /(?:overloaded|rate.?limit|too many requests|service.?unavailable|server.?error|internal.?error|provider.?returned.?error)|\b(?:429|500|502|503|504|524)\b/i;
47
+ /**
48
+ * Account facts, not liveness: a budget does not refill on a retry. Checked FIRST,
49
+ * because these arrive worded as a throttle — `429 GoUsageLimitError` is a
50
+ * subscription limit, not a queue.
51
+ */
52
+ const NON_RETRYABLE_RE = /\b(?:insufficient_quota|quota exceeded|out of budget|billing|usage limit reached|available balance|GoUsageLimitError|FreeUsageLimitError)\b/i;
53
+ export function isConnectionError(cause) {
54
+ if (NON_RETRYABLE_RE.test(cause))
55
+ return false;
56
+ return CONNECTION_ERROR_RE.test(cause) || PROVIDER_LOAD_RE.test(cause);
57
+ }
58
+ /**
59
+ * Exponential backoff before a connection-error retry: 500ms, 1s, 2s — three
60
+ * requests over 3.5s, which is not a storm even against a throttle. pi's own
61
+ * ladder is three at 2s/4s/8s.
62
+ */
63
+ export function connectionRetryBackoffMs(attempt) {
64
+ return 500 * 2 ** attempt;
65
+ }
@@ -1,5 +1,3 @@
1
- /** Base URLs of every custom provider pi is configured with (possibly none). */
2
- export declare function discoverModelEndpoints(agentDir?: string): string[];
3
1
  /**
4
2
  * The provider/id a child pi process will actually resolve.
5
3
  *
@@ -11,24 +9,12 @@ export interface ModelRef {
11
9
  provider: string;
12
10
  id: string;
13
11
  }
14
- export declare function defaultModelRef(agentDir?: string): ModelRef | undefined;
15
12
  /**
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.
13
+ * Still read from settings.json rather than the session snapshot: pi persists a
14
+ * `/model` switch there, and a child with no `--model` resolves it fresh, so the
15
+ * file is the one source that is right after a mid-session switch.
30
16
  */
31
- export declare function modelBaseUrl(ref: ModelRef, agentDir?: string): string | undefined;
17
+ export declare function defaultModelRef(agentDir?: string): ModelRef | undefined;
32
18
  /**
33
19
  * What to probe on behalf of one child — the endpoint that child's own model
34
20
  * uses, not every endpoint on the machine.
@@ -45,12 +31,14 @@ export declare function modelBaseUrl(ref: ModelRef, agentDir?: string): string |
45
31
  * child whose own backend is healthy, and it can leave the guard disarmed for
46
32
  * one whose backend is dead.
47
33
  *
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.
34
+ * Every escape is toward never killing: an unknown model, a model with no URL,
35
+ * an unreadable default and a session that has not started all answer `[]`,
36
+ * which `probeModelEndpoints` reads as reachable. Probing some OTHER model's
37
+ * url instead would import a false positive, which is the one thing a blind
38
+ * guard never does.
39
+ *
40
+ * `agentDir` locates settings.json for the unpinned case only. The URL always
41
+ * comes from this session's registry snapshot, never from a file under it.
54
42
  */
55
43
  export declare function childModelEndpoints(spec?: string, agentDir?: string): string[];
56
44
  /**
@@ -1,6 +1,6 @@
1
1
  /**
2
- * model-endpoint — discovery + reachability probe for the model backend(s) a
3
- * child pi process talks to.
2
+ * model-endpoint — which backend a child pi process talks to, and whether it
3
+ * still answers.
4
4
  *
5
5
  * The failure this serves: the model server dies mid-child and the child hangs
6
6
  * mute. pi's own connection-error handling cannot help, because it runs from a
@@ -10,43 +10,26 @@
10
10
  * while it runs, so only "no output AND the endpoint does not answer" counts as
11
11
  * a dead backend.
12
12
  *
13
- * Discovery is generic no provider or server name is hardcoded anywhere here.
14
- * No discoverable endpoint means nothing to probe, and the guard then NEVER
15
- * kills: a child on a backend we cannot see gets the benefit of the doubt.
16
- *
17
- * `childModelEndpoints` is what the guards call; `discoverModelEndpoints` is its
18
- * last resort. The difference is the point. Discovery answers "every endpoint on
19
- * this machine", and `probeModelEndpoints` ORs over it, so one healthy provider
20
- * says "reachable" for a child stranded on a dead one. Resolving the child's OWN
21
- * model first turns that OR into a single exact question.
13
+ * WHERE THE URL COMES FROM. The registry, snapshotted at session_start by
14
+ * workers/model-warning.ts into config/group-args.ts the guards run where no
15
+ * `ctx` exists. It used to be re-parsed out of pi's `models.json` and
16
+ * `models-store.json`, which answer `undefined` for every one of pi-ai's 39
17
+ * built-in providers, so the guard was blind exactly where the registry knew
18
+ * the URL. It is now ARMED for those. The other direction is unchanged: no
19
+ * snapshot yet, or a model with no URL, means nothing to probe, and the guard
20
+ * then NEVER kills a child on a backend we cannot see gets the benefit of the
21
+ * doubt.
22
22
  */
23
23
  import * as fs from 'node:fs';
24
24
  import * as os from 'node:os';
25
25
  import * as path from 'node:path';
26
- /** Base URLs of every custom provider pi is configured with (possibly none). */
27
- export function discoverModelEndpoints(agentDir = path.join(os.homedir(), '.pi', 'agent')) {
28
- try {
29
- const j = JSON.parse(fs.readFileSync(path.join(agentDir, 'models.json'), 'utf8'));
30
- const urls = [];
31
- for (const p of Object.values(j.providers ?? {})) {
32
- if (typeof p?.baseUrl === 'string' && p.baseUrl.length > 0)
33
- urls.push(p.baseUrl);
34
- }
35
- return [...new Set(urls)];
36
- }
37
- catch {
38
- return [];
39
- }
40
- }
41
- /** A `provider/id` spec as a ref, splitting on the FIRST slash. */
42
- function specRef(spec) {
43
- if (spec === undefined)
44
- return undefined;
45
- const i = spec.indexOf('/');
46
- if (i <= 0 || i === spec.length - 1)
47
- return undefined;
48
- return { provider: spec.slice(0, i), id: spec.slice(i + 1) };
49
- }
26
+ import { modelEndpoint } from '../config/group-args.js';
27
+ import { specOf } from './model-resolve.js';
28
+ /**
29
+ * Still read from settings.json rather than the session snapshot: pi persists a
30
+ * `/model` switch there, and a child with no `--model` resolves it fresh, so the
31
+ * file is the one source that is right after a mid-session switch.
32
+ */
50
33
  export function defaultModelRef(agentDir = path.join(os.homedir(), '.pi', 'agent')) {
51
34
  try {
52
35
  const j = JSON.parse(fs.readFileSync(path.join(agentDir, 'settings.json'), 'utf8'));
@@ -62,43 +45,6 @@ export function defaultModelRef(agentDir = path.join(os.homedir(), '.pi', 'agent
62
45
  return undefined;
63
46
  }
64
47
  }
65
- /**
66
- * The base URL one model is served from, or `undefined` for "not configured on
67
- * this machine".
68
- *
69
- * Two files, two shapes, and the difference is not cosmetic. `models.json` is
70
- * hand-written and hangs `baseUrl` off the PROVIDER; `models-store.json` is the
71
- * cached remote catalogue and hangs it off each MODEL — its provider objects are
72
- * `{models, checkedAt, lastModified, etag}` with no `baseUrl` key at all. A
73
- * reader that knows only the first shape sees one endpoint on a machine that has
74
- * fifteen.
75
- *
76
- * `undefined` is the honest answer for a model pi serves from `@earendil-works/
77
- * pi-ai`'s built-in catalogue, whose URLs live in that package rather than on
78
- * disk. We do not import it — see the header of this file — so we say we do not
79
- * know, and the caller declines to probe rather than probing something else.
80
- */
81
- export function modelBaseUrl(ref, agentDir = path.join(os.homedir(), '.pi', 'agent')) {
82
- const url = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
83
- try {
84
- const j = JSON.parse(fs.readFileSync(path.join(agentDir, 'models.json'), 'utf8'));
85
- const p = j.providers?.[ref.provider];
86
- if (p) {
87
- const own = p.models?.find(m => m.id === ref.id);
88
- return url(own?.baseUrl) ?? url(p.baseUrl);
89
- }
90
- }
91
- catch {
92
- /* fall through to the store */
93
- }
94
- try {
95
- const j = JSON.parse(fs.readFileSync(path.join(agentDir, 'models-store.json'), 'utf8'));
96
- return url(j[ref.provider]?.models?.find(m => m.id === ref.id)?.baseUrl);
97
- }
98
- catch {
99
- return undefined;
100
- }
101
- }
102
48
  /**
103
49
  * What to probe on behalf of one child — the endpoint that child's own model
104
50
  * uses, not every endpoint on the machine.
@@ -115,18 +61,22 @@ export function modelBaseUrl(ref, agentDir = path.join(os.homedir(), '.pi', 'age
115
61
  * child whose own backend is healthy, and it can leave the guard disarmed for
116
62
  * one whose backend is dead.
117
63
  *
118
- * Two escapes, both toward never killing:
119
- * - model known, endpoint not on disk (a built-in provider) `[]`, which
120
- * `probeModelEndpoints` reads as reachable. We cannot see that server, so we
121
- * do not get a vote. Probing some OTHER provider's url instead would import a
122
- * false positive, which is the one thing today's blind OR never does.
123
- * - no readable default → today's behaviour, unchanged.
64
+ * Every escape is toward never killing: an unknown model, a model with no URL,
65
+ * an unreadable default and a session that has not started all answer `[]`,
66
+ * which `probeModelEndpoints` reads as reachable. Probing some OTHER model's
67
+ * url instead would import a false positive, which is the one thing a blind
68
+ * guard never does.
69
+ *
70
+ * `agentDir` locates settings.json for the unpinned case only. The URL always
71
+ * comes from this session's registry snapshot, never from a file under it.
124
72
  */
125
73
  export function childModelEndpoints(spec, agentDir = path.join(os.homedir(), '.pi', 'agent')) {
126
- const ref = specRef(spec) ?? defaultModelRef(agentDir);
127
- if (!ref)
128
- return discoverModelEndpoints(agentDir);
129
- const url = modelBaseUrl(ref, agentDir);
74
+ let ref = spec;
75
+ if (ref === undefined) {
76
+ const saved = defaultModelRef(agentDir);
77
+ ref = saved && specOf(saved);
78
+ }
79
+ const url = ref === undefined ? undefined : modelEndpoint(ref);
130
80
  return url === undefined ? [] : [url];
131
81
  }
132
82
  /**
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The one seam between a stored `provider/id` spec and pi's live model registry.
3
+ *
4
+ * Every question of the form "which pi Model does X run on" comes through here:
5
+ * the session hints, the context-window table, the settings panel's catalogue
6
+ * and the implementation hold. They used to each carry their own split-then-
7
+ * `find`, and each re-decided what `inherit` means. One of them getting it
8
+ * wrong is a warning naming the wrong model or a hold restoring the wrong one.
9
+ *
10
+ * `ctx` is the STRUCTURAL minimum, not one of pi's context interfaces: a tool
11
+ * context, a command context and a test literal all satisfy it. `ctx.model` and
12
+ * `ctx.modelRegistry` are GETTERS on the real thing that call `assertActive()`
13
+ * and throw on a stale context, so every reader here answers `undefined`
14
+ * instead of throwing — a session hint must not take the session down.
15
+ */
16
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
17
+ import { type ChildGroup } from '../config/groups.js';
18
+ /**
19
+ * pi's own `Model`, named without importing `@earendil-works/pi-ai` — which is
20
+ * neither a dependency, a devDependency nor a peerDependency of this package.
21
+ * The context already carries the type, so deriving it adds no edge to the graph.
22
+ */
23
+ export type PiModel = NonNullable<ExtensionContext['model']>;
24
+ /** The two registry questions this asks. `getAvailable` only feeds the endpoint map. */
25
+ export interface ModelRegistryView {
26
+ find(provider: string, id: string): PiModel | undefined;
27
+ getRegisteredProviderIds?(): readonly string[];
28
+ getAvailable?(): readonly PiModel[];
29
+ }
30
+ export interface ModelContext {
31
+ model?: PiModel;
32
+ modelRegistry?: ModelRegistryView;
33
+ }
34
+ /** A spec, resolved. Structurally a `GroupModelFacts`, so it feeds the reasoning check as-is. */
35
+ export interface ResolvedModel {
36
+ /** Canonical `provider/id` — for `inherit`, the session model's own. */
37
+ spec: string;
38
+ name: string;
39
+ reasoning: boolean;
40
+ thinkingLevelMap?: PiModel['thinkingLevelMap'];
41
+ /** Absent when the model declares none; a probe cannot be aimed at it. */
42
+ baseUrl?: string;
43
+ /** 0 when the model declares none, so `||` falls through to the parent's. */
44
+ contextWindow: number;
45
+ /**
46
+ * Its provider was registered by a host extension. Children run
47
+ * `--no-extensions`, so the model works in a child exactly when that
48
+ * extension is in the child whitelist — a warning, never a drop.
49
+ */
50
+ fromExtension: boolean;
51
+ handle: PiModel;
52
+ }
53
+ /** The `provider/id` inverse of {@link splitSpec}. */
54
+ export declare function specOf(model: {
55
+ provider: string;
56
+ id: string;
57
+ }): string;
58
+ /**
59
+ * `inherit` is the session's model. That is decision 3 of the model table —
60
+ * children are NOT switched to follow the host — and the honest value is
61
+ * settings.json's default, which need not be the session's. Naming the
62
+ * session's model is still the better of the two: it is the one the user can
63
+ * see, and on every machine with one provider the two agree.
64
+ *
65
+ * `find` is EXACT, deliberately stricter than pi's own CLI, which also
66
+ * substring-matches. We store a canonical `provider/id`, so exact is the only
67
+ * match that should ever count.
68
+ */
69
+ export declare function resolveModel(ctx: ModelContext, spec: string): ResolvedModel | undefined;
70
+ /**
71
+ * One group's cell, as the code with no `ctx` needs it (see config/group-args.ts).
72
+ *
73
+ * `unresolved` — no such model here, so the `--model` flag is dropped.
74
+ * `extension` — see {@link ResolvedModel.fromExtension}; a warning, not a drop.
75
+ */
76
+ export interface GroupModelSnapshot {
77
+ spec: string;
78
+ /** false ⇒ emit no `--model` for this group. Only `unresolved` clears it. */
79
+ usable: boolean;
80
+ /** Absent for `inherit` and for an unresolved spec: the caller keeps its live parent value. */
81
+ contextWindow?: number;
82
+ problem?: 'unresolved' | 'extension';
83
+ }
84
+ /**
85
+ * Every group's cell in ONE registry walk, so the argv drop, the churn window
86
+ * and the hint can never disagree about which model a group runs on.
87
+ *
88
+ * An `inherit` cell gets NO window. Storing the parent's would freeze a
89
+ * session_start snapshot in front of the live per-run value: a user who
90
+ * switches the session model with Ctrl+P to a bigger one would have every
91
+ * child judged against the old window, and the churn rule then fires early and
92
+ * kills a healthy child. An unresolved cell gets none for the same reason —
93
+ * such a child runs on the live default, whichever that is by then.
94
+ *
95
+ * A registry that cannot answer condemns NOTHING: every cell stays usable and
96
+ * carries no window. Claiming every spec unresolved would drop every `--model`
97
+ * on a session whose runtime merely was not ready.
98
+ */
99
+ export declare function resolveGroupModels(ctx: ModelContext, specs: Readonly<Record<ChildGroup, string>>): Record<ChildGroup, GroupModelSnapshot>;
100
+ /**
101
+ * `spec → baseUrl` for every model this session can use, for the dead-backend
102
+ * probe (shared/model-endpoint.ts). Empty when the registry cannot answer,
103
+ * which the probe reads as "cannot see, so never kill".
104
+ */
105
+ export declare function resolveModelEndpoints(ctx: ModelContext): Map<string, string>;
@@ -0,0 +1,97 @@
1
+ import { MODEL_INHERIT, splitSpec } from '../config/group-models.js';
2
+ import { CHILD_GROUPS } from '../config/groups.js';
3
+ /** The `provider/id` inverse of {@link splitSpec}. */
4
+ export function specOf(model) {
5
+ return `${model.provider}/${model.id}`;
6
+ }
7
+ /**
8
+ * `inherit` is the session's model. That is decision 3 of the model table —
9
+ * children are NOT switched to follow the host — and the honest value is
10
+ * settings.json's default, which need not be the session's. Naming the
11
+ * session's model is still the better of the two: it is the one the user can
12
+ * see, and on every machine with one provider the two agree.
13
+ *
14
+ * `find` is EXACT, deliberately stricter than pi's own CLI, which also
15
+ * substring-matches. We store a canonical `provider/id`, so exact is the only
16
+ * match that should ever count.
17
+ */
18
+ export function resolveModel(ctx, spec) {
19
+ try {
20
+ const parts = spec === MODEL_INHERIT ? undefined : splitSpec(spec);
21
+ const handle = spec === MODEL_INHERIT ?
22
+ ctx.model
23
+ : parts && ctx.modelRegistry?.find(parts.provider, parts.id);
24
+ if (!handle)
25
+ return undefined;
26
+ const provider = parts?.provider ?? handle.provider;
27
+ const extensionProviders = ctx.modelRegistry?.getRegisteredProviderIds?.() ?? [];
28
+ return {
29
+ spec: parts ? spec : specOf(handle),
30
+ name: handle.name || handle.id,
31
+ reasoning: handle.reasoning,
32
+ ...(handle.thinkingLevelMap === undefined ?
33
+ {}
34
+ : { thinkingLevelMap: handle.thinkingLevelMap }),
35
+ ...(handle.baseUrl ? { baseUrl: handle.baseUrl } : {}),
36
+ contextWindow: handle.contextWindow ?? 0,
37
+ fromExtension: extensionProviders.includes(provider),
38
+ handle
39
+ };
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ /**
46
+ * Every group's cell in ONE registry walk, so the argv drop, the churn window
47
+ * and the hint can never disagree about which model a group runs on.
48
+ *
49
+ * An `inherit` cell gets NO window. Storing the parent's would freeze a
50
+ * session_start snapshot in front of the live per-run value: a user who
51
+ * switches the session model with Ctrl+P to a bigger one would have every
52
+ * child judged against the old window, and the churn rule then fires early and
53
+ * kills a healthy child. An unresolved cell gets none for the same reason —
54
+ * such a child runs on the live default, whichever that is by then.
55
+ *
56
+ * A registry that cannot answer condemns NOTHING: every cell stays usable and
57
+ * carries no window. Claiming every spec unresolved would drop every `--model`
58
+ * on a session whose runtime merely was not ready.
59
+ */
60
+ export function resolveGroupModels(ctx, specs) {
61
+ const registry = readRegistry(ctx);
62
+ const cell = (spec) => {
63
+ if (spec === MODEL_INHERIT || registry === undefined)
64
+ return { spec, usable: true };
65
+ const found = resolveModel({ modelRegistry: registry }, spec);
66
+ if (!found)
67
+ return { spec, usable: false, problem: 'unresolved' };
68
+ return {
69
+ spec,
70
+ usable: true,
71
+ ...(found.contextWindow > 0 ? { contextWindow: found.contextWindow } : {}),
72
+ ...(found.fromExtension ? { problem: 'extension' } : {})
73
+ };
74
+ };
75
+ return Object.fromEntries(CHILD_GROUPS.map(g => [g, cell(specs[g])]));
76
+ }
77
+ /**
78
+ * `spec → baseUrl` for every model this session can use, for the dead-backend
79
+ * probe (shared/model-endpoint.ts). Empty when the registry cannot answer,
80
+ * which the probe reads as "cannot see, so never kill".
81
+ */
82
+ export function resolveModelEndpoints(ctx) {
83
+ const out = new Map();
84
+ for (const m of readRegistry(ctx)?.getAvailable?.() ?? []) {
85
+ if (m.baseUrl)
86
+ out.set(specOf(m), m.baseUrl);
87
+ }
88
+ return out;
89
+ }
90
+ function readRegistry(ctx) {
91
+ try {
92
+ return ctx.modelRegistry;
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ }
@@ -67,6 +67,26 @@ export declare function supportedThinkingLevels(model: ReasoningModelFacts): Lad
67
67
  * the model supports it — which is what makes the inequality a mismatch test.
68
68
  */
69
69
  export declare function clampToModel(model: ReasoningModelFacts, level: LadderLevel): LadderLevel;
70
+ /**
71
+ * The settings a /task-config row may offer, given the model its group runs on.
72
+ *
73
+ * The INTERSECTION with `REASONING_SETTINGS`, not `supportedThinkingLevels`
74
+ * directly: that returns the whole ladder including `xhigh` and `max`, which
75
+ * the menu excludes on purpose (see config/reasoning.ts) because pi's own UI
76
+ * may not offer them. A model declaring `xhigh` must not smuggle it in.
77
+ */
78
+ export declare function offeredLevels(facts: ReasoningModelFacts | undefined): GroupSetting[];
79
+ /**
80
+ * The setting a row will really run at, inside the menu's own vocabulary.
81
+ *
82
+ * ONE function for the picker's preselect and the writer, because they used to
83
+ * be two copies of the same clamp and only one re-projected into
84
+ * {@link offeredLevels}. `clampToModel` walks UP first and knows the whole
85
+ * ladder, so a model declaring `xhigh` can land on a level the menu excludes;
86
+ * a writer that stored it would put a value in the table that no row can show.
87
+ * The highest OFFERED level is the honest neighbour of an excluded one.
88
+ */
89
+ export declare function effectiveSetting(facts: ReasoningModelFacts | undefined, wanted: GroupSetting): GroupSetting;
70
90
  /** One group whose configured setting the model it runs on will not honour. */
71
91
  export interface ReasoningMismatch {
72
92
  group: ChildGroup;
@@ -30,7 +30,7 @@
30
30
  * line-for-line identical to it, ladder included. Nothing here imports pi-ai, so
31
31
  * an upstream change will not fail a test — the two have to be re-compared.
32
32
  */
33
- import { CHILD_GROUPS } from '../config/reasoning.js';
33
+ import { CHILD_GROUPS, REASONING_SETTINGS } from '../config/reasoning.js';
34
34
  /**
35
35
  * pi's own level ladder, in order — the same seven names, in the same sequence,
36
36
  * as `EXTENDED_THINKING_LEVELS` in pi-ai's models.js. The order IS the algorithm:
@@ -86,6 +86,37 @@ export function clampToModel(model, level) {
86
86
  }
87
87
  return available[0] ?? 'off';
88
88
  }
89
+ /**
90
+ * The settings a /task-config row may offer, given the model its group runs on.
91
+ *
92
+ * The INTERSECTION with `REASONING_SETTINGS`, not `supportedThinkingLevels`
93
+ * directly: that returns the whole ladder including `xhigh` and `max`, which
94
+ * the menu excludes on purpose (see config/reasoning.ts) because pi's own UI
95
+ * may not offer them. A model declaring `xhigh` must not smuggle it in.
96
+ */
97
+ export function offeredLevels(facts) {
98
+ if (facts === undefined)
99
+ return [...REASONING_SETTINGS];
100
+ const supported = supportedThinkingLevels(facts);
101
+ return REASONING_SETTINGS.filter(s => s === 'inherit' || supported.includes(s));
102
+ }
103
+ /**
104
+ * The setting a row will really run at, inside the menu's own vocabulary.
105
+ *
106
+ * ONE function for the picker's preselect and the writer, because they used to
107
+ * be two copies of the same clamp and only one re-projected into
108
+ * {@link offeredLevels}. `clampToModel` walks UP first and knows the whole
109
+ * ladder, so a model declaring `xhigh` can land on a level the menu excludes;
110
+ * a writer that stored it would put a value in the table that no row can show.
111
+ * The highest OFFERED level is the honest neighbour of an excluded one.
112
+ */
113
+ export function effectiveSetting(facts, wanted) {
114
+ if (facts === undefined || wanted === 'inherit')
115
+ return wanted;
116
+ const offered = offeredLevels(facts);
117
+ const clamped = clampToModel(facts, wanted);
118
+ return offered.includes(clamped) ? clamped : (offered.at(-1) ?? 'inherit');
119
+ }
89
120
  /**
90
121
  * Every group whose setting the model IT RUNS ON will silently change.
91
122
  *