pi-orchestrator-config 4.2.0 → 4.3.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.
- package/README.md +76 -67
- package/agents/code-reviewer-docs.md +11 -1
- package/agents/code-reviewer-guidelines.md +11 -1
- package/agents/code-reviewer-quality.md +11 -1
- package/agents/code-reviewer-security.md +11 -1
- package/agents/code-reviewer-spec.md +11 -1
- package/agents/git-expert.md +42 -3
- package/agents/github-expert.md +11 -0
- package/extensions/acpx-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/agents/claude.ts +3 -2
- package/extensions/cli-provider/agents/cursor.ts +3 -5
- package/extensions/cli-provider/agents/gemini.ts +3 -2
- package/extensions/cli-provider/discover.ts +7 -7
- package/extensions/cli-provider/index.ts +5 -0
- package/extensions/cli-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/session-reaper.ts +5 -7
- package/extensions/cli-provider/sessions.ts +4 -0
- package/extensions/cli-provider/shared/discover-cache.ts +17 -176
- package/extensions/coms/coms-p2p.ts +1719 -423
- package/extensions/coms/coms-shared.ts +261 -39
- package/extensions/coms/coms-wrapper.ts +100 -62
- package/extensions/coms/index.ts +8 -4
- package/extensions/coms/mixed-turn.ts +26 -0
- package/extensions/coms/probe-socket.ts +42 -0
- package/extensions/coms/themeMap.ts +0 -1
- package/extensions/image-gen/image-gen.ts +19 -1
- package/extensions/orchestrator/async-agents.ts +563 -102
- package/extensions/orchestrator/async-capability.ts +8 -8
- package/extensions/orchestrator/cron.ts +15 -9
- package/extensions/orchestrator/dreaming.ts +32 -25
- package/extensions/orchestrator/enforcement-helpers.ts +459 -17
- package/extensions/orchestrator/enforcement-rules.ts +5 -4
- package/extensions/orchestrator/enforcement.ts +80 -27
- package/extensions/orchestrator/extended-autocomplete.ts +7 -0
- package/extensions/orchestrator/git-helpers.ts +2 -2
- package/extensions/orchestrator/index.ts +78 -10
- package/extensions/orchestrator/markdown-transformer.ts +313 -0
- package/extensions/orchestrator/memory-embeddings.ts +36 -34
- package/extensions/orchestrator/memory-provenance.ts +29 -25
- package/extensions/orchestrator/memory-scoring.ts +23 -18
- package/extensions/orchestrator/memory-tools.ts +10 -3
- package/extensions/orchestrator/overlay-dashboard.ts +17 -5
- package/extensions/orchestrator/parse-model-override.ts +29 -0
- package/extensions/orchestrator/pi-config-review-state.ts +57 -33
- package/extensions/orchestrator/project-settings.ts +333 -127
- package/extensions/orchestrator/resolve-agent-model.ts +24 -13
- package/extensions/orchestrator/review-porcelain.ts +4 -0
- package/extensions/orchestrator/review-ui.ts +63 -15
- package/extensions/orchestrator/rule-placeholders.ts +411 -2
- package/extensions/orchestrator/rules.ts +80 -132
- package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
- package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
- package/extensions/orchestrator/settings-tui.ts +636 -0
- package/extensions/orchestrator/state-jsonl.ts +431 -0
- package/extensions/orchestrator/subagent-tool.ts +157 -59
- package/extensions/orchestrator/sync-limit.ts +23 -0
- package/extensions/orchestrator/task-lifecycle.ts +77 -67
- package/extensions/orchestrator/utils.ts +8 -5
- package/extensions/pidash/pidash.ts +193 -156
- package/extensions/pidiff/pidiff.ts +96 -38
- package/extensions/pidiff/spawn-lock.ts +62 -0
- package/extensions/pitasks/index.ts +430 -0
- package/extensions/pitasks/reminders.ts +37 -0
- package/extensions/pitasks/task-store.ts +467 -0
- package/extensions/pitasks/task-tools.ts +375 -0
- package/extensions/pitasks/task-widget.ts +181 -0
- package/extensions/pitasks/types.ts +38 -0
- package/extensions/providers/acpx-driver.ts +12 -2
- package/extensions/providers/built-in-drivers.ts +6 -6
- package/extensions/providers/claude-driver.ts +1 -1
- package/extensions/providers/cursor-cli-driver.ts +1 -1
- package/extensions/providers/gemini-driver.ts +1 -1
- package/extensions/providers/index.ts +144 -26
- package/extensions/providers/initialized-guard.ts +27 -0
- package/extensions/providers/restore-default-model.ts +596 -0
- package/extensions/providers/session-shutdown.ts +39 -0
- package/extensions/shared/coms-active.ts +23 -0
- package/extensions/shared/create-runtime-provider.ts +2 -0
- package/extensions/shared/daemon-manager.ts +0 -8
- package/extensions/shared/file-logger.ts +132 -25
- package/extensions/shared/logger.ts +50 -0
- package/extensions/shared/models-dev.ts +421 -0
- package/extensions/shared/oneshot.ts +116 -0
- package/extensions/shared/resolve-binary.ts +191 -0
- package/extensions/shared/types.ts +3 -0
- package/package.json +9 -3
- package/prompts/coderabbit-rate-limit.md +2 -0
- package/rules/00-orchestrator-core.md +3 -0
- package/rules/10-agent-routing.md +12 -1
- package/rules/20-code-review-loop.md +52 -25
- package/rules/30-prompt-templates.md +4 -0
- package/rules/35-memory.md +3 -3
- package/rules/40-critical-rules.md +3 -0
- package/rules/55-coms-protocol.md +84 -18
- package/rules/60-task-tracking.md +2 -0
- package/settings-keys.json +563 -0
- package/templates/coms-feature-manager-prompt.md +4 -6
- package/extensions/coms/coms-net-server.ts +0 -1642
- package/extensions/coms/coms-net-wrapper.ts +0 -521
- package/extensions/coms/coms-net.ts +0 -1589
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cold-start restore of saved default model (#753).
|
|
3
|
+
*
|
|
4
|
+
* pi findInitialModel requires getModel AND hasConfiguredAuth. Native
|
|
5
|
+
* registerNativeProvider does not provisional-configure auth, so a race can
|
|
6
|
+
* leave hasConfiguredAuth false at pick time → wrong initial model.
|
|
7
|
+
* On session_start (providers already registered), restore the saved default
|
|
8
|
+
* when current differs (or is missing), unless the user passed --model /
|
|
9
|
+
* --provider / --models, enabledModels scopes the model list, or the session
|
|
10
|
+
* is resume/fork/reload.
|
|
11
|
+
*
|
|
12
|
+
* Kept free of @earendil-works/pi-ai so unit tests can import under tsx.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { createLogger } from "../shared/logger.js";
|
|
19
|
+
|
|
20
|
+
const log = createLogger("providers");
|
|
21
|
+
|
|
22
|
+
/** session_start reasons where cold-start restore is allowed. */
|
|
23
|
+
export const RESTORE_ALLOWED_REASONS = new Set(["startup", "new"]);
|
|
24
|
+
|
|
25
|
+
export type RestoreDefaultModelOpts = {
|
|
26
|
+
defaultProvider?: string | null;
|
|
27
|
+
defaultModelId?: string | null;
|
|
28
|
+
currentProvider?: string | null;
|
|
29
|
+
currentModelId?: string | null;
|
|
30
|
+
/** session_start reason; only "startup" | "new" may restore. */
|
|
31
|
+
reason?: string | null;
|
|
32
|
+
/**
|
|
33
|
+
* CLI argv to scan for --model / --provider / --models (user override).
|
|
34
|
+
* Defaults to process.argv when omitted in restoreDefaultModelOnSessionStart.
|
|
35
|
+
*/
|
|
36
|
+
argv?: string[] | null;
|
|
37
|
+
/**
|
|
38
|
+
* Settings enabledModels — when non-empty, pi scopes the model list the
|
|
39
|
+
* same way as --models; auth-race restore must not override that scope.
|
|
40
|
+
*/
|
|
41
|
+
enabledModels?: string[] | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** True when argv contains an explicit --model, --provider, or --models flag. */
|
|
45
|
+
export function argvHasModelOrProviderOverride(argv: string[] | null | undefined): boolean {
|
|
46
|
+
if (!Array.isArray(argv)) {
|
|
47
|
+
log.debug("argvHasModelOrProviderOverride", { result: false, reason: "not-array" });
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const result = argv.some(
|
|
51
|
+
(arg) =>
|
|
52
|
+
arg === "--model" ||
|
|
53
|
+
arg === "--provider" ||
|
|
54
|
+
arg === "--models" ||
|
|
55
|
+
arg.startsWith("--model=") ||
|
|
56
|
+
arg.startsWith("--provider=") ||
|
|
57
|
+
arg.startsWith("--models="),
|
|
58
|
+
);
|
|
59
|
+
log.debug("argvHasModelOrProviderOverride", { result, argvLen: argv.length });
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when settings enabledModels is a non-empty array (scopes like --models). */
|
|
64
|
+
export function hasEnabledModelsScope(
|
|
65
|
+
enabledModels: string[] | null | undefined,
|
|
66
|
+
): boolean {
|
|
67
|
+
const result = Array.isArray(enabledModels) && enabledModels.length > 0;
|
|
68
|
+
log.debug("hasEnabledModelsScope", {
|
|
69
|
+
result,
|
|
70
|
+
length: Array.isArray(enabledModels) ? enabledModels.length : null,
|
|
71
|
+
});
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether session_start should call setModel to recover a saved default
|
|
77
|
+
* that findInitialModel missed (auth race → wrong initial model).
|
|
78
|
+
*
|
|
79
|
+
* Gates (all must pass):
|
|
80
|
+
* 1. defaultProvider and defaultModelId both non-empty
|
|
81
|
+
* 2. reason is startup|new
|
|
82
|
+
* 3. current missing OR current provider/id ≠ default
|
|
83
|
+
* 4. argv does not contain --model, --provider, or --models
|
|
84
|
+
* 5. enabledModels is missing or empty (non-empty scopes like --models)
|
|
85
|
+
*/
|
|
86
|
+
export function shouldRestoreDefaultModel(opts: RestoreDefaultModelOpts): boolean {
|
|
87
|
+
const reason = typeof opts.reason === "string" ? opts.reason.trim() : "";
|
|
88
|
+
if (!RESTORE_ALLOWED_REASONS.has(reason)) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const provider = typeof opts.defaultProvider === "string"
|
|
93
|
+
? opts.defaultProvider.trim()
|
|
94
|
+
: "";
|
|
95
|
+
const modelId = typeof opts.defaultModelId === "string"
|
|
96
|
+
? opts.defaultModelId.trim()
|
|
97
|
+
: "";
|
|
98
|
+
|
|
99
|
+
if (!provider || !modelId) return false;
|
|
100
|
+
|
|
101
|
+
if (argvHasModelOrProviderOverride(opts.argv)) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (hasEnabledModelsScope(opts.enabledModels)) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const currentProvider = typeof opts.currentProvider === "string"
|
|
110
|
+
? opts.currentProvider.trim()
|
|
111
|
+
: "";
|
|
112
|
+
const currentModelId = typeof opts.currentModelId === "string"
|
|
113
|
+
? opts.currentModelId.trim()
|
|
114
|
+
: "";
|
|
115
|
+
|
|
116
|
+
if (!currentProvider || !currentModelId) return true;
|
|
117
|
+
return currentProvider !== provider || currentModelId !== modelId;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type PiAgentDefaults = {
|
|
121
|
+
defaultProvider?: string;
|
|
122
|
+
defaultModel?: string;
|
|
123
|
+
/** When non-empty, pi scopes models (same class as --models). */
|
|
124
|
+
enabledModels?: string[];
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve pi agent config directory.
|
|
129
|
+
* Prefer explicit agentDir; else PI_CODING_AGENT_DIR; else ~/.pi/agent.
|
|
130
|
+
* ExtensionContext has cwd but no agentDir — callers should not cast agentDir
|
|
131
|
+
* from ctx; pass agentDir only for tests or explicit override.
|
|
132
|
+
*/
|
|
133
|
+
export function resolvePiAgentDir(agentDir?: string | null): string {
|
|
134
|
+
const explicit = typeof agentDir === "string" ? agentDir.trim() : "";
|
|
135
|
+
if (explicit) return explicit;
|
|
136
|
+
const envDir = typeof process.env.PI_CODING_AGENT_DIR === "string"
|
|
137
|
+
? process.env.PI_CODING_AGENT_DIR.trim()
|
|
138
|
+
: "";
|
|
139
|
+
if (envDir) return envDir;
|
|
140
|
+
return join(homedir(), ".pi", "agent");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Resolve path to settings.json under the agent dir (global settings). */
|
|
144
|
+
export function resolvePiAgentSettingsPath(agentDir?: string | null): string {
|
|
145
|
+
return join(resolvePiAgentDir(agentDir), "settings.json");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Project settings path: `<cwd>/.pi/settings.json`. */
|
|
149
|
+
export function resolveProjectPiSettingsPath(cwd: string): string {
|
|
150
|
+
return join(cwd, ".pi", "settings.json");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseEnabledModels(value: unknown): string[] | undefined {
|
|
154
|
+
if (!Array.isArray(value)) return undefined;
|
|
155
|
+
const models = value
|
|
156
|
+
.filter((m): m is string => typeof m === "string")
|
|
157
|
+
.map((m) => m.trim())
|
|
158
|
+
.filter((m) => m.length > 0);
|
|
159
|
+
return models.length > 0 ? models : [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parsePiAgentSettingsFile(settingsPath: string): PiAgentDefaults {
|
|
163
|
+
try {
|
|
164
|
+
const raw = readFileSync(settingsPath, "utf8");
|
|
165
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
166
|
+
const defaultProvider = typeof parsed.defaultProvider === "string"
|
|
167
|
+
? parsed.defaultProvider.trim() || undefined
|
|
168
|
+
: undefined;
|
|
169
|
+
const defaultModel = typeof parsed.defaultModel === "string"
|
|
170
|
+
? parsed.defaultModel.trim() || undefined
|
|
171
|
+
: undefined;
|
|
172
|
+
const enabledModels = parseEnabledModels(parsed.enabledModels);
|
|
173
|
+
log.debug(
|
|
174
|
+
"readPiAgentDefaults",
|
|
175
|
+
`path=${settingsPath} provider=${defaultProvider ?? "(none)"} ` +
|
|
176
|
+
`model=${defaultModel ?? "(none)"} ` +
|
|
177
|
+
`enabledModels=${enabledModels ? enabledModels.length : "(none)"}`,
|
|
178
|
+
);
|
|
179
|
+
return { defaultProvider, defaultModel, enabledModels };
|
|
180
|
+
} catch (err) {
|
|
181
|
+
log.debug(
|
|
182
|
+
"readPiAgentDefaults skipped",
|
|
183
|
+
`${settingsPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
184
|
+
);
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Merge global (agentDir) + project (cwd/.pi) settings like pi SettingsManager:
|
|
191
|
+
* project wins for defaultProvider / defaultModel / enabledModels.
|
|
192
|
+
*/
|
|
193
|
+
export function mergePiAgentDefaults(
|
|
194
|
+
globalDefaults: PiAgentDefaults,
|
|
195
|
+
projectDefaults: PiAgentDefaults,
|
|
196
|
+
): PiAgentDefaults {
|
|
197
|
+
const merged: PiAgentDefaults = { ...globalDefaults };
|
|
198
|
+
if (projectDefaults.defaultProvider !== undefined) {
|
|
199
|
+
merged.defaultProvider = projectDefaults.defaultProvider;
|
|
200
|
+
}
|
|
201
|
+
if (projectDefaults.defaultModel !== undefined) {
|
|
202
|
+
merged.defaultModel = projectDefaults.defaultModel;
|
|
203
|
+
}
|
|
204
|
+
if (projectDefaults.enabledModels !== undefined) {
|
|
205
|
+
merged.enabledModels = projectDefaults.enabledModels;
|
|
206
|
+
}
|
|
207
|
+
return merged;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Read defaultProvider / defaultModel / enabledModels from settings.
|
|
212
|
+
* When cwd is set and projectTrusted is true, merges global agentDir settings
|
|
213
|
+
* with project `cwd/.pi/settings.json` (project wins), matching pi SettingsManager.
|
|
214
|
+
* When projectTrusted is false or undefined (fail-closed), reads global only
|
|
215
|
+
* (ignores the project file — same trust gate as SettingsManager).
|
|
216
|
+
* When settingsPath is set without cwd, reads that single file (tests).
|
|
217
|
+
*/
|
|
218
|
+
export function readPiAgentDefaults(opts?: {
|
|
219
|
+
settingsPath?: string;
|
|
220
|
+
agentDir?: string | null;
|
|
221
|
+
cwd?: string | null;
|
|
222
|
+
/**
|
|
223
|
+
* When true, merge project `.pi/settings.json`. Missing/undefined → false
|
|
224
|
+
* (fail closed). Wire from ctx.isProjectTrusted().
|
|
225
|
+
*/
|
|
226
|
+
projectTrusted?: boolean;
|
|
227
|
+
} | string): PiAgentDefaults {
|
|
228
|
+
// Back-compat: readPiAgentDefaults(path) for single-file reads (tests).
|
|
229
|
+
if (typeof opts === "string") {
|
|
230
|
+
return parsePiAgentSettingsFile(opts);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const settingsPath = opts?.settingsPath;
|
|
234
|
+
const cwd = typeof opts?.cwd === "string" ? opts.cwd.trim() : "";
|
|
235
|
+
// Fail closed: missing/undefined projectTrusted → do not read project settings.
|
|
236
|
+
const projectTrusted = opts?.projectTrusted === true;
|
|
237
|
+
|
|
238
|
+
if (settingsPath && !cwd) {
|
|
239
|
+
return parsePiAgentSettingsFile(settingsPath);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const globalPath = settingsPath ?? resolvePiAgentSettingsPath(opts?.agentDir);
|
|
243
|
+
const globalDefaults = parsePiAgentSettingsFile(globalPath);
|
|
244
|
+
|
|
245
|
+
if (!cwd) {
|
|
246
|
+
return globalDefaults;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!projectTrusted) {
|
|
250
|
+
log.debug(
|
|
251
|
+
"readPiAgentDefaults untrusted",
|
|
252
|
+
`cwd=${cwd} — ignoring project settings; using global=${globalPath} ` +
|
|
253
|
+
`provider=${globalDefaults.defaultProvider ?? "(none)"} ` +
|
|
254
|
+
`model=${globalDefaults.defaultModel ?? "(none)"}`,
|
|
255
|
+
);
|
|
256
|
+
return globalDefaults;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const projectPath = resolveProjectPiSettingsPath(cwd);
|
|
260
|
+
const projectDefaults = parsePiAgentSettingsFile(projectPath);
|
|
261
|
+
const merged = mergePiAgentDefaults(globalDefaults, projectDefaults);
|
|
262
|
+
log.debug(
|
|
263
|
+
"readPiAgentDefaults merged",
|
|
264
|
+
`global=${globalPath} project=${projectPath} ` +
|
|
265
|
+
`provider=${merged.defaultProvider ?? "(none)"} ` +
|
|
266
|
+
`model=${merged.defaultModel ?? "(none)"} ` +
|
|
267
|
+
`enabledModels=${merged.enabledModels ? merged.enabledModels.length : "(none)"}`,
|
|
268
|
+
);
|
|
269
|
+
return merged;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export type ModelLike = {
|
|
273
|
+
id: string;
|
|
274
|
+
provider: string;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export type ModelRegistryLike = {
|
|
278
|
+
find?: (provider: string, modelId: string) => ModelLike | undefined;
|
|
279
|
+
getAvailable?: () => ModelLike[];
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Resolve provider+id from a model registry (find preferred; getAvailable fallback).
|
|
284
|
+
* getAvailable is auth-filtered — never use it alone for hopeless detection.
|
|
285
|
+
*/
|
|
286
|
+
export function resolveDefaultModel(
|
|
287
|
+
registry: ModelRegistryLike | undefined | null,
|
|
288
|
+
provider: string,
|
|
289
|
+
modelId: string,
|
|
290
|
+
): ModelLike | undefined {
|
|
291
|
+
if (!registry) return undefined;
|
|
292
|
+
if (typeof registry.find === "function") {
|
|
293
|
+
const found = registry.find(provider, modelId);
|
|
294
|
+
if (found) return found;
|
|
295
|
+
}
|
|
296
|
+
if (typeof registry.getAvailable === "function") {
|
|
297
|
+
try {
|
|
298
|
+
return registry.getAvailable().find(
|
|
299
|
+
(m) => m.provider === provider && m.id === modelId,
|
|
300
|
+
);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
log.warn(
|
|
303
|
+
"resolveDefaultModel getAvailable threw",
|
|
304
|
+
err instanceof Error ? err.message : String(err),
|
|
305
|
+
);
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* True when restore should stop — only when registeredProviders is an array
|
|
314
|
+
* that does not include the default provider (including empty array).
|
|
315
|
+
*
|
|
316
|
+
* Optional / tests only. Production omits registeredProviders and relies on
|
|
317
|
+
* find + setModel retries (warn on exhaust). Never pass a cli/acpx-only list.
|
|
318
|
+
*
|
|
319
|
+
* NEVER treat getAvailable missing the target as hopeless: getAvailable only
|
|
320
|
+
* returns auth-ready providers. During the #753 race a provider may be
|
|
321
|
+
* registered but not yet in configuredProviders while another IS in
|
|
322
|
+
* getAvailable — that must keep retrying setModel.
|
|
323
|
+
* registry/modelId are unused (API compat for callers).
|
|
324
|
+
*/
|
|
325
|
+
export function isRestoreModelHopeless(
|
|
326
|
+
_registry: ModelRegistryLike | undefined | null,
|
|
327
|
+
provider: string,
|
|
328
|
+
_modelId: string,
|
|
329
|
+
registeredProviders?: string[] | null,
|
|
330
|
+
): boolean {
|
|
331
|
+
if (Array.isArray(registeredProviders) && !registeredProviders.includes(provider)) {
|
|
332
|
+
log.debug(
|
|
333
|
+
"isRestoreModelHopeless",
|
|
334
|
+
`registeredProviders lack ${provider} ` +
|
|
335
|
+
`(count=${registeredProviders.length})`,
|
|
336
|
+
);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export type RestoreSessionCtx = {
|
|
343
|
+
model?: ModelLike | undefined;
|
|
344
|
+
modelRegistry?: ModelRegistryLike;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
/** Shorter retry window; optional registeredProviders fail-fast for tests. */
|
|
348
|
+
export const RESTORE_DEFAULT_MODEL_RETRIES = 5;
|
|
349
|
+
/** Delay between restore attempts (ms). */
|
|
350
|
+
export const RESTORE_DEFAULT_MODEL_DELAY_MS = 100;
|
|
351
|
+
|
|
352
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
353
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
354
|
+
|
|
355
|
+
function trimModelRef(
|
|
356
|
+
model: { provider?: string | null; id?: string | null } | undefined | null,
|
|
357
|
+
): { provider: string; id: string } | undefined {
|
|
358
|
+
const provider = typeof model?.provider === "string" ? model.provider.trim() : "";
|
|
359
|
+
const id = typeof model?.id === "string" ? model.id.trim() : "";
|
|
360
|
+
if (!provider || !id) return undefined;
|
|
361
|
+
return { provider, id };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* If settings have a default and current model differs (or is missing),
|
|
366
|
+
* resolve and setModel. Retries briefly when the model is not yet resolvable
|
|
367
|
+
* or setModel returns false (stale hasConfiguredAuth).
|
|
368
|
+
* Fail-fast only when registeredProviders is an array lacking the default
|
|
369
|
+
* provider (including empty) — optional; production omits it for agnostic
|
|
370
|
+
* restore. getAvailable must not drive hopelessness.
|
|
371
|
+
* Skips when argv contains --model, --provider, or --models, or when
|
|
372
|
+
* enabledModels is a non-empty array (scoped models).
|
|
373
|
+
* Before each attempt, optional getCurrentModel aborts if already at default
|
|
374
|
+
* or the user switched (snapshot mismatch). When session_start had no model,
|
|
375
|
+
* track the first non-default live model seen mid-flight and only abort if
|
|
376
|
+
* it later *changes* (first sighting alone is not user intent).
|
|
377
|
+
* Re-checks immediately before setModel (TOCTOU).
|
|
378
|
+
* Returns true when setModel succeeded.
|
|
379
|
+
*/
|
|
380
|
+
export async function restoreDefaultModelOnSessionStart(opts: {
|
|
381
|
+
ctx: RestoreSessionCtx;
|
|
382
|
+
setModel: (model: ModelLike) => Promise<boolean>;
|
|
383
|
+
settingsPath?: string;
|
|
384
|
+
agentDir?: string | null;
|
|
385
|
+
/** Project cwd — merges cwd/.pi/settings.json over global when trusted. */
|
|
386
|
+
cwd?: string | null;
|
|
387
|
+
/**
|
|
388
|
+
* When true, merge project settings. Missing/undefined → false (fail closed).
|
|
389
|
+
* Wire from ctx.isProjectTrusted() — same gate as pi SettingsManager.
|
|
390
|
+
*/
|
|
391
|
+
projectTrusted?: boolean;
|
|
392
|
+
reason?: string;
|
|
393
|
+
/**
|
|
394
|
+
* Optional provider ids for fail-fast (tests). Production should omit —
|
|
395
|
+
* do not pass a cli/acpx-only list (breaks native default restore).
|
|
396
|
+
*/
|
|
397
|
+
registeredProviders?: string[];
|
|
398
|
+
/** Total attempts (default 5). Injectable for tests. */
|
|
399
|
+
retries?: number;
|
|
400
|
+
/** Delay between attempts in ms (default 100). Injectable for tests. */
|
|
401
|
+
delayMs?: number;
|
|
402
|
+
/** Sleep implementation (default setTimeout). Injectable for tests. */
|
|
403
|
+
sleep?: (ms: number) => Promise<void>;
|
|
404
|
+
/** CLI argv for --model/--provider/--models skip (default process.argv). */
|
|
405
|
+
argv?: string[];
|
|
406
|
+
/**
|
|
407
|
+
* Live current model (e.g. () => ctx.model). Re-checked before each attempt
|
|
408
|
+
* and immediately before setModel; abort if already at default or user
|
|
409
|
+
* switched away from the session_start snapshot (or changed mid-flight
|
|
410
|
+
* selection when there was no initial snapshot).
|
|
411
|
+
*/
|
|
412
|
+
getCurrentModel?: () => { provider: string; id: string } | undefined;
|
|
413
|
+
}): Promise<boolean> {
|
|
414
|
+
const {
|
|
415
|
+
defaultProvider,
|
|
416
|
+
defaultModel,
|
|
417
|
+
enabledModels,
|
|
418
|
+
} = readPiAgentDefaults({
|
|
419
|
+
settingsPath: opts.settingsPath,
|
|
420
|
+
agentDir: opts.agentDir,
|
|
421
|
+
cwd: opts.cwd,
|
|
422
|
+
projectTrusted: opts.projectTrusted,
|
|
423
|
+
});
|
|
424
|
+
const current = opts.ctx.model;
|
|
425
|
+
const currentProvider = current?.provider;
|
|
426
|
+
const currentModelId = current?.id;
|
|
427
|
+
const argv = opts.argv ?? process.argv;
|
|
428
|
+
const argvOverride = argvHasModelOrProviderOverride(argv);
|
|
429
|
+
const enabledModelsScope = hasEnabledModelsScope(enabledModels);
|
|
430
|
+
|
|
431
|
+
if (!shouldRestoreDefaultModel({
|
|
432
|
+
defaultProvider,
|
|
433
|
+
defaultModelId: defaultModel,
|
|
434
|
+
currentProvider,
|
|
435
|
+
currentModelId,
|
|
436
|
+
reason: opts.reason,
|
|
437
|
+
argv,
|
|
438
|
+
enabledModels,
|
|
439
|
+
})) {
|
|
440
|
+
log.debug(
|
|
441
|
+
"restore-default-model skip",
|
|
442
|
+
`reason=${opts.reason ?? "?"} default=${defaultProvider ?? "?"}/${defaultModel ?? "?"} ` +
|
|
443
|
+
`current=${currentProvider ?? "?"}/${currentModelId ?? "?"} ` +
|
|
444
|
+
`argvOverride=${argvOverride} enabledModelsScope=${enabledModelsScope}` +
|
|
445
|
+
(enabledModelsScope ? ` enabledModels=${enabledModels!.length}` : ""),
|
|
446
|
+
);
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const retries = opts.retries ?? RESTORE_DEFAULT_MODEL_RETRIES;
|
|
451
|
+
const delayMs = opts.delayMs ?? RESTORE_DEFAULT_MODEL_DELAY_MS;
|
|
452
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
453
|
+
const label = `${defaultProvider}/${defaultModel}`;
|
|
454
|
+
let lastFailReason = "unknown";
|
|
455
|
+
const snapshot = trimModelRef({
|
|
456
|
+
provider: currentProvider,
|
|
457
|
+
id: currentModelId,
|
|
458
|
+
});
|
|
459
|
+
/** First non-default live model seen when snapshot was empty — baseline for change detection. */
|
|
460
|
+
let firstSeenLive: { provider: string; id: string } | undefined;
|
|
461
|
+
|
|
462
|
+
if (
|
|
463
|
+
isRestoreModelHopeless(
|
|
464
|
+
opts.ctx.modelRegistry,
|
|
465
|
+
defaultProvider!,
|
|
466
|
+
defaultModel!,
|
|
467
|
+
opts.registeredProviders,
|
|
468
|
+
)
|
|
469
|
+
) {
|
|
470
|
+
const count = Array.isArray(opts.registeredProviders)
|
|
471
|
+
? opts.registeredProviders.length
|
|
472
|
+
: 0;
|
|
473
|
+
log.warn(
|
|
474
|
+
"restore-default-model fail-fast",
|
|
475
|
+
`registeredProviders lack ${defaultProvider} (count=${count}): ${label} ` +
|
|
476
|
+
`(session_start reason=${opts.reason ?? "?"})`,
|
|
477
|
+
);
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Abort if live already matches default or user switched mid-retry. */
|
|
482
|
+
const shouldAbortForLiveModel = (
|
|
483
|
+
live: { provider: string; id: string } | undefined,
|
|
484
|
+
attempt: number,
|
|
485
|
+
phase: "pre-resolve" | "pre-setModel",
|
|
486
|
+
): boolean => {
|
|
487
|
+
if (!live) return false;
|
|
488
|
+
if (live.provider === defaultProvider && live.id === defaultModel) {
|
|
489
|
+
log.debug(
|
|
490
|
+
"restore-default-model abort",
|
|
491
|
+
`already at default ${label} (${phase} attempt ${attempt}/${retries})`,
|
|
492
|
+
);
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
if (snapshot) {
|
|
496
|
+
if (live.provider !== snapshot.provider || live.id !== snapshot.id) {
|
|
497
|
+
log.debug(
|
|
498
|
+
"restore-default-model abort",
|
|
499
|
+
`current changed from session_start snapshot ` +
|
|
500
|
+
`${snapshot.provider}/${snapshot.id} → ${live.provider}/${live.id} ` +
|
|
501
|
+
`(${phase} attempt ${attempt}/${retries})`,
|
|
502
|
+
);
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
} else {
|
|
506
|
+
// Empty snapshot: first non-default live model is baseline, not abort.
|
|
507
|
+
// Only abort when live later *changes* from that mid-flight selection.
|
|
508
|
+
if (!firstSeenLive) {
|
|
509
|
+
firstSeenLive = live;
|
|
510
|
+
log.debug(
|
|
511
|
+
"restore-default-model baseline",
|
|
512
|
+
`no session_start snapshot; first live=${live.provider}/${live.id} ` +
|
|
513
|
+
`(${phase} attempt ${attempt}/${retries})`,
|
|
514
|
+
);
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
if (
|
|
518
|
+
live.provider !== firstSeenLive.provider ||
|
|
519
|
+
live.id !== firstSeenLive.id
|
|
520
|
+
) {
|
|
521
|
+
log.debug(
|
|
522
|
+
"restore-default-model abort",
|
|
523
|
+
`no session_start snapshot; live changed ` +
|
|
524
|
+
`${firstSeenLive.provider}/${firstSeenLive.id} → ${live.provider}/${live.id} ` +
|
|
525
|
+
`(${phase} attempt ${attempt}/${retries})`,
|
|
526
|
+
);
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return false;
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
534
|
+
if (typeof opts.getCurrentModel === "function") {
|
|
535
|
+
const live = trimModelRef(opts.getCurrentModel());
|
|
536
|
+
if (shouldAbortForLiveModel(live, attempt, "pre-resolve")) {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const model = resolveDefaultModel(
|
|
542
|
+
opts.ctx.modelRegistry,
|
|
543
|
+
defaultProvider!,
|
|
544
|
+
defaultModel!,
|
|
545
|
+
);
|
|
546
|
+
if (!model) {
|
|
547
|
+
lastFailReason = `model not resolvable: ${label}`;
|
|
548
|
+
log.debug(
|
|
549
|
+
"restore-default-model retry",
|
|
550
|
+
`${lastFailReason} (attempt ${attempt}/${retries})`,
|
|
551
|
+
);
|
|
552
|
+
if (attempt < retries) await sleep(delayMs);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// TOCTOU: re-check immediately before setModel after resolve.
|
|
557
|
+
if (typeof opts.getCurrentModel === "function") {
|
|
558
|
+
const liveBeforeSet = trimModelRef(opts.getCurrentModel());
|
|
559
|
+
if (shouldAbortForLiveModel(liveBeforeSet, attempt, "pre-setModel")) {
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
const ok = await opts.setModel(model);
|
|
566
|
+
if (ok) {
|
|
567
|
+
log.info(
|
|
568
|
+
"restore-default-model",
|
|
569
|
+
`restored ${label} ` +
|
|
570
|
+
`(was ${currentProvider ?? "none"}/${currentModelId ?? "none"}; ` +
|
|
571
|
+
`session_start reason=${opts.reason ?? "?"}; attempt ${attempt}/${retries})`,
|
|
572
|
+
);
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
lastFailReason = `setModel returned false for ${label}`;
|
|
576
|
+
log.debug(
|
|
577
|
+
"restore-default-model retry",
|
|
578
|
+
`${lastFailReason} (attempt ${attempt}/${retries})`,
|
|
579
|
+
);
|
|
580
|
+
} catch (err) {
|
|
581
|
+
lastFailReason = err instanceof Error ? err.message : String(err);
|
|
582
|
+
log.debug(
|
|
583
|
+
"restore-default-model retry",
|
|
584
|
+
`setModel threw: ${lastFailReason} (attempt ${attempt}/${retries})`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
if (attempt < retries) await sleep(delayMs);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
log.warn(
|
|
591
|
+
"restore-default-model exhausted",
|
|
592
|
+
`${lastFailReason} after ${retries} attempts ` +
|
|
593
|
+
`(session_start reason=${opts.reason ?? "?"})`,
|
|
594
|
+
);
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Providers session_shutdown teardown — clears instances and the init guard
|
|
3
|
+
* so the next factory invocation re-runs discovery + registerProvider (#752).
|
|
4
|
+
*
|
|
5
|
+
* Kept free of @earendil-works/pi-ai so unit tests can import it under tsx.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { stopCliSessionReaper } from "../cli-provider/session-reaper.js";
|
|
9
|
+
import { createLogger } from "../shared/logger.js";
|
|
10
|
+
import type { ProviderDriverRegistry } from "../shared/provider-registry.js";
|
|
11
|
+
import { resetProvidersInitialized } from "./initialized-guard.js";
|
|
12
|
+
|
|
13
|
+
const log = createLogger("providers");
|
|
14
|
+
|
|
15
|
+
export async function teardownProvidersOnSessionShutdown(
|
|
16
|
+
registry: ProviderDriverRegistry,
|
|
17
|
+
cliInstances: Map<string, unknown>,
|
|
18
|
+
acpxInstances: Map<string, unknown>,
|
|
19
|
+
): Promise<void> {
|
|
20
|
+
try {
|
|
21
|
+
stopCliSessionReaper();
|
|
22
|
+
await registry.teardownAll();
|
|
23
|
+
} catch (err) {
|
|
24
|
+
log.error(
|
|
25
|
+
"session_shutdown teardown failed",
|
|
26
|
+
err instanceof Error ? err.message : String(err),
|
|
27
|
+
);
|
|
28
|
+
throw err;
|
|
29
|
+
} finally {
|
|
30
|
+
// Always clear maps + init guard so a teardown failure cannot leave
|
|
31
|
+
// initialized stuck true (would skip registerProvider on /new|/resume|/fork).
|
|
32
|
+
cliInstances.clear();
|
|
33
|
+
acpxInstances.clear();
|
|
34
|
+
resetProvidersInitialized();
|
|
35
|
+
log.info(
|
|
36
|
+
"session_shutdown: reset initialized so providers re-register on next session",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local flag for whether P2P coms is active in this session.
|
|
3
|
+
* Kept in shared/ to avoid circular imports between orchestrator/rules and coms.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createLogger } from "./logger.js";
|
|
7
|
+
|
|
8
|
+
const log = createLogger("coms-active");
|
|
9
|
+
|
|
10
|
+
let active = false;
|
|
11
|
+
|
|
12
|
+
export function setComsActive(v: boolean): void {
|
|
13
|
+
if (active === v) {
|
|
14
|
+
log.debug("setComsActive no-op", `already ${v}`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
active = v;
|
|
18
|
+
log.info("coms active", String(v));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isComsActive(): boolean {
|
|
22
|
+
return active;
|
|
23
|
+
}
|
|
@@ -34,6 +34,7 @@ export interface BuildRuntimeModelOptions {
|
|
|
34
34
|
provider: string;
|
|
35
35
|
baseUrl?: string;
|
|
36
36
|
reasoning?: boolean;
|
|
37
|
+
thinkingLevelMap?: Model<Api>["thinkingLevelMap"];
|
|
37
38
|
input?: ("text" | "image")[];
|
|
38
39
|
cost?: Model<Api>["cost"];
|
|
39
40
|
contextWindow?: number;
|
|
@@ -49,6 +50,7 @@ export function buildRuntimeModel(opts: BuildRuntimeModelOptions): Model<Api> {
|
|
|
49
50
|
provider: opts.provider,
|
|
50
51
|
baseUrl: opts.baseUrl ?? DEFAULT_RUNTIME_BASE_URL,
|
|
51
52
|
reasoning: opts.reasoning ?? false,
|
|
53
|
+
...(opts.thinkingLevelMap ? { thinkingLevelMap: opts.thinkingLevelMap } : {}),
|
|
52
54
|
input: opts.input ?? ["text", "image"],
|
|
53
55
|
cost: opts.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
54
56
|
contextWindow: opts.contextWindow ?? 200_000,
|
|
@@ -14,14 +14,6 @@ import { fileURLToPath } from "node:url";
|
|
|
14
14
|
|
|
15
15
|
const HEALTH_CHECK_TIMEOUT_MS = 2000;
|
|
16
16
|
|
|
17
|
-
// ── Logging ─────────────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
export function createLogger(logPath: string, prefix: string) {
|
|
20
|
-
return (msg: string) => {
|
|
21
|
-
try { fs.appendFileSync(logPath, `${new Date().toISOString()} [${prefix}] ${msg}\n`); } catch {}
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
17
|
// ── Health check ────────────────────────────────────────────────────
|
|
26
18
|
|
|
27
19
|
export function checkHealth(port: number): Promise<boolean> {
|