@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.
Files changed (73) hide show
  1. package/dist/cli.js +220 -108
  2. package/dist/cli.js.map +1 -1
  3. package/dist/client/assets/{CodeViewer-mEiBZFA5.js → CodeViewer-BMWwxG7q.js} +1 -1
  4. package/dist/client/assets/{TerminalPanel-O9381Eme.js → TerminalPanel-CacQDIYn.js} +1 -1
  5. package/dist/client/assets/{index-uI2AGFUy.js → index-DUW2xnoV.js} +486 -298
  6. package/dist/client/assets/vendor-editor-core-CXO8gGab.js +12 -0
  7. package/dist/client/assets/vendor-editor-languages-CpW4sJsX.js +46 -0
  8. package/dist/client/index.html +3 -3
  9. package/dist/config.js +1 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/environment.js +4 -0
  12. package/dist/environment.js.map +1 -0
  13. package/dist/nativeServices/installedServiceDefinitions.js +602 -0
  14. package/dist/nativeServices/installedServiceDefinitions.js.map +1 -0
  15. package/dist/nativeServices/serviceAction.js +174 -0
  16. package/dist/nativeServices/serviceAction.js.map +1 -0
  17. package/dist/nativeServices/serviceDoctor.js +374 -71
  18. package/dist/nativeServices/serviceDoctor.js.map +1 -1
  19. package/dist/{pi-web-plugins → pi-packages}/relays/markdownDocument.js +1 -1
  20. package/dist/pi-packages/relays/package.json +11 -0
  21. package/dist/{pi-web-plugins → pi-packages}/relays/pi-web-plugin.js +1 -1
  22. package/dist/pi-packages/relays/prompts/relay-worktree.md +157 -0
  23. package/dist/pi-packages/relays/prompts/relay.md +152 -0
  24. package/dist/{pi-web-plugins → pi-packages}/relays/relayDiscovery.js +1 -1
  25. package/dist/{pi-web-plugins → pi-packages}/relays/relaysPanelElement.js +1 -1
  26. package/dist/pi-packages/relays/skills/relay/SKILL.md +123 -0
  27. package/dist/pi-web-plugins/info/infoInternals.js +15 -0
  28. package/dist/piWebVersionReport.js +63 -10
  29. package/dist/piWebVersionReport.js.map +1 -1
  30. package/dist/server/app.js +6 -0
  31. package/dist/server/app.js.map +1 -1
  32. package/dist/server/knownAutoInstallPiPackages.js +30 -0
  33. package/dist/server/knownAutoInstallPiPackages.js.map +1 -0
  34. package/dist/server/machines/machineService.js +1 -0
  35. package/dist/server/machines/machineService.js.map +1 -1
  36. package/dist/server/piPackageIdentity.js +25 -0
  37. package/dist/server/piPackageIdentity.js.map +1 -0
  38. package/dist/server/piPackageService.js +69 -8
  39. package/dist/server/piPackageService.js.map +1 -1
  40. package/dist/server/piWebStatus.js +13 -2
  41. package/dist/server/piWebStatus.js.map +1 -1
  42. package/dist/server/projectTrustRoutes.js +80 -0
  43. package/dist/server/projectTrustRoutes.js.map +1 -0
  44. package/dist/server/projects/projectService.js +3 -1
  45. package/dist/server/projects/projectService.js.map +1 -1
  46. package/dist/server/sessiond/autoInstallPiPackages.js +63 -0
  47. package/dist/server/sessiond/autoInstallPiPackages.js.map +1 -0
  48. package/dist/server/sessiond.js +21 -1
  49. package/dist/server/sessiond.js.map +1 -1
  50. package/dist/server/sessions/piSessionService.js +358 -12
  51. package/dist/server/sessions/piSessionService.js.map +1 -1
  52. package/dist/server/sessions/sessionModelScope.js +160 -0
  53. package/dist/server/sessions/sessionModelScope.js.map +1 -0
  54. package/dist/server/sessions/sessionNameGenerator.js +6 -6
  55. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  56. package/dist/server/sessions/sessionRoutes.js +41 -0
  57. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  58. package/dist/server/storage/piPackageDismissalStore.js +78 -0
  59. package/dist/server/storage/piPackageDismissalStore.js.map +1 -0
  60. package/dist/shared/apiTypes.js.map +1 -1
  61. package/dist/shared/federatedRoutes.js +8 -0
  62. package/dist/shared/federatedRoutes.js.map +1 -1
  63. package/dist/shared/piWebStatusParsing.js +4 -0
  64. package/dist/shared/piWebStatusParsing.js.map +1 -1
  65. package/dist/shared/pluginApiTypes.d.ts +2 -0
  66. package/docs/config.md +16 -1
  67. package/docs/plugins.md +20 -10
  68. package/package.json +2 -2
  69. package/dist/client/assets/vendor-editor-core-vUi74DnD.js +0 -12
  70. package/dist/client/assets/vendor-editor-languages-DoaXUodB.js +0 -46
  71. package/dist/pi-web-plugins/relays/package.json +0 -9
  72. /package/dist/{pi-web-plugins → pi-packages}/relays/vendor/README.md +0 -0
  73. /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 add, leaving the loader exactly as pi configures it.
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
- ...(initialModel === undefined ? {} : { model: initialModel }),
315
- ...(initialThinkingLevel === undefined ? {} : { thinkingLevel: initialThinkingLevel }),
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
- * The models a session may pick from: its scoped set when model-scoped,
682
- * otherwise the runtime's available snapshot. Refreshes the runtime catalog
683
- * first so callers see newly configured providers and models. The refresh
684
- * stays local (`allowNetwork: false`); network refreshes belong to the
685
- * bounded background catalog refresher, not this request path.
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
- : session.modelRuntime.getAvailableSnapshot();
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.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
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,