@jmfederico/pi-web 1.202607.2 → 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 (103) hide show
  1. package/README.md +1 -1
  2. package/dist/client/assets/{CodeViewer-B4M13447.js → CodeViewer-D91Sp61M.js} +1 -1
  3. package/dist/client/assets/{TerminalPanel-Bmvj-Ecn.js → TerminalPanel-fYrKc3jh.js} +2 -2
  4. package/dist/client/assets/{UnifiedDiffViewer-C0hygwRo.js → UnifiedDiffViewer-BafLWGtF.js} +1 -1
  5. package/dist/client/assets/index-CA8q9_o7.js +4025 -0
  6. package/dist/client/index.html +1 -1
  7. package/dist/config.js +39 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/pi-web-plugins/info/infoInternals.js +208 -0
  10. package/dist/pi-web-plugins/info/pi-web-plugin.js +12 -15
  11. package/dist/pi-web-plugins/relays/markdownDocument.js +81 -0
  12. package/dist/pi-web-plugins/relays/package.json +9 -0
  13. package/dist/pi-web-plugins/relays/pi-web-plugin.js +44 -0
  14. package/dist/pi-web-plugins/relays/relayDiscovery.js +228 -0
  15. package/dist/pi-web-plugins/relays/relaysPanelElement.js +550 -0
  16. package/dist/pi-web-plugins/relays/vendor/README.md +24 -0
  17. package/dist/pi-web-plugins/relays/vendor/marked.esm.js +76 -0
  18. package/dist/plugin-api.d.ts +7 -1
  19. package/dist/server/app.js +0 -22
  20. package/dist/server/app.js.map +1 -1
  21. package/dist/server/browserMessageProjection.js +0 -2
  22. package/dist/server/browserMessageProjection.js.map +1 -1
  23. package/dist/server/configRoutes.js +13 -11
  24. package/dist/server/configRoutes.js.map +1 -1
  25. package/dist/server/machines/machineProxyRoutes.js +2 -13
  26. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  27. package/dist/server/piWebStatus.js +12 -38
  28. package/dist/server/piWebStatus.js.map +1 -1
  29. package/dist/server/sessiond/agentHttpDispatcher.js +108 -0
  30. package/dist/server/sessiond/agentHttpDispatcher.js.map +1 -0
  31. package/dist/server/sessiond/agentProcessEnvironment.js +62 -0
  32. package/dist/server/sessiond/agentProcessEnvironment.js.map +1 -0
  33. package/dist/server/sessiond/sessionServiceDependencies.js +33 -0
  34. package/dist/server/sessiond/sessionServiceDependencies.js.map +1 -0
  35. package/dist/server/sessiond.js +145 -116
  36. package/dist/server/sessiond.js.map +1 -1
  37. package/dist/server/sessions/askUserTool.js +102 -0
  38. package/dist/server/sessions/askUserTool.js.map +1 -0
  39. package/dist/server/sessions/authRoutes.js +0 -10
  40. package/dist/server/sessions/authRoutes.js.map +1 -1
  41. package/dist/server/sessions/authService.js +0 -24
  42. package/dist/server/sessions/authService.js.map +1 -1
  43. package/dist/server/sessions/extensionDialogWaiters.js +76 -0
  44. package/dist/server/sessions/extensionDialogWaiters.js.map +1 -0
  45. package/dist/server/sessions/globalProviderPolicy.js +79 -10
  46. package/dist/server/sessions/globalProviderPolicy.js.map +1 -1
  47. package/dist/server/sessions/messagePaging.js +2 -2
  48. package/dist/server/sessions/messagePaging.js.map +1 -1
  49. package/dist/server/sessions/modelCatalogRefresher.js +8 -0
  50. package/dist/server/sessions/modelCatalogRefresher.js.map +1 -1
  51. package/dist/server/sessions/oauthLoginFlowService.js +2 -5
  52. package/dist/server/sessions/oauthLoginFlowService.js.map +1 -1
  53. package/dist/server/sessions/parentSessionLocator.js +75 -0
  54. package/dist/server/sessions/parentSessionLocator.js.map +1 -0
  55. package/dist/server/sessions/pendingAskStore.js +275 -0
  56. package/dist/server/sessions/pendingAskStore.js.map +1 -0
  57. package/dist/server/sessions/pendingExtensionDialogStore.js +196 -0
  58. package/dist/server/sessions/pendingExtensionDialogStore.js.map +1 -0
  59. package/dist/server/sessions/piSessionManagerGateway.js +182 -2
  60. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  61. package/dist/server/sessions/piSessionService.js +721 -203
  62. package/dist/server/sessions/piSessionService.js.map +1 -1
  63. package/dist/server/sessions/sessionFileHeader.js +117 -0
  64. package/dist/server/sessions/sessionFileHeader.js.map +1 -0
  65. package/dist/server/sessions/sessionRoutes.js +169 -54
  66. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  67. package/dist/server/sessions/sessionSummaryScanner.js +619 -0
  68. package/dist/server/sessions/sessionSummaryScanner.js.map +1 -0
  69. package/dist/server/sessions/spawnSessionTool.js +8 -1
  70. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  71. package/dist/server/sessions/spawnSubsessionTool.js +7 -1
  72. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  73. package/dist/server/sessions/spawnTargetResolver.js +3 -16
  74. package/dist/server/sessions/spawnTargetResolver.js.map +1 -1
  75. package/dist/server/workspaces/effectivePathAccess.js +0 -19
  76. package/dist/server/workspaces/effectivePathAccess.js.map +1 -1
  77. package/dist/server/workspaces/gitWorktreeDiscovery.js +9 -0
  78. package/dist/server/workspaces/gitWorktreeDiscovery.js.map +1 -1
  79. package/dist/server/workspaces/projectWorkspaceCwds.js +22 -0
  80. package/dist/server/workspaces/projectWorkspaceCwds.js.map +1 -0
  81. package/dist/server/workspaces/workspaceDeletionRoutes.js +30 -4
  82. package/dist/server/workspaces/workspaceDeletionRoutes.js.map +1 -1
  83. package/dist/server/workspaces/workspaceService.js +15 -2
  84. package/dist/server/workspaces/workspaceService.js.map +1 -1
  85. package/dist/sessiond/config.js +2 -2
  86. package/dist/sessiond/config.js.map +1 -1
  87. package/dist/shared/activity.js +9 -1
  88. package/dist/shared/activity.js.map +1 -1
  89. package/dist/shared/apiTypes.d.ts +300 -24
  90. package/dist/shared/apiTypes.js +30 -15
  91. package/dist/shared/apiTypes.js.map +1 -1
  92. package/dist/shared/capabilities.js +10 -41
  93. package/dist/shared/capabilities.js.map +1 -1
  94. package/dist/shared/federatedRoutes.js +4 -3
  95. package/dist/shared/federatedRoutes.js.map +1 -1
  96. package/docs/config.md +115 -11
  97. package/docs/plugins.md +82 -14
  98. package/package.json +11 -9
  99. package/dist/client/assets/index-TvPsRyS1.js +0 -3545
  100. package/dist/server/sessiond/sessionDaemonStartup.js +0 -49
  101. package/dist/server/sessiond/sessionDaemonStartup.js.map +0 -1
  102. package/dist/server/sessions/sessionArchiveMigration.js +0 -590
  103. package/dist/server/sessions/sessionArchiveMigration.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { open, 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";
@@ -13,8 +13,16 @@ import { deterministicSessionName, fallbackSessionName, generateShortSessionName
13
13
  import { computeEditPreview } from "./editPreview.js";
14
14
  import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
15
15
  import { parsePromptAttachments } from "../../shared/promptAttachments.js";
16
- import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
16
+ import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
17
17
  import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
18
+ import { readSessionHeaderSummary } from "./sessionFileHeader.js";
19
+ import { countOutOfListingChildren, locateOutOfListingParents } from "./parentSessionLocator.js";
20
+ import { siblingWorkspaceCwds } from "../workspaces/projectWorkspaceCwds.js";
21
+ import { createAskUserToolDefinition } from "./askUserTool.js";
22
+ import { PendingAskStore, renderAskUserAnswersText } from "./pendingAskStore.js";
23
+ import { PendingExtensionDialogStore } from "./pendingExtensionDialogStore.js";
24
+ import { ExtensionDialogWaiters, effectiveExtensionDialogTimeoutMs, extensionDialogCancelValue } from "./extensionDialogWaiters.js";
25
+ import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS } from "../../config.js";
18
26
  import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
19
27
  import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
20
28
  import { buildTranscriptView } from "./subsessionTranscript.js";
@@ -24,6 +32,20 @@ import { plainTextTheme } from "./plainTextTheme.js";
24
32
  import { SessionUnreadStore } from "./sessionUnreadStore.js";
25
33
  const noopLogger = { info() { } };
26
34
  const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
35
+ /**
36
+ * User-facing names for the two phases of session startup PI WEB can prove it
37
+ * is inside: it awaits exactly one call for each, so the phase is a fact rather
38
+ * than a guess. Deliberately free of internal symbol names and file paths.
39
+ */
40
+ const STARTUP_PHASE_RUNTIME = "Starting the Pi session";
41
+ const STARTUP_PHASE_EXTENSIONS = "Loading session extensions";
42
+ /**
43
+ * Appended to whichever phase is running when a background provider catalog
44
+ * refresh happens to be in flight. It is stated as a concurrent fact, never as
45
+ * the cause: PI WEB can verify that a refresh is running, but not that this
46
+ * particular startup is waiting on it.
47
+ */
48
+ const STARTUP_CONCURRENT_CATALOG_REFRESH = "provider model lists are refreshing";
27
49
  const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
28
50
  const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
29
51
  function noop() {
@@ -34,17 +56,36 @@ function spawnTargetError(decision) {
34
56
  return new Error("Spawning session is not in a registered project");
35
57
  return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
36
58
  }
59
+ function modelSpecOf(model) {
60
+ return `${model.provider}/${model.id}`;
61
+ }
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) };
71
+ }
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".`);
80
+ }
37
81
  function authLossWarningKey(sessionId, provider, modelId) {
38
82
  return `${sessionId}:${provider}/${modelId}`;
39
83
  }
40
- function sessionIdFromLookup(ref) {
41
- return typeof ref === "string" ? ref : ref.id;
42
- }
43
- function isPiSessionRef(ref) {
44
- return typeof ref !== "string";
84
+ function refMatchesActiveSession(ref, active) {
85
+ return cwdPathsEqual(active.runtime.cwd, ref.cwd);
45
86
  }
46
- function lookupMatchesActiveSession(ref, active) {
47
- return !isPiSessionRef(ref) || cwdPathsEqual(active.runtime.cwd, ref.cwd);
87
+ function refMatchesStartupSession(ref, session) {
88
+ return cwdPathsEqual(session.sessionManager.getCwd(), ref.cwd);
48
89
  }
49
90
  function sessionTreeNavigationOptions(request) {
50
91
  switch (request.summary.mode) {
@@ -195,49 +236,58 @@ export function dismissSessionWarning(session, dismissId) {
195
236
  function defaultCreateAgentRuntime(createRuntime, options) {
196
237
  if (!(options.sessionManager instanceof SessionManager))
197
238
  throw new Error("Default runtime creation requires an SDK SessionManager");
198
- const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.delegationToolsEnabled);
239
+ const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.initialThinkingLevel, options.delegationToolsEnabled);
199
240
  return createAgentSessionRuntime(runtimeFactory, {
200
241
  cwd: options.cwd,
201
242
  agentDir: options.agentDir,
202
243
  sessionManager: options.sessionManager,
203
244
  });
204
245
  }
205
- function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, delegationToolsEnabled) {
246
+ function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, initialThinkingLevel, delegationToolsEnabled) {
206
247
  // These inputs belong only to the session being opened. A later runtime
207
- // 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.
208
250
  let pendingInitialModel = initialModel;
251
+ let pendingInitialThinkingLevel = initialThinkingLevel;
209
252
  let pendingDelegationToolsEnabled = delegationToolsEnabled;
210
253
  return async (options) => {
211
254
  const model = pendingInitialModel;
255
+ const thinkingLevel = pendingInitialThinkingLevel;
212
256
  const toolsEnabled = pendingDelegationToolsEnabled;
213
257
  pendingInitialModel = undefined;
258
+ pendingInitialThinkingLevel = undefined;
214
259
  pendingDelegationToolsEnabled = undefined;
215
260
  return createRuntime({
216
261
  ...options,
217
262
  ...(model === undefined ? {} : { initialModel: model }),
263
+ ...(thinkingLevel === undefined ? {} : { initialThinkingLevel: thinkingLevel }),
218
264
  ...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }),
219
265
  });
220
266
  };
221
267
  }
222
- export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions) {
268
+ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, askUser) {
223
269
  return [
224
270
  createPiWebEditToolDefinition(cwd),
225
271
  ...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
226
272
  ...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
273
+ // Asking the user is not delegation: the questions land in the session the
274
+ // user is already watching, so tracked children may ask too.
275
+ ...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
227
276
  ];
228
277
  }
229
- function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions) {
230
- return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
278
+ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser) {
279
+ return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
231
280
  const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
232
281
  const resolvedDelegationToolsEnabled = delegationToolsEnabled
233
282
  ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
234
- const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
283
+ const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
235
284
  const result = await createAgentSessionFromServices({
236
285
  services,
237
286
  sessionManager,
238
287
  customTools,
239
288
  ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
240
289
  ...(initialModel === undefined ? {} : { model: initialModel }),
290
+ ...(initialThinkingLevel === undefined ? {} : { thinkingLevel: initialThinkingLevel }),
241
291
  });
242
292
  return { ...result, services, diagnostics: services.diagnostics };
243
293
  };
@@ -268,6 +318,14 @@ export class PiSessionService {
268
318
  this.events = events;
269
319
  this.active = new Map();
270
320
  this.pendingSessionOpens = new Map();
321
+ /**
322
+ * Sessions whose extension binding is still in flight. A `session_start`
323
+ * dialog parks that window before the session ever becomes active, so this
324
+ * is the only way the dialog answer/cancel and status paths can reach it;
325
+ * {@link getOrOpen} never consults it, keeping every other operation gated
326
+ * on full readiness.
327
+ */
328
+ this.startupSessions = new Map();
271
329
  this.activities = new Map();
272
330
  /** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
273
331
  this.treeNavigations = new WeakSet();
@@ -289,6 +347,13 @@ export class PiSessionService {
289
347
  this.subsessionLinks = new Map();
290
348
  /** Parent id/file identities whose persisted links have already been loaded. */
291
349
  this.subsessionHydratedParents = new Set();
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
+ */
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();
292
357
  /**
293
358
  * Tracked subsession id -> whether a completion notification is armed.
294
359
  * Armed when the child starts working; firing on completion disarms it so a
@@ -296,18 +361,48 @@ export class PiSessionService {
296
361
  */
297
362
  this.subsessionNotifyArmed = new Map();
298
363
  this.notificationGenerationBySession = new WeakMap();
364
+ /** The parked extension Promise resolvers behind the store's open dialogs. */
365
+ this.dialogWaiters = new ExtensionDialogWaiters();
299
366
  this.pendingUnreadMutations = [];
300
367
  this.unreadPublicationFlushRequested = false;
301
368
  this.unreadPublicationStopped = false;
369
+ /**
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.
381
+ */
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;
391
+ };
302
392
  this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
303
393
  this.agentDir = deps.agentDir;
304
394
  this.sessionManager = deps.sessionManager;
305
395
  this.modelRuntime = deps.modelRuntime;
306
396
  this.spawnTargets = deps.spawnTargets;
397
+ this.projectWorkspaces = deps.projectWorkspaces;
307
398
  this.logger = deps.logger ?? noopLogger;
308
399
  this.now = deps.now ?? (() => new Date());
309
400
  this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
310
401
  this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
402
+ this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
403
+ this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
404
+ this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
405
+ this.catalogRefreshStatus = deps.catalogRefreshStatus;
311
406
  this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
312
407
  this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
313
408
  // Subsessions are a beta capability gated behind their own flag, and they
@@ -318,11 +413,11 @@ export class PiSessionService {
318
413
  list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
319
414
  check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
320
415
  read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
321
- });
416
+ }, deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined);
322
417
  this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
323
418
  this.workspaceActivity = deps.workspaceActivity;
324
419
  this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
325
- 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, {
326
421
  onCompactionStart: (session) => {
327
422
  this.beginSessionEntryMutation(session, "compact the session");
328
423
  this.publishActivity(session, "compacting", "active");
@@ -406,7 +501,6 @@ export class PiSessionService {
406
501
  await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
407
502
  readyDeleteRecords.push(record);
408
503
  }
409
- await this.ensureArchivedRecordsMoved(readyDeleteRecords);
410
504
  const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
411
505
  deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
412
506
  await this.forgetUnreadSessions(deleteRecords);
@@ -423,14 +517,22 @@ export class PiSessionService {
423
517
  this.clearUnreadPublicationRetry();
424
518
  clearInterval(this.heartbeat);
425
519
  this.clearCompactionDrainTimers();
520
+ // Same startup-park hazard as closeActive(): settle `session_start` dialogs
521
+ // of sessions still binding extensions before awaiting their pending opens.
522
+ for (const sessionId of this.startupSessions.keys())
523
+ this.endSessionExtensionDialogs(sessionId);
426
524
  const pendingOpens = this.pendingSessionOpenPromises();
427
525
  if (pendingOpens.length > 0)
428
526
  await Promise.allSettled(pendingOpens);
429
527
  const activeSessions = Array.from(new Set(this.active.values()));
430
- for (const active of activeSessions)
528
+ for (const active of activeSessions) {
431
529
  this.forgetUnreadActivity(active.runtime.session);
530
+ this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
531
+ this.endSessionExtensionDialogs(active.runtime.session.sessionId);
532
+ }
432
533
  this.active.clear();
433
534
  this.pendingSessionOpens.clear();
535
+ this.startupSessions.clear();
434
536
  this.activities.clear();
435
537
  this.compactionPromptQueues.clear();
436
538
  this.authLossWarnings.clear();
@@ -439,6 +541,8 @@ export class PiSessionService {
439
541
  this.subsessionLinks.clear();
440
542
  this.subsessionHydratedParents.clear();
441
543
  this.subsessionNotifyArmed.clear();
544
+ this.sessionHeaderCache.clear();
545
+ this.pendingSessionHeaderReads.clear();
442
546
  this.notificationStore.clearAll("service-dispose");
443
547
  await Promise.all(activeSessions.map(async (active) => {
444
548
  active.unsubscribe();
@@ -456,9 +560,7 @@ export class PiSessionService {
456
560
  async list(cwd) {
457
561
  const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
458
562
  const sessionsById = new Map(sessions.map((session) => [session.id, session]));
459
- const archivedForCwd = await Promise.all(archivedRecords
460
- .filter((record) => record.cwd === cwd)
461
- .map((record) => this.ensureArchivedSessionMoved(record, sessionsById.get(record.sessionId))));
563
+ const archivedForCwd = archivedRecords.filter((record) => record.cwd === cwd);
462
564
  const archivedById = new Map(archivedForCwd.map((record) => [record.sessionId, record]));
463
565
  for (const record of archivedForCwd) {
464
566
  this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
@@ -471,14 +573,93 @@ export class PiSessionService {
471
573
  .sort(compareArchivedRecords)
472
574
  .map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
473
575
  .filter(isDefined);
474
- return [...unarchivedSessions, ...archivedSessions];
576
+ return await this.withRelatedSessionsElsewhere([...unarchivedSessions, ...archivedSessions], cwd);
577
+ }
578
+ /**
579
+ * Annotate a listing with the session relationships that cross workspace
580
+ * boundaries: where an out-of-listing parent lives, and how many children a
581
+ * listed session has in sibling workspaces.
582
+ *
583
+ * Both directions are best-effort. An unreadable parent header or a sibling
584
+ * workspace that cannot be listed leaves the session unannotated rather than
585
+ * failing the listing, and the browser falls back to its generic states.
586
+ */
587
+ async withRelatedSessionsElsewhere(sessions, cwd) {
588
+ const [parentLocations, childCounts] = await Promise.all([
589
+ locateOutOfListingParents(sessions, cwd, this.readCachedSessionHeader),
590
+ this.countChildrenInSiblingWorkspaces(sessions, cwd),
591
+ ]);
592
+ return sessions.map((session) => {
593
+ const parent = session.parentSessionPath === undefined ? undefined : parentLocations.get(session.parentSessionPath);
594
+ const childrenElsewhere = childCounts.get(session.path);
595
+ if (parent === undefined && childrenElsewhere === undefined)
596
+ return session;
597
+ const annotated = { ...session };
598
+ if (parent !== undefined) {
599
+ annotated.parentSessionId = parent.parentSessionId;
600
+ annotated.parentSessionCwd = parent.parentSessionCwd;
601
+ }
602
+ if (childrenElsewhere !== undefined)
603
+ annotated.childSessionsElsewhere = childrenElsewhere;
604
+ return annotated;
605
+ });
606
+ }
607
+ /**
608
+ * Count children of the listed sessions that live in other workspaces of the
609
+ * same project.
610
+ *
611
+ * Only sibling workspaces are scanned: agents may only spawn into workspaces
612
+ * of the spawning session's own project, so that bounds where a child can be.
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.
617
+ */
618
+ async countChildrenInSiblingWorkspaces(sessions, cwd) {
619
+ if (this.projectWorkspaces === undefined || sessions.length === 0)
620
+ return new Map();
621
+ try {
622
+ const siblingCwds = await siblingWorkspaceCwds(this.projectWorkspaces, cwd);
623
+ if (siblingCwds.length === 0)
624
+ return new Map();
625
+ const listings = await Promise.all(siblingCwds.map((siblingCwd) => this.sessionManager.listParentSessionPaths(siblingCwd, this.readCachedSessionHeader)));
626
+ return countOutOfListingChildren(sessions, listings.flat());
627
+ }
628
+ catch (error) {
629
+ this.logger.info({ cwd, error: error instanceof Error ? error.message : String(error) }, "failed to count child sessions in sibling workspaces");
630
+ return new Map();
631
+ }
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;
475
653
  }
476
654
  async start(cwd, options = {}) {
477
655
  return this.startSession(cwd, options);
478
656
  }
479
657
  async startSession(cwd, options) {
480
658
  const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
659
+ startupIntent: "create",
660
+ ...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
481
661
  ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
662
+ ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
482
663
  ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
483
664
  });
484
665
  const { session } = active.runtime;
@@ -511,10 +692,23 @@ export class PiSessionService {
511
692
  const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
512
693
  if (!decision.allowed)
513
694
  throw spawnTargetError(decision);
514
- const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
515
- 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);
516
706
  this.logger.info({ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_session started a new session");
517
- return { sessionId: created.id, cwd: decision.cwd };
707
+ return {
708
+ sessionId: created.id,
709
+ cwd: decision.cwd,
710
+ ...(modelUsed === undefined ? {} : { model: modelSpecOf(modelUsed) }),
711
+ };
518
712
  }
519
713
  /**
520
714
  * Start a *tracked* child session on behalf of a LLM. Identical to
@@ -528,11 +722,18 @@ export class PiSessionService {
528
722
  const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
529
723
  if (!decision.allowed)
530
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);
531
730
  const created = await this.startSession(decision.cwd, {
532
731
  ...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
533
- ...(input.model === undefined ? {} : { initialModel: input.model }),
732
+ ...(model === undefined ? {} : { initialModel: model }),
733
+ ...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
534
734
  creationProvenance: "tracked-subsession",
535
735
  });
736
+ const modelUsed = this.active.get(created.id)?.runtime.session.model;
536
737
  const parentSessionFile = nonEmptyString(input.parentSessionFile);
537
738
  const link = {
538
739
  parentSessionId: input.parentSessionId,
@@ -544,9 +745,254 @@ export class PiSessionService {
544
745
  await this.registerVerifiedSubsession(link);
545
746
  this.persistSubsessionLink(link);
546
747
  this.persistSubsessionChildMarker(input.parentSessionId, created.id);
547
- await this.prompt(created.id, input.prompt);
748
+ await this.prompt(created, input.prompt);
548
749
  this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
549
- 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;
785
+ }
786
+ /**
787
+ * Register the question set an agent wants the user to answer as the session's
788
+ * open ask. Deliberately does not wait for the user: `ask_user` terminates the
789
+ * run and the submitted answers come back later as a follow-up message.
790
+ *
791
+ * Rejected question sets throw {@link PendingAskValidationError}, which the
792
+ * agent loop reports to the model as an error tool result.
793
+ */
794
+ // eslint-disable-next-line @typescript-eslint/require-await -- async so a rejected question set becomes a rejection rather than a synchronous throw from a promise-returning method.
795
+ async openAsk(input) {
796
+ const result = this.pendingAskStore.open(input);
797
+ // A supersede closes the earlier ask, so the browsers watching it must hear
798
+ // that before they hear about its replacement.
799
+ if (result.superseded !== undefined)
800
+ this.publishAskClosed(input.sessionId, result.superseded);
801
+ this.events.publish(input.sessionId, { type: "ask.opened", ask: result.ask });
802
+ this.publishStatusForSessionId(input.sessionId);
803
+ return result;
804
+ }
805
+ /**
806
+ * Record the user's answers to the session's open ask and hand them to the
807
+ * model. The answers travel as a system-authored custom message rather than a
808
+ * user message, so they are not attributed to the human in the transcript;
809
+ * they still wake an idle session (`triggerTurn`) and queue behind in-flight
810
+ * work (`deliverAs: "followUp"`), which is how the run that `ask_user`
811
+ * terminated continues.
812
+ */
813
+ async submitAsk(ref, askId, submission) {
814
+ await this.assertWritable(ref);
815
+ const session = await this.getOrOpen(ref);
816
+ // Checked before the store closes the ask so a refused delivery cannot
817
+ // discard answers the user already submitted.
818
+ this.assertTreeNavigationInactive(session, "answer questions");
819
+ return this.closeAsk(session, this.pendingAskStore.submit(session.sessionId, askId, submission));
820
+ }
821
+ /**
822
+ * Close the open ask without answers. The model is still told, naming every
823
+ * question as unanswered: it was promised a follow-up message and would
824
+ * otherwise wait for one that never comes.
825
+ */
826
+ async cancelAsk(ref, askId) {
827
+ await this.assertWritable(ref);
828
+ const session = await this.getOrOpen(ref);
829
+ this.assertTreeNavigationInactive(session, "dismiss questions");
830
+ return this.closeAsk(session, this.pendingAskStore.cancel(session.sessionId, askId));
831
+ }
832
+ /**
833
+ * Publish and deliver a closed ask. A stale close is reported rather than
834
+ * thrown: losing the race against a supersede, another browser, or a session
835
+ * that went away is ordinary, and the returned status tells the browser what
836
+ * the session's open ask is now.
837
+ */
838
+ async closeAsk(session, result) {
839
+ if (result.status === "stale")
840
+ return { result: "stale", sessionStatus: this.statusFromSession(session) };
841
+ const { outcome } = result;
842
+ this.publishAskClosed(session.sessionId, outcome);
843
+ await this.runSessionEntryMutation(session, "deliver answers to your questions", () => session.sendCustomMessage({ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, { triggerTurn: true, deliverAs: "followUp" }));
844
+ this.publishStatus(session);
845
+ return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
846
+ }
847
+ publishAskClosed(sessionId, outcome) {
848
+ this.events.publish(sessionId, { type: "ask.closed", askId: outcome.askId, reason: outcome.reason });
849
+ }
850
+ /**
851
+ * Void the session's open ask because the user sent a chat message instead of
852
+ * answering it. Every browser closes the card as cancelled, and the model is
853
+ * told — without being woken — so the notice rides into the turn the message
854
+ * itself triggers rather than becoming a turn of its own.
855
+ */
856
+ async voidOpenAskForUserMessage(session) {
857
+ const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
858
+ if (outcome === undefined)
859
+ return;
860
+ this.publishAskClosed(session.sessionId, outcome);
861
+ await this.runSessionEntryMutation(session, "void the open questions", () => session.sendCustomMessage({ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, { triggerTurn: false, deliverAs: "followUp" }));
862
+ this.publishStatus(session);
863
+ }
864
+ /**
865
+ * Record the user's answer to an open extension dialog and resolve the
866
+ * extension's parked Promise with it. Unlike an ask, nothing is delivered to
867
+ * the model: the waiter is extension code inside an already in-flight run
868
+ * (or an idle handler), so no custom message and no turn are triggered.
869
+ */
870
+ async answerDialog(ref, dialogId, value) {
871
+ await this.assertWritable(ref);
872
+ const session = await this.sessionForStatusOrDialogClose(ref);
873
+ const result = this.pendingExtensionDialogStore.answer(session.sessionId, dialogId, value);
874
+ if (result.status === "stale")
875
+ return { result: "stale", sessionStatus: this.statusFromSession(session) };
876
+ const { outcome } = result;
877
+ this.publishDialogClosed(session.sessionId, outcome);
878
+ // `value` is what the store validated and recorded as the outcome's answer.
879
+ this.dialogWaiters.settleWithAnswer(dialogId, value);
880
+ this.publishStatus(session);
881
+ return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
882
+ }
883
+ /** Close an open extension dialog without an answer; the extension's wait settles with its kind's cancel value. */
884
+ async cancelDialog(ref, dialogId) {
885
+ await this.assertWritable(ref);
886
+ const session = await this.sessionForStatusOrDialogClose(ref);
887
+ const result = this.pendingExtensionDialogStore.cancel(session.sessionId, dialogId, "cancelled");
888
+ if (result.status === "stale")
889
+ return { result: "stale", sessionStatus: this.statusFromSession(session) };
890
+ const { outcome } = result;
891
+ this.publishDialogClosed(session.sessionId, outcome);
892
+ this.dialogWaiters.settleWithCancelValue(dialogId);
893
+ this.publishStatus(session);
894
+ return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
895
+ }
896
+ /**
897
+ * Implement one `ctx.ui.select()`/`confirm()`/`input()` call from extension
898
+ * code: open the store record, tell the browsers, and park a Promise that
899
+ * settles when the browser answers or cancels, the extension's own
900
+ * `signal`/`timeout` dismisses the dialog, the daemon default timeout
901
+ * elapses, or the runtime goes away. `store.open` validates the dialog, so a
902
+ * malformed one rejects the extension's call rather than rendering garbage.
903
+ * `async` so a rejected dialog becomes a rejection rather than a synchronous
904
+ * throw from a promise-returning method.
905
+ */
906
+ async openExtensionDialog(session, request, opts) {
907
+ const signal = opts?.signal;
908
+ // A pre-aborted signal dismisses the dialog before it ever opens.
909
+ if (signal?.aborted === true)
910
+ return extensionDialogCancelValue(request.kind);
911
+ const timeoutMs = effectiveExtensionDialogTimeoutMs(opts?.timeout, this.extensionDialogsTimeoutMs);
912
+ const dialog = this.pendingExtensionDialogStore.open({
913
+ sessionId: session.sessionId,
914
+ kind: request.kind,
915
+ title: request.title,
916
+ ...(request.message === undefined ? {} : { message: request.message }),
917
+ ...(request.options === undefined ? {} : { options: request.options }),
918
+ ...(request.placeholder === undefined ? {} : { placeholder: request.placeholder }),
919
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
920
+ runScoped: session.isStreaming,
921
+ });
922
+ this.events.publish(session.sessionId, { type: "dialog.opened", dialog });
923
+ this.publishStatus(session);
924
+ return this.dialogWaiters.park(dialog, {
925
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
926
+ ...(signal === undefined ? {} : { signal }),
927
+ onTrigger: (reason) => {
928
+ if (this.closeExtensionDialogFromTrigger(session.sessionId, dialog.dialogId, reason))
929
+ this.publishStatusForSessionId(session.sessionId);
930
+ },
931
+ });
932
+ }
933
+ /**
934
+ * Close a dialog whose wait ended without the browser (timeout, signal
935
+ * abort, run end, runtime teardown) and settle its parked Promise. Returns
936
+ * whether this call closed the dialog; a stale close means a browser answer
937
+ * or an earlier trigger already settled everything.
938
+ */
939
+ closeExtensionDialogFromTrigger(sessionId, dialogId, reason) {
940
+ const result = this.pendingExtensionDialogStore.cancel(sessionId, dialogId, reason);
941
+ if (result.status !== "closed")
942
+ return false;
943
+ this.publishDialogClosed(sessionId, result.outcome);
944
+ this.dialogWaiters.settleWithCancelValue(dialogId);
945
+ return true;
946
+ }
947
+ /**
948
+ * Settle the session's run-scoped dialogs as `"aborted"`. Runs at
949
+ * abort-request time (a user abort parks the agent loop behind the dialog
950
+ * handler, so `agent_end` would never arrive on its own) and again from
951
+ * the `agent_end` observer as the run-crash backstop — the store makes the
952
+ * second settlement a stale no-op. Idle-opened dialogs (a `session_start`
953
+ * probe, say) are not run-scoped and survive, because their waiter
954
+ * outlives the run.
955
+ */
956
+ abortRunScopedExtensionDialogs(sessionId) {
957
+ let closedAny = false;
958
+ for (const dialog of this.pendingExtensionDialogStore.pendingDialogs(sessionId)) {
959
+ if (dialog.runScoped)
960
+ closedAny = this.closeExtensionDialogFromTrigger(sessionId, dialog.dialogId, "aborted") || closedAny;
961
+ }
962
+ if (closedAny)
963
+ this.publishStatusForSessionId(sessionId);
964
+ }
965
+ /**
966
+ * Settle every dialog of the session as `"session-ended"`: the runtime
967
+ * whose extension code is parked on them is being closed, replaced, or
968
+ * disposed, so those Promises would otherwise never settle.
969
+ */
970
+ endSessionExtensionDialogs(sessionId) {
971
+ let closedAny = false;
972
+ for (const dialog of this.pendingExtensionDialogStore.pendingDialogs(sessionId)) {
973
+ closedAny = this.closeExtensionDialogFromTrigger(sessionId, dialog.dialogId, "session-ended") || closedAny;
974
+ }
975
+ // Publishes only while the session is still (or already re-)registered as
976
+ // active, so teardown paths stay silent and runtime replacement refreshes.
977
+ if (closedAny)
978
+ this.publishStatusForSessionId(sessionId);
979
+ }
980
+ publishDialogClosed(sessionId, outcome) {
981
+ this.events.publish(sessionId, {
982
+ type: "dialog.closed",
983
+ dialogId: outcome.dialogId,
984
+ reason: outcome.reason,
985
+ ...(outcome.answer === undefined ? {} : { answer: outcome.answer }),
986
+ });
987
+ }
988
+ /**
989
+ * Publish status for a session known only by id, as the ask tools are: they
990
+ * run inside the session's own runtime, so the active entry is the session.
991
+ */
992
+ publishStatusForSessionId(sessionId) {
993
+ const session = this.active.get(sessionId)?.runtime.session;
994
+ if (session !== undefined)
995
+ this.publishStatus(session);
550
996
  }
551
997
  /** Summaries of the tracked subsessions spawned by `parentSessionId`. */
552
998
  async listSubsessions(parentSessionId, parentSessionFile) {
@@ -885,7 +1331,7 @@ export class PiSessionService {
885
1331
  return pageMessagesAtSafeBoundary(historyMessages(session), page);
886
1332
  }
887
1333
  async status(ref) {
888
- return this.statusFromSession(await this.getOrOpen(ref));
1334
+ return this.statusFromSession(await this.sessionForStatusOrDialogClose(ref));
889
1335
  }
890
1336
  /**
891
1337
  * Join-time snapshot of the in-flight assistant stream. The `seq` watermark and
@@ -903,26 +1349,20 @@ export class PiSessionService {
903
1349
  const streamingMessage = session.state.streamingMessage;
904
1350
  const partial = streamingMessage === undefined || streamingMessage === null
905
1351
  ? null
906
- : projectBrowserMessage(streamingMessage);
1352
+ : annotateAssistantThinkingLevel(projectBrowserMessage(streamingMessage), session.thinkingLevel);
907
1353
  return { seq, partial };
908
1354
  }
909
1355
  async availableModels(ref) {
910
1356
  const session = await this.getOrOpen(ref);
911
- await session.modelRuntime.refresh();
912
- const models = session.scopedModels.length > 0
913
- ? session.scopedModels.map((scoped) => scoped.model)
914
- : session.modelRuntime.getAvailableSnapshot();
1357
+ const models = await this.sessionModelCandidates(session);
915
1358
  return models.map(modelToClientModel);
916
1359
  }
917
1360
  async setModel(ref, provider, modelId) {
918
1361
  await this.assertWritable(ref);
919
1362
  const session = await this.getOrOpen(ref);
920
1363
  this.assertTreeNavigationInactive(session, "change models");
921
- await session.modelRuntime.refresh();
1364
+ const candidates = await this.sessionModelCandidates(session);
922
1365
  this.assertTreeNavigationInactive(session, "change models");
923
- const candidates = session.scopedModels.length > 0
924
- ? session.scopedModels.map((scoped) => scoped.model)
925
- : session.modelRuntime.getAvailableSnapshot();
926
1366
  const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
927
1367
  ?? session.modelRuntime.getModel(provider, modelId);
928
1368
  if (model === undefined)
@@ -1007,6 +1447,11 @@ export class PiSessionService {
1007
1447
  this.publishStatus(session);
1008
1448
  return;
1009
1449
  }
1450
+ // A chat message answers the session's open ask in the user's own words, so
1451
+ // the form is void: keeping it open would invite answers to questions the
1452
+ // conversation has already moved past. Ignored duplicates skip this on
1453
+ // purpose: they must not void an ask posted after the queued original.
1454
+ await this.voidOpenAskForUserMessage(session);
1010
1455
  if (session.isCompacting) {
1011
1456
  this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
1012
1457
  return;
@@ -1090,7 +1535,7 @@ export class PiSessionService {
1090
1535
  async navigateTree(ref, request) {
1091
1536
  if (request.targetId.trim() === "")
1092
1537
  throw new Error("Session tree target is required");
1093
- if (this.isTreeExclusiveSessionIdentityActive(sessionIdFromLookup(ref))) {
1538
+ if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
1094
1539
  throw new Error("Stop current session activity before navigating the session tree");
1095
1540
  }
1096
1541
  await this.assertWritable(ref);
@@ -1189,7 +1634,7 @@ export class PiSessionService {
1189
1634
  const uniqueRefs = uniqueBulkSessionRefs(refs);
1190
1635
  const [archivedRecords, sessionContext] = await Promise.all([
1191
1636
  this.archiveStore.list(),
1192
- this.bulkSessionLookupContext(uniqueRefs),
1637
+ this.bulkSessionRefContext(uniqueRefs),
1193
1638
  ]);
1194
1639
  const failures = [];
1195
1640
  const alreadyArchivedSessionIds = [];
@@ -1203,7 +1648,7 @@ export class PiSessionService {
1203
1648
  unreadArchivedIdentities.push(archived);
1204
1649
  continue;
1205
1650
  }
1206
- const active = this.activeForLookup(bulkRefToLookup(ref));
1651
+ const active = this.activeForRef(bulkRefToSessionRef(ref));
1207
1652
  const listed = findListedSessionForBulkRef(sessionContext, ref);
1208
1653
  const resolvedSessionId = active?.runtime.session.sessionId ?? listed?.id ?? ref.id;
1209
1654
  if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
@@ -1227,7 +1672,7 @@ export class PiSessionService {
1227
1672
  }
1228
1673
  const readyPlanItems = [];
1229
1674
  for (const item of planItems) {
1230
- 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 });
1231
1676
  if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1232
1677
  failures.push({ sessionId: item.input.sessionId, error: "Stop current session activity before archiving" });
1233
1678
  continue;
@@ -1304,18 +1749,6 @@ export class PiSessionService {
1304
1749
  await this.archiveStore.restore(archived.sessionId);
1305
1750
  await this.forgetUnreadSessions([archived]);
1306
1751
  }
1307
- async deleteArchived(ref) {
1308
- const record = await this.getArchived(ref);
1309
- if (record === undefined)
1310
- throw new Error("Archived session not found");
1311
- if (this.archiveStore.deleteArchived === undefined)
1312
- throw new Error("Archive store does not support deletion");
1313
- await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
1314
- if (record.archivePath === undefined)
1315
- await this.ensureArchivedRecordMoved(record);
1316
- await this.archiveStore.deleteArchived(record.sessionId);
1317
- await this.forgetUnreadSessions([record]);
1318
- }
1319
1752
  async deleteArchivedMany(refs) {
1320
1753
  if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
1321
1754
  throw new Error("Archive store does not support deletion");
@@ -1329,7 +1762,7 @@ export class PiSessionService {
1329
1762
  failures.push({ sessionId: ref.id, error: "Archived session not found" });
1330
1763
  continue;
1331
1764
  }
1332
- const active = this.activeForLookup({ id: record.sessionId, cwd: record.cwd });
1765
+ const active = this.activeForRef({ id: record.sessionId, cwd: record.cwd });
1333
1766
  if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1334
1767
  failures.push({ sessionId: record.sessionId, error: "Stop current session activity before deleting archived session" });
1335
1768
  continue;
@@ -1346,12 +1779,7 @@ export class PiSessionService {
1346
1779
  failures.push({ sessionId: item.record.sessionId, error: errorMessage(error) });
1347
1780
  }
1348
1781
  }
1349
- const moveFailures = await this.moveLegacyArchivedRecordsForDelete(readyRecords);
1350
- failures.push(...moveFailures);
1351
- const moveFailureIds = new Set(moveFailures.map((failure) => failure.sessionId));
1352
- const deleteIds = readyRecords
1353
- .map((record) => record.sessionId)
1354
- .filter((sessionId) => !moveFailureIds.has(sessionId));
1782
+ const deleteIds = readyRecords.map((record) => record.sessionId);
1355
1783
  let deletedSessionIds = [];
1356
1784
  try {
1357
1785
  deletedSessionIds = await this.archiveStoreDeleteArchivedMany(deleteIds);
@@ -1404,6 +1832,14 @@ export class PiSessionService {
1404
1832
  if (sessionFile === undefined || sessionFile === "")
1405
1833
  throw new Error("Session is not persisted");
1406
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);
1407
1843
  clearParentSessionHeader(session.sessionManager);
1408
1844
  this.unregisterSubsession(session.sessionId);
1409
1845
  await this.forgetUnreadSessions([{ sessionId: session.sessionId, cwd: session.sessionManager.getCwd() }]);
@@ -1423,12 +1859,18 @@ export class PiSessionService {
1423
1859
  return this.statusFromSession(session);
1424
1860
  }
1425
1861
  async abort(ref) {
1426
- const active = this.activeForLookup(ref);
1862
+ const active = this.activeForRef(ref);
1427
1863
  if (active === undefined)
1428
1864
  return;
1429
1865
  const sessionId = active.runtime.session.sessionId;
1430
1866
  this.clearCompactionPromptQueue(sessionId);
1431
1867
  clearSessionQueue(active.runtime.session);
1868
+ // Settle run-scoped dialogs now, at abort-request time: pi's agent loop
1869
+ // waits for a parked `tool_call` dialog handler before it can emit
1870
+ // `agent_end`, so leaving settlement to the `agent_end` observer would
1871
+ // strand the dialog until its timeout. Settling before the runtime abort
1872
+ // also means a failing or hung abort cannot strand the parked waiter.
1873
+ this.abortRunScopedExtensionDialogs(sessionId);
1432
1874
  try {
1433
1875
  await this.abortSessionOperations(active.runtime.session);
1434
1876
  this.publishActivity(active.runtime.session, "stopped", "idle");
@@ -1443,31 +1885,26 @@ export class PiSessionService {
1443
1885
  }
1444
1886
  }
1445
1887
  async stop(ref) {
1446
- const active = this.activeForLookup(ref);
1888
+ const active = this.activeForRef(ref);
1447
1889
  if (active !== undefined) {
1448
1890
  await this.closeActive(active.runtime.session.sessionId);
1449
1891
  return;
1450
1892
  }
1451
- if (isPiSessionRef(ref)) {
1452
- 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);
1453
1899
  return;
1454
1900
  }
1455
- await this.closeActive(ref);
1901
+ this.publishNotificationMutations(this.notificationStore.clearSessionIdentity(ref.id, canonicalizeStoredCwd(ref.cwd), "runtime-close"));
1456
1902
  }
1457
- async bulkSessionLookupContext(refs) {
1903
+ async bulkSessionRefContext(refs) {
1458
1904
  const cwdSet = new Set();
1459
- let needsAllSessions = false;
1460
- for (const ref of refs) {
1461
- if (ref.cwd === undefined)
1462
- needsAllSessions = true;
1463
- else
1464
- cwdSet.add(ref.cwd);
1465
- }
1466
- const [sessionsByCwd, allSessions] = await Promise.all([
1467
- this.listSessionsByCwd([...cwdSet]),
1468
- needsAllSessions ? this.sessionManager.listAll?.() ?? Promise.resolve([]) : Promise.resolve(undefined),
1469
- ]);
1470
- return allSessions === undefined ? { sessionsByCwd } : { sessionsByCwd, allSessions };
1905
+ for (const ref of refs)
1906
+ cwdSet.add(ref.cwd);
1907
+ return { sessionsByCwd: await this.listSessionsByCwd([...cwdSet]) };
1471
1908
  }
1472
1909
  async listSessionsByCwd(cwds) {
1473
1910
  const uniqueCwds = uniqueStrings(cwds);
@@ -1495,36 +1932,8 @@ export class PiSessionService {
1495
1932
  await this.archiveStore.deleteArchived(sessionId);
1496
1933
  return [...sessionIds];
1497
1934
  }
1498
- async moveLegacyArchivedRecordsForDelete(records) {
1499
- const legacyRecords = records.filter((record) => record.archivePath === undefined);
1500
- if (legacyRecords.length === 0)
1501
- return [];
1502
- let sessionsByCwd;
1503
- try {
1504
- sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
1505
- }
1506
- catch (error) {
1507
- return legacyRecords.map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
1508
- }
1509
- const moveInputs = legacyRecords
1510
- .map((record) => findSessionByIdOrPrefix(sessionsByCwd.get(record.cwd) ?? [], record.sessionId))
1511
- .filter(isDefined)
1512
- .map(archiveInputFromListEntry);
1513
- if (moveInputs.length === 0)
1514
- return [];
1515
- try {
1516
- await this.archiveStoreArchiveMany(moveInputs);
1517
- return [];
1518
- }
1519
- catch (error) {
1520
- const failedIds = new Set(moveInputs.map((input) => input.sessionId));
1521
- return legacyRecords
1522
- .filter((record) => failedIds.has(record.sessionId))
1523
- .map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
1524
- }
1525
- }
1526
1935
  async cleanupPlan(request) {
1527
- 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()]);
1528
1937
  return planSessionCleanup({
1529
1938
  sessions,
1530
1939
  archivedRecords,
@@ -1553,34 +1962,6 @@ export class PiSessionService {
1553
1962
  }
1554
1963
  return [...sessionIds];
1555
1964
  }
1556
- async ensureArchivedSessionMoved(record, session) {
1557
- if (session === undefined || this.active.has(record.sessionId))
1558
- return record;
1559
- try {
1560
- return await this.archiveStore.archive(archiveInputFromListEntry(session));
1561
- }
1562
- catch {
1563
- return record;
1564
- }
1565
- }
1566
- async ensureArchivedRecordMoved(record) {
1567
- const session = (await this.sessionManager.list(record.cwd)).find((candidate) => candidate.id === record.sessionId);
1568
- if (session === undefined)
1569
- return record;
1570
- const [moved] = await this.archiveStoreArchiveMany([archiveInputFromListEntry(session)]);
1571
- return moved ?? record;
1572
- }
1573
- async ensureArchivedRecordsMoved(records) {
1574
- const legacyRecords = records.filter((record) => record.archivePath === undefined);
1575
- if (legacyRecords.length === 0)
1576
- return;
1577
- const sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
1578
- const moveInputs = legacyRecords
1579
- .map((record) => sessionsByCwd.get(record.cwd)?.find((candidate) => candidate.id === record.sessionId))
1580
- .filter(isDefined)
1581
- .map(archiveInputFromListEntry);
1582
- await this.archiveStoreArchiveMany(moveInputs);
1583
- }
1584
1965
  async archiveInputForSession(session) {
1585
1966
  const cwd = session.sessionManager.getCwd();
1586
1967
  const sessionFile = session.sessionFile;
@@ -1642,6 +2023,11 @@ export class PiSessionService {
1642
2023
  return [...names];
1643
2024
  }
1644
2025
  async closeActive(sessionId, notificationPolicy = CLEAR_RUNTIME_NOTIFICATIONS) {
2026
+ // A session whose open is parked on a `session_start` dialog holds its
2027
+ // pending open until the dialog settles; settle it first so closing cannot
2028
+ // block behind the dialog timeout (which `0` makes infinite).
2029
+ if (this.startupSessions.has(sessionId))
2030
+ this.endSessionExtensionDialogs(sessionId);
1645
2031
  const pendingOpens = this.pendingSessionOpenPromises(sessionId);
1646
2032
  if (pendingOpens.length > 0)
1647
2033
  await Promise.allSettled(pendingOpens);
@@ -1656,6 +2042,12 @@ export class PiSessionService {
1656
2042
  if (!active)
1657
2043
  return;
1658
2044
  this.forgetUnreadActivity(active.runtime.session);
2045
+ // An open ask is meaningful only while the runtime that posted it exists: no
2046
+ // one is left to receive the answers, so it is dropped without an outcome.
2047
+ this.pendingAskStore.forgetSession(sessionId);
2048
+ // Open dialogs share that stance, but their extension waiters are parked
2049
+ // Promises inside the dying runtime: settle them rather than dropping them.
2050
+ this.endSessionExtensionDialogs(sessionId);
1659
2051
  this.active.delete(sessionId);
1660
2052
  this.activities.delete(sessionId);
1661
2053
  this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
@@ -1706,7 +2098,7 @@ export class PiSessionService {
1706
2098
  return (await this.getActive(ref)).runtime.session;
1707
2099
  }
1708
2100
  async getActive(ref, options = {}) {
1709
- const active = this.activeForLookup(ref);
2101
+ const active = this.activeForRef(ref);
1710
2102
  if (active !== undefined)
1711
2103
  return active;
1712
2104
  const archived = await this.getArchived(ref);
@@ -1714,15 +2106,15 @@ export class PiSessionService {
1714
2106
  const { archivePath } = archived;
1715
2107
  return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath), { notifications: "disabled" });
1716
2108
  }
1717
- const match = isPiSessionRef(ref)
1718
- ? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
1719
- : (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);
1720
2112
  if (!match)
1721
2113
  throw new Error("Session not found");
1722
2114
  return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
1723
2115
  }
1724
2116
  openExistingSession(sessionId, cwd, openSessionManager, options = {}) {
1725
- const active = this.activeForLookup({ id: sessionId, cwd });
2117
+ const active = this.activeForRef({ id: sessionId, cwd });
1726
2118
  if (active !== undefined)
1727
2119
  return Promise.resolve(active);
1728
2120
  const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionId]);
@@ -1746,28 +2138,79 @@ export class PiSessionService {
1746
2138
  .map((pending) => pending.promise);
1747
2139
  }
1748
2140
  async getArchived(ref) {
1749
- const archived = await this.archiveStore.get(sessionIdFromLookup(ref));
2141
+ const archived = await this.archiveStore.get(ref.id);
1750
2142
  if (archived === undefined)
1751
2143
  return undefined;
1752
- if (isPiSessionRef(ref) && archived.cwd !== ref.cwd)
2144
+ if (archived.cwd !== ref.cwd)
1753
2145
  return undefined;
1754
2146
  return archived;
1755
2147
  }
1756
2148
  isCurrentActiveSession(session) {
1757
2149
  return this.active.get(session.sessionId)?.runtime.session === session;
1758
2150
  }
1759
- activeForLookup(ref) {
1760
- 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;
1761
2164
  const exact = this.active.get(sessionId);
1762
- if (exact !== undefined && lookupMatchesActiveSession(ref, exact))
2165
+ if (exact !== undefined && refMatchesActiveSession(ref, exact))
1763
2166
  return exact;
1764
2167
  for (const [candidateId, active] of this.active.entries()) {
1765
- if (candidateId.startsWith(sessionId) && lookupMatchesActiveSession(ref, active))
2168
+ if (candidateId.startsWith(sessionId) && refMatchesActiveSession(ref, active))
1766
2169
  return active;
1767
2170
  }
1768
2171
  return undefined;
1769
2172
  }
2173
+ startupSessionForRef(ref) {
2174
+ const sessionId = ref.id;
2175
+ const exact = this.startupSessions.get(sessionId);
2176
+ if (exact !== undefined && refMatchesStartupSession(ref, exact))
2177
+ return exact;
2178
+ for (const [candidateId, session] of this.startupSessions.entries()) {
2179
+ if (candidateId.startsWith(sessionId) && refMatchesStartupSession(ref, session))
2180
+ return session;
2181
+ }
2182
+ return undefined;
2183
+ }
2184
+ /**
2185
+ * The session to serve a read-only status or a dialog close for, while it
2186
+ * can still be found: active first, then still starting up, and only then
2187
+ * the on-demand open path (which a stale close on an idle session needs for
2188
+ * its status projection).
2189
+ */
2190
+ async sessionForStatusOrDialogClose(ref) {
2191
+ const reachable = this.activeForRef(ref)?.runtime.session ?? this.startupSessionForRef(ref);
2192
+ if (reachable !== undefined)
2193
+ return reachable;
2194
+ return this.getOrOpen(ref);
2195
+ }
2196
+ /**
2197
+ * Construct a session while telling waiting browsers which phase of startup
2198
+ * they are waiting on. The reporting wraps the *whole* construction rather
2199
+ * than the inner bookkeeping `try`, because the runtime construction that runs
2200
+ * first is both the slowest phase and one that can fail on its own; a clear
2201
+ * that only ran for the later phases would leave a stale label behind.
2202
+ */
1770
2203
  async create(sessionManager, cwd, options = {}) {
2204
+ const startup = this.startupProgress(sessionManager, options.startupIntent ?? "open", options.startupToken);
2205
+ try {
2206
+ return await this.createSessionRuntime(sessionManager, cwd, options, startup);
2207
+ }
2208
+ finally {
2209
+ startup.end();
2210
+ }
2211
+ }
2212
+ async createSessionRuntime(sessionManager, cwd, options, startup) {
2213
+ startup.report(STARTUP_PHASE_RUNTIME);
1771
2214
  const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
1772
2215
  && await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
1773
2216
  const runtime = await this.createAgentRuntime(this.createRuntime, {
@@ -1776,6 +2219,7 @@ export class PiSessionService {
1776
2219
  sessionManager,
1777
2220
  delegationToolsEnabled,
1778
2221
  ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
2222
+ ...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
1779
2223
  });
1780
2224
  const active = { runtime, unsubscribe: noop };
1781
2225
  let boundSession = runtime.session;
@@ -1807,6 +2251,7 @@ export class PiSessionService {
1807
2251
  else {
1808
2252
  await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
1809
2253
  }
2254
+ startup.report(STARTUP_PHASE_EXTENSIONS);
1810
2255
  await this.bindSessionExtensions(runtime.session, notificationGeneration);
1811
2256
  this.bindRuntime(active);
1812
2257
  runtime.setRebindSession(async (session) => {
@@ -1820,6 +2265,10 @@ export class PiSessionService {
1820
2265
  this.notificationGenerationBySession.set(session, candidateGeneration);
1821
2266
  }
1822
2267
  this.bindRuntime(active, session);
2268
+ // The runtime being replaced parked every dialog the store still
2269
+ // holds for this session; settle those waits before the new
2270
+ // runtime's extensions can open fresh dialogs under the same id.
2271
+ this.endSessionExtensionDialogs(boundSession.sessionId);
1823
2272
  boundSession = session;
1824
2273
  await this.bindSessionExtensions(session, candidateGeneration);
1825
2274
  if (candidateGeneration !== undefined) {
@@ -1855,6 +2304,9 @@ export class PiSessionService {
1855
2304
  }
1856
2305
  active.unsubscribe();
1857
2306
  this.forgetUnreadActivity(boundSession);
2307
+ // A session_start dialog may already be parked when a later startup
2308
+ // step fails; its waiter dies with the runtime being torn down here.
2309
+ this.endSessionExtensionDialogs(boundSession.sessionId);
1858
2310
  let removedActive = false;
1859
2311
  for (const [sessionId, candidate] of this.active.entries()) {
1860
2312
  if (candidate !== active)
@@ -1879,15 +2331,25 @@ export class PiSessionService {
1879
2331
  }
1880
2332
  async bindSessionExtensions(session, generation) {
1881
2333
  const uiContext = this.sessionUiContext(session, generation);
1882
- await session.bindExtensions({
1883
- uiContext,
1884
- mode: "rpc",
1885
- onError: (error) => {
1886
- const message = `${error.extensionPath}: ${error.error}`;
1887
- this.publishActivity(session, "extension error", "error", message);
1888
- this.events.publish(session.sessionId, { type: "session.error", message });
1889
- },
1890
- });
2334
+ // A `session_start` hook can park this bind on a dialog the browser has
2335
+ // not answered yet. On the initial create/open path the session becomes
2336
+ // active only after this returns, so register it for the duration: the
2337
+ // answer that unblocks startup has to be reachable while it waits.
2338
+ this.startupSessions.set(session.sessionId, session);
2339
+ try {
2340
+ await session.bindExtensions({
2341
+ uiContext,
2342
+ mode: "rpc",
2343
+ onError: (error) => {
2344
+ const message = `${error.extensionPath}: ${error.error}`;
2345
+ this.publishActivity(session, "extension error", "error", message);
2346
+ this.events.publish(session.sessionId, { type: "session.error", message });
2347
+ },
2348
+ });
2349
+ }
2350
+ finally {
2351
+ this.startupSessions.delete(session.sessionId);
2352
+ }
1891
2353
  }
1892
2354
  replaceSessionNotificationContext(session, generation) {
1893
2355
  session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
@@ -1905,24 +2367,27 @@ export class PiSessionService {
1905
2367
  }
1906
2368
  const added = this.notificationStore.addNotification(generation, message, type);
1907
2369
  this.publishNotificationMutations(added.mutations);
1908
- if (added.notification === undefined)
1909
- return;
1910
- this.events.publish(session.sessionId, {
1911
- type: "command.output",
1912
- level: type === "error" ? "error" : "info",
1913
- message,
1914
- notificationId: added.notification.id,
1915
- });
1916
2370
  };
1917
- // PI WEB owns the browser-facing notification and text-formatting
1918
- // boundaries. Delegate every other UI method to Pi's headless defaults so
1919
- // unsupported dialogs cancel safely instead of hanging.
2371
+ // PI WEB owns the browser-facing dialog, notification, and text-formatting
2372
+ // boundaries: the three dialog primitives park daemon-held Promises that
2373
+ // the browser answers, while every other UI method delegates to Pi's
2374
+ // headless defaults so unsupported surfaces cancel safely instead of
2375
+ // hanging.
1920
2376
  return new Proxy(baseUiContext, {
1921
- get(target, property, receiver) {
2377
+ get: (target, property, receiver) => {
1922
2378
  if (property === "notify")
1923
2379
  return notify;
1924
2380
  if (property === "theme")
1925
2381
  return plainTextTheme;
2382
+ if (property === "confirm") {
2383
+ return (title, message, opts) => this.openExtensionDialog(session, { kind: "confirm", title, message }, opts);
2384
+ }
2385
+ if (property === "select") {
2386
+ return (title, options, opts) => this.openExtensionDialog(session, { kind: "select", title, options }, opts);
2387
+ }
2388
+ if (property === "input") {
2389
+ return (title, placeholder, opts) => this.openExtensionDialog(session, { kind: "input", title, placeholder }, opts);
2390
+ }
1926
2391
  const value = Reflect.get(target, property, receiver);
1927
2392
  return value;
1928
2393
  },
@@ -2048,9 +2513,11 @@ export class PiSessionService {
2048
2513
  }
2049
2514
  }
2050
2515
  active.unsubscribe = session.subscribe((event) => {
2051
- this.events.publish(session.sessionId, toClientEvent(event));
2516
+ this.events.publish(session.sessionId, toClientEvent(event, session.thinkingLevel));
2052
2517
  this.publishActivityForEvent(session, event);
2053
2518
  const eventType = getString(event, "type");
2519
+ if (eventType === "agent_end")
2520
+ this.abortRunScopedExtensionDialogs(session.sessionId);
2054
2521
  if (eventType === "compaction_end")
2055
2522
  this.scheduleCompactionQueueDrain(session.sessionId);
2056
2523
  if (eventType === "agent_start" || eventType === "agent_end")
@@ -2387,6 +2854,54 @@ export class PiSessionService {
2387
2854
  if (this.hasActiveWork(session))
2388
2855
  this.publishActivity(session, eventType.replaceAll("_", " "), "active");
2389
2856
  }
2857
+ /**
2858
+ * Build the reporter for one session construction.
2859
+ *
2860
+ * The session id is known before any await — a `SessionManager` has its id
2861
+ * from construction — so the daemon can name what it is starting even though
2862
+ * the `PiAgentSession` that {@link publishActivity} needs does not exist yet.
2863
+ * Without an id there is nothing to report against, so the reporter stays
2864
+ * silent and the browser keeps its own generic wording.
2865
+ */
2866
+ startupProgress(sessionManager, intent, startupToken) {
2867
+ const sessionId = sessionManager.getSessionId();
2868
+ if (sessionId === "")
2869
+ return { report: noop, end: noop };
2870
+ const label = intent === "create" ? "Creating session" : "Opening session";
2871
+ return {
2872
+ report: (phase) => { this.publishStartupProgress(sessionId, startupToken, label, "active", this.startupDetail(phase)); },
2873
+ end: () => {
2874
+ // A real activity published during the window (an extension error, say)
2875
+ // is the truth about this session and must survive the clear.
2876
+ if (this.activities.has(sessionId))
2877
+ return;
2878
+ this.publishStartupProgress(sessionId, startupToken, "idle", "idle", undefined);
2879
+ },
2880
+ };
2881
+ }
2882
+ startupDetail(phase) {
2883
+ return this.catalogRefreshStatus?.isRefreshInFlight() === true
2884
+ ? `${phase} · ${STARTUP_CONCURRENT_CATALOG_REFRESH}`
2885
+ : phase;
2886
+ }
2887
+ /**
2888
+ * Report startup progress on the global channel only, echoing the caller's
2889
+ * correlation token so a waiting browser row recognises its own construction.
2890
+ *
2891
+ * Unlike {@link publishActivity} this deliberately records nothing: no
2892
+ * `activities` entry, no workspace activity, no unread observation. There is
2893
+ * no session to own that state, and a failed creation would leave it stranded.
2894
+ *
2895
+ * Every report is marked `startup`, which is what keeps a session that is
2896
+ * merely opening from counting as one doing work. This is the only publisher
2897
+ * that sets the marker, and because it writes no `activities` entry no later
2898
+ * heartbeat re-publication can carry it.
2899
+ */
2900
+ publishStartupProgress(sessionId, startupToken, label, phase, detail) {
2901
+ const at = new Date().toISOString();
2902
+ const activity = detail === undefined ? { sessionId, phase, label, at, startup: true } : { sessionId, phase, label, detail, at, startup: true };
2903
+ this.events.publishGlobal(startupToken === undefined ? { type: "session.startup", activity } : { type: "session.startup", startupToken, activity });
2904
+ }
2390
2905
  publishActivity(session, label, phase, detail) {
2391
2906
  const at = new Date().toISOString();
2392
2907
  const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
@@ -2421,6 +2936,8 @@ export class PiSessionService {
2421
2936
  const model = session.model === undefined ? undefined : modelToClientModel(session.model);
2422
2937
  const contextUsage = session.getContextUsage();
2423
2938
  const warnings = this.warningsForSession(session);
2939
+ const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
2940
+ const pendingDialogs = this.pendingExtensionDialogStore.pendingDialogs(session.sessionId);
2424
2941
  return {
2425
2942
  sessionId: session.sessionId,
2426
2943
  persisted: sessionFileExists(session.sessionFile),
@@ -2436,6 +2953,8 @@ export class PiSessionService {
2436
2953
  cost: stats.cost,
2437
2954
  ...(contextUsage === undefined ? {} : { contextUsage }),
2438
2955
  ...(warnings.length === 0 ? {} : { warnings }),
2956
+ ...(pendingAsk === undefined ? {} : { pendingAsk }),
2957
+ ...(pendingDialogs.length === 0 ? {} : { pendingDialogs }),
2439
2958
  };
2440
2959
  }
2441
2960
  /**
@@ -2475,7 +2994,7 @@ function uniqueBulkSessionRefs(refs) {
2475
2994
  const seen = new Set();
2476
2995
  const unique = [];
2477
2996
  for (const ref of refs) {
2478
- const key = `${ref.cwd ?? ""}\0${ref.id}`;
2997
+ const key = `${ref.cwd}\0${ref.id}`;
2479
2998
  if (seen.has(key))
2480
2999
  continue;
2481
3000
  seen.add(key);
@@ -2483,16 +3002,14 @@ function uniqueBulkSessionRefs(refs) {
2483
3002
  }
2484
3003
  return unique;
2485
3004
  }
2486
- function bulkRefToLookup(ref) {
2487
- return ref.cwd === undefined ? ref.id : { id: ref.id, cwd: ref.cwd };
3005
+ function bulkRefToSessionRef(ref) {
3006
+ return { id: ref.id, cwd: ref.cwd };
2488
3007
  }
2489
3008
  function findArchivedRecordForBulkRef(records, ref) {
2490
- 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)));
2491
3010
  }
2492
3011
  function findListedSessionForBulkRef(context, ref) {
2493
- if (ref.cwd !== undefined)
2494
- return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
2495
- return context.allSessions === undefined ? undefined : findSessionByIdOrPrefix(context.allSessions, ref.id);
3012
+ return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
2496
3013
  }
2497
3014
  function findSessionByIdOrPrefix(sessions, sessionId) {
2498
3015
  return sessions.find((session) => session.id === sessionId) ?? sessions.find((session) => session.id.startsWith(sessionId));
@@ -2811,28 +3328,6 @@ function activeSessionFileMatches(active, expectedSessionFile) {
2811
3328
  function trackedLinkParentFileMatches(link, parentSessionFile) {
2812
3329
  return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
2813
3330
  }
2814
- async function readSessionHeaderSummary(sessionFile) {
2815
- let file;
2816
- try {
2817
- file = await open(sessionFile, "r");
2818
- const buffer = Buffer.alloc(4096);
2819
- const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
2820
- const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
2821
- if (firstLine === undefined || firstLine === "")
2822
- return undefined;
2823
- const header = JSON.parse(firstLine);
2824
- if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string")
2825
- return undefined;
2826
- const parentSession = getString(header, "parentSession");
2827
- return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) };
2828
- }
2829
- catch {
2830
- return undefined;
2831
- }
2832
- finally {
2833
- await file?.close().catch(() => undefined);
2834
- }
2835
- }
2836
3331
  async function sessionFileHeaderMatches(sessionFile, expected) {
2837
3332
  const header = await readSessionHeaderSummary(sessionFile);
2838
3333
  if (header?.id !== expected.sessionId)
@@ -2897,13 +3392,34 @@ function buildPromptOptions(behavior, images) {
2897
3392
  function stringValue(value) {
2898
3393
  return typeof value === "string" ? value : "";
2899
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
+ }
2900
3408
  function historyMessages(session) {
2901
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;
2902
3413
  for (const entry of session.sessionManager.getBranch()) {
2903
3414
  if (!isRecord(entry))
2904
3415
  continue;
2905
3416
  if (entry["type"] === "message")
2906
- 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
+ }
2907
3423
  else if (entry["type"] === "custom_message" && entry["display"] === true)
2908
3424
  messages.push({ role: "custom", content: entry["content"], customType: entry["customType"], details: entry["details"] });
2909
3425
  else if (entry["type"] === "compaction")
@@ -2948,7 +3464,7 @@ function finalAssistantText(messages) {
2948
3464
  }
2949
3465
  return "";
2950
3466
  }
2951
- function toClientEvent(event) {
3467
+ function toClientEvent(event, thinkingLevel) {
2952
3468
  const eventType = getString(event, "type");
2953
3469
  const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
2954
3470
  if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
@@ -2975,7 +3491,9 @@ function toClientEvent(event) {
2975
3491
  return { type: "agent.end" };
2976
3492
  if (eventType === "message_end") {
2977
3493
  const message = getProperty(event, "message");
2978
- 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) };
2979
3497
  }
2980
3498
  return { type: "pi.event", eventType: eventType ?? "unknown" };
2981
3499
  }