@jmfederico/pi-web 1.202608.1 → 1.202608.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/dist/cli.js +220 -108
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/{CodeViewer-mEiBZFA5.js → CodeViewer-BMWwxG7q.js} +1 -1
- package/dist/client/assets/{TerminalPanel-O9381Eme.js → TerminalPanel-CacQDIYn.js} +1 -1
- package/dist/client/assets/{index-uI2AGFUy.js → index-DUW2xnoV.js} +486 -298
- package/dist/client/assets/vendor-editor-core-CXO8gGab.js +12 -0
- package/dist/client/assets/vendor-editor-languages-CpW4sJsX.js +46 -0
- package/dist/client/index.html +3 -3
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/environment.js +4 -0
- package/dist/environment.js.map +1 -0
- package/dist/nativeServices/installedServiceDefinitions.js +602 -0
- package/dist/nativeServices/installedServiceDefinitions.js.map +1 -0
- package/dist/nativeServices/serviceAction.js +174 -0
- package/dist/nativeServices/serviceAction.js.map +1 -0
- package/dist/nativeServices/serviceDoctor.js +374 -71
- package/dist/nativeServices/serviceDoctor.js.map +1 -1
- package/dist/{pi-web-plugins → pi-packages}/relays/markdownDocument.js +1 -1
- package/dist/pi-packages/relays/package.json +11 -0
- package/dist/{pi-web-plugins → pi-packages}/relays/pi-web-plugin.js +1 -1
- package/dist/pi-packages/relays/prompts/relay-worktree.md +157 -0
- package/dist/pi-packages/relays/prompts/relay.md +152 -0
- package/dist/{pi-web-plugins → pi-packages}/relays/relayDiscovery.js +1 -1
- package/dist/{pi-web-plugins → pi-packages}/relays/relaysPanelElement.js +1 -1
- package/dist/pi-packages/relays/skills/relay/SKILL.md +123 -0
- package/dist/pi-web-plugins/info/infoInternals.js +15 -0
- package/dist/piWebVersionReport.js +63 -10
- package/dist/piWebVersionReport.js.map +1 -1
- package/dist/server/app.js +6 -0
- package/dist/server/app.js.map +1 -1
- package/dist/server/knownAutoInstallPiPackages.js +30 -0
- package/dist/server/knownAutoInstallPiPackages.js.map +1 -0
- package/dist/server/machines/machineService.js +1 -0
- package/dist/server/machines/machineService.js.map +1 -1
- package/dist/server/piPackageIdentity.js +25 -0
- package/dist/server/piPackageIdentity.js.map +1 -0
- package/dist/server/piPackageService.js +69 -8
- package/dist/server/piPackageService.js.map +1 -1
- package/dist/server/piWebStatus.js +13 -2
- package/dist/server/piWebStatus.js.map +1 -1
- package/dist/server/projectTrustRoutes.js +80 -0
- package/dist/server/projectTrustRoutes.js.map +1 -0
- package/dist/server/projects/projectService.js +3 -1
- package/dist/server/projects/projectService.js.map +1 -1
- package/dist/server/sessiond/autoInstallPiPackages.js +63 -0
- package/dist/server/sessiond/autoInstallPiPackages.js.map +1 -0
- package/dist/server/sessiond.js +21 -1
- package/dist/server/sessiond.js.map +1 -1
- package/dist/server/sessions/piSessionService.js +358 -12
- package/dist/server/sessions/piSessionService.js.map +1 -1
- package/dist/server/sessions/sessionModelScope.js +160 -0
- package/dist/server/sessions/sessionModelScope.js.map +1 -0
- package/dist/server/sessions/sessionNameGenerator.js +6 -6
- package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
- package/dist/server/sessions/sessionRoutes.js +41 -0
- package/dist/server/sessions/sessionRoutes.js.map +1 -1
- package/dist/server/storage/piPackageDismissalStore.js +78 -0
- package/dist/server/storage/piPackageDismissalStore.js.map +1 -0
- package/dist/shared/apiTypes.js.map +1 -1
- package/dist/shared/federatedRoutes.js +8 -0
- package/dist/shared/federatedRoutes.js.map +1 -1
- package/dist/shared/piWebStatusParsing.js +4 -0
- package/dist/shared/piWebStatusParsing.js.map +1 -1
- package/dist/shared/pluginApiTypes.d.ts +2 -0
- package/docs/config.md +16 -1
- package/docs/plugins.md +20 -10
- package/package.json +2 -2
- package/dist/client/assets/vendor-editor-core-vUi74DnD.js +0 -12
- package/dist/client/assets/vendor-editor-languages-DoaXUodB.js +0 -46
- package/dist/pi-web-plugins/relays/package.json +0 -9
- /package/dist/{pi-web-plugins → pi-packages}/relays/vendor/README.md +0 -0
- /package/dist/{pi-web-plugins → pi-packages}/relays/vendor/marked.esm.js +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
-
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, readStoredCredential, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, hasTrustRequiringProjectResources, ProjectTrustStore, readStoredCredential, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { projectBrowserMessage } from "../browserMessageProjection.js";
|
|
6
6
|
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
|
7
7
|
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
|
@@ -28,6 +28,7 @@ import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionC
|
|
|
28
28
|
import { SessionNotificationStore, } from "./sessionNotificationStore.js";
|
|
29
29
|
import { plainTextTheme } from "./plainTextTheme.js";
|
|
30
30
|
import { SessionUnreadStore } from "./sessionUnreadStore.js";
|
|
31
|
+
import { applyEnabledModelToggle, catalogWithEnabledFirst, modelScopeId, persistedEnabledModelPatterns, resolveEnabledModelIds, resolveSessionModelOptions, scopedModelsFromEnabledIds } from "./sessionModelScope.js";
|
|
31
32
|
const noopLogger = { info() { } };
|
|
32
33
|
const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
|
|
33
34
|
/**
|
|
@@ -281,13 +282,130 @@ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn,
|
|
|
281
282
|
...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
|
|
282
283
|
];
|
|
283
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* Run the `project_trust` event over a pre-trust extension set, mirroring
|
|
287
|
+
* `emitProjectTrustEvent` in the SDK's `dist/core/extensions/runner.js`. That
|
|
288
|
+
* helper is not part of the package's public exports (the main index exports
|
|
289
|
+
* only the `ProjectTrust*` types and `ProjectTrustStore`, and the package's
|
|
290
|
+
* `exports` map blocks subpath imports, so `resolveProjectTrusted`/
|
|
291
|
+
* `emitProjectTrustEvent` are not callable from here), so PI WEB reimplements
|
|
292
|
+
* its documented decision loop over the SDK-provided extension objects: per
|
|
293
|
+
* extension, the registered `project_trust` handlers run in order; the first
|
|
294
|
+
* handler returning `yes`/`no` decides and `undecided` falls through to the
|
|
295
|
+
* next handler/extension; a throwing handler is collected as an error and
|
|
296
|
+
* later handlers still get their chance.
|
|
297
|
+
*/
|
|
298
|
+
export async function emitWebProjectTrustEvent(extensionsResult, event, ctx) {
|
|
299
|
+
const errors = [];
|
|
300
|
+
for (const extension of extensionsResult.extensions) {
|
|
301
|
+
// A single extension may register multiple handlers for the same event.
|
|
302
|
+
// The handlers map is keyed exactly as the extension registered it, so a
|
|
303
|
+
// `project_trust` key guarantees `ProjectTrustHandler` entries — the same
|
|
304
|
+
// assumption the SDK's emitProjectTrustEvent makes.
|
|
305
|
+
const handlers = extension.handlers.get("project_trust");
|
|
306
|
+
if (handlers === undefined || handlers.length === 0)
|
|
307
|
+
continue;
|
|
308
|
+
for (const handler of handlers) {
|
|
309
|
+
try {
|
|
310
|
+
const handlerResult = await handler(event, ctx);
|
|
311
|
+
// The SDK reads `trusted` straight off the handler result, so a
|
|
312
|
+
// non-object would throw there; PI WEB reports it as a handler error
|
|
313
|
+
// and lets the next handler/extension try.
|
|
314
|
+
if (typeof handlerResult !== "object" || handlerResult === null) {
|
|
315
|
+
errors.push({ extensionPath: extension.path, error: "project_trust handler returned a non-object result" });
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const trusted = "trusted" in handlerResult ? handlerResult.trusted : undefined;
|
|
319
|
+
if (trusted === "undecided") {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
// Rebuild the decision so only the documented `trusted`/`remember`
|
|
323
|
+
// fields carry over — the SDK's resolver reads exactly those two.
|
|
324
|
+
const remember = "remember" in handlerResult ? handlerResult.remember : undefined;
|
|
325
|
+
return {
|
|
326
|
+
result: {
|
|
327
|
+
trusted: trusted === "yes" ? "yes" : "no",
|
|
328
|
+
...(remember === true ? { remember: true } : {}),
|
|
329
|
+
},
|
|
330
|
+
errors,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
errors.push({
|
|
335
|
+
extensionPath: extension.path,
|
|
336
|
+
error: error instanceof Error ? error.message : String(error),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return { errors };
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* PI WEB's headless project-trust context. `hasUI` is false because there is
|
|
345
|
+
* no browser trust prompt (chartered behavior: `ask` never loads untrusted
|
|
346
|
+
* resources), so the UI methods are inert — the same no-UI shape the SDK
|
|
347
|
+
* passes when pi runs without a trust UI. The host mode is `rpc`, mirroring
|
|
348
|
+
* how PI WEB binds its session extension contexts.
|
|
349
|
+
*/
|
|
350
|
+
function webProjectTrustContext(cwd) {
|
|
351
|
+
return {
|
|
352
|
+
cwd,
|
|
353
|
+
mode: "rpc",
|
|
354
|
+
hasUI: false,
|
|
355
|
+
ui: {
|
|
356
|
+
select: () => Promise.resolve(undefined),
|
|
357
|
+
confirm: () => Promise.resolve(false),
|
|
358
|
+
input: () => Promise.resolve(undefined),
|
|
359
|
+
notify: () => undefined,
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Resolve whether a workspace's project-local `.pi/` resources may load, the
|
|
365
|
+
* way `pi` resolves it — a faithful mirror of the SDK's `resolveProjectTrusted`
|
|
366
|
+
* (`dist/core/project-trust.js`, also not a public export). PI WEB has no
|
|
367
|
+
* browser trust prompt, so the precedence is, in order:
|
|
368
|
+
*
|
|
369
|
+
* 1. Nothing trust-requiring under `cwd` → trusted.
|
|
370
|
+
* 2. Pre-trust extensions (user/global — project-local ones are not loaded
|
|
371
|
+
* yet) may decide via the `project_trust` event; `remember: true` persists
|
|
372
|
+
* the decision to the agent dir's `trust.json`. Handler errors are reported
|
|
373
|
+
* through {@link WebProjectTrustResolution.onExtensionError} and never
|
|
374
|
+
* abort resolution.
|
|
375
|
+
* 3. Otherwise the saved `trust.json` decision wins.
|
|
376
|
+
* 4. Otherwise `defaultProjectTrust` decides (`always` trusts; `never`/`ask`
|
|
377
|
+
* do not — `ask` cannot prompt in the browser, matching `pi` run without a
|
|
378
|
+
* trust UI).
|
|
379
|
+
*/
|
|
380
|
+
export async function resolveWebProjectTrusted(resolution) {
|
|
381
|
+
const { cwd, trustStore, settingsManager } = resolution;
|
|
382
|
+
if (!hasTrustRequiringProjectResources(cwd))
|
|
383
|
+
return true;
|
|
384
|
+
if (resolution.extensionsResult) {
|
|
385
|
+
const { result, errors } = await emitWebProjectTrustEvent(resolution.extensionsResult, { type: "project_trust", cwd }, webProjectTrustContext(cwd));
|
|
386
|
+
for (const error of errors) {
|
|
387
|
+
resolution.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
|
|
388
|
+
}
|
|
389
|
+
if (result) {
|
|
390
|
+
const trusted = result.trusted === "yes";
|
|
391
|
+
if (result.remember === true) {
|
|
392
|
+
trustStore.set(cwd, trusted);
|
|
393
|
+
}
|
|
394
|
+
return trusted;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const saved = trustStore.get(cwd);
|
|
398
|
+
if (saved !== null)
|
|
399
|
+
return saved;
|
|
400
|
+
return settingsManager.getDefaultProjectTrust() === "always";
|
|
401
|
+
}
|
|
284
402
|
/**
|
|
285
403
|
* Resource-loader options that append PI WEB's own system-prompt sections.
|
|
286
404
|
*
|
|
287
405
|
* `appendSystemPromptOverride` composes with what the loader already resolved,
|
|
288
406
|
* so the operator's `SYSTEM.md` / `APPEND_SYSTEM.md` files keep their content
|
|
289
407
|
* and PI WEB's sections land after them. Returns `undefined` when there is
|
|
290
|
-
* nothing to
|
|
408
|
+
* nothing to append, leaving the loader exactly as pi configures it.
|
|
291
409
|
*/
|
|
292
410
|
export function piWebResourceLoaderOptions(appendSystemPromptSections) {
|
|
293
411
|
if (appendSystemPromptSections.length === 0)
|
|
@@ -297,12 +415,49 @@ export function piWebResourceLoaderOptions(appendSystemPromptSections) {
|
|
|
297
415
|
function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser, appendSystemPromptSections = []) {
|
|
298
416
|
const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
|
|
299
417
|
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
|
|
418
|
+
// PI WEB always honors pi's project-trust model. When the workspace ships
|
|
419
|
+
// trust-requiring resources, trust is resolved exactly once, mirroring the
|
|
420
|
+
// SDK's flow: the resource loader first loads the pre-trust extension set
|
|
421
|
+
// (user/global; project-local ones stay out) and calls back with it, so
|
|
422
|
+
// those extensions may decide via the `project_trust` event; the resolved
|
|
423
|
+
// value then lands in the SettingsManager before any project-local
|
|
424
|
+
// resource (extensions, packages, settings, prompts) loads. With no
|
|
425
|
+
// browser trust prompt, an untrusted project's resources are skipped
|
|
426
|
+
// (matching `pi` run without a UI). Projects without trust-requiring
|
|
427
|
+
// resources skip resolution entirely and are trusted, as before.
|
|
428
|
+
const projectTrustRequiring = hasTrustRequiringProjectResources(cwd);
|
|
429
|
+
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: !projectTrustRequiring });
|
|
430
|
+
// Pre-session-creation trust failures (`project_trust` handler errors)
|
|
431
|
+
// land in the runtime diagnostics next to the services diagnostics,
|
|
432
|
+
// exactly as the CLI appends its project-trust diagnostics.
|
|
433
|
+
const projectTrustDiagnostics = [];
|
|
300
434
|
const services = await createAgentSessionServices({
|
|
301
435
|
cwd,
|
|
302
436
|
agentDir,
|
|
303
437
|
modelRuntime,
|
|
438
|
+
settingsManager,
|
|
304
439
|
...(resourceLoaderOptions === undefined ? {} : { resourceLoaderOptions }),
|
|
440
|
+
...(projectTrustRequiring
|
|
441
|
+
? {
|
|
442
|
+
resourceLoaderReloadOptions: {
|
|
443
|
+
resolveProjectTrust: async ({ extensionsResult }) => resolveWebProjectTrusted({
|
|
444
|
+
cwd,
|
|
445
|
+
trustStore: new ProjectTrustStore(agentDir),
|
|
446
|
+
settingsManager,
|
|
447
|
+
extensionsResult,
|
|
448
|
+
onExtensionError: (message) => projectTrustDiagnostics.push({ type: "warning", message }),
|
|
449
|
+
}),
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
: {}),
|
|
305
453
|
});
|
|
454
|
+
const modelOptions = await resolveSessionModelOptions({
|
|
455
|
+
services,
|
|
456
|
+
hasExistingSession: sessionManager.buildSessionContext().messages.length > 0,
|
|
457
|
+
...(initialModel === undefined ? {} : { initialModel }),
|
|
458
|
+
...(initialThinkingLevel === undefined ? {} : { initialThinkingLevel }),
|
|
459
|
+
});
|
|
460
|
+
services.diagnostics.push(...modelOptions.diagnostics);
|
|
306
461
|
const resolvedDelegationToolsEnabled = delegationToolsEnabled
|
|
307
462
|
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
|
308
463
|
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
|
|
@@ -311,10 +466,11 @@ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subse
|
|
|
311
466
|
sessionManager,
|
|
312
467
|
customTools,
|
|
313
468
|
...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
|
|
314
|
-
...(
|
|
315
|
-
...(
|
|
469
|
+
...(modelOptions.model === undefined ? {} : { model: modelOptions.model }),
|
|
470
|
+
...(modelOptions.thinkingLevel === undefined ? {} : { thinkingLevel: modelOptions.thinkingLevel }),
|
|
471
|
+
...(modelOptions.scopedModels.length === 0 ? {} : { scopedModels: modelOptions.scopedModels }),
|
|
316
472
|
});
|
|
317
|
-
return { ...result, services, diagnostics: services.diagnostics };
|
|
473
|
+
return { ...result, services, diagnostics: [...projectTrustDiagnostics, ...services.diagnostics] };
|
|
318
474
|
};
|
|
319
475
|
}
|
|
320
476
|
function createPiWebEditToolDefinition(cwd) {
|
|
@@ -356,6 +512,16 @@ export class PiSessionService {
|
|
|
356
512
|
this.treeNavigations = new WeakSet();
|
|
357
513
|
/** Counts async operations that may append an entry before they settle. */
|
|
358
514
|
this.sessionEntryMutationCounts = new WeakMap();
|
|
515
|
+
/** Settings-wide queue preventing enabled-model read/modify/write races across sessions. */
|
|
516
|
+
this.modelScopeMutationQueue = Promise.resolve();
|
|
517
|
+
/** Global and workspace-owned enabled-model states; live session scopes project one entry lazily. */
|
|
518
|
+
this.modelScopeStates = new Map();
|
|
519
|
+
/** Coalesces initialization reads independently for the global and each workspace scope. */
|
|
520
|
+
this.modelScopeInitializations = new Map();
|
|
521
|
+
/** Monotonic revision published for global scope changes and consumed by clients. */
|
|
522
|
+
this.modelScopeRevision = 0;
|
|
523
|
+
/** Last scope/catalog revision projected into each runtime, synchronized lazily on use. */
|
|
524
|
+
this.modelScopeCache = new WeakMap();
|
|
359
525
|
/** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
|
|
360
526
|
this.treeExclusiveRuntimeOperationCounts = new WeakMap();
|
|
361
527
|
this.treeExclusiveSessionOperationCounts = new Map();
|
|
@@ -678,17 +844,117 @@ export class PiSessionService {
|
|
|
678
844
|
};
|
|
679
845
|
}
|
|
680
846
|
/**
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
|
|
847
|
+
* Resolve the settings domain for a session. Pi's settings manager deep-merges
|
|
848
|
+
* objects, but an `enabledModels` array in `.pi/settings.json` replaces the
|
|
849
|
+
* global array, so that workspace gets its own effective scope and is not
|
|
850
|
+
* affected by global picker edits.
|
|
851
|
+
*/
|
|
852
|
+
modelScopeContext(session) {
|
|
853
|
+
const projectSettings = session.settingsManager.getProjectSettings?.() ?? {};
|
|
854
|
+
if (!Object.hasOwn(projectSettings, "enabledModels")) {
|
|
855
|
+
return { key: "global", source: "global", editable: true };
|
|
856
|
+
}
|
|
857
|
+
const workspace = canonicalizeStoredCwd(session.sessionManager.getCwd());
|
|
858
|
+
return { key: `workspace:${workspace}`, source: "workspace", editable: false };
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Read the effective scope for one settings domain. Global sessions share one
|
|
862
|
+
* snapshot; project-overridden sessions share a snapshot keyed by workspace.
|
|
863
|
+
* Neither path enumerates or mutates other active sessions.
|
|
864
|
+
*/
|
|
865
|
+
async modelScopeSnapshot(session, context = this.modelScopeContext(session)) {
|
|
866
|
+
const existing = this.modelScopeStates.get(context.key);
|
|
867
|
+
if (existing !== undefined)
|
|
868
|
+
return existing;
|
|
869
|
+
const pending = this.modelScopeInitializations.get(context.key);
|
|
870
|
+
if (pending !== undefined)
|
|
871
|
+
return pending;
|
|
872
|
+
const initialization = (async () => {
|
|
873
|
+
const enabledIds = await resolveEnabledModelIds(sessionScopeSource(session));
|
|
874
|
+
const snapshot = {
|
|
875
|
+
enabledIds: enabledIds === null ? null : [...enabledIds],
|
|
876
|
+
revision: 0,
|
|
877
|
+
};
|
|
878
|
+
this.modelScopeStates.set(context.key, snapshot);
|
|
879
|
+
return snapshot;
|
|
880
|
+
})();
|
|
881
|
+
this.modelScopeInitializations.set(context.key, initialization);
|
|
882
|
+
try {
|
|
883
|
+
return await initialization;
|
|
884
|
+
}
|
|
885
|
+
finally {
|
|
886
|
+
if (this.modelScopeInitializations.get(context.key) === initialization)
|
|
887
|
+
this.modelScopeInitializations.delete(context.key);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Project one settings-domain scope into one SDK session only when that
|
|
892
|
+
* session is used. `PiAgentSession` needs an in-memory array for cycling, but
|
|
893
|
+
* that array is a cache; the keyed snapshot above is the source of truth.
|
|
894
|
+
*/
|
|
895
|
+
async synchronizeSessionModelScope(session, available) {
|
|
896
|
+
const context = this.modelScopeContext(session);
|
|
897
|
+
const snapshot = await this.modelScopeSnapshot(session, context);
|
|
898
|
+
const catalogKey = available.map(modelScopeId).join("\0");
|
|
899
|
+
const cached = this.modelScopeCache.get(session);
|
|
900
|
+
if (cached?.scopeKey === context.key && cached.revision === snapshot.revision && cached.catalogKey === catalogKey)
|
|
901
|
+
return;
|
|
902
|
+
session.setScopedModels(scopedModelsFromEnabledIds(available, snapshot.enabledIds, session.scopedModels));
|
|
903
|
+
this.modelScopeCache.set(session, { scopeKey: context.key, revision: snapshot.revision, catalogKey });
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* The models a session may pick from: its synchronized scoped set when
|
|
907
|
+
* model-scoped, otherwise the runtime's available snapshot. Refreshes the
|
|
908
|
+
* shared runtime catalog first so callers see newly configured providers and
|
|
909
|
+
* models. The refresh stays local (`allowNetwork: false`); network refreshes
|
|
910
|
+
* belong to the bounded background catalog refresher, not this request path.
|
|
686
911
|
*/
|
|
687
912
|
async sessionModelCandidates(session) {
|
|
688
913
|
await session.modelRuntime.refresh({ allowNetwork: false });
|
|
914
|
+
const available = session.modelRuntime.getAvailableSnapshot();
|
|
915
|
+
await this.synchronizeSessionModelScope(session, available);
|
|
689
916
|
return session.scopedModels.length > 0
|
|
690
917
|
? session.scopedModels.map((scoped) => scoped.model)
|
|
691
|
-
:
|
|
918
|
+
: available;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* The session machine's full available catalog with per-model enabled state,
|
|
922
|
+
* ordered enabled-first for scope semantics while retaining each model's
|
|
923
|
+
* natural catalog index for stable picker placement. The enabled state comes
|
|
924
|
+
* from the keyed scope snapshot rather than a session-local SettingsManager.
|
|
925
|
+
*/
|
|
926
|
+
async enabledModelCatalog(session) {
|
|
927
|
+
await session.modelRuntime.refresh({ allowNetwork: false });
|
|
928
|
+
const available = session.modelRuntime.getAvailableSnapshot();
|
|
929
|
+
await this.synchronizeSessionModelScope(session, available);
|
|
930
|
+
const context = this.modelScopeContext(session);
|
|
931
|
+
const enabledIds = (await this.modelScopeSnapshot(session, context)).enabledIds;
|
|
932
|
+
return catalogWithEnabledFirst(available, enabledIds);
|
|
933
|
+
}
|
|
934
|
+
runModelScopeMutation(operation) {
|
|
935
|
+
const queued = this.modelScopeMutationQueue.then(operation);
|
|
936
|
+
this.modelScopeMutationQueue = queued.then(() => undefined, () => undefined);
|
|
937
|
+
return queued;
|
|
938
|
+
}
|
|
939
|
+
/** Persist one global enabled-model selection and publish its new revision. */
|
|
940
|
+
applyEnabledModelScope(session, available, enabledIds) {
|
|
941
|
+
const availableIds = available.map(modelScopeId);
|
|
942
|
+
const persisted = persistedEnabledModelPatterns(enabledIds, availableIds);
|
|
943
|
+
const nextState = {
|
|
944
|
+
// Pi normalizes a scope covering the complete current catalog to an
|
|
945
|
+
// omitted setting, which is the global canonical all-enabled state.
|
|
946
|
+
enabledIds: persisted === undefined ? null : enabledIds === null ? [] : [...enabledIds],
|
|
947
|
+
revision: ++this.modelScopeRevision,
|
|
948
|
+
};
|
|
949
|
+
session.settingsManager.setEnabledModels(persisted);
|
|
950
|
+
this.modelScopeStates.set("global", nextState);
|
|
951
|
+
session.setScopedModels(scopedModelsFromEnabledIds(available, nextState.enabledIds, session.scopedModels));
|
|
952
|
+
this.modelScopeCache.set(session, {
|
|
953
|
+
scopeKey: "global",
|
|
954
|
+
revision: nextState.revision,
|
|
955
|
+
catalogKey: available.map(modelScopeId).join("\0"),
|
|
956
|
+
});
|
|
957
|
+
this.events.publishGlobal({ type: "models.changed", revision: nextState.revision });
|
|
692
958
|
}
|
|
693
959
|
/**
|
|
694
960
|
* Resolve a strict `provider/model-id` spec from a spawn tool against the
|
|
@@ -1283,6 +1549,68 @@ export class PiSessionService {
|
|
|
1283
1549
|
const models = await this.sessionModelCandidates(session);
|
|
1284
1550
|
return models.map(modelToClientModel);
|
|
1285
1551
|
}
|
|
1552
|
+
async modelCatalog(ref) {
|
|
1553
|
+
const session = await this.getOrOpen(ref);
|
|
1554
|
+
const scope = this.modelScopeContext(session);
|
|
1555
|
+
return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry, scope.editable));
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Add/remove one model to/from the global pi `enabledModels` scope. A
|
|
1559
|
+
* workspace `.pi/settings.json` override remains authoritative for that
|
|
1560
|
+
* workspace and is intentionally read-only in this picker.
|
|
1561
|
+
*/
|
|
1562
|
+
async setModelEnabled(ref, provider, modelId, enabled) {
|
|
1563
|
+
await this.assertWritable(ref);
|
|
1564
|
+
const session = await this.getOrOpen(ref);
|
|
1565
|
+
return this.runModelScopeMutation(async () => {
|
|
1566
|
+
const scope = this.modelScopeContext(session);
|
|
1567
|
+
if (scope.source !== "global")
|
|
1568
|
+
throw new Error("Model availability is controlled by this workspace's .pi/settings.json");
|
|
1569
|
+
this.assertTreeNavigationInactive(session, "change enabled models");
|
|
1570
|
+
await session.modelRuntime.refresh({ allowNetwork: false });
|
|
1571
|
+
const currentIds = (await this.modelScopeSnapshot(session, scope)).enabledIds;
|
|
1572
|
+
const available = session.modelRuntime.getAvailableSnapshot();
|
|
1573
|
+
const availableIds = available.map(modelScopeId);
|
|
1574
|
+
const targetId = `${provider}/${modelId}`;
|
|
1575
|
+
if (!availableIds.includes(targetId))
|
|
1576
|
+
throw new Error(`Model not found: ${targetId}`);
|
|
1577
|
+
if (!enabled && session.model !== undefined && modelScopeId(session.model) === targetId) {
|
|
1578
|
+
throw new Error("Current model cannot be disabled");
|
|
1579
|
+
}
|
|
1580
|
+
const nextIds = applyEnabledModelToggle(currentIds, availableIds, targetId, enabled);
|
|
1581
|
+
this.assertTreeNavigationInactive(session, "change enabled models");
|
|
1582
|
+
if (nextIds !== currentIds)
|
|
1583
|
+
this.applyEnabledModelScope(session, available, nextIds);
|
|
1584
|
+
// Respond from a fresh post-edit read so the response is exactly what
|
|
1585
|
+
// GET models/catalog returns after the edit, including pi's normalizations.
|
|
1586
|
+
return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry));
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
/** Atomically expose every model or narrow the global enabled scope to the current model. */
|
|
1590
|
+
async setModelScope(ref, mode) {
|
|
1591
|
+
await this.assertWritable(ref);
|
|
1592
|
+
const session = await this.getOrOpen(ref);
|
|
1593
|
+
return this.runModelScopeMutation(async () => {
|
|
1594
|
+
const scope = this.modelScopeContext(session);
|
|
1595
|
+
if (scope.source !== "global")
|
|
1596
|
+
throw new Error("Model availability is controlled by this workspace's .pi/settings.json");
|
|
1597
|
+
this.assertTreeNavigationInactive(session, "change enabled models");
|
|
1598
|
+
await session.modelRuntime.refresh({ allowNetwork: false });
|
|
1599
|
+
const available = session.modelRuntime.getAvailableSnapshot();
|
|
1600
|
+
const availableIds = available.map(modelScopeId);
|
|
1601
|
+
let nextIds = null;
|
|
1602
|
+
if (mode === "current") {
|
|
1603
|
+
const current = session.model;
|
|
1604
|
+
if (current === undefined || !availableIds.includes(modelScopeId(current))) {
|
|
1605
|
+
throw new Error("Current model is unavailable");
|
|
1606
|
+
}
|
|
1607
|
+
nextIds = [modelScopeId(current)];
|
|
1608
|
+
}
|
|
1609
|
+
this.assertTreeNavigationInactive(session, "change enabled models");
|
|
1610
|
+
this.applyEnabledModelScope(session, available, nextIds);
|
|
1611
|
+
return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry));
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1286
1614
|
async setModel(ref, provider, modelId) {
|
|
1287
1615
|
await this.assertWritable(ref);
|
|
1288
1616
|
const session = await this.getOrOpen(ref);
|
|
@@ -1301,7 +1629,11 @@ export class PiSessionService {
|
|
|
1301
1629
|
async cycleModel(ref, direction) {
|
|
1302
1630
|
await this.assertWritable(ref);
|
|
1303
1631
|
const session = await this.getOrOpen(ref);
|
|
1304
|
-
const result = await this.
|
|
1632
|
+
const result = await this.runModelScopeMutation(async () => {
|
|
1633
|
+
await session.modelRuntime.refresh({ allowNetwork: false });
|
|
1634
|
+
await this.synchronizeSessionModelScope(session, session.modelRuntime.getAvailableSnapshot());
|
|
1635
|
+
return this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
|
|
1636
|
+
});
|
|
1305
1637
|
if (result === undefined)
|
|
1306
1638
|
throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
|
|
1307
1639
|
this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
|
|
@@ -3040,6 +3372,20 @@ function modelToClientModel(model) {
|
|
|
3040
3372
|
...(reasoning === undefined ? {} : { reasoning }),
|
|
3041
3373
|
};
|
|
3042
3374
|
}
|
|
3375
|
+
/** The scope source pi's enabled-id precedence reads from: live scope, settings patterns, runtime catalog. */
|
|
3376
|
+
function sessionScopeSource(session) {
|
|
3377
|
+
return { settingsManager: session.settingsManager, modelRuntime: session.modelRuntime, scopedModels: session.scopedModels };
|
|
3378
|
+
}
|
|
3379
|
+
function catalogEntryToClientModel(entry, editable = true) {
|
|
3380
|
+
return {
|
|
3381
|
+
...modelToClientModel(entry.model),
|
|
3382
|
+
provider: entry.model.provider,
|
|
3383
|
+
id: entry.model.id,
|
|
3384
|
+
enabled: entry.enabled,
|
|
3385
|
+
catalogIndex: entry.catalogIndex,
|
|
3386
|
+
...(editable ? {} : { editable: false }),
|
|
3387
|
+
};
|
|
3388
|
+
}
|
|
3043
3389
|
function notificationIdentityForSession(session) {
|
|
3044
3390
|
return {
|
|
3045
3391
|
sessionId: session.sessionId,
|