@jmfederico/pi-web 1.202607.3 → 1.202608.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 (69) hide show
  1. package/README.md +1 -1
  2. package/dist/client/assets/{CodeViewer-CDVbMiN9.js → CodeViewer-D91Sp61M.js} +1 -1
  3. package/dist/client/assets/{TerminalPanel-CTS1CgqF.js → TerminalPanel-fYrKc3jh.js} +1 -1
  4. package/dist/client/assets/{UnifiedDiffViewer-Cqke12ZX.js → UnifiedDiffViewer-BafLWGtF.js} +1 -1
  5. package/dist/client/assets/{index-LME0LfPb.js → index-CA8q9_o7.js} +310 -353
  6. package/dist/client/index.html +1 -1
  7. package/dist/pi-web-plugins/relays/relayDiscovery.js +159 -27
  8. package/dist/pi-web-plugins/relays/relaysPanelElement.js +173 -16
  9. package/dist/server/app.js +0 -22
  10. package/dist/server/app.js.map +1 -1
  11. package/dist/server/browserMessageProjection.js +0 -2
  12. package/dist/server/browserMessageProjection.js.map +1 -1
  13. package/dist/server/configRoutes.js +4 -13
  14. package/dist/server/configRoutes.js.map +1 -1
  15. package/dist/server/machines/machineProxyRoutes.js +2 -13
  16. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  17. package/dist/server/piWebStatus.js +12 -38
  18. package/dist/server/piWebStatus.js.map +1 -1
  19. package/dist/server/sessiond/agentHttpDispatcher.js +108 -0
  20. package/dist/server/sessiond/agentHttpDispatcher.js.map +1 -0
  21. package/dist/server/sessiond/agentProcessEnvironment.js +62 -0
  22. package/dist/server/sessiond/agentProcessEnvironment.js.map +1 -0
  23. package/dist/server/sessiond/sessionServiceDependencies.js +1 -0
  24. package/dist/server/sessiond/sessionServiceDependencies.js.map +1 -1
  25. package/dist/server/sessiond.js +26 -5
  26. package/dist/server/sessiond.js.map +1 -1
  27. package/dist/server/sessions/askUserTool.js +0 -3
  28. package/dist/server/sessions/askUserTool.js.map +1 -1
  29. package/dist/server/sessions/authRoutes.js +0 -10
  30. package/dist/server/sessions/authRoutes.js.map +1 -1
  31. package/dist/server/sessions/authService.js +0 -24
  32. package/dist/server/sessions/authService.js.map +1 -1
  33. package/dist/server/sessions/messagePaging.js +2 -2
  34. package/dist/server/sessions/messagePaging.js.map +1 -1
  35. package/dist/server/sessions/oauthLoginFlowService.js +2 -5
  36. package/dist/server/sessions/oauthLoginFlowService.js.map +1 -1
  37. package/dist/server/sessions/pendingAskStore.js +0 -3
  38. package/dist/server/sessions/pendingAskStore.js.map +1 -1
  39. package/dist/server/sessions/piSessionManagerGateway.js +182 -2
  40. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  41. package/dist/server/sessions/piSessionService.js +244 -186
  42. package/dist/server/sessions/piSessionService.js.map +1 -1
  43. package/dist/server/sessions/sessionFileHeader.js +92 -20
  44. package/dist/server/sessions/sessionFileHeader.js.map +1 -1
  45. package/dist/server/sessions/sessionRoutes.js +74 -56
  46. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  47. package/dist/server/sessions/sessionSummaryScanner.js +619 -0
  48. package/dist/server/sessions/sessionSummaryScanner.js.map +1 -0
  49. package/dist/server/sessions/spawnSessionTool.js +8 -1
  50. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  51. package/dist/server/sessions/spawnSubsessionTool.js +7 -1
  52. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  53. package/dist/server/workspaces/effectivePathAccess.js +0 -19
  54. package/dist/server/workspaces/effectivePathAccess.js.map +1 -1
  55. package/dist/server/workspaces/workspaceDeletionRoutes.js +30 -4
  56. package/dist/server/workspaces/workspaceDeletionRoutes.js.map +1 -1
  57. package/dist/server/workspaces/workspaceService.js.map +1 -1
  58. package/dist/sessiond/config.js +2 -2
  59. package/dist/sessiond/config.js.map +1 -1
  60. package/dist/shared/apiTypes.d.ts +12 -29
  61. package/dist/shared/apiTypes.js +7 -16
  62. package/dist/shared/apiTypes.js.map +1 -1
  63. package/dist/shared/capabilities.js +10 -44
  64. package/dist/shared/capabilities.js.map +1 -1
  65. package/dist/shared/federatedRoutes.js +0 -3
  66. package/dist/shared/federatedRoutes.js.map +1 -1
  67. package/docs/config.md +59 -4
  68. package/docs/plugins.md +5 -3
  69. package/package.json +10 -9
@@ -1,6 +1,6 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { readFile, writeFile } from "node:fs/promises";
3
+ import { readFile, stat, writeFile } from "node:fs/promises";
4
4
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, readStoredCredential, SessionManager, } from "@earendil-works/pi-coding-agent";
5
5
  import { projectBrowserMessage } from "../browserMessageProjection.js";
6
6
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
@@ -56,20 +56,36 @@ function spawnTargetError(decision) {
56
56
  return new Error("Spawning session is not in a registered project");
57
57
  return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
58
58
  }
59
- function authLossWarningKey(sessionId, provider, modelId) {
60
- return `${sessionId}:${provider}/${modelId}`;
59
+ function modelSpecOf(model) {
60
+ return `${model.provider}/${model.id}`;
61
61
  }
62
- function sessionIdFromLookup(ref) {
63
- return typeof ref === "string" ? ref : ref.id;
62
+ /**
63
+ * Parse a strict `provider/model-id` spec: split on the first `/` (model ids
64
+ * may themselves contain `/`) and require both parts to be non-empty.
65
+ */
66
+ function parseModelSpec(spec) {
67
+ const slash = spec.indexOf("/");
68
+ if (slash <= 0 || slash === spec.length - 1)
69
+ return undefined;
70
+ return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
64
71
  }
65
- function isPiSessionRef(ref) {
66
- return typeof ref !== "string";
72
+ /**
73
+ * Error for a spawn-tool model spec that matched nothing. States the facts —
74
+ * the bad spec and the required format — with deliberately no model list
75
+ * (a list would invite guesses). The agent loop turns the throw into an
76
+ * error tool result; how to recover is the agent's call.
77
+ */
78
+ function unknownSpawnModelError(modelSpec) {
79
+ return new Error(`Unknown model "${modelSpec}". Pass an exact "provider/model-id".`);
67
80
  }
68
- function lookupMatchesActiveSession(ref, active) {
69
- return !isPiSessionRef(ref) || cwdPathsEqual(active.runtime.cwd, ref.cwd);
81
+ function authLossWarningKey(sessionId, provider, modelId) {
82
+ return `${sessionId}:${provider}/${modelId}`;
70
83
  }
71
- function lookupMatchesStartupSession(ref, session) {
72
- return !isPiSessionRef(ref) || cwdPathsEqual(session.sessionManager.getCwd(), ref.cwd);
84
+ function refMatchesActiveSession(ref, active) {
85
+ return cwdPathsEqual(active.runtime.cwd, ref.cwd);
86
+ }
87
+ function refMatchesStartupSession(ref, session) {
88
+ return cwdPathsEqual(session.sessionManager.getCwd(), ref.cwd);
73
89
  }
74
90
  function sessionTreeNavigationOptions(request) {
75
91
  switch (request.summary.mode) {
@@ -220,26 +236,31 @@ export function dismissSessionWarning(session, dismissId) {
220
236
  function defaultCreateAgentRuntime(createRuntime, options) {
221
237
  if (!(options.sessionManager instanceof SessionManager))
222
238
  throw new Error("Default runtime creation requires an SDK SessionManager");
223
- const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.delegationToolsEnabled);
239
+ const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.initialThinkingLevel, options.delegationToolsEnabled);
224
240
  return createAgentSessionRuntime(runtimeFactory, {
225
241
  cwd: options.cwd,
226
242
  agentDir: options.agentDir,
227
243
  sessionManager: options.sessionManager,
228
244
  });
229
245
  }
230
- function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, delegationToolsEnabled) {
246
+ function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, initialThinkingLevel, delegationToolsEnabled) {
231
247
  // These inputs belong only to the session being opened. A later runtime
232
- // replacement resolves its own model and delegation capability.
248
+ // replacement resolves its own model and delegation capability, and restores
249
+ // the thinking level from the existing session file.
233
250
  let pendingInitialModel = initialModel;
251
+ let pendingInitialThinkingLevel = initialThinkingLevel;
234
252
  let pendingDelegationToolsEnabled = delegationToolsEnabled;
235
253
  return async (options) => {
236
254
  const model = pendingInitialModel;
255
+ const thinkingLevel = pendingInitialThinkingLevel;
237
256
  const toolsEnabled = pendingDelegationToolsEnabled;
238
257
  pendingInitialModel = undefined;
258
+ pendingInitialThinkingLevel = undefined;
239
259
  pendingDelegationToolsEnabled = undefined;
240
260
  return createRuntime({
241
261
  ...options,
242
262
  ...(model === undefined ? {} : { initialModel: model }),
263
+ ...(thinkingLevel === undefined ? {} : { initialThinkingLevel: thinkingLevel }),
243
264
  ...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }),
244
265
  });
245
266
  };
@@ -255,7 +276,7 @@ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn,
255
276
  ];
256
277
  }
257
278
  function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser) {
258
- return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
279
+ return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
259
280
  const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
260
281
  const resolvedDelegationToolsEnabled = delegationToolsEnabled
261
282
  ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
@@ -266,6 +287,7 @@ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subse
266
287
  customTools,
267
288
  ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
268
289
  ...(initialModel === undefined ? {} : { model: initialModel }),
290
+ ...(initialThinkingLevel === undefined ? {} : { thinkingLevel: initialThinkingLevel }),
269
291
  });
270
292
  return { ...result, services, diagnostics: services.diagnostics };
271
293
  };
@@ -325,8 +347,13 @@ export class PiSessionService {
325
347
  this.subsessionLinks = new Map();
326
348
  /** Parent id/file identities whose persisted links have already been loaded. */
327
349
  this.subsessionHydratedParents = new Set();
328
- /** Session file path -> its parsed header. Headers are written once, so successful reads are cached. */
350
+ /**
351
+ * Session file path -> its parsed header and the file identity at read
352
+ * time. Headers are written once, so identity-verified reads are cached.
353
+ */
329
354
  this.sessionHeaderCache = new Map();
355
+ /** Session file path -> its in-flight header read, so concurrent scans share one read per file. */
356
+ this.pendingSessionHeaderReads = new Map();
330
357
  /**
331
358
  * Tracked subsession id -> whether a completion notification is armed.
332
359
  * Armed when the child starts working; firing on completion disarms it so a
@@ -340,18 +367,27 @@ export class PiSessionService {
340
367
  this.unreadPublicationFlushRequested = false;
341
368
  this.unreadPublicationStopped = false;
342
369
  /**
343
- * Read a session file header, memoized per path. Pi writes the header once at
344
- * session creation, so a successful read stays valid for the process lifetime;
345
- * failures are not cached so a session file that appears later is picked up.
370
+ * Read a session file header, memoized per path, with one in-flight read
371
+ * per path: concurrent scans of a shared session directory share the read
372
+ * instead of each opening the file. Pi writes the header once at session
373
+ * creation, so a successful read stays valid as long as the path still
374
+ * holds the same file: every hit re-checks the file's identity (dev/ino),
375
+ * and a replacement landed at a cached path is re-read instead of serving
376
+ * the previous file's header — the summary scanner detects such
377
+ * replacements, so the listing and the header reads must agree. The one
378
+ * thing identity cannot detect is an in-place rewrite that keeps the
379
+ * inode; detach, the only such writer, deletes the entry itself. Failures
380
+ * are not cached so a session file that appears later is picked up.
346
381
  */
347
- this.readCachedSessionHeader = async (sessionFile) => {
348
- const cached = this.sessionHeaderCache.get(sessionFile);
349
- if (cached !== undefined)
350
- return cached;
351
- const header = await readSessionHeaderSummary(sessionFile);
352
- if (header !== undefined)
353
- this.sessionHeaderCache.set(sessionFile, header);
354
- return header;
382
+ this.readCachedSessionHeader = (sessionFile) => {
383
+ const pending = this.pendingSessionHeaderReads.get(sessionFile);
384
+ if (pending !== undefined)
385
+ return pending;
386
+ const read = this.loadSessionHeader(sessionFile).finally(() => {
387
+ this.pendingSessionHeaderReads.delete(sessionFile);
388
+ });
389
+ this.pendingSessionHeaderReads.set(sessionFile, read);
390
+ return read;
355
391
  };
356
392
  this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
357
393
  this.agentDir = deps.agentDir;
@@ -381,7 +417,7 @@ export class PiSessionService {
381
417
  this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
382
418
  this.workspaceActivity = deps.workspaceActivity;
383
419
  this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
384
- this.commandService = new SessionCommandService((sessionId) => this.getActive(sessionId), (sessionId, text) => this.prompt(sessionId, text, undefined, undefined, { echoUserMessage: false }), events, {
420
+ this.commandService = new SessionCommandService((sessionId) => this.getActive(this.activeSessionRef(sessionId)), (sessionId, text) => this.prompt(this.activeSessionRef(sessionId), text, undefined, undefined, { echoUserMessage: false }), events, {
385
421
  onCompactionStart: (session) => {
386
422
  this.beginSessionEntryMutation(session, "compact the session");
387
423
  this.publishActivity(session, "compacting", "active");
@@ -465,7 +501,6 @@ export class PiSessionService {
465
501
  await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
466
502
  readyDeleteRecords.push(record);
467
503
  }
468
- await this.ensureArchivedRecordsMoved(readyDeleteRecords);
469
504
  const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
470
505
  deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
471
506
  await this.forgetUnreadSessions(deleteRecords);
@@ -507,6 +542,7 @@ export class PiSessionService {
507
542
  this.subsessionHydratedParents.clear();
508
543
  this.subsessionNotifyArmed.clear();
509
544
  this.sessionHeaderCache.clear();
545
+ this.pendingSessionHeaderReads.clear();
510
546
  this.notificationStore.clearAll("service-dispose");
511
547
  await Promise.all(activeSessions.map(async (active) => {
512
548
  active.unsubscribe();
@@ -524,9 +560,7 @@ export class PiSessionService {
524
560
  async list(cwd) {
525
561
  const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
526
562
  const sessionsById = new Map(sessions.map((session) => [session.id, session]));
527
- const archivedForCwd = await Promise.all(archivedRecords
528
- .filter((record) => record.cwd === cwd)
529
- .map((record) => this.ensureArchivedSessionMoved(record, sessionsById.get(record.sessionId))));
563
+ const archivedForCwd = archivedRecords.filter((record) => record.cwd === cwd);
530
564
  const archivedById = new Map(archivedForCwd.map((record) => [record.sessionId, record]));
531
565
  for (const record of archivedForCwd) {
532
566
  this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
@@ -576,8 +610,10 @@ export class PiSessionService {
576
610
  *
577
611
  * Only sibling workspaces are scanned: agents may only spawn into workspaces
578
612
  * of the spawning session's own project, so that bounds where a child can be.
579
- * Listing is skipped entirely when no project-workspace locator is configured
580
- * or the cwd belongs to no registered project.
613
+ * Each sibling scan reads only the header line of every session file (the
614
+ * only place `parentSessionPath` is recorded), never the transcripts. Listing
615
+ * is skipped entirely when no project-workspace locator is configured or the
616
+ * cwd belongs to no registered project.
581
617
  */
582
618
  async countChildrenInSiblingWorkspaces(sessions, cwd) {
583
619
  if (this.projectWorkspaces === undefined || sessions.length === 0)
@@ -586,10 +622,7 @@ export class PiSessionService {
586
622
  const siblingCwds = await siblingWorkspaceCwds(this.projectWorkspaces, cwd);
587
623
  if (siblingCwds.length === 0)
588
624
  return new Map();
589
- const listings = await Promise.all(siblingCwds.map(async (siblingCwd) => {
590
- const entries = await this.sessionManager.list(siblingCwd);
591
- return entries.flatMap((entry) => entry.parentSessionPath === undefined ? [] : [entry.parentSessionPath]);
592
- }));
625
+ const listings = await Promise.all(siblingCwds.map((siblingCwd) => this.sessionManager.listParentSessionPaths(siblingCwd, this.readCachedSessionHeader)));
593
626
  return countOutOfListingChildren(sessions, listings.flat());
594
627
  }
595
628
  catch (error) {
@@ -597,6 +630,27 @@ export class PiSessionService {
597
630
  return new Map();
598
631
  }
599
632
  }
633
+ async loadSessionHeader(sessionFile) {
634
+ let stats;
635
+ try {
636
+ stats = await stat(sessionFile);
637
+ }
638
+ catch {
639
+ // Gone or unreadable: report no header and forget any cached entry.
640
+ this.sessionHeaderCache.delete(sessionFile);
641
+ return undefined;
642
+ }
643
+ const cached = this.sessionHeaderCache.get(sessionFile);
644
+ if (cached?.dev === stats.dev && cached.ino === stats.ino)
645
+ return cached.header;
646
+ const header = await readSessionHeaderSummary(sessionFile);
647
+ if (header === undefined) {
648
+ this.sessionHeaderCache.delete(sessionFile);
649
+ return undefined;
650
+ }
651
+ this.sessionHeaderCache.set(sessionFile, { header, dev: stats.dev, ino: stats.ino });
652
+ return header;
653
+ }
600
654
  async start(cwd, options = {}) {
601
655
  return this.startSession(cwd, options);
602
656
  }
@@ -605,6 +659,7 @@ export class PiSessionService {
605
659
  startupIntent: "create",
606
660
  ...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
607
661
  ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
662
+ ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
608
663
  ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
609
664
  });
610
665
  const { session } = active.runtime;
@@ -637,10 +692,23 @@ export class PiSessionService {
637
692
  const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
638
693
  if (!decision.allowed)
639
694
  throw spawnTargetError(decision);
640
- const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
641
- await this.prompt(created.id, input.prompt);
695
+ // A model spec overrides the inherited model. Only a spec resolves
696
+ // against the spawning session; the default path must not depend on it.
697
+ const model = input.modelSpec === undefined
698
+ ? input.model
699
+ : await this.resolveSpawnModel({ id: input.spawningSessionId, cwd: input.spawningCwd }, input.modelSpec);
700
+ const created = await this.start(decision.cwd, {
701
+ ...(model === undefined ? {} : { initialModel: model }),
702
+ ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
703
+ });
704
+ const modelUsed = this.active.get(created.id)?.runtime.session.model;
705
+ await this.prompt(created, input.prompt);
642
706
  this.logger.info({ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_session started a new session");
643
- return { sessionId: created.id, cwd: decision.cwd };
707
+ return {
708
+ sessionId: created.id,
709
+ cwd: decision.cwd,
710
+ ...(modelUsed === undefined ? {} : { model: modelSpecOf(modelUsed) }),
711
+ };
644
712
  }
645
713
  /**
646
714
  * Start a *tracked* child session on behalf of a LLM. Identical to
@@ -654,11 +722,18 @@ export class PiSessionService {
654
722
  const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
655
723
  if (!decision.allowed)
656
724
  throw spawnTargetError(decision);
725
+ // A model spec overrides the inherited model and is resolved against the
726
+ // parent's model runtime; only a spec resolves against the parent.
727
+ const model = input.modelSpec === undefined
728
+ ? input.model
729
+ : await this.resolveSpawnModel({ id: input.parentSessionId, cwd: input.spawningCwd }, input.modelSpec);
657
730
  const created = await this.startSession(decision.cwd, {
658
731
  ...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
659
- ...(input.model === undefined ? {} : { initialModel: input.model }),
732
+ ...(model === undefined ? {} : { initialModel: model }),
733
+ ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
660
734
  creationProvenance: "tracked-subsession",
661
735
  });
736
+ const modelUsed = this.active.get(created.id)?.runtime.session.model;
662
737
  const parentSessionFile = nonEmptyString(input.parentSessionFile);
663
738
  const link = {
664
739
  parentSessionId: input.parentSessionId,
@@ -670,9 +745,43 @@ export class PiSessionService {
670
745
  await this.registerVerifiedSubsession(link);
671
746
  this.persistSubsessionLink(link);
672
747
  this.persistSubsessionChildMarker(input.parentSessionId, created.id);
673
- await this.prompt(created.id, input.prompt);
748
+ await this.prompt(created, input.prompt);
674
749
  this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
675
- return { sessionId: created.id, cwd: decision.cwd };
750
+ return {
751
+ sessionId: created.id,
752
+ cwd: decision.cwd,
753
+ ...(modelUsed === undefined ? {} : { model: modelSpecOf(modelUsed) }),
754
+ };
755
+ }
756
+ /**
757
+ * The models a session may pick from: its scoped set when model-scoped,
758
+ * otherwise the runtime's available snapshot. Refreshes the runtime catalog
759
+ * first so callers see newly configured providers and models.
760
+ */
761
+ async sessionModelCandidates(session) {
762
+ await session.modelRuntime.refresh();
763
+ return session.scopedModels.length > 0
764
+ ? session.scopedModels.map((scoped) => scoped.model)
765
+ : session.modelRuntime.getAvailableSnapshot();
766
+ }
767
+ /**
768
+ * Resolve a strict `provider/model-id` spec from a spawn tool against the
769
+ * *spawning* session's model runtime, using the same candidates
770
+ * {@link setModel} offers plus a direct runtime lookup as fallback. Unknown
771
+ * or malformed specs throw; the agent loop turns that into an error tool
772
+ * result the spawning agent can retry from.
773
+ */
774
+ async resolveSpawnModel(spawningRef, modelSpec) {
775
+ const session = await this.getOrOpen(spawningRef);
776
+ const parsed = parseModelSpec(modelSpec);
777
+ const candidates = await this.sessionModelCandidates(session);
778
+ const model = parsed === undefined
779
+ ? undefined
780
+ : candidates.find((candidate) => candidate.provider === parsed.provider && candidate.id === parsed.modelId)
781
+ ?? session.modelRuntime.getModel(parsed.provider, parsed.modelId);
782
+ if (model === undefined)
783
+ throw unknownSpawnModelError(modelSpec);
784
+ return model;
676
785
  }
677
786
  /**
678
787
  * Register the question set an agent wants the user to answer as the session's
@@ -1240,26 +1349,20 @@ export class PiSessionService {
1240
1349
  const streamingMessage = session.state.streamingMessage;
1241
1350
  const partial = streamingMessage === undefined || streamingMessage === null
1242
1351
  ? null
1243
- : projectBrowserMessage(streamingMessage);
1352
+ : annotateAssistantThinkingLevel(projectBrowserMessage(streamingMessage), session.thinkingLevel);
1244
1353
  return { seq, partial };
1245
1354
  }
1246
1355
  async availableModels(ref) {
1247
1356
  const session = await this.getOrOpen(ref);
1248
- await session.modelRuntime.refresh();
1249
- const models = session.scopedModels.length > 0
1250
- ? session.scopedModels.map((scoped) => scoped.model)
1251
- : session.modelRuntime.getAvailableSnapshot();
1357
+ const models = await this.sessionModelCandidates(session);
1252
1358
  return models.map(modelToClientModel);
1253
1359
  }
1254
1360
  async setModel(ref, provider, modelId) {
1255
1361
  await this.assertWritable(ref);
1256
1362
  const session = await this.getOrOpen(ref);
1257
1363
  this.assertTreeNavigationInactive(session, "change models");
1258
- await session.modelRuntime.refresh();
1364
+ const candidates = await this.sessionModelCandidates(session);
1259
1365
  this.assertTreeNavigationInactive(session, "change models");
1260
- const candidates = session.scopedModels.length > 0
1261
- ? session.scopedModels.map((scoped) => scoped.model)
1262
- : session.modelRuntime.getAvailableSnapshot();
1263
1366
  const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
1264
1367
  ?? session.modelRuntime.getModel(provider, modelId);
1265
1368
  if (model === undefined)
@@ -1432,7 +1535,7 @@ export class PiSessionService {
1432
1535
  async navigateTree(ref, request) {
1433
1536
  if (request.targetId.trim() === "")
1434
1537
  throw new Error("Session tree target is required");
1435
- if (this.isTreeExclusiveSessionIdentityActive(sessionIdFromLookup(ref))) {
1538
+ if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
1436
1539
  throw new Error("Stop current session activity before navigating the session tree");
1437
1540
  }
1438
1541
  await this.assertWritable(ref);
@@ -1531,7 +1634,7 @@ export class PiSessionService {
1531
1634
  const uniqueRefs = uniqueBulkSessionRefs(refs);
1532
1635
  const [archivedRecords, sessionContext] = await Promise.all([
1533
1636
  this.archiveStore.list(),
1534
- this.bulkSessionLookupContext(uniqueRefs),
1637
+ this.bulkSessionRefContext(uniqueRefs),
1535
1638
  ]);
1536
1639
  const failures = [];
1537
1640
  const alreadyArchivedSessionIds = [];
@@ -1545,7 +1648,7 @@ export class PiSessionService {
1545
1648
  unreadArchivedIdentities.push(archived);
1546
1649
  continue;
1547
1650
  }
1548
- const active = this.activeForLookup(bulkRefToLookup(ref));
1651
+ const active = this.activeForRef(bulkRefToSessionRef(ref));
1549
1652
  const listed = findListedSessionForBulkRef(sessionContext, ref);
1550
1653
  const resolvedSessionId = active?.runtime.session.sessionId ?? listed?.id ?? ref.id;
1551
1654
  if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
@@ -1569,7 +1672,7 @@ export class PiSessionService {
1569
1672
  }
1570
1673
  const readyPlanItems = [];
1571
1674
  for (const item of planItems) {
1572
- const active = this.activeForLookup({ id: item.input.sessionId, cwd: item.input.cwd });
1675
+ const active = this.activeForRef({ id: item.input.sessionId, cwd: item.input.cwd });
1573
1676
  if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1574
1677
  failures.push({ sessionId: item.input.sessionId, error: "Stop current session activity before archiving" });
1575
1678
  continue;
@@ -1646,18 +1749,6 @@ export class PiSessionService {
1646
1749
  await this.archiveStore.restore(archived.sessionId);
1647
1750
  await this.forgetUnreadSessions([archived]);
1648
1751
  }
1649
- async deleteArchived(ref) {
1650
- const record = await this.getArchived(ref);
1651
- if (record === undefined)
1652
- throw new Error("Archived session not found");
1653
- if (this.archiveStore.deleteArchived === undefined)
1654
- throw new Error("Archive store does not support deletion");
1655
- await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
1656
- if (record.archivePath === undefined)
1657
- await this.ensureArchivedRecordMoved(record);
1658
- await this.archiveStore.deleteArchived(record.sessionId);
1659
- await this.forgetUnreadSessions([record]);
1660
- }
1661
1752
  async deleteArchivedMany(refs) {
1662
1753
  if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
1663
1754
  throw new Error("Archive store does not support deletion");
@@ -1671,7 +1762,7 @@ export class PiSessionService {
1671
1762
  failures.push({ sessionId: ref.id, error: "Archived session not found" });
1672
1763
  continue;
1673
1764
  }
1674
- const active = this.activeForLookup({ id: record.sessionId, cwd: record.cwd });
1765
+ const active = this.activeForRef({ id: record.sessionId, cwd: record.cwd });
1675
1766
  if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1676
1767
  failures.push({ sessionId: record.sessionId, error: "Stop current session activity before deleting archived session" });
1677
1768
  continue;
@@ -1688,12 +1779,7 @@ export class PiSessionService {
1688
1779
  failures.push({ sessionId: item.record.sessionId, error: errorMessage(error) });
1689
1780
  }
1690
1781
  }
1691
- const moveFailures = await this.moveLegacyArchivedRecordsForDelete(readyRecords);
1692
- failures.push(...moveFailures);
1693
- const moveFailureIds = new Set(moveFailures.map((failure) => failure.sessionId));
1694
- const deleteIds = readyRecords
1695
- .map((record) => record.sessionId)
1696
- .filter((sessionId) => !moveFailureIds.has(sessionId));
1782
+ const deleteIds = readyRecords.map((record) => record.sessionId);
1697
1783
  let deletedSessionIds = [];
1698
1784
  try {
1699
1785
  deletedSessionIds = await this.archiveStoreDeleteArchivedMany(deleteIds);
@@ -1746,6 +1832,14 @@ export class PiSessionService {
1746
1832
  if (sessionFile === undefined || sessionFile === "")
1747
1833
  throw new Error("Session is not persisted");
1748
1834
  await clearParentSession(sessionFile);
1835
+ // The header cache memoizes the first successful read, and detach is the
1836
+ // only flow that rewrites a header; drop the stale entry so sibling child
1837
+ // counts and parent locations observe the cleared parent immediately.
1838
+ this.sessionHeaderCache.delete(sessionFile);
1839
+ // The same in-place rewrite is invisible to the gateway's summary memo
1840
+ // (same inode, possibly grown file), which would otherwise keep listing
1841
+ // the old parent link and a stale message count until restart.
1842
+ this.sessionManager.invalidateSessionFile(sessionFile);
1749
1843
  clearParentSessionHeader(session.sessionManager);
1750
1844
  this.unregisterSubsession(session.sessionId);
1751
1845
  await this.forgetUnreadSessions([{ sessionId: session.sessionId, cwd: session.sessionManager.getCwd() }]);
@@ -1765,7 +1859,7 @@ export class PiSessionService {
1765
1859
  return this.statusFromSession(session);
1766
1860
  }
1767
1861
  async abort(ref) {
1768
- const active = this.activeForLookup(ref);
1862
+ const active = this.activeForRef(ref);
1769
1863
  if (active === undefined)
1770
1864
  return;
1771
1865
  const sessionId = active.runtime.session.sessionId;
@@ -1791,31 +1885,26 @@ export class PiSessionService {
1791
1885
  }
1792
1886
  }
1793
1887
  async stop(ref) {
1794
- const active = this.activeForLookup(ref);
1888
+ const active = this.activeForRef(ref);
1795
1889
  if (active !== undefined) {
1796
1890
  await this.closeActive(active.runtime.session.sessionId);
1797
1891
  return;
1798
1892
  }
1799
- if (isPiSessionRef(ref)) {
1800
- this.publishNotificationMutations(this.notificationStore.clearSessionIdentity(ref.id, canonicalizeStoredCwd(ref.cwd), "runtime-close"));
1893
+ // A session whose open is parked (e.g. on a session_start dialog) is not
1894
+ // active yet; close it through the same path so stopping cannot block
1895
+ // behind the dialog timeout.
1896
+ const startup = this.startupSessionForRef(ref);
1897
+ if (startup !== undefined) {
1898
+ await this.closeActive(startup.sessionId);
1801
1899
  return;
1802
1900
  }
1803
- await this.closeActive(ref);
1901
+ this.publishNotificationMutations(this.notificationStore.clearSessionIdentity(ref.id, canonicalizeStoredCwd(ref.cwd), "runtime-close"));
1804
1902
  }
1805
- async bulkSessionLookupContext(refs) {
1903
+ async bulkSessionRefContext(refs) {
1806
1904
  const cwdSet = new Set();
1807
- let needsAllSessions = false;
1808
- for (const ref of refs) {
1809
- if (ref.cwd === undefined)
1810
- needsAllSessions = true;
1811
- else
1812
- cwdSet.add(ref.cwd);
1813
- }
1814
- const [sessionsByCwd, allSessions] = await Promise.all([
1815
- this.listSessionsByCwd([...cwdSet]),
1816
- needsAllSessions ? this.sessionManager.listAll?.() ?? Promise.resolve([]) : Promise.resolve(undefined),
1817
- ]);
1818
- return allSessions === undefined ? { sessionsByCwd } : { sessionsByCwd, allSessions };
1905
+ for (const ref of refs)
1906
+ cwdSet.add(ref.cwd);
1907
+ return { sessionsByCwd: await this.listSessionsByCwd([...cwdSet]) };
1819
1908
  }
1820
1909
  async listSessionsByCwd(cwds) {
1821
1910
  const uniqueCwds = uniqueStrings(cwds);
@@ -1843,36 +1932,8 @@ export class PiSessionService {
1843
1932
  await this.archiveStore.deleteArchived(sessionId);
1844
1933
  return [...sessionIds];
1845
1934
  }
1846
- async moveLegacyArchivedRecordsForDelete(records) {
1847
- const legacyRecords = records.filter((record) => record.archivePath === undefined);
1848
- if (legacyRecords.length === 0)
1849
- return [];
1850
- let sessionsByCwd;
1851
- try {
1852
- sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
1853
- }
1854
- catch (error) {
1855
- return legacyRecords.map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
1856
- }
1857
- const moveInputs = legacyRecords
1858
- .map((record) => findSessionByIdOrPrefix(sessionsByCwd.get(record.cwd) ?? [], record.sessionId))
1859
- .filter(isDefined)
1860
- .map(archiveInputFromListEntry);
1861
- if (moveInputs.length === 0)
1862
- return [];
1863
- try {
1864
- await this.archiveStoreArchiveMany(moveInputs);
1865
- return [];
1866
- }
1867
- catch (error) {
1868
- const failedIds = new Set(moveInputs.map((input) => input.sessionId));
1869
- return legacyRecords
1870
- .filter((record) => failedIds.has(record.sessionId))
1871
- .map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
1872
- }
1873
- }
1874
1935
  async cleanupPlan(request) {
1875
- const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll?.() ?? [], this.archiveStore.list()]);
1936
+ const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll(), this.archiveStore.list()]);
1876
1937
  return planSessionCleanup({
1877
1938
  sessions,
1878
1939
  archivedRecords,
@@ -1901,34 +1962,6 @@ export class PiSessionService {
1901
1962
  }
1902
1963
  return [...sessionIds];
1903
1964
  }
1904
- async ensureArchivedSessionMoved(record, session) {
1905
- if (session === undefined || this.active.has(record.sessionId))
1906
- return record;
1907
- try {
1908
- return await this.archiveStore.archive(archiveInputFromListEntry(session));
1909
- }
1910
- catch {
1911
- return record;
1912
- }
1913
- }
1914
- async ensureArchivedRecordMoved(record) {
1915
- const session = (await this.sessionManager.list(record.cwd)).find((candidate) => candidate.id === record.sessionId);
1916
- if (session === undefined)
1917
- return record;
1918
- const [moved] = await this.archiveStoreArchiveMany([archiveInputFromListEntry(session)]);
1919
- return moved ?? record;
1920
- }
1921
- async ensureArchivedRecordsMoved(records) {
1922
- const legacyRecords = records.filter((record) => record.archivePath === undefined);
1923
- if (legacyRecords.length === 0)
1924
- return;
1925
- const sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
1926
- const moveInputs = legacyRecords
1927
- .map((record) => sessionsByCwd.get(record.cwd)?.find((candidate) => candidate.id === record.sessionId))
1928
- .filter(isDefined)
1929
- .map(archiveInputFromListEntry);
1930
- await this.archiveStoreArchiveMany(moveInputs);
1931
- }
1932
1965
  async archiveInputForSession(session) {
1933
1966
  const cwd = session.sessionManager.getCwd();
1934
1967
  const sessionFile = session.sessionFile;
@@ -2065,7 +2098,7 @@ export class PiSessionService {
2065
2098
  return (await this.getActive(ref)).runtime.session;
2066
2099
  }
2067
2100
  async getActive(ref, options = {}) {
2068
- const active = this.activeForLookup(ref);
2101
+ const active = this.activeForRef(ref);
2069
2102
  if (active !== undefined)
2070
2103
  return active;
2071
2104
  const archived = await this.getArchived(ref);
@@ -2073,15 +2106,15 @@ export class PiSessionService {
2073
2106
  const { archivePath } = archived;
2074
2107
  return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath), { notifications: "disabled" });
2075
2108
  }
2076
- const match = isPiSessionRef(ref)
2077
- ? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
2078
- : (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
2109
+ // Resolve the session file directly by id: opening one inactive session
2110
+ // must not pay for a full transcript listing of its whole workspace.
2111
+ const match = await this.sessionManager.resolveSessionFile(ref.cwd, ref.id, this.readCachedSessionHeader);
2079
2112
  if (!match)
2080
2113
  throw new Error("Session not found");
2081
2114
  return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
2082
2115
  }
2083
2116
  openExistingSession(sessionId, cwd, openSessionManager, options = {}) {
2084
- const active = this.activeForLookup({ id: sessionId, cwd });
2117
+ const active = this.activeForRef({ id: sessionId, cwd });
2085
2118
  if (active !== undefined)
2086
2119
  return Promise.resolve(active);
2087
2120
  const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionId]);
@@ -2105,34 +2138,45 @@ export class PiSessionService {
2105
2138
  .map((pending) => pending.promise);
2106
2139
  }
2107
2140
  async getArchived(ref) {
2108
- const archived = await this.archiveStore.get(sessionIdFromLookup(ref));
2141
+ const archived = await this.archiveStore.get(ref.id);
2109
2142
  if (archived === undefined)
2110
2143
  return undefined;
2111
- if (isPiSessionRef(ref) && archived.cwd !== ref.cwd)
2144
+ if (archived.cwd !== ref.cwd)
2112
2145
  return undefined;
2113
2146
  return archived;
2114
2147
  }
2115
2148
  isCurrentActiveSession(session) {
2116
2149
  return this.active.get(session.sessionId)?.runtime.session === session;
2117
2150
  }
2118
- activeForLookup(ref) {
2119
- const sessionId = sessionIdFromLookup(ref);
2151
+ /**
2152
+ * The command service tracks sessions by id alone; its callbacks only ever
2153
+ * run against a session the caller just resolved as active, so the cwd
2154
+ * needed for a full ref comes from that active runtime.
2155
+ */
2156
+ activeSessionRef(sessionId) {
2157
+ const active = this.active.get(sessionId);
2158
+ if (active === undefined)
2159
+ throw new Error("Session not found");
2160
+ return { id: sessionId, cwd: active.runtime.cwd };
2161
+ }
2162
+ activeForRef(ref) {
2163
+ const sessionId = ref.id;
2120
2164
  const exact = this.active.get(sessionId);
2121
- if (exact !== undefined && lookupMatchesActiveSession(ref, exact))
2165
+ if (exact !== undefined && refMatchesActiveSession(ref, exact))
2122
2166
  return exact;
2123
2167
  for (const [candidateId, active] of this.active.entries()) {
2124
- if (candidateId.startsWith(sessionId) && lookupMatchesActiveSession(ref, active))
2168
+ if (candidateId.startsWith(sessionId) && refMatchesActiveSession(ref, active))
2125
2169
  return active;
2126
2170
  }
2127
2171
  return undefined;
2128
2172
  }
2129
- startupSessionForLookup(ref) {
2130
- const sessionId = sessionIdFromLookup(ref);
2173
+ startupSessionForRef(ref) {
2174
+ const sessionId = ref.id;
2131
2175
  const exact = this.startupSessions.get(sessionId);
2132
- if (exact !== undefined && lookupMatchesStartupSession(ref, exact))
2176
+ if (exact !== undefined && refMatchesStartupSession(ref, exact))
2133
2177
  return exact;
2134
2178
  for (const [candidateId, session] of this.startupSessions.entries()) {
2135
- if (candidateId.startsWith(sessionId) && lookupMatchesStartupSession(ref, session))
2179
+ if (candidateId.startsWith(sessionId) && refMatchesStartupSession(ref, session))
2136
2180
  return session;
2137
2181
  }
2138
2182
  return undefined;
@@ -2144,7 +2188,7 @@ export class PiSessionService {
2144
2188
  * its status projection).
2145
2189
  */
2146
2190
  async sessionForStatusOrDialogClose(ref) {
2147
- const reachable = this.activeForLookup(ref)?.runtime.session ?? this.startupSessionForLookup(ref);
2191
+ const reachable = this.activeForRef(ref)?.runtime.session ?? this.startupSessionForRef(ref);
2148
2192
  if (reachable !== undefined)
2149
2193
  return reachable;
2150
2194
  return this.getOrOpen(ref);
@@ -2175,6 +2219,7 @@ export class PiSessionService {
2175
2219
  sessionManager,
2176
2220
  delegationToolsEnabled,
2177
2221
  ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
2222
+ ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
2178
2223
  });
2179
2224
  const active = { runtime, unsubscribe: noop };
2180
2225
  let boundSession = runtime.session;
@@ -2322,14 +2367,6 @@ export class PiSessionService {
2322
2367
  }
2323
2368
  const added = this.notificationStore.addNotification(generation, message, type);
2324
2369
  this.publishNotificationMutations(added.mutations);
2325
- if (added.notification === undefined)
2326
- return;
2327
- this.events.publish(session.sessionId, {
2328
- type: "command.output",
2329
- level: type === "error" ? "error" : "info",
2330
- message,
2331
- notificationId: added.notification.id,
2332
- });
2333
2370
  };
2334
2371
  // PI WEB owns the browser-facing dialog, notification, and text-formatting
2335
2372
  // boundaries: the three dialog primitives park daemon-held Promises that
@@ -2476,7 +2513,7 @@ export class PiSessionService {
2476
2513
  }
2477
2514
  }
2478
2515
  active.unsubscribe = session.subscribe((event) => {
2479
- this.events.publish(session.sessionId, toClientEvent(event));
2516
+ this.events.publish(session.sessionId, toClientEvent(event, session.thinkingLevel));
2480
2517
  this.publishActivityForEvent(session, event);
2481
2518
  const eventType = getString(event, "type");
2482
2519
  if (eventType === "agent_end")
@@ -2957,7 +2994,7 @@ function uniqueBulkSessionRefs(refs) {
2957
2994
  const seen = new Set();
2958
2995
  const unique = [];
2959
2996
  for (const ref of refs) {
2960
- const key = `${ref.cwd ?? ""}\0${ref.id}`;
2997
+ const key = `${ref.cwd}\0${ref.id}`;
2961
2998
  if (seen.has(key))
2962
2999
  continue;
2963
3000
  seen.add(key);
@@ -2965,16 +3002,14 @@ function uniqueBulkSessionRefs(refs) {
2965
3002
  }
2966
3003
  return unique;
2967
3004
  }
2968
- function bulkRefToLookup(ref) {
2969
- return ref.cwd === undefined ? ref.id : { id: ref.id, cwd: ref.cwd };
3005
+ function bulkRefToSessionRef(ref) {
3006
+ return { id: ref.id, cwd: ref.cwd };
2970
3007
  }
2971
3008
  function findArchivedRecordForBulkRef(records, ref) {
2972
- return records.find((record) => (ref.cwd === undefined || record.cwd === ref.cwd) && (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
3009
+ return records.find((record) => record.cwd === ref.cwd && (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
2973
3010
  }
2974
3011
  function findListedSessionForBulkRef(context, ref) {
2975
- if (ref.cwd !== undefined)
2976
- return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
2977
- return context.allSessions === undefined ? undefined : findSessionByIdOrPrefix(context.allSessions, ref.id);
3012
+ return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
2978
3013
  }
2979
3014
  function findSessionByIdOrPrefix(sessions, sessionId) {
2980
3015
  return sessions.find((session) => session.id === sessionId) ?? sessions.find((session) => session.id.startsWith(sessionId));
@@ -3357,13 +3392,34 @@ function buildPromptOptions(behavior, images) {
3357
3392
  function stringValue(value) {
3358
3393
  return typeof value === "string" ? value : "";
3359
3394
  }
3395
+ /**
3396
+ * Attach the thinking level in effect when an assistant message was generated,
3397
+ * so chat bubbles can show it next to the model. Non-assistant messages pass
3398
+ * through by reference; assistant messages are copied only when a level is set.
3399
+ * "off" is the absence of thinking, not a level worth labeling on every bubble.
3400
+ */
3401
+ function annotateAssistantThinkingLevel(message, thinkingLevel) {
3402
+ if (thinkingLevel === undefined || thinkingLevel === "" || thinkingLevel === "off")
3403
+ return message;
3404
+ if (!isRecord(message) || message["role"] !== "assistant")
3405
+ return message;
3406
+ return { ...message, thinkingLevel };
3407
+ }
3360
3408
  function historyMessages(session) {
3361
3409
  const messages = [];
3410
+ // Pi records the initial level at session creation and every later change, so
3411
+ // walking the branch yields the level in effect for each assistant message.
3412
+ let thinkingLevel;
3362
3413
  for (const entry of session.sessionManager.getBranch()) {
3363
3414
  if (!isRecord(entry))
3364
3415
  continue;
3365
3416
  if (entry["type"] === "message")
3366
- messages.push(entry["message"]);
3417
+ messages.push(annotateAssistantThinkingLevel(entry["message"], thinkingLevel));
3418
+ else if (entry["type"] === "thinking_level_change") {
3419
+ const level = getString(entry, "thinkingLevel");
3420
+ if (level !== undefined)
3421
+ thinkingLevel = level;
3422
+ }
3367
3423
  else if (entry["type"] === "custom_message" && entry["display"] === true)
3368
3424
  messages.push({ role: "custom", content: entry["content"], customType: entry["customType"], details: entry["details"] });
3369
3425
  else if (entry["type"] === "compaction")
@@ -3408,7 +3464,7 @@ function finalAssistantText(messages) {
3408
3464
  }
3409
3465
  return "";
3410
3466
  }
3411
- function toClientEvent(event) {
3467
+ function toClientEvent(event, thinkingLevel) {
3412
3468
  const eventType = getString(event, "type");
3413
3469
  const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
3414
3470
  if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
@@ -3435,7 +3491,9 @@ function toClientEvent(event) {
3435
3491
  return { type: "agent.end" };
3436
3492
  if (eventType === "message_end") {
3437
3493
  const message = getProperty(event, "message");
3438
- return message === undefined ? { type: "message.end" } : { type: "message.end", message };
3494
+ if (message === undefined)
3495
+ return { type: "message.end" };
3496
+ return { type: "message.end", message: annotateAssistantThinkingLevel(message, thinkingLevel) };
3439
3497
  }
3440
3498
  return { type: "pi.event", eventType: eventType ?? "unknown" };
3441
3499
  }