@hyperdreamer/pi-webui 1.11.0-beta.8 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +1 -1
  2. package/dist/client/assets/{CodeViewer-DdYnGA-X.js → CodeViewer-Jy1WSFR_.js} +1 -1
  3. package/dist/client/assets/{UnifiedDiffViewer-BSMTtVUW.js → UnifiedDiffViewer-iic0AurS.js} +1 -1
  4. package/dist/client/assets/{index-B5Kn_roi.js → index-6NlIF1nJ.js} +593 -451
  5. package/dist/client/index.html +1 -1
  6. package/dist/config.js +70 -11
  7. package/dist/config.js.map +1 -1
  8. package/dist/server/app.js +6 -1
  9. package/dist/server/app.js.map +1 -1
  10. package/dist/server/sessiond/sessionProxyRoutes.js +1 -0
  11. package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
  12. package/dist/server/sessiond.js +38 -5
  13. package/dist/server/sessiond.js.map +1 -1
  14. package/dist/server/sessions/modelPolicySettingsPersistence.js +82 -0
  15. package/dist/server/sessions/modelPolicySettingsPersistence.js.map +1 -0
  16. package/dist/server/sessions/piSessionManagerGateway.js +227 -9
  17. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  18. package/dist/server/sessions/piSessionService.js +678 -242
  19. package/dist/server/sessions/piSessionService.js.map +1 -1
  20. package/dist/server/sessions/rememberCurrentModelPolicy.js +62 -0
  21. package/dist/server/sessions/rememberCurrentModelPolicy.js.map +1 -0
  22. package/dist/server/sessions/sessionArchiveStore.js +6 -0
  23. package/dist/server/sessions/sessionArchiveStore.js.map +1 -1
  24. package/dist/server/sessions/sessionCreationSource.js +101 -0
  25. package/dist/server/sessions/sessionCreationSource.js.map +1 -0
  26. package/dist/server/sessions/sessionDefaultsRoutes.js +7 -1
  27. package/dist/server/sessions/sessionDefaultsRoutes.js.map +1 -1
  28. package/dist/server/sessions/sessionDefaultsService.js +85 -3
  29. package/dist/server/sessions/sessionDefaultsService.js.map +1 -1
  30. package/dist/server/sessions/sessionMetadataStore.js +107 -13
  31. package/dist/server/sessions/sessionMetadataStore.js.map +1 -1
  32. package/dist/server/sessions/sessionModelPolicy.js +23 -0
  33. package/dist/server/sessions/sessionModelPolicy.js.map +1 -1
  34. package/dist/server/sessions/sessionNameGenerator.js +5 -4
  35. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  36. package/dist/server/sessions/sessionReorder.js +117 -0
  37. package/dist/server/sessions/sessionReorder.js.map +1 -0
  38. package/dist/server/sessions/sessionRouteFastifyOptions.js +8 -0
  39. package/dist/server/sessions/sessionRouteFastifyOptions.js.map +1 -0
  40. package/dist/server/sessions/sessionRoutes.js +187 -4
  41. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  42. package/dist/server/sessions/starterModelPolicyPreferenceStore.js +142 -29
  43. package/dist/server/sessions/starterModelPolicyPreferenceStore.js.map +1 -1
  44. package/dist/server/sessions/utilityModelExtension.js +197 -0
  45. package/dist/server/sessions/utilityModelExtension.js.map +1 -0
  46. package/dist/server/sessions/utilityModelResolver.js +93 -0
  47. package/dist/server/sessions/utilityModelResolver.js.map +1 -0
  48. package/dist/server/sessions/utilityModelSettingsRoutes.js +62 -0
  49. package/dist/server/sessions/utilityModelSettingsRoutes.js.map +1 -0
  50. package/dist/server/sessions/utilityModelSettingsService.js +114 -0
  51. package/dist/server/sessions/utilityModelSettingsService.js.map +1 -0
  52. package/dist/shared/apiTypes.d.ts +105 -2
  53. package/dist/shared/apiTypes.js +8 -0
  54. package/dist/shared/apiTypes.js.map +1 -1
  55. package/dist/shared/capabilities.js +9 -0
  56. package/dist/shared/capabilities.js.map +1 -1
  57. package/dist/shared/federatedRoutes.js +4 -0
  58. package/dist/shared/federatedRoutes.js.map +1 -1
  59. package/docs/assets/pi-webui-desktop.png +0 -0
  60. package/docs/config.md +37 -8
  61. package/package.json +1 -1
@@ -10,7 +10,7 @@ import { BUILTIN_COMMANDS } from "./builtinCommands.js";
10
10
  import { SessionCommandService, clientSessionFromRuntime, } from "./sessionCommandService.js";
11
11
  import { projectSessionTree, } from "./sessionTreeProjection.js";
12
12
  import { SessionArchiveStore, } from "./sessionArchiveStore.js";
13
- import { SessionMetadataStore } from "./sessionMetadataStore.js";
13
+ import { SessionMetadataStore, SessionMetadataOrderConflictError } from "./sessionMetadataStore.js";
14
14
  import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree, } from "./sessionArchiveTree.js";
15
15
  import { deterministicSessionName, fallbackSessionName, generateShortSessionName, } from "./sessionNameGenerator.js";
16
16
  import { computeEditPreview } from "./editPreview.js";
@@ -18,6 +18,7 @@ import { attachmentsToInlineImages, saveAttachmentsToWorkspace, } from "./attach
18
18
  import { parsePromptAttachments } from "../../shared/promptAttachments.js";
19
19
  import { isKnownThinkingLevel } from "../../shared/thinkingLevels.js";
20
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
23
  import { createSpawnSessionToolDefinition, } from "./spawnSessionTool.js";
23
24
  import { createSubsessionToolDefinitions, } from "./spawnSubsessionTool.js";
@@ -29,7 +30,13 @@ import { SessionNotificationStore, } from "./sessionNotificationStore.js";
29
30
  import { plainTextTheme } from "./plainTextTheme.js";
30
31
  import { SessionUnreadStore, } from "./sessionUnreadStore.js";
31
32
  import { createModelTierRegistry, isModelTier, runtimeThinkingLevels, } from "./modelTierRegistry.js";
32
- import { inspectSessionModelPolicy, planSessionModelPolicyUpdate, serializeSessionModelPolicy, SESSION_MODEL_POLICY_CUSTOM_TYPE, } from "./sessionModelPolicy.js";
33
+ import { createUtilityModelExtension, } from "./utilityModelExtension.js";
34
+ import { createUtilityModelResolver, runWithUtilityModelFallback, } from "./utilityModelResolver.js";
35
+ import { inspectSessionModelPolicy, planSessionModelPolicyInitialization, planSessionModelPolicyUpdate, serializeSessionModelPolicy, SESSION_MODEL_POLICY_CUSTOM_TYPE, } from "./sessionModelPolicy.js";
36
+ import { inspectSessionCreationRootEligibility, inspectSessionCreationSource, serializeSessionCreationSource, SESSION_CREATION_SOURCE_CUSTOM_TYPE, } from "./sessionCreationSource.js";
37
+ import { RememberCurrentModelPolicyCommand, } from "./rememberCurrentModelPolicy.js";
38
+ import { StarterModelPolicyPreferenceStore } from "./starterModelPolicyPreferenceStore.js";
39
+ import { captureModelPolicySettings, modelPolicySettingsPersistence, restoreModelPolicySettings, settleModelPolicySettings, } from "./modelPolicySettingsPersistence.js";
33
40
  const noopLogger = {
34
41
  info() {
35
42
  /* no-op */
@@ -43,6 +50,33 @@ const MIN_GENERATION_RATE_ELAPSED_MS = 500;
43
50
  function noop() {
44
51
  // Intentionally empty default unsubscribe callback.
45
52
  }
53
+ function normalizeError(error) {
54
+ return error instanceof Error ? error : new Error(String(error));
55
+ }
56
+ class SessionInitializationCleanupAggregateError extends AggregateError {
57
+ constructor(initializationError, cleanupFailures, completeInitialization) {
58
+ super([initializationError, ...cleanupFailures], completeInitialization
59
+ ? `Complete session initialization failed and rollback was incomplete: ${initializationError.message}`
60
+ : `Session initialization failed and cleanup was incomplete: ${initializationError.message}`);
61
+ this.initializationError = initializationError;
62
+ this.cleanupFailures = [...cleanupFailures];
63
+ this.completeInitialization = completeInitialization;
64
+ }
65
+ }
66
+ function sessionInitializationCleanupFailure(error, cleanupFailures, completeInitialization = false) {
67
+ if (error instanceof SessionInitializationCleanupAggregateError) {
68
+ if (cleanupFailures.length === 0)
69
+ return error;
70
+ return new SessionInitializationCleanupAggregateError(error.initializationError, [...error.cleanupFailures, ...cleanupFailures], error.completeInitialization || completeInitialization);
71
+ }
72
+ const initializationError = normalizeError(error);
73
+ if (cleanupFailures.length === 0)
74
+ return initializationError;
75
+ return new SessionInitializationCleanupAggregateError(initializationError, cleanupFailures, completeInitialization);
76
+ }
77
+ function completeInitializationFailure(error, rollbackFailures) {
78
+ return sessionInitializationCleanupFailure(error, rollbackFailures, true);
79
+ }
46
80
  function spawnTargetError(decision) {
47
81
  if (decision.reason === "not-registered")
48
82
  return new Error("Spawning session is not in a registered project");
@@ -284,17 +318,34 @@ export function createPiWebUiCustomToolDefinitions(cwd, delegationEnabled, spawn
284
318
  : []),
285
319
  ];
286
320
  }
287
- function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, modelPolicy) {
321
+ export function createDefaultRuntimeFactory(modelRuntime, sessionManagers, utilityModelResolver, logger, spawn, subsessions, modelPolicy, sdk = {
322
+ createServices: createAgentSessionServices,
323
+ createFromServices: createAgentSessionFromServices,
324
+ }) {
288
325
  return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled, }) => {
289
- const services = await createAgentSessionServices({
326
+ const runtimeRefs = {};
327
+ const services = await sdk.createServices({
290
328
  cwd,
291
329
  agentDir,
292
330
  modelRuntime,
331
+ resourceLoaderOptions: {
332
+ extensionFactories: [
333
+ createUtilityModelExtension({
334
+ // Pi's ExtensionContext exposes Model<any>; production candidates still come from ModelRuntime's Model<Api> snapshot.
335
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
336
+ resolver: utilityModelResolver,
337
+ modelRuntime,
338
+ refs: runtimeRefs,
339
+ logger,
340
+ }),
341
+ ],
342
+ },
293
343
  });
344
+ runtimeRefs.settingsManager = services.settingsManager;
294
345
  const resolvedDelegationToolsEnabled = delegationToolsEnabled ??
295
346
  (await sessionAllowsDelegationTools(sessionManager, sessionManagers));
296
347
  const customTools = createPiWebUiCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, modelPolicy);
297
- const result = await createAgentSessionFromServices({
348
+ const result = await sdk.createFromServices({
298
349
  services,
299
350
  sessionManager,
300
351
  customTools,
@@ -304,6 +355,7 @@ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subse
304
355
  ? {}
305
356
  : { thinkingLevel: initialThinkingLevel }),
306
357
  });
358
+ runtimeRefs.streamFunction = result.session.agent.streamFunction;
307
359
  return { ...result, services, diagnostics: services.diagnostics };
308
360
  };
309
361
  }
@@ -415,13 +467,40 @@ export class PiSessionService {
415
467
  this.pendingUnreadMutations = [];
416
468
  this.unreadPublicationFlushRequested = false;
417
469
  this.unreadPublicationStopped = false;
470
+ /**
471
+ * Serializes reorder against every other mutation that can change sibling
472
+ * membership, pin state, or current/archive membership (pin, unpin,
473
+ * detach-parent, archive, archiveMany, archiveTree, restore, and cleanup),
474
+ * so no such mutation can interleave with a reorder's before/after catalog
475
+ * reads and its atomic metadata write.
476
+ */
477
+ this.sessionOrderMutationQueue = Promise.resolve();
418
478
  this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
419
479
  this.metadataStore = deps.metadataStore ?? new SessionMetadataStore();
480
+ this.clearParentSessionFile = deps.clearParentSession ?? clearParentSession;
420
481
  this.agentDir = deps.agentDir;
421
482
  this.sessionManager = deps.sessionManager;
422
483
  this.modelRuntime = deps.modelRuntime;
423
484
  this.spawnTargets = deps.spawnTargets;
424
485
  this.logger = deps.logger ?? noopLogger;
486
+ this.utilityModelResolver =
487
+ deps.utilityModelResolver ??
488
+ createUtilityModelResolver({
489
+ loadConfig: () => {
490
+ const loaded = loadPiWebUiConfig();
491
+ return {
492
+ ...(loaded.config.utilityModels === undefined
493
+ ? {}
494
+ : { utilityModels: loaded.config.utilityModels }),
495
+ ...(loaded.utilityModelsError === undefined
496
+ ? {}
497
+ : { utilityModelsError: loaded.utilityModelsError }),
498
+ };
499
+ },
500
+ modelRuntime: this.modelRuntime,
501
+ thinkingLevelsForModel: runtimeThinkingLevels,
502
+ logger: this.logger,
503
+ });
425
504
  this.now = deps.now ?? (() => new Date());
426
505
  this.notificationStore =
427
506
  deps.notificationStore ?? new SessionNotificationStore();
@@ -433,7 +512,7 @@ export class PiSessionService {
433
512
  const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
434
513
  this.createRuntime =
435
514
  deps.createRuntime ??
436
- createDefaultRuntimeFactory(this.modelRuntime, this.sessionManager, this.spawnTargets === undefined
515
+ createDefaultRuntimeFactory(this.modelRuntime, this.sessionManager, this.utilityModelResolver, this.logger, this.spawnTargets === undefined
437
516
  ? undefined
438
517
  : (input) => this.spawnSession(input), !subsessionsActive
439
518
  ? undefined
@@ -467,6 +546,11 @@ export class PiSessionService {
467
546
  supportedThinkingLevels: runtimeThinkingLevels,
468
547
  });
469
548
  this.workspaceActivity = deps.workspaceActivity;
549
+ this.rememberCurrentModelPolicyCommand = new RememberCurrentModelPolicyCommand({
550
+ loadSnapshot: (session) => this.confirmedPolicySnapshot(session),
551
+ preferenceStore: deps.starterModelPolicyPreferenceStore ??
552
+ new StarterModelPolicyPreferenceStore(),
553
+ });
470
554
  this.heartbeat = setInterval(() => {
471
555
  this.publishHeartbeats();
472
556
  }, deps.heartbeatIntervalMs ?? 2000);
@@ -530,51 +614,53 @@ export class PiSessionService {
530
614
  return previewResponseFromPlan(await this.cleanupPlan(request));
531
615
  }
532
616
  async cleanup(request) {
533
- const plan = await this.cleanupPlan(request);
534
- if (plan.deleteRecords.length > 0 &&
535
- this.archiveStore.deleteArchived === undefined &&
536
- this.archiveStore.deleteArchivedMany === undefined)
537
- throw new Error("Archive store does not support deletion");
538
- const archiveInputs = [];
539
- const readyArchiveInputs = [];
540
- const deleteRecords = [];
541
- const readyDeleteRecords = [];
542
- const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
543
- for (const input of plan.archiveInputs) {
544
- if (this.activeSessionHasWork(input.sessionId)) {
545
- skippedBusySessionIds.add(input.sessionId);
546
- continue;
617
+ return await this.runSessionOrderMutation(async () => {
618
+ const plan = await this.cleanupPlan(request);
619
+ if (plan.deleteRecords.length > 0 &&
620
+ this.archiveStore.deleteArchived === undefined &&
621
+ this.archiveStore.deleteArchivedMany === undefined)
622
+ throw new Error("Archive store does not support deletion");
623
+ const archiveInputs = [];
624
+ const readyArchiveInputs = [];
625
+ const deleteRecords = [];
626
+ const readyDeleteRecords = [];
627
+ const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
628
+ for (const input of plan.archiveInputs) {
629
+ if (this.activeSessionHasWork(input.sessionId)) {
630
+ skippedBusySessionIds.add(input.sessionId);
631
+ continue;
632
+ }
633
+ await this.closeActive(input.sessionId, {
634
+ kind: "clear",
635
+ reason: "archive",
636
+ });
637
+ readyArchiveInputs.push(input);
547
638
  }
548
- await this.closeActive(input.sessionId, {
549
- kind: "clear",
550
- reason: "archive",
551
- });
552
- readyArchiveInputs.push(input);
553
- }
554
- await this.archiveStoreArchiveMany(readyArchiveInputs);
555
- archiveInputs.push(...readyArchiveInputs);
556
- await this.forgetUnreadSessions(readyArchiveInputs);
557
- for (const record of plan.deleteRecords) {
558
- if (this.activeSessionHasWork(record.sessionId)) {
559
- skippedBusySessionIds.add(record.sessionId);
560
- continue;
639
+ await this.archiveStoreArchiveMany(readyArchiveInputs);
640
+ archiveInputs.push(...readyArchiveInputs);
641
+ await this.forgetUnreadSessions(readyArchiveInputs);
642
+ for (const record of plan.deleteRecords) {
643
+ if (this.activeSessionHasWork(record.sessionId)) {
644
+ skippedBusySessionIds.add(record.sessionId);
645
+ continue;
646
+ }
647
+ await this.closeActive(record.sessionId, {
648
+ kind: "clear",
649
+ reason: "delete",
650
+ });
651
+ readyDeleteRecords.push(record);
561
652
  }
562
- await this.closeActive(record.sessionId, {
563
- kind: "clear",
564
- reason: "delete",
653
+ await this.ensureArchivedRecordsMoved(readyDeleteRecords);
654
+ const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
655
+ deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
656
+ await this.forgetUnreadSessions(deleteRecords);
657
+ return summarizeSessionCleanupExecution({
658
+ archiveInputs,
659
+ deleteRecords,
660
+ thresholds: plan.thresholds,
661
+ generatedAt: plan.generatedAt,
662
+ skippedBusySessionIds: [...skippedBusySessionIds],
565
663
  });
566
- readyDeleteRecords.push(record);
567
- }
568
- await this.ensureArchivedRecordsMoved(readyDeleteRecords);
569
- const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
570
- deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
571
- await this.forgetUnreadSessions(deleteRecords);
572
- return summarizeSessionCleanupExecution({
573
- archiveInputs,
574
- deleteRecords,
575
- thresholds: plan.thresholds,
576
- generatedAt: plan.generatedAt,
577
- skippedBusySessionIds: [...skippedBusySessionIds],
578
664
  });
579
665
  }
580
666
  async forceCleanup() {
@@ -649,12 +735,68 @@ export class PiSessionService {
649
735
  await this.publishUnreadMutations([]);
650
736
  }
651
737
  async list(cwd) {
652
- const [sessions, archivedRecords, pinnedPaths] = await Promise.all([
738
+ const [sessions, archivedRecords, metadata] = await Promise.all([
653
739
  this.sessionManager.list(cwd),
654
740
  this.archiveStore.list(),
655
- this.metadataStore.pinnedPaths(),
741
+ this.metadataStore.snapshot(),
656
742
  ]);
657
- const pinnedPathSet = new Set(pinnedPaths);
743
+ return this.listFromSnapshots(cwd, sessions, archivedRecords, metadata);
744
+ }
745
+ /**
746
+ * Run `operation` inside the exclusive ordering queue shared by every
747
+ * mutation that can change sibling membership, pin state, or
748
+ * current/archive membership, so reorder's before/after catalog reads
749
+ * never interleave with a concurrent pin, detach, archive, or restore.
750
+ */
751
+ async runSessionOrderMutation(operation) {
752
+ const previous = this.sessionOrderMutationQueue;
753
+ let release = () => undefined;
754
+ this.sessionOrderMutationQueue = new Promise((resolve) => { release = resolve; });
755
+ await previous.catch(() => undefined);
756
+ try {
757
+ return await operation();
758
+ }
759
+ finally {
760
+ release();
761
+ }
762
+ }
763
+ async reorder(ref, request) {
764
+ return await this.runSessionOrderMutation(async () => {
765
+ const target = { id: ref.id, cwd: canonicalizeStoredCwd(ref.cwd) };
766
+ const before = await this.listReorderCatalog(request.catalogCwds);
767
+ const validated = validateSessionReorder(target, request, uniqueSessionsByIdentity(before));
768
+ try {
769
+ await this.metadataStore.replaceOrder(validated.sessionPaths, request.scope, request.pinned);
770
+ }
771
+ catch (error) {
772
+ if (error instanceof SessionMetadataOrderConflictError) {
773
+ throw new SessionReorderDomainError("conflict", error.message);
774
+ }
775
+ throw error;
776
+ }
777
+ const after = await this.listReorderCatalog(request.catalogCwds);
778
+ assertSubmittedSessionsCurrent(target, request, uniqueSessionsByIdentity(after));
779
+ return validated.response;
780
+ });
781
+ }
782
+ /**
783
+ * Read one coherent metadata/archive view across every requested CWD.
784
+ * Gateway session-list reads run in parallel per CWD, but the metadata and
785
+ * archive snapshots are each taken exactly once for the whole call, so the
786
+ * result is one atomic before/after catalog rather than one per CWD.
787
+ */
788
+ async listReorderCatalog(cwds) {
789
+ const metadataPromise = this.metadataStore.snapshot();
790
+ const archivePromise = this.archiveStore.list();
791
+ const sessionListsPromise = Promise.all(cwds.map((cwd) => this.sessionManager.list(cwd)));
792
+ const [metadata, archivedRecords, sessionLists] = await Promise.all([
793
+ metadataPromise,
794
+ archivePromise,
795
+ sessionListsPromise,
796
+ ]);
797
+ return (await Promise.all(cwds.map((cwd, index) => this.listFromSnapshots(cwd, sessionLists[index] ?? [], archivedRecords, metadata)))).flat();
798
+ }
799
+ async listFromSnapshots(cwd, sessions, archivedRecords, metadata) {
658
800
  const sessionsById = new Map(sessions.map((session) => [session.id, session]));
659
801
  const archivedForCwd = await Promise.all(archivedRecords
660
802
  .filter((record) => record.cwd === cwd)
@@ -665,7 +807,7 @@ export class PiSessionService {
665
807
  }
666
808
  const unarchivedSessions = sessions
667
809
  .filter((session) => !archivedById.has(session.id))
668
- .map((entry) => mergeSessionMetadata(clientSessionFromListEntry(entry), pinnedPathSet));
810
+ .map((entry) => mergeSessionMetadata(clientSessionFromListEntry(entry), metadata));
669
811
  const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
670
812
  this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
671
813
  await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
@@ -673,13 +815,30 @@ export class PiSessionService {
673
815
  .sort(compareArchivedRecords)
674
816
  .map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
675
817
  .filter(isDefined)
676
- .map((session) => mergeSessionMetadata(session, pinnedPathSet));
818
+ .map((session) => mergeSessionMetadata(session, metadata));
677
819
  return [...unarchivedSessions, ...archivedSessions];
678
820
  }
679
821
  async start(cwd, options) {
822
+ const creationSource = options?.creationSource;
823
+ const initialModelPolicy = options?.initialModelPolicy;
824
+ if (creationSource !== undefined || initialModelPolicy !== undefined) {
825
+ if (creationSource === undefined)
826
+ throw new Error("A complete initial model policy requires a session creation source");
827
+ if (initialModelPolicy === undefined)
828
+ throw new Error("A session creation source requires a complete initial model policy");
829
+ return this.startSession(cwd, {
830
+ creationSource,
831
+ initializeModelPolicy: {
832
+ kind: "complete-policy",
833
+ policy: initialModelPolicy,
834
+ },
835
+ });
836
+ }
680
837
  const modelPolicy = options?.modelPolicy;
681
838
  return this.startSession(cwd, {
682
- initializeModelPolicy: modelPolicy ?? true,
839
+ initializeModelPolicy: modelPolicy === undefined
840
+ ? { kind: "runtime-default" }
841
+ : { kind: "legacy-update", update: modelPolicy },
683
842
  });
684
843
  }
685
844
  async startSession(cwd, options) {
@@ -695,11 +854,15 @@ export class PiSessionService {
695
854
  ...(options.creationProvenance === undefined
696
855
  ? {}
697
856
  : { creationProvenance: options.creationProvenance }),
857
+ ...(options.creationSource === undefined
858
+ ? {}
859
+ : { creationSource: options.creationSource }),
698
860
  ...(options.initializeModelPolicy === undefined
699
861
  ? {}
700
862
  : { initializeModelPolicy: options.initializeModelPolicy }),
701
863
  });
702
864
  const { session } = active.runtime;
865
+ const creationSource = validSessionCreationSource(session.sessionManager);
703
866
  const created = {
704
867
  id: session.sessionId,
705
868
  path: session.sessionFile ?? "",
@@ -714,6 +877,7 @@ export class PiSessionService {
714
877
  ...(options.parentSession === undefined
715
878
  ? {}
716
879
  : { parentSessionPath: options.parentSession }),
880
+ ...(creationSource === undefined ? {} : { creationSource }),
717
881
  };
718
882
  // Broadcast so other clients (and the spawning agent's UI) can add the new
719
883
  // session to their list without a manual reload.
@@ -1199,6 +1363,9 @@ export class PiSessionService {
1199
1363
  session: this.statusFromSession(session),
1200
1364
  };
1201
1365
  }
1366
+ async rememberCurrentModelPolicy(ref) {
1367
+ return this.rememberCurrentModelPolicyCommand.remember(ref);
1368
+ }
1202
1369
  /**
1203
1370
  * The only interface that changes a session's Exact/Tiered policy. Applies one
1204
1371
  * complete transition atomically and publishes a single confirmed status; a
@@ -1688,173 +1855,181 @@ export class PiSessionService {
1688
1855
  });
1689
1856
  }
1690
1857
  async archive(ref) {
1691
- const session = await this.getOrOpen(ref);
1692
- if (this.hasActiveWork(session))
1693
- throw new Error("Stop current session activity before archiving");
1694
- await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before archiving", async () => {
1695
- const archiveInput = await this.archiveInputForSession(session);
1696
- await this.closeActive(session.sessionId, {
1697
- kind: "clear",
1698
- reason: "archive",
1858
+ await this.runSessionOrderMutation(async () => {
1859
+ const session = await this.getOrOpen(ref);
1860
+ if (this.hasActiveWork(session))
1861
+ throw new Error("Stop current session activity before archiving");
1862
+ await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before archiving", async () => {
1863
+ const archiveInput = await this.archiveInputForSession(session);
1864
+ await this.closeActive(session.sessionId, {
1865
+ kind: "clear",
1866
+ reason: "archive",
1867
+ });
1868
+ await this.archiveStore.archive(archiveInput);
1869
+ await this.forgetUnreadSessions([archiveInput]);
1699
1870
  });
1700
- await this.archiveStore.archive(archiveInput);
1701
- await this.forgetUnreadSessions([archiveInput]);
1702
1871
  });
1703
1872
  }
1704
1873
  async archiveMany(refs) {
1705
- const uniqueRefs = uniqueBulkSessionRefs(refs);
1706
- const [archivedRecords, sessionContext] = await Promise.all([
1707
- this.archiveStore.list(),
1708
- this.bulkSessionLookupContext(uniqueRefs),
1709
- ]);
1710
- const failures = [];
1711
- const alreadyArchivedSessionIds = [];
1712
- const unreadArchivedIdentities = [];
1713
- const planItems = [];
1714
- for (const ref of uniqueRefs) {
1715
- const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
1716
- if (archived !== undefined) {
1717
- this.publishNotificationMutations(this.notificationStore.clearSession(archived.sessionId, "archive"));
1718
- alreadyArchivedSessionIds.push(archived.sessionId);
1719
- unreadArchivedIdentities.push(archived);
1720
- continue;
1721
- }
1722
- const active = this.activeForLookup(bulkRefToLookup(ref));
1723
- const listed = findListedSessionForBulkRef(sessionContext, ref);
1724
- const resolvedSessionId = active?.runtime.session.sessionId ?? listed?.id ?? ref.id;
1725
- if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1726
- failures.push({
1727
- sessionId: resolvedSessionId,
1728
- error: "Stop current session activity before archiving",
1729
- });
1730
- continue;
1731
- }
1732
- try {
1733
- if (listed !== undefined) {
1734
- planItems.push({ input: archiveInputFromListEntry(listed) });
1874
+ return await this.runSessionOrderMutation(async () => {
1875
+ const uniqueRefs = uniqueBulkSessionRefs(refs);
1876
+ const [archivedRecords, sessionContext] = await Promise.all([
1877
+ this.archiveStore.list(),
1878
+ this.bulkSessionLookupContext(uniqueRefs),
1879
+ ]);
1880
+ const failures = [];
1881
+ const alreadyArchivedSessionIds = [];
1882
+ const unreadArchivedIdentities = [];
1883
+ const planItems = [];
1884
+ for (const ref of uniqueRefs) {
1885
+ const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
1886
+ if (archived !== undefined) {
1887
+ this.publishNotificationMutations(this.notificationStore.clearSession(archived.sessionId, "archive"));
1888
+ alreadyArchivedSessionIds.push(archived.sessionId);
1889
+ unreadArchivedIdentities.push(archived);
1890
+ continue;
1735
1891
  }
1736
- else if (active !== undefined) {
1737
- planItems.push({
1738
- input: archiveInputFromActiveSession(active.runtime.session),
1892
+ const active = this.activeForLookup(bulkRefToLookup(ref));
1893
+ const listed = findListedSessionForBulkRef(sessionContext, ref);
1894
+ const resolvedSessionId = active?.runtime.session.sessionId ?? listed?.id ?? ref.id;
1895
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1896
+ failures.push({
1897
+ sessionId: resolvedSessionId,
1898
+ error: "Stop current session activity before archiving",
1739
1899
  });
1900
+ continue;
1740
1901
  }
1741
- else {
1742
- failures.push({ sessionId: ref.id, error: "Session not found" });
1743
- }
1744
- }
1745
- catch (error) {
1746
- failures.push({
1747
- sessionId: resolvedSessionId,
1748
- error: errorMessage(error),
1749
- });
1750
- }
1751
- }
1752
- const readyPlanItems = [];
1753
- for (const item of planItems) {
1754
- const active = this.activeForLookup({
1755
- id: item.input.sessionId,
1756
- cwd: item.input.cwd,
1757
- });
1758
- if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1759
- failures.push({
1760
- sessionId: item.input.sessionId,
1761
- error: "Stop current session activity before archiving",
1762
- });
1763
- continue;
1764
- }
1765
- readyPlanItems.push(active === undefined ? item : { ...item, active });
1766
- }
1767
- const readyInputs = [];
1768
- const archivedSessionIds = [...alreadyArchivedSessionIds];
1769
- await this.runTreeExclusiveOperation(readyPlanItems.map(({ input, active }) => ({
1770
- sessionId: input.sessionId,
1771
- ...(active === undefined
1772
- ? {}
1773
- : { session: active.runtime.session, runtime: active.runtime }),
1774
- })), "Stop current session activity before archiving", async () => {
1775
- for (const item of readyPlanItems) {
1776
1902
  try {
1777
- await this.closeActive(item.input.sessionId, {
1778
- kind: "clear",
1779
- reason: "archive",
1780
- });
1781
- readyInputs.push(item.input);
1903
+ if (listed !== undefined) {
1904
+ planItems.push({ input: archiveInputFromListEntry(listed) });
1905
+ }
1906
+ else if (active !== undefined) {
1907
+ planItems.push({
1908
+ input: archiveInputFromActiveSession(active.runtime.session),
1909
+ });
1910
+ }
1911
+ else {
1912
+ failures.push({ sessionId: ref.id, error: "Session not found" });
1913
+ }
1782
1914
  }
1783
1915
  catch (error) {
1784
1916
  failures.push({
1785
- sessionId: item.input.sessionId,
1917
+ sessionId: resolvedSessionId,
1786
1918
  error: errorMessage(error),
1787
1919
  });
1788
1920
  }
1789
1921
  }
1790
- try {
1791
- const archived = await this.archiveStoreArchiveMany(readyInputs);
1792
- archivedSessionIds.push(...archived.map((record) => record.sessionId));
1793
- unreadArchivedIdentities.push(...archived);
1794
- }
1795
- catch (error) {
1796
- for (const input of readyInputs)
1922
+ const readyPlanItems = [];
1923
+ for (const item of planItems) {
1924
+ const active = this.activeForLookup({
1925
+ id: item.input.sessionId,
1926
+ cwd: item.input.cwd,
1927
+ });
1928
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1797
1929
  failures.push({
1798
- sessionId: input.sessionId,
1799
- error: errorMessage(error),
1930
+ sessionId: item.input.sessionId,
1931
+ error: "Stop current session activity before archiving",
1800
1932
  });
1933
+ continue;
1934
+ }
1935
+ readyPlanItems.push(active === undefined ? item : { ...item, active });
1801
1936
  }
1937
+ const readyInputs = [];
1938
+ const archivedSessionIds = [...alreadyArchivedSessionIds];
1939
+ await this.runTreeExclusiveOperation(readyPlanItems.map(({ input, active }) => ({
1940
+ sessionId: input.sessionId,
1941
+ ...(active === undefined
1942
+ ? {}
1943
+ : { session: active.runtime.session, runtime: active.runtime }),
1944
+ })), "Stop current session activity before archiving", async () => {
1945
+ for (const item of readyPlanItems) {
1946
+ try {
1947
+ await this.closeActive(item.input.sessionId, {
1948
+ kind: "clear",
1949
+ reason: "archive",
1950
+ });
1951
+ readyInputs.push(item.input);
1952
+ }
1953
+ catch (error) {
1954
+ failures.push({
1955
+ sessionId: item.input.sessionId,
1956
+ error: errorMessage(error),
1957
+ });
1958
+ }
1959
+ }
1960
+ try {
1961
+ const archived = await this.archiveStoreArchiveMany(readyInputs);
1962
+ archivedSessionIds.push(...archived.map((record) => record.sessionId));
1963
+ unreadArchivedIdentities.push(...archived);
1964
+ }
1965
+ catch (error) {
1966
+ for (const input of readyInputs)
1967
+ failures.push({
1968
+ sessionId: input.sessionId,
1969
+ error: errorMessage(error),
1970
+ });
1971
+ }
1972
+ });
1973
+ await this.forgetUnreadSessions(unreadArchivedIdentities);
1974
+ return {
1975
+ archived: true,
1976
+ archivedSessionIds: uniqueStrings(archivedSessionIds),
1977
+ failures,
1978
+ generatedAt: new Date().toISOString(),
1979
+ };
1802
1980
  });
1803
- await this.forgetUnreadSessions(unreadArchivedIdentities);
1804
- return {
1805
- archived: true,
1806
- archivedSessionIds: uniqueStrings(archivedSessionIds),
1807
- failures,
1808
- generatedAt: new Date().toISOString(),
1809
- };
1810
1981
  }
1811
1982
  async archiveTree(ref) {
1812
- const session = await this.getOrOpen(ref);
1813
- const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
1814
- const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ??
1815
- archiveCandidateFromActiveSession(session, false);
1816
- const plan = planSessionArchiveTree(root, catalog);
1817
- const busy = plan.targets
1818
- .map((target) => target.activeSession)
1819
- .find((target) => target !== undefined && this.hasActiveWork(target));
1820
- if (busy !== undefined)
1821
- throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
1822
- const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
1823
- await this.runTreeExclusiveOperation(plan.unarchivedTargets.map((target) => ({
1824
- sessionId: target.id,
1825
- ...(target.activeSession === undefined
1826
- ? {}
1827
- : { session: target.activeSession }),
1828
- })), `Stop current session activity before archiving ${sessionDisplayName(session)}`, async () => {
1829
- for (const target of plan.targets) {
1830
- if (target.archived)
1831
- this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
1832
- }
1833
- for (const input of archiveInputs)
1834
- await this.closeActive(input.sessionId, {
1835
- kind: "clear",
1836
- reason: "archive",
1837
- });
1838
- await this.archiveStoreArchiveMany(archiveInputs);
1983
+ return await this.runSessionOrderMutation(async () => {
1984
+ const session = await this.getOrOpen(ref);
1985
+ const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
1986
+ const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ??
1987
+ archiveCandidateFromActiveSession(session, false);
1988
+ const plan = planSessionArchiveTree(root, catalog);
1989
+ const busy = plan.targets
1990
+ .map((target) => target.activeSession)
1991
+ .find((target) => target !== undefined && this.hasActiveWork(target));
1992
+ if (busy !== undefined)
1993
+ throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
1994
+ const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
1995
+ await this.runTreeExclusiveOperation(plan.unarchivedTargets.map((target) => ({
1996
+ sessionId: target.id,
1997
+ ...(target.activeSession === undefined
1998
+ ? {}
1999
+ : { session: target.activeSession }),
2000
+ })), `Stop current session activity before archiving ${sessionDisplayName(session)}`, async () => {
2001
+ for (const target of plan.targets) {
2002
+ if (target.archived)
2003
+ this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
2004
+ }
2005
+ for (const input of archiveInputs)
2006
+ await this.closeActive(input.sessionId, {
2007
+ kind: "clear",
2008
+ reason: "archive",
2009
+ });
2010
+ await this.archiveStoreArchiveMany(archiveInputs);
2011
+ });
2012
+ await this.forgetUnreadSessions(plan.targets.map((target) => ({ sessionId: target.id, cwd: target.cwd })));
2013
+ return {
2014
+ archived: true,
2015
+ sessionIds: archiveInputs.map((input) => input.sessionId),
2016
+ archivedCount: archiveInputs.length,
2017
+ skippedAlreadyArchivedCount: plan.skippedAlreadyArchivedCount,
2018
+ };
1839
2019
  });
1840
- await this.forgetUnreadSessions(plan.targets.map((target) => ({ sessionId: target.id, cwd: target.cwd })));
1841
- return {
1842
- archived: true,
1843
- sessionIds: archiveInputs.map((input) => input.sessionId),
1844
- archivedCount: archiveInputs.length,
1845
- skippedAlreadyArchivedCount: plan.skippedAlreadyArchivedCount,
1846
- };
1847
2020
  }
1848
2021
  async restore(ref) {
1849
- const archived = await this.getArchived(ref);
1850
- if (archived === undefined)
1851
- throw new Error("Session not found");
1852
- await this.closeActive(archived.sessionId, {
1853
- kind: "clear",
1854
- reason: "restore",
2022
+ await this.runSessionOrderMutation(async () => {
2023
+ const archived = await this.getArchived(ref);
2024
+ if (archived === undefined)
2025
+ throw new Error("Session not found");
2026
+ await this.closeActive(archived.sessionId, {
2027
+ kind: "clear",
2028
+ reason: "restore",
2029
+ });
2030
+ await this.archiveStore.restore(archived.sessionId);
2031
+ await this.forgetUnreadSessions([archived]);
1855
2032
  });
1856
- await this.archiveStore.restore(archived.sessionId);
1857
- await this.forgetUnreadSessions([archived]);
1858
2033
  }
1859
2034
  async deleteArchived(ref) {
1860
2035
  const record = await this.getArchived(ref);
@@ -1978,35 +2153,43 @@ export class PiSessionService {
1978
2153
  this.publishStatus(reopenedSession);
1979
2154
  }
1980
2155
  async detachParent(ref) {
1981
- const session = await this.getOrOpen(ref);
1982
- const sessionFile = session.sessionFile;
1983
- if (sessionFile === undefined || sessionFile === "")
1984
- throw new Error("Session is not persisted");
1985
- await clearParentSession(sessionFile);
1986
- clearParentSessionHeader(session.sessionManager);
1987
- this.unregisterSubsession(session.sessionId);
1988
- await this.forgetUnreadSessions([
1989
- { sessionId: session.sessionId, cwd: session.sessionManager.getCwd() },
1990
- ]);
2156
+ await this.runSessionOrderMutation(async () => {
2157
+ const session = await this.getOrOpen(ref);
2158
+ const sessionFile = session.sessionFile;
2159
+ if (sessionFile === undefined || sessionFile === "")
2160
+ throw new Error("Session is not persisted");
2161
+ await this.clearParentSessionFile(sessionFile);
2162
+ clearParentSessionHeader(session.sessionManager);
2163
+ await this.metadataStore.clearOrder(sessionFile);
2164
+ this.unregisterSubsession(session.sessionId);
2165
+ await this.forgetUnreadSessions([
2166
+ { sessionId: session.sessionId, cwd: session.sessionManager.getCwd() },
2167
+ ]);
2168
+ });
1991
2169
  }
1992
2170
  async pin(ref) {
1993
- const session = await this.getOrOpen(ref);
1994
- const sessionFile = session.sessionFile;
1995
- if (sessionFile === undefined || sessionFile === "")
1996
- throw new Error("Session is not persisted");
1997
- await this.metadataStore.pin(sessionFile);
1998
- return this.sessionInfoFromActive(session, { pinned: true });
2171
+ return await this.runSessionOrderMutation(async () => {
2172
+ const session = await this.getOrOpen(ref);
2173
+ const sessionFile = session.sessionFile;
2174
+ if (sessionFile === undefined || sessionFile === "")
2175
+ throw new Error("Session is not persisted");
2176
+ await this.metadataStore.pin(sessionFile);
2177
+ return this.sessionInfoFromActive(session, { pinned: true });
2178
+ });
1999
2179
  }
2000
2180
  async unpin(ref) {
2001
- const session = await this.getOrOpen(ref);
2002
- const sessionFile = session.sessionFile;
2003
- if (sessionFile === undefined || sessionFile === "")
2004
- throw new Error("Session is not persisted");
2005
- await this.metadataStore.unpin(sessionFile);
2006
- return this.sessionInfoFromActive(session, { pinned: false });
2181
+ return await this.runSessionOrderMutation(async () => {
2182
+ const session = await this.getOrOpen(ref);
2183
+ const sessionFile = session.sessionFile;
2184
+ if (sessionFile === undefined || sessionFile === "")
2185
+ throw new Error("Session is not persisted");
2186
+ await this.metadataStore.unpin(sessionFile);
2187
+ return this.sessionInfoFromActive(session, { pinned: false });
2188
+ });
2007
2189
  }
2008
2190
  sessionInfoFromActive(session, overrides) {
2009
2191
  const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
2192
+ const creationSource = validSessionCreationSource(session.sessionManager);
2010
2193
  return {
2011
2194
  id: session.sessionId,
2012
2195
  path: session.sessionFile ?? "",
@@ -2020,6 +2203,7 @@ export class PiSessionService {
2020
2203
  ? {}
2021
2204
  : { name: session.sessionName }),
2022
2205
  ...(parentSessionPath === undefined ? {} : { parentSessionPath }),
2206
+ ...(creationSource === undefined ? {} : { creationSource }),
2023
2207
  ...overrides,
2024
2208
  };
2025
2209
  }
@@ -2459,6 +2643,7 @@ export class PiSessionService {
2459
2643
  : notificationGeneration === undefined
2460
2644
  ? "registered"
2461
2645
  : "external";
2646
+ let completeInitialization;
2462
2647
  if (notificationOwnership === "registered") {
2463
2648
  const notificationIdentity = notificationIdentityForSession(runtime.session);
2464
2649
  const existingCandidate = this.notificationStore.beginReplacementForSession(notificationIdentity.sessionId, notificationIdentity.cwd);
@@ -2483,7 +2668,6 @@ export class PiSessionService {
2483
2668
  await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
2484
2669
  }
2485
2670
  await this.bindSessionExtensions(runtime.session, notificationGeneration);
2486
- this.bindRuntime(active);
2487
2671
  runtime.setRebindSession(async (session) => {
2488
2672
  const priorGeneration = notificationGeneration;
2489
2673
  let candidateGeneration;
@@ -2512,10 +2696,20 @@ export class PiSessionService {
2512
2696
  throw error;
2513
2697
  }
2514
2698
  });
2515
- this.active.set(runtime.session.sessionId, active);
2516
- if (options.initializeModelPolicy !== undefined) {
2699
+ if (options.initializeModelPolicy?.kind === "complete-policy") {
2700
+ if (options.creationSource === undefined) {
2701
+ throw new Error("A complete initial model policy requires a session creation source");
2702
+ }
2703
+ completeInitialization =
2704
+ await this.initializeCompleteSessionModelPolicy(runtime.session, options.initializeModelPolicy.policy, options.creationSource);
2705
+ }
2706
+ else if (options.initializeModelPolicy !== undefined) {
2517
2707
  await this.initializeSessionModelPolicy(runtime.session, options.initializeModelPolicy);
2518
2708
  }
2709
+ if (options.creationSource !== undefined &&
2710
+ options.initializeModelPolicy?.kind !== "complete-policy")
2711
+ throw new Error("A session creation source requires a complete initial model policy");
2712
+ this.bindRuntime(active);
2519
2713
  if (notificationOwnership === "replacement" &&
2520
2714
  notificationGeneration !== undefined) {
2521
2715
  this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
@@ -2548,13 +2742,28 @@ export class PiSessionService {
2548
2742
  if (removedActive) {
2549
2743
  this.workspaceActivity?.removeSession(runtime.session.sessionId, runtime.session.sessionManager.getCwd());
2550
2744
  }
2745
+ let failure = error;
2746
+ if (completeInitialization !== undefined) {
2747
+ const rollbackFailures = await this.rollbackCompleteSessionInitialization(runtime.session, completeInitialization);
2748
+ failure = completeInitializationFailure(error, rollbackFailures);
2749
+ }
2750
+ const teardownFailures = [];
2551
2751
  try {
2552
2752
  await runtime.session.abort();
2553
2753
  }
2554
- finally {
2754
+ catch (abortError) {
2755
+ teardownFailures.push(normalizeError(abortError));
2756
+ }
2757
+ try {
2555
2758
  await runtime.dispose();
2556
2759
  }
2557
- throw error;
2760
+ catch (disposeError) {
2761
+ teardownFailures.push(normalizeError(disposeError));
2762
+ }
2763
+ if (teardownFailures.length > 0) {
2764
+ failure = sessionInitializationCleanupFailure(failure, teardownFailures, completeInitialization !== undefined);
2765
+ }
2766
+ throw failure;
2558
2767
  }
2559
2768
  }
2560
2769
  async bindSessionExtensions(session, generation) {
@@ -2885,12 +3094,22 @@ export class PiSessionService {
2885
3094
  const model = session.model;
2886
3095
  if (model === undefined)
2887
3096
  return;
2888
- void generateShortSessionName(session.agent.streamFunction, model, firstMessage)
3097
+ void runWithUtilityModelFallback(this.utilityModelResolver, "lightweight", { model, thinkingLevel: "minimal" }, (attempt) => generateShortSessionName(session.agent.streamFunction, attempt.model, firstMessage, attempt.thinkingLevel), (attempt, error) => {
3098
+ this.logger.info({
3099
+ provider: attempt.model.provider,
3100
+ modelId: attempt.model.id,
3101
+ thinkingLevel: attempt.thinkingLevel,
3102
+ error: error instanceof Error ? error.message : String(error),
3103
+ }, "utility title model failed");
3104
+ })
2889
3105
  .then((name) => {
2890
3106
  this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
2891
3107
  })
2892
- .catch(() => {
2893
- this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
3108
+ .catch((error) => {
3109
+ this.logger.info({
3110
+ sessionId: session.sessionId,
3111
+ error: error instanceof Error ? error.message : String(error),
3112
+ }, "failed to apply generated session name");
2894
3113
  });
2895
3114
  }
2896
3115
  applyGeneratedSessionName(session, name) {
@@ -3241,8 +3460,57 @@ export class PiSessionService {
3241
3460
  };
3242
3461
  }
3243
3462
  policyEntries(session) {
3244
- return (session.sessionManager.getEntries?.() ??
3245
- session.sessionManager.getBranch());
3463
+ return session.sessionManager.getBranch();
3464
+ }
3465
+ async confirmedPolicySnapshot(ref) {
3466
+ const session = await this.getOrOpen(ref);
3467
+ const sessionFile = session.sessionManager.getSessionFile();
3468
+ const persisted = sessionFileExists(sessionFile);
3469
+ const manager = persisted
3470
+ ? this.sessionManager.open(sessionFile)
3471
+ : session.sessionManager;
3472
+ const activeEntries = manager.getBranch();
3473
+ // Creation provenance belongs to the durable root file, even when its
3474
+ // original entry is not on the currently selected conversation branch.
3475
+ const creationSourceEntries = manager.getEntries?.() ?? activeEntries;
3476
+ const creationSource = inspectSessionCreationSource(creationSourceEntries);
3477
+ return {
3478
+ cwd: canonicalizeStoredCwd(manager.getCwd()),
3479
+ creationSource,
3480
+ persisted,
3481
+ rootEligibility: this.sessionCreationRootEligibility(manager, creationSource),
3482
+ modelPolicy: inspectSessionModelPolicy(activeEntries, this.exactSelectionFromSession(session)),
3483
+ transitionInFlight: this.isModelPolicyMutationActive(session),
3484
+ };
3485
+ }
3486
+ sessionCreationRootEligibility(manager, source) {
3487
+ const sessionFile = manager.getSessionFile();
3488
+ const header = manager.getHeader?.();
3489
+ if (sessionFile === undefined || sessionFile === "") {
3490
+ return {
3491
+ kind: "ineligible",
3492
+ reason: "the session has no durable transcript path",
3493
+ };
3494
+ }
3495
+ if (header === undefined || header === null || typeof header.id !== "string") {
3496
+ return {
3497
+ kind: "ineligible",
3498
+ reason: "the session header does not identify its root",
3499
+ };
3500
+ }
3501
+ if (header.id !== manager.getSessionId()) {
3502
+ return {
3503
+ kind: "ineligible",
3504
+ reason: "the session header id does not match the opened session",
3505
+ };
3506
+ }
3507
+ return inspectSessionCreationRootEligibility(source, {
3508
+ sessionId: header.id,
3509
+ sessionFile,
3510
+ ...(header.parentSession === undefined
3511
+ ? {}
3512
+ : { parentSession: header.parentSession }),
3513
+ });
3246
3514
  }
3247
3515
  inspectAndCacheSessionModelPolicy(session) {
3248
3516
  const inspection = inspectSessionModelPolicy(this.policyEntries(session), this.exactSelectionFromSession(session));
@@ -3260,26 +3528,113 @@ export class PiSessionService {
3260
3528
  return (this.modelPolicyRuntimeBlocks.get(session.sessionId) ??
3261
3529
  this.modelPolicyEntryReasons.get(session));
3262
3530
  }
3263
- /**
3264
- * Root-session policy initialization. `true` records the runtime's own tuple;
3265
- * an update applies the requested transition. Both complete before the caller
3266
- * of `start()` sees the session, so no prompt can precede the policy.
3267
- */
3531
+ /** Root-session policy initialization, completed before the root is visible. */
3268
3532
  async initializeSessionModelPolicy(session, initializer) {
3269
3533
  const current = {
3270
3534
  mode: "exact",
3271
3535
  exact: this.exactSelectionFromSession(session),
3272
3536
  };
3273
- if (initializer === true) {
3537
+ if (initializer.kind === "runtime-default") {
3274
3538
  this.appendSessionModelPolicy(session, current);
3275
3539
  this.inspectAndCacheSessionModelPolicy(session);
3276
3540
  return;
3277
3541
  }
3278
- // Root creation reports failure through its own abort/dispose cleanup rather
3279
- // than publishing a failed transition for a session no client has seen.
3280
- await this.applySessionModelPolicy(session, current, initializer, {
3281
- action: "initialize the session model policy",
3542
+ if (initializer.kind === "legacy-update") {
3543
+ // Root creation reports failure through its own abort/dispose cleanup
3544
+ // rather than publishing a transition for a session no client has seen.
3545
+ await this.applySessionModelPolicy(session, current, initializer.update, {
3546
+ action: "initialize the session model policy",
3547
+ });
3548
+ return;
3549
+ }
3550
+ throw new Error("A complete initial model policy requires durable plus-root initialization");
3551
+ }
3552
+ async initializeCompleteSessionModelPolicy(session, policy, source) {
3553
+ const plan = planSessionModelPolicyInitialization(policy, (tier) => {
3554
+ const resolved = this.modelTierRegistry.resolve(tier);
3555
+ return {
3556
+ model: { provider: resolved.model.provider, id: resolved.model.id },
3557
+ thinkingLevel: resolved.thinkingLevel,
3558
+ };
3282
3559
  });
3560
+ // Only the active target is checked against the current runtime catalog.
3561
+ // The inactive branch remains remembered even when it is unavailable.
3562
+ const target = await this.resolveAvailableExactSelection(session, plan.target);
3563
+ const settings = modelPolicySettingsPersistence(session.settingsManager);
3564
+ await settleModelPolicySettings(settings, "before complete session initialization");
3565
+ this.assertModelPolicyMutationIdle(session, "initialize the session model policy");
3566
+ const initialization = {
3567
+ previousSelection: this.exactSelectionFromSession(session),
3568
+ previousSettings: captureModelPolicySettings(settings),
3569
+ };
3570
+ try {
3571
+ await this.runSessionModelPolicyMutation(session, "initialize the session model policy", async () => {
3572
+ await this.applyExactSelection(session, target);
3573
+ // Pi queues global default writes behind its setters and swallows their
3574
+ // storage failures, so prove durability before any transcript record
3575
+ // exists: everything after this point must be reversible.
3576
+ await settleModelPolicySettings(settings, "while applying initial model defaults");
3577
+ this.appendSessionModelPolicy(session, plan.policy);
3578
+ this.verifyPersistedSessionModelPolicy(session, plan.policy);
3579
+ this.appendSessionCreationSource(session, source);
3580
+ await this.commitAndVerifyInitialSessionEntries(session, plan.policy);
3581
+ });
3582
+ this.inspectAndCacheSessionModelPolicy(session);
3583
+ return initialization;
3584
+ }
3585
+ catch (error) {
3586
+ const rollbackFailures = await this.rollbackCompleteSessionInitialization(session, initialization);
3587
+ throw completeInitializationFailure(error, rollbackFailures);
3588
+ }
3589
+ }
3590
+ async rollbackCompleteSessionInitialization(session, initialization) {
3591
+ const failures = [];
3592
+ if (!(await this.restoreExactSelection(session, initialization.previousSelection))) {
3593
+ failures.push(new Error("the previous runtime model and thinking pair was not restored"));
3594
+ }
3595
+ try {
3596
+ await restoreModelPolicySettings(modelPolicySettingsPersistence(session.settingsManager), initialization.previousSettings);
3597
+ }
3598
+ catch (error) {
3599
+ failures.push(normalizeError(error));
3600
+ }
3601
+ try {
3602
+ await this.sessionManager.discardInitialEntries?.(session.sessionManager);
3603
+ }
3604
+ catch (error) {
3605
+ failures.push(normalizeError(error));
3606
+ }
3607
+ try {
3608
+ this.inspectAndCacheSessionModelPolicy(session);
3609
+ }
3610
+ catch (error) {
3611
+ failures.push(normalizeError(error));
3612
+ }
3613
+ return failures;
3614
+ }
3615
+ async commitAndVerifyInitialSessionEntries(session, expectedPolicy) {
3616
+ if (this.sessionManager.commitInitialEntries === undefined) {
3617
+ throw new Error("Cannot durably commit the complete initial session policy and provenance");
3618
+ }
3619
+ await this.sessionManager.commitInitialEntries(session.sessionManager);
3620
+ const sessionFile = session.sessionManager.getSessionFile();
3621
+ if (!sessionFileExists(sessionFile))
3622
+ throw new Error("Cannot verify the durable initial session transcript");
3623
+ const reopened = this.sessionManager.open(sessionFile);
3624
+ const entries = reopened.getEntries?.() ?? reopened.getBranch();
3625
+ const policyInspection = inspectSessionModelPolicy(entries, expectedPolicy.exact);
3626
+ if (policyInspection.kind !== "persisted" ||
3627
+ !sameSessionModelPolicy(policyInspection.policy, expectedPolicy)) {
3628
+ throw new Error("Cannot verify the complete initial session model policy after reopen");
3629
+ }
3630
+ const sourceInspection = inspectSessionCreationSource(entries);
3631
+ if (sourceInspection.kind !== "valid") {
3632
+ throw new Error("Cannot verify the session creation source after reopen");
3633
+ }
3634
+ const rootEligibility = this.sessionCreationRootEligibility(reopened, sourceInspection);
3635
+ if (rootEligibility.kind !== "eligible") {
3636
+ throw new Error(`Cannot verify the durable plus-session root origin: ${rootEligibility.reason}`);
3637
+ }
3283
3638
  }
3284
3639
  /**
3285
3640
  * The single policy transition path. Validates the complete target before any
@@ -3539,6 +3894,35 @@ export class PiSessionService {
3539
3894
  }
3540
3895
  session.sessionManager.appendCustomEntry(SESSION_MODEL_POLICY_CUSTOM_TYPE, serializeSessionModelPolicy(policy));
3541
3896
  }
3897
+ verifyPersistedSessionModelPolicy(session, expected) {
3898
+ const inspection = this.inspectAndCacheSessionModelPolicy(session);
3899
+ if (inspection.kind !== "persisted" ||
3900
+ !sameSessionModelPolicy(inspection.policy, expected)) {
3901
+ throw new Error("Cannot verify the complete initial session model policy");
3902
+ }
3903
+ }
3904
+ appendSessionCreationSource(session, source) {
3905
+ if (session.sessionManager.appendCustomEntry === undefined) {
3906
+ throw new Error("Cannot persist the session creation source: session persistence is unavailable");
3907
+ }
3908
+ const sessionFile = session.sessionManager.getSessionFile();
3909
+ if (sessionFile === undefined || sessionFile === "") {
3910
+ throw new Error("Cannot persist the session creation source without a session file");
3911
+ }
3912
+ if (session.sessionManager.getSessionId() !== session.sessionId) {
3913
+ throw new Error("Cannot persist the session creation source for a mismatched session id");
3914
+ }
3915
+ session.sessionManager.appendCustomEntry(SESSION_CREATION_SOURCE_CUSTOM_TYPE, serializeSessionCreationSource(source, {
3916
+ sessionId: session.sessionId,
3917
+ sessionFile,
3918
+ }));
3919
+ const inspection = inspectSessionCreationSource(this.policyEntries(session));
3920
+ const rootEligibility = this.sessionCreationRootEligibility(session.sessionManager, inspection);
3921
+ if (inspection.kind !== "valid" ||
3922
+ rootEligibility.kind !== "eligible") {
3923
+ throw new Error("Cannot verify the session creation source");
3924
+ }
3925
+ }
3542
3926
  modelPolicyStatusFromSession(session) {
3543
3927
  const inspection = this.modelPolicyInspections.get(session);
3544
3928
  if (inspection === undefined)
@@ -3668,6 +4052,19 @@ function findSessionByIdOrPrefix(sessions, sessionId) {
3668
4052
  function uniqueStrings(values) {
3669
4053
  return [...new Set(values)];
3670
4054
  }
4055
+ /**
4056
+ * Reconcile duplicate catalog entries by canonical CWD + id, matching the
4057
+ * last-writer-wins convention other catalog reconciliation helpers use. The
4058
+ * reorder loader can read the same session more than once when overlapping
4059
+ * catalog CWDs are requested (e.g. a cross-workspace children scope).
4060
+ */
4061
+ function uniqueSessionsByIdentity(sessions) {
4062
+ const byIdentity = new Map();
4063
+ for (const session of sessions) {
4064
+ byIdentity.set(JSON.stringify([canonicalizeStoredCwd(session.cwd), session.id]), session);
4065
+ }
4066
+ return [...byIdentity.values()];
4067
+ }
3671
4068
  function errorMessage(error) {
3672
4069
  return error instanceof Error ? error.message : String(error);
3673
4070
  }
@@ -3704,6 +4101,9 @@ function clientSessionFromListEntry(session) {
3704
4101
  ...(session.parentSessionPath === undefined
3705
4102
  ? {}
3706
4103
  : { parentSessionPath: session.parentSessionPath }),
4104
+ ...(session.creationSource === undefined
4105
+ ? {}
4106
+ : { creationSource: session.creationSource }),
3707
4107
  };
3708
4108
  }
3709
4109
  function archiveInputFromListEntry(session) {
@@ -3719,6 +4119,9 @@ function archiveInputFromListEntry(session) {
3719
4119
  ...(session.parentSessionPath === undefined
3720
4120
  ? {}
3721
4121
  : { parentSessionPath: session.parentSessionPath }),
4122
+ ...(session.creationSource === undefined
4123
+ ? {}
4124
+ : { creationSource: session.creationSource }),
3722
4125
  };
3723
4126
  }
3724
4127
  function archiveInputFromActiveSession(session) {
@@ -3726,6 +4129,7 @@ function archiveInputFromActiveSession(session) {
3726
4129
  if (sessionFile === undefined || sessionFile === "")
3727
4130
  throw new Error("Session is not persisted");
3728
4131
  const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
4132
+ const creationSource = validSessionCreationSource(session.sessionManager);
3729
4133
  return {
3730
4134
  sessionId: session.sessionId,
3731
4135
  cwd: session.sessionManager.getCwd(),
@@ -3736,6 +4140,7 @@ function archiveInputFromActiveSession(session) {
3736
4140
  firstMessage: "",
3737
4141
  ...(session.sessionName === undefined ? {} : { name: session.sessionName }),
3738
4142
  ...(parentSessionPath === undefined ? {} : { parentSessionPath }),
4143
+ ...(creationSource === undefined ? {} : { creationSource }),
3739
4144
  };
3740
4145
  }
3741
4146
  function archiveCandidateFromListEntry(session) {
@@ -3808,6 +4213,7 @@ function clientSessionFromArchivedRecord(record, fallback) {
3808
4213
  return undefined;
3809
4214
  const name = record.name ?? fallback?.name;
3810
4215
  const parentSessionPath = record.parentSessionPath ?? fallback?.parentSessionPath;
4216
+ const creationSource = record.creationSource ?? fallback?.creationSource;
3811
4217
  return {
3812
4218
  id: record.sessionId,
3813
4219
  path,
@@ -3818,10 +4224,22 @@ function clientSessionFromArchivedRecord(record, fallback) {
3818
4224
  messageCount,
3819
4225
  firstMessage,
3820
4226
  ...(parentSessionPath === undefined ? {} : { parentSessionPath }),
4227
+ ...(creationSource === undefined ? {} : { creationSource }),
3821
4228
  archived: true,
3822
4229
  archivedAt: record.archivedAt,
3823
4230
  };
3824
4231
  }
4232
+ function validSessionCreationSource(manager) {
4233
+ const inspection = inspectSessionCreationSource(manager.getEntries?.() ?? manager.getBranch());
4234
+ return inspection.kind === "valid" ? inspection.source : undefined;
4235
+ }
4236
+ function sameSessionModelPolicy(left, right) {
4237
+ return (left.mode === right.mode &&
4238
+ left.tier === right.tier &&
4239
+ left.exact.model.provider === right.exact.model.provider &&
4240
+ left.exact.model.id === right.exact.model.id &&
4241
+ left.exact.thinkingLevel === right.exact.thinkingLevel);
4242
+ }
3825
4243
  function addSessionName(names, name) {
3826
4244
  const trimmed = name?.replace(/\s+/g, " ").trim();
3827
4245
  if (trimmed !== undefined && trimmed !== "")
@@ -3837,10 +4255,28 @@ function archivedTimestamp(record) {
3837
4255
  function isDefined(value) {
3838
4256
  return value !== undefined;
3839
4257
  }
3840
- function mergeSessionMetadata(session, pinnedPaths) {
3841
- if (!pinnedPaths.has(session.path))
3842
- return session;
3843
- return { ...session, pinned: true };
4258
+ function mergeSessionMetadata(session, metadata) {
4259
+ const entry = metadata[session.path];
4260
+ const pinned = entry?.pinned === true;
4261
+ const scope = session.parentSessionPath === undefined
4262
+ ? { kind: "root", cwd: canonicalizeStoredCwd(session.cwd) }
4263
+ : { kind: "children", parentSessionPath: session.parentSessionPath };
4264
+ const order = entry?.order;
4265
+ const manualOrder = session.archived !== true
4266
+ && order?.pinned === pinned
4267
+ && sessionReorderScopesEqual(order.scope, scope)
4268
+ ? order.position
4269
+ : undefined;
4270
+ return {
4271
+ ...session,
4272
+ ...(pinned ? { pinned: true } : {}),
4273
+ ...(manualOrder === undefined ? {} : { manualOrder }),
4274
+ };
4275
+ }
4276
+ function sessionReorderScopesEqual(a, b) {
4277
+ if (a.kind === "root")
4278
+ return b.kind === "root" && a.cwd === b.cwd;
4279
+ return b.kind === "children" && a.parentSessionPath === b.parentSessionPath;
3844
4280
  }
3845
4281
  /**
3846
4282
  * Resolve the delegation capability from server-owned, persisted session