@hyperdreamer/pi-webui 1.11.0-beta.4 → 1.11.0-beta.5

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 (47) hide show
  1. package/README.md +15 -0
  2. package/dist/cli.js +265 -32
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{CodeViewer-Vvy_gSv8.js → CodeViewer-BJQFgK3S.js} +1 -1
  5. package/dist/client/assets/{UnifiedDiffViewer-OAmwWrcV.js → UnifiedDiffViewer-DfJJrNLr.js} +1 -1
  6. package/dist/client/assets/{index-DycHqr3e.js → index-CsVTzPPD.js} +551 -405
  7. package/dist/client/index.html +1 -1
  8. package/dist/server/sessions/modelTierRegistry.js +14 -0
  9. package/dist/server/sessions/modelTierRegistry.js.map +1 -1
  10. package/dist/server/sessions/piSessionService.js +479 -16
  11. package/dist/server/sessions/piSessionService.js.map +1 -1
  12. package/dist/server/sessions/sessionModelPolicy.js +118 -0
  13. package/dist/server/sessions/sessionModelPolicy.js.map +1 -0
  14. package/dist/server/sessions/sessionRoutes.js +98 -3
  15. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  16. package/dist/server/skills/optionalSkillInstall.js +69 -0
  17. package/dist/server/skills/optionalSkillInstall.js.map +1 -0
  18. package/dist/server/skills/optionalSkillInstaller.js +148 -0
  19. package/dist/server/skills/optionalSkillInstaller.js.map +1 -0
  20. package/dist/shared/apiTypes.d.ts +34 -0
  21. package/dist/shared/apiTypes.js +1 -0
  22. package/dist/shared/apiTypes.js.map +1 -1
  23. package/dist/shared/capabilities.js +3 -0
  24. package/dist/shared/capabilities.js.map +1 -1
  25. package/dist/shared/federatedRoutes.js +2 -0
  26. package/dist/shared/federatedRoutes.js.map +1 -1
  27. package/docs/config.md +10 -0
  28. package/optional-skills/deterministic-subagent-driven-development/SKILL.md +224 -0
  29. package/optional-skills/deterministic-subagent-driven-development/pi-webui-skill.json +28 -0
  30. package/optional-skills/deterministic-subagent-driven-development/prompts/final-reviewer.md +132 -0
  31. package/optional-skills/deterministic-subagent-driven-development/prompts/implementer.md +101 -0
  32. package/optional-skills/deterministic-subagent-driven-development/prompts/re-reviewer.md +60 -0
  33. package/optional-skills/deterministic-subagent-driven-development/prompts/task-reviewer.md +80 -0
  34. package/optional-skills/deterministic-subagent-driven-development/references/capability-contract.md +174 -0
  35. package/optional-skills/deterministic-subagent-driven-development/references/plan-contract.md +268 -0
  36. package/optional-skills/deterministic-subagent-driven-development/references/state-machine.md +177 -0
  37. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/manifest.mjs +258 -0
  38. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/plan-policy.mjs +341 -0
  39. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/prompt-renderer.mjs +290 -0
  40. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/state-machine.mjs +1263 -0
  41. package/optional-skills/deterministic-subagent-driven-development/scripts/lib/state-store.mjs +532 -0
  42. package/optional-skills/deterministic-subagent-driven-development/scripts/sdd-state +3 -0
  43. package/optional-skills/deterministic-subagent-driven-development/scripts/sdd-state.mjs +348 -0
  44. package/optional-skills/deterministic-writing-plans/SKILL.md +232 -0
  45. package/optional-skills/deterministic-writing-plans/references/grammar.md +84 -0
  46. package/optional-skills/deterministic-writing-plans/templates/plan-skeleton.md +143 -0
  47. package/package.json +6 -3
@@ -27,6 +27,7 @@ import { SessionNotificationStore, } from "./sessionNotificationStore.js";
27
27
  import { plainTextTheme } from "./plainTextTheme.js";
28
28
  import { SessionUnreadStore } from "./sessionUnreadStore.js";
29
29
  import { createModelTierRegistry, isModelTier, runtimeThinkingLevels } from "./modelTierRegistry.js";
30
+ import { inspectSessionModelPolicy, planSessionModelPolicyUpdate, serializeSessionModelPolicy, SESSION_MODEL_POLICY_CUSTOM_TYPE, } from "./sessionModelPolicy.js";
30
31
  const noopLogger = { info() { } };
31
32
  const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
32
33
  const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
@@ -291,6 +292,43 @@ export class PiSessionService {
291
292
  this.pendingSessionOpens = new Map();
292
293
  this.activities = new Map();
293
294
  this.generationMetrics = new WeakMap();
295
+ this.modelPolicyInspections = new WeakMap();
296
+ this.modelPolicyLadderValidations = new WeakMap();
297
+ /**
298
+ * Reason the newest persisted policy entry is unusable. Recomputed on every
299
+ * inspection refresh, so repairing the entry clears it.
300
+ */
301
+ this.modelPolicyEntryReasons = new WeakMap();
302
+ /**
303
+ * `MODEL_POLICY_BLOCKED` runtime state from an application whose restoration
304
+ * could not be proven. Deliberately *not* cleared by inspection refresh: only
305
+ * an explicit successful policy application clears it, because the runtime
306
+ * tuple — not the persisted entry — is what became ambiguous.
307
+ *
308
+ * Keyed by session *id* rather than by the runtime session object so the block
309
+ * is daemon-owned: it survives a runtime rebind, a reload, and a close/reopen
310
+ * of the same session, none of which prove anything about the ambiguous tuple.
311
+ */
312
+ this.modelPolicyRuntimeBlocks = new Map();
313
+ /**
314
+ * Sessions inside the apply-and-persist window of a policy transition. During
315
+ * that window the runtime tuple is transient (model set, thinking not yet, or
316
+ * persistence not yet confirmed), so no prompt may reach Pi.
317
+ */
318
+ this.modelPolicyMutationCounts = new Map();
319
+ /**
320
+ * Tuple last *confirmed* by the policy runtime adapter, captured when the
321
+ * outermost policy mutation opens and dropped when it closes.
322
+ *
323
+ * A status may be published from inside the transition window (the runtime
324
+ * subscription and the heartbeat both reach `publishStatus`), where the live
325
+ * runtime tuple is mid-apply: the model is set but the thinking level is not,
326
+ * so reading it would report a pair that was never requested and never
327
+ * persisted. `ClientSessionModelPolicyStatus.resolved` promises the last
328
+ * confirmed tuple, so the window reports this instead of live state. Keyed by
329
+ * session id to match the mutation counter it is scoped by.
330
+ */
331
+ this.modelPolicyConfirmedSelections = new Map();
294
332
  /** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
295
333
  this.treeNavigations = new WeakSet();
296
334
  /** Counts async operations that may append an entry before they settle. */
@@ -496,6 +534,9 @@ export class PiSessionService {
496
534
  this.pendingSessionOpens.clear();
497
535
  this.activities.clear();
498
536
  this.compactionPromptQueues.clear();
537
+ this.modelPolicyRuntimeBlocks.clear();
538
+ this.modelPolicyMutationCounts.clear();
539
+ this.modelPolicyConfirmedSelections.clear();
499
540
  this.authLossWarnings.clear();
500
541
  this.subsessionParents.clear();
501
542
  this.subsessionChildren.clear();
@@ -544,14 +585,16 @@ export class PiSessionService {
544
585
  .map((session) => mergeSessionMetadata(session, pinnedPathSet));
545
586
  return [...unarchivedSessions, ...archivedSessions];
546
587
  }
547
- async start(cwd, options = {}) {
548
- return this.startSession(cwd, options);
588
+ async start(cwd, options) {
589
+ const modelPolicy = options?.modelPolicy;
590
+ return this.startSession(cwd, { initializeModelPolicy: modelPolicy ?? true });
549
591
  }
550
592
  async startSession(cwd, options) {
551
593
  const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
552
594
  ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
553
595
  ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
554
596
  ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
597
+ ...(options.initializeModelPolicy === undefined ? {} : { initializeModelPolicy: options.initializeModelPolicy }),
555
598
  });
556
599
  const { session } = active.runtime;
557
600
  const created = {
@@ -583,7 +626,7 @@ export class PiSessionService {
583
626
  const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
584
627
  if (!decision.allowed)
585
628
  throw spawnTargetError(decision);
586
- const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
629
+ const created = await this.startSession(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
587
630
  await this.prompt(created.id, input.prompt);
588
631
  this.logger.info({ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_session started a new session");
589
632
  return { sessionId: created.id, cwd: decision.cwd };
@@ -977,6 +1020,41 @@ export class PiSessionService {
977
1020
  async status(ref) {
978
1021
  return this.statusFromSession(await this.getOrOpen(ref));
979
1022
  }
1023
+ async modelPolicy(ref) {
1024
+ const session = await this.getOrOpen(ref);
1025
+ const inspection = this.inspectAndCacheSessionModelPolicy(session);
1026
+ return {
1027
+ contractVersion: 1,
1028
+ ...(inspection.kind === "invalid" ? {} : { policy: inspection.policy }),
1029
+ session: this.statusFromSession(session),
1030
+ };
1031
+ }
1032
+ /**
1033
+ * The only interface that changes a session's Exact/Tiered policy. Applies one
1034
+ * complete transition atomically and publishes a single confirmed status; a
1035
+ * corrupt newest entry is repaired only by the explicit update supplied here,
1036
+ * never executed as a silent fallback. A transition that fails after the plan
1037
+ * is resolved publishes one error/blocked status before the route error, so
1038
+ * other clients learn the session refused the change.
1039
+ */
1040
+ async setModelPolicy(ref, update) {
1041
+ const action = "change the session model policy";
1042
+ await this.assertWritable(ref);
1043
+ const session = await this.getOrOpen(ref);
1044
+ this.assertModelPolicyMutationIdle(session, action);
1045
+ const inspection = this.inspectAndCacheSessionModelPolicy(session);
1046
+ // A malformed newest entry contributes only a starting exact branch for the
1047
+ // new explicit entry; it is never treated as an executable current policy.
1048
+ const current = inspection.kind === "invalid" ? inspection.fallback : inspection.policy;
1049
+ const policy = await this.applySessionModelPolicy(session, current, update, {
1050
+ action,
1051
+ onTransitionFailure: (reason) => { this.publishModelPolicyTransitionFailure(session, reason); },
1052
+ });
1053
+ const resolved = this.exactSelectionFromSession(session);
1054
+ this.publishActivity(session, `model policy: ${policy.mode}`, "idle", policy.mode === "tiered" ? policy.tier : resolved.model.id);
1055
+ this.publishStatus(session);
1056
+ return this.modelPolicy(ref);
1057
+ }
980
1058
  async systemPrompt(ref) {
981
1059
  const systemPrompt = (await this.getOrOpen(ref)).state.systemPrompt;
982
1060
  return systemPrompt === undefined ? {} : { systemPrompt };
@@ -1011,9 +1089,9 @@ export class PiSessionService {
1011
1089
  async setModel(ref, provider, modelId) {
1012
1090
  await this.assertWritable(ref);
1013
1091
  const session = await this.getOrOpen(ref);
1014
- this.assertTreeNavigationInactive(session, "change models");
1092
+ const currentPolicy = this.assertExactModelPolicyMutationAllowed(session, "change models");
1015
1093
  await session.modelRuntime.refresh({ allowNetwork: false });
1016
- this.assertTreeNavigationInactive(session, "change models");
1094
+ this.assertModelPolicyMutationIdle(session, "change models");
1017
1095
  const candidates = session.scopedModels.length > 0
1018
1096
  ? session.scopedModels.map((scoped) => scoped.model)
1019
1097
  : session.modelRuntime.getAvailableSnapshot();
@@ -1021,7 +1099,7 @@ export class PiSessionService {
1021
1099
  ?? session.modelRuntime.getModel(provider, modelId);
1022
1100
  if (model === undefined)
1023
1101
  throw new Error(`Model not found: ${provider}/${modelId}`);
1024
- await this.runSessionEntryMutation(session, "change models", () => session.setModel(model));
1102
+ await this.runExactModelPolicyMutation(session, "change models", currentPolicy, () => session.setModel(model));
1025
1103
  this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
1026
1104
  this.publishStatus(session);
1027
1105
  return this.statusFromSession(session);
@@ -1029,9 +1107,14 @@ export class PiSessionService {
1029
1107
  async cycleModel(ref, direction) {
1030
1108
  await this.assertWritable(ref);
1031
1109
  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");
1110
+ const currentPolicy = this.assertExactModelPolicyMutationAllowed(session, "change models");
1111
+ const result = await this.runExactModelPolicyMutation(session, "change models", currentPolicy, async () => {
1112
+ const cycled = await session.cycleModel(direction);
1113
+ if (cycled === undefined) {
1114
+ throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
1115
+ }
1116
+ return cycled;
1117
+ });
1035
1118
  this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
1036
1119
  this.publishStatus(session);
1037
1120
  return this.statusFromSession(session);
@@ -1043,14 +1126,17 @@ export class PiSessionService {
1043
1126
  async setThinkingLevel(ref, level) {
1044
1127
  await this.assertWritable(ref);
1045
1128
  const session = await this.getOrOpen(ref);
1046
- this.assertTreeNavigationInactive(session, "change the thinking level");
1129
+ const currentPolicy = this.assertExactModelPolicyMutationAllowed(session, "change the thinking level");
1047
1130
  // pi owns the valid set; validate against the session's live levels rather
1048
1131
  // than a hardcoded union so this stays correct if pi changes the set.
1049
1132
  const available = session.getAvailableThinkingLevels();
1050
1133
  const match = available.find((candidate) => candidate === level);
1051
1134
  if (match === undefined)
1052
1135
  throw new Error(`Invalid thinking level: ${level}`);
1053
- session.setThinkingLevel(match);
1136
+ await this.runExactModelPolicyMutation(session, "change the thinking level", currentPolicy, () => {
1137
+ session.setThinkingLevel(match);
1138
+ return Promise.resolve();
1139
+ });
1054
1140
  this.publishActivity(session, `thinking: ${session.thinkingLevel}`, "idle");
1055
1141
  this.publishStatus(session);
1056
1142
  return this.statusFromSession(session);
@@ -1058,10 +1144,13 @@ export class PiSessionService {
1058
1144
  async cycleThinkingLevel(ref) {
1059
1145
  await this.assertWritable(ref);
1060
1146
  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");
1147
+ const currentPolicy = this.assertExactModelPolicyMutationAllowed(session, "change the thinking level");
1148
+ const level = await this.runExactModelPolicyMutation(session, "change the thinking level", currentPolicy, () => {
1149
+ const cycled = session.cycleThinkingLevel();
1150
+ if (cycled === undefined)
1151
+ throw new Error("Current model does not support thinking");
1152
+ return Promise.resolve(cycled);
1153
+ });
1065
1154
  this.publishActivity(session, `thinking: ${level}`, "idle");
1066
1155
  this.publishStatus(session);
1067
1156
  return this.statusFromSession(session);
@@ -1093,6 +1182,7 @@ export class PiSessionService {
1093
1182
  await this.assertWritable(ref);
1094
1183
  const session = await this.getOrOpen(ref);
1095
1184
  this.assertTreeNavigationInactive(session, "send a prompt");
1185
+ this.assertPromptModelPolicyAllowed(session);
1096
1186
  this.maybeGenerateSessionName(session, promptText);
1097
1187
  const isQueued = session.isStreaming || session.isCompacting;
1098
1188
  const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
@@ -1105,9 +1195,31 @@ export class PiSessionService {
1105
1195
  this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
1106
1196
  return;
1107
1197
  }
1198
+ if (this.isModelPolicyMutationActive(session)) {
1199
+ this.retainPromptDuringModelPolicyMutation(session, promptText, behavior ?? "followUp", images, echoUserMessage);
1200
+ return;
1201
+ }
1108
1202
  void this.submitPrompt(session, promptText, behavior, images, echoUserMessage);
1109
1203
  }
1110
1204
  submitPrompt(session, text, behavior, images = [], echoUserMessage = true) {
1205
+ // A policy transition may have opened while this prompt waited in the
1206
+ // compaction queue. The runtime tuple is transient until the transition
1207
+ // persists, so retain the input instead of reaching Pi with a partial pair.
1208
+ if (this.isModelPolicyMutationActive(session)) {
1209
+ this.retainPromptDuringModelPolicyMutation(session, text, behavior ?? "followUp", images, echoUserMessage, "front");
1210
+ return Promise.resolve();
1211
+ }
1212
+ // Re-check before any activity publication or provider call: a prompt held
1213
+ // in the compaction queue may have waited while the policy entry changed.
1214
+ try {
1215
+ this.assertPromptModelPolicyAllowed(session);
1216
+ }
1217
+ catch (error) {
1218
+ const message = error instanceof Error ? error.message : String(error);
1219
+ this.publishActivity(session, "error", "error", message);
1220
+ this.events.publish(session.sessionId, { type: "session.error", message });
1221
+ return Promise.resolve();
1222
+ }
1111
1223
  this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
1112
1224
  if (behavior === undefined && echoUserMessage)
1113
1225
  this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
@@ -1127,6 +1239,23 @@ export class PiSessionService {
1127
1239
  this.publishActivity(session, "message queued during compaction", "active");
1128
1240
  this.publishStatus(session);
1129
1241
  }
1242
+ /**
1243
+ * Hold prompt input for the duration of a policy transition, reusing the
1244
+ * existing queue so the post-mutation drain submits it once the confirmed
1245
+ * model/thinking pair is persisted. A prompt taken back out of the queue is
1246
+ * re-retained at the front so relative order is preserved.
1247
+ */
1248
+ retainPromptDuringModelPolicyMutation(session, text, kind, images = [], echoUserMessage = true, position = "back") {
1249
+ const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
1250
+ const queued = { kind, text, ...(images.length > 0 ? { images } : {}), ...(echoUserMessage ? {} : { echoUserMessage: false }) };
1251
+ if (position === "front")
1252
+ queue.unshift(queued);
1253
+ else
1254
+ queue.push(queued);
1255
+ this.compactionPromptQueues.set(session.sessionId, queue);
1256
+ this.publishActivity(session, "message queued during model policy change", "active");
1257
+ this.publishStatus(session);
1258
+ }
1130
1259
  async saveAttachments(ref, attachments, folder) {
1131
1260
  const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
1132
1261
  if (parsed.length === 0)
@@ -1969,6 +2098,7 @@ export class PiSessionService {
1969
2098
  if (notificationGeneration !== undefined)
1970
2099
  this.notificationGenerationBySession.set(runtime.session, notificationGeneration);
1971
2100
  try {
2101
+ this.inspectAndCacheSessionModelPolicy(runtime.session);
1972
2102
  if (options.creationProvenance === "tracked-subsession") {
1973
2103
  await this.publishUnreadMutations(this.unreadStore.excludeSession(runtime.session.sessionId, canonicalizeStoredCwd(runtime.session.sessionManager.getCwd())));
1974
2104
  }
@@ -1987,6 +2117,7 @@ export class PiSessionService {
1987
2117
  candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
1988
2118
  this.notificationGenerationBySession.set(session, candidateGeneration);
1989
2119
  }
2120
+ this.inspectAndCacheSessionModelPolicy(session);
1990
2121
  this.bindRuntime(active, session);
1991
2122
  boundSession = session;
1992
2123
  await this.bindSessionExtensions(session, candidateGeneration);
@@ -2005,6 +2136,9 @@ export class PiSessionService {
2005
2136
  }
2006
2137
  });
2007
2138
  this.active.set(runtime.session.sessionId, active);
2139
+ if (options.initializeModelPolicy !== undefined) {
2140
+ await this.initializeSessionModelPolicy(runtime.session, options.initializeModelPolicy);
2141
+ }
2008
2142
  if (notificationOwnership === "replacement" && notificationGeneration !== undefined) {
2009
2143
  this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
2010
2144
  notificationOwnership = "external";
@@ -2295,7 +2429,7 @@ export class PiSessionService {
2295
2429
  if (active === undefined)
2296
2430
  return;
2297
2431
  const { session } = active.runtime;
2298
- if (session.isCompacting) {
2432
+ if (session.isCompacting || this.isModelPolicyMutationActive(session)) {
2299
2433
  this.scheduleCompactionQueueDrain(sessionId, 100);
2300
2434
  return;
2301
2435
  }
@@ -2637,6 +2771,334 @@ export class PiSessionService {
2637
2771
  this.events.publish(session.sessionId, { type: "activity.update", activity });
2638
2772
  this.events.publishGlobal({ type: "activity.update", activity });
2639
2773
  }
2774
+ exactSelectionFromSession(session) {
2775
+ const model = session.model;
2776
+ if (model === undefined
2777
+ || typeof model.provider !== "string"
2778
+ || model.provider.trim() === ""
2779
+ || typeof model.id !== "string"
2780
+ || model.id.trim() === "") {
2781
+ throw new Error("Session model policy requires a resolved runtime model provider and id");
2782
+ }
2783
+ if (typeof session.thinkingLevel !== "string" || session.thinkingLevel.trim() === "") {
2784
+ throw new Error("Session model policy requires a resolved runtime thinking level");
2785
+ }
2786
+ return {
2787
+ model: { provider: model.provider, id: model.id },
2788
+ thinkingLevel: session.thinkingLevel,
2789
+ };
2790
+ }
2791
+ policyEntries(session) {
2792
+ return session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch();
2793
+ }
2794
+ inspectAndCacheSessionModelPolicy(session) {
2795
+ const inspection = inspectSessionModelPolicy(this.policyEntries(session), this.exactSelectionFromSession(session));
2796
+ const ladderValidation = this.modelTierRegistry.validate();
2797
+ this.modelPolicyInspections.set(session, inspection);
2798
+ this.modelPolicyLadderValidations.set(session, ladderValidation);
2799
+ if (inspection.kind === "invalid")
2800
+ this.modelPolicyEntryReasons.set(session, inspection.reason);
2801
+ else
2802
+ this.modelPolicyEntryReasons.delete(session);
2803
+ return inspection;
2804
+ }
2805
+ /** Effective block: an unproven runtime restoration outranks an entry defect. */
2806
+ modelPolicyBlockedReason(session) {
2807
+ return this.modelPolicyRuntimeBlocks.get(session.sessionId) ?? this.modelPolicyEntryReasons.get(session);
2808
+ }
2809
+ /**
2810
+ * Root-session policy initialization. `true` records the runtime's own tuple;
2811
+ * an update applies the requested transition. Both complete before the caller
2812
+ * of `start()` sees the session, so no prompt can precede the policy.
2813
+ */
2814
+ async initializeSessionModelPolicy(session, initializer) {
2815
+ const current = { mode: "exact", exact: this.exactSelectionFromSession(session) };
2816
+ if (initializer === true) {
2817
+ this.appendSessionModelPolicy(session, current);
2818
+ this.inspectAndCacheSessionModelPolicy(session);
2819
+ return;
2820
+ }
2821
+ // Root creation reports failure through its own abort/dispose cleanup rather
2822
+ // than publishing a failed transition for a session no client has seen.
2823
+ await this.applySessionModelPolicy(session, current, initializer, { action: "initialize the session model policy" });
2824
+ }
2825
+ /**
2826
+ * The single policy transition path. Validates the complete target before any
2827
+ * setter, re-checks that no conflicting work started during that async
2828
+ * validation, applies model then thinking under the serialized entry-mutation
2829
+ * seam, verifies the effective pair, and only then persists and caches.
2830
+ */
2831
+ async applySessionModelPolicy(session, current, update, options) {
2832
+ const plan = planSessionModelPolicyUpdate(current, update, (tier) => {
2833
+ const resolved = this.modelTierRegistry.resolve(tier);
2834
+ return { model: { provider: resolved.model.provider, id: resolved.model.id }, thinkingLevel: resolved.thinkingLevel };
2835
+ });
2836
+ // Validation happens before the mutation opens, so a rejected target leaves
2837
+ // the runtime and the persisted policy untouched.
2838
+ const target = await this.resolveAvailableExactSelection(session, plan.target);
2839
+ // Refresh/validation awaited above, so a prompt or another mutation may have
2840
+ // started meanwhile. Re-check here — synchronously adjacent to the mutation
2841
+ // open, and *outside* it so the mutation is never its own conflict — to close
2842
+ // the await-to-setter race without touching tree-navigation behavior.
2843
+ this.assertModelPolicyMutationIdle(session, options.action);
2844
+ return this.runSessionModelPolicyMutation(session, "change session model policy", async () => {
2845
+ const previous = this.exactSelectionFromSession(session);
2846
+ try {
2847
+ await this.applyExactSelection(session, target);
2848
+ this.appendSessionModelPolicy(session, plan.policy);
2849
+ }
2850
+ catch (error) {
2851
+ const reason = error instanceof Error ? error.message : String(error);
2852
+ if (!await this.restoreExactSelection(session, previous)) {
2853
+ this.modelPolicyRuntimeBlocks.set(session.sessionId, `MODEL_POLICY_BLOCKED: ${reason}; the previous model and thinking pair could not be restored`);
2854
+ }
2855
+ this.inspectAndCacheSessionModelPolicy(session);
2856
+ options.onTransitionFailure?.(reason);
2857
+ throw error;
2858
+ }
2859
+ this.modelPolicyRuntimeBlocks.delete(session.sessionId);
2860
+ this.inspectAndCacheSessionModelPolicy(session);
2861
+ return plan.policy;
2862
+ });
2863
+ }
2864
+ /**
2865
+ * Serialized policy-transition seam. Adds the policy-mutation marker on top of
2866
+ * the shared entry-mutation lifecycle so prompt submission can tell a transient
2867
+ * policy window apart from ordinary session work, and drains any prompt input
2868
+ * retained during that window once the tuple is settled again.
2869
+ */
2870
+ async runSessionModelPolicyMutation(session, action, operation) {
2871
+ // Captured before the first setter runs, so this is the confirmed tuple the
2872
+ // window reports. Only the outermost mutation records it: a nested mutation
2873
+ // opens on an already-transient tuple, which would not be confirmed.
2874
+ if (!this.isModelPolicyMutationActive(session))
2875
+ this.recordConfirmedExactSelection(session);
2876
+ this.modelPolicyMutationCounts.set(session.sessionId, (this.modelPolicyMutationCounts.get(session.sessionId) ?? 0) + 1);
2877
+ try {
2878
+ return await this.runSessionEntryMutation(session, action, operation);
2879
+ }
2880
+ finally {
2881
+ decrementMapCount(this.modelPolicyMutationCounts, session.sessionId);
2882
+ if (!this.isModelPolicyMutationActive(session)) {
2883
+ // The tuple is settled again (applied, restored, or blocked), so live
2884
+ // runtime state is authoritative once more.
2885
+ this.modelPolicyConfirmedSelections.delete(session.sessionId);
2886
+ this.scheduleCompactionQueueDrain(session.sessionId);
2887
+ }
2888
+ }
2889
+ }
2890
+ /**
2891
+ * Remember the pre-transition tuple for the status projection. A session whose
2892
+ * runtime has no resolved model has no confirmed tuple to report; that case is
2893
+ * left to the existing `exactSelectionFromSession` failure rather than
2894
+ * substituting anything.
2895
+ */
2896
+ recordConfirmedExactSelection(session) {
2897
+ try {
2898
+ this.modelPolicyConfirmedSelections.set(session.sessionId, this.exactSelectionFromSession(session));
2899
+ }
2900
+ catch {
2901
+ this.modelPolicyConfirmedSelections.delete(session.sessionId);
2902
+ }
2903
+ }
2904
+ isModelPolicyMutationActive(session) {
2905
+ return (this.modelPolicyMutationCounts.get(session.sessionId) ?? 0) > 0;
2906
+ }
2907
+ /**
2908
+ * Tell other clients a policy transition failed. Called after the failure state
2909
+ * (including any `MODEL_POLICY_BLOCKED`) is recorded and before the route error
2910
+ * propagates, so the published status already carries the blocked reason.
2911
+ */
2912
+ publishModelPolicyTransitionFailure(session, reason) {
2913
+ this.publishActivity(session, "model policy change failed", "error", reason);
2914
+ this.publishStatus(session);
2915
+ }
2916
+ /**
2917
+ * Reject a policy mutation that could race Pi's own entry writes. Tree
2918
+ * navigation is reported first because it is the more specific state.
2919
+ */
2920
+ assertModelPolicyMutationIdle(session, action) {
2921
+ this.assertTreeNavigationInactive(session, action);
2922
+ if (this.hasActiveWork(session))
2923
+ throw new Error(`Stop current session activity before you ${action}`);
2924
+ }
2925
+ /**
2926
+ * Gate for the direct Exact model/thinking routes. They act only in Exact
2927
+ * mode: while Tiered is active the ladder owns the runtime tuple, and while
2928
+ * the runtime is blocked no tuple change may be recorded as authoritative.
2929
+ */
2930
+ assertExactModelPolicyMutationAllowed(session, action) {
2931
+ this.assertModelPolicyMutationIdle(session, action);
2932
+ const inspection = this.inspectAndCacheSessionModelPolicy(session);
2933
+ const runtimeBlock = this.modelPolicyRuntimeBlocks.get(session.sessionId);
2934
+ if (runtimeBlock !== undefined)
2935
+ throw new Error(`Cannot ${action}: ${runtimeBlock}`);
2936
+ if (inspection.kind === "invalid") {
2937
+ throw new Error(`Cannot ${action}: the session model policy entry is invalid (${inspection.reason}). Repair the policy first.`);
2938
+ }
2939
+ if (inspection.policy.mode === "tiered") {
2940
+ throw new Error(`Cannot ${action} directly while the session model policy is Tiered. Change the model policy instead.`);
2941
+ }
2942
+ return inspection.policy;
2943
+ }
2944
+ /**
2945
+ * Run one Exact route operation and record Pi's *confirmed* resulting pair as
2946
+ * the new authoritative policy inside the same serialized mutation. Pi's own
2947
+ * model-selection clamp is therefore persisted as-is rather than replaced by
2948
+ * an invented target thinking level.
2949
+ */
2950
+ async runExactModelPolicyMutation(session, action, currentPolicy, operation) {
2951
+ let transitionFailure;
2952
+ try {
2953
+ return await this.runSessionModelPolicyMutation(session, action, async () => {
2954
+ const previous = this.exactSelectionFromSession(session);
2955
+ const result = await operation();
2956
+ const policy = {
2957
+ mode: "exact",
2958
+ exact: this.exactSelectionFromSession(session),
2959
+ ...(currentPolicy.tier === undefined ? {} : { tier: currentPolicy.tier }),
2960
+ };
2961
+ try {
2962
+ this.appendSessionModelPolicy(session, policy);
2963
+ }
2964
+ catch (error) {
2965
+ const reason = error instanceof Error ? error.message : String(error);
2966
+ if (!await this.restoreExactSelection(session, previous)) {
2967
+ this.modelPolicyRuntimeBlocks.set(session.sessionId, `MODEL_POLICY_BLOCKED: ${reason}; the previous model and thinking pair could not be restored`);
2968
+ }
2969
+ this.inspectAndCacheSessionModelPolicy(session);
2970
+ // Only a failure that already moved the runtime tuple is announced; an
2971
+ // operation precondition ("Only one model available") keeps its plain
2972
+ // route error and publishes nothing.
2973
+ transitionFailure = reason;
2974
+ throw error;
2975
+ }
2976
+ this.inspectAndCacheSessionModelPolicy(session);
2977
+ return result;
2978
+ });
2979
+ }
2980
+ catch (error) {
2981
+ // Published after the mutation closed so the status reports the settled
2982
+ // (blocked or restored) state rather than "updating session".
2983
+ if (transitionFailure !== undefined)
2984
+ this.publishModelPolicyTransitionFailure(session, transitionFailure);
2985
+ throw error;
2986
+ }
2987
+ }
2988
+ /**
2989
+ * Refuse to reach a provider while the authoritative policy is unusable. This
2990
+ * covers a malformed newest entry and an unproven restoration; it deliberately
2991
+ * does no tier remapping.
2992
+ */
2993
+ assertPromptModelPolicyAllowed(session) {
2994
+ const inspection = this.inspectAndCacheSessionModelPolicy(session);
2995
+ const runtimeBlock = this.modelPolicyRuntimeBlocks.get(session.sessionId);
2996
+ if (runtimeBlock !== undefined)
2997
+ throw new Error(`Cannot send a prompt: ${runtimeBlock}`);
2998
+ if (inspection.kind === "invalid") {
2999
+ throw new Error(`Cannot send a prompt: the session model policy entry is invalid (${inspection.reason}). Repair the policy first.`);
3000
+ }
3001
+ }
3002
+ /**
3003
+ * Bind an exact selection to a currently available runtime model and prove the
3004
+ * target thinking level is supported by that *incoming* model. Pi's
3005
+ * `setThinkingLevel` clamps silently, so this check must precede every setter.
3006
+ */
3007
+ async resolveAvailableExactSelection(session, selection) {
3008
+ await session.modelRuntime.refresh({ allowNetwork: false });
3009
+ const candidates = session.scopedModels.length > 0
3010
+ ? session.scopedModels.map((scoped) => scoped.model)
3011
+ : session.modelRuntime.getAvailableSnapshot();
3012
+ const model = candidates.find((candidate) => candidate.provider === selection.model.provider && candidate.id === selection.model.id);
3013
+ const described = `${selection.model.provider}/${selection.model.id}`;
3014
+ if (model === undefined)
3015
+ throw new Error(`Model not found: ${described}`);
3016
+ if (!isKnownThinkingLevel(selection.thinkingLevel)) {
3017
+ throw new Error(`Unknown thinking level ${selection.thinkingLevel} for ${described}`);
3018
+ }
3019
+ if (!runtimeThinkingLevels(model).includes(selection.thinkingLevel)) {
3020
+ throw new Error(`Thinking level ${selection.thinkingLevel} is unsupported by ${described}`);
3021
+ }
3022
+ return { model, selection: { model: { provider: model.provider, id: model.id }, thinkingLevel: selection.thinkingLevel } };
3023
+ }
3024
+ /**
3025
+ * Model first, then thinking: `setThinkingLevel` clamps against the currently
3026
+ * selected model, so the reverse order can discard a level the incoming model
3027
+ * supports. The effective pair is verified afterwards; a clamped substitute is
3028
+ * a failed transition, never a success.
3029
+ */
3030
+ async applyExactSelection(session, target) {
3031
+ const level = target.selection.thinkingLevel;
3032
+ if (!isKnownThinkingLevel(level))
3033
+ throw new Error(`Unknown thinking level ${level}`);
3034
+ await session.setModel(target.model);
3035
+ session.setThinkingLevel(level);
3036
+ const effective = this.exactSelectionFromSession(session);
3037
+ if (effective.model.provider !== target.selection.model.provider
3038
+ || effective.model.id !== target.selection.model.id
3039
+ || effective.thinkingLevel !== level) {
3040
+ throw new Error(`Session model policy could not activate ${target.selection.model.provider}/${target.selection.model.id} at thinking level ${level}`
3041
+ + `; the runtime reports ${effective.model.provider}/${effective.model.id} at thinking level ${effective.thinkingLevel}`);
3042
+ }
3043
+ }
3044
+ /**
3045
+ * Put the previous exact pair back in the same model-then-thinking order.
3046
+ * Returns whether the previous pair is *proven* active again; a false result
3047
+ * means the runtime tuple is ambiguous and the session must refuse prompts.
3048
+ */
3049
+ async restoreExactSelection(session, previous) {
3050
+ try {
3051
+ const current = this.exactSelectionFromSession(session);
3052
+ if (current.model.provider === previous.model.provider
3053
+ && current.model.id === previous.model.id
3054
+ && current.thinkingLevel === previous.thinkingLevel) {
3055
+ return true;
3056
+ }
3057
+ const target = await this.resolveAvailableExactSelection(session, previous);
3058
+ await this.applyExactSelection(session, target);
3059
+ return true;
3060
+ }
3061
+ catch {
3062
+ return false;
3063
+ }
3064
+ }
3065
+ appendSessionModelPolicy(session, policy) {
3066
+ if (session.sessionManager.appendCustomEntry === undefined) {
3067
+ throw new Error("Cannot persist the session model policy: session persistence is unavailable");
3068
+ }
3069
+ session.sessionManager.appendCustomEntry(SESSION_MODEL_POLICY_CUSTOM_TYPE, serializeSessionModelPolicy(policy));
3070
+ }
3071
+ modelPolicyStatusFromSession(session) {
3072
+ const inspection = this.modelPolicyInspections.get(session);
3073
+ if (inspection === undefined)
3074
+ throw new Error("Session model policy inspection is unavailable");
3075
+ const ladderValidation = this.modelPolicyLadderValidations.get(session);
3076
+ if (ladderValidation === undefined)
3077
+ throw new Error("Session model tier validation is unavailable");
3078
+ // Inside a transition the live tuple is half-applied, so report the tuple
3079
+ // this session last confirmed. `mode` already comes from the unchanged
3080
+ // entry, keeping the published pair internally consistent.
3081
+ const confirmed = this.isModelPolicyMutationActive(session)
3082
+ ? this.modelPolicyConfirmedSelections.get(session.sessionId)
3083
+ : undefined;
3084
+ const resolved = confirmed ?? this.exactSelectionFromSession(session);
3085
+ const blockedReason = this.modelPolicyBlockedReason(session);
3086
+ if (inspection.kind === "invalid") {
3087
+ return {
3088
+ mode: "exact",
3089
+ resolved,
3090
+ ladderValid: ladderValidation.valid,
3091
+ ...(blockedReason === undefined ? {} : { blockedReason }),
3092
+ };
3093
+ }
3094
+ return {
3095
+ mode: inspection.policy.mode,
3096
+ ...(inspection.policy.tier === undefined ? {} : { tier: inspection.policy.tier }),
3097
+ resolved,
3098
+ ladderValid: ladderValidation.valid,
3099
+ ...(blockedReason === undefined ? {} : { blockedReason }),
3100
+ };
3101
+ }
2640
3102
  statusFromSession(session) {
2641
3103
  const stats = session.getSessionStats();
2642
3104
  const model = session.model === undefined ? undefined : modelToClientModel(session.model);
@@ -2648,6 +3110,7 @@ export class PiSessionService {
2648
3110
  persisted: sessionFileExists(session.sessionFile),
2649
3111
  ...(model === undefined ? {} : { model }),
2650
3112
  thinkingLevel: session.thinkingLevel,
3113
+ modelPolicy: this.modelPolicyStatusFromSession(session),
2651
3114
  isStreaming: session.isStreaming,
2652
3115
  isCompacting: session.isCompacting,
2653
3116
  isBashRunning: session.isBashRunning,