@chainingintention/pi-web-cn 1.202609.0 → 1.202609.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 (100) hide show
  1. package/dist/cli.js +24 -14
  2. package/dist/cli.js.map +1 -1
  3. package/dist/client/apple-touch-icon-dev.png +0 -0
  4. package/dist/client/assets/{CodeViewer-gfeiLsvJ.js → CodeViewer-CY319sHK.js} +1 -1
  5. package/dist/client/assets/{TerminalPanel-CF7TJrJF.js → TerminalPanel-DvFd2ZkS.js} +1 -1
  6. package/dist/client/assets/{index-Y2sFZ4_U.js → index-Lt_wrMvn.js} +423 -359
  7. package/dist/client/favicon-dev.svg +8 -0
  8. package/dist/client/index.html +1 -1
  9. package/dist/client/pwa-icon-dev-192.png +0 -0
  10. package/dist/client/pwa-icon-dev-512.png +0 -0
  11. package/dist/config.js +14 -0
  12. package/dist/config.js.map +1 -1
  13. package/dist/environment.js +4 -0
  14. package/dist/environment.js.map +1 -0
  15. package/dist/nativeServices/installedServiceDefinitions.js +601 -0
  16. package/dist/nativeServices/installedServiceDefinitions.js.map +1 -0
  17. package/dist/nativeServices/serviceDoctor.js +47 -16
  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 +28 -0
  23. package/dist/pi-packages/relays/prompts/relay.md +45 -0
  24. package/dist/{pi-web-plugins → pi-packages}/relays/relayDiscovery.js +2 -2
  25. package/dist/{pi-web-plugins → pi-packages}/relays/relaysPanelElement.js +2 -2
  26. package/dist/pi-packages/relays/skills/relay/SKILL.md +94 -0
  27. package/dist/pi-packages/relays/skills/relay-runner/SKILL.md +245 -0
  28. package/dist/pi-web-plugins/updates/pi-web-plugin.js +34 -36
  29. package/dist/server/app.js +14 -3
  30. package/dist/server/app.js.map +1 -1
  31. package/dist/server/configRoutes.js +6 -1
  32. package/dist/server/configRoutes.js.map +1 -1
  33. package/dist/server/deploymentIdentity.js +67 -0
  34. package/dist/server/deploymentIdentity.js.map +1 -0
  35. package/dist/server/deploymentIdentityRoutes.js +22 -0
  36. package/dist/server/deploymentIdentityRoutes.js.map +1 -0
  37. package/dist/server/knownAutoInstallPiPackages.js +30 -0
  38. package/dist/server/knownAutoInstallPiPackages.js.map +1 -0
  39. package/dist/server/managementEmbed.js +9 -0
  40. package/dist/server/managementEmbed.js.map +1 -1
  41. package/dist/server/notices/serverNoticeRoutes.js +43 -0
  42. package/dist/server/notices/serverNoticeRoutes.js.map +1 -0
  43. package/dist/server/notices/serverNoticeService.js +26 -0
  44. package/dist/server/notices/serverNoticeService.js.map +1 -0
  45. package/dist/server/notices/serverNoticeStore.js +78 -0
  46. package/dist/server/notices/serverNoticeStore.js.map +1 -0
  47. package/dist/server/piPackageIdentity.js +25 -0
  48. package/dist/server/piPackageIdentity.js.map +1 -0
  49. package/dist/server/piPackageService.js +69 -8
  50. package/dist/server/piPackageService.js.map +1 -1
  51. package/dist/server/piWebStatus.js +1 -1
  52. package/dist/server/piWebStatus.js.map +1 -1
  53. package/dist/server/plugins/pluginBackendProxyRoutes.js +1 -10
  54. package/dist/server/plugins/pluginBackendProxyRoutes.js.map +1 -1
  55. package/dist/server/sessiond/autoInstallPiPackages.js +63 -0
  56. package/dist/server/sessiond/autoInstallPiPackages.js.map +1 -0
  57. package/dist/server/sessiond/sessionProxyRoutes.js +3 -10
  58. package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
  59. package/dist/server/sessiond.js +26 -5
  60. package/dist/server/sessiond.js.map +1 -1
  61. package/dist/server/sessions/attachmentService.js +2 -5
  62. package/dist/server/sessions/attachmentService.js.map +1 -1
  63. package/dist/server/sessions/piSessionManagerGateway.js +122 -1
  64. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  65. package/dist/server/sessions/piSessionService.js +213 -104
  66. package/dist/server/sessions/piSessionService.js.map +1 -1
  67. package/dist/server/sessions/projectSettingsScope.js +12 -31
  68. package/dist/server/sessions/projectSettingsScope.js.map +1 -1
  69. package/dist/server/sessions/relaySkill.js +107 -0
  70. package/dist/server/sessions/relaySkill.js.map +1 -0
  71. package/dist/server/sessions/sessionModelScope.js +23 -8
  72. package/dist/server/sessions/sessionModelScope.js.map +1 -1
  73. package/dist/server/sessions/sessionRoutes.js +15 -0
  74. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  75. package/dist/server/sessions/transcriptBranchCache.js +81 -0
  76. package/dist/server/sessions/transcriptBranchCache.js.map +1 -0
  77. package/dist/server/storage/piPackageDismissalStore.js +78 -0
  78. package/dist/server/storage/piPackageDismissalStore.js.map +1 -0
  79. package/dist/server/terminals/terminalService.js +18 -1
  80. package/dist/server/terminals/terminalService.js.map +1 -1
  81. package/dist/server/workspaces/projectPiWebConfig.js +6 -1
  82. package/dist/server/workspaces/projectPiWebConfig.js.map +1 -1
  83. package/dist/server/workspaces/sessionDaemonWorkspaceCatalog.js +1 -10
  84. package/dist/server/workspaces/sessionDaemonWorkspaceCatalog.js.map +1 -1
  85. package/dist/server/workspaces/workspaceDeletionRoutes.js +1 -10
  86. package/dist/server/workspaces/workspaceDeletionRoutes.js.map +1 -1
  87. package/dist/server/workspaces/workspaceRemovalService.js +16 -5
  88. package/dist/server/workspaces/workspaceRemovalService.js.map +1 -1
  89. package/dist/shared/apiTypes.js.map +1 -1
  90. package/dist/shared/federatedRoutes.js +3 -0
  91. package/dist/shared/federatedRoutes.js.map +1 -1
  92. package/dist/shared/piWebConfigParsing.js +10 -0
  93. package/dist/shared/piWebConfigParsing.js.map +1 -1
  94. package/docs/config.md +26 -2
  95. package/docs/plugins.md +4 -2
  96. package/package.json +9 -8
  97. package/skills/relay/SKILL.md +59 -80
  98. package/dist/pi-web-plugins/relays/package.json +0 -9
  99. /package/dist/{pi-web-plugins → pi-packages}/relays/vendor/README.md +0 -0
  100. /package/dist/{pi-web-plugins → pi-packages}/relays/vendor/marked.esm.js +0 -0
@@ -1,7 +1,6 @@
1
- import { constants, realpathSync, statSync } from "node:fs";
2
- import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
4
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, getAgentDir, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, defineTool, readStoredCredential, SessionManager, } from "@earendil-works/pi-coding-agent";
6
5
  import { projectBrowserMessage } from "../browserMessageProjection.js";
7
6
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
@@ -14,6 +13,7 @@ import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sess
14
13
  import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
15
14
  import { computeEditPreview } from "./editPreview.js";
16
15
  import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
16
+ import { loadEffectiveProjectAttachmentsConfig } from "../workspaces/projectPiWebConfig.js";
17
17
  import { parsePromptAttachments } from "../../shared/promptAttachments.js";
18
18
  import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
19
19
  import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
@@ -38,12 +38,13 @@ import { createManagedAgentToolOptions, createManagedPythonToolDefinition } from
38
38
  import { PI_PERMISSION_SYSTEM_POLICY_AGENT_DIR, managementAgentToolNames, withRuntimeCreationEnvironment, writeManagementPermissionSystemPolicy } from "./managementPermissionSystem.js";
39
39
  import { SessionNotificationStore } from "./sessionNotificationStore.js";
40
40
  import { plainTextTheme } from "./plainTextTheme.js";
41
+ import { deduplicateBundledRelaySkill, ensureManagedRelaySkill, MANAGED_RELAY_SKILL_DIRECTORY } from "./relaySkill.js";
41
42
  import { SessionUnreadStore } from "./sessionUnreadStore.js";
42
43
  import { archiveCandidateFromActiveSession, archiveCandidateFromArchivedRecord, archiveCandidateFromListEntry, archiveInputFromActiveSession, archiveInputFromCandidate, archiveInputFromListEntry, clientSessionFromArchivedRecord, compareArchivedRecords, } from "./sessionArchiveMapping.js";
43
44
  import { annotateAssistantThinkingLevel, getBoolean, getProperty, getString, toClientEvent } from "./sessionUiEvents.js";
44
45
  import { WorkbenchHttpError } from "../workbench/workbenchClient.js";
45
46
  import { createWorkbenchToolDefinitions } from "../workbench/workbenchTools.js";
46
- import { applyEnabledModelToggle, catalogWithEnabledFirst, liveScopedModelIds, modelScopeId, persistedEnabledModelPatterns, resolveEnabledModelIds, resolveSessionModelOptions } from "./sessionModelScope.js";
47
+ import { applyEnabledModelToggle, catalogWithEnabledFirst, modelScopeId, persistedEnabledModelPatterns, resolveEnabledModelIds, resolveSessionModelOptions, scopedModelsFromEnabledIds } from "./sessionModelScope.js";
47
48
  const noopLogger = { info() { } };
48
49
  const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
49
50
  /**
@@ -62,6 +63,7 @@ const STARTUP_PHASE_EXTENSIONS = "正在加载会话扩展";
62
63
  const STARTUP_CONCURRENT_CATALOG_REFRESH = "服务商模型列表正在刷新";
63
64
  const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
64
65
  const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
66
+ const IDLE_SESSION_FILE_RESOLUTION_THROTTLE_MS = 30_000;
65
67
  function auditVisibleAssistantMessage(message) {
66
68
  if (!isAuditRecord(message))
67
69
  return message;
@@ -176,8 +178,6 @@ function parsePromptStreamingBehavior(value) {
176
178
  throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
177
179
  }
178
180
  const GLOBAL_AGENT_CONTEXT_FILE_NAMES = new Set(["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]);
179
- const MANAGED_RELAY_SKILL_DIRECTORY = join(".pi", "skills", "relay");
180
- const BUNDLED_RELAY_SKILL_PATH = fileURLToPath(new URL("../../../skills/relay/SKILL.md", import.meta.url));
181
181
  export function filterManagedGlobalContextFiles(cwd, agentDir, base) {
182
182
  return {
183
183
  agentsFiles: base.agentsFiles.filter((file) => {
@@ -200,25 +200,6 @@ export function filterManagedProjectSkills(cwd, base) {
200
200
  diagnostics: filterManagedSkillDiagnostics(base.diagnostics, pathAllowed),
201
201
  };
202
202
  }
203
- export async function ensureManagedRelaySkill(cwd, bundledSkillPath = BUNDLED_RELAY_SKILL_PATH) {
204
- const projectRoot = realpathSync(cwd);
205
- const piDirectory = resolve(projectRoot, ".pi");
206
- const skillsDirectory = join(piDirectory, "skills");
207
- const skillDirectory = resolve(projectRoot, MANAGED_RELAY_SKILL_DIRECTORY);
208
- const skillPath = join(skillDirectory, "SKILL.md");
209
- for (const directory of [piDirectory, skillsDirectory, skillDirectory]) {
210
- await ensureManagedSkillDirectory(projectRoot, directory);
211
- }
212
- try {
213
- await copyFile(bundledSkillPath, skillPath, constants.COPYFILE_EXCL);
214
- }
215
- catch (error) {
216
- if (!isNodeError(error, "EEXIST"))
217
- throw error;
218
- }
219
- assertManagedSkillPath(projectRoot, skillPath, "file");
220
- return skillPath;
221
- }
222
203
  export function filterManagedWorkbenchSkills(cwd, base, receipt) {
223
204
  const projectRoot = realpathSync(cwd);
224
205
  const allowedDirectories = new Set([
@@ -243,27 +224,6 @@ function pathInsideRealDirectory(directory, path) {
243
224
  return false;
244
225
  }
245
226
  }
246
- async function ensureManagedSkillDirectory(projectRoot, path) {
247
- try {
248
- await mkdir(path);
249
- }
250
- catch (error) {
251
- if (!isNodeError(error, "EEXIST"))
252
- throw error;
253
- }
254
- assertManagedSkillPath(projectRoot, path, "directory");
255
- }
256
- function assertManagedSkillPath(projectRoot, path, expectedType) {
257
- const realPath = realpathSync(path);
258
- const stats = statSync(realPath);
259
- const hasExpectedType = expectedType === "directory" ? stats.isDirectory() : stats.isFile();
260
- if (!pathInsideOrEqual(projectRoot, realPath) || !hasExpectedType) {
261
- throw new Error(`Managed skill path is invalid: ${path}`);
262
- }
263
- }
264
- function isNodeError(error, code) {
265
- return error instanceof Error && "code" in error && error.code === code;
266
- }
267
227
  function pathInsideOrEqual(parent, child) {
268
228
  const normalizedParent = resolve(parent);
269
229
  const normalizedChild = resolve(child);
@@ -431,13 +391,16 @@ function createDefaultRuntimeFactory(normalModelRuntimeForCwd, managementModelRu
431
391
  globalAgentDir: agentDir,
432
392
  mode: "normal",
433
393
  });
434
- const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
394
+ const resourceLoaderOptions = {
395
+ ...piWebResourceLoaderOptions(appendSystemPromptSections),
396
+ skillsOverride: (base) => deduplicateBundledRelaySkill(cwd, base),
397
+ };
435
398
  const services = await createAgentSessionServices({
436
399
  cwd,
437
400
  agentDir,
438
401
  modelRuntime: scopedModelRuntime,
439
402
  settingsManager,
440
- ...(resourceLoaderOptions === undefined ? {} : { resourceLoaderOptions }),
403
+ resourceLoaderOptions,
441
404
  });
442
405
  const modelOptions = await resolveSessionModelOptions({
443
406
  services,
@@ -609,6 +572,13 @@ export class PiSessionService {
609
572
  this.treeNavigations = new WeakSet();
610
573
  /** Counts async operations that may append an entry before they settle. */
611
574
  this.sessionEntryMutationCounts = new WeakMap();
575
+ /** Serializes enabled-model read/modify/write operations. */
576
+ this.modelScopeMutationQueue = Promise.resolve();
577
+ /** Effective enabled-model state keyed by the project-scoped settings store. */
578
+ this.modelScopeStates = new Map();
579
+ this.modelScopeInitializations = new Map();
580
+ this.modelScopeRevision = 0;
581
+ this.modelScopeCache = new WeakMap();
612
582
  /** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
613
583
  this.treeExclusiveRuntimeOperationCounts = new WeakMap();
614
584
  this.treeExclusiveSessionOperationCounts = new Map();
@@ -633,6 +603,7 @@ export class PiSessionService {
633
603
  */
634
604
  this.subsessionNotifyArmed = new Map();
635
605
  this.notificationGenerationBySession = new WeakMap();
606
+ this.idleSessionFileResolutions = new WeakMap();
636
607
  /** The parked extension Promise resolvers behind the store's open dialogs. */
637
608
  this.dialogWaiters = new ExtensionDialogWaiters();
638
609
  this.pendingUnreadMutations = [];
@@ -671,6 +642,7 @@ export class PiSessionService {
671
642
  this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
672
643
  this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
673
644
  this.catalogRefreshStatus = deps.catalogRefreshStatus;
645
+ this.config = deps.config;
674
646
  this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
675
647
  this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
676
648
  // Subsessions are gated behind their own flag, and they
@@ -987,24 +959,84 @@ export class PiSessionService {
987
959
  ...(modelUsed === undefined ? {} : { model: modelSpecOf(modelUsed) }),
988
960
  };
989
961
  }
990
- /**
991
- * The models a session may pick from: its scoped set when model-scoped,
992
- * otherwise the runtime's available snapshot. Refreshes the runtime catalog
993
- * first so callers see newly configured providers and models.
994
- */
962
+ modelScopeContext(session) {
963
+ const settingsKey = session.settingsManager.piWebScopeKey ?? "global";
964
+ const projectSettings = session.settingsManager.getProjectSettings?.() ?? {};
965
+ if (Object.hasOwn(projectSettings, "enabledModels")) {
966
+ const workspace = canonicalizeStoredCwd(session.sessionManager.getCwd());
967
+ return { key: `${settingsKey}:workspace:${workspace}`, editable: false };
968
+ }
969
+ return { key: settingsKey, editable: session.settingsManager.piWebModelScopeEditable !== false };
970
+ }
971
+ async modelScopeSnapshot(session, context = this.modelScopeContext(session)) {
972
+ const existing = this.modelScopeStates.get(context.key);
973
+ if (existing !== undefined)
974
+ return existing;
975
+ const pending = this.modelScopeInitializations.get(context.key);
976
+ if (pending !== undefined)
977
+ return pending;
978
+ const initialization = (async () => {
979
+ const enabledIds = await resolveEnabledModelIds({
980
+ settingsManager: session.settingsManager,
981
+ // Runtime-backed sessions always hold the SDK ModelRuntime; PiAgentSession keeps a narrow public seam for tests.
982
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
983
+ modelRuntime: session.modelRuntime,
984
+ scopedModels: session.scopedModels,
985
+ });
986
+ const snapshot = { enabledIds: enabledIds === null ? null : [...enabledIds], revision: 0 };
987
+ this.modelScopeStates.set(context.key, snapshot);
988
+ return snapshot;
989
+ })();
990
+ this.modelScopeInitializations.set(context.key, initialization);
991
+ try {
992
+ return await initialization;
993
+ }
994
+ finally {
995
+ if (this.modelScopeInitializations.get(context.key) === initialization)
996
+ this.modelScopeInitializations.delete(context.key);
997
+ }
998
+ }
999
+ async synchronizeSessionModelScope(session, available) {
1000
+ const context = this.modelScopeContext(session);
1001
+ const snapshot = await this.modelScopeSnapshot(session, context);
1002
+ const catalogKey = available.map(modelScopeId).join("\0");
1003
+ const cached = this.modelScopeCache.get(session);
1004
+ if (cached?.scopeKey === context.key && cached.revision === snapshot.revision && cached.catalogKey === catalogKey)
1005
+ return;
1006
+ session.setScopedModels(scopedModelsFromEnabledIds(available, snapshot.enabledIds, session.scopedModels));
1007
+ this.modelScopeCache.set(session, { scopeKey: context.key, revision: snapshot.revision, catalogKey });
1008
+ }
995
1009
  async sessionModelCandidates(session) {
996
- // Pi 0.84 keeps request paths local-only; the bounded catalog refresher is
997
- // the only path allowed to fetch provider catalogs over the network.
998
1010
  await session.modelRuntime.refresh({ allowNetwork: false });
999
- return session.scopedModels.length > 0
1000
- ? session.scopedModels.map((scoped) => scoped.model)
1001
- : await session.modelRuntime.getAvailable();
1011
+ const available = session.modelRuntime.getAvailableSnapshot();
1012
+ await this.synchronizeSessionModelScope(session, available);
1013
+ return session.scopedModels.length > 0 ? session.scopedModels.map((scoped) => scoped.model) : available;
1002
1014
  }
1003
- async enabledModelCatalog(runtime) {
1004
- const session = runtime.session;
1015
+ async enabledModelCatalog(session) {
1005
1016
  await session.modelRuntime.refresh({ allowNetwork: false });
1006
- const enabledIds = await resolveEnabledModelIds(sessionScopeSource(runtime));
1007
- return catalogWithEnabledFirst(session.modelRuntime.getAvailableSnapshot(), enabledIds);
1017
+ const available = session.modelRuntime.getAvailableSnapshot();
1018
+ await this.synchronizeSessionModelScope(session, available);
1019
+ const context = this.modelScopeContext(session);
1020
+ return catalogWithEnabledFirst(available, (await this.modelScopeSnapshot(session, context)).enabledIds);
1021
+ }
1022
+ runModelScopeMutation(operation) {
1023
+ const queued = this.modelScopeMutationQueue.then(operation);
1024
+ this.modelScopeMutationQueue = queued.then(() => undefined, () => undefined);
1025
+ return queued;
1026
+ }
1027
+ applyEnabledModelScope(session, available, enabledIds) {
1028
+ const context = this.modelScopeContext(session);
1029
+ const availableIds = available.map(modelScopeId);
1030
+ const persisted = persistedEnabledModelPatterns(enabledIds, availableIds);
1031
+ const nextState = {
1032
+ enabledIds: persisted === undefined ? null : enabledIds === null ? [] : [...enabledIds],
1033
+ revision: ++this.modelScopeRevision,
1034
+ };
1035
+ session.settingsManager.setEnabledModels(persisted);
1036
+ this.modelScopeStates.set(context.key, nextState);
1037
+ session.setScopedModels(scopedModelsFromEnabledIds(available, nextState.enabledIds, session.scopedModels));
1038
+ this.modelScopeCache.set(session, { scopeKey: context.key, revision: nextState.revision, catalogKey: available.map(modelScopeId).join("\0") });
1039
+ this.events.publishGlobal({ type: "models.changed", revision: nextState.revision });
1008
1040
  }
1009
1041
  /**
1010
1042
  * Resolve a strict `provider/model-id` spec from a spawn tool against the
@@ -1548,10 +1580,14 @@ export class PiSessionService {
1548
1580
  }
1549
1581
  async messages(ref, page, managementContext) {
1550
1582
  const session = await this.getOrOpen(ref, managementContext);
1551
- return pageMessagesAtSafeBoundary(historyMessages(session), page);
1583
+ return pageMessagesAtSafeBoundary(historyMessagesFromEntries(await this.readableSessionBranch(session)), page);
1552
1584
  }
1553
1585
  async status(ref, managementContext) {
1554
- return this.statusFromSession(await this.sessionForStatusOrDialogClose(ref, managementContext));
1586
+ const session = await this.sessionForStatusOrDialogClose(ref, managementContext);
1587
+ if (this.hasActiveWork(session))
1588
+ return this.statusFromSession(session);
1589
+ const branch = await this.readableSessionBranch(session);
1590
+ return this.statusFromSession(session, transcriptMessageCount(branch));
1555
1591
  }
1556
1592
  async searchCandidates(cwd, normalizedQuery, managementContext) {
1557
1593
  const listing = await this.listWorkspaceSessions(cwd, managementContext);
@@ -1588,38 +1624,60 @@ export class PiSessionService {
1588
1624
  return models.map(modelToClientModel);
1589
1625
  }
1590
1626
  async modelCatalog(ref, managementContext) {
1591
- const runtime = (await this.getActive(ref, managementContext)).runtime;
1592
- return (await this.enabledModelCatalog(runtime)).map(catalogEntryToClientModel);
1627
+ const session = await this.getOrOpen(ref, managementContext);
1628
+ const scope = this.modelScopeContext(session);
1629
+ return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry, scope.editable));
1593
1630
  }
1594
1631
  async setModelEnabled(ref, provider, modelId, enabled, managementContext) {
1595
1632
  if (managementContext !== undefined)
1596
1633
  throw new Error("管理嵌入模式不允许修改已启用模型范围");
1597
1634
  await this.assertWritable(ref);
1598
- const runtime = (await this.getActive(ref)).runtime;
1599
- const session = runtime.session;
1600
- this.assertTreeNavigationInactive(session, "change enabled models");
1601
- await session.modelRuntime.refresh({ allowNetwork: false });
1602
- const currentIds = await resolveEnabledModelIds(sessionScopeSource(runtime));
1603
- const available = session.modelRuntime.getAvailableSnapshot();
1604
- const availableIds = available.map(modelScopeId);
1605
- const targetId = `${provider}/${modelId}`;
1606
- if (!availableIds.includes(targetId))
1607
- throw new Error(`未找到模型:${targetId}`);
1608
- const nextIds = applyEnabledModelToggle(currentIds, availableIds, targetId, enabled);
1609
- this.assertTreeNavigationInactive(session, "change enabled models");
1610
- if (nextIds !== currentIds) {
1611
- const liveIds = liveScopedModelIds(nextIds, availableIds);
1612
- const modelsById = new Map(available.map((model) => [modelScopeId(model), model]));
1613
- const scoped = liveIds === null
1614
- ? []
1615
- : liveIds.flatMap((id) => {
1616
- const model = modelsById.get(id);
1617
- return model === undefined ? [] : [{ model }];
1618
- });
1619
- session.settingsManager.setEnabledModels(persistedEnabledModelPatterns(nextIds, availableIds));
1620
- session.setScopedModels(scoped);
1621
- }
1622
- return (await this.enabledModelCatalog(runtime)).map(catalogEntryToClientModel);
1635
+ const session = await this.getOrOpen(ref);
1636
+ return this.runModelScopeMutation(async () => {
1637
+ const scope = this.modelScopeContext(session);
1638
+ if (!scope.editable)
1639
+ throw new Error("模型可用范围由此工作区的 .pi/settings.json 控制");
1640
+ this.assertTreeNavigationInactive(session, "change enabled models");
1641
+ await session.modelRuntime.refresh({ allowNetwork: false });
1642
+ const currentIds = (await this.modelScopeSnapshot(session, scope)).enabledIds;
1643
+ const available = session.modelRuntime.getAvailableSnapshot();
1644
+ const availableIds = available.map(modelScopeId);
1645
+ const targetId = `${provider}/${modelId}`;
1646
+ if (!availableIds.includes(targetId))
1647
+ throw new Error(`未找到模型:${targetId}`);
1648
+ if (!enabled && session.model !== undefined && modelScopeId(session.model) === targetId)
1649
+ throw new Error("不能停用当前模型");
1650
+ const nextIds = applyEnabledModelToggle(currentIds, availableIds, targetId, enabled);
1651
+ this.assertTreeNavigationInactive(session, "change enabled models");
1652
+ if (nextIds !== currentIds)
1653
+ this.applyEnabledModelScope(session, available, nextIds);
1654
+ return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry));
1655
+ });
1656
+ }
1657
+ async setModelScope(ref, mode, managementContext) {
1658
+ if (managementContext !== undefined)
1659
+ throw new Error("管理嵌入模式不允许修改已启用模型范围");
1660
+ await this.assertWritable(ref);
1661
+ const session = await this.getOrOpen(ref);
1662
+ return this.runModelScopeMutation(async () => {
1663
+ const scope = this.modelScopeContext(session);
1664
+ if (!scope.editable)
1665
+ throw new Error("模型可用范围由此工作区的 .pi/settings.json 控制");
1666
+ this.assertTreeNavigationInactive(session, "change enabled models");
1667
+ await session.modelRuntime.refresh({ allowNetwork: false });
1668
+ const available = session.modelRuntime.getAvailableSnapshot();
1669
+ const availableIds = available.map(modelScopeId);
1670
+ let nextIds = null;
1671
+ if (mode === "current") {
1672
+ const current = session.model;
1673
+ if (current === undefined || !availableIds.includes(modelScopeId(current)))
1674
+ throw new Error("当前模型不可用");
1675
+ nextIds = [modelScopeId(current)];
1676
+ }
1677
+ this.assertTreeNavigationInactive(session, "change enabled models");
1678
+ this.applyEnabledModelScope(session, available, nextIds);
1679
+ return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry));
1680
+ });
1623
1681
  }
1624
1682
  async setModel(ref, provider, modelId, managementContext) {
1625
1683
  await this.assertWritable(ref);
@@ -1639,7 +1697,11 @@ export class PiSessionService {
1639
1697
  async cycleModel(ref, direction, managementContext) {
1640
1698
  await this.assertWritable(ref);
1641
1699
  const session = await this.getOrOpen(ref, managementContext);
1642
- const result = await this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
1700
+ const result = await this.runModelScopeMutation(async () => {
1701
+ await session.modelRuntime.refresh({ allowNetwork: false });
1702
+ await this.synchronizeSessionModelScope(session, session.modelRuntime.getAvailableSnapshot());
1703
+ return this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
1704
+ });
1643
1705
  if (result === undefined)
1644
1706
  throw new Error(session.scopedModels.length > 0 ? "作用域内只有一个模型" : "只有一个可用模型");
1645
1707
  this.publishActivity(session, `模型:${result.model.id}`, "idle", result.model.provider);
@@ -1752,7 +1814,13 @@ export class PiSessionService {
1752
1814
  return [];
1753
1815
  await this.assertWritable(ref);
1754
1816
  const active = await this.getActive(ref, managementContext);
1755
- return saveAttachmentsToWorkspace(active.runtime.cwd, parsed, folder === undefined ? {} : { folder });
1817
+ const cwd = active.runtime.cwd;
1818
+ const effectiveFolder = folder ?? (await this.workspaceAttachmentsConfig(cwd)).defaultFolder;
1819
+ return saveAttachmentsToWorkspace(cwd, parsed, effectiveFolder === undefined ? {} : { folder: effectiveFolder });
1820
+ }
1821
+ async workspaceAttachmentsConfig(cwd) {
1822
+ const globalConfig = this.config === undefined ? {} : (await this.config.read()).effectiveConfig;
1823
+ return loadEffectiveProjectAttachmentsConfig(cwd, globalConfig);
1756
1824
  }
1757
1825
  async shell(ref, text, managementContext) {
1758
1826
  await this.assertWritable(ref);
@@ -2492,6 +2560,35 @@ export class PiSessionService {
2492
2560
  const reachable = this.activeForLookup(ref, managementContext)?.runtime.session ?? this.startupSessionForLookup(ref, managementContext);
2493
2561
  return reachable ?? this.getOrOpen(ref, managementContext);
2494
2562
  }
2563
+ async readableSessionBranch(session) {
2564
+ if (this.hasActiveWork(session) || this.sessionManager.readBranch === undefined)
2565
+ return session.sessionManager.getBranch();
2566
+ const leafId = session.sessionManager.getLeafId();
2567
+ const latestEntry = session.sessionManager.getEntries?.().at(-1);
2568
+ // Unsummarized tree navigation changes only the SDK's in-memory leaf.
2569
+ if (isRecord(latestEntry) && typeof latestEntry["id"] === "string" && latestEntry["id"] !== leafId)
2570
+ return session.sessionManager.getBranch();
2571
+ const sessionFile = session.sessionFile ?? session.sessionManager.getSessionFile();
2572
+ const resolvedPath = sessionFile === undefined ? await this.idleSessionFilePath(session) : undefined;
2573
+ if (this.hasActiveWork(session))
2574
+ return session.sessionManager.getBranch();
2575
+ const path = sessionFile ?? resolvedPath;
2576
+ if (path === undefined)
2577
+ return session.sessionManager.getBranch();
2578
+ const snapshot = await this.sessionManager.readBranch(path);
2579
+ if (snapshot === undefined)
2580
+ return session.sessionManager.getBranch();
2581
+ return this.hasActiveWork(session) || session.sessionManager.getLeafId() !== leafId ? session.sessionManager.getBranch() : snapshot;
2582
+ }
2583
+ async idleSessionFilePath(session) {
2584
+ const cached = this.idleSessionFileResolutions.get(session);
2585
+ const at = this.now().getTime();
2586
+ if (cached !== undefined && at - cached.at < IDLE_SESSION_FILE_RESOLUTION_THROTTLE_MS)
2587
+ return cached.path;
2588
+ const match = await this.sessionManager.resolveSessionFile?.(session.sessionManager.getCwd(), session.sessionId);
2589
+ this.idleSessionFileResolutions.set(session, { at, path: match?.path });
2590
+ return match?.path;
2591
+ }
2495
2592
  async getActive(ref, managementContext) {
2496
2593
  const active = this.activeForLookup(ref, managementContext);
2497
2594
  if (active !== undefined)
@@ -3153,7 +3250,7 @@ export class PiSessionService {
3153
3250
  for (const session of sessions)
3154
3251
  this.publishStatus(session);
3155
3252
  }
3156
- statusFromSession(session) {
3253
+ statusFromSession(session, messageCount = session.messages.length) {
3157
3254
  const stats = session.getSessionStats();
3158
3255
  const model = session.model === undefined ? undefined : modelToClientModel(session.model);
3159
3256
  const contextUsage = session.getContextUsage();
@@ -3170,7 +3267,7 @@ export class PiSessionService {
3170
3267
  isBashRunning: session.isBashRunning,
3171
3268
  pendingMessageCount: this.pendingMessageCount(session),
3172
3269
  queuedMessages: queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)),
3173
- messageCount: session.messages.length,
3270
+ messageCount,
3174
3271
  tokens: stats.tokens,
3175
3272
  cost: stats.cost,
3176
3273
  ...(contextUsage === undefined ? {} : { contextUsage }),
@@ -3684,14 +3781,15 @@ function modelToClientModel(model) {
3684
3781
  ...(reasoning === undefined ? {} : { reasoning }),
3685
3782
  };
3686
3783
  }
3687
- function sessionScopeSource(runtime) {
3688
- const modelRuntime = runtime.services?.modelRuntime;
3689
- if (modelRuntime === undefined)
3690
- throw new Error("会话模型目录不可用");
3691
- return { settingsManager: runtime.session.settingsManager, modelRuntime, scopedModels: runtime.session.scopedModels };
3692
- }
3693
- function catalogEntryToClientModel(entry) {
3694
- return { ...modelToClientModel(entry.model), provider: entry.model.provider, id: entry.model.id, enabled: entry.enabled };
3784
+ function catalogEntryToClientModel(entry, editable = true) {
3785
+ return {
3786
+ ...modelToClientModel(entry.model),
3787
+ provider: entry.model.provider,
3788
+ id: entry.model.id,
3789
+ enabled: entry.enabled,
3790
+ catalogIndex: entry.catalogIndex,
3791
+ ...(editable ? {} : { editable: false }),
3792
+ };
3695
3793
  }
3696
3794
  function notificationIdentityForSession(session) {
3697
3795
  return {
@@ -3963,9 +4061,12 @@ function historyMessages(session) {
3963
4061
  return historyMessagesFromManager(session.sessionManager);
3964
4062
  }
3965
4063
  function historyMessagesFromManager(sessionManager) {
4064
+ return historyMessagesFromEntries(sessionManager.getBranch());
4065
+ }
4066
+ function historyMessagesFromEntries(entries) {
3966
4067
  const messages = [];
3967
4068
  let thinkingLevel;
3968
- for (const entry of sessionManager.getBranch()) {
4069
+ for (const entry of entries) {
3969
4070
  if (!isRecord(entry))
3970
4071
  continue;
3971
4072
  if (entry["type"] === "message")
@@ -3984,6 +4085,14 @@ function historyMessagesFromManager(sessionManager) {
3984
4085
  }
3985
4086
  return messages;
3986
4087
  }
4088
+ function transcriptMessageCount(entries) {
4089
+ let count = 0;
4090
+ for (const entry of entries) {
4091
+ if (isRecord(entry) && entry["type"] === "message")
4092
+ count += 1;
4093
+ }
4094
+ return count;
4095
+ }
3987
4096
  /** custom entry type used to persist parent -> child subsession links outside LLM context. */
3988
4097
  const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
3989
4098
  /** custom entry type used to mark a child as created by spawn_subsession. */