@jmfederico/pi-web 1.202608.1 → 1.202609.0

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 (107) hide show
  1. package/dist/cli.js +220 -108
  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-mEiBZFA5.js → CodeViewer-CGAlg9S8.js} +1 -1
  5. package/dist/client/assets/{TerminalPanel-O9381Eme.js → TerminalPanel-xhJRhOas.js} +1 -1
  6. package/dist/client/assets/{index-uI2AGFUy.js → index-DXQKhn1P.js} +570 -335
  7. package/dist/client/assets/vendor-editor-core-CXO8gGab.js +12 -0
  8. package/dist/client/assets/vendor-editor-languages-CpW4sJsX.js +46 -0
  9. package/dist/client/favicon-dev.svg +8 -0
  10. package/dist/client/index.html +4 -4
  11. package/dist/client/pwa-icon-dev-192.png +0 -0
  12. package/dist/client/pwa-icon-dev-512.png +0 -0
  13. package/dist/config.js +21 -0
  14. package/dist/config.js.map +1 -1
  15. package/dist/environment.js +4 -0
  16. package/dist/environment.js.map +1 -0
  17. package/dist/nativeServices/installedServiceDefinitions.js +601 -0
  18. package/dist/nativeServices/installedServiceDefinitions.js.map +1 -0
  19. package/dist/nativeServices/serviceAction.js +174 -0
  20. package/dist/nativeServices/serviceAction.js.map +1 -0
  21. package/dist/nativeServices/serviceDoctor.js +374 -71
  22. package/dist/nativeServices/serviceDoctor.js.map +1 -1
  23. package/dist/{pi-web-plugins → pi-packages}/relays/markdownDocument.js +1 -1
  24. package/dist/pi-packages/relays/package.json +11 -0
  25. package/dist/{pi-web-plugins → pi-packages}/relays/pi-web-plugin.js +1 -1
  26. package/dist/pi-packages/relays/prompts/relay-worktree.md +28 -0
  27. package/dist/pi-packages/relays/prompts/relay.md +45 -0
  28. package/dist/{pi-web-plugins → pi-packages}/relays/relayDiscovery.js +3 -3
  29. package/dist/{pi-web-plugins → pi-packages}/relays/relaysPanelElement.js +2 -2
  30. package/dist/pi-packages/relays/skills/relay/SKILL.md +94 -0
  31. package/dist/pi-packages/relays/skills/relay-runner/SKILL.md +245 -0
  32. package/dist/pi-web-plugins/info/infoInternals.js +15 -0
  33. package/dist/pi-web-plugins/updates/pi-web-plugin.js +44 -34
  34. package/dist/piWebVersionReport.js +63 -10
  35. package/dist/piWebVersionReport.js.map +1 -1
  36. package/dist/server/app.js +19 -3
  37. package/dist/server/app.js.map +1 -1
  38. package/dist/server/configRoutes.js +6 -1
  39. package/dist/server/configRoutes.js.map +1 -1
  40. package/dist/server/deploymentIdentity.js +67 -0
  41. package/dist/server/deploymentIdentity.js.map +1 -0
  42. package/dist/server/deploymentIdentityRoutes.js +22 -0
  43. package/dist/server/deploymentIdentityRoutes.js.map +1 -0
  44. package/dist/server/knownAutoInstallPiPackages.js +30 -0
  45. package/dist/server/knownAutoInstallPiPackages.js.map +1 -0
  46. package/dist/server/machines/machineService.js +1 -0
  47. package/dist/server/machines/machineService.js.map +1 -1
  48. package/dist/server/notices/serverNoticeRoutes.js +34 -0
  49. package/dist/server/notices/serverNoticeRoutes.js.map +1 -0
  50. package/dist/server/notices/serverNoticeService.js +26 -0
  51. package/dist/server/notices/serverNoticeService.js.map +1 -0
  52. package/dist/server/notices/serverNoticeStore.js +78 -0
  53. package/dist/server/notices/serverNoticeStore.js.map +1 -0
  54. package/dist/server/piPackageIdentity.js +25 -0
  55. package/dist/server/piPackageIdentity.js.map +1 -0
  56. package/dist/server/piPackageService.js +69 -8
  57. package/dist/server/piPackageService.js.map +1 -1
  58. package/dist/server/piWebStatus.js +14 -3
  59. package/dist/server/piWebStatus.js.map +1 -1
  60. package/dist/server/projectTrustRoutes.js +80 -0
  61. package/dist/server/projectTrustRoutes.js.map +1 -0
  62. package/dist/server/projects/projectService.js +3 -1
  63. package/dist/server/projects/projectService.js.map +1 -1
  64. package/dist/server/sessiond/autoInstallPiPackages.js +63 -0
  65. package/dist/server/sessiond/autoInstallPiPackages.js.map +1 -0
  66. package/dist/server/sessiond/sessionProxyRoutes.js +2 -0
  67. package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
  68. package/dist/server/sessiond/sessionServiceDependencies.js +1 -0
  69. package/dist/server/sessiond/sessionServiceDependencies.js.map +1 -1
  70. package/dist/server/sessiond.js +36 -5
  71. package/dist/server/sessiond.js.map +1 -1
  72. package/dist/server/sessions/attachmentService.js +1 -5
  73. package/dist/server/sessions/attachmentService.js.map +1 -1
  74. package/dist/server/sessions/piSessionManagerGateway.js +171 -1
  75. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  76. package/dist/server/sessions/piSessionService.js +535 -20
  77. package/dist/server/sessions/piSessionService.js.map +1 -1
  78. package/dist/server/sessions/sessionModelScope.js +160 -0
  79. package/dist/server/sessions/sessionModelScope.js.map +1 -0
  80. package/dist/server/sessions/sessionNameGenerator.js +6 -6
  81. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  82. package/dist/server/sessions/sessionRoutes.js +41 -0
  83. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  84. package/dist/server/sessions/transcriptBranchCache.js +81 -0
  85. package/dist/server/sessions/transcriptBranchCache.js.map +1 -0
  86. package/dist/server/storage/piPackageDismissalStore.js +78 -0
  87. package/dist/server/storage/piPackageDismissalStore.js.map +1 -0
  88. package/dist/server/terminals/terminalService.js +18 -1
  89. package/dist/server/terminals/terminalService.js.map +1 -1
  90. package/dist/server/workspaces/projectPiWebConfig.js +6 -1
  91. package/dist/server/workspaces/projectPiWebConfig.js.map +1 -1
  92. package/dist/server/workspaces/workspaceRemovalService.js +16 -5
  93. package/dist/server/workspaces/workspaceRemovalService.js.map +1 -1
  94. package/dist/shared/apiTypes.js.map +1 -1
  95. package/dist/shared/federatedRoutes.js +10 -0
  96. package/dist/shared/federatedRoutes.js.map +1 -1
  97. package/dist/shared/piWebStatusParsing.js +4 -0
  98. package/dist/shared/piWebStatusParsing.js.map +1 -1
  99. package/dist/shared/pluginApiTypes.d.ts +2 -0
  100. package/docs/config.md +45 -4
  101. package/docs/plugins.md +25 -11
  102. package/package.json +6 -6
  103. package/dist/client/assets/vendor-editor-core-vUi74DnD.js +0 -12
  104. package/dist/client/assets/vendor-editor-languages-DoaXUodB.js +0 -46
  105. package/dist/pi-web-plugins/relays/package.json +0 -9
  106. /package/dist/{pi-web-plugins → pi-packages}/relays/vendor/README.md +0 -0
  107. /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";
@@ -12,6 +12,7 @@ import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sess
12
12
  import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
13
13
  import { computeEditPreview } from "./editPreview.js";
14
14
  import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
15
+ import { loadEffectiveProjectAttachmentsConfig } from "../workspaces/projectPiWebConfig.js";
15
16
  import { parsePromptAttachments } from "../../shared/promptAttachments.js";
16
17
  import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
17
18
  import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
@@ -28,6 +29,7 @@ import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionC
28
29
  import { SessionNotificationStore, } from "./sessionNotificationStore.js";
29
30
  import { plainTextTheme } from "./plainTextTheme.js";
30
31
  import { SessionUnreadStore } from "./sessionUnreadStore.js";
32
+ import { applyEnabledModelToggle, catalogWithEnabledFirst, modelScopeId, persistedEnabledModelPatterns, resolveEnabledModelIds, resolveSessionModelOptions, scopedModelsFromEnabledIds } from "./sessionModelScope.js";
31
33
  const noopLogger = { info() { } };
32
34
  const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
33
35
  /**
@@ -46,6 +48,15 @@ const STARTUP_PHASE_EXTENSIONS = "Loading session extensions";
46
48
  const STARTUP_CONCURRENT_CATALOG_REFRESH = "provider model lists are refreshing";
47
49
  const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
48
50
  const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
51
+ /**
52
+ * Upper bound on how often one idle runtime re-resolves its transcript file.
53
+ * A runtime created in memory and never persisted has no session file, so
54
+ * every poll would otherwise rescan the session directory; throttling keeps
55
+ * steady-state polling of such a session O(1). The window can only delay
56
+ * noticing a file the runtime did not write itself: once the runtime
57
+ * persists, `getSessionFile()` answers and this throttle is bypassed.
58
+ */
59
+ const IDLE_SESSION_FILE_RESOLUTION_THROTTLE_MS = 30_000;
49
60
  function noop() {
50
61
  // Intentionally empty default unsubscribe callback.
51
62
  }
@@ -281,13 +292,130 @@ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn,
281
292
  ...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
282
293
  ];
283
294
  }
295
+ /**
296
+ * Run the `project_trust` event over a pre-trust extension set, mirroring
297
+ * `emitProjectTrustEvent` in the SDK's `dist/core/extensions/runner.js`. That
298
+ * helper is not part of the package's public exports (the main index exports
299
+ * only the `ProjectTrust*` types and `ProjectTrustStore`, and the package's
300
+ * `exports` map blocks subpath imports, so `resolveProjectTrusted`/
301
+ * `emitProjectTrustEvent` are not callable from here), so PI WEB reimplements
302
+ * its documented decision loop over the SDK-provided extension objects: per
303
+ * extension, the registered `project_trust` handlers run in order; the first
304
+ * handler returning `yes`/`no` decides and `undecided` falls through to the
305
+ * next handler/extension; a throwing handler is collected as an error and
306
+ * later handlers still get their chance.
307
+ */
308
+ export async function emitWebProjectTrustEvent(extensionsResult, event, ctx) {
309
+ const errors = [];
310
+ for (const extension of extensionsResult.extensions) {
311
+ // A single extension may register multiple handlers for the same event.
312
+ // The handlers map is keyed exactly as the extension registered it, so a
313
+ // `project_trust` key guarantees `ProjectTrustHandler` entries — the same
314
+ // assumption the SDK's emitProjectTrustEvent makes.
315
+ const handlers = extension.handlers.get("project_trust");
316
+ if (handlers === undefined || handlers.length === 0)
317
+ continue;
318
+ for (const handler of handlers) {
319
+ try {
320
+ const handlerResult = await handler(event, ctx);
321
+ // The SDK reads `trusted` straight off the handler result, so a
322
+ // non-object would throw there; PI WEB reports it as a handler error
323
+ // and lets the next handler/extension try.
324
+ if (typeof handlerResult !== "object" || handlerResult === null) {
325
+ errors.push({ extensionPath: extension.path, error: "project_trust handler returned a non-object result" });
326
+ continue;
327
+ }
328
+ const trusted = "trusted" in handlerResult ? handlerResult.trusted : undefined;
329
+ if (trusted === "undecided") {
330
+ continue;
331
+ }
332
+ // Rebuild the decision so only the documented `trusted`/`remember`
333
+ // fields carry over — the SDK's resolver reads exactly those two.
334
+ const remember = "remember" in handlerResult ? handlerResult.remember : undefined;
335
+ return {
336
+ result: {
337
+ trusted: trusted === "yes" ? "yes" : "no",
338
+ ...(remember === true ? { remember: true } : {}),
339
+ },
340
+ errors,
341
+ };
342
+ }
343
+ catch (error) {
344
+ errors.push({
345
+ extensionPath: extension.path,
346
+ error: error instanceof Error ? error.message : String(error),
347
+ });
348
+ }
349
+ }
350
+ }
351
+ return { errors };
352
+ }
353
+ /**
354
+ * PI WEB's headless project-trust context. `hasUI` is false because there is
355
+ * no browser trust prompt (chartered behavior: `ask` never loads untrusted
356
+ * resources), so the UI methods are inert — the same no-UI shape the SDK
357
+ * passes when pi runs without a trust UI. The host mode is `rpc`, mirroring
358
+ * how PI WEB binds its session extension contexts.
359
+ */
360
+ function webProjectTrustContext(cwd) {
361
+ return {
362
+ cwd,
363
+ mode: "rpc",
364
+ hasUI: false,
365
+ ui: {
366
+ select: () => Promise.resolve(undefined),
367
+ confirm: () => Promise.resolve(false),
368
+ input: () => Promise.resolve(undefined),
369
+ notify: () => undefined,
370
+ },
371
+ };
372
+ }
373
+ /**
374
+ * Resolve whether a workspace's project-local `.pi/` resources may load, the
375
+ * way `pi` resolves it — a faithful mirror of the SDK's `resolveProjectTrusted`
376
+ * (`dist/core/project-trust.js`, also not a public export). PI WEB has no
377
+ * browser trust prompt, so the precedence is, in order:
378
+ *
379
+ * 1. Nothing trust-requiring under `cwd` → trusted.
380
+ * 2. Pre-trust extensions (user/global — project-local ones are not loaded
381
+ * yet) may decide via the `project_trust` event; `remember: true` persists
382
+ * the decision to the agent dir's `trust.json`. Handler errors are reported
383
+ * through {@link WebProjectTrustResolution.onExtensionError} and never
384
+ * abort resolution.
385
+ * 3. Otherwise the saved `trust.json` decision wins.
386
+ * 4. Otherwise `defaultProjectTrust` decides (`always` trusts; `never`/`ask`
387
+ * do not — `ask` cannot prompt in the browser, matching `pi` run without a
388
+ * trust UI).
389
+ */
390
+ export async function resolveWebProjectTrusted(resolution) {
391
+ const { cwd, trustStore, settingsManager } = resolution;
392
+ if (!hasTrustRequiringProjectResources(cwd))
393
+ return true;
394
+ if (resolution.extensionsResult) {
395
+ const { result, errors } = await emitWebProjectTrustEvent(resolution.extensionsResult, { type: "project_trust", cwd }, webProjectTrustContext(cwd));
396
+ for (const error of errors) {
397
+ resolution.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
398
+ }
399
+ if (result) {
400
+ const trusted = result.trusted === "yes";
401
+ if (result.remember === true) {
402
+ trustStore.set(cwd, trusted);
403
+ }
404
+ return trusted;
405
+ }
406
+ }
407
+ const saved = trustStore.get(cwd);
408
+ if (saved !== null)
409
+ return saved;
410
+ return settingsManager.getDefaultProjectTrust() === "always";
411
+ }
284
412
  /**
285
413
  * Resource-loader options that append PI WEB's own system-prompt sections.
286
414
  *
287
415
  * `appendSystemPromptOverride` composes with what the loader already resolved,
288
416
  * so the operator's `SYSTEM.md` / `APPEND_SYSTEM.md` files keep their content
289
417
  * 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.
418
+ * nothing to append, leaving the loader exactly as pi configures it.
291
419
  */
292
420
  export function piWebResourceLoaderOptions(appendSystemPromptSections) {
293
421
  if (appendSystemPromptSections.length === 0)
@@ -297,12 +425,49 @@ export function piWebResourceLoaderOptions(appendSystemPromptSections) {
297
425
  function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser, appendSystemPromptSections = []) {
298
426
  const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
299
427
  return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
428
+ // PI WEB always honors pi's project-trust model. When the workspace ships
429
+ // trust-requiring resources, trust is resolved exactly once, mirroring the
430
+ // SDK's flow: the resource loader first loads the pre-trust extension set
431
+ // (user/global; project-local ones stay out) and calls back with it, so
432
+ // those extensions may decide via the `project_trust` event; the resolved
433
+ // value then lands in the SettingsManager before any project-local
434
+ // resource (extensions, packages, settings, prompts) loads. With no
435
+ // browser trust prompt, an untrusted project's resources are skipped
436
+ // (matching `pi` run without a UI). Projects without trust-requiring
437
+ // resources skip resolution entirely and are trusted, as before.
438
+ const projectTrustRequiring = hasTrustRequiringProjectResources(cwd);
439
+ const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: !projectTrustRequiring });
440
+ // Pre-session-creation trust failures (`project_trust` handler errors)
441
+ // land in the runtime diagnostics next to the services diagnostics,
442
+ // exactly as the CLI appends its project-trust diagnostics.
443
+ const projectTrustDiagnostics = [];
300
444
  const services = await createAgentSessionServices({
301
445
  cwd,
302
446
  agentDir,
303
447
  modelRuntime,
448
+ settingsManager,
304
449
  ...(resourceLoaderOptions === undefined ? {} : { resourceLoaderOptions }),
450
+ ...(projectTrustRequiring
451
+ ? {
452
+ resourceLoaderReloadOptions: {
453
+ resolveProjectTrust: async ({ extensionsResult }) => resolveWebProjectTrusted({
454
+ cwd,
455
+ trustStore: new ProjectTrustStore(agentDir),
456
+ settingsManager,
457
+ extensionsResult,
458
+ onExtensionError: (message) => projectTrustDiagnostics.push({ type: "warning", message }),
459
+ }),
460
+ },
461
+ }
462
+ : {}),
305
463
  });
464
+ const modelOptions = await resolveSessionModelOptions({
465
+ services,
466
+ hasExistingSession: sessionManager.buildSessionContext().messages.length > 0,
467
+ ...(initialModel === undefined ? {} : { initialModel }),
468
+ ...(initialThinkingLevel === undefined ? {} : { initialThinkingLevel }),
469
+ });
470
+ services.diagnostics.push(...modelOptions.diagnostics);
306
471
  const resolvedDelegationToolsEnabled = delegationToolsEnabled
307
472
  ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
308
473
  const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
@@ -311,10 +476,11 @@ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subse
311
476
  sessionManager,
312
477
  customTools,
313
478
  ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
314
- ...(initialModel === undefined ? {} : { model: initialModel }),
315
- ...(initialThinkingLevel === undefined ? {} : { thinkingLevel: initialThinkingLevel }),
479
+ ...(modelOptions.model === undefined ? {} : { model: modelOptions.model }),
480
+ ...(modelOptions.thinkingLevel === undefined ? {} : { thinkingLevel: modelOptions.thinkingLevel }),
481
+ ...(modelOptions.scopedModels.length === 0 ? {} : { scopedModels: modelOptions.scopedModels }),
316
482
  });
317
- return { ...result, services, diagnostics: services.diagnostics };
483
+ return { ...result, services, diagnostics: [...projectTrustDiagnostics, ...services.diagnostics] };
318
484
  };
319
485
  }
320
486
  function createPiWebEditToolDefinition(cwd) {
@@ -354,8 +520,24 @@ export class PiSessionService {
354
520
  this.activities = new Map();
355
521
  /** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
356
522
  this.treeNavigations = new WeakSet();
523
+ /**
524
+ * Bare live leaf selected without an appended summary entry. Recording that
525
+ * leaf distinguishes the unpersisted move from a later runtime append that
526
+ * can anchor the selected branch on disk.
527
+ */
528
+ this.unpersistedTreeBranchLeaves = new WeakMap();
357
529
  /** Counts async operations that may append an entry before they settle. */
358
530
  this.sessionEntryMutationCounts = new WeakMap();
531
+ /** Settings-wide queue preventing enabled-model read/modify/write races across sessions. */
532
+ this.modelScopeMutationQueue = Promise.resolve();
533
+ /** Global and workspace-owned enabled-model states; live session scopes project one entry lazily. */
534
+ this.modelScopeStates = new Map();
535
+ /** Coalesces initialization reads independently for the global and each workspace scope. */
536
+ this.modelScopeInitializations = new Map();
537
+ /** Monotonic revision published for global scope changes and consumed by clients. */
538
+ this.modelScopeRevision = 0;
539
+ /** Last scope/catalog revision projected into each runtime, synchronized lazily on use. */
540
+ this.modelScopeCache = new WeakMap();
359
541
  /** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
360
542
  this.treeExclusiveRuntimeOperationCounts = new WeakMap();
361
543
  this.treeExclusiveSessionOperationCounts = new Map();
@@ -379,6 +561,8 @@ export class PiSessionService {
379
561
  */
380
562
  this.subsessionNotifyArmed = new Map();
381
563
  this.notificationGenerationBySession = new WeakMap();
564
+ /** Last idle-poll transcript file resolution per runtime, throttled; entries die with their runtime. */
565
+ this.idleSessionFileResolutions = new WeakMap();
382
566
  /** The parked extension Promise resolvers behind the store's open dialogs. */
383
567
  this.dialogWaiters = new ExtensionDialogWaiters();
384
568
  this.pendingUnreadMutations = [];
@@ -398,6 +582,7 @@ export class PiSessionService {
398
582
  this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
399
583
  this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
400
584
  this.catalogRefreshStatus = deps.catalogRefreshStatus;
585
+ this.config = deps.config;
401
586
  this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
402
587
  this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
403
588
  // Subsessions are gated behind their own flag, and they
@@ -678,17 +863,117 @@ export class PiSessionService {
678
863
  };
679
864
  }
680
865
  /**
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.
866
+ * Resolve the settings domain for a session. Pi's settings manager deep-merges
867
+ * objects, but an `enabledModels` array in `.pi/settings.json` replaces the
868
+ * global array, so that workspace gets its own effective scope and is not
869
+ * affected by global picker edits.
870
+ */
871
+ modelScopeContext(session) {
872
+ const projectSettings = session.settingsManager.getProjectSettings?.() ?? {};
873
+ if (!Object.hasOwn(projectSettings, "enabledModels")) {
874
+ return { key: "global", source: "global", editable: true };
875
+ }
876
+ const workspace = canonicalizeStoredCwd(session.sessionManager.getCwd());
877
+ return { key: `workspace:${workspace}`, source: "workspace", editable: false };
878
+ }
879
+ /**
880
+ * Read the effective scope for one settings domain. Global sessions share one
881
+ * snapshot; project-overridden sessions share a snapshot keyed by workspace.
882
+ * Neither path enumerates or mutates other active sessions.
883
+ */
884
+ async modelScopeSnapshot(session, context = this.modelScopeContext(session)) {
885
+ const existing = this.modelScopeStates.get(context.key);
886
+ if (existing !== undefined)
887
+ return existing;
888
+ const pending = this.modelScopeInitializations.get(context.key);
889
+ if (pending !== undefined)
890
+ return pending;
891
+ const initialization = (async () => {
892
+ const enabledIds = await resolveEnabledModelIds(sessionScopeSource(session));
893
+ const snapshot = {
894
+ enabledIds: enabledIds === null ? null : [...enabledIds],
895
+ revision: 0,
896
+ };
897
+ this.modelScopeStates.set(context.key, snapshot);
898
+ return snapshot;
899
+ })();
900
+ this.modelScopeInitializations.set(context.key, initialization);
901
+ try {
902
+ return await initialization;
903
+ }
904
+ finally {
905
+ if (this.modelScopeInitializations.get(context.key) === initialization)
906
+ this.modelScopeInitializations.delete(context.key);
907
+ }
908
+ }
909
+ /**
910
+ * Project one settings-domain scope into one SDK session only when that
911
+ * session is used. `PiAgentSession` needs an in-memory array for cycling, but
912
+ * that array is a cache; the keyed snapshot above is the source of truth.
913
+ */
914
+ async synchronizeSessionModelScope(session, available) {
915
+ const context = this.modelScopeContext(session);
916
+ const snapshot = await this.modelScopeSnapshot(session, context);
917
+ const catalogKey = available.map(modelScopeId).join("\0");
918
+ const cached = this.modelScopeCache.get(session);
919
+ if (cached?.scopeKey === context.key && cached.revision === snapshot.revision && cached.catalogKey === catalogKey)
920
+ return;
921
+ session.setScopedModels(scopedModelsFromEnabledIds(available, snapshot.enabledIds, session.scopedModels));
922
+ this.modelScopeCache.set(session, { scopeKey: context.key, revision: snapshot.revision, catalogKey });
923
+ }
924
+ /**
925
+ * The models a session may pick from: its synchronized scoped set when
926
+ * model-scoped, otherwise the runtime's available snapshot. Refreshes the
927
+ * shared runtime catalog first so callers see newly configured providers and
928
+ * models. The refresh stays local (`allowNetwork: false`); network refreshes
929
+ * belong to the bounded background catalog refresher, not this request path.
686
930
  */
687
931
  async sessionModelCandidates(session) {
688
932
  await session.modelRuntime.refresh({ allowNetwork: false });
933
+ const available = session.modelRuntime.getAvailableSnapshot();
934
+ await this.synchronizeSessionModelScope(session, available);
689
935
  return session.scopedModels.length > 0
690
936
  ? session.scopedModels.map((scoped) => scoped.model)
691
- : session.modelRuntime.getAvailableSnapshot();
937
+ : available;
938
+ }
939
+ /**
940
+ * The session machine's full available catalog with per-model enabled state,
941
+ * ordered enabled-first for scope semantics while retaining each model's
942
+ * natural catalog index for stable picker placement. The enabled state comes
943
+ * from the keyed scope snapshot rather than a session-local SettingsManager.
944
+ */
945
+ async enabledModelCatalog(session) {
946
+ await session.modelRuntime.refresh({ allowNetwork: false });
947
+ const available = session.modelRuntime.getAvailableSnapshot();
948
+ await this.synchronizeSessionModelScope(session, available);
949
+ const context = this.modelScopeContext(session);
950
+ const enabledIds = (await this.modelScopeSnapshot(session, context)).enabledIds;
951
+ return catalogWithEnabledFirst(available, enabledIds);
952
+ }
953
+ runModelScopeMutation(operation) {
954
+ const queued = this.modelScopeMutationQueue.then(operation);
955
+ this.modelScopeMutationQueue = queued.then(() => undefined, () => undefined);
956
+ return queued;
957
+ }
958
+ /** Persist one global enabled-model selection and publish its new revision. */
959
+ applyEnabledModelScope(session, available, enabledIds) {
960
+ const availableIds = available.map(modelScopeId);
961
+ const persisted = persistedEnabledModelPatterns(enabledIds, availableIds);
962
+ const nextState = {
963
+ // Pi normalizes a scope covering the complete current catalog to an
964
+ // omitted setting, which is the global canonical all-enabled state.
965
+ enabledIds: persisted === undefined ? null : enabledIds === null ? [] : [...enabledIds],
966
+ revision: ++this.modelScopeRevision,
967
+ };
968
+ session.settingsManager.setEnabledModels(persisted);
969
+ this.modelScopeStates.set("global", nextState);
970
+ session.setScopedModels(scopedModelsFromEnabledIds(available, nextState.enabledIds, session.scopedModels));
971
+ this.modelScopeCache.set(session, {
972
+ scopeKey: "global",
973
+ revision: nextState.revision,
974
+ catalogKey: available.map(modelScopeId).join("\0"),
975
+ });
976
+ this.events.publishGlobal({ type: "models.changed", revision: nextState.revision });
692
977
  }
693
978
  /**
694
979
  * Resolve a strict `provider/model-id` spec from a spawn tool against the
@@ -1254,10 +1539,14 @@ export class PiSessionService {
1254
1539
  }
1255
1540
  async messages(ref, page) {
1256
1541
  const session = await this.getOrOpen(ref);
1257
- return pageMessagesAtSafeBoundary(historyMessages(session), page);
1542
+ return pageMessagesAtSafeBoundary(historyMessagesFromEntries(await this.readableSessionBranch(ref, session)), page);
1258
1543
  }
1259
1544
  async status(ref) {
1260
- return this.statusFromSession(await this.sessionForStatusOrDialogClose(ref));
1545
+ const session = await this.sessionForStatusOrDialogClose(ref);
1546
+ if (this.hasActiveWork(session))
1547
+ return this.statusFromSession(session);
1548
+ const branch = await this.readableSessionBranch(ref, session);
1549
+ return this.statusFromSession(session, transcriptMessageCount(branch));
1261
1550
  }
1262
1551
  /**
1263
1552
  * Join-time snapshot of the in-flight assistant stream. The `seq` watermark and
@@ -1283,6 +1572,68 @@ export class PiSessionService {
1283
1572
  const models = await this.sessionModelCandidates(session);
1284
1573
  return models.map(modelToClientModel);
1285
1574
  }
1575
+ async modelCatalog(ref) {
1576
+ const session = await this.getOrOpen(ref);
1577
+ const scope = this.modelScopeContext(session);
1578
+ return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry, scope.editable));
1579
+ }
1580
+ /**
1581
+ * Add/remove one model to/from the global pi `enabledModels` scope. A
1582
+ * workspace `.pi/settings.json` override remains authoritative for that
1583
+ * workspace and is intentionally read-only in this picker.
1584
+ */
1585
+ async setModelEnabled(ref, provider, modelId, enabled) {
1586
+ await this.assertWritable(ref);
1587
+ const session = await this.getOrOpen(ref);
1588
+ return this.runModelScopeMutation(async () => {
1589
+ const scope = this.modelScopeContext(session);
1590
+ if (scope.source !== "global")
1591
+ throw new Error("Model availability is controlled by this workspace's .pi/settings.json");
1592
+ this.assertTreeNavigationInactive(session, "change enabled models");
1593
+ await session.modelRuntime.refresh({ allowNetwork: false });
1594
+ const currentIds = (await this.modelScopeSnapshot(session, scope)).enabledIds;
1595
+ const available = session.modelRuntime.getAvailableSnapshot();
1596
+ const availableIds = available.map(modelScopeId);
1597
+ const targetId = `${provider}/${modelId}`;
1598
+ if (!availableIds.includes(targetId))
1599
+ throw new Error(`Model not found: ${targetId}`);
1600
+ if (!enabled && session.model !== undefined && modelScopeId(session.model) === targetId) {
1601
+ throw new Error("Current model cannot be disabled");
1602
+ }
1603
+ const nextIds = applyEnabledModelToggle(currentIds, availableIds, targetId, enabled);
1604
+ this.assertTreeNavigationInactive(session, "change enabled models");
1605
+ if (nextIds !== currentIds)
1606
+ this.applyEnabledModelScope(session, available, nextIds);
1607
+ // Respond from a fresh post-edit read so the response is exactly what
1608
+ // GET models/catalog returns after the edit, including pi's normalizations.
1609
+ return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry));
1610
+ });
1611
+ }
1612
+ /** Atomically expose every model or narrow the global enabled scope to the current model. */
1613
+ async setModelScope(ref, mode) {
1614
+ await this.assertWritable(ref);
1615
+ const session = await this.getOrOpen(ref);
1616
+ return this.runModelScopeMutation(async () => {
1617
+ const scope = this.modelScopeContext(session);
1618
+ if (scope.source !== "global")
1619
+ throw new Error("Model availability is controlled by this workspace's .pi/settings.json");
1620
+ this.assertTreeNavigationInactive(session, "change enabled models");
1621
+ await session.modelRuntime.refresh({ allowNetwork: false });
1622
+ const available = session.modelRuntime.getAvailableSnapshot();
1623
+ const availableIds = available.map(modelScopeId);
1624
+ let nextIds = null;
1625
+ if (mode === "current") {
1626
+ const current = session.model;
1627
+ if (current === undefined || !availableIds.includes(modelScopeId(current))) {
1628
+ throw new Error("Current model is unavailable");
1629
+ }
1630
+ nextIds = [modelScopeId(current)];
1631
+ }
1632
+ this.assertTreeNavigationInactive(session, "change enabled models");
1633
+ this.applyEnabledModelScope(session, available, nextIds);
1634
+ return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry));
1635
+ });
1636
+ }
1286
1637
  async setModel(ref, provider, modelId) {
1287
1638
  await this.assertWritable(ref);
1288
1639
  const session = await this.getOrOpen(ref);
@@ -1301,7 +1652,11 @@ export class PiSessionService {
1301
1652
  async cycleModel(ref, direction) {
1302
1653
  await this.assertWritable(ref);
1303
1654
  const session = await this.getOrOpen(ref);
1304
- const result = await this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
1655
+ const result = await this.runModelScopeMutation(async () => {
1656
+ await session.modelRuntime.refresh({ allowNetwork: false });
1657
+ await this.synchronizeSessionModelScope(session, session.modelRuntime.getAvailableSnapshot());
1658
+ return this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
1659
+ });
1305
1660
  if (result === undefined)
1306
1661
  throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
1307
1662
  this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
@@ -1410,7 +1765,25 @@ export class PiSessionService {
1410
1765
  return [];
1411
1766
  await this.assertWritable(ref);
1412
1767
  const active = await this.getActive(ref);
1413
- return saveAttachmentsToWorkspace(active.runtime.cwd, parsed, folder === undefined ? {} : { folder });
1768
+ const cwd = active.runtime.cwd;
1769
+ // An explicit request folder wins; the config lookup below is only the
1770
+ // fallback for folder-less calls (see workspaceAttachmentsConfig).
1771
+ const effectiveFolder = folder ?? (await this.workspaceAttachmentsConfig(cwd)).defaultFolder;
1772
+ return saveAttachmentsToWorkspace(cwd, parsed, effectiveFolder === undefined ? {} : { folder: effectiveFolder });
1773
+ }
1774
+ /**
1775
+ * Fallback attachments config for save requests that omit an explicit
1776
+ * folder: the live global config merged with the session cwd's own
1777
+ * project-local override. Unlike `workspaceEffectiveConfig` in app.ts (which
1778
+ * resolves from the owning project's path), this lookup keys off the cwd
1779
+ * itself, so for secondary (worktree) workspaces it cannot see the owning
1780
+ * project's override. The composer therefore always sends the
1781
+ * workspace-effective folder it displayed explicitly; this cwd-based
1782
+ * resolution only governs folder-less API calls.
1783
+ */
1784
+ async workspaceAttachmentsConfig(cwd) {
1785
+ const globalConfig = this.config === undefined ? {} : (await this.config.read()).effectiveConfig;
1786
+ return loadEffectiveProjectAttachmentsConfig(cwd, globalConfig);
1414
1787
  }
1415
1788
  async shell(ref, text) {
1416
1789
  await this.assertWritable(ref);
@@ -1475,18 +1848,53 @@ export class PiSessionService {
1475
1848
  // may enter this runtime until Pi's potentially asynchronous summary settles.
1476
1849
  this.treeNavigations.add(session);
1477
1850
  try {
1478
- if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
1851
+ const oldLeafId = session.sessionManager.getLeafId();
1852
+ if (oldLeafId !== request.expectedLeafId) {
1479
1853
  throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
1480
1854
  }
1855
+ const activeEditableTargetParentId = activeEditableTreeTargetParentId(session.sessionManager, request.targetId, oldLeafId);
1481
1856
  this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
1482
1857
  this.publishStatus(session);
1483
- const result = await session.navigateTree(request.targetId, options);
1858
+ let result = await session.navigateTree(request.targetId, options);
1859
+ if (activeEditableTargetParentId !== undefined
1860
+ && !result.cancelled
1861
+ && result.editorText === undefined
1862
+ && result.summaryEntry === undefined
1863
+ && session.sessionManager.getLeafId() === oldLeafId) {
1864
+ // Supported Pi versions can return early when the target is already the
1865
+ // leaf, before applying their user/custom-message edit semantics. Move
1866
+ // to the target's parent and delegate again so Pi still owns editor-text
1867
+ // extraction, agent-context rebuilding, and tree extension events.
1868
+ setSessionTreeLeaf(session.sessionManager, activeEditableTargetParentId);
1869
+ try {
1870
+ result = await session.navigateTree(request.targetId, options);
1871
+ }
1872
+ catch (error) {
1873
+ session.sessionManager.branch(request.targetId);
1874
+ throw error;
1875
+ }
1876
+ if (result.cancelled)
1877
+ session.sessionManager.branch(request.targetId);
1878
+ }
1484
1879
  if (result.cancelled) {
1485
1880
  if (this.isCurrentActiveSession(session)) {
1486
1881
  this.publishActivity(session, result.aborted === true ? "branch summary aborted" : "tree navigation cancelled", "idle");
1487
1882
  }
1488
1883
  return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
1489
1884
  }
1885
+ if (result.summaryEntry !== undefined) {
1886
+ // A summary entry durably identifies the selected branch as the file's
1887
+ // newest leaf, superseding any earlier bare selection.
1888
+ this.unpersistedTreeBranchLeaves.delete(session);
1889
+ }
1890
+ else {
1891
+ const selectedLeafId = session.sessionManager.getLeafId();
1892
+ if (selectedLeafId !== oldLeafId) {
1893
+ // SessionManager.branch()/resetLeaf() only move Pi's in-memory leaf.
1894
+ // Keep that branch authoritative until a later append makes disk agree.
1895
+ this.unpersistedTreeBranchLeaves.set(session, selectedLeafId);
1896
+ }
1897
+ }
1490
1898
  if (this.isCurrentActiveSession(session))
1491
1899
  this.publishActivity(session, "session tree navigated", "idle");
1492
1900
  return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
@@ -2097,6 +2505,63 @@ export class PiSessionService {
2097
2505
  async getOrOpen(ref) {
2098
2506
  return (await this.getActive(ref)).runtime.session;
2099
2507
  }
2508
+ /**
2509
+ * An idle runtime is only Pi Web's cached control view. Another Pi process may
2510
+ * keep appending to the same JSONL file, so transcript reads must open a fresh
2511
+ * read-only snapshot rather than serving that cached branch forever. Active
2512
+ * runtimes remain authoritative and are never replaced, reloaded, or aborted.
2513
+ */
2514
+ async readableSessionBranch(ref, session) {
2515
+ if (this.hasActiveWork(session) || this.sessionManager.readBranch === undefined)
2516
+ return session.sessionManager.getBranch();
2517
+ const sessionFile = session.sessionFile ?? session.sessionManager.getSessionFile();
2518
+ const resolvedPath = sessionFile === undefined ? await this.idleSessionFilePath(ref, session) : undefined;
2519
+ if (this.hasActiveWork(session))
2520
+ return session.sessionManager.getBranch();
2521
+ const path = sessionFile ?? resolvedPath;
2522
+ if (path === undefined)
2523
+ return session.sessionManager.getBranch();
2524
+ const snapshot = await this.sessionManager.readBranch(path);
2525
+ // No snapshot exists when the transcript file is absent (a session known
2526
+ // by path but never persisted, or externally removed): the runtime branch
2527
+ // is the only readable branch then.
2528
+ if (snapshot === undefined)
2529
+ return session.sessionManager.getBranch();
2530
+ // Reading also yields. A prompt that started meanwhile must still win over
2531
+ // the completed disk snapshot and its potentially older event watermark.
2532
+ if (this.hasActiveWork(session))
2533
+ return session.sessionManager.getBranch();
2534
+ const selectedLeafId = this.unpersistedTreeBranchLeaves.get(session);
2535
+ if (selectedLeafId !== undefined) {
2536
+ const runtimeLeafId = session.sessionManager.getLeafId();
2537
+ const selectedBranchIsAnchored = runtimeLeafId !== null
2538
+ && runtimeLeafId !== selectedLeafId
2539
+ && transcriptBranchIncludesEntry(snapshot, runtimeLeafId);
2540
+ if (!selectedBranchIsAnchored)
2541
+ return session.sessionManager.getBranch();
2542
+ // A later runtime append now anchors the live selection in this persisted
2543
+ // branch. Resume ordinary idle snapshots, including external descendants.
2544
+ this.unpersistedTreeBranchLeaves.delete(session);
2545
+ }
2546
+ return snapshot;
2547
+ }
2548
+ /**
2549
+ * Resolve the transcript file for an idle runtime that does not know one,
2550
+ * at most once per {@link IDLE_SESSION_FILE_RESOLUTION_THROTTLE_MS}. The
2551
+ * negative result (no file yet) is the hot case: a never-persisted session
2552
+ * is polled every few seconds, and re-scanning its directory each tick is
2553
+ * pure waste. `getActive` never uses this path, so prompt routing always
2554
+ * sees a fresh resolution.
2555
+ */
2556
+ async idleSessionFilePath(ref, session) {
2557
+ const cached = this.idleSessionFileResolutions.get(session);
2558
+ const at = this.now().getTime();
2559
+ if (cached !== undefined && at - cached.at < IDLE_SESSION_FILE_RESOLUTION_THROTTLE_MS)
2560
+ return cached.path;
2561
+ const match = await this.sessionManager.resolveSessionFile(ref.cwd, ref.id);
2562
+ this.idleSessionFileResolutions.set(session, { at, path: match?.path });
2563
+ return match?.path;
2564
+ }
2100
2565
  async getActive(ref, options = {}) {
2101
2566
  const active = this.activeForRef(ref);
2102
2567
  if (active !== undefined)
@@ -2938,7 +3403,7 @@ export class PiSessionService {
2938
3403
  this.events.publish(session.sessionId, { type: "activity.update", activity });
2939
3404
  this.events.publishGlobal({ type: "activity.update", activity });
2940
3405
  }
2941
- statusFromSession(session) {
3406
+ statusFromSession(session, messageCount = session.messages.length) {
2942
3407
  const stats = session.getSessionStats();
2943
3408
  const model = session.model === undefined ? undefined : modelToClientModel(session.model);
2944
3409
  const contextUsage = session.getContextUsage();
@@ -2955,7 +3420,7 @@ export class PiSessionService {
2955
3420
  isBashRunning: session.isBashRunning,
2956
3421
  pendingMessageCount: this.pendingMessageCount(session),
2957
3422
  queuedMessages: queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)),
2958
- messageCount: session.messages.length,
3423
+ messageCount,
2959
3424
  tokens: stats.tokens,
2960
3425
  cost: stats.cost,
2961
3426
  ...(contextUsage === undefined ? {} : { contextUsage }),
@@ -3040,6 +3505,20 @@ function modelToClientModel(model) {
3040
3505
  ...(reasoning === undefined ? {} : { reasoning }),
3041
3506
  };
3042
3507
  }
3508
+ /** The scope source pi's enabled-id precedence reads from: live scope, settings patterns, runtime catalog. */
3509
+ function sessionScopeSource(session) {
3510
+ return { settingsManager: session.settingsManager, modelRuntime: session.modelRuntime, scopedModels: session.scopedModels };
3511
+ }
3512
+ function catalogEntryToClientModel(entry, editable = true) {
3513
+ return {
3514
+ ...modelToClientModel(entry.model),
3515
+ provider: entry.model.provider,
3516
+ id: entry.model.id,
3517
+ enabled: entry.enabled,
3518
+ catalogIndex: entry.catalogIndex,
3519
+ ...(editable ? {} : { editable: false }),
3520
+ };
3521
+ }
3043
3522
  function notificationIdentityForSession(session) {
3044
3523
  return {
3045
3524
  sessionId: session.sessionId,
@@ -3413,11 +3892,14 @@ function annotateAssistantThinkingLevel(message, thinkingLevel) {
3413
3892
  return { ...message, thinkingLevel };
3414
3893
  }
3415
3894
  function historyMessages(session) {
3895
+ return historyMessagesFromEntries(session.sessionManager.getBranch());
3896
+ }
3897
+ function historyMessagesFromEntries(entries) {
3416
3898
  const messages = [];
3417
3899
  // Pi records the initial level at session creation and every later change, so
3418
3900
  // walking the branch yields the level in effect for each assistant message.
3419
3901
  let thinkingLevel;
3420
- for (const entry of session.sessionManager.getBranch()) {
3902
+ for (const entry of entries) {
3421
3903
  if (!isRecord(entry))
3422
3904
  continue;
3423
3905
  if (entry["type"] === "message")
@@ -3436,6 +3918,39 @@ function historyMessages(session) {
3436
3918
  }
3437
3919
  return messages;
3438
3920
  }
3921
+ function transcriptMessageCount(entries) {
3922
+ let count = 0;
3923
+ for (const entry of entries) {
3924
+ if (isRecord(entry) && entry["type"] === "message")
3925
+ count += 1;
3926
+ }
3927
+ return count;
3928
+ }
3929
+ function activeEditableTreeTargetParentId(manager, targetId, activeLeafId) {
3930
+ if (activeLeafId !== targetId)
3931
+ return undefined;
3932
+ const entry = manager.getBranch().at(-1);
3933
+ if (!isRecord(entry) || entry["id"] !== targetId)
3934
+ return undefined;
3935
+ const isUserMessage = entry["type"] === "message"
3936
+ && isRecord(entry["message"])
3937
+ && entry["message"]["role"] === "user";
3938
+ if (!isUserMessage && entry["type"] !== "custom_message")
3939
+ return undefined;
3940
+ const parentId = entry["parentId"];
3941
+ if (parentId === targetId)
3942
+ return undefined;
3943
+ return parentId === null || typeof parentId === "string" ? parentId : undefined;
3944
+ }
3945
+ function setSessionTreeLeaf(manager, leafId) {
3946
+ if (leafId === null)
3947
+ manager.resetLeaf();
3948
+ else
3949
+ manager.branch(leafId);
3950
+ }
3951
+ function transcriptBranchIncludesEntry(entries, entryId) {
3952
+ return entries.some((entry) => isRecord(entry) && entry["id"] === entryId);
3953
+ }
3439
3954
  /** custom entry type used to persist parent -> child subsession links outside LLM context. */
3440
3955
  const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
3441
3956
  /** custom entry type used to mark a child as created by spawn_subsession. */