@hyperdreamer/pi-webui 1.10.7 → 1.11.0-beta.10

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 (92) hide show
  1. package/README.md +17 -2
  2. package/dist/cli.js +265 -32
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{CodeViewer-BIb2Wx8W.js → CodeViewer-CAf_j27u.js} +1 -1
  5. package/dist/client/assets/{UnifiedDiffViewer-DpWiGlQo.js → UnifiedDiffViewer-wBjEJWa1.js} +1 -1
  6. package/dist/client/assets/{index-DX583DEv.js → index-5EtDb9nj.js} +1197 -476
  7. package/dist/client/index.html +1 -1
  8. package/dist/config.js +52 -11
  9. package/dist/config.js.map +1 -1
  10. package/dist/pi-webui-plugins/workspace-memory/pi-webui-plugin.js +3 -2
  11. package/dist/pi-webui-plugins/workspace-tasks/pi-webui-plugin.js +1 -0
  12. package/dist/plugin-api.d.ts +30 -0
  13. package/dist/server/app.js +6 -1
  14. package/dist/server/app.js.map +1 -1
  15. package/dist/server/realtime/sessionEventHub.js +40 -5
  16. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  17. package/dist/server/realtime/sessionStatusCoalescer.js +137 -0
  18. package/dist/server/realtime/sessionStatusCoalescer.js.map +1 -0
  19. package/dist/server/sessiond/sessionProxyRoutes.js +2 -0
  20. package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
  21. package/dist/server/sessiond.js +57 -5
  22. package/dist/server/sessiond.js.map +1 -1
  23. package/dist/server/sessions/modelPolicyCapability.js +88 -0
  24. package/dist/server/sessions/modelPolicyCapability.js.map +1 -0
  25. package/dist/server/sessions/modelPolicyTool.js +31 -0
  26. package/dist/server/sessions/modelPolicyTool.js.map +1 -0
  27. package/dist/server/sessions/modelTierRegistry.js +14 -0
  28. package/dist/server/sessions/modelTierRegistry.js.map +1 -1
  29. package/dist/server/sessions/modelTierSettingsRoutes.js +36 -0
  30. package/dist/server/sessions/modelTierSettingsRoutes.js.map +1 -0
  31. package/dist/server/sessions/modelTierSettingsService.js +100 -0
  32. package/dist/server/sessions/modelTierSettingsService.js.map +1 -0
  33. package/dist/server/sessions/piSessionService.js +1643 -433
  34. package/dist/server/sessions/piSessionService.js.map +1 -1
  35. package/dist/server/sessions/sessionDefaultsRoutes.js +39 -0
  36. package/dist/server/sessions/sessionDefaultsRoutes.js.map +1 -1
  37. package/dist/server/sessions/sessionDefaultsService.js +36 -6
  38. package/dist/server/sessions/sessionDefaultsService.js.map +1 -1
  39. package/dist/server/sessions/sessionMetadataStore.js +107 -13
  40. package/dist/server/sessions/sessionMetadataStore.js.map +1 -1
  41. package/dist/server/sessions/sessionModelPolicy.js +118 -0
  42. package/dist/server/sessions/sessionModelPolicy.js.map +1 -0
  43. package/dist/server/sessions/sessionReorder.js +117 -0
  44. package/dist/server/sessions/sessionReorder.js.map +1 -0
  45. package/dist/server/sessions/sessionRouteFastifyOptions.js +8 -0
  46. package/dist/server/sessions/sessionRouteFastifyOptions.js.map +1 -0
  47. package/dist/server/sessions/sessionRoutes.js +208 -3
  48. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  49. package/dist/server/sessions/starterModelPolicyPreferenceStore.js +164 -0
  50. package/dist/server/sessions/starterModelPolicyPreferenceStore.js.map +1 -0
  51. package/dist/server/sessions/utilityModelExtension.js +199 -0
  52. package/dist/server/sessions/utilityModelExtension.js.map +1 -0
  53. package/dist/server/sessions/utilityModelResolver.js +77 -0
  54. package/dist/server/sessions/utilityModelResolver.js.map +1 -0
  55. package/dist/server/sessions/utilityModelSettingsRoutes.js +62 -0
  56. package/dist/server/sessions/utilityModelSettingsRoutes.js.map +1 -0
  57. package/dist/server/sessions/utilityModelSettingsService.js +101 -0
  58. package/dist/server/sessions/utilityModelSettingsService.js.map +1 -0
  59. package/dist/server/skills/optionalSkillInstall.js +69 -0
  60. package/dist/server/skills/optionalSkillInstall.js.map +1 -0
  61. package/dist/server/skills/optionalSkillInstaller.js +148 -0
  62. package/dist/server/skills/optionalSkillInstaller.js.map +1 -0
  63. package/dist/shared/apiTypes.d.ts +129 -3
  64. package/dist/shared/apiTypes.js +10 -0
  65. package/dist/shared/apiTypes.js.map +1 -1
  66. package/dist/shared/capabilities.js +15 -0
  67. package/dist/shared/capabilities.js.map +1 -1
  68. package/dist/shared/federatedRoutes.js +7 -0
  69. package/dist/shared/federatedRoutes.js.map +1 -1
  70. package/docs/assets/pi-webui-desktop.png +0 -0
  71. package/docs/config.md +61 -3
  72. package/docs/plugins.md +93 -16
  73. package/optional-skills/deterministic-subagent-driven-development/SKILL.md +224 -0
  74. package/optional-skills/deterministic-subagent-driven-development/pi-webui-skill.json +28 -0
  75. package/optional-skills/deterministic-subagent-driven-development/prompts/final-reviewer.md +132 -0
  76. package/optional-skills/deterministic-subagent-driven-development/prompts/implementer.md +101 -0
  77. package/optional-skills/deterministic-subagent-driven-development/prompts/re-reviewer.md +60 -0
  78. package/optional-skills/deterministic-subagent-driven-development/prompts/task-reviewer.md +80 -0
  79. package/optional-skills/deterministic-subagent-driven-development/references/capability-contract.md +174 -0
  80. package/optional-skills/deterministic-subagent-driven-development/references/plan-contract.md +268 -0
  81. package/optional-skills/deterministic-subagent-driven-development/references/state-machine.md +177 -0
  82. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/manifest.mjs +258 -0
  83. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/plan-policy.mjs +350 -0
  84. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/prompt-renderer.mjs +290 -0
  85. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/state-machine.mjs +1264 -0
  86. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/state-store.mjs +532 -0
  87. package/optional-skills/deterministic-subagent-driven-development/scripts/sdd-state +3 -0
  88. package/optional-skills/deterministic-subagent-driven-development/scripts/sdd-state.mjs +349 -0
  89. package/optional-skills/deterministic-writing-plans/SKILL.md +232 -0
  90. package/optional-skills/deterministic-writing-plans/references/grammar.md +84 -0
  91. package/optional-skills/deterministic-writing-plans/templates/plan-skeleton.md +143 -0
  92. package/package.json +13 -9
@@ -7,27 +7,37 @@ import { projectBrowserMessage } from "../browserMessageProjection.js";
7
7
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
8
8
  import { exportSessionHistoryHtml } from "./sessionHistoryExport.js";
9
9
  import { BUILTIN_COMMANDS } from "./builtinCommands.js";
10
- import { SessionCommandService, clientSessionFromRuntime } from "./sessionCommandService.js";
11
- import { projectSessionTree } from "./sessionTreeProjection.js";
12
- import { SessionArchiveStore } from "./sessionArchiveStore.js";
13
- import { SessionMetadataStore } from "./sessionMetadataStore.js";
14
- import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
15
- import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
10
+ import { SessionCommandService, clientSessionFromRuntime, } from "./sessionCommandService.js";
11
+ import { projectSessionTree, } from "./sessionTreeProjection.js";
12
+ import { SessionArchiveStore, } from "./sessionArchiveStore.js";
13
+ import { SessionMetadataStore, SessionMetadataOrderConflictError } from "./sessionMetadataStore.js";
14
+ import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, } from "./sessionArchiveTree.js";
15
+ import { deterministicSessionName, fallbackSessionName, generateShortSessionName, } from "./sessionNameGenerator.js";
16
16
  import { computeEditPreview } from "./editPreview.js";
17
- import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
17
+ import { attachmentsToInlineImages, saveAttachmentsToWorkspace, } from "./attachmentService.js";
18
18
  import { parsePromptAttachments } from "../../shared/promptAttachments.js";
19
19
  import { isKnownThinkingLevel } from "../../shared/thinkingLevels.js";
20
- import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
20
+ import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT, } from "../../shared/apiTypes.js";
21
+ import { assertSubmittedSessionsCurrent, SessionReorderDomainError, validateSessionReorder, } from "./sessionReorder.js";
21
22
  import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
22
- import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
23
- import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
23
+ import { createSpawnSessionToolDefinition, } from "./spawnSessionTool.js";
24
+ import { createSubsessionToolDefinitions, } from "./spawnSubsessionTool.js";
25
+ import { createModelPolicyToolDefinition, } from "./modelPolicyTool.js";
26
+ import { buildModelPolicyCapability, } from "./modelPolicyCapability.js";
24
27
  import { buildTranscriptView } from "./subsessionTranscript.js";
25
- import { planForceCleanup, planSessionCleanup, summarizeForceCleanupExecution, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
28
+ import { planForceCleanup, planSessionCleanup, summarizeForceCleanupExecution, summarizeSessionCleanupExecution, } from "./sessionCleanup.js";
26
29
  import { SessionNotificationStore, } from "./sessionNotificationStore.js";
27
30
  import { plainTextTheme } from "./plainTextTheme.js";
28
- import { SessionUnreadStore } from "./sessionUnreadStore.js";
29
- import { createModelTierRegistry, isModelTier, runtimeThinkingLevels } from "./modelTierRegistry.js";
30
- const noopLogger = { info() { } };
31
+ import { SessionUnreadStore, } from "./sessionUnreadStore.js";
32
+ import { createModelTierRegistry, isModelTier, runtimeThinkingLevels, } from "./modelTierRegistry.js";
33
+ import { createUtilityModelExtension, } from "./utilityModelExtension.js";
34
+ import { createUtilityModelResolver, runWithUtilityModelFallback, } from "./utilityModelResolver.js";
35
+ import { inspectSessionModelPolicy, planSessionModelPolicyUpdate, serializeSessionModelPolicy, SESSION_MODEL_POLICY_CUSTOM_TYPE, } from "./sessionModelPolicy.js";
36
+ const noopLogger = {
37
+ info() {
38
+ /* no-op */
39
+ },
40
+ };
31
41
  const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
32
42
  const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
33
43
  const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
@@ -89,13 +99,13 @@ function requirePromptText(value) {
89
99
  throw new Error("Prompt text is required");
90
100
  return value;
91
101
  }
92
- function leadingTierDirective(prompt) {
93
- const match = /^(?:\uFEFF)?\/tier-([^\s\r\n]+)(?:[ \t]*(?:\r?\n|$))/u.exec(prompt);
102
+ function leadingTierEcho(prompt) {
103
+ const match = /^(?:\uFEFF)?Model tier: ([^\s\r\n]+)(?:[ \t]*(?:\r?\n|$))/u.exec(prompt);
94
104
  if (match === null)
95
105
  return undefined;
96
106
  const value = match[1];
97
107
  if (value === undefined || !isModelTier(value))
98
- throw new Error(`Unknown leading tier directive: /tier-${value ?? ""}`);
108
+ throw new Error(`Unknown leading model tier label: ${value ?? ""}`);
99
109
  return value;
100
110
  }
101
111
  function parsePromptStreamingBehavior(value) {
@@ -105,7 +115,10 @@ function parsePromptStreamingBehavior(value) {
105
115
  return value;
106
116
  throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
107
117
  }
108
- const CLEAR_RUNTIME_NOTIFICATIONS = { kind: "clear", reason: "runtime-close" };
118
+ const CLEAR_RUNTIME_NOTIFICATIONS = {
119
+ kind: "clear",
120
+ reason: "runtime-close",
121
+ };
109
122
  const DEFER_RUNTIME_NOTIFICATIONS = { kind: "defer" };
110
123
  function resourceDiagnosticToWarning(diagnostic, source) {
111
124
  return {
@@ -116,7 +129,11 @@ function resourceDiagnosticToWarning(diagnostic, source) {
116
129
  };
117
130
  }
118
131
  function runtimeDiagnosticToWarning(diagnostic) {
119
- return { severity: diagnostic.type, message: diagnostic.message, source: "runtime" };
132
+ return {
133
+ severity: diagnostic.type,
134
+ message: diagnostic.message,
135
+ source: "runtime",
136
+ };
120
137
  }
121
138
  /**
122
139
  * Compute the live warnings for a runtime by re-reading its current resource
@@ -140,7 +157,12 @@ export function collectRuntimeWarnings(runtime) {
140
157
  for (const diagnostic of resourceLoader.getThemes().diagnostics)
141
158
  warnings.push(resourceDiagnosticToWarning(diagnostic, "theme"));
142
159
  for (const error of resourceLoader.getExtensions().errors) {
143
- warnings.push({ severity: "error", message: `${error.path}: ${error.error}`, source: "extension", path: error.path });
160
+ warnings.push({
161
+ severity: "error",
162
+ message: `${error.path}: ${error.error}`,
163
+ source: "extension",
164
+ path: error.path,
165
+ });
144
166
  }
145
167
  }
146
168
  return warnings;
@@ -206,7 +228,10 @@ export function dismissSessionWarning(session, dismissId) {
206
228
  if (dismissId !== ANTHROPIC_EXTRA_USAGE_DISMISS_ID) {
207
229
  throw new Error(`Unknown session warning dismiss id: ${dismissId}`);
208
230
  }
209
- session.settingsManager.setWarnings({ ...session.settingsManager.getWarnings(), anthropicExtraUsage: false });
231
+ session.settingsManager.setWarnings({
232
+ ...session.settingsManager.getWarnings(),
233
+ anthropicExtraUsage: false,
234
+ });
210
235
  }
211
236
  function defaultCreateAgentRuntime(createRuntime, options) {
212
237
  if (!(options.sessionManager instanceof SessionManager))
@@ -234,32 +259,72 @@ function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, ini
234
259
  return createRuntime({
235
260
  ...options,
236
261
  ...(model === undefined ? {} : { initialModel: model }),
237
- ...(thinkingLevel === undefined ? {} : { initialThinkingLevel: thinkingLevel }),
238
- ...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }),
262
+ ...(thinkingLevel === undefined
263
+ ? {}
264
+ : { initialThinkingLevel: thinkingLevel }),
265
+ ...(toolsEnabled === undefined
266
+ ? {}
267
+ : { delegationToolsEnabled: toolsEnabled }),
239
268
  });
240
269
  };
241
270
  }
242
- export function createPiWebUiCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions) {
271
+ export function createPiWebUiCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, modelPolicy) {
243
272
  return [
244
273
  createPiWebUiEditToolDefinition(cwd),
245
- ...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
246
- ...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
274
+ ...(delegationEnabled && spawn !== undefined
275
+ ? [createSpawnSessionToolDefinition(cwd, { spawn })]
276
+ : []),
277
+ ...(delegationEnabled && subsessions !== undefined
278
+ ? createSubsessionToolDefinitions(cwd, subsessions)
279
+ : []),
280
+ // Paired with the subsession tools: the policy view exists so a dispatching
281
+ // agent can verify tier dispatch before using it, so it is pointless without
282
+ // the dispatch capability and misleading if advertised separately.
283
+ ...(delegationEnabled &&
284
+ subsessions !== undefined &&
285
+ modelPolicy !== undefined
286
+ ? [createModelPolicyToolDefinition(modelPolicy)]
287
+ : []),
247
288
  ];
248
289
  }
249
- function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions) {
250
- return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
251
- const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
252
- const resolvedDelegationToolsEnabled = delegationToolsEnabled
253
- ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
254
- const customTools = createPiWebUiCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
255
- const result = await createAgentSessionFromServices({
290
+ export function createDefaultRuntimeFactory(modelRuntime, sessionManagers, utilityModelResolver, logger, spawn, subsessions, modelPolicy, sdk = {
291
+ createServices: createAgentSessionServices,
292
+ createFromServices: createAgentSessionFromServices,
293
+ }) {
294
+ return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled, }) => {
295
+ const runtimeRefs = {};
296
+ const services = await sdk.createServices({
297
+ cwd,
298
+ agentDir,
299
+ modelRuntime,
300
+ resourceLoaderOptions: {
301
+ extensionFactories: [
302
+ createUtilityModelExtension({
303
+ // Pi's ExtensionContext exposes Model<any>; production candidates still come from ModelRuntime's Model<Api> snapshot.
304
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
305
+ resolver: utilityModelResolver,
306
+ modelRuntime,
307
+ refs: runtimeRefs,
308
+ logger,
309
+ }),
310
+ ],
311
+ },
312
+ });
313
+ runtimeRefs.settingsManager = services.settingsManager;
314
+ const resolvedDelegationToolsEnabled = delegationToolsEnabled ??
315
+ (await sessionAllowsDelegationTools(sessionManager, sessionManagers));
316
+ const customTools = createPiWebUiCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, modelPolicy);
317
+ const result = await sdk.createFromServices({
256
318
  services,
257
319
  sessionManager,
258
320
  customTools,
259
321
  ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
260
322
  ...(initialModel === undefined ? {} : { model: initialModel }),
261
- ...(initialThinkingLevel === undefined ? {} : { thinkingLevel: initialThinkingLevel }),
323
+ ...(initialThinkingLevel === undefined
324
+ ? {}
325
+ : { thinkingLevel: initialThinkingLevel }),
262
326
  });
327
+ runtimeRefs.streamFunction = result.session.agent.streamFunction;
263
328
  return { ...result, services, diagnostics: services.diagnostics };
264
329
  };
265
330
  }
@@ -269,16 +334,29 @@ function createPiWebUiEditToolDefinition(cwd) {
269
334
  name: editTool.name,
270
335
  label: editTool.label,
271
336
  description: editTool.description,
272
- ...(editTool.promptSnippet === undefined ? {} : { promptSnippet: editTool.promptSnippet }),
273
- ...(editTool.promptGuidelines === undefined ? {} : { promptGuidelines: editTool.promptGuidelines }),
337
+ ...(editTool.promptSnippet === undefined
338
+ ? {}
339
+ : { promptSnippet: editTool.promptSnippet }),
340
+ ...(editTool.promptGuidelines === undefined
341
+ ? {}
342
+ : { promptGuidelines: editTool.promptGuidelines }),
274
343
  parameters: editTool.parameters,
275
- ...(editTool.renderShell === undefined ? {} : { renderShell: editTool.renderShell }),
276
- ...(editTool.prepareArguments === undefined ? {} : { prepareArguments: editTool.prepareArguments }),
277
- ...(editTool.executionMode === undefined ? {} : { executionMode: editTool.executionMode }),
344
+ ...(editTool.renderShell === undefined
345
+ ? {}
346
+ : { renderShell: editTool.renderShell }),
347
+ ...(editTool.prepareArguments === undefined
348
+ ? {}
349
+ : { prepareArguments: editTool.prepareArguments }),
350
+ ...(editTool.executionMode === undefined
351
+ ? {}
352
+ : { executionMode: editTool.executionMode }),
278
353
  async execute(toolCallId, params, signal, onUpdate, ctx) {
279
354
  const preview = await computeEditPreview(params.path, params.edits, cwd);
280
355
  if (signal?.aborted !== true) {
281
- onUpdate?.({ content: [{ type: "text", text: "Edit preview computed." }], details: { preview } });
356
+ onUpdate?.({
357
+ content: [{ type: "text", text: "Edit preview computed." }],
358
+ details: { preview },
359
+ });
282
360
  }
283
361
  return editTool.execute(toolCallId, params, signal, onUpdate, ctx);
284
362
  },
@@ -291,6 +369,43 @@ export class PiSessionService {
291
369
  this.pendingSessionOpens = new Map();
292
370
  this.activities = new Map();
293
371
  this.generationMetrics = new WeakMap();
372
+ this.modelPolicyInspections = new WeakMap();
373
+ this.modelPolicyLadderValidations = new WeakMap();
374
+ /**
375
+ * Reason the newest persisted policy entry is unusable. Recomputed on every
376
+ * inspection refresh, so repairing the entry clears it.
377
+ */
378
+ this.modelPolicyEntryReasons = new WeakMap();
379
+ /**
380
+ * `MODEL_POLICY_BLOCKED` runtime state from an application whose restoration
381
+ * could not be proven. Deliberately *not* cleared by inspection refresh: only
382
+ * an explicit successful policy application clears it, because the runtime
383
+ * tuple — not the persisted entry — is what became ambiguous.
384
+ *
385
+ * Keyed by session *id* rather than by the runtime session object so the block
386
+ * is daemon-owned: it survives a runtime rebind, a reload, and a close/reopen
387
+ * of the same session, none of which prove anything about the ambiguous tuple.
388
+ */
389
+ this.modelPolicyRuntimeBlocks = new Map();
390
+ /**
391
+ * Sessions inside the apply-and-persist window of a policy transition. During
392
+ * that window the runtime tuple is transient (model set, thinking not yet, or
393
+ * persistence not yet confirmed), so no prompt may reach Pi.
394
+ */
395
+ this.modelPolicyMutationCounts = new Map();
396
+ /**
397
+ * Tuple last *confirmed* by the policy runtime adapter, captured when the
398
+ * outermost policy mutation opens and dropped when it closes.
399
+ *
400
+ * A status may be published from inside the transition window (the runtime
401
+ * subscription and the heartbeat both reach `publishStatus`), where the live
402
+ * runtime tuple is mid-apply: the model is set but the thinking level is not,
403
+ * so reading it would report a pair that was never requested and never
404
+ * persisted. `ClientSessionModelPolicyStatus.resolved` promises the last
405
+ * confirmed tuple, so the window reports this instead of live state. Keyed by
406
+ * session id to match the mutation counter it is scoped by.
407
+ */
408
+ this.modelPolicyConfirmedSelections = new Map();
294
409
  /** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
295
410
  this.treeNavigations = new WeakSet();
296
411
  /** Counts async operations that may append an entry before they settle. */
@@ -321,42 +436,90 @@ export class PiSessionService {
321
436
  this.pendingUnreadMutations = [];
322
437
  this.unreadPublicationFlushRequested = false;
323
438
  this.unreadPublicationStopped = false;
439
+ /**
440
+ * Serializes reorder against every other mutation that can change sibling
441
+ * membership, pin state, or current/archive membership (pin, unpin,
442
+ * detach-parent, archive, archiveMany, archiveTree, restore, and cleanup),
443
+ * so no such mutation can interleave with a reorder's before/after catalog
444
+ * reads and its atomic metadata write.
445
+ */
446
+ this.sessionOrderMutationQueue = Promise.resolve();
324
447
  this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
325
448
  this.metadataStore = deps.metadataStore ?? new SessionMetadataStore();
449
+ this.clearParentSessionFile = deps.clearParentSession ?? clearParentSession;
326
450
  this.agentDir = deps.agentDir;
327
451
  this.sessionManager = deps.sessionManager;
328
452
  this.modelRuntime = deps.modelRuntime;
329
453
  this.spawnTargets = deps.spawnTargets;
330
454
  this.logger = deps.logger ?? noopLogger;
455
+ this.utilityModelResolver =
456
+ deps.utilityModelResolver ??
457
+ createUtilityModelResolver({
458
+ loadConfig: () => {
459
+ const loaded = loadPiWebUiConfig();
460
+ return {
461
+ ...(loaded.config.utilityModels === undefined
462
+ ? {}
463
+ : { utilityModels: loaded.config.utilityModels }),
464
+ ...(loaded.utilityModelsError === undefined
465
+ ? {}
466
+ : { utilityModelsError: loaded.utilityModelsError }),
467
+ };
468
+ },
469
+ modelRuntime: this.modelRuntime,
470
+ logger: this.logger,
471
+ });
331
472
  this.now = deps.now ?? (() => new Date());
332
- this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
473
+ this.notificationStore =
474
+ deps.notificationStore ?? new SessionNotificationStore();
333
475
  this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
334
476
  this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
335
477
  this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
336
478
  // Subsessions are a beta capability gated behind their own flag, and they
337
479
  // also require the spawn capability (they share its project-scope resolver).
338
480
  const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
339
- this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
340
- spawn: (input) => this.spawnSubsession(input),
341
- list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
342
- check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
343
- read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
344
- });
345
- this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
346
- this.modelTierRegistry = deps.modelTierRegistry ?? createModelTierRegistry({
347
- loadConfig: () => {
348
- const loaded = loadPiWebUiConfig();
349
- return {
350
- ...(loaded.config.modelTiers === undefined ? {} : { modelTiers: loaded.config.modelTiers }),
351
- ...(loaded.modelTiersError === undefined ? {} : { modelTiersError: loaded.modelTiersError }),
352
- };
353
- },
354
- models: () => this.modelRuntime.getAvailableSnapshot(),
355
- supportedThinkingLevels: runtimeThinkingLevels,
356
- });
481
+ this.createRuntime =
482
+ deps.createRuntime ??
483
+ createDefaultRuntimeFactory(this.modelRuntime, this.sessionManager, this.utilityModelResolver, this.logger, this.spawnTargets === undefined
484
+ ? undefined
485
+ : (input) => this.spawnSession(input), !subsessionsActive
486
+ ? undefined
487
+ : {
488
+ spawn: (input) => this.spawnSubsession(input),
489
+ list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
490
+ check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
491
+ read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
492
+ }, !subsessionsActive
493
+ ? undefined
494
+ : {
495
+ inspect: (sessionId) => this.modelPolicyCapability(sessionId),
496
+ });
497
+ this.createAgentRuntime =
498
+ deps.createAgentRuntime ?? defaultCreateAgentRuntime;
499
+ this.modelTierRegistry =
500
+ deps.modelTierRegistry ??
501
+ createModelTierRegistry({
502
+ loadConfig: () => {
503
+ const loaded = loadPiWebUiConfig();
504
+ return {
505
+ ...(loaded.config.modelTiers === undefined
506
+ ? {}
507
+ : { modelTiers: loaded.config.modelTiers }),
508
+ ...(loaded.modelTiersError === undefined
509
+ ? {}
510
+ : { modelTiersError: loaded.modelTiersError }),
511
+ };
512
+ },
513
+ models: () => this.modelRuntime.getAvailableSnapshot(),
514
+ supportedThinkingLevels: runtimeThinkingLevels,
515
+ });
357
516
  this.workspaceActivity = deps.workspaceActivity;
358
- this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
359
- this.commandService = new SessionCommandService((sessionId) => this.getActive(sessionId), (sessionId, text) => this.prompt(sessionId, text, undefined, undefined, { echoUserMessage: false }), events, {
517
+ this.heartbeat = setInterval(() => {
518
+ this.publishHeartbeats();
519
+ }, deps.heartbeatIntervalMs ?? 2000);
520
+ this.commandService = new SessionCommandService((sessionId) => this.getActive(sessionId), (sessionId, text) => this.prompt(sessionId, text, undefined, undefined, {
521
+ echoUserMessage: false,
522
+ }), events, {
360
523
  onCompactionStart: (session) => {
361
524
  this.beginSessionEntryMutation(session, "compact the session");
362
525
  this.publishActivity(session, "compacting", "active");
@@ -369,7 +532,8 @@ export class PiSessionService {
369
532
  },
370
533
  reloadSession: (session) => this.reloadSessionRuntime(session),
371
534
  getSessionTree: (session) => {
372
- if (typeof session.sessionManager.getTree !== "function" || typeof session.navigateTree !== "function")
535
+ if (typeof session.sessionManager.getTree !== "function" ||
536
+ typeof session.navigateTree !== "function")
373
537
  return undefined;
374
538
  return projectSessionTree(session.sessionManager.getTree(), session.sessionManager.getLeafId());
375
539
  },
@@ -413,47 +577,58 @@ export class PiSessionService {
413
577
  return previewResponseFromPlan(await this.cleanupPlan(request));
414
578
  }
415
579
  async cleanup(request) {
416
- const plan = await this.cleanupPlan(request);
417
- if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
418
- throw new Error("Archive store does not support deletion");
419
- const archiveInputs = [];
420
- const readyArchiveInputs = [];
421
- const deleteRecords = [];
422
- const readyDeleteRecords = [];
423
- const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
424
- for (const input of plan.archiveInputs) {
425
- if (this.activeSessionHasWork(input.sessionId)) {
426
- skippedBusySessionIds.add(input.sessionId);
427
- continue;
580
+ return await this.runSessionOrderMutation(async () => {
581
+ const plan = await this.cleanupPlan(request);
582
+ if (plan.deleteRecords.length > 0 &&
583
+ this.archiveStore.deleteArchived === undefined &&
584
+ this.archiveStore.deleteArchivedMany === undefined)
585
+ throw new Error("Archive store does not support deletion");
586
+ const archiveInputs = [];
587
+ const readyArchiveInputs = [];
588
+ const deleteRecords = [];
589
+ const readyDeleteRecords = [];
590
+ const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
591
+ for (const input of plan.archiveInputs) {
592
+ if (this.activeSessionHasWork(input.sessionId)) {
593
+ skippedBusySessionIds.add(input.sessionId);
594
+ continue;
595
+ }
596
+ await this.closeActive(input.sessionId, {
597
+ kind: "clear",
598
+ reason: "archive",
599
+ });
600
+ readyArchiveInputs.push(input);
428
601
  }
429
- await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
430
- readyArchiveInputs.push(input);
431
- }
432
- await this.archiveStoreArchiveMany(readyArchiveInputs);
433
- archiveInputs.push(...readyArchiveInputs);
434
- await this.forgetUnreadSessions(readyArchiveInputs);
435
- for (const record of plan.deleteRecords) {
436
- if (this.activeSessionHasWork(record.sessionId)) {
437
- skippedBusySessionIds.add(record.sessionId);
438
- continue;
602
+ await this.archiveStoreArchiveMany(readyArchiveInputs);
603
+ archiveInputs.push(...readyArchiveInputs);
604
+ await this.forgetUnreadSessions(readyArchiveInputs);
605
+ for (const record of plan.deleteRecords) {
606
+ if (this.activeSessionHasWork(record.sessionId)) {
607
+ skippedBusySessionIds.add(record.sessionId);
608
+ continue;
609
+ }
610
+ await this.closeActive(record.sessionId, {
611
+ kind: "clear",
612
+ reason: "delete",
613
+ });
614
+ readyDeleteRecords.push(record);
439
615
  }
440
- await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
441
- readyDeleteRecords.push(record);
442
- }
443
- await this.ensureArchivedRecordsMoved(readyDeleteRecords);
444
- const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
445
- deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
446
- await this.forgetUnreadSessions(deleteRecords);
447
- return summarizeSessionCleanupExecution({
448
- archiveInputs,
449
- deleteRecords,
450
- thresholds: plan.thresholds,
451
- generatedAt: plan.generatedAt,
452
- skippedBusySessionIds: [...skippedBusySessionIds],
616
+ await this.ensureArchivedRecordsMoved(readyDeleteRecords);
617
+ const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
618
+ deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
619
+ await this.forgetUnreadSessions(deleteRecords);
620
+ return summarizeSessionCleanupExecution({
621
+ archiveInputs,
622
+ deleteRecords,
623
+ thresholds: plan.thresholds,
624
+ generatedAt: plan.generatedAt,
625
+ skippedBusySessionIds: [...skippedBusySessionIds],
626
+ });
453
627
  });
454
628
  }
455
629
  async forceCleanup() {
456
- if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
630
+ if (this.archiveStore.deleteArchived === undefined &&
631
+ this.archiveStore.deleteArchivedMany === undefined)
457
632
  throw new Error("Archive store does not support deletion");
458
633
  const archivedRecords = await this.archiveStore.list();
459
634
  const plan = planForceCleanup({
@@ -468,7 +643,10 @@ export class PiSessionService {
468
643
  skippedBusySessionIds.add(record.sessionId);
469
644
  continue;
470
645
  }
471
- await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
646
+ await this.closeActive(record.sessionId, {
647
+ kind: "clear",
648
+ reason: "delete",
649
+ });
472
650
  readyDeleteRecords.push(record);
473
651
  }
474
652
  await this.ensureArchivedRecordsMoved(readyDeleteRecords);
@@ -496,6 +674,9 @@ export class PiSessionService {
496
674
  this.pendingSessionOpens.clear();
497
675
  this.activities.clear();
498
676
  this.compactionPromptQueues.clear();
677
+ this.modelPolicyRuntimeBlocks.clear();
678
+ this.modelPolicyMutationCounts.clear();
679
+ this.modelPolicyConfirmedSelections.clear();
499
680
  this.authLossWarnings.clear();
500
681
  this.subsessionParents.clear();
501
682
  this.subsessionChildren.clear();
@@ -517,12 +698,68 @@ export class PiSessionService {
517
698
  await this.publishUnreadMutations([]);
518
699
  }
519
700
  async list(cwd) {
520
- const [sessions, archivedRecords, pinnedPaths] = await Promise.all([
701
+ const [sessions, archivedRecords, metadata] = await Promise.all([
521
702
  this.sessionManager.list(cwd),
522
703
  this.archiveStore.list(),
523
- this.metadataStore.pinnedPaths(),
704
+ this.metadataStore.snapshot(),
705
+ ]);
706
+ return this.listFromSnapshots(cwd, sessions, archivedRecords, metadata);
707
+ }
708
+ /**
709
+ * Run `operation` inside the exclusive ordering queue shared by every
710
+ * mutation that can change sibling membership, pin state, or
711
+ * current/archive membership, so reorder's before/after catalog reads
712
+ * never interleave with a concurrent pin, detach, archive, or restore.
713
+ */
714
+ async runSessionOrderMutation(operation) {
715
+ const previous = this.sessionOrderMutationQueue;
716
+ let release = () => undefined;
717
+ this.sessionOrderMutationQueue = new Promise((resolve) => { release = resolve; });
718
+ await previous.catch(() => undefined);
719
+ try {
720
+ return await operation();
721
+ }
722
+ finally {
723
+ release();
724
+ }
725
+ }
726
+ async reorder(ref, request) {
727
+ return await this.runSessionOrderMutation(async () => {
728
+ const target = { id: ref.id, cwd: canonicalizeStoredCwd(ref.cwd) };
729
+ const before = await this.listReorderCatalog(request.catalogCwds);
730
+ const validated = validateSessionReorder(target, request, uniqueSessionsByIdentity(before));
731
+ try {
732
+ await this.metadataStore.replaceOrder(validated.sessionPaths, request.scope, request.pinned);
733
+ }
734
+ catch (error) {
735
+ if (error instanceof SessionMetadataOrderConflictError) {
736
+ throw new SessionReorderDomainError("conflict", error.message);
737
+ }
738
+ throw error;
739
+ }
740
+ const after = await this.listReorderCatalog(request.catalogCwds);
741
+ assertSubmittedSessionsCurrent(target, request, uniqueSessionsByIdentity(after));
742
+ return validated.response;
743
+ });
744
+ }
745
+ /**
746
+ * Read one coherent metadata/archive view across every requested CWD.
747
+ * Gateway session-list reads run in parallel per CWD, but the metadata and
748
+ * archive snapshots are each taken exactly once for the whole call, so the
749
+ * result is one atomic before/after catalog rather than one per CWD.
750
+ */
751
+ async listReorderCatalog(cwds) {
752
+ const metadataPromise = this.metadataStore.snapshot();
753
+ const archivePromise = this.archiveStore.list();
754
+ const sessionListsPromise = Promise.all(cwds.map((cwd) => this.sessionManager.list(cwd)));
755
+ const [metadata, archivedRecords, sessionLists] = await Promise.all([
756
+ metadataPromise,
757
+ archivePromise,
758
+ sessionListsPromise,
524
759
  ]);
525
- const pinnedPathSet = new Set(pinnedPaths);
760
+ return (await Promise.all(cwds.map((cwd, index) => this.listFromSnapshots(cwd, sessionLists[index] ?? [], archivedRecords, metadata)))).flat();
761
+ }
762
+ async listFromSnapshots(cwd, sessions, archivedRecords, metadata) {
526
763
  const sessionsById = new Map(sessions.map((session) => [session.id, session]));
527
764
  const archivedForCwd = await Promise.all(archivedRecords
528
765
  .filter((record) => record.cwd === cwd)
@@ -533,7 +770,7 @@ export class PiSessionService {
533
770
  }
534
771
  const unarchivedSessions = sessions
535
772
  .filter((session) => !archivedById.has(session.id))
536
- .map((entry) => mergeSessionMetadata(clientSessionFromListEntry(entry), pinnedPathSet));
773
+ .map((entry) => mergeSessionMetadata(clientSessionFromListEntry(entry), metadata));
537
774
  const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
538
775
  this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
539
776
  await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
@@ -541,17 +778,31 @@ export class PiSessionService {
541
778
  .sort(compareArchivedRecords)
542
779
  .map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
543
780
  .filter(isDefined)
544
- .map((session) => mergeSessionMetadata(session, pinnedPathSet));
781
+ .map((session) => mergeSessionMetadata(session, metadata));
545
782
  return [...unarchivedSessions, ...archivedSessions];
546
783
  }
547
- async start(cwd, options = {}) {
548
- return this.startSession(cwd, options);
784
+ async start(cwd, options) {
785
+ const modelPolicy = options?.modelPolicy;
786
+ return this.startSession(cwd, {
787
+ initializeModelPolicy: modelPolicy ?? true,
788
+ });
549
789
  }
550
790
  async startSession(cwd, options) {
551
- const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
552
- ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
553
- ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
554
- ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
791
+ const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined
792
+ ? undefined
793
+ : { parentSession: options.parentSession }), cwd, {
794
+ ...(options.initialModel === undefined
795
+ ? {}
796
+ : { initialModel: options.initialModel }),
797
+ ...(options.initialThinkingLevel === undefined
798
+ ? {}
799
+ : { initialThinkingLevel: options.initialThinkingLevel }),
800
+ ...(options.creationProvenance === undefined
801
+ ? {}
802
+ : { creationProvenance: options.creationProvenance }),
803
+ ...(options.initializeModelPolicy === undefined
804
+ ? {}
805
+ : { initializeModelPolicy: options.initializeModelPolicy }),
555
806
  });
556
807
  const { session } = active.runtime;
557
808
  const created = {
@@ -565,7 +816,9 @@ export class PiSessionService {
565
816
  firstMessage: "",
566
817
  // Include the parent so listeners can nest the new session in the tree
567
818
  // immediately, instead of showing it flat until the next reload.
568
- ...(options.parentSession === undefined ? {} : { parentSessionPath: options.parentSession }),
819
+ ...(options.parentSession === undefined
820
+ ? {}
821
+ : { parentSessionPath: options.parentSession }),
569
822
  };
570
823
  // Broadcast so other clients (and the spawning agent's UI) can add the new
571
824
  // session to their list without a manual reload.
@@ -583,9 +836,14 @@ export class PiSessionService {
583
836
  const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
584
837
  if (!decision.allowed)
585
838
  throw spawnTargetError(decision);
586
- const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
839
+ const created = await this.startSession(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
587
840
  await this.prompt(created.id, input.prompt);
588
- this.logger.info({ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_session started a new session");
841
+ this.logger.info({
842
+ spawningCwd: input.spawningCwd,
843
+ sessionId: created.id,
844
+ cwd: decision.cwd,
845
+ promptLength: input.prompt.length,
846
+ }, "spawn_session started a new session");
589
847
  return { sessionId: created.id, cwd: decision.cwd };
590
848
  }
591
849
  /**
@@ -603,9 +861,9 @@ export class PiSessionService {
603
861
  let initialModel = input.model;
604
862
  let initialThinkingLevel;
605
863
  if (input.tier !== undefined) {
606
- const echoedTier = leadingTierDirective(input.prompt);
864
+ const echoedTier = leadingTierEcho(input.prompt);
607
865
  if (echoedTier !== undefined && echoedTier !== input.tier) {
608
- throw new Error(`Leading tier directive /tier-${echoedTier} disagrees with typed tier ${input.tier}`);
866
+ throw new Error(`Leading Model tier: ${echoedTier} label disagrees with typed tier ${input.tier}`);
609
867
  }
610
868
  const resolved = this.modelTierRegistry.resolve(input.tier);
611
869
  if (!isKnownThinkingLevel(resolved.thinkingLevel)) {
@@ -615,7 +873,9 @@ export class PiSessionService {
615
873
  initialThinkingLevel = resolved.thinkingLevel;
616
874
  }
617
875
  const created = await this.startSession(decision.cwd, {
618
- ...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
876
+ ...(input.parentSessionFile === undefined
877
+ ? {}
878
+ : { parentSession: input.parentSessionFile }),
619
879
  ...(initialModel === undefined ? {} : { initialModel }),
620
880
  ...(initialThinkingLevel === undefined ? {} : { initialThinkingLevel }),
621
881
  creationProvenance: "tracked-subsession",
@@ -632,7 +892,12 @@ export class PiSessionService {
632
892
  this.persistSubsessionLink(link);
633
893
  this.persistSubsessionChildMarker(input.parentSessionId, created.id);
634
894
  await this.prompt(created.id, input.prompt);
635
- this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
895
+ this.logger.info({
896
+ parentSessionId: input.parentSessionId,
897
+ sessionId: created.id,
898
+ cwd: decision.cwd,
899
+ promptLength: input.prompt.length,
900
+ }, "spawn_subsession started a tracked child session");
636
901
  return { sessionId: created.id, cwd: decision.cwd };
637
902
  }
638
903
  /** Summaries of the tracked subsessions spawned by `parentSessionId`. */
@@ -643,7 +908,10 @@ export class PiSessionService {
643
908
  if (childIds === undefined)
644
909
  return [];
645
910
  const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId));
646
- return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
911
+ return Promise.all(authorizedChildIds.map(async (childId) => ({
912
+ sessionId: childId,
913
+ ...(await this.subsessionSummaryFields(childId)),
914
+ })));
647
915
  }
648
916
  /** Status and final result of a subsession, scoped to the caller's children. */
649
917
  async checkSubsession(parentSessionId, sessionId, parentSessionFile) {
@@ -672,7 +940,8 @@ export class PiSessionService {
672
940
  async openSubsession(parentSessionId, sessionId, parentSessionFile) {
673
941
  const parentFile = nonEmptyString(parentSessionFile);
674
942
  await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
675
- if (this.subsessionParents.get(sessionId) !== parentSessionId || !this.subsessionLinkBelongsToParent(parentSessionId, parentFile, sessionId)) {
943
+ if (this.subsessionParents.get(sessionId) !== parentSessionId ||
944
+ !this.subsessionLinkBelongsToParent(parentSessionId, parentFile, sessionId)) {
676
945
  throw new Error(`Session ${sessionId} is not one of your subsessions`);
677
946
  }
678
947
  return this.getOrOpenTrackedSubsession(sessionId);
@@ -681,19 +950,24 @@ export class PiSessionService {
681
950
  const link = this.subsessionLinks.get(childSessionId);
682
951
  if (link?.parentSessionId !== parentSessionId)
683
952
  return false;
684
- return parentSessionFile === undefined || trackedLinkParentFileMatches(link, parentSessionFile);
953
+ return (parentSessionFile === undefined ||
954
+ trackedLinkParentFileMatches(link, parentSessionFile));
685
955
  }
686
956
  activeChildForSubsessionLink(link) {
687
957
  const active = this.active.get(link.childSessionId);
688
958
  if (active === undefined)
689
959
  return undefined;
690
- return activeSessionFileMatches(active, link.childSessionFile) ? active : undefined;
960
+ return activeSessionFileMatches(active, link.childSessionFile)
961
+ ? active
962
+ : undefined;
691
963
  }
692
964
  activeParentForSubsessionLink(link) {
693
965
  const active = this.active.get(link.parentSessionId);
694
966
  if (active === undefined)
695
967
  return undefined;
696
- return activeSessionFileMatches(active, link.parentSessionFile) ? active : undefined;
968
+ return activeSessionFileMatches(active, link.parentSessionFile)
969
+ ? active
970
+ : undefined;
697
971
  }
698
972
  subsessionLinkForActiveChild(session) {
699
973
  const childId = session.sessionId;
@@ -701,12 +975,15 @@ export class PiSessionService {
701
975
  const link = this.subsessionLinks.get(childId);
702
976
  if (parentId === undefined || link?.parentSessionId !== parentId)
703
977
  return undefined;
704
- return sessionFileMatches(session, link.childSessionFile) ? link : undefined;
978
+ return sessionFileMatches(session, link.childSessionFile)
979
+ ? link
980
+ : undefined;
705
981
  }
706
982
  async registerVerifiedSubsession(link) {
707
983
  const { childSessionId, parentSessionId } = link;
708
984
  const previousParentId = this.subsessionParents.get(childSessionId);
709
- if (previousParentId !== undefined && previousParentId !== parentSessionId) {
985
+ if (previousParentId !== undefined &&
986
+ previousParentId !== parentSessionId) {
710
987
  const previousChildren = this.subsessionChildren.get(previousParentId);
711
988
  previousChildren?.delete(childSessionId);
712
989
  if (previousChildren?.size === 0)
@@ -760,7 +1037,11 @@ export class PiSessionService {
760
1037
  parent.sessionManager.appendCustomEntry(SUBSESSION_LINK_CUSTOM_TYPE, persistedParentSubsessionLinkData(link));
761
1038
  }
762
1039
  catch (error) {
763
- this.logger.info({ parentSessionId: link.parentSessionId, sessionId: link.childSessionId, error: error instanceof Error ? error.message : String(error) }, "failed to persist subsession link");
1040
+ this.logger.info({
1041
+ parentSessionId: link.parentSessionId,
1042
+ sessionId: link.childSessionId,
1043
+ error: error instanceof Error ? error.message : String(error),
1044
+ }, "failed to persist subsession link");
764
1045
  }
765
1046
  }
766
1047
  persistSubsessionChildMarker(parentSessionId, childSessionId) {
@@ -773,7 +1054,11 @@ export class PiSessionService {
773
1054
  child.sessionManager.appendCustomEntry(SUBSESSION_CHILD_LINK_CUSTOM_TYPE, persistedChildSubsessionLinkData(parentSessionId, childSessionId));
774
1055
  }
775
1056
  catch (error) {
776
- this.logger.info({ parentSessionId, sessionId: childSessionId, error: error instanceof Error ? error.message : String(error) }, "failed to persist subsession child marker");
1057
+ this.logger.info({
1058
+ parentSessionId,
1059
+ sessionId: childSessionId,
1060
+ error: error instanceof Error ? error.message : String(error),
1061
+ }, "failed to persist subsession child marker");
777
1062
  }
778
1063
  }
779
1064
  async hydrateSubsessionsForParent(parentSessionId, parentSessionFile) {
@@ -781,7 +1066,9 @@ export class PiSessionService {
781
1066
  if (this.subsessionHydratedParents.has(hydrationKey))
782
1067
  return;
783
1068
  const activeParent = this.active.get(parentSessionId);
784
- if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) {
1069
+ if (activeParent !== undefined &&
1070
+ (parentSessionFile === undefined ||
1071
+ activeSessionFileMatches(activeParent, parentSessionFile))) {
785
1072
  const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile);
786
1073
  const complete = await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
787
1074
  if (complete)
@@ -790,7 +1077,8 @@ export class PiSessionService {
790
1077
  }
791
1078
  if (parentSessionFile === undefined)
792
1079
  return;
793
- if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId)
1080
+ if ((await readSessionHeaderSummary(parentSessionFile))?.id !==
1081
+ parentSessionId)
794
1082
  return;
795
1083
  let parentManager;
796
1084
  try {
@@ -832,8 +1120,11 @@ export class PiSessionService {
832
1120
  return trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile);
833
1121
  }
834
1122
  async parentLinkHasValidChildTarget(parentSessionFile, link) {
835
- return link.spawnedSessionFile !== undefined
836
- && await sessionFileHeaderMatches(link.spawnedSessionFile, { sessionId: link.spawnedSessionId, parentSessionFile });
1123
+ return (link.spawnedSessionFile !== undefined &&
1124
+ (await sessionFileHeaderMatches(link.spawnedSessionFile, {
1125
+ sessionId: link.spawnedSessionId,
1126
+ parentSessionFile,
1127
+ })));
837
1128
  }
838
1129
  async recoverSubsessionTrackingForOpenedSession(session) {
839
1130
  const link = await this.verifiedSubsessionLinkFromOpenedChild(session);
@@ -857,7 +1148,10 @@ export class PiSessionService {
857
1148
  if (active !== undefined)
858
1149
  return active.runtime.session;
859
1150
  if (link.childSessionFile !== undefined) {
860
- if (!(await sessionFileHeaderMatches(link.childSessionFile, { sessionId, parentSessionFile: link.parentSessionFile })))
1151
+ if (!(await sessionFileHeaderMatches(link.childSessionFile, {
1152
+ sessionId,
1153
+ parentSessionFile: link.parentSessionFile,
1154
+ })))
861
1155
  throw new Error("Session not found");
862
1156
  const sessionManager = this.sessionManager.open(link.childSessionFile);
863
1157
  return (await this.create(sessionManager, link.cwd ?? sessionManager.getCwd())).runtime.session;
@@ -868,9 +1162,16 @@ export class PiSessionService {
868
1162
  const link = this.subsessionLinks.get(childSessionId);
869
1163
  const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
870
1164
  if (active !== undefined) {
871
- return { cwd: active.runtime.cwd, status: this.subsessionStatus(active.runtime.session) };
1165
+ return {
1166
+ cwd: active.runtime.cwd,
1167
+ status: this.subsessionStatus(active.runtime.session),
1168
+ };
872
1169
  }
873
- if (link?.childSessionFile !== undefined && (await sessionFileHeaderMatches(link.childSessionFile, { sessionId: childSessionId, parentSessionFile: link.parentSessionFile }))) {
1170
+ if (link?.childSessionFile !== undefined &&
1171
+ (await sessionFileHeaderMatches(link.childSessionFile, {
1172
+ sessionId: childSessionId,
1173
+ parentSessionFile: link.parentSessionFile,
1174
+ }))) {
874
1175
  return { cwd: link.cwd ?? "", status: "idle" };
875
1176
  }
876
1177
  if (link?.cwd !== undefined)
@@ -890,7 +1191,9 @@ export class PiSessionService {
890
1191
  return [];
891
1192
  return [...childIds].filter((childId) => {
892
1193
  const link = this.subsessionLinks.get(childId);
893
- const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
1194
+ const active = link === undefined
1195
+ ? undefined
1196
+ : this.activeChildForSubsessionLink(link);
894
1197
  return active !== undefined && this.hasActiveWork(active.runtime.session);
895
1198
  });
896
1199
  }
@@ -932,11 +1235,13 @@ export class PiSessionService {
932
1235
  const parentSessionFile = link.parentSessionFile;
933
1236
  if (parentSessionFile === undefined)
934
1237
  throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
935
- if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
1238
+ if ((await readSessionHeaderSummary(parentSessionFile))?.id !==
1239
+ parentSessionId) {
936
1240
  throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
937
1241
  }
938
1242
  const sessionManager = this.sessionManager.open(parentSessionFile);
939
- return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session;
1243
+ return (await this.create(sessionManager, sessionManager.getCwd())).runtime
1244
+ .session;
940
1245
  }
941
1246
  /**
942
1247
  * Deliver a subsession-completion notice to the parent as a system-authored
@@ -954,18 +1259,31 @@ export class PiSessionService {
954
1259
  this.deferredSubsessionNotifications.set(session, pending);
955
1260
  return;
956
1261
  }
957
- await this.deliverSubsessionNotification(session, { parentId, childId, text });
1262
+ await this.deliverSubsessionNotification(session, {
1263
+ parentId,
1264
+ childId,
1265
+ text,
1266
+ });
958
1267
  }
959
1268
  catch (error) {
960
1269
  this.logSubsessionNotificationFailure(parentId, childId, error);
961
1270
  }
962
1271
  }
963
1272
  async deliverSubsessionNotification(session, notification) {
964
- await this.runSessionEntryMutation(session, "deliver a subsession notification", () => session.sendCustomMessage({ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: notification.text, display: true, details: { sessionId: notification.childId } }, { triggerTurn: true, deliverAs: "followUp" }));
1273
+ await this.runSessionEntryMutation(session, "deliver a subsession notification", () => session.sendCustomMessage({
1274
+ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE,
1275
+ content: notification.text,
1276
+ display: true,
1277
+ details: { sessionId: notification.childId },
1278
+ }, { triggerTurn: true, deliverAs: "followUp" }));
965
1279
  this.publishStatus(session);
966
1280
  }
967
1281
  logSubsessionNotificationFailure(parentId, childId, error) {
968
- this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
1282
+ this.logger.info({
1283
+ parentSessionId: parentId,
1284
+ sessionId: childId,
1285
+ error: error instanceof Error ? error.message : String(error),
1286
+ }, "failed to notify parent of subsession completion");
969
1287
  }
970
1288
  async exportHistory(ref) {
971
1289
  return exportSessionHistoryHtml(await this.sessionFileForHistory(ref));
@@ -977,6 +1295,43 @@ export class PiSessionService {
977
1295
  async status(ref) {
978
1296
  return this.statusFromSession(await this.getOrOpen(ref));
979
1297
  }
1298
+ async modelPolicy(ref) {
1299
+ const session = await this.getOrOpen(ref);
1300
+ const inspection = this.inspectAndCacheSessionModelPolicy(session);
1301
+ return {
1302
+ contractVersion: 1,
1303
+ ...(inspection.kind === "invalid" ? {} : { policy: inspection.policy }),
1304
+ session: this.statusFromSession(session),
1305
+ };
1306
+ }
1307
+ /**
1308
+ * The only interface that changes a session's Exact/Tiered policy. Applies one
1309
+ * complete transition atomically and publishes a single confirmed status; a
1310
+ * corrupt newest entry is repaired only by the explicit update supplied here,
1311
+ * never executed as a silent fallback. A transition that fails after the plan
1312
+ * is resolved publishes one error/blocked status before the route error, so
1313
+ * other clients learn the session refused the change.
1314
+ */
1315
+ async setModelPolicy(ref, update) {
1316
+ const action = "change the session model policy";
1317
+ await this.assertWritable(ref);
1318
+ const session = await this.getOrOpen(ref);
1319
+ this.assertModelPolicyMutationIdle(session, action);
1320
+ const inspection = this.inspectAndCacheSessionModelPolicy(session);
1321
+ // A malformed newest entry contributes only a starting exact branch for the
1322
+ // new explicit entry; it is never treated as an executable current policy.
1323
+ const current = inspection.kind === "invalid" ? inspection.fallback : inspection.policy;
1324
+ const policy = await this.applySessionModelPolicy(session, current, update, {
1325
+ action,
1326
+ onTransitionFailure: (reason) => {
1327
+ this.publishModelPolicyTransitionFailure(session, reason);
1328
+ },
1329
+ });
1330
+ const resolved = this.exactSelectionFromSession(session);
1331
+ this.publishActivity(session, `model policy: ${policy.mode}`, "idle", policy.mode === "tiered" ? policy.tier : resolved.model.id);
1332
+ this.publishStatus(session);
1333
+ return this.modelPolicy(ref);
1334
+ }
980
1335
  async systemPrompt(ref) {
981
1336
  const systemPrompt = (await this.getOrOpen(ref)).state.systemPrompt;
982
1337
  return systemPrompt === undefined ? {} : { systemPrompt };
@@ -1011,17 +1366,16 @@ export class PiSessionService {
1011
1366
  async setModel(ref, provider, modelId) {
1012
1367
  await this.assertWritable(ref);
1013
1368
  const session = await this.getOrOpen(ref);
1014
- this.assertTreeNavigationInactive(session, "change models");
1369
+ const currentPolicy = this.assertExactModelPolicyMutationAllowed(session, "change models");
1015
1370
  await session.modelRuntime.refresh({ allowNetwork: false });
1016
- this.assertTreeNavigationInactive(session, "change models");
1371
+ this.assertModelPolicyMutationIdle(session, "change models");
1017
1372
  const candidates = session.scopedModels.length > 0
1018
1373
  ? session.scopedModels.map((scoped) => scoped.model)
1019
1374
  : session.modelRuntime.getAvailableSnapshot();
1020
- const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
1021
- ?? session.modelRuntime.getModel(provider, modelId);
1375
+ const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId) ?? session.modelRuntime.getModel(provider, modelId);
1022
1376
  if (model === undefined)
1023
1377
  throw new Error(`Model not found: ${provider}/${modelId}`);
1024
- await this.runSessionEntryMutation(session, "change models", () => session.setModel(model));
1378
+ await this.runExactModelPolicyMutation(session, "change models", currentPolicy, () => session.setModel(model));
1025
1379
  this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
1026
1380
  this.publishStatus(session);
1027
1381
  return this.statusFromSession(session);
@@ -1029,9 +1383,16 @@ export class PiSessionService {
1029
1383
  async cycleModel(ref, direction) {
1030
1384
  await this.assertWritable(ref);
1031
1385
  const session = await this.getOrOpen(ref);
1032
- const result = await this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
1033
- if (result === undefined)
1034
- throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
1386
+ const currentPolicy = this.assertExactModelPolicyMutationAllowed(session, "change models");
1387
+ const result = await this.runExactModelPolicyMutation(session, "change models", currentPolicy, async () => {
1388
+ const cycled = await session.cycleModel(direction);
1389
+ if (cycled === undefined) {
1390
+ throw new Error(session.scopedModels.length > 0
1391
+ ? "Only one model in scope"
1392
+ : "Only one model available");
1393
+ }
1394
+ return cycled;
1395
+ });
1035
1396
  this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
1036
1397
  this.publishStatus(session);
1037
1398
  return this.statusFromSession(session);
@@ -1043,14 +1404,17 @@ export class PiSessionService {
1043
1404
  async setThinkingLevel(ref, level) {
1044
1405
  await this.assertWritable(ref);
1045
1406
  const session = await this.getOrOpen(ref);
1046
- this.assertTreeNavigationInactive(session, "change the thinking level");
1407
+ const currentPolicy = this.assertExactModelPolicyMutationAllowed(session, "change the thinking level");
1047
1408
  // pi owns the valid set; validate against the session's live levels rather
1048
1409
  // than a hardcoded union so this stays correct if pi changes the set.
1049
1410
  const available = session.getAvailableThinkingLevels();
1050
1411
  const match = available.find((candidate) => candidate === level);
1051
1412
  if (match === undefined)
1052
1413
  throw new Error(`Invalid thinking level: ${level}`);
1053
- session.setThinkingLevel(match);
1414
+ await this.runExactModelPolicyMutation(session, "change the thinking level", currentPolicy, () => {
1415
+ session.setThinkingLevel(match);
1416
+ return Promise.resolve();
1417
+ });
1054
1418
  this.publishActivity(session, `thinking: ${session.thinkingLevel}`, "idle");
1055
1419
  this.publishStatus(session);
1056
1420
  return this.statusFromSession(session);
@@ -1058,10 +1422,13 @@ export class PiSessionService {
1058
1422
  async cycleThinkingLevel(ref) {
1059
1423
  await this.assertWritable(ref);
1060
1424
  const session = await this.getOrOpen(ref);
1061
- this.assertTreeNavigationInactive(session, "change the thinking level");
1062
- const level = session.cycleThinkingLevel();
1063
- if (level === undefined)
1064
- throw new Error("Current model does not support thinking");
1425
+ const currentPolicy = this.assertExactModelPolicyMutationAllowed(session, "change the thinking level");
1426
+ const level = await this.runExactModelPolicyMutation(session, "change the thinking level", currentPolicy, () => {
1427
+ const cycled = session.cycleThinkingLevel();
1428
+ if (cycled === undefined)
1429
+ throw new Error("Current model does not support thinking");
1430
+ return Promise.resolve(cycled);
1431
+ });
1065
1432
  this.publishActivity(session, `thinking: ${level}`, "idle");
1066
1433
  this.publishStatus(session);
1067
1434
  return this.statusFromSession(session);
@@ -1070,13 +1437,31 @@ export class PiSessionService {
1070
1437
  const session = await this.getOrOpen(ref);
1071
1438
  const commands = [...BUILTIN_COMMANDS];
1072
1439
  for (const command of session.extensionRunner.getRegisteredCommands()) {
1073
- commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" });
1440
+ commands.push({
1441
+ name: command.invocationName,
1442
+ ...(command.description === undefined
1443
+ ? {}
1444
+ : { description: command.description }),
1445
+ source: "extension",
1446
+ });
1074
1447
  }
1075
1448
  for (const template of session.promptTemplates) {
1076
- commands.push({ name: template.name, ...(template.description === undefined ? {} : { description: template.description }), source: "prompt" });
1449
+ commands.push({
1450
+ name: template.name,
1451
+ ...(template.description === undefined
1452
+ ? {}
1453
+ : { description: template.description }),
1454
+ source: "prompt",
1455
+ });
1077
1456
  }
1078
1457
  for (const skill of session.resourceLoader.getSkills().skills) {
1079
- commands.push({ name: `skill:${skill.name}`, ...(skill.description === undefined ? {} : { description: skill.description }), source: "skill" });
1458
+ commands.push({
1459
+ name: `skill:${skill.name}`,
1460
+ ...(skill.description === undefined
1461
+ ? {}
1462
+ : { description: skill.description }),
1463
+ source: "skill",
1464
+ });
1080
1465
  }
1081
1466
  return commands.sort((a, b) => a.name.localeCompare(b.name));
1082
1467
  }
@@ -1088,15 +1473,20 @@ export class PiSessionService {
1088
1473
  // as a transient line that vanishes on reload.
1089
1474
  const echoUserMessage = options?.echoUserMessage !== false;
1090
1475
  const requestedBehavior = parsePromptStreamingBehavior(streamingBehavior);
1091
- const parsedAttachments = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
1476
+ const parsedAttachments = parsePromptAttachments(attachments, {
1477
+ enforceInlineSizeLimit: false,
1478
+ });
1092
1479
  const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
1093
1480
  await this.assertWritable(ref);
1094
1481
  const session = await this.getOrOpen(ref);
1095
1482
  this.assertTreeNavigationInactive(session, "send a prompt");
1483
+ this.assertPromptModelPolicyAllowed(session);
1096
1484
  this.maybeGenerateSessionName(session, promptText);
1097
1485
  const isQueued = session.isStreaming || session.isCompacting;
1098
1486
  const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
1099
- if (isQueued && images.length === 0 && this.hasQueuedMessageText(session, promptText)) {
1487
+ if (isQueued &&
1488
+ images.length === 0 &&
1489
+ this.hasQueuedMessageText(session, promptText)) {
1100
1490
  this.publishActivity(session, "duplicate queued message ignored", "active");
1101
1491
  this.publishStatus(session);
1102
1492
  return;
@@ -1105,30 +1495,95 @@ export class PiSessionService {
1105
1495
  this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
1106
1496
  return;
1107
1497
  }
1498
+ if (this.isModelPolicyMutationActive(session)) {
1499
+ this.retainPromptDuringModelPolicyMutation(session, promptText, behavior ?? "followUp", images, echoUserMessage);
1500
+ return;
1501
+ }
1108
1502
  void this.submitPrompt(session, promptText, behavior, images, echoUserMessage);
1109
1503
  }
1110
1504
  submitPrompt(session, text, behavior, images = [], echoUserMessage = true) {
1111
- this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
1505
+ // A policy transition may have opened while this prompt waited in the
1506
+ // compaction queue. The runtime tuple is transient until the transition
1507
+ // persists, so retain the input instead of reaching Pi with a partial pair.
1508
+ if (this.isModelPolicyMutationActive(session)) {
1509
+ this.retainPromptDuringModelPolicyMutation(session, text, behavior ?? "followUp", images, echoUserMessage, "front");
1510
+ return Promise.resolve();
1511
+ }
1512
+ // Re-check before any activity publication or provider call: a prompt held
1513
+ // in the compaction queue may have waited while the policy entry changed.
1514
+ try {
1515
+ this.assertPromptModelPolicyAllowed(session);
1516
+ }
1517
+ catch (error) {
1518
+ const message = error instanceof Error ? error.message : String(error);
1519
+ this.publishActivity(session, "error", "error", message);
1520
+ this.events.publish(session.sessionId, {
1521
+ type: "session.error",
1522
+ message,
1523
+ });
1524
+ return Promise.resolve();
1525
+ }
1526
+ this.publishActivity(session, behavior === "steer"
1527
+ ? "steering queued"
1528
+ : behavior === "followUp"
1529
+ ? "message queued"
1530
+ : "prompt accepted", "active");
1112
1531
  if (behavior === undefined && echoUserMessage)
1113
- this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
1532
+ this.events.publish(session.sessionId, {
1533
+ type: "message.append",
1534
+ message: userMessage(text, images),
1535
+ });
1114
1536
  const promptOptions = buildPromptOptions(behavior, images);
1115
1537
  const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).catch((error) => {
1116
1538
  const message = error instanceof Error ? error.message : String(error);
1117
1539
  this.publishActivity(session, "error", "error", message);
1118
- this.events.publish(session.sessionId, { type: "session.error", message });
1540
+ this.events.publish(session.sessionId, {
1541
+ type: "session.error",
1542
+ message,
1543
+ });
1119
1544
  });
1120
1545
  void promptPromise;
1121
1546
  return promptPromise;
1122
1547
  }
1123
1548
  enqueuePromptDuringCompaction(session, text, kind, images = [], echoUserMessage = true) {
1124
1549
  const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
1125
- queue.push({ kind, text, ...(images.length > 0 ? { images } : {}), ...(echoUserMessage ? {} : { echoUserMessage: false }) });
1550
+ queue.push({
1551
+ kind,
1552
+ text,
1553
+ ...(images.length > 0 ? { images } : {}),
1554
+ ...(echoUserMessage ? {} : { echoUserMessage: false }),
1555
+ });
1126
1556
  this.compactionPromptQueues.set(session.sessionId, queue);
1127
1557
  this.publishActivity(session, "message queued during compaction", "active");
1128
1558
  this.publishStatus(session);
1129
1559
  }
1560
+ /**
1561
+ * Hold prompt input for the duration of a policy transition, reusing the
1562
+ * existing queue so the post-mutation drain submits it once the confirmed
1563
+ * model/thinking pair is persisted. A prompt taken back out of the queue is
1564
+ * re-retained at the front so relative order is preserved.
1565
+ */
1566
+ retainPromptDuringModelPolicyMutation(session, text, kind, images = [], echoUserMessage = true, position = "back") {
1567
+ const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
1568
+ const queued = {
1569
+ kind,
1570
+ text,
1571
+ ...(images.length > 0 ? { images } : {}),
1572
+ ...(echoUserMessage ? {} : { echoUserMessage: false }),
1573
+ };
1574
+ if (position === "front")
1575
+ queue.unshift(queued);
1576
+ else
1577
+ queue.push(queued);
1578
+ this.compactionPromptQueues.set(session.sessionId, queue);
1579
+ this.publishActivity(session, "message queued during model policy change", "active");
1580
+ this.publishStatus(session);
1581
+ }
1130
1582
  async saveAttachments(ref, attachments, folder) {
1131
- const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
1583
+ const parsed = parsePromptAttachments(attachments, {
1584
+ enforceInlineSizeLimit: false,
1585
+ allowFileAttachments: true,
1586
+ });
1132
1587
  if (parsed.length === 0)
1133
1588
  return [];
1134
1589
  await this.assertWritable(ref);
@@ -1147,26 +1602,46 @@ export class PiSessionService {
1147
1602
  if (session.isBashRunning)
1148
1603
  throw new Error("A bash command is already running");
1149
1604
  this.publishActivity(session, "running bash", "active", command);
1150
- this.events.publish(session.sessionId, { type: "shell.start", command, excludeFromContext: isExcluded });
1605
+ this.events.publish(session.sessionId, {
1606
+ type: "shell.start",
1607
+ command,
1608
+ excludeFromContext: isExcluded,
1609
+ });
1151
1610
  void this.runSessionEntryMutation(session, "run a shell command", () => session.executeBash(command, (chunk) => {
1152
- this.events.publish(session.sessionId, { type: "shell.chunk", chunk });
1611
+ this.events.publish(session.sessionId, {
1612
+ type: "shell.chunk",
1613
+ chunk,
1614
+ });
1153
1615
  this.publishActivity(session, "running bash", "active", command);
1154
1616
  this.publishStatus(session);
1155
- }, { excludeFromContext: isExcluded })).then((result) => {
1617
+ }, { excludeFromContext: isExcluded }))
1618
+ .then((result) => {
1156
1619
  this.events.publish(session.sessionId, {
1157
1620
  type: "shell.end",
1158
1621
  output: result.output,
1159
- ...(result.exitCode === undefined ? {} : { exitCode: result.exitCode }),
1622
+ ...(result.exitCode === undefined
1623
+ ? {}
1624
+ : { exitCode: result.exitCode }),
1160
1625
  cancelled: result.cancelled,
1161
1626
  truncated: result.truncated,
1162
- ...(result.fullOutputPath === undefined ? {} : { fullOutputPath: result.fullOutputPath }),
1627
+ ...(result.fullOutputPath === undefined
1628
+ ? {}
1629
+ : { fullOutputPath: result.fullOutputPath }),
1163
1630
  });
1164
1631
  this.publishActivity(session, "bash complete", result.exitCode === 0 ? "idle" : "error", command);
1165
1632
  this.publishStatus(session);
1166
- }).catch((error) => {
1633
+ })
1634
+ .catch((error) => {
1167
1635
  const message = error instanceof Error ? error.message : String(error);
1168
- this.events.publish(session.sessionId, { type: "shell.end", output: message, isError: true });
1169
- this.events.publish(session.sessionId, { type: "session.error", message });
1636
+ this.events.publish(session.sessionId, {
1637
+ type: "shell.end",
1638
+ output: message,
1639
+ isError: true,
1640
+ });
1641
+ this.events.publish(session.sessionId, {
1642
+ type: "session.error",
1643
+ message,
1644
+ });
1170
1645
  this.publishActivity(session, "bash failed", "error", message);
1171
1646
  this.publishStatus(session);
1172
1647
  });
@@ -1198,7 +1673,13 @@ export class PiSessionService {
1198
1673
  await this.assertWritable(ref);
1199
1674
  const active = await this.getActive(ref);
1200
1675
  const sourceSession = active.runtime.session;
1201
- const result = await this.runTreeExclusiveOperation([{ sessionId: sourceSession.sessionId, session: sourceSession, runtime: active.runtime }], "Stop current session activity before replacing the session", () => active.runtime.fork(entryId));
1676
+ const result = await this.runTreeExclusiveOperation([
1677
+ {
1678
+ sessionId: sourceSession.sessionId,
1679
+ session: sourceSession,
1680
+ runtime: active.runtime,
1681
+ },
1682
+ ], "Stop current session activity before replacing the session", () => active.runtime.fork(entryId));
1202
1683
  if (result.cancelled)
1203
1684
  return { cancelled: true };
1204
1685
  const session = clientSessionFromRuntime(active.runtime);
@@ -1221,7 +1702,8 @@ export class PiSessionService {
1221
1702
  // may enter this runtime until Pi's potentially asynchronous summary settles.
1222
1703
  this.treeNavigations.add(session);
1223
1704
  try {
1224
- if (expectedLeafId !== undefined && session.sessionManager.getLeafId() !== expectedLeafId) {
1705
+ if (expectedLeafId !== undefined &&
1706
+ session.sessionManager.getLeafId() !== expectedLeafId) {
1225
1707
  throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
1226
1708
  }
1227
1709
  this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
@@ -1229,19 +1711,32 @@ export class PiSessionService {
1229
1711
  const result = await session.navigateTree(targetId, options);
1230
1712
  if (result.cancelled) {
1231
1713
  if (this.isCurrentActiveSession(session)) {
1232
- this.publishActivity(session, result.aborted === true ? "branch summary aborted" : "tree navigation cancelled", "idle");
1714
+ this.publishActivity(session, result.aborted === true
1715
+ ? "branch summary aborted"
1716
+ : "tree navigation cancelled", "idle");
1233
1717
  }
1234
- return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
1718
+ return {
1719
+ cancelled: true,
1720
+ ...(result.aborted === undefined ? {} : { aborted: result.aborted }),
1721
+ };
1235
1722
  }
1236
1723
  if (this.isCurrentActiveSession(session))
1237
1724
  this.publishActivity(session, "session tree navigated", "idle");
1238
- return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
1725
+ return {
1726
+ cancelled: false,
1727
+ ...(result.editorText === undefined
1728
+ ? {}
1729
+ : { editorText: result.editorText }),
1730
+ };
1239
1731
  }
1240
1732
  catch (error) {
1241
1733
  const message = error instanceof Error ? error.message : String(error);
1242
1734
  if (this.isCurrentActiveSession(session)) {
1243
1735
  this.publishActivity(session, "tree navigation failed", "error", message);
1244
- this.events.publish(session.sessionId, { type: "session.error", message });
1736
+ this.events.publish(session.sessionId, {
1737
+ type: "session.error",
1738
+ message,
1739
+ });
1245
1740
  }
1246
1741
  throw error;
1247
1742
  }
@@ -1265,13 +1760,16 @@ export class PiSessionService {
1265
1760
  const priorGeneration = this.notificationGenerationBySession.get(session);
1266
1761
  let candidateGeneration;
1267
1762
  try {
1268
- await session.reload(priorGeneration === undefined ? undefined : {
1269
- beforeSessionStart: () => {
1270
- candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
1271
- this.notificationGenerationBySession.set(session, candidateGeneration);
1272
- this.replaceSessionNotificationContext(session, candidateGeneration);
1273
- },
1274
- });
1763
+ await session.reload(priorGeneration === undefined
1764
+ ? undefined
1765
+ : {
1766
+ beforeSessionStart: () => {
1767
+ candidateGeneration =
1768
+ this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
1769
+ this.notificationGenerationBySession.set(session, candidateGeneration);
1770
+ this.replaceSessionNotificationContext(session, candidateGeneration);
1771
+ },
1772
+ });
1275
1773
  if (candidateGeneration !== undefined) {
1276
1774
  this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
1277
1775
  }
@@ -1285,141 +1783,191 @@ export class PiSessionService {
1285
1783
  }
1286
1784
  const message = error instanceof Error ? error.message : String(error);
1287
1785
  this.publishActivity(session, "reload failed", "error", message);
1288
- this.events.publish(session.sessionId, { type: "session.error", message });
1786
+ this.events.publish(session.sessionId, {
1787
+ type: "session.error",
1788
+ message,
1789
+ });
1289
1790
  this.publishStatus(session);
1290
1791
  throw error;
1291
1792
  }
1292
1793
  });
1293
1794
  }
1294
1795
  async archive(ref) {
1295
- const session = await this.getOrOpen(ref);
1296
- if (this.hasActiveWork(session))
1297
- throw new Error("Stop current session activity before archiving");
1298
- await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before archiving", async () => {
1299
- const archiveInput = await this.archiveInputForSession(session);
1300
- await this.closeActive(session.sessionId, { kind: "clear", reason: "archive" });
1301
- await this.archiveStore.archive(archiveInput);
1302
- await this.forgetUnreadSessions([archiveInput]);
1796
+ await this.runSessionOrderMutation(async () => {
1797
+ const session = await this.getOrOpen(ref);
1798
+ if (this.hasActiveWork(session))
1799
+ throw new Error("Stop current session activity before archiving");
1800
+ await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before archiving", async () => {
1801
+ const archiveInput = await this.archiveInputForSession(session);
1802
+ await this.closeActive(session.sessionId, {
1803
+ kind: "clear",
1804
+ reason: "archive",
1805
+ });
1806
+ await this.archiveStore.archive(archiveInput);
1807
+ await this.forgetUnreadSessions([archiveInput]);
1808
+ });
1303
1809
  });
1304
1810
  }
1305
1811
  async archiveMany(refs) {
1306
- const uniqueRefs = uniqueBulkSessionRefs(refs);
1307
- const [archivedRecords, sessionContext] = await Promise.all([
1308
- this.archiveStore.list(),
1309
- this.bulkSessionLookupContext(uniqueRefs),
1310
- ]);
1311
- const failures = [];
1312
- const alreadyArchivedSessionIds = [];
1313
- const unreadArchivedIdentities = [];
1314
- const planItems = [];
1315
- for (const ref of uniqueRefs) {
1316
- const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
1317
- if (archived !== undefined) {
1318
- this.publishNotificationMutations(this.notificationStore.clearSession(archived.sessionId, "archive"));
1319
- alreadyArchivedSessionIds.push(archived.sessionId);
1320
- unreadArchivedIdentities.push(archived);
1321
- continue;
1322
- }
1323
- const active = this.activeForLookup(bulkRefToLookup(ref));
1324
- const listed = findListedSessionForBulkRef(sessionContext, ref);
1325
- const resolvedSessionId = active?.runtime.session.sessionId ?? listed?.id ?? ref.id;
1326
- if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1327
- failures.push({ sessionId: resolvedSessionId, error: "Stop current session activity before archiving" });
1328
- continue;
1329
- }
1330
- try {
1331
- if (listed !== undefined) {
1332
- planItems.push({ input: archiveInputFromListEntry(listed) });
1812
+ return await this.runSessionOrderMutation(async () => {
1813
+ const uniqueRefs = uniqueBulkSessionRefs(refs);
1814
+ const [archivedRecords, sessionContext] = await Promise.all([
1815
+ this.archiveStore.list(),
1816
+ this.bulkSessionLookupContext(uniqueRefs),
1817
+ ]);
1818
+ const failures = [];
1819
+ const alreadyArchivedSessionIds = [];
1820
+ const unreadArchivedIdentities = [];
1821
+ const planItems = [];
1822
+ for (const ref of uniqueRefs) {
1823
+ const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
1824
+ if (archived !== undefined) {
1825
+ this.publishNotificationMutations(this.notificationStore.clearSession(archived.sessionId, "archive"));
1826
+ alreadyArchivedSessionIds.push(archived.sessionId);
1827
+ unreadArchivedIdentities.push(archived);
1828
+ continue;
1333
1829
  }
1334
- else if (active !== undefined) {
1335
- planItems.push({ input: archiveInputFromActiveSession(active.runtime.session) });
1830
+ const active = this.activeForLookup(bulkRefToLookup(ref));
1831
+ const listed = findListedSessionForBulkRef(sessionContext, ref);
1832
+ const resolvedSessionId = active?.runtime.session.sessionId ?? listed?.id ?? ref.id;
1833
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1834
+ failures.push({
1835
+ sessionId: resolvedSessionId,
1836
+ error: "Stop current session activity before archiving",
1837
+ });
1838
+ continue;
1336
1839
  }
1337
- else {
1338
- failures.push({ sessionId: ref.id, error: "Session not found" });
1840
+ try {
1841
+ if (listed !== undefined) {
1842
+ planItems.push({ input: archiveInputFromListEntry(listed) });
1843
+ }
1844
+ else if (active !== undefined) {
1845
+ planItems.push({
1846
+ input: archiveInputFromActiveSession(active.runtime.session),
1847
+ });
1848
+ }
1849
+ else {
1850
+ failures.push({ sessionId: ref.id, error: "Session not found" });
1851
+ }
1852
+ }
1853
+ catch (error) {
1854
+ failures.push({
1855
+ sessionId: resolvedSessionId,
1856
+ error: errorMessage(error),
1857
+ });
1339
1858
  }
1340
1859
  }
1341
- catch (error) {
1342
- failures.push({ sessionId: resolvedSessionId, error: errorMessage(error) });
1343
- }
1344
- }
1345
- const readyPlanItems = [];
1346
- for (const item of planItems) {
1347
- const active = this.activeForLookup({ id: item.input.sessionId, cwd: item.input.cwd });
1348
- if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1349
- failures.push({ sessionId: item.input.sessionId, error: "Stop current session activity before archiving" });
1350
- continue;
1860
+ const readyPlanItems = [];
1861
+ for (const item of planItems) {
1862
+ const active = this.activeForLookup({
1863
+ id: item.input.sessionId,
1864
+ cwd: item.input.cwd,
1865
+ });
1866
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1867
+ failures.push({
1868
+ sessionId: item.input.sessionId,
1869
+ error: "Stop current session activity before archiving",
1870
+ });
1871
+ continue;
1872
+ }
1873
+ readyPlanItems.push(active === undefined ? item : { ...item, active });
1351
1874
  }
1352
- readyPlanItems.push(active === undefined ? item : { ...item, active });
1353
- }
1354
- const readyInputs = [];
1355
- const archivedSessionIds = [...alreadyArchivedSessionIds];
1356
- await this.runTreeExclusiveOperation(readyPlanItems.map(({ input, active }) => ({
1357
- sessionId: input.sessionId,
1358
- ...(active === undefined ? {} : { session: active.runtime.session, runtime: active.runtime }),
1359
- })), "Stop current session activity before archiving", async () => {
1360
- for (const item of readyPlanItems) {
1875
+ const readyInputs = [];
1876
+ const archivedSessionIds = [...alreadyArchivedSessionIds];
1877
+ await this.runTreeExclusiveOperation(readyPlanItems.map(({ input, active }) => ({
1878
+ sessionId: input.sessionId,
1879
+ ...(active === undefined
1880
+ ? {}
1881
+ : { session: active.runtime.session, runtime: active.runtime }),
1882
+ })), "Stop current session activity before archiving", async () => {
1883
+ for (const item of readyPlanItems) {
1884
+ try {
1885
+ await this.closeActive(item.input.sessionId, {
1886
+ kind: "clear",
1887
+ reason: "archive",
1888
+ });
1889
+ readyInputs.push(item.input);
1890
+ }
1891
+ catch (error) {
1892
+ failures.push({
1893
+ sessionId: item.input.sessionId,
1894
+ error: errorMessage(error),
1895
+ });
1896
+ }
1897
+ }
1361
1898
  try {
1362
- await this.closeActive(item.input.sessionId, { kind: "clear", reason: "archive" });
1363
- readyInputs.push(item.input);
1899
+ const archived = await this.archiveStoreArchiveMany(readyInputs);
1900
+ archivedSessionIds.push(...archived.map((record) => record.sessionId));
1901
+ unreadArchivedIdentities.push(...archived);
1364
1902
  }
1365
1903
  catch (error) {
1366
- failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
1904
+ for (const input of readyInputs)
1905
+ failures.push({
1906
+ sessionId: input.sessionId,
1907
+ error: errorMessage(error),
1908
+ });
1367
1909
  }
1368
- }
1369
- try {
1370
- const archived = await this.archiveStoreArchiveMany(readyInputs);
1371
- archivedSessionIds.push(...archived.map((record) => record.sessionId));
1372
- unreadArchivedIdentities.push(...archived);
1373
- }
1374
- catch (error) {
1375
- for (const input of readyInputs)
1376
- failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
1377
- }
1910
+ });
1911
+ await this.forgetUnreadSessions(unreadArchivedIdentities);
1912
+ return {
1913
+ archived: true,
1914
+ archivedSessionIds: uniqueStrings(archivedSessionIds),
1915
+ failures,
1916
+ generatedAt: new Date().toISOString(),
1917
+ };
1378
1918
  });
1379
- await this.forgetUnreadSessions(unreadArchivedIdentities);
1380
- return {
1381
- archived: true,
1382
- archivedSessionIds: uniqueStrings(archivedSessionIds),
1383
- failures,
1384
- generatedAt: new Date().toISOString(),
1385
- };
1386
1919
  }
1387
1920
  async archiveTree(ref) {
1388
- const session = await this.getOrOpen(ref);
1389
- const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
1390
- const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
1391
- const plan = planSessionArchiveTree(root, catalog);
1392
- const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
1393
- if (busy !== undefined)
1394
- throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
1395
- const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
1396
- await this.runTreeExclusiveOperation(plan.unarchivedTargets.map((target) => ({
1397
- sessionId: target.id,
1398
- ...(target.activeSession === undefined ? {} : { session: target.activeSession }),
1399
- })), `Stop current session activity before archiving ${sessionDisplayName(session)}`, async () => {
1400
- for (const target of plan.targets) {
1401
- if (target.archived)
1402
- this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
1403
- }
1404
- for (const input of archiveInputs)
1405
- await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
1406
- await this.archiveStoreArchiveMany(archiveInputs);
1921
+ return await this.runSessionOrderMutation(async () => {
1922
+ const session = await this.getOrOpen(ref);
1923
+ const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
1924
+ const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ??
1925
+ archiveCandidateFromActiveSession(session, false);
1926
+ const plan = planSessionArchiveTree(root, catalog);
1927
+ const busy = plan.targets
1928
+ .map((target) => target.activeSession)
1929
+ .find((target) => target !== undefined && this.hasActiveWork(target));
1930
+ if (busy !== undefined)
1931
+ throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
1932
+ const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
1933
+ await this.runTreeExclusiveOperation(plan.unarchivedTargets.map((target) => ({
1934
+ sessionId: target.id,
1935
+ ...(target.activeSession === undefined
1936
+ ? {}
1937
+ : { session: target.activeSession }),
1938
+ })), `Stop current session activity before archiving ${sessionDisplayName(session)}`, async () => {
1939
+ for (const target of plan.targets) {
1940
+ if (target.archived)
1941
+ this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
1942
+ }
1943
+ for (const input of archiveInputs)
1944
+ await this.closeActive(input.sessionId, {
1945
+ kind: "clear",
1946
+ reason: "archive",
1947
+ });
1948
+ await this.archiveStoreArchiveMany(archiveInputs);
1949
+ });
1950
+ await this.forgetUnreadSessions(plan.targets.map((target) => ({ sessionId: target.id, cwd: target.cwd })));
1951
+ return {
1952
+ archived: true,
1953
+ sessionIds: archiveInputs.map((input) => input.sessionId),
1954
+ archivedCount: archiveInputs.length,
1955
+ skippedAlreadyArchivedCount: plan.skippedAlreadyArchivedCount,
1956
+ };
1407
1957
  });
1408
- await this.forgetUnreadSessions(plan.targets.map((target) => ({ sessionId: target.id, cwd: target.cwd })));
1409
- return {
1410
- archived: true,
1411
- sessionIds: archiveInputs.map((input) => input.sessionId),
1412
- archivedCount: archiveInputs.length,
1413
- skippedAlreadyArchivedCount: plan.skippedAlreadyArchivedCount,
1414
- };
1415
1958
  }
1416
1959
  async restore(ref) {
1417
- const archived = await this.getArchived(ref);
1418
- if (archived === undefined)
1419
- throw new Error("Session not found");
1420
- await this.closeActive(archived.sessionId, { kind: "clear", reason: "restore" });
1421
- await this.archiveStore.restore(archived.sessionId);
1422
- await this.forgetUnreadSessions([archived]);
1960
+ await this.runSessionOrderMutation(async () => {
1961
+ const archived = await this.getArchived(ref);
1962
+ if (archived === undefined)
1963
+ throw new Error("Session not found");
1964
+ await this.closeActive(archived.sessionId, {
1965
+ kind: "clear",
1966
+ reason: "restore",
1967
+ });
1968
+ await this.archiveStore.restore(archived.sessionId);
1969
+ await this.forgetUnreadSessions([archived]);
1970
+ });
1423
1971
  }
1424
1972
  async deleteArchived(ref) {
1425
1973
  const record = await this.getArchived(ref);
@@ -1427,14 +1975,18 @@ export class PiSessionService {
1427
1975
  throw new Error("Archived session not found");
1428
1976
  if (this.archiveStore.deleteArchived === undefined)
1429
1977
  throw new Error("Archive store does not support deletion");
1430
- await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
1978
+ await this.closeActive(record.sessionId, {
1979
+ kind: "clear",
1980
+ reason: "delete",
1981
+ });
1431
1982
  if (record.archivePath === undefined)
1432
1983
  await this.ensureArchivedRecordMoved(record);
1433
1984
  await this.archiveStore.deleteArchived(record.sessionId);
1434
1985
  await this.forgetUnreadSessions([record]);
1435
1986
  }
1436
1987
  async deleteArchivedMany(refs) {
1437
- if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
1988
+ if (this.archiveStore.deleteArchived === undefined &&
1989
+ this.archiveStore.deleteArchivedMany === undefined)
1438
1990
  throw new Error("Archive store does not support deletion");
1439
1991
  const uniqueRefs = uniqueBulkSessionRefs(refs);
1440
1992
  const archivedRecords = await this.archiveStore.list();
@@ -1443,12 +1995,21 @@ export class PiSessionService {
1443
1995
  for (const ref of uniqueRefs) {
1444
1996
  const record = findArchivedRecordForBulkRef(archivedRecords, ref);
1445
1997
  if (record === undefined) {
1446
- failures.push({ sessionId: ref.id, error: "Archived session not found" });
1998
+ failures.push({
1999
+ sessionId: ref.id,
2000
+ error: "Archived session not found",
2001
+ });
1447
2002
  continue;
1448
2003
  }
1449
- const active = this.activeForLookup({ id: record.sessionId, cwd: record.cwd });
2004
+ const active = this.activeForLookup({
2005
+ id: record.sessionId,
2006
+ cwd: record.cwd,
2007
+ });
1450
2008
  if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1451
- failures.push({ sessionId: record.sessionId, error: "Stop current session activity before deleting archived session" });
2009
+ failures.push({
2010
+ sessionId: record.sessionId,
2011
+ error: "Stop current session activity before deleting archived session",
2012
+ });
1452
2013
  continue;
1453
2014
  }
1454
2015
  planItems.push({ record });
@@ -1456,11 +2017,17 @@ export class PiSessionService {
1456
2017
  const readyRecords = [];
1457
2018
  for (const item of planItems) {
1458
2019
  try {
1459
- await this.closeActive(item.record.sessionId, { kind: "clear", reason: "delete" });
2020
+ await this.closeActive(item.record.sessionId, {
2021
+ kind: "clear",
2022
+ reason: "delete",
2023
+ });
1460
2024
  readyRecords.push(item.record);
1461
2025
  }
1462
2026
  catch (error) {
1463
- failures.push({ sessionId: item.record.sessionId, error: errorMessage(error) });
2027
+ failures.push({
2028
+ sessionId: item.record.sessionId,
2029
+ error: errorMessage(error),
2030
+ });
1464
2031
  }
1465
2032
  }
1466
2033
  const moveFailures = await this.moveLegacyArchivedRecordsForDelete(readyRecords);
@@ -1496,11 +2063,19 @@ export class PiSessionService {
1496
2063
  const { sessionId, cwd } = notificationIdentityForSession(session);
1497
2064
  let candidateGeneration;
1498
2065
  try {
1499
- await this.closeActive(sessionId, priorGeneration === undefined ? CLEAR_RUNTIME_NOTIFICATIONS : DEFER_RUNTIME_NOTIFICATIONS);
1500
- candidateGeneration = priorGeneration === undefined
1501
- ? undefined
1502
- : this.notificationStore.beginReplacement(priorGeneration, { sessionId, cwd });
1503
- const reopened = await this.getActive(ref, candidateGeneration === undefined ? {} : { notificationGeneration: candidateGeneration });
2066
+ await this.closeActive(sessionId, priorGeneration === undefined
2067
+ ? CLEAR_RUNTIME_NOTIFICATIONS
2068
+ : DEFER_RUNTIME_NOTIFICATIONS);
2069
+ candidateGeneration =
2070
+ priorGeneration === undefined
2071
+ ? undefined
2072
+ : this.notificationStore.beginReplacement(priorGeneration, {
2073
+ sessionId,
2074
+ cwd,
2075
+ });
2076
+ const reopened = await this.getActive(ref, candidateGeneration === undefined
2077
+ ? {}
2078
+ : { notificationGeneration: candidateGeneration });
1504
2079
  if (candidateGeneration !== undefined) {
1505
2080
  this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
1506
2081
  }
@@ -1516,30 +2091,39 @@ export class PiSessionService {
1516
2091
  this.publishStatus(reopenedSession);
1517
2092
  }
1518
2093
  async detachParent(ref) {
1519
- const session = await this.getOrOpen(ref);
1520
- const sessionFile = session.sessionFile;
1521
- if (sessionFile === undefined || sessionFile === "")
1522
- throw new Error("Session is not persisted");
1523
- await clearParentSession(sessionFile);
1524
- clearParentSessionHeader(session.sessionManager);
1525
- this.unregisterSubsession(session.sessionId);
1526
- await this.forgetUnreadSessions([{ sessionId: session.sessionId, cwd: session.sessionManager.getCwd() }]);
2094
+ await this.runSessionOrderMutation(async () => {
2095
+ const session = await this.getOrOpen(ref);
2096
+ const sessionFile = session.sessionFile;
2097
+ if (sessionFile === undefined || sessionFile === "")
2098
+ throw new Error("Session is not persisted");
2099
+ await this.clearParentSessionFile(sessionFile);
2100
+ clearParentSessionHeader(session.sessionManager);
2101
+ await this.metadataStore.clearOrder(sessionFile);
2102
+ this.unregisterSubsession(session.sessionId);
2103
+ await this.forgetUnreadSessions([
2104
+ { sessionId: session.sessionId, cwd: session.sessionManager.getCwd() },
2105
+ ]);
2106
+ });
1527
2107
  }
1528
2108
  async pin(ref) {
1529
- const session = await this.getOrOpen(ref);
1530
- const sessionFile = session.sessionFile;
1531
- if (sessionFile === undefined || sessionFile === "")
1532
- throw new Error("Session is not persisted");
1533
- await this.metadataStore.pin(sessionFile);
1534
- return this.sessionInfoFromActive(session, { pinned: true });
2109
+ return await this.runSessionOrderMutation(async () => {
2110
+ const session = await this.getOrOpen(ref);
2111
+ const sessionFile = session.sessionFile;
2112
+ if (sessionFile === undefined || sessionFile === "")
2113
+ throw new Error("Session is not persisted");
2114
+ await this.metadataStore.pin(sessionFile);
2115
+ return this.sessionInfoFromActive(session, { pinned: true });
2116
+ });
1535
2117
  }
1536
2118
  async unpin(ref) {
1537
- const session = await this.getOrOpen(ref);
1538
- const sessionFile = session.sessionFile;
1539
- if (sessionFile === undefined || sessionFile === "")
1540
- throw new Error("Session is not persisted");
1541
- await this.metadataStore.unpin(sessionFile);
1542
- return this.sessionInfoFromActive(session, { pinned: false });
2119
+ return await this.runSessionOrderMutation(async () => {
2120
+ const session = await this.getOrOpen(ref);
2121
+ const sessionFile = session.sessionFile;
2122
+ if (sessionFile === undefined || sessionFile === "")
2123
+ throw new Error("Session is not persisted");
2124
+ await this.metadataStore.unpin(sessionFile);
2125
+ return this.sessionInfoFromActive(session, { pinned: false });
2126
+ });
1543
2127
  }
1544
2128
  sessionInfoFromActive(session, overrides) {
1545
2129
  const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
@@ -1552,7 +2136,9 @@ export class PiSessionService {
1552
2136
  modified: new Date().toISOString(),
1553
2137
  messageCount: session.messages.length,
1554
2138
  firstMessage: "",
1555
- ...(session.sessionName === undefined ? {} : { name: session.sessionName }),
2139
+ ...(session.sessionName === undefined
2140
+ ? {}
2141
+ : { name: session.sessionName }),
1556
2142
  ...(parentSessionPath === undefined ? {} : { parentSessionPath }),
1557
2143
  ...overrides,
1558
2144
  };
@@ -1614,9 +2200,13 @@ export class PiSessionService {
1614
2200
  }
1615
2201
  const [sessionsByCwd, allSessions] = await Promise.all([
1616
2202
  this.listSessionsByCwd([...cwdSet]),
1617
- needsAllSessions ? this.sessionManager.listAll?.() ?? Promise.resolve([]) : Promise.resolve(undefined),
2203
+ needsAllSessions
2204
+ ? this.sessionManager.listAll?.() ?? Promise.resolve([])
2205
+ : Promise.resolve(undefined),
1618
2206
  ]);
1619
- return allSessions === undefined ? { sessionsByCwd } : { sessionsByCwd, allSessions };
2207
+ return allSessions === undefined
2208
+ ? { sessionsByCwd }
2209
+ : { sessionsByCwd, allSessions };
1620
2210
  }
1621
2211
  async listSessionsByCwd(cwds) {
1622
2212
  const uniqueCwds = uniqueStrings(cwds);
@@ -1653,7 +2243,10 @@ export class PiSessionService {
1653
2243
  sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
1654
2244
  }
1655
2245
  catch (error) {
1656
- return legacyRecords.map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
2246
+ return legacyRecords.map((record) => ({
2247
+ sessionId: record.sessionId,
2248
+ error: errorMessage(error),
2249
+ }));
1657
2250
  }
1658
2251
  const moveInputs = legacyRecords
1659
2252
  .map((record) => findSessionByIdOrPrefix(sessionsByCwd.get(record.cwd) ?? [], record.sessionId))
@@ -1669,17 +2262,25 @@ export class PiSessionService {
1669
2262
  const failedIds = new Set(moveInputs.map((input) => input.sessionId));
1670
2263
  return legacyRecords
1671
2264
  .filter((record) => failedIds.has(record.sessionId))
1672
- .map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
2265
+ .map((record) => ({
2266
+ sessionId: record.sessionId,
2267
+ error: errorMessage(error),
2268
+ }));
1673
2269
  }
1674
2270
  }
1675
2271
  async cleanupPlan(request) {
1676
- const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll?.() ?? [], this.archiveStore.list()]);
2272
+ const [sessions, archivedRecords] = await Promise.all([
2273
+ this.sessionManager.listAll?.() ?? [],
2274
+ this.archiveStore.list(),
2275
+ ]);
1677
2276
  return planSessionCleanup({
1678
2277
  sessions,
1679
2278
  archivedRecords,
1680
2279
  activeSessions: this.cleanupActiveSessionStatuses(),
1681
2280
  thresholds: request.thresholds,
1682
- ...(request.projectCwds === undefined ? {} : { projectCwds: request.projectCwds }),
2281
+ ...(request.projectCwds === undefined
2282
+ ? {}
2283
+ : { projectCwds: request.projectCwds }),
1683
2284
  now: this.now(),
1684
2285
  });
1685
2286
  }
@@ -1697,7 +2298,8 @@ export class PiSessionService {
1697
2298
  const sessionIds = new Set(listedSessionIds);
1698
2299
  for (const active of new Set(this.active.values())) {
1699
2300
  const session = active.runtime.session;
1700
- if (session.sessionManager.getCwd() === cwd && !archivedById.has(session.sessionId))
2301
+ if (session.sessionManager.getCwd() === cwd &&
2302
+ !archivedById.has(session.sessionId))
1701
2303
  sessionIds.add(session.sessionId);
1702
2304
  }
1703
2305
  return [...sessionIds];
@@ -1716,7 +2318,9 @@ export class PiSessionService {
1716
2318
  const session = (await this.sessionManager.list(record.cwd)).find((candidate) => candidate.id === record.sessionId);
1717
2319
  if (session === undefined)
1718
2320
  return record;
1719
- const [moved] = await this.archiveStoreArchiveMany([archiveInputFromListEntry(session)]);
2321
+ const [moved] = await this.archiveStoreArchiveMany([
2322
+ archiveInputFromListEntry(session),
2323
+ ]);
1720
2324
  return moved ?? record;
1721
2325
  }
1722
2326
  async ensureArchivedRecordsMoved(records) {
@@ -1725,7 +2329,9 @@ export class PiSessionService {
1725
2329
  return;
1726
2330
  const sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
1727
2331
  const moveInputs = legacyRecords
1728
- .map((record) => sessionsByCwd.get(record.cwd)?.find((candidate) => candidate.id === record.sessionId))
2332
+ .map((record) => sessionsByCwd
2333
+ .get(record.cwd)
2334
+ ?.find((candidate) => candidate.id === record.sessionId))
1729
2335
  .filter(isDefined)
1730
2336
  .map(archiveInputFromListEntry);
1731
2337
  await this.archiveStoreArchiveMany(moveInputs);
@@ -1741,7 +2347,10 @@ export class PiSessionService {
1741
2347
  return archiveInputFromActiveSession(session);
1742
2348
  }
1743
2349
  async workspaceArchiveCandidates(cwd) {
1744
- const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
2350
+ const [sessions, archivedRecords] = await Promise.all([
2351
+ this.sessionManager.list(cwd),
2352
+ this.archiveStore.list(),
2353
+ ]);
1745
2354
  const candidates = new Map();
1746
2355
  const archivedById = new Map();
1747
2356
  for (const record of archivedRecords) {
@@ -1767,15 +2376,22 @@ export class PiSessionService {
1767
2376
  }
1768
2377
  for (const active of new Set(this.active.values())) {
1769
2378
  const session = active.runtime.session;
1770
- if (session.sessionManager.getCwd() !== cwd || archivedById.has(session.sessionId))
2379
+ if (session.sessionManager.getCwd() !== cwd ||
2380
+ archivedById.has(session.sessionId))
1771
2381
  continue;
1772
2382
  const existing = candidates.get(session.sessionId);
1773
- candidates.set(session.sessionId, { ...(existing ?? archiveCandidateFromActiveSession(session, false)), activeSession: session });
2383
+ candidates.set(session.sessionId, {
2384
+ ...(existing ?? archiveCandidateFromActiveSession(session, false)),
2385
+ activeSession: session,
2386
+ });
1774
2387
  }
1775
2388
  return [...candidates.values()];
1776
2389
  }
1777
2390
  async listSessionNames(cwd) {
1778
- const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
2391
+ const [sessions, archivedRecords] = await Promise.all([
2392
+ this.sessionManager.list(cwd),
2393
+ this.archiveStore.list(),
2394
+ ]);
1779
2395
  const names = new Set();
1780
2396
  for (const session of sessions)
1781
2397
  addSessionName(names, session.name);
@@ -1796,7 +2412,9 @@ export class PiSessionService {
1796
2412
  await Promise.allSettled(pendingOpens);
1797
2413
  const active = this.active.get(sessionId);
1798
2414
  if (notificationPolicy.kind === "clear") {
1799
- const generation = active === undefined ? undefined : this.notificationGenerationBySession.get(active.runtime.session);
2415
+ const generation = active === undefined
2416
+ ? undefined
2417
+ : this.notificationGenerationBySession.get(active.runtime.session);
1800
2418
  const mutations = generation === undefined
1801
2419
  ? this.notificationStore.clearSession(sessionId, notificationPolicy.reason)
1802
2420
  : this.notificationStore.clearGeneration(generation, notificationPolicy.reason);
@@ -1848,7 +2466,7 @@ export class PiSessionService {
1848
2466
  throw branchSummaryAbortError;
1849
2467
  }
1850
2468
  async assertWritable(ref) {
1851
- if (await this.getArchived(ref) !== undefined)
2469
+ if ((await this.getArchived(ref)) !== undefined)
1852
2470
  throw new Error("Archived sessions are read-only. Restore the session to continue.");
1853
2471
  }
1854
2472
  async getOrOpen(ref) {
@@ -1867,7 +2485,7 @@ export class PiSessionService {
1867
2485
  return archived.archivePath;
1868
2486
  const match = isPiSessionRef(ref)
1869
2487
  ? (await this.sessionManager.list(ref.cwd)).find((session) => session.id === ref.id || session.id.startsWith(ref.id))
1870
- : (await this.sessionManager.listAll?.() ?? []).find((session) => session.id === ref || session.id.startsWith(ref));
2488
+ : ((await this.sessionManager.listAll?.()) ?? []).find((session) => session.id === ref || session.id.startsWith(ref));
1871
2489
  if (match === undefined || match.path === "")
1872
2490
  throw new Error("Session not found");
1873
2491
  return match.path;
@@ -1883,7 +2501,7 @@ export class PiSessionService {
1883
2501
  }
1884
2502
  const match = isPiSessionRef(ref)
1885
2503
  ? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
1886
- : (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
2504
+ : ((await this.sessionManager.listAll?.()) ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
1887
2505
  if (!match)
1888
2506
  throw new Error("Session not found");
1889
2507
  return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
@@ -1929,23 +2547,31 @@ export class PiSessionService {
1929
2547
  if (exact !== undefined && lookupMatchesActiveSession(ref, exact))
1930
2548
  return exact;
1931
2549
  for (const [candidateId, active] of this.active.entries()) {
1932
- if (candidateId.startsWith(sessionId) && lookupMatchesActiveSession(ref, active))
2550
+ if (candidateId.startsWith(sessionId) &&
2551
+ lookupMatchesActiveSession(ref, active))
1933
2552
  return active;
1934
2553
  }
1935
2554
  return undefined;
1936
2555
  }
1937
2556
  async create(sessionManager, cwd, options = {}) {
1938
- const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
1939
- && await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
2557
+ const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession" &&
2558
+ (await sessionAllowsDelegationTools(sessionManager, this.sessionManager));
1940
2559
  const runtime = await this.createAgentRuntime(this.createRuntime, {
1941
2560
  cwd,
1942
2561
  agentDir: this.agentDir,
1943
2562
  sessionManager,
1944
2563
  delegationToolsEnabled,
1945
- ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
1946
- ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
2564
+ ...(options.initialModel === undefined
2565
+ ? {}
2566
+ : { initialModel: options.initialModel }),
2567
+ ...(options.initialThinkingLevel === undefined
2568
+ ? {}
2569
+ : { initialThinkingLevel: options.initialThinkingLevel }),
1947
2570
  });
1948
- const active = { runtime, unsubscribe: noop };
2571
+ const active = {
2572
+ runtime,
2573
+ unsubscribe: noop,
2574
+ };
1949
2575
  let boundSession = runtime.session;
1950
2576
  let notificationGeneration = options.notificationGeneration;
1951
2577
  let notificationOwnership = options.notifications === "disabled"
@@ -1969,6 +2595,7 @@ export class PiSessionService {
1969
2595
  if (notificationGeneration !== undefined)
1970
2596
  this.notificationGenerationBySession.set(runtime.session, notificationGeneration);
1971
2597
  try {
2598
+ this.inspectAndCacheSessionModelPolicy(runtime.session);
1972
2599
  if (options.creationProvenance === "tracked-subsession") {
1973
2600
  await this.publishUnreadMutations(this.unreadStore.excludeSession(runtime.session.sessionId, canonicalizeStoredCwd(runtime.session.sessionManager.getCwd())));
1974
2601
  }
@@ -1987,6 +2614,7 @@ export class PiSessionService {
1987
2614
  candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
1988
2615
  this.notificationGenerationBySession.set(session, candidateGeneration);
1989
2616
  }
2617
+ this.inspectAndCacheSessionModelPolicy(session);
1990
2618
  this.bindRuntime(active, session);
1991
2619
  boundSession = session;
1992
2620
  await this.bindSessionExtensions(session, candidateGeneration);
@@ -2005,7 +2633,11 @@ export class PiSessionService {
2005
2633
  }
2006
2634
  });
2007
2635
  this.active.set(runtime.session.sessionId, active);
2008
- if (notificationOwnership === "replacement" && notificationGeneration !== undefined) {
2636
+ if (options.initializeModelPolicy !== undefined) {
2637
+ await this.initializeSessionModelPolicy(runtime.session, options.initializeModelPolicy);
2638
+ }
2639
+ if (notificationOwnership === "replacement" &&
2640
+ notificationGeneration !== undefined) {
2009
2641
  this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
2010
2642
  notificationOwnership = "external";
2011
2643
  }
@@ -2053,7 +2685,10 @@ export class PiSessionService {
2053
2685
  onError: (error) => {
2054
2686
  const message = `${error.extensionPath}: ${error.error}`;
2055
2687
  this.publishActivity(session, "extension error", "error", message);
2056
- this.events.publish(session.sessionId, { type: "session.error", message });
2688
+ this.events.publish(session.sessionId, {
2689
+ type: "session.error",
2690
+ message,
2691
+ });
2057
2692
  },
2058
2693
  });
2059
2694
  }
@@ -2106,7 +2741,8 @@ export class PiSessionService {
2106
2741
  const previousCwd = canonicalizeStoredCwd(previous.sessionManager.getCwd());
2107
2742
  this.unreadStore.forgetActivity(previous.sessionId, previousCwd);
2108
2743
  const nextCwd = canonicalizeStoredCwd(next.sessionManager.getCwd());
2109
- if (previous.sessionId === next.sessionId && cwdPathsEqual(previousCwd, nextCwd))
2744
+ if (previous.sessionId === next.sessionId &&
2745
+ cwdPathsEqual(previousCwd, nextCwd))
2110
2746
  return;
2111
2747
  await this.publishUnreadMutations(this.unreadStore.forgetSession(previous.sessionId, previousCwd));
2112
2748
  }
@@ -2129,11 +2765,14 @@ export class PiSessionService {
2129
2765
  publishUnreadMutations(mutations) {
2130
2766
  this.enqueueUnreadMutations(mutations);
2131
2767
  this.unreadPublicationFlushRequested = true;
2132
- if (this.unreadPublication === undefined && this.unreadPublicationRetryTimer !== undefined) {
2768
+ if (this.unreadPublication === undefined &&
2769
+ this.unreadPublicationRetryTimer !== undefined) {
2133
2770
  const failure = this.unreadPublicationFailure;
2134
2771
  return Promise.reject(failure instanceof Error
2135
2772
  ? failure
2136
- : new Error("Session unread publication is awaiting retry", { cause: failure }));
2773
+ : new Error("Session unread publication is awaiting retry", {
2774
+ cause: failure,
2775
+ }));
2137
2776
  }
2138
2777
  return this.ensureUnreadPublication();
2139
2778
  }
@@ -2156,7 +2795,8 @@ export class PiSessionService {
2156
2795
  return publication;
2157
2796
  }
2158
2797
  async drainUnreadPublication() {
2159
- while (this.unreadPublicationFlushRequested || this.pendingUnreadMutations.length > 0) {
2798
+ while (this.unreadPublicationFlushRequested ||
2799
+ this.pendingUnreadMutations.length > 0) {
2160
2800
  this.unreadPublicationFlushRequested = false;
2161
2801
  const batch = this.pendingUnreadMutations.splice(0);
2162
2802
  let publishedCount = 0;
@@ -2190,7 +2830,8 @@ export class PiSessionService {
2190
2830
  this.pendingUnreadMutations.splice(0, excess);
2191
2831
  }
2192
2832
  scheduleUnreadPublicationRetry() {
2193
- if (this.unreadPublicationStopped || this.unreadPublicationRetryTimer !== undefined)
2833
+ if (this.unreadPublicationStopped ||
2834
+ this.unreadPublicationRetryTimer !== undefined)
2194
2835
  return;
2195
2836
  const delay = this.unreadPublicationRetryDelayMs;
2196
2837
  this.unreadPublicationRetryDelayMs = Math.min(Math.max(delay * 2, this.unreadPublicationRetryInitialMs), Math.max(MAX_UNREAD_PUBLICATION_RETRY_MS, this.unreadPublicationRetryInitialMs));
@@ -2259,8 +2900,11 @@ export class PiSessionService {
2259
2900
  recordGenerationProgress(session, message) {
2260
2901
  const current = this.generationMetrics.get(session);
2261
2902
  const providerTokens = providerOutputTokens(message);
2262
- const estimatedTokens = providerTokens === undefined && current?.estimated !== false ? estimatedOutputTokens(message) : undefined;
2263
- const outputTokens = providerTokens ?? (current?.estimated === false ? current.outputTokens : estimatedTokens);
2903
+ const estimatedTokens = providerTokens === undefined && current?.estimated !== false
2904
+ ? estimatedOutputTokens(message)
2905
+ : undefined;
2906
+ const outputTokens = providerTokens ??
2907
+ (current?.estimated === false ? current.outputTokens : estimatedTokens);
2264
2908
  if (outputTokens === undefined)
2265
2909
  return;
2266
2910
  this.generationMetrics.set(session, {
@@ -2274,15 +2918,20 @@ export class PiSessionService {
2274
2918
  if (metrics === undefined)
2275
2919
  return undefined;
2276
2920
  const elapsedMs = this.now().getTime() - metrics.startedAt;
2277
- const tokensPerSecond = elapsedMs >= MIN_GENERATION_RATE_ELAPSED_MS ? metrics.outputTokens * 1_000 / elapsedMs : undefined;
2921
+ const tokensPerSecond = elapsedMs >= MIN_GENERATION_RATE_ELAPSED_MS
2922
+ ? (metrics.outputTokens * 1_000) / elapsedMs
2923
+ : undefined;
2278
2924
  return {
2279
2925
  outputTokens: metrics.outputTokens,
2280
- ...(tokensPerSecond === undefined || !Number.isFinite(tokensPerSecond) ? {} : { tokensPerSecond }),
2926
+ ...(tokensPerSecond === undefined || !Number.isFinite(tokensPerSecond)
2927
+ ? {}
2928
+ : { tokensPerSecond }),
2281
2929
  ...(metrics.estimated ? { estimated: true } : {}),
2282
2930
  };
2283
2931
  }
2284
2932
  scheduleCompactionQueueDrain(sessionId, delayMs = 0) {
2285
- if (!this.compactionPromptQueues.has(sessionId) || this.compactionDrainTimers.has(sessionId))
2933
+ if (!this.compactionPromptQueues.has(sessionId) ||
2934
+ this.compactionDrainTimers.has(sessionId))
2286
2935
  return;
2287
2936
  const timer = setTimeout(() => {
2288
2937
  this.compactionDrainTimers.delete(sessionId);
@@ -2295,7 +2944,7 @@ export class PiSessionService {
2295
2944
  if (active === undefined)
2296
2945
  return;
2297
2946
  const { session } = active.runtime;
2298
- if (session.isCompacting) {
2947
+ if (session.isCompacting || this.isModelPolicyMutationActive(session)) {
2299
2948
  this.scheduleCompactionQueueDrain(sessionId, 100);
2300
2949
  return;
2301
2950
  }
@@ -2313,7 +2962,9 @@ export class PiSessionService {
2313
2962
  return;
2314
2963
  this.publishStatus(session);
2315
2964
  const submitted = this.submitPrompt(session, prompt.text, undefined, prompt.images, prompt.echoUserMessage ?? true);
2316
- void submitted.finally(() => { this.scheduleCompactionQueueDrain(sessionId); });
2965
+ void submitted.finally(() => {
2966
+ this.scheduleCompactionQueueDrain(sessionId);
2967
+ });
2317
2968
  }
2318
2969
  takeCompactionPromptQueue(sessionId) {
2319
2970
  const queued = this.compactionPromptQueues.get(sessionId) ?? [];
@@ -2341,7 +2992,10 @@ export class PiSessionService {
2341
2992
  this.compactionDrainTimers.clear();
2342
2993
  }
2343
2994
  maybeGenerateSessionName(session, firstMessage) {
2344
- if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting)
2995
+ if (session.sessionName !== undefined ||
2996
+ session.messages.length !== 0 ||
2997
+ session.isStreaming ||
2998
+ session.isCompacting)
2345
2999
  return;
2346
3000
  const deterministicName = deterministicSessionName(firstMessage);
2347
3001
  if (deterministicName !== undefined) {
@@ -2351,10 +3005,21 @@ export class PiSessionService {
2351
3005
  const model = session.model;
2352
3006
  if (model === undefined)
2353
3007
  return;
2354
- void generateShortSessionName(session.agent.streamFunction, model, firstMessage).then((name) => {
3008
+ void runWithUtilityModelFallback(this.utilityModelResolver, "lightweight", model, (candidate) => generateShortSessionName(session.agent.streamFunction, candidate, firstMessage), (candidate, error) => {
3009
+ this.logger.info({
3010
+ provider: candidate.provider,
3011
+ modelId: candidate.id,
3012
+ error: error instanceof Error ? error.message : String(error),
3013
+ }, "utility title model failed");
3014
+ })
3015
+ .then((name) => {
2355
3016
  this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
2356
- }).catch(() => {
2357
- this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
3017
+ })
3018
+ .catch((error) => {
3019
+ this.logger.info({
3020
+ sessionId: session.sessionId,
3021
+ error: error instanceof Error ? error.message : String(error),
3022
+ }, "failed to apply generated session name");
2358
3023
  });
2359
3024
  }
2360
3025
  applyGeneratedSessionName(session, name) {
@@ -2375,7 +3040,10 @@ export class PiSessionService {
2375
3040
  this.applyGeneratedSessionName(session, generatedName);
2376
3041
  }
2377
3042
  catch (error) {
2378
- this.logger.info({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) }, "failed to apply deferred session name");
3043
+ this.logger.info({
3044
+ sessionId: session.sessionId,
3045
+ error: error instanceof Error ? error.message : String(error),
3046
+ }, "failed to apply deferred session name");
2379
3047
  }
2380
3048
  }
2381
3049
  const notifications = this.deferredSubsessionNotifications.get(session) ?? [];
@@ -2410,7 +3078,9 @@ export class PiSessionService {
2410
3078
  this.authLossWarnings.delete(warningKey);
2411
3079
  return;
2412
3080
  }
2413
- if (removedProviderId === undefined || model.provider !== removedProviderId || this.authLossWarnings.has(warningKey))
3081
+ if (removedProviderId === undefined ||
3082
+ model.provider !== removedProviderId ||
3083
+ this.authLossWarnings.has(warningKey))
2414
3084
  return;
2415
3085
  this.authLossWarnings.add(warningKey);
2416
3086
  this.events.publish(session.sessionId, {
@@ -2429,7 +3099,11 @@ export class PiSessionService {
2429
3099
  publishSessionName(session) {
2430
3100
  const event = session.sessionName === undefined
2431
3101
  ? { type: "session.name", sessionId: session.sessionId }
2432
- : { type: "session.name", sessionId: session.sessionId, name: session.sessionName };
3102
+ : {
3103
+ type: "session.name",
3104
+ sessionId: session.sessionId,
3105
+ name: session.sessionName,
3106
+ };
2433
3107
  this.events.publish(session.sessionId, event);
2434
3108
  this.events.publishGlobal(event);
2435
3109
  }
@@ -2469,17 +3143,20 @@ export class PiSessionService {
2469
3143
  return "active";
2470
3144
  }
2471
3145
  hasActiveWork(session) {
2472
- return this.treeNavigations.has(session)
2473
- || this.isSessionEntryMutationActive(session)
2474
- || this.isTreeExclusiveOperationActive(session)
2475
- || sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
3146
+ return (this.treeNavigations.has(session) ||
3147
+ this.isSessionEntryMutationActive(session) ||
3148
+ this.isTreeExclusiveOperationActive(session) ||
3149
+ sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length));
2476
3150
  }
2477
3151
  async runTreeExclusiveOperation(targets, activeError, operation) {
2478
3152
  const sessionIds = new Set();
2479
3153
  const runtimes = new Set();
2480
3154
  const sessions = new Set();
2481
3155
  for (const target of targets) {
2482
- const runtime = target.runtime ?? (target.session === undefined ? undefined : this.activeRuntimeForSession(target.session));
3156
+ const runtime = target.runtime ??
3157
+ (target.session === undefined
3158
+ ? undefined
3159
+ : this.activeRuntimeForSession(target.session));
2483
3160
  const session = target.session ?? runtime?.session;
2484
3161
  if (session !== undefined && this.hasActiveWork(session))
2485
3162
  throw new Error(activeError);
@@ -2518,7 +3195,8 @@ export class PiSessionService {
2518
3195
  if (this.isTreeExclusiveSessionIdentityActive(session.sessionId))
2519
3196
  return true;
2520
3197
  const runtime = this.activeRuntimeForSession(session);
2521
- return runtime !== undefined && (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) > 0;
3198
+ return (runtime !== undefined &&
3199
+ (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) > 0);
2522
3200
  }
2523
3201
  activeRuntimeForSession(session) {
2524
3202
  for (const active of new Set(this.active.values())) {
@@ -2610,11 +3288,18 @@ export class PiSessionService {
2610
3288
  }
2611
3289
  publishActivity(session, label, phase, detail) {
2612
3290
  const at = new Date().toISOString();
2613
- const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
3291
+ const stored = detail === undefined
3292
+ ? { phase, label, at }
3293
+ : { phase, label, detail, at };
2614
3294
  this.activities.set(session.sessionId, stored);
2615
- const activity = detail === undefined ? { sessionId: session.sessionId, phase, label, at } : { sessionId: session.sessionId, phase, label, detail, at };
3295
+ const activity = detail === undefined
3296
+ ? { sessionId: session.sessionId, phase, label, at }
3297
+ : { sessionId: session.sessionId, phase, label, detail, at };
2616
3298
  this.workspaceActivity?.applySessionActivity(session.sessionManager.getCwd(), activity);
2617
- this.events.publish(session.sessionId, { type: "activity.update", activity });
3299
+ this.events.publish(session.sessionId, {
3300
+ type: "activity.update",
3301
+ activity,
3302
+ });
2618
3303
  this.events.publishGlobal({ type: "activity.update", activity });
2619
3304
  this.observeUnreadActivityState(session);
2620
3305
  }
@@ -2634,12 +3319,393 @@ export class PiSessionService {
2634
3319
  const stored = { phase: "idle", label: "idle", at };
2635
3320
  this.activities.set(session.sessionId, stored);
2636
3321
  const activity = { sessionId: session.sessionId, ...stored };
2637
- this.events.publish(session.sessionId, { type: "activity.update", activity });
3322
+ this.events.publish(session.sessionId, {
3323
+ type: "activity.update",
3324
+ activity,
3325
+ });
2638
3326
  this.events.publishGlobal({ type: "activity.update", activity });
2639
3327
  }
3328
+ /**
3329
+ * Read-only capability view for `get_model_policy`, built from the session's
3330
+ * own cached inspection so it cannot disagree with what the HTTP route reports.
3331
+ */
3332
+ modelPolicyCapability(sessionId) {
3333
+ // The tool only ever asks about its own session, which is live by definition
3334
+ // while a tool call is executing. A miss means the runtime was torn down
3335
+ // mid-call, which is a terminal read failure rather than a policy answer.
3336
+ const session = this.active.get(sessionId)?.runtime.session;
3337
+ if (session === undefined)
3338
+ throw new Error(`Session ${sessionId} is not active`);
3339
+ const inspection = this.inspectAndCacheSessionModelPolicy(session);
3340
+ return buildModelPolicyCapability({
3341
+ policy: inspection.kind === "invalid" ? undefined : inspection.policy,
3342
+ currentRuntime: this.exactSelectionFromSession(session),
3343
+ ladder: this.modelTierRegistry.validate(),
3344
+ resolveTier: (tier) => {
3345
+ const resolved = this.modelTierRegistry.resolve(tier);
3346
+ return {
3347
+ model: { provider: resolved.model.provider, id: resolved.model.id },
3348
+ thinkingLevel: resolved.thinkingLevel,
3349
+ };
3350
+ },
3351
+ blockedReason: this.modelPolicyBlockedReason(session),
3352
+ });
3353
+ }
3354
+ exactSelectionFromSession(session) {
3355
+ const model = session.model;
3356
+ if (model === undefined ||
3357
+ typeof model.provider !== "string" ||
3358
+ model.provider.trim() === "" ||
3359
+ typeof model.id !== "string" ||
3360
+ model.id.trim() === "") {
3361
+ throw new Error("Session model policy requires a resolved runtime model provider and id");
3362
+ }
3363
+ if (typeof session.thinkingLevel !== "string" ||
3364
+ session.thinkingLevel.trim() === "") {
3365
+ throw new Error("Session model policy requires a resolved runtime thinking level");
3366
+ }
3367
+ return {
3368
+ model: { provider: model.provider, id: model.id },
3369
+ thinkingLevel: session.thinkingLevel,
3370
+ };
3371
+ }
3372
+ policyEntries(session) {
3373
+ return (session.sessionManager.getEntries?.() ??
3374
+ session.sessionManager.getBranch());
3375
+ }
3376
+ inspectAndCacheSessionModelPolicy(session) {
3377
+ const inspection = inspectSessionModelPolicy(this.policyEntries(session), this.exactSelectionFromSession(session));
3378
+ const ladderValidation = this.modelTierRegistry.validate();
3379
+ this.modelPolicyInspections.set(session, inspection);
3380
+ this.modelPolicyLadderValidations.set(session, ladderValidation);
3381
+ if (inspection.kind === "invalid")
3382
+ this.modelPolicyEntryReasons.set(session, inspection.reason);
3383
+ else
3384
+ this.modelPolicyEntryReasons.delete(session);
3385
+ return inspection;
3386
+ }
3387
+ /** Effective block: an unproven runtime restoration outranks an entry defect. */
3388
+ modelPolicyBlockedReason(session) {
3389
+ return (this.modelPolicyRuntimeBlocks.get(session.sessionId) ??
3390
+ this.modelPolicyEntryReasons.get(session));
3391
+ }
3392
+ /**
3393
+ * Root-session policy initialization. `true` records the runtime's own tuple;
3394
+ * an update applies the requested transition. Both complete before the caller
3395
+ * of `start()` sees the session, so no prompt can precede the policy.
3396
+ */
3397
+ async initializeSessionModelPolicy(session, initializer) {
3398
+ const current = {
3399
+ mode: "exact",
3400
+ exact: this.exactSelectionFromSession(session),
3401
+ };
3402
+ if (initializer === true) {
3403
+ this.appendSessionModelPolicy(session, current);
3404
+ this.inspectAndCacheSessionModelPolicy(session);
3405
+ return;
3406
+ }
3407
+ // Root creation reports failure through its own abort/dispose cleanup rather
3408
+ // than publishing a failed transition for a session no client has seen.
3409
+ await this.applySessionModelPolicy(session, current, initializer, {
3410
+ action: "initialize the session model policy",
3411
+ });
3412
+ }
3413
+ /**
3414
+ * The single policy transition path. Validates the complete target before any
3415
+ * setter, re-checks that no conflicting work started during that async
3416
+ * validation, applies model then thinking under the serialized entry-mutation
3417
+ * seam, verifies the effective pair, and only then persists and caches.
3418
+ */
3419
+ async applySessionModelPolicy(session, current, update, options) {
3420
+ const plan = planSessionModelPolicyUpdate(current, update, (tier) => {
3421
+ const resolved = this.modelTierRegistry.resolve(tier);
3422
+ return {
3423
+ model: { provider: resolved.model.provider, id: resolved.model.id },
3424
+ thinkingLevel: resolved.thinkingLevel,
3425
+ };
3426
+ });
3427
+ // Validation happens before the mutation opens, so a rejected target leaves
3428
+ // the runtime and the persisted policy untouched.
3429
+ const target = await this.resolveAvailableExactSelection(session, plan.target);
3430
+ // Refresh/validation awaited above, so a prompt or another mutation may have
3431
+ // started meanwhile. Re-check here — synchronously adjacent to the mutation
3432
+ // open, and *outside* it so the mutation is never its own conflict — to close
3433
+ // the await-to-setter race without touching tree-navigation behavior.
3434
+ this.assertModelPolicyMutationIdle(session, options.action);
3435
+ return this.runSessionModelPolicyMutation(session, "change session model policy", async () => {
3436
+ const previous = this.exactSelectionFromSession(session);
3437
+ try {
3438
+ await this.applyExactSelection(session, target);
3439
+ this.appendSessionModelPolicy(session, plan.policy);
3440
+ }
3441
+ catch (error) {
3442
+ const reason = error instanceof Error ? error.message : String(error);
3443
+ if (!(await this.restoreExactSelection(session, previous))) {
3444
+ this.modelPolicyRuntimeBlocks.set(session.sessionId, `MODEL_POLICY_BLOCKED: ${reason}; the previous model and thinking pair could not be restored`);
3445
+ }
3446
+ this.inspectAndCacheSessionModelPolicy(session);
3447
+ options.onTransitionFailure?.(reason);
3448
+ throw error;
3449
+ }
3450
+ this.modelPolicyRuntimeBlocks.delete(session.sessionId);
3451
+ this.inspectAndCacheSessionModelPolicy(session);
3452
+ return plan.policy;
3453
+ });
3454
+ }
3455
+ /**
3456
+ * Serialized policy-transition seam. Adds the policy-mutation marker on top of
3457
+ * the shared entry-mutation lifecycle so prompt submission can tell a transient
3458
+ * policy window apart from ordinary session work, and drains any prompt input
3459
+ * retained during that window once the tuple is settled again.
3460
+ */
3461
+ async runSessionModelPolicyMutation(session, action, operation) {
3462
+ // Captured before the first setter runs, so this is the confirmed tuple the
3463
+ // window reports. Only the outermost mutation records it: a nested mutation
3464
+ // opens on an already-transient tuple, which would not be confirmed.
3465
+ if (!this.isModelPolicyMutationActive(session))
3466
+ this.recordConfirmedExactSelection(session);
3467
+ this.modelPolicyMutationCounts.set(session.sessionId, (this.modelPolicyMutationCounts.get(session.sessionId) ?? 0) + 1);
3468
+ try {
3469
+ return await this.runSessionEntryMutation(session, action, operation);
3470
+ }
3471
+ finally {
3472
+ decrementMapCount(this.modelPolicyMutationCounts, session.sessionId);
3473
+ if (!this.isModelPolicyMutationActive(session)) {
3474
+ // The tuple is settled again (applied, restored, or blocked), so live
3475
+ // runtime state is authoritative once more.
3476
+ this.modelPolicyConfirmedSelections.delete(session.sessionId);
3477
+ this.scheduleCompactionQueueDrain(session.sessionId);
3478
+ }
3479
+ }
3480
+ }
3481
+ /**
3482
+ * Remember the pre-transition tuple for the status projection. A session whose
3483
+ * runtime has no resolved model has no confirmed tuple to report; that case is
3484
+ * left to the existing `exactSelectionFromSession` failure rather than
3485
+ * substituting anything.
3486
+ */
3487
+ recordConfirmedExactSelection(session) {
3488
+ try {
3489
+ this.modelPolicyConfirmedSelections.set(session.sessionId, this.exactSelectionFromSession(session));
3490
+ }
3491
+ catch {
3492
+ this.modelPolicyConfirmedSelections.delete(session.sessionId);
3493
+ }
3494
+ }
3495
+ isModelPolicyMutationActive(session) {
3496
+ return (this.modelPolicyMutationCounts.get(session.sessionId) ?? 0) > 0;
3497
+ }
3498
+ /**
3499
+ * Tell other clients a policy transition failed. Called after the failure state
3500
+ * (including any `MODEL_POLICY_BLOCKED`) is recorded and before the route error
3501
+ * propagates, so the published status already carries the blocked reason.
3502
+ */
3503
+ publishModelPolicyTransitionFailure(session, reason) {
3504
+ this.publishActivity(session, "model policy change failed", "error", reason);
3505
+ this.publishStatus(session);
3506
+ }
3507
+ /**
3508
+ * Reject a policy mutation that could race Pi's own entry writes. Tree
3509
+ * navigation is reported first because it is the more specific state.
3510
+ */
3511
+ assertModelPolicyMutationIdle(session, action) {
3512
+ this.assertTreeNavigationInactive(session, action);
3513
+ if (this.hasActiveWork(session))
3514
+ throw new Error(`Stop current session activity before you ${action}`);
3515
+ }
3516
+ /**
3517
+ * Gate for the direct Exact model/thinking routes. They act only in Exact
3518
+ * mode: while Tiered is active the ladder owns the runtime tuple, and while
3519
+ * the runtime is blocked no tuple change may be recorded as authoritative.
3520
+ */
3521
+ assertExactModelPolicyMutationAllowed(session, action) {
3522
+ this.assertModelPolicyMutationIdle(session, action);
3523
+ const inspection = this.inspectAndCacheSessionModelPolicy(session);
3524
+ const runtimeBlock = this.modelPolicyRuntimeBlocks.get(session.sessionId);
3525
+ if (runtimeBlock !== undefined)
3526
+ throw new Error(`Cannot ${action}: ${runtimeBlock}`);
3527
+ if (inspection.kind === "invalid") {
3528
+ throw new Error(`Cannot ${action}: the session model policy entry is invalid (${inspection.reason}). Repair the policy first.`);
3529
+ }
3530
+ if (inspection.policy.mode === "tiered") {
3531
+ throw new Error(`Cannot ${action} directly while the session model policy is Tiered. Change the model policy instead.`);
3532
+ }
3533
+ return inspection.policy;
3534
+ }
3535
+ /**
3536
+ * Run one Exact route operation and record Pi's *confirmed* resulting pair as
3537
+ * the new authoritative policy inside the same serialized mutation. Pi's own
3538
+ * model-selection clamp is therefore persisted as-is rather than replaced by
3539
+ * an invented target thinking level.
3540
+ */
3541
+ async runExactModelPolicyMutation(session, action, currentPolicy, operation) {
3542
+ let transitionFailure;
3543
+ try {
3544
+ return await this.runSessionModelPolicyMutation(session, action, async () => {
3545
+ const previous = this.exactSelectionFromSession(session);
3546
+ const result = await operation();
3547
+ const policy = {
3548
+ mode: "exact",
3549
+ exact: this.exactSelectionFromSession(session),
3550
+ ...(currentPolicy.tier === undefined
3551
+ ? {}
3552
+ : { tier: currentPolicy.tier }),
3553
+ };
3554
+ try {
3555
+ this.appendSessionModelPolicy(session, policy);
3556
+ }
3557
+ catch (error) {
3558
+ const reason = error instanceof Error ? error.message : String(error);
3559
+ if (!(await this.restoreExactSelection(session, previous))) {
3560
+ this.modelPolicyRuntimeBlocks.set(session.sessionId, `MODEL_POLICY_BLOCKED: ${reason}; the previous model and thinking pair could not be restored`);
3561
+ }
3562
+ this.inspectAndCacheSessionModelPolicy(session);
3563
+ // Only a failure that already moved the runtime tuple is announced; an
3564
+ // operation precondition ("Only one model available") keeps its plain
3565
+ // route error and publishes nothing.
3566
+ transitionFailure = reason;
3567
+ throw error;
3568
+ }
3569
+ this.inspectAndCacheSessionModelPolicy(session);
3570
+ return result;
3571
+ });
3572
+ }
3573
+ catch (error) {
3574
+ // Published after the mutation closed so the status reports the settled
3575
+ // (blocked or restored) state rather than "updating session".
3576
+ if (transitionFailure !== undefined)
3577
+ this.publishModelPolicyTransitionFailure(session, transitionFailure);
3578
+ throw error;
3579
+ }
3580
+ }
3581
+ /**
3582
+ * Refuse to reach a provider while the authoritative policy is unusable. This
3583
+ * covers a malformed newest entry and an unproven restoration; it deliberately
3584
+ * does no tier remapping.
3585
+ */
3586
+ assertPromptModelPolicyAllowed(session) {
3587
+ const inspection = this.inspectAndCacheSessionModelPolicy(session);
3588
+ const runtimeBlock = this.modelPolicyRuntimeBlocks.get(session.sessionId);
3589
+ if (runtimeBlock !== undefined)
3590
+ throw new Error(`Cannot send a prompt: ${runtimeBlock}`);
3591
+ if (inspection.kind === "invalid") {
3592
+ throw new Error(`Cannot send a prompt: the session model policy entry is invalid (${inspection.reason}). Repair the policy first.`);
3593
+ }
3594
+ }
3595
+ /**
3596
+ * Bind an exact selection to a currently available runtime model and prove the
3597
+ * target thinking level is supported by that *incoming* model. Pi's
3598
+ * `setThinkingLevel` clamps silently, so this check must precede every setter.
3599
+ */
3600
+ async resolveAvailableExactSelection(session, selection) {
3601
+ await session.modelRuntime.refresh({ allowNetwork: false });
3602
+ const candidates = session.scopedModels.length > 0
3603
+ ? session.scopedModels.map((scoped) => scoped.model)
3604
+ : session.modelRuntime.getAvailableSnapshot();
3605
+ const model = candidates.find((candidate) => candidate.provider === selection.model.provider &&
3606
+ candidate.id === selection.model.id);
3607
+ const described = `${selection.model.provider}/${selection.model.id}`;
3608
+ if (model === undefined)
3609
+ throw new Error(`Model not found: ${described}`);
3610
+ if (!isKnownThinkingLevel(selection.thinkingLevel)) {
3611
+ throw new Error(`Unknown thinking level ${selection.thinkingLevel} for ${described}`);
3612
+ }
3613
+ if (!runtimeThinkingLevels(model).includes(selection.thinkingLevel)) {
3614
+ throw new Error(`Thinking level ${selection.thinkingLevel} is unsupported by ${described}`);
3615
+ }
3616
+ return {
3617
+ model,
3618
+ selection: {
3619
+ model: { provider: model.provider, id: model.id },
3620
+ thinkingLevel: selection.thinkingLevel,
3621
+ },
3622
+ };
3623
+ }
3624
+ /**
3625
+ * Model first, then thinking: `setThinkingLevel` clamps against the currently
3626
+ * selected model, so the reverse order can discard a level the incoming model
3627
+ * supports. The effective pair is verified afterwards; a clamped substitute is
3628
+ * a failed transition, never a success.
3629
+ */
3630
+ async applyExactSelection(session, target) {
3631
+ const level = target.selection.thinkingLevel;
3632
+ if (!isKnownThinkingLevel(level))
3633
+ throw new Error(`Unknown thinking level ${level}`);
3634
+ await session.setModel(target.model);
3635
+ session.setThinkingLevel(level);
3636
+ const effective = this.exactSelectionFromSession(session);
3637
+ if (effective.model.provider !== target.selection.model.provider ||
3638
+ effective.model.id !== target.selection.model.id ||
3639
+ effective.thinkingLevel !== level) {
3640
+ throw new Error(`Session model policy could not activate ${target.selection.model.provider}/${target.selection.model.id} at thinking level ${level}` +
3641
+ `; the runtime reports ${effective.model.provider}/${effective.model.id} at thinking level ${effective.thinkingLevel}`);
3642
+ }
3643
+ }
3644
+ /**
3645
+ * Put the previous exact pair back in the same model-then-thinking order.
3646
+ * Returns whether the previous pair is *proven* active again; a false result
3647
+ * means the runtime tuple is ambiguous and the session must refuse prompts.
3648
+ */
3649
+ async restoreExactSelection(session, previous) {
3650
+ try {
3651
+ const current = this.exactSelectionFromSession(session);
3652
+ if (current.model.provider === previous.model.provider &&
3653
+ current.model.id === previous.model.id &&
3654
+ current.thinkingLevel === previous.thinkingLevel) {
3655
+ return true;
3656
+ }
3657
+ const target = await this.resolveAvailableExactSelection(session, previous);
3658
+ await this.applyExactSelection(session, target);
3659
+ return true;
3660
+ }
3661
+ catch {
3662
+ return false;
3663
+ }
3664
+ }
3665
+ appendSessionModelPolicy(session, policy) {
3666
+ if (session.sessionManager.appendCustomEntry === undefined) {
3667
+ throw new Error("Cannot persist the session model policy: session persistence is unavailable");
3668
+ }
3669
+ session.sessionManager.appendCustomEntry(SESSION_MODEL_POLICY_CUSTOM_TYPE, serializeSessionModelPolicy(policy));
3670
+ }
3671
+ modelPolicyStatusFromSession(session) {
3672
+ const inspection = this.modelPolicyInspections.get(session);
3673
+ if (inspection === undefined)
3674
+ throw new Error("Session model policy inspection is unavailable");
3675
+ const ladderValidation = this.modelPolicyLadderValidations.get(session);
3676
+ if (ladderValidation === undefined)
3677
+ throw new Error("Session model tier validation is unavailable");
3678
+ // Inside a transition the live tuple is half-applied, so report the tuple
3679
+ // this session last confirmed. `mode` already comes from the unchanged
3680
+ // entry, keeping the published pair internally consistent.
3681
+ const confirmed = this.isModelPolicyMutationActive(session)
3682
+ ? this.modelPolicyConfirmedSelections.get(session.sessionId)
3683
+ : undefined;
3684
+ const resolved = confirmed ?? this.exactSelectionFromSession(session);
3685
+ const blockedReason = this.modelPolicyBlockedReason(session);
3686
+ if (inspection.kind === "invalid") {
3687
+ return {
3688
+ mode: "exact",
3689
+ resolved,
3690
+ ladderValid: ladderValidation.valid,
3691
+ ...(blockedReason === undefined ? {} : { blockedReason }),
3692
+ };
3693
+ }
3694
+ return {
3695
+ mode: inspection.policy.mode,
3696
+ ...(inspection.policy.tier === undefined
3697
+ ? {}
3698
+ : { tier: inspection.policy.tier }),
3699
+ resolved,
3700
+ ladderValid: ladderValidation.valid,
3701
+ ...(blockedReason === undefined ? {} : { blockedReason }),
3702
+ };
3703
+ }
2640
3704
  statusFromSession(session) {
2641
3705
  const stats = session.getSessionStats();
2642
- const model = session.model === undefined ? undefined : modelToClientModel(session.model);
3706
+ const model = session.model === undefined
3707
+ ? undefined
3708
+ : modelToClientModel(session.model);
2643
3709
  const generation = this.generationForStatus(session);
2644
3710
  const contextUsage = session.getContextUsage();
2645
3711
  const warnings = this.warningsForSession(session);
@@ -2648,6 +3714,7 @@ export class PiSessionService {
2648
3714
  persisted: sessionFileExists(session.sessionFile),
2649
3715
  ...(model === undefined ? {} : { model }),
2650
3716
  thinkingLevel: session.thinkingLevel,
3717
+ modelPolicy: this.modelPolicyStatusFromSession(session),
2651
3718
  isStreaming: session.isStreaming,
2652
3719
  isCompacting: session.isCompacting,
2653
3720
  isBashRunning: session.isBashRunning,
@@ -2676,7 +3743,8 @@ export class PiSessionService {
2676
3743
  return warnings;
2677
3744
  }
2678
3745
  pendingMessageCount(session) {
2679
- return session.pendingMessageCount + this.compactionQueuedMessages(session.sessionId).length;
3746
+ return (session.pendingMessageCount +
3747
+ this.compactionQueuedMessages(session.sessionId).length);
2680
3748
  }
2681
3749
  compactionQueuedMessages(sessionId) {
2682
3750
  return this.compactionPromptQueues.get(sessionId) ?? [];
@@ -2691,7 +3759,9 @@ function previewResponseFromPlan(plan) {
2691
3759
  thresholds: plan.thresholds,
2692
3760
  projects: plan.projects,
2693
3761
  totals: plan.totals,
2694
- ...(plan.skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds: plan.skippedBusySessionIds }),
3762
+ ...(plan.skippedBusySessionIds.length === 0
3763
+ ? {}
3764
+ : { skippedBusySessionIds: plan.skippedBusySessionIds }),
2695
3765
  };
2696
3766
  }
2697
3767
  function uniqueBulkSessionRefs(refs) {
@@ -2710,19 +3780,36 @@ function bulkRefToLookup(ref) {
2710
3780
  return ref.cwd === undefined ? ref.id : { id: ref.id, cwd: ref.cwd };
2711
3781
  }
2712
3782
  function findArchivedRecordForBulkRef(records, ref) {
2713
- return records.find((record) => (ref.cwd === undefined || record.cwd === ref.cwd) && (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
3783
+ return records.find((record) => (ref.cwd === undefined || record.cwd === ref.cwd) &&
3784
+ (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
2714
3785
  }
2715
3786
  function findListedSessionForBulkRef(context, ref) {
2716
3787
  if (ref.cwd !== undefined)
2717
3788
  return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
2718
- return context.allSessions === undefined ? undefined : findSessionByIdOrPrefix(context.allSessions, ref.id);
3789
+ return context.allSessions === undefined
3790
+ ? undefined
3791
+ : findSessionByIdOrPrefix(context.allSessions, ref.id);
2719
3792
  }
2720
3793
  function findSessionByIdOrPrefix(sessions, sessionId) {
2721
- return sessions.find((session) => session.id === sessionId) ?? sessions.find((session) => session.id.startsWith(sessionId));
3794
+ return (sessions.find((session) => session.id === sessionId) ??
3795
+ sessions.find((session) => session.id.startsWith(sessionId)));
2722
3796
  }
2723
3797
  function uniqueStrings(values) {
2724
3798
  return [...new Set(values)];
2725
3799
  }
3800
+ /**
3801
+ * Reconcile duplicate catalog entries by canonical CWD + id, matching the
3802
+ * last-writer-wins convention other catalog reconciliation helpers use. The
3803
+ * reorder loader can read the same session more than once when overlapping
3804
+ * catalog CWDs are requested (e.g. a cross-workspace children scope).
3805
+ */
3806
+ function uniqueSessionsByIdentity(sessions) {
3807
+ const byIdentity = new Map();
3808
+ for (const session of sessions) {
3809
+ byIdentity.set(JSON.stringify([canonicalizeStoredCwd(session.cwd), session.id]), session);
3810
+ }
3811
+ return [...byIdentity.values()];
3812
+ }
2726
3813
  function errorMessage(error) {
2727
3814
  return error instanceof Error ? error.message : String(error);
2728
3815
  }
@@ -2756,7 +3843,9 @@ function clientSessionFromListEntry(session) {
2756
3843
  modified: session.modified.toISOString(),
2757
3844
  messageCount: session.messageCount,
2758
3845
  firstMessage: session.firstMessage,
2759
- ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
3846
+ ...(session.parentSessionPath === undefined
3847
+ ? {}
3848
+ : { parentSessionPath: session.parentSessionPath }),
2760
3849
  };
2761
3850
  }
2762
3851
  function archiveInputFromListEntry(session) {
@@ -2769,7 +3858,9 @@ function archiveInputFromListEntry(session) {
2769
3858
  messageCount: session.messageCount,
2770
3859
  firstMessage: session.firstMessage,
2771
3860
  ...(session.name === undefined ? {} : { name: session.name }),
2772
- ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
3861
+ ...(session.parentSessionPath === undefined
3862
+ ? {}
3863
+ : { parentSessionPath: session.parentSessionPath }),
2773
3864
  };
2774
3865
  }
2775
3866
  function archiveInputFromActiveSession(session) {
@@ -2796,7 +3887,9 @@ function archiveCandidateFromListEntry(session) {
2796
3887
  cwd: session.cwd,
2797
3888
  archived: false,
2798
3889
  listEntry: session,
2799
- ...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
3890
+ ...(session.parentSessionPath === undefined
3891
+ ? {}
3892
+ : { parentSessionPath: session.parentSessionPath }),
2800
3893
  };
2801
3894
  }
2802
3895
  function archiveCandidateFromArchivedRecord(record, fallback) {
@@ -2835,7 +3928,10 @@ function archiveInputFromCandidate(candidate) {
2835
3928
  throw new Error(`Session is not available for archiving: ${candidate.id}`);
2836
3929
  }
2837
3930
  function sessionHasActiveWork(session, extraQueuedMessageCount = 0) {
2838
- return session.isStreaming || session.isCompacting || session.isBashRunning || session.pendingMessageCount + extraQueuedMessageCount > 0;
3931
+ return (session.isStreaming ||
3932
+ session.isCompacting ||
3933
+ session.isBashRunning ||
3934
+ session.pendingMessageCount + extraQueuedMessageCount > 0);
2839
3935
  }
2840
3936
  function sessionDisplayName(session) {
2841
3937
  return session.sessionName ?? session.sessionId;
@@ -2846,7 +3942,11 @@ function clientSessionFromArchivedRecord(record, fallback) {
2846
3942
  const modified = record.modified ?? fallback?.modified.toISOString();
2847
3943
  const messageCount = record.messageCount ?? fallback?.messageCount;
2848
3944
  const firstMessage = record.firstMessage ?? fallback?.firstMessage;
2849
- if (path === undefined || created === undefined || modified === undefined || messageCount === undefined || firstMessage === undefined)
3945
+ if (path === undefined ||
3946
+ created === undefined ||
3947
+ modified === undefined ||
3948
+ messageCount === undefined ||
3949
+ firstMessage === undefined)
2850
3950
  return undefined;
2851
3951
  const name = record.name ?? fallback?.name;
2852
3952
  const parentSessionPath = record.parentSessionPath ?? fallback?.parentSessionPath;
@@ -2879,10 +3979,28 @@ function archivedTimestamp(record) {
2879
3979
  function isDefined(value) {
2880
3980
  return value !== undefined;
2881
3981
  }
2882
- function mergeSessionMetadata(session, pinnedPaths) {
2883
- if (!pinnedPaths.has(session.path))
2884
- return session;
2885
- return { ...session, pinned: true };
3982
+ function mergeSessionMetadata(session, metadata) {
3983
+ const entry = metadata[session.path];
3984
+ const pinned = entry?.pinned === true;
3985
+ const scope = session.parentSessionPath === undefined
3986
+ ? { kind: "root", cwd: canonicalizeStoredCwd(session.cwd) }
3987
+ : { kind: "children", parentSessionPath: session.parentSessionPath };
3988
+ const order = entry?.order;
3989
+ const manualOrder = session.archived !== true
3990
+ && order?.pinned === pinned
3991
+ && sessionReorderScopesEqual(order.scope, scope)
3992
+ ? order.position
3993
+ : undefined;
3994
+ return {
3995
+ ...session,
3996
+ ...(pinned ? { pinned: true } : {}),
3997
+ ...(manualOrder === undefined ? {} : { manualOrder }),
3998
+ };
3999
+ }
4000
+ function sessionReorderScopesEqual(a, b) {
4001
+ if (a.kind === "root")
4002
+ return b.kind === "root" && a.cwd === b.cwd;
4003
+ return b.kind === "children" && a.parentSessionPath === b.parentSessionPath;
2886
4004
  }
2887
4005
  /**
2888
4006
  * Resolve the delegation capability from server-owned, persisted session
@@ -2946,9 +4064,11 @@ function findReciprocalParentSubsessionLink(managers, parentSessionFile, parentS
2946
4064
  const link = parsePersistedParentSubsessionLink(entry);
2947
4065
  if (link === undefined)
2948
4066
  continue;
2949
- if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId)
4067
+ if (link.spawnedBySessionId !== parentSessionId ||
4068
+ link.spawnedSessionId !== childSessionId)
2950
4069
  continue;
2951
- if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile))
4070
+ if (link.spawnedSessionFile === undefined ||
4071
+ !sessionPathsEqual(link.spawnedSessionFile, childSessionFile))
2952
4072
  continue;
2953
4073
  return link;
2954
4074
  }
@@ -2958,7 +4078,9 @@ function trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessio
2958
4078
  return {
2959
4079
  parentSessionId,
2960
4080
  childSessionId: link.spawnedSessionId,
2961
- ...(link.spawnedSessionFile === undefined ? {} : { childSessionFile: link.spawnedSessionFile }),
4081
+ ...(link.spawnedSessionFile === undefined
4082
+ ? {}
4083
+ : { childSessionFile: link.spawnedSessionFile }),
2962
4084
  parentSessionFile,
2963
4085
  ...(link.cwd === undefined ? {} : { cwd: link.cwd }),
2964
4086
  };
@@ -2968,7 +4090,9 @@ function persistedParentSubsessionLinkData(link) {
2968
4090
  version: 1,
2969
4091
  spawnedBySessionId: link.parentSessionId,
2970
4092
  spawnedSessionId: link.childSessionId,
2971
- ...(link.childSessionFile === undefined ? {} : { spawnedSessionFile: link.childSessionFile }),
4093
+ ...(link.childSessionFile === undefined
4094
+ ? {}
4095
+ : { spawnedSessionFile: link.childSessionFile }),
2972
4096
  ...(link.cwd === undefined ? {} : { cwd: link.cwd }),
2973
4097
  };
2974
4098
  }
@@ -2980,33 +4104,45 @@ function persistedChildSubsessionLinkData(parentSessionId, childSessionId) {
2980
4104
  };
2981
4105
  }
2982
4106
  function parsePersistedParentSubsessionLink(entry) {
2983
- if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE)
4107
+ if (!isRecord(entry) ||
4108
+ entry["type"] !== "custom" ||
4109
+ entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE)
2984
4110
  return undefined;
2985
4111
  const data = entry["data"];
2986
4112
  if (!isRecord(data))
2987
4113
  return undefined;
2988
4114
  const spawnedBySessionId = getString(data, "spawnedBySessionId");
2989
4115
  const spawnedSessionId = getString(data, "spawnedSessionId");
2990
- if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
4116
+ if (spawnedBySessionId === undefined ||
4117
+ spawnedBySessionId === "" ||
4118
+ spawnedSessionId === undefined ||
4119
+ spawnedSessionId === "")
2991
4120
  return undefined;
2992
4121
  const spawnedSessionFile = getString(data, "spawnedSessionFile");
2993
4122
  const cwd = getString(data, "cwd");
2994
4123
  return {
2995
4124
  spawnedBySessionId,
2996
4125
  spawnedSessionId,
2997
- ...(spawnedSessionFile === undefined || spawnedSessionFile === "" ? {} : { spawnedSessionFile }),
4126
+ ...(spawnedSessionFile === undefined || spawnedSessionFile === ""
4127
+ ? {}
4128
+ : { spawnedSessionFile }),
2998
4129
  ...(cwd === undefined || cwd === "" ? {} : { cwd }),
2999
4130
  };
3000
4131
  }
3001
4132
  function parsePersistedChildSubsessionLink(entry) {
3002
- if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE)
4133
+ if (!isRecord(entry) ||
4134
+ entry["type"] !== "custom" ||
4135
+ entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE)
3003
4136
  return undefined;
3004
4137
  const data = entry["data"];
3005
4138
  if (!isRecord(data))
3006
4139
  return undefined;
3007
4140
  const spawnedBySessionId = getString(data, "spawnedBySessionId");
3008
4141
  const spawnedSessionId = getString(data, "spawnedSessionId");
3009
- if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
4142
+ if (spawnedBySessionId === undefined ||
4143
+ spawnedBySessionId === "" ||
4144
+ spawnedSessionId === undefined ||
4145
+ spawnedSessionId === "")
3010
4146
  return undefined;
3011
4147
  return { spawnedBySessionId, spawnedSessionId };
3012
4148
  }
@@ -3031,13 +4167,16 @@ function sessionFileExists(sessionFile) {
3031
4167
  }
3032
4168
  function sessionFileMatches(session, expectedSessionFile) {
3033
4169
  const sessionFile = nonEmptyString(session.sessionFile);
3034
- return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile);
4170
+ return (sessionFile !== undefined &&
4171
+ expectedSessionFile !== undefined &&
4172
+ sessionPathsEqual(sessionFile, expectedSessionFile));
3035
4173
  }
3036
4174
  function activeSessionFileMatches(active, expectedSessionFile) {
3037
4175
  return sessionFileMatches(active.runtime.session, expectedSessionFile);
3038
4176
  }
3039
4177
  function trackedLinkParentFileMatches(link, parentSessionFile) {
3040
- return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
4178
+ return (link.parentSessionFile !== undefined &&
4179
+ sessionPathsEqual(link.parentSessionFile, parentSessionFile));
3041
4180
  }
3042
4181
  async function readSessionHeaderSummary(sessionFile) {
3043
4182
  let file;
@@ -3049,10 +4188,15 @@ async function readSessionHeaderSummary(sessionFile) {
3049
4188
  if (firstLine === undefined || firstLine === "")
3050
4189
  return undefined;
3051
4190
  const header = JSON.parse(firstLine);
3052
- if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string")
4191
+ if (!isRecord(header) ||
4192
+ header["type"] !== "session" ||
4193
+ typeof header["id"] !== "string")
3053
4194
  return undefined;
3054
4195
  const parentSession = getString(header, "parentSession");
3055
- return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) };
4196
+ return {
4197
+ id: header["id"],
4198
+ ...(parentSession === undefined ? {} : { parentSession }),
4199
+ };
3056
4200
  }
3057
4201
  catch {
3058
4202
  return undefined;
@@ -3067,7 +4211,8 @@ async function sessionFileHeaderMatches(sessionFile, expected) {
3067
4211
  return false;
3068
4212
  if (expected.parentSessionFile === undefined)
3069
4213
  return true;
3070
- return header.parentSession !== undefined && sessionPathsEqual(header.parentSession, expected.parentSessionFile);
4214
+ return (header.parentSession !== undefined &&
4215
+ sessionPathsEqual(header.parentSession, expected.parentSessionFile));
3071
4216
  }
3072
4217
  async function clearParentSession(sessionFile) {
3073
4218
  const content = await readFile(sessionFile, "utf8");
@@ -3092,8 +4237,12 @@ function clearSessionQueue(session) {
3092
4237
  }
3093
4238
  function queuedMessagesFromSession(session, extraQueuedMessages = []) {
3094
4239
  return [
3095
- ...session.getSteeringMessages().map((text) => ({ kind: "steer", text })),
3096
- ...session.getFollowUpMessages().map((text) => ({ kind: "followUp", text })),
4240
+ ...session
4241
+ .getSteeringMessages()
4242
+ .map((text) => ({ kind: "steer", text })),
4243
+ ...session
4244
+ .getFollowUpMessages()
4245
+ .map((text) => ({ kind: "followUp", text })),
3097
4246
  ...extraQueuedMessages,
3098
4247
  ];
3099
4248
  }
@@ -3145,7 +4294,8 @@ function historyMessages(session) {
3145
4294
  messages.push({
3146
4295
  ...historyMessage,
3147
4296
  entryId,
3148
- ...(previousMessage?.role === "assistant" && previousMessage.entryId !== undefined
4297
+ ...(previousMessage?.role === "assistant" &&
4298
+ previousMessage.entryId !== undefined
3149
4299
  ? { previousAssistantEntryId: previousMessage.entryId }
3150
4300
  : {}),
3151
4301
  ...(messages.length > 0 ? { canFork: true } : {}),
@@ -3158,17 +4308,30 @@ function historyMessages(session) {
3158
4308
  continue;
3159
4309
  }
3160
4310
  if (entry["type"] === "custom_message" && entry["display"] === true) {
3161
- messages.push({ role: "custom", content: entry["content"], customType: entry["customType"], details: entry["details"] });
4311
+ messages.push({
4312
+ role: "custom",
4313
+ content: entry["content"],
4314
+ customType: entry["customType"],
4315
+ details: entry["details"],
4316
+ });
3162
4317
  previousMessage = { role: "custom", entryId: undefined };
3163
4318
  continue;
3164
4319
  }
3165
4320
  if (entry["type"] === "compaction") {
3166
- messages.push({ role: "system", source: "compaction", content: `Compacted history:\n\n${stringValue(entry["summary"])}` });
4321
+ messages.push({
4322
+ role: "system",
4323
+ source: "compaction",
4324
+ content: `Compacted history:\n\n${stringValue(entry["summary"])}`,
4325
+ });
3167
4326
  previousMessage = { role: "system", entryId: undefined };
3168
4327
  continue;
3169
4328
  }
3170
4329
  if (entry["type"] === "branch_summary") {
3171
- messages.push({ role: "system", source: "branch_summary", content: `Branch summary:\n\n${stringValue(entry["summary"])}` });
4330
+ messages.push({
4331
+ role: "system",
4332
+ source: "branch_summary",
4333
+ content: `Branch summary:\n\n${stringValue(entry["summary"])}`,
4334
+ });
3172
4335
  previousMessage = { role: "system", entryId: undefined };
3173
4336
  }
3174
4337
  }
@@ -3201,7 +4364,9 @@ function finalAssistantText(messages) {
3201
4364
  continue;
3202
4365
  const texts = [];
3203
4366
  for (const part of content) {
3204
- if (isRecord(part) && part["type"] === "text" && typeof part["text"] === "string")
4367
+ if (isRecord(part) &&
4368
+ part["type"] === "text" &&
4369
+ typeof part["text"] === "string")
3205
4370
  texts.push(part["text"]);
3206
4371
  }
3207
4372
  if (texts.length > 0)
@@ -3212,23 +4377,52 @@ function finalAssistantText(messages) {
3212
4377
  function toClientEvent(event) {
3213
4378
  const eventType = getString(event, "type");
3214
4379
  const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
3215
- if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
3216
- return { type: "assistant.delta", text: getString(assistantMessageEvent, "delta") ?? "" };
4380
+ if (eventType === "message_update" &&
4381
+ getString(assistantMessageEvent, "type") === "text_delta") {
4382
+ return {
4383
+ type: "assistant.delta",
4384
+ text: getString(assistantMessageEvent, "delta") ?? "",
4385
+ };
3217
4386
  }
3218
- if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "thinking_delta") {
3219
- return { type: "assistant.thinking.delta", text: getString(assistantMessageEvent, "delta") ?? "" };
4387
+ if (eventType === "message_update" &&
4388
+ getString(assistantMessageEvent, "type") === "thinking_delta") {
4389
+ return {
4390
+ type: "assistant.thinking.delta",
4391
+ text: getString(assistantMessageEvent, "delta") ?? "",
4392
+ };
3220
4393
  }
3221
4394
  if (eventType === "tool_execution_start") {
3222
4395
  const args = getProperty(event, "args");
3223
- return { type: "tool.start", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", summary: summarizeToolArgs(args), args };
4396
+ return {
4397
+ type: "tool.start",
4398
+ toolName: getString(event, "toolName") ?? "",
4399
+ toolCallId: getString(event, "toolCallId") ?? "",
4400
+ summary: summarizeToolArgs(args),
4401
+ args,
4402
+ };
3224
4403
  }
3225
4404
  if (eventType === "tool_execution_update") {
3226
4405
  const partialResult = getProperty(event, "partialResult");
3227
- return { type: "tool.update", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(partialResult), content: toolResultContent(partialResult), details: toolResultDetails(partialResult) };
4406
+ return {
4407
+ type: "tool.update",
4408
+ toolName: getString(event, "toolName") ?? "",
4409
+ toolCallId: getString(event, "toolCallId") ?? "",
4410
+ text: stringifyToolResult(partialResult),
4411
+ content: toolResultContent(partialResult),
4412
+ details: toolResultDetails(partialResult),
4413
+ };
3228
4414
  }
3229
4415
  if (eventType === "tool_execution_end") {
3230
4416
  const result = getProperty(event, "result");
3231
- return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(result), content: toolResultContent(result), details: toolResultDetails(result), isError: getBoolean(event, "isError") === true };
4417
+ return {
4418
+ type: "tool.end",
4419
+ toolName: getString(event, "toolName") ?? "",
4420
+ toolCallId: getString(event, "toolCallId") ?? "",
4421
+ text: stringifyToolResult(result),
4422
+ content: toolResultContent(result),
4423
+ details: toolResultDetails(result),
4424
+ isError: getBoolean(event, "isError") === true,
4425
+ };
3232
4426
  }
3233
4427
  if (eventType === "agent_start")
3234
4428
  return { type: "agent.start" };
@@ -3236,7 +4430,9 @@ function toClientEvent(event) {
3236
4430
  return { type: "agent.end" };
3237
4431
  if (eventType === "message_end") {
3238
4432
  const message = getProperty(event, "message");
3239
- return message === undefined ? { type: "message.end" } : { type: "message.end", message };
4433
+ return message === undefined
4434
+ ? { type: "message.end" }
4435
+ : { type: "message.end", message };
3240
4436
  }
3241
4437
  return { type: "pi.event", eventType: eventType ?? "unknown" };
3242
4438
  }
@@ -3249,13 +4445,18 @@ function summarizeToolArgs(args) {
3249
4445
  const path = getString(args, "path");
3250
4446
  if (path !== undefined)
3251
4447
  return path;
3252
- if (typeof args["oldText"] === "string" && typeof args["newText"] === "string")
4448
+ if (typeof args["oldText"] === "string" &&
4449
+ typeof args["newText"] === "string")
3253
4450
  return "edit text replacement";
3254
4451
  const edits = args["edits"];
3255
4452
  if (Array.isArray(edits))
3256
4453
  return `${String(edits.length)} edit${edits.length === 1 ? "" : "s"}`;
3257
- const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3);
3258
- return entries.map(([key, value]) => `${key}: ${shortToolValue(value)}`).join(" · ");
4454
+ const entries = Object.entries(args)
4455
+ .filter(([, value]) => value != null)
4456
+ .slice(0, 3);
4457
+ return entries
4458
+ .map(([key, value]) => `${key}: ${shortToolValue(value)}`)
4459
+ .join(" · ");
3259
4460
  }
3260
4461
  function shortToolValue(value) {
3261
4462
  if (typeof value === "string")
@@ -3288,11 +4489,16 @@ function stringifyToolResult(result) {
3288
4489
  if (typeof result === "string")
3289
4490
  return result;
3290
4491
  if (Array.isArray(result))
3291
- return result.map(stringifyToolResult).filter((text) => text !== "").join("\n");
4492
+ return result
4493
+ .map(stringifyToolResult)
4494
+ .filter((text) => text !== "")
4495
+ .join("\n");
3292
4496
  if (isRecord(result)) {
3293
4497
  if (getString(result, "type") === "image")
3294
4498
  return "[image]";
3295
- const text = getString(result, "text") ?? getString(result, "content") ?? getString(result, "output");
4499
+ const text = getString(result, "text") ??
4500
+ getString(result, "content") ??
4501
+ getString(result, "output");
3296
4502
  if (text !== undefined)
3297
4503
  return text;
3298
4504
  const content = getProperty(result, "content");
@@ -3318,7 +4524,9 @@ function getBoolean(value, key) {
3318
4524
  }
3319
4525
  function providerOutputTokens(message) {
3320
4526
  const output = getProperty(getProperty(message, "usage"), "output");
3321
- return typeof output === "number" && Number.isFinite(output) && output > 0 ? output : undefined;
4527
+ return typeof output === "number" && Number.isFinite(output) && output > 0
4528
+ ? output
4529
+ : undefined;
3322
4530
  }
3323
4531
  /** Match Pi Web's 4-characters-per-token live approximation. */
3324
4532
  function estimatedOutputTokens(message) {
@@ -3353,7 +4561,9 @@ function stringifyPrimitive(value) {
3353
4561
  return "";
3354
4562
  if (typeof value === "string")
3355
4563
  return value;
3356
- if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint")
4564
+ if (typeof value === "number" ||
4565
+ typeof value === "boolean" ||
4566
+ typeof value === "bigint")
3357
4567
  return String(value);
3358
4568
  return "";
3359
4569
  }