pi-crew 0.11.6 → 0.11.7

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.
@@ -0,0 +1,334 @@
1
+ /**
2
+ * W2 (Pi 1.0.0 adoption — session-file recovery).
3
+ *
4
+ * Workers run with a DETERMINISTIC pi session identity: `--session-id` +
5
+ * `--session-dir` pointing under the run's artifacts root
6
+ * (`<artifactsRoot>/sessions/<safeTaskId>/`), so session JSONL lives and dies
7
+ * with run artifacts (auto-prune) instead of `~/.pi`. When a worker dies
8
+ * WITHOUT a final assistant event (exitCode null / killed — SIGKILL between
9
+ * turns), the session file still contains every COMPLETED turn (the SDK
10
+ * persists records at message_end; a kill can only tear the line being
11
+ * written). This module tail-replays that file and surfaces the last complete
12
+ * assistant record as `recoveredFromSession` on the run result.
13
+ *
14
+ * Contract (per verified packet):
15
+ * - Entry parsing is DELEGATED to the SDK (`parseSessionEntries` /
16
+ * `migrateSessionEntries`) — no hand-rolled parser. The only local IO
17
+ * defense is dropping a torn trailing fragment line (no final `\n`).
18
+ * - A trailing user/toolResult record with no assistant after it means the
19
+ * worker died mid-turn → the last COMPLETE assistant BEFORE it is recovered.
20
+ * - Recovery AUGMENTS the settle-time result; manifest polling is untouched.
21
+ * - Default ON; `PI_CREW_SESSION_RECOVERY` env overrides; explicit
22
+ * `sessionRecovery: false` (runtime config) is the opt-out below env.
23
+ *
24
+ * SDK import is a LAZY dynamic import (first value-import of the SDK in
25
+ * src/runtime): recovery only runs on crash paths, so module init must never
26
+ * pay the SDK load cost.
27
+ */
28
+
29
+ import * as fs from "node:fs";
30
+ import * as path from "node:path";
31
+ import { getCrewEnvBool } from "../../config/env-vars.ts";
32
+ import { isSafePathId } from "../../utils/safe-paths.ts";
33
+
34
+ /** Recovered last-complete-assistant payload attached to the run result. */
35
+ export interface SessionRecoveryInfo {
36
+ /** The session id the worker ran with (`--session-id`). */
37
+ sessionId: string;
38
+ /** Absolute path of the session JSONL the text was recovered from. */
39
+ sessionFile: string;
40
+ /** Text content of the last complete assistant message (text parts joined). */
41
+ text: string;
42
+ /** Timestamp of the recovered session entry (ISO). */
43
+ timestamp: string;
44
+ /** Stop reason of the recovered assistant message (e.g. "stop", "toolUse"). */
45
+ stopReason?: string;
46
+ }
47
+
48
+ /** Per-worker session identity resolved from the run input. */
49
+ export interface WorkerSessionContext {
50
+ sessionId: string;
51
+ sessionDir: string;
52
+ }
53
+
54
+ /** Identity fields read off `ChildPiRunInput` (kept structural for tests). */
55
+ export interface SessionIdentityInput {
56
+ sessionId?: string;
57
+ sessionDir?: string;
58
+ agentId?: string;
59
+ artifactsRoot?: string;
60
+ }
61
+
62
+ /**
63
+ * Sanitize a task/session id the same way the state store does
64
+ * (crew-agent-records.ts safeAgentTaskId — module-private there, mirrored
65
+ * here): delegate ids "parent:child" collapse to the last segment, then the
66
+ * result must be path-safe. Returns null when unsafe — callers fail closed
67
+ * (no session flags, no recovery) rather than spawning with a broken id.
68
+ * [A-Za-z0-9_-]+ is a strict subset of the SDK's session-id charset.
69
+ */
70
+ function sanitizeSessionIdentity(rawId: string): string | null {
71
+ const leaf = rawId.includes(":") ? (rawId.split(":").pop() ?? "") : rawId;
72
+ return isSafePathId(leaf) ? leaf : null;
73
+ }
74
+
75
+ /**
76
+ * Derive the worker's deterministic session identity.
77
+ *
78
+ * Layout (packet §R2.2 + fact pack §3): session files land in a PER-WORKER
79
+ * directory `<artifactsRoot>/sessions/<safeTaskId>/`; the session id rides
80
+ * `--session-id` (explicit `sessionId` — e.g. manifest.sessionId already
81
+ * threaded by child-executor — preferred, else the safe task id). Same id
82
+ * across retries appends to one file = cross-attempt resume for free.
83
+ *
84
+ * Returns null (→ no session flags, recovery silently impossible) when the
85
+ * artifacts root is unknown or no sanitizable identity exists.
86
+ */
87
+ export function deriveSessionPaths(input: SessionIdentityInput): WorkerSessionContext | null {
88
+ const safeAgent = input.agentId ? sanitizeSessionIdentity(input.agentId) : null;
89
+ const safeSession = input.sessionId ? sanitizeSessionIdentity(input.sessionId) : null;
90
+ // File id: explicit session id (manifest.sessionId) preferred, else task id.
91
+ const sessionId = safeSession ?? safeAgent;
92
+ // Dir leaf: PER-WORKER (safe task id) so same-run siblings never share a
93
+ // session file; fall back to the session id for agent-id-less custom spawns.
94
+ const dirLeaf = safeAgent ?? safeSession;
95
+ if (!sessionId || !dirLeaf) return null;
96
+ if (input.sessionDir) return { sessionId, sessionDir: input.sessionDir };
97
+ if (!input.artifactsRoot) return null;
98
+ return { sessionId, sessionDir: path.join(input.artifactsRoot, "sessions", dirLeaf) };
99
+ }
100
+
101
+ /**
102
+ * Append the session flags to the two argv views produced by
103
+ * `prepareSpawnContext`: `builtArgs` (raw worker argv — also consumed by the
104
+ * surface branch) and the headless `spawnArgs` ([script, ...builtArgs]).
105
+ * Flags are inserted right after the `--mode json -p` cluster so they never
106
+ * trail the task-file positional. Idempotent: if the flags are already
107
+ * present (builder forwarding, future wiring), this is a no-op.
108
+ */
109
+ export function appendWorkerSessionArgs(spawnArgs: string[], builtArgs: string[], ctx: WorkerSessionContext): boolean {
110
+ // PER-FLAG idempotency (battery fix 2026-10-04): the builder may forward a
111
+ // PARTIAL identity (e.g. manifest sessionId without sessionDir — the
112
+ // child-executor runWorker call passes sessionId only). The old
113
+ // both-or-nothing guard ("if --session-id present, skip everything") let
114
+ // that partial arming self-disarm the full activation: --session-id went
115
+ // out, --session-dir never did, and worker session files silently landed
116
+ // in the cwd-keyed default dir instead of the per-worker artifacts dir —
117
+ // breaking crash recovery in the live path. Fill in each missing flag.
118
+ let changed = false;
119
+ const missing: string[] = [];
120
+ if (!builtArgs.includes("--session-id")) missing.push("--session-id", ctx.sessionId);
121
+ if (!builtArgs.includes("--session-dir")) missing.push("--session-dir", ctx.sessionDir);
122
+ if (missing.length > 0) {
123
+ insertAfterHeadlessCluster(builtArgs, missing);
124
+ changed = true;
125
+ }
126
+ // Mirror onto the headless spawnArgs view (independent array) with the
127
+ // same per-flag check.
128
+ const missingSpawn: string[] = [];
129
+ if (!spawnArgs.includes("--session-id")) missingSpawn.push("--session-id", ctx.sessionId);
130
+ if (!spawnArgs.includes("--session-dir")) missingSpawn.push("--session-dir", ctx.sessionDir);
131
+ if (missingSpawn.length > 0) {
132
+ insertAfterHeadlessCluster(spawnArgs, missingSpawn);
133
+ changed = true;
134
+ }
135
+ return changed;
136
+ }
137
+
138
+ function insertAfterHeadlessCluster(arr: string[], flags: string[]): void {
139
+ const idx = arr.indexOf("--mode");
140
+ if (idx !== -1 && arr[idx + 1] === "json" && arr[idx + 2] === "-p") {
141
+ arr.splice(idx + 3, 0, ...flags);
142
+ } else {
143
+ arr.push(...flags);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Recovery gate: explicit flag (runtime.sessionRecovery → ChildPiRunInput)
149
+ * with env override. Default ON (W2 scope). The env var is the operator
150
+ * kill-switch and wins in EITHER direction.
151
+ */
152
+ export function resolveSessionRecoveryEnabled(explicit?: boolean): boolean {
153
+ const env = getCrewEnvBool("PI_CREW_SESSION_RECOVERY");
154
+ if (env !== undefined) return env;
155
+ return explicit ?? true;
156
+ }
157
+
158
+ /**
159
+ * Crash-path predicate (packet: exitCode === null OR killed). Structural so
160
+ * both the pre-resolve result and a materialized WorkerExitStatus feed it.
161
+ */
162
+ export function shouldAttemptSessionRecovery(
163
+ result: { exitCode: number | null; exitStatus?: { exitCode?: number | null; killed?: boolean } },
164
+ killed: boolean,
165
+ ): boolean {
166
+ // NB: explicit `exitCode: null` in exitStatus IS the signal-death signal —
167
+ // `??` would collapse it onto the outer exitCode, so branch on presence.
168
+ const status = result.exitStatus;
169
+ const exitCode = status && status.exitCode !== undefined ? status.exitCode : result.exitCode;
170
+ const wasKilled = status && status.killed !== undefined ? status.killed : killed;
171
+ return exitCode === null || wasKilled === true;
172
+ }
173
+
174
+ /** Bounded crash-path work: never let a pathological read hang task settle. */
175
+ const RECOVERY_DEADLINE_MS = 5_000;
176
+ /** Scan at most this many candidate files (id-matched first, then newest). */
177
+ const RECOVERY_MAX_FILES = 3;
178
+ /** Skip absurdly large session files rather than blocking settle for them. */
179
+ const RECOVERY_MAX_FILE_BYTES = 64 * 1024 * 1024;
180
+
181
+ interface SessionParserModule {
182
+ parseSessionEntries(content: string): unknown[];
183
+ migrateSessionEntries(entries: unknown[]): void;
184
+ }
185
+
186
+ let sessionParserCache: SessionParserModule | null | undefined;
187
+
188
+ async function loadSessionParser(): Promise<SessionParserModule | null> {
189
+ if (sessionParserCache !== undefined) return sessionParserCache;
190
+ try {
191
+ // First value-import of the SDK inside src/runtime — recovery is a
192
+ // crash-path concern; child-pi module init must never pay SDK load cost.
193
+ // LAZY: SDK session parsers (parseSessionEntries/migrateSessionEntries)
194
+ const mod = (await import("@earendil-works/pi-coding-agent")) as unknown as SessionParserModule;
195
+ sessionParserCache = typeof mod.parseSessionEntries === "function" && typeof mod.migrateSessionEntries === "function" ? mod : null;
196
+ } catch {
197
+ sessionParserCache = null;
198
+ }
199
+ return sessionParserCache;
200
+ }
201
+
202
+ /**
203
+ * Candidate session files, best-first: id-suffixed matches
204
+ * (`<ISO-ts>_<sessionId>.jsonl`) newest-mtime first, then any other .jsonl
205
+ * (fallback for id-less derivation). Missing/unreadable dir → [].
206
+ */
207
+ function listSessionCandidateFiles(sessionDir: string, sessionId?: string): string[] {
208
+ let names: string[];
209
+ try {
210
+ names = fs.readdirSync(sessionDir);
211
+ } catch {
212
+ return [];
213
+ }
214
+ const jsonl = names.filter((n) => n.endsWith(".jsonl"));
215
+ const withMtime = (list: string[]): { name: string; mtimeMs: number }[] => {
216
+ const out: { name: string; mtimeMs: number }[] = [];
217
+ for (const name of list) {
218
+ try {
219
+ out.push({ name, mtimeMs: fs.statSync(path.join(sessionDir, name)).mtimeMs });
220
+ } catch {
221
+ /* raced away — ignore */
222
+ }
223
+ }
224
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
225
+ };
226
+ const matched = sessionId ? withMtime(jsonl.filter((n) => n.endsWith(`_${sessionId}.jsonl`))) : [];
227
+ const matchedSet = new Set(matched.map((x) => x.name));
228
+ const rest = withMtime(jsonl.filter((n) => !matchedSet.has(n)));
229
+ return [...matched, ...rest].map((x) => path.join(sessionDir, x.name));
230
+ }
231
+
232
+ interface AssistantMessageLike {
233
+ role?: unknown;
234
+ content?: unknown;
235
+ stopReason?: unknown;
236
+ }
237
+
238
+ /** Join the text parts of an assistant message's content array. */
239
+ function extractAssistantText(content: unknown): string {
240
+ if (!Array.isArray(content)) return typeof content === "string" ? content : "";
241
+ const parts: string[] = [];
242
+ for (const part of content) {
243
+ if (part && typeof part === "object" && (part as { type?: unknown }).type === "text") {
244
+ const text = (part as { text?: unknown }).text;
245
+ if (typeof text === "string" && text.length > 0) parts.push(text);
246
+ }
247
+ }
248
+ return parts.join("\n");
249
+ }
250
+
251
+ async function readLastCompleteAssistant(sessionFile: string, sessionId: string): Promise<SessionRecoveryInfo | null> {
252
+ let size: number;
253
+ try {
254
+ size = fs.statSync(sessionFile).size;
255
+ } catch {
256
+ return null;
257
+ }
258
+ if (size <= 0 || size > RECOVERY_MAX_FILE_BYTES) return null;
259
+ let content: string;
260
+ try {
261
+ content = fs.readFileSync(sessionFile, "utf8");
262
+ } catch {
263
+ return null;
264
+ }
265
+ // Defensive torn-tail: a SIGKILL can interrupt the final line mid-write
266
+ // (no trailing newline). parseSessionEntries already skips malformed
267
+ // lines, but drop the unterminated fragment explicitly so it can never be
268
+ // resurrected as a partial record by migration.
269
+ if (content.length > 0 && !content.endsWith("\n")) {
270
+ content = content.slice(0, content.lastIndexOf("\n") + 1);
271
+ }
272
+ const parser = await loadSessionParser();
273
+ if (!parser) return null;
274
+ let entries: unknown[];
275
+ try {
276
+ entries = parser.parseSessionEntries(content);
277
+ parser.migrateSessionEntries(entries);
278
+ } catch {
279
+ return null;
280
+ }
281
+ // Backward scan for the last COMPLETE assistant record: role assistant,
282
+ // stopReason present and not "pending" (dangling), and it must carry
283
+ // replayable text — tool-call-only turns recover nothing useful, keep
284
+ // scanning for the last turn that actually spoke.
285
+ for (let i = entries.length - 1; i >= 0; i--) {
286
+ const entry = entries[i];
287
+ if (!entry || typeof entry !== "object" || (entry as { type?: unknown }).type !== "message") continue;
288
+ const message = (entry as { message?: unknown }).message;
289
+ if (!message || typeof message !== "object") continue;
290
+ const assistant = message as AssistantMessageLike;
291
+ if (assistant.role !== "assistant") continue;
292
+ const stopReason = assistant.stopReason;
293
+ if (typeof stopReason !== "string" || stopReason === "pending") continue;
294
+ const text = extractAssistantText(assistant.content);
295
+ if (!text) continue;
296
+ return {
297
+ sessionId,
298
+ sessionFile,
299
+ text,
300
+ timestamp:
301
+ typeof (entry as { timestamp?: unknown }).timestamp === "string"
302
+ ? ((entry as { timestamp?: unknown }).timestamp as string)
303
+ : "",
304
+ stopReason,
305
+ };
306
+ }
307
+ return null;
308
+ }
309
+
310
+ async function doRecoverLastAssistant(sessionDir: string, sessionId?: string): Promise<SessionRecoveryInfo | null> {
311
+ for (const file of listSessionCandidateFiles(sessionDir, sessionId).slice(0, RECOVERY_MAX_FILES)) {
312
+ const info = await readLastCompleteAssistant(file, sessionId ?? path.basename(file).replace(/\.jsonl$/, ""));
313
+ if (info) return info;
314
+ }
315
+ return null;
316
+ }
317
+
318
+ /**
319
+ * Tail-replay a worker's session directory: recover the last COMPLETE
320
+ * assistant record from the best candidate session JSONL. Returns null when
321
+ * nothing recoverable exists (no dir, no files, no complete assistant turn,
322
+ * SDK unresolvable). Best-effort by contract — never throws, never hangs
323
+ * past RECOVERY_DEADLINE_MS.
324
+ */
325
+ export async function recoverLastAssistantFromSession(sessionDir: string, sessionId?: string): Promise<SessionRecoveryInfo | null> {
326
+ const attempt = doRecoverLastAssistant(sessionDir, sessionId);
327
+ return await Promise.race([
328
+ attempt,
329
+ new Promise<SessionRecoveryInfo | null>((resolve) => {
330
+ const timer = setTimeout(() => resolve(null), RECOVERY_DEADLINE_MS);
331
+ timer.unref();
332
+ }),
333
+ ]);
334
+ }
@@ -11,11 +11,34 @@
11
11
  * - defaultSubagentModel / parentModel-inherited out-of-scope
12
12
  * → WARNING + runs anyway (soft warn, same as frontmatter).
13
13
  *
14
- * Pattern semantics match pi's `--models` CLI / `enabledModels` allowlist:
15
- * - `"anthropic/claude-opus-4-5"` — exact match (case-insensitive).
16
- * - `"claude-*"`, `"*sonnet*"`, `"github-copilot/*"` — glob (single `*`).
17
- * - Any other string — case-insensitive substring fallback (pi's
18
- * `tryMatchModel` behavior, model-resolver.ts).
14
+ * Pattern semantics mirror pi 1.0.0's canonical `resolveModelScopeFromModels()`
15
+ * (SDK `core/model-resolver.js:204-270`) and `parseModelPattern()` (:155-200) —
16
+ * R3-20 alignment (docs/reviews/pi-1.0.0-deep-learn-r3-2026-10-03.md §R3b.3):
17
+ * - Optional `:<thinking>` suffix (`off|minimal|low|medium|high|xhigh|max`,
18
+ * case-sensitive) is stripped from patterns AND resolved model ids before
19
+ * matching, so `anthropic/claude-sonnet-5:high` in enabledModels no longer
20
+ * falsely rejects the plain model id (hard-error bug).
21
+ * - A pattern containing ANY of `*`, `?`, `[` is a glob, translated with
22
+ * minimatch semantics (`*`/`?` do not cross `/`, `[...]`/`[!...]` classes,
23
+ * whole-segment `**` crosses separators), anchored + case-insensitive.
24
+ * - Globs are tried against BOTH the configured `provider/id` form and the
25
+ * bare model id (after the first `/`), mirroring
26
+ * `minimatch(fullId, p) || minimatch(id, p)` — so `gpt-5*` matches
27
+ * `openai/gpt-5-mini`.
28
+ * - Non-glob patterns fall back to case-insensitive substring, progressively
29
+ * stripping trailing colon suffixes (mirrors parseModelPattern's
30
+ * recursion: valid levels become thinking hints, invalid ones are
31
+ * recursed through with a warning — membership still follows the prefix).
32
+ *
33
+ * Why reimplement instead of importing the canonical matcher: the SDK does
34
+ * NOT export `resolveModelScopeFromModels` from the package root (runtime-
35
+ * verified: only `resolveModelScopeWithDiagnostics` is exported, and it needs
36
+ * a live async `ModelRuntime` + catalog I/O). This gate is synchronous and
37
+ * this module is pure by design, so an exact-semantics reimplementation is
38
+ * the only import-compatible option. Residual divergence (documented, not
39
+ * fixable string-level): canonical fuzzy also matches model display `name`s
40
+ * (`model-resolver.js:114-115`), which requires the catalog — display-name-
41
+ * only patterns (e.g. `"Claude Opus 4.5"`) stay unmatched here.
19
42
  *
20
43
  * This module is pure (no I/O, no globals). Reading the actual
21
44
  * `enabledModels` from pi's settings is the caller's job (instantiate
@@ -41,37 +64,134 @@ export interface ModelScopeCheck {
41
64
  }
42
65
 
43
66
  /**
44
- * Convert a glob pattern with `*` wildcards into a RegExp.
45
- * Escape all regex meta-characters except `*`, which becomes `.*`.
46
- * Anchored (^...$) and case-insensitive.
67
+ * Valid `:<thinking>` level names — mirrors pi's `VALID_THINKING_LEVELS`
68
+ * (SDK `cli/args.js`). Matched case-sensitively, exactly like the canonical
69
+ * `isValidThinkingLevel()`, so OpenRouter-style ids (`foo:exacto`) and
70
+ * mistyped levels (`:HIGH`) are NOT treated as thinking suffixes.
71
+ */
72
+ const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
73
+
74
+ function isValidThinkingLevel(level: string): boolean {
75
+ return (VALID_THINKING_LEVELS as readonly string[]).includes(level);
76
+ }
77
+
78
+ /**
79
+ * Strip one trailing VALID `:<thinking>` suffix — the canonical glob-branch
80
+ * rule (model-resolver.js:214-222). Invalid suffixes are left in place here;
81
+ * the fuzzy branch strips them progressively instead (parseModelPattern
82
+ * recursion) — see matchesModelPattern.
83
+ */
84
+ function stripValidThinkingSuffix(value: string): string {
85
+ const colonIdx = value.lastIndexOf(":");
86
+ if (colonIdx === -1) return value;
87
+ return isValidThinkingLevel(value.substring(colonIdx + 1)) ? value.substring(0, colonIdx) : value;
88
+ }
89
+
90
+ /** Does the pattern contain glob characters? Canonical test: `*`, `?` or `[`. */
91
+ function hasGlobChars(pattern: string): boolean {
92
+ return pattern.includes("*") || pattern.includes("?") || pattern.includes("[");
93
+ }
94
+
95
+ /** Escape a literal character for use in a RegExp body. */
96
+ function escapeRegExpChar(ch: string): string {
97
+ return /[.+^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
98
+ }
99
+
100
+ /**
101
+ * Convert a glob pattern into an anchored, case-insensitive RegExp with
102
+ * minimatch semantics (canonical uses `minimatch(p, { nocase: true })`):
103
+ * - `*` → `[^/]*` (any run of non-separator chars; does NOT cross `/`)
104
+ * - `?` → `[^/]` (exactly one non-separator char)
105
+ * - `[...]` / `[!...]` / `[^...]` → character class (both `!` and `^` negate)
106
+ * - `[` without a closing `]` → literal `[`
107
+ * - a whole-segment run of `**` → `.*` (crosses separators, but still
108
+ * requires the preceding `/` — `anthropic/**` does not match `anthropic`)
109
+ * - everything else → regex-escaped literal
47
110
  */
48
111
  export function patternToRegExp(pattern: string): RegExp {
49
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
50
- return new RegExp(`${escaped.replace(/\*/g, ".*")}`, "i");
112
+ let out = "^";
113
+ for (let i = 0; i < pattern.length; i++) {
114
+ const ch = pattern[i];
115
+ if (ch === "*") {
116
+ let end = i;
117
+ while (end < pattern.length && pattern[end] === "*") end++;
118
+ const wholeSegment = (i === 0 || pattern[i - 1] === "/") && (end === pattern.length || pattern[end] === "/");
119
+ out += end - i >= 2 && wholeSegment ? ".*" : "[^/]*";
120
+ i = end - 1;
121
+ } else if (ch === "?") {
122
+ out += "[^/]";
123
+ } else if (ch === "[") {
124
+ const closing = pattern.indexOf("]", i + 1);
125
+ let inner = closing === -1 ? "" : pattern.substring(i + 1, closing);
126
+ let negated = false;
127
+ if (inner.startsWith("!") || inner.startsWith("^")) {
128
+ negated = true;
129
+ inner = inner.substring(1);
130
+ }
131
+ if (closing === -1 || inner.length === 0) {
132
+ // Unmatched `[` (or empty class `[]`/`[!]`) → literal, like minimatch.
133
+ out += "\\[";
134
+ } else {
135
+ let cls = "";
136
+ for (const c of inner) {
137
+ if (c === "\\") cls += "\\\\";
138
+ else if (c === "]") cls += "\\]";
139
+ else cls += c;
140
+ }
141
+ out += `[${negated ? "^" : ""}${cls}]`;
142
+ i = closing;
143
+ }
144
+ } else {
145
+ out += escapeRegExpChar(ch);
146
+ }
147
+ }
148
+ return new RegExp(`${out}$`, "i");
51
149
  }
52
150
 
53
151
  /**
54
152
  * Does a model id match a single allowlist pattern?
55
- * Semantics (in order):
56
- * 1. Exact case-insensitive match.
57
- * 2. Glob match (pattern contains `*`).
58
- * 3. Case-insensitive substring match (pi's fallback).
153
+ * Mirrors the canonical `resolveModelScopeFromModels()` per-pattern flow:
154
+ * - Glob branch (pattern contains `*`, `?` or `[`): strip one VALID
155
+ * `:<thinking>` suffix, try the exact reference first, then match the
156
+ * glob against BOTH the configured string and its bare-id form
157
+ * (`minimatch(fullId, p) || minimatch(id, p)`, model-resolver.js:228-232).
158
+ * - Fuzzy branch (parseModelPattern): substring match, progressively
159
+ * stripping trailing colon suffixes until a hit or no colon remains.
59
160
  * Returns true on first hit; false otherwise.
60
161
  */
61
162
  export function matchesModelPattern(modelId: string, pattern: string): boolean {
62
163
  if (!modelId || !pattern) return false;
63
- const id = modelId.trim();
64
- const pat = pattern.trim();
65
- if (!id || !pat) return false;
66
- if (id.toLowerCase() === pat.toLowerCase()) return true;
67
- if (pat.includes("*")) {
164
+ const rawId = modelId.trim();
165
+ const rawPat = pattern.trim();
166
+ if (!rawId || !rawPat) return false;
167
+ // Canonical compares thinking-less base ids: pi-crew's resolved candidates
168
+ // keep their `:high` suffix (splitThinkingSuffix), and user patterns may
169
+ // carry one — strip valid suffixes from BOTH sides before matching.
170
+ const id = stripValidThinkingSuffix(rawId);
171
+ // Bare-id form = everything after the FIRST `/` (the provider segment);
172
+ // openrouter-style ids can themselves contain slashes.
173
+ const bareId = id.includes("/") ? id.substring(id.indexOf("/") + 1) : id;
174
+ if (hasGlobChars(rawPat)) {
175
+ const glob = stripValidThinkingSuffix(rawPat);
176
+ if (id.toLowerCase() === glob.toLowerCase() || bareId.toLowerCase() === glob.toLowerCase()) {
177
+ return true;
178
+ }
68
179
  try {
69
- return patternToRegExp(pat).test(id);
180
+ const re = patternToRegExp(glob);
181
+ return re.test(id) || re.test(bareId);
70
182
  } catch {
71
183
  return false;
72
184
  }
73
185
  }
74
- return id.toLowerCase().includes(pat.toLowerCase());
186
+ let pat = rawPat;
187
+ for (;;) {
188
+ // Substring over the configured form is a superset of canonical's
189
+ // exact-then-substring over the same string, so verdicts agree.
190
+ if (id.toLowerCase().includes(pat.toLowerCase())) return true;
191
+ const colonIdx = pat.lastIndexOf(":");
192
+ if (colonIdx === -1) return false;
193
+ pat = pat.substring(0, colonIdx);
194
+ }
75
195
  }
76
196
 
77
197
  /**