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.
- package/CHANGELOG.md +95 -0
- package/dist/index.mjs +486 -481
- package/package.json +2 -2
- package/src/config/config-validation.ts +28 -0
- package/src/config/env-vars.ts +23 -0
- package/src/config/types.ts +47 -0
- package/src/prompt/prompt-runtime.ts +33 -4
- package/src/runtime/child-pi/child-pi-spawn.ts +7 -0
- package/src/runtime/child-pi/child-pi.ts +93 -4
- package/src/runtime/child-pi/session-recovery.ts +334 -0
- package/src/runtime/model/model-scope.ts +141 -21
- package/src/runtime/model/pi-args.ts +104 -3
- package/src/runtime/peer-dep.ts +24 -0
- package/src/runtime/rpc/README.md +100 -0
- package/src/runtime/rpc/frame-client.ts +328 -0
- package/src/runtime/rpc/rpc-worker.ts +151 -0
- package/src/runtime/rpc/ui-request-policy.ts +112 -0
- package/src/runtime/run-worker.ts +14 -0
- package/src/runtime/surface/surface-spawn.ts +8 -1
- package/src/runtime/task-output-context.ts +191 -32
- package/src/runtime/task-packet.ts +3 -0
- package/src/runtime/task-runner/child-executor.ts +40 -2
- package/src/runtime/task-runner/post-execution.ts +5 -3
- package/src/runtime/task-runner/pre-execution.ts +24 -1
- package/src/runtime/task-runner/prompt-builder.ts +10 -2
- package/src/schema/config-schema.ts +25 -0
- package/src/schema/team-tool-schema.ts +10 -0
- package/src/ui/pi-ui-compat.ts +18 -9
- package/src/ui/widget/index.ts +0 -1
|
@@ -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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
18
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
64
|
-
const
|
|
65
|
-
if (!
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|