@jmfederico/pi-web 1.202606.7 → 1.202607.1

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 (135) hide show
  1. package/README.md +14 -5
  2. package/dist/cli.js +291 -320
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/CodeViewer-uASb28Y1.js +4 -0
  5. package/dist/client/assets/TerminalPanel-DfTmykFw.js +187 -0
  6. package/dist/client/assets/UnifiedDiffViewer-laQN1x7P.js +39 -0
  7. package/dist/client/assets/index-Dhapi0oR.js +3093 -0
  8. package/dist/client/assets/vendor-editor-core-vUi74DnD.js +12 -0
  9. package/dist/client/assets/vendor-editor-languages-DoaXUodB.js +46 -0
  10. package/dist/client/assets/vendor-editor-legacy-CYBnW6ZU.js +1 -0
  11. package/dist/client/assets/vendor-terminal-BrP-ENHg.css +1 -0
  12. package/dist/client/assets/vendor-terminal-D8k4UKM2.js +35 -0
  13. package/dist/client/index.html +9 -6
  14. package/dist/client/manifest.webmanifest +4 -4
  15. package/dist/config.js +144 -2
  16. package/dist/config.js.map +1 -1
  17. package/dist/docker/piWebDockerCommandPlan.js +211 -0
  18. package/dist/docker/piWebDockerCommandPlan.js.map +1 -0
  19. package/dist/nativeServices/serviceDoctor.js +480 -0
  20. package/dist/nativeServices/serviceDoctor.js.map +1 -0
  21. package/dist/nativeServices/serviceInstall.js +35 -0
  22. package/dist/nativeServices/serviceInstall.js.map +1 -0
  23. package/dist/nativeServices/servicePlan.js +358 -0
  24. package/dist/nativeServices/servicePlan.js.map +1 -0
  25. package/dist/nativeServices/serviceProbe.js +447 -0
  26. package/dist/nativeServices/serviceProbe.js.map +1 -0
  27. package/dist/nativeServices/serviceRendering.js +122 -0
  28. package/dist/nativeServices/serviceRendering.js.map +1 -0
  29. package/dist/pi-web-plugins/updates/pi-web-plugin.js +27 -5
  30. package/dist/pi-web-plugins/updates/updatesLogic.js +37 -5
  31. package/dist/piWebVersionReport.js +4 -0
  32. package/dist/piWebVersionReport.js.map +1 -1
  33. package/dist/plugin-api.d.ts +3 -1
  34. package/dist/server/activeAgentProfileProvider.js +25 -0
  35. package/dist/server/activeAgentProfileProvider.js.map +1 -0
  36. package/dist/server/app.js +69 -13
  37. package/dist/server/app.js.map +1 -1
  38. package/dist/server/browserMessageProjection.js +56 -0
  39. package/dist/server/browserMessageProjection.js.map +1 -0
  40. package/dist/server/configRoutes.js +147 -6
  41. package/dist/server/configRoutes.js.map +1 -1
  42. package/dist/server/machines/machineClient.js +19 -9
  43. package/dist/server/machines/machineClient.js.map +1 -1
  44. package/dist/server/machines/machinePluginProxyRoutes.js +7 -4
  45. package/dist/server/machines/machinePluginProxyRoutes.js.map +1 -1
  46. package/dist/server/machines/machineProxyRoutes.js +76 -11
  47. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  48. package/dist/server/machines/machineRoutes.js +1 -1
  49. package/dist/server/machines/machineRoutes.js.map +1 -1
  50. package/dist/server/machines/machineService.js +6 -28
  51. package/dist/server/machines/machineService.js.map +1 -1
  52. package/dist/server/piPackageRoutes.js +91 -0
  53. package/dist/server/piPackageRoutes.js.map +1 -0
  54. package/dist/server/piPackageService.js +101 -0
  55. package/dist/server/piPackageService.js.map +1 -0
  56. package/dist/server/piWebPluginService.js +91 -20
  57. package/dist/server/piWebPluginService.js.map +1 -1
  58. package/dist/server/piWebReleaseLookupCache.js +37 -0
  59. package/dist/server/piWebReleaseLookupCache.js.map +1 -0
  60. package/dist/server/piWebStatus.js +90 -37
  61. package/dist/server/piWebStatus.js.map +1 -1
  62. package/dist/server/piWebStatusCache.js +19 -6
  63. package/dist/server/piWebStatusCache.js.map +1 -1
  64. package/dist/server/realtime/sessionEventHub.js +34 -7
  65. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  66. package/dist/server/sessiond/sessionDaemonStartup.js +49 -0
  67. package/dist/server/sessiond/sessionDaemonStartup.js.map +1 -0
  68. package/dist/server/sessiond.js +88 -62
  69. package/dist/server/sessiond.js.map +1 -1
  70. package/dist/server/sessions/authProviderOptions.js +30 -32
  71. package/dist/server/sessions/authProviderOptions.js.map +1 -1
  72. package/dist/server/sessions/authRoutes.js +14 -4
  73. package/dist/server/sessions/authRoutes.js.map +1 -1
  74. package/dist/server/sessions/authService.js +89 -29
  75. package/dist/server/sessions/authService.js.map +1 -1
  76. package/dist/server/sessions/builtinCommands.js +1 -1
  77. package/dist/server/sessions/builtinCommands.js.map +1 -1
  78. package/dist/server/sessions/oauthLoginFlowService.js +176 -53
  79. package/dist/server/sessions/oauthLoginFlowService.js.map +1 -1
  80. package/dist/server/sessions/piSessionManagerGateway.js +22 -21
  81. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  82. package/dist/server/sessions/piSessionService.js +715 -127
  83. package/dist/server/sessions/piSessionService.js.map +1 -1
  84. package/dist/server/sessions/sessionArchiveMigration.js +590 -0
  85. package/dist/server/sessions/sessionArchiveMigration.js.map +1 -0
  86. package/dist/server/sessions/sessionArchiveStore.js +58 -26
  87. package/dist/server/sessions/sessionArchiveStore.js.map +1 -1
  88. package/dist/server/sessions/sessionCommandService.js +16 -0
  89. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  90. package/dist/server/sessions/sessionNameGenerator.js +27 -44
  91. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  92. package/dist/server/sessions/sessionRoutes.js +65 -3
  93. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  94. package/dist/server/sessions/sessionService.js +2 -0
  95. package/dist/server/sessions/sessionService.js.map +1 -0
  96. package/dist/server/sessions/spawnSessionTool.js +9 -4
  97. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  98. package/dist/server/sessions/spawnSubsessionTool.js +79 -32
  99. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  100. package/dist/server/terminals/terminalService.js +10 -4
  101. package/dist/server/terminals/terminalService.js.map +1 -1
  102. package/dist/server/workspaces/fileSuggestions.js +7 -3
  103. package/dist/server/workspaces/fileSuggestions.js.map +1 -1
  104. package/dist/sessiond/activeAgentProfile.js +31 -0
  105. package/dist/sessiond/activeAgentProfile.js.map +1 -0
  106. package/dist/sessiond/sessionDaemonClient.js +38 -0
  107. package/dist/sessiond/sessionDaemonClient.js.map +1 -1
  108. package/dist/shared/activeAgentProfile.js +76 -0
  109. package/dist/shared/activeAgentProfile.js.map +1 -0
  110. package/dist/shared/activity.js +0 -15
  111. package/dist/shared/activity.js.map +1 -1
  112. package/dist/shared/apiTypes.d.ts +160 -3
  113. package/dist/shared/apiTypes.js +6 -0
  114. package/dist/shared/apiTypes.js.map +1 -1
  115. package/dist/shared/capabilities.js +33 -2
  116. package/dist/shared/capabilities.js.map +1 -1
  117. package/dist/shared/federatedRoutes.js +14 -0
  118. package/dist/shared/federatedRoutes.js.map +1 -1
  119. package/dist/shared/piWebStatusParsing.js +26 -8
  120. package/dist/shared/piWebStatusParsing.js.map +1 -1
  121. package/dist/shared/thinkingLevels.d.ts +1 -1
  122. package/dist/shared/thinkingLevels.js +1 -1
  123. package/dist/shared/thinkingLevels.js.map +1 -1
  124. package/docs/config.md +75 -19
  125. package/docs/plugins.md +46 -18
  126. package/package.json +36 -32
  127. package/dist/client/assets/CodeViewer-CQU9mJ74.js +0 -4
  128. package/dist/client/assets/TerminalPanel-DtfVBsqI.js +0 -126
  129. package/dist/client/assets/UnifiedDiffViewer-BZQQK99S.js +0 -34
  130. package/dist/client/assets/index-CbE7df66.js +0 -2742
  131. package/dist/client/assets/vendor-editor-core-C7aWiQy0.js +0 -12
  132. package/dist/client/assets/vendor-editor-languages-EW34FfVy.js +0 -26
  133. package/dist/client/assets/vendor-editor-legacy-B4QLsWF8.js +0 -1
  134. package/dist/client/assets/vendor-terminal-DDGTF8rc.css +0 -1
  135. package/dist/client/assets/vendor-terminal-DjQ08hXu.js +0 -16
@@ -1,16 +1,18 @@
1
+ import { statSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { open, readFile, writeFile } from "node:fs/promises";
2
- import { AuthStorage, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, getAgentDir, ModelRegistry, SessionManager, } from "@earendil-works/pi-coding-agent";
4
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, readStoredCredential, SessionManager, } from "@earendil-works/pi-coding-agent";
5
+ import { projectBrowserMessage } from "../browserMessageProjection.js";
3
6
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
4
7
  import { BUILTIN_COMMANDS } from "./builtinCommands.js";
5
8
  import { SessionCommandService } from "./sessionCommandService.js";
6
9
  import { SessionArchiveStore } from "./sessionArchiveStore.js";
7
10
  import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
8
- import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
11
+ import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
9
12
  import { computeEditPreview } from "./editPreview.js";
10
- import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
11
13
  import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
12
14
  import { parsePromptAttachments } from "../../shared/promptAttachments.js";
13
- import { cwdPathsEqual } from "../workingDirectory.js";
15
+ import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
14
16
  import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
15
17
  import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
16
18
  import { buildTranscriptView } from "./subsessionTranscript.js";
@@ -48,23 +50,154 @@ function parsePromptStreamingBehavior(value) {
48
50
  return value;
49
51
  throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
50
52
  }
53
+ function resourceDiagnosticToWarning(diagnostic, source) {
54
+ return {
55
+ severity: diagnostic.type === "error" ? "error" : "warning",
56
+ message: diagnostic.message,
57
+ source,
58
+ ...(diagnostic.path === undefined ? {} : { path: diagnostic.path }),
59
+ };
60
+ }
61
+ function runtimeDiagnosticToWarning(diagnostic) {
62
+ return { severity: diagnostic.type, message: diagnostic.message, source: "runtime" };
63
+ }
64
+ /**
65
+ * Compute the live warnings for a runtime by re-reading its current resource
66
+ * loader diagnostics, extension load errors, and runtime setup diagnostics.
67
+ *
68
+ * This mimics the TUI recomputing warnings on every (re)bind: it reads the
69
+ * runtime's current state rather than a cached snapshot, so a rebuilt runtime
70
+ * yields fresh warnings. Runtimes without SDK services (e.g. test fakes)
71
+ * contribute no warnings.
72
+ */
73
+ export function collectRuntimeWarnings(runtime) {
74
+ const warnings = [];
75
+ for (const diagnostic of runtime.diagnostics ?? [])
76
+ warnings.push(runtimeDiagnosticToWarning(diagnostic));
77
+ const resourceLoader = runtime.services?.resourceLoader;
78
+ if (resourceLoader !== undefined) {
79
+ for (const diagnostic of resourceLoader.getSkills().diagnostics)
80
+ warnings.push(resourceDiagnosticToWarning(diagnostic, "skill"));
81
+ for (const diagnostic of resourceLoader.getPrompts().diagnostics)
82
+ warnings.push(resourceDiagnosticToWarning(diagnostic, "prompt"));
83
+ for (const diagnostic of resourceLoader.getThemes().diagnostics)
84
+ warnings.push(resourceDiagnosticToWarning(diagnostic, "theme"));
85
+ for (const error of resourceLoader.getExtensions().errors) {
86
+ warnings.push({ severity: "error", message: `${error.path}: ${error.error}`, source: "extension", path: error.path });
87
+ }
88
+ }
89
+ return warnings;
90
+ }
91
+ /**
92
+ * Verbatim TUI wording for the Anthropic subscription-auth billing notice. Kept
93
+ * character-for-character in sync with `ANTHROPIC_SUBSCRIPTION_AUTH_WARNING` in
94
+ * the SDK's interactive mode so the browser shows the same message the TUI does.
95
+ */
96
+ const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage.";
97
+ /** Mirror of the SDK TUI `isAnthropicSubscriptionAuthKey` (subscription API keys start with `sk-ant-oat`). */
98
+ function isAnthropicSubscriptionAuthKey(apiKey) {
99
+ return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
100
+ }
101
+ /**
102
+ * Dismiss id for the Anthropic subscription-auth billing notice. This is `pi`'s
103
+ * own `WarningSettings` key verbatim (`anthropicExtraUsage`): we carry the
104
+ * coupling `pi` already defines rather than inventing a parallel vocabulary, and
105
+ * {@link dismissSessionWarning} maps it back to `setWarnings`.
106
+ */
107
+ const ANTHROPIC_EXTRA_USAGE_DISMISS_ID = "anthropicExtraUsage";
108
+ /**
109
+ * Port of the TUI `maybeWarnAboutAnthropicSubscriptionAuth` gate/trigger, computed
110
+ * live from the session's current model, stored Anthropic credential, and warning
111
+ * settings. Returns the billing warning when the active provider is `anthropic`
112
+ * and auth is a subscription credential (stored `oauth`, or an `sk-ant-oat` API
113
+ * key), unless suppressed via `getWarnings().anthropicExtraUsage === false`.
114
+ *
115
+ * The stored credential is read synchronously (matching the TUI's `oauth` branch
116
+ * and the documented `sk-ant-oat` key trigger) so warnings stay part of the
117
+ * synchronous live status computation.
118
+ */
119
+ export function anthropicSubscriptionWarning(session, authPath) {
120
+ if (session.settingsManager.getWarnings().anthropicExtraUsage === false)
121
+ return undefined;
122
+ if (session.model?.provider !== "anthropic")
123
+ return undefined;
124
+ const credential = readStoredCredential("anthropic", authPath);
125
+ if (credential === undefined)
126
+ return undefined;
127
+ const isSubscriptionAuth = credential.type === "oauth"
128
+ ? true
129
+ : isAnthropicSubscriptionAuthKey(credential.key);
130
+ if (!isSubscriptionAuth)
131
+ return undefined;
132
+ return {
133
+ severity: "warning",
134
+ message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING,
135
+ source: "anthropic",
136
+ dismiss: { id: ANTHROPIC_EXTRA_USAGE_DISMISS_ID },
137
+ };
138
+ }
139
+ /**
140
+ * Durably suppress a dismissable session warning by mapping its opaque dismiss
141
+ * id back to the concrete `pi` suppression it represents. Only known ids are
142
+ * honored; unknown ids throw so a stale/forged client cannot silently no-op.
143
+ *
144
+ * This is the single place provider-specific suppression lives: the wire type,
145
+ * parser, and UI stay agnostic. Adding a future dismissable warning is a
146
+ * server-only change here plus a `dismiss` id on its producer.
147
+ */
148
+ export function dismissSessionWarning(session, dismissId) {
149
+ if (dismissId !== ANTHROPIC_EXTRA_USAGE_DISMISS_ID) {
150
+ throw new Error(`Unknown session warning dismiss id: ${dismissId}`);
151
+ }
152
+ session.settingsManager.setWarnings({ ...session.settingsManager.getWarnings(), anthropicExtraUsage: false });
153
+ }
51
154
  function defaultCreateAgentRuntime(createRuntime, options) {
52
155
  if (!(options.sessionManager instanceof SessionManager))
53
156
  throw new Error("Default runtime creation requires an SDK SessionManager");
54
- return createAgentSessionRuntime(createRuntime, { ...options, sessionManager: options.sessionManager });
55
- }
56
- function createDefaultRuntimeFactory(authStorage, modelRegistry, spawn, subsessions) {
57
- return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
58
- const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
59
- const customTools = [
60
- createPiWebEditToolDefinition(cwd),
61
- ...(spawn === undefined ? [] : [createSpawnSessionToolDefinition(cwd, { spawn })]),
62
- ...(subsessions === undefined ? [] : createSubsessionToolDefinitions(cwd, subsessions)),
63
- ];
64
- const options = sessionStartEvent === undefined
65
- ? { services, sessionManager, customTools }
66
- : { services, sessionManager, sessionStartEvent, customTools };
67
- const result = await createAgentSessionFromServices(options);
157
+ const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.delegationToolsEnabled);
158
+ return createAgentSessionRuntime(runtimeFactory, {
159
+ cwd: options.cwd,
160
+ agentDir: options.agentDir,
161
+ sessionManager: options.sessionManager,
162
+ });
163
+ }
164
+ function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, delegationToolsEnabled) {
165
+ // These inputs belong only to the session being opened. A later runtime
166
+ // replacement resolves its own model and delegation capability.
167
+ let pendingInitialModel = initialModel;
168
+ let pendingDelegationToolsEnabled = delegationToolsEnabled;
169
+ return async (options) => {
170
+ const model = pendingInitialModel;
171
+ const toolsEnabled = pendingDelegationToolsEnabled;
172
+ pendingInitialModel = undefined;
173
+ pendingDelegationToolsEnabled = undefined;
174
+ return createRuntime({
175
+ ...options,
176
+ ...(model === undefined ? {} : { initialModel: model }),
177
+ ...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }),
178
+ });
179
+ };
180
+ }
181
+ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions) {
182
+ return [
183
+ createPiWebEditToolDefinition(cwd),
184
+ ...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
185
+ ...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
186
+ ];
187
+ }
188
+ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions) {
189
+ return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
190
+ const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
191
+ const resolvedDelegationToolsEnabled = delegationToolsEnabled
192
+ ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
193
+ const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
194
+ const result = await createAgentSessionFromServices({
195
+ services,
196
+ sessionManager,
197
+ customTools,
198
+ ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
199
+ ...(initialModel === undefined ? {} : { model: initialModel }),
200
+ });
68
201
  return { ...result, services, diagnostics: services.diagnostics };
69
202
  };
70
203
  }
@@ -90,9 +223,10 @@ function createPiWebEditToolDefinition(cwd) {
90
223
  });
91
224
  }
92
225
  export class PiSessionService {
93
- constructor(events, deps = {}) {
226
+ constructor(events, deps) {
94
227
  this.events = events;
95
228
  this.active = new Map();
229
+ this.pendingSessionOpens = new Map();
96
230
  this.activities = new Map();
97
231
  this.compactionPromptQueues = new Map();
98
232
  this.compactionDrainTimers = new Map();
@@ -112,16 +246,16 @@ export class PiSessionService {
112
246
  */
113
247
  this.subsessionNotifyArmed = new Map();
114
248
  this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
115
- this.agentDir = deps.agentDir ?? getAgentDir();
116
- this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir });
117
- this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
249
+ this.agentDir = deps.agentDir;
250
+ this.sessionManager = deps.sessionManager;
251
+ this.modelRuntime = deps.modelRuntime;
118
252
  this.spawnTargets = deps.spawnTargets;
119
253
  this.logger = deps.logger ?? noopLogger;
120
254
  this.now = deps.now ?? (() => new Date());
121
255
  // Subsessions are a beta capability gated behind their own flag, and they
122
256
  // also require the spawn capability (they share its project-scope resolver).
123
257
  const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
124
- this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
258
+ this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
125
259
  spawn: (input) => this.spawnSubsession(input),
126
260
  list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
127
261
  check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
@@ -139,6 +273,7 @@ export class PiSessionService {
139
273
  this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
140
274
  this.publishStatus(session);
141
275
  },
276
+ reloadSession: (session) => this.reloadSessionRuntime(session),
142
277
  }, { listSessionNames: (cwd) => this.listSessionNames(cwd) });
143
278
  }
144
279
  activeCount() {
@@ -149,10 +284,12 @@ export class PiSessionService {
149
284
  }
150
285
  async cleanup(request) {
151
286
  const plan = await this.cleanupPlan(request);
152
- if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined)
287
+ if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
153
288
  throw new Error("Archive store does not support deletion");
154
289
  const archiveInputs = [];
290
+ const readyArchiveInputs = [];
155
291
  const deleteRecords = [];
292
+ const readyDeleteRecords = [];
156
293
  const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
157
294
  for (const input of plan.archiveInputs) {
158
295
  if (this.activeSessionHasWork(input.sessionId)) {
@@ -160,20 +297,21 @@ export class PiSessionService {
160
297
  continue;
161
298
  }
162
299
  await this.closeActive(input.sessionId);
163
- await this.archiveStore.archive(input);
164
- archiveInputs.push(input);
300
+ readyArchiveInputs.push(input);
165
301
  }
302
+ await this.archiveStoreArchiveMany(readyArchiveInputs);
303
+ archiveInputs.push(...readyArchiveInputs);
166
304
  for (const record of plan.deleteRecords) {
167
305
  if (this.activeSessionHasWork(record.sessionId)) {
168
306
  skippedBusySessionIds.add(record.sessionId);
169
307
  continue;
170
308
  }
171
309
  await this.closeActive(record.sessionId);
172
- if (record.archivePath === undefined)
173
- await this.ensureArchivedRecordMoved(record);
174
- await this.archiveStore.deleteArchived?.(record.sessionId);
175
- deleteRecords.push(record);
310
+ readyDeleteRecords.push(record);
176
311
  }
312
+ await this.ensureArchivedRecordsMoved(readyDeleteRecords);
313
+ const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
314
+ deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
177
315
  return summarizeSessionCleanupExecution({
178
316
  archiveInputs,
179
317
  deleteRecords,
@@ -185,8 +323,12 @@ export class PiSessionService {
185
323
  async dispose() {
186
324
  clearInterval(this.heartbeat);
187
325
  this.clearCompactionDrainTimers();
326
+ const pendingOpens = this.pendingSessionOpenPromises();
327
+ if (pendingOpens.length > 0)
328
+ await Promise.allSettled(pendingOpens);
188
329
  const activeSessions = Array.from(new Set(this.active.values()));
189
330
  this.active.clear();
331
+ this.pendingSessionOpens.clear();
190
332
  this.activities.clear();
191
333
  this.compactionPromptQueues.clear();
192
334
  this.authLossWarnings.clear();
@@ -198,8 +340,12 @@ export class PiSessionService {
198
340
  await Promise.all(activeSessions.map(async (active) => {
199
341
  active.unsubscribe();
200
342
  this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
201
- await active.runtime.session.abort();
202
- await active.runtime.dispose();
343
+ try {
344
+ await active.runtime.session.abort();
345
+ }
346
+ finally {
347
+ await active.runtime.dispose();
348
+ }
203
349
  }));
204
350
  }
205
351
  async list(cwd) {
@@ -217,20 +363,27 @@ export class PiSessionService {
217
363
  .filter(isDefined);
218
364
  return [...unarchivedSessions, ...archivedSessions];
219
365
  }
220
- async start(cwd, parentSession) {
221
- const active = await this.create(this.sessionManager.create(cwd, parentSession === undefined ? undefined : { parentSession }), cwd);
366
+ async start(cwd, options = {}) {
367
+ return this.startSession(cwd, options);
368
+ }
369
+ async startSession(cwd, options) {
370
+ const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
371
+ ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
372
+ ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
373
+ });
222
374
  const { session } = active.runtime;
223
375
  const created = {
224
376
  id: session.sessionId,
225
377
  path: session.sessionFile ?? "",
226
378
  cwd,
379
+ persisted: sessionFileExists(session.sessionFile),
227
380
  created: new Date().toISOString(),
228
381
  modified: new Date().toISOString(),
229
382
  messageCount: session.messages.length,
230
383
  firstMessage: "",
231
384
  // Include the parent so listeners can nest the new session in the tree
232
385
  // immediately, instead of showing it flat until the next reload.
233
- ...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
386
+ ...(options.parentSession === undefined ? {} : { parentSessionPath: options.parentSession }),
234
387
  };
235
388
  // Broadcast so other clients (and the spawning agent's UI) can add the new
236
389
  // session to their list without a manual reload.
@@ -248,7 +401,7 @@ export class PiSessionService {
248
401
  const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
249
402
  if (!decision.allowed)
250
403
  throw spawnTargetError(decision);
251
- const created = await this.start(decision.cwd);
404
+ const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
252
405
  await this.prompt(created.id, input.prompt);
253
406
  this.logger.info({ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_session started a new session");
254
407
  return { sessionId: created.id, cwd: decision.cwd };
@@ -265,7 +418,11 @@ export class PiSessionService {
265
418
  const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
266
419
  if (!decision.allowed)
267
420
  throw spawnTargetError(decision);
268
- const created = await this.start(decision.cwd, input.parentSessionFile);
421
+ const created = await this.startSession(decision.cwd, {
422
+ ...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
423
+ ...(input.model === undefined ? {} : { initialModel: input.model }),
424
+ creationProvenance: "tracked-subsession",
425
+ });
269
426
  const parentSessionFile = nonEmptyString(input.parentSessionFile);
270
427
  const link = {
271
428
  parentSessionId: input.parentSessionId,
@@ -465,61 +622,13 @@ export class PiSessionService {
465
622
  return;
466
623
  this.registerVerifiedSubsession(link);
467
624
  }
468
- async verifiedSubsessionLinkFromOpenedChild(session) {
469
- // Child markers are only hints; the current child header and reciprocal
470
- // parent custom link must agree on the exact ids and files before relinking.
471
- const entries = session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch();
472
- let marker;
473
- for (const entry of entries) {
474
- const parsed = parsePersistedChildSubsessionLink(entry);
475
- if (parsed?.spawnedSessionId === session.sessionId)
476
- marker = parsed;
477
- }
478
- if (marker === undefined)
479
- return undefined;
480
- const childSessionFile = nonEmptyString(session.sessionFile);
481
- if (childSessionFile === undefined)
482
- return undefined;
483
- const childHeader = await readSessionHeaderSummary(childSessionFile);
484
- if (childHeader?.id !== session.sessionId)
485
- return undefined;
486
- const parentSessionFile = nonEmptyString(childHeader.parentSession);
487
- if (parentSessionFile === undefined)
488
- return undefined;
489
- const parentHeader = await readSessionHeaderSummary(parentSessionFile);
490
- if (parentHeader?.id !== marker.spawnedBySessionId)
491
- return undefined;
492
- const parentLink = this.findReciprocalParentSubsessionLink(parentSessionFile, marker.spawnedBySessionId, session.sessionId, childSessionFile);
493
- if (parentLink === undefined)
494
- return undefined;
495
- return {
496
- parentSessionId: marker.spawnedBySessionId,
497
- childSessionId: session.sessionId,
498
- childSessionFile,
499
- parentSessionFile,
500
- cwd: parentLink.cwd ?? session.sessionManager.getCwd(),
501
- };
502
- }
503
- findReciprocalParentSubsessionLink(parentSessionFile, parentSessionId, childSessionId, childSessionFile) {
504
- let parentManager;
505
- try {
506
- parentManager = this.sessionManager.open(parentSessionFile);
507
- }
508
- catch {
509
- return undefined;
510
- }
511
- const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
512
- for (const entry of entries) {
513
- const link = parsePersistedParentSubsessionLink(entry);
514
- if (link === undefined)
515
- continue;
516
- if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId)
517
- continue;
518
- if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile))
519
- continue;
520
- return link;
521
- }
522
- return undefined;
625
+ verifiedSubsessionLinkFromOpenedChild(session) {
626
+ return verifiedTrackedSubsessionLink(this.sessionManager, {
627
+ sessionId: session.sessionId,
628
+ sessionFile: session.sessionFile,
629
+ sessionManager: session.sessionManager,
630
+ cwd: session.sessionManager.getCwd(),
631
+ });
523
632
  }
524
633
  async getOrOpenTrackedSubsession(sessionId) {
525
634
  const link = this.subsessionLinks.get(sessionId);
@@ -556,6 +665,16 @@ export class PiSessionService {
556
665
  return "error";
557
666
  return "idle";
558
667
  }
668
+ workingSubsessionIds(parentSessionId) {
669
+ const childIds = this.subsessionChildren.get(parentSessionId);
670
+ if (childIds === undefined)
671
+ return [];
672
+ return [...childIds].filter((childId) => {
673
+ const link = this.subsessionLinks.get(childId);
674
+ const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
675
+ return active !== undefined && this.hasActiveWork(active.runtime.session);
676
+ });
677
+ }
559
678
  /**
560
679
  * Drive parent notifications from a tracked child's status. Arms a pending
561
680
  * notification while the child is working, and when it stops fires a single
@@ -576,8 +695,12 @@ export class PiSessionService {
576
695
  this.subsessionNotifyArmed.set(childId, false);
577
696
  const status = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
578
697
  const finalText = finalAssistantText(historyMessages(session));
579
- const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
580
- const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nUse check_subsession with sessionId "${childId}" for its status and latest output, or read_subsession to look through its full transcript.`;
698
+ const outputSection = formatSubsessionNotificationOutput(childId, finalText);
699
+ const workingIds = this.workingSubsessionIds(link.parentSessionId);
700
+ const next = workingIds.length === 0
701
+ ? "No other tracked subsessions are working."
702
+ : `Still working: ${workingIds.join(", ")}. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.`;
703
+ const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n${outputSection}`;
581
704
  void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
582
705
  }
583
706
  async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
@@ -620,23 +743,42 @@ export class PiSessionService {
620
743
  async status(ref) {
621
744
  return this.statusFromSession(await this.getOrOpen(ref));
622
745
  }
746
+ /**
747
+ * Join-time snapshot of the in-flight assistant stream. The `seq` watermark and
748
+ * the partial are read together in one synchronous tick (no await between the
749
+ * `currentSeq` read and the `state.streamingMessage` read) so a joining client
750
+ * can seed the partial and then apply only buffered live events with
751
+ * `seq > snapshot.seq`. The partial is browser-projected to strip thinking
752
+ * signatures; it is `null` when no assistant message is mid-stream.
753
+ */
754
+ async streamSnapshot(ref) {
755
+ const session = await this.getOrOpen(ref);
756
+ // Single consistent tick: capture the watermark and the partial together so
757
+ // the seq matches the partial the client seeds against.
758
+ const seq = this.events.currentSeq(session.sessionId);
759
+ const streamingMessage = session.state.streamingMessage;
760
+ const partial = streamingMessage === undefined || streamingMessage === null
761
+ ? null
762
+ : projectBrowserMessage(streamingMessage);
763
+ return { seq, partial };
764
+ }
623
765
  async availableModels(ref) {
624
766
  const session = await this.getOrOpen(ref);
625
- session.modelRegistry.refresh();
767
+ await session.modelRuntime.reloadConfig();
626
768
  const models = session.scopedModels.length > 0
627
769
  ? session.scopedModels.map((scoped) => scoped.model)
628
- : session.modelRegistry.getAvailable();
770
+ : session.modelRuntime.getAvailableSnapshot();
629
771
  return models.map(modelToClientModel);
630
772
  }
631
773
  async setModel(ref, provider, modelId) {
632
774
  await this.assertWritable(ref);
633
775
  const session = await this.getOrOpen(ref);
634
- session.modelRegistry.refresh();
776
+ await session.modelRuntime.reloadConfig();
635
777
  const candidates = session.scopedModels.length > 0
636
778
  ? session.scopedModels.map((scoped) => scoped.model)
637
- : session.modelRegistry.getAvailable();
779
+ : session.modelRuntime.getAvailableSnapshot();
638
780
  const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
639
- ?? session.modelRegistry.find(provider, modelId);
781
+ ?? session.modelRuntime.getModel(provider, modelId);
640
782
  if (model === undefined)
641
783
  throw new Error(`Model not found: ${provider}/${modelId}`);
642
784
  await session.setModel(model);
@@ -795,6 +937,23 @@ export class PiSessionService {
795
937
  const active = await this.getActive(ref);
796
938
  return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
797
939
  }
940
+ async reloadSessionRuntime(session) {
941
+ if (this.hasActiveWork(session))
942
+ throw new Error("Stop current session activity before reloading");
943
+ this.publishActivity(session, "reloading resources", "active");
944
+ try {
945
+ await session.reload();
946
+ this.publishActivity(session, "resources reloaded", "idle");
947
+ this.publishStatus(session);
948
+ }
949
+ catch (error) {
950
+ const message = error instanceof Error ? error.message : String(error);
951
+ this.publishActivity(session, "reload failed", "error", message);
952
+ this.events.publish(session.sessionId, { type: "session.error", message });
953
+ this.publishStatus(session);
954
+ throw error;
955
+ }
956
+ }
798
957
  async archive(ref) {
799
958
  const session = await this.getOrOpen(ref);
800
959
  if (this.hasActiveWork(session))
@@ -803,6 +962,69 @@ export class PiSessionService {
803
962
  await this.closeActive(session.sessionId);
804
963
  await this.archiveStore.archive(archiveInput);
805
964
  }
965
+ async archiveMany(refs) {
966
+ const uniqueRefs = uniqueBulkSessionRefs(refs);
967
+ const [archivedRecords, sessionContext] = await Promise.all([
968
+ this.archiveStore.list(),
969
+ this.bulkSessionLookupContext(uniqueRefs),
970
+ ]);
971
+ const failures = [];
972
+ const alreadyArchivedSessionIds = [];
973
+ const planItems = [];
974
+ for (const ref of uniqueRefs) {
975
+ const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
976
+ if (archived !== undefined) {
977
+ alreadyArchivedSessionIds.push(archived.sessionId);
978
+ continue;
979
+ }
980
+ const active = this.activeForLookup(bulkRefToLookup(ref));
981
+ const listed = findListedSessionForBulkRef(sessionContext, ref);
982
+ const resolvedSessionId = active?.runtime.session.sessionId ?? listed?.id ?? ref.id;
983
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
984
+ failures.push({ sessionId: resolvedSessionId, error: "Stop current session activity before archiving" });
985
+ continue;
986
+ }
987
+ try {
988
+ if (listed !== undefined) {
989
+ planItems.push({ input: archiveInputFromListEntry(listed) });
990
+ }
991
+ else if (active !== undefined) {
992
+ planItems.push({ input: archiveInputFromActiveSession(active.runtime.session) });
993
+ }
994
+ else {
995
+ failures.push({ sessionId: ref.id, error: "Session not found" });
996
+ }
997
+ }
998
+ catch (error) {
999
+ failures.push({ sessionId: resolvedSessionId, error: errorMessage(error) });
1000
+ }
1001
+ }
1002
+ const readyInputs = [];
1003
+ for (const item of planItems) {
1004
+ try {
1005
+ await this.closeActive(item.input.sessionId);
1006
+ readyInputs.push(item.input);
1007
+ }
1008
+ catch (error) {
1009
+ failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
1010
+ }
1011
+ }
1012
+ const archivedSessionIds = [...alreadyArchivedSessionIds];
1013
+ try {
1014
+ const archived = await this.archiveStoreArchiveMany(readyInputs);
1015
+ archivedSessionIds.push(...archived.map((record) => record.sessionId));
1016
+ }
1017
+ catch (error) {
1018
+ for (const input of readyInputs)
1019
+ failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
1020
+ }
1021
+ return {
1022
+ archived: true,
1023
+ archivedSessionIds: uniqueStrings(archivedSessionIds),
1024
+ failures,
1025
+ generatedAt: new Date().toISOString(),
1026
+ };
1027
+ }
806
1028
  async archiveTree(ref) {
807
1029
  const session = await this.getOrOpen(ref);
808
1030
  const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
@@ -814,8 +1036,7 @@ export class PiSessionService {
814
1036
  const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
815
1037
  for (const input of archiveInputs)
816
1038
  await this.closeActive(input.sessionId);
817
- for (const input of archiveInputs)
818
- await this.archiveStore.archive(input);
1039
+ await this.archiveStoreArchiveMany(archiveInputs);
819
1040
  return {
820
1041
  archived: true,
821
1042
  sessionIds: archiveInputs.map((input) => input.sessionId),
@@ -841,6 +1062,57 @@ export class PiSessionService {
841
1062
  await this.ensureArchivedRecordMoved(record);
842
1063
  await this.archiveStore.deleteArchived(record.sessionId);
843
1064
  }
1065
+ async deleteArchivedMany(refs) {
1066
+ if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
1067
+ throw new Error("Archive store does not support deletion");
1068
+ const uniqueRefs = uniqueBulkSessionRefs(refs);
1069
+ const archivedRecords = await this.archiveStore.list();
1070
+ const failures = [];
1071
+ const planItems = [];
1072
+ for (const ref of uniqueRefs) {
1073
+ const record = findArchivedRecordForBulkRef(archivedRecords, ref);
1074
+ if (record === undefined) {
1075
+ failures.push({ sessionId: ref.id, error: "Archived session not found" });
1076
+ continue;
1077
+ }
1078
+ const active = this.activeForLookup({ id: record.sessionId, cwd: record.cwd });
1079
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
1080
+ failures.push({ sessionId: record.sessionId, error: "Stop current session activity before deleting archived session" });
1081
+ continue;
1082
+ }
1083
+ planItems.push({ record });
1084
+ }
1085
+ const readyRecords = [];
1086
+ for (const item of planItems) {
1087
+ try {
1088
+ await this.closeActive(item.record.sessionId);
1089
+ readyRecords.push(item.record);
1090
+ }
1091
+ catch (error) {
1092
+ failures.push({ sessionId: item.record.sessionId, error: errorMessage(error) });
1093
+ }
1094
+ }
1095
+ const moveFailures = await this.moveLegacyArchivedRecordsForDelete(readyRecords);
1096
+ failures.push(...moveFailures);
1097
+ const moveFailureIds = new Set(moveFailures.map((failure) => failure.sessionId));
1098
+ const deleteIds = readyRecords
1099
+ .map((record) => record.sessionId)
1100
+ .filter((sessionId) => !moveFailureIds.has(sessionId));
1101
+ let deletedSessionIds = [];
1102
+ try {
1103
+ deletedSessionIds = await this.archiveStoreDeleteArchivedMany(deleteIds);
1104
+ }
1105
+ catch (error) {
1106
+ for (const sessionId of deleteIds)
1107
+ failures.push({ sessionId, error: errorMessage(error) });
1108
+ }
1109
+ return {
1110
+ deleted: true,
1111
+ deletedSessionIds,
1112
+ failures,
1113
+ generatedAt: new Date().toISOString(),
1114
+ };
1115
+ }
844
1116
  async reload(ref) {
845
1117
  await this.assertWritable(ref);
846
1118
  const session = await this.getOrOpen(ref);
@@ -859,6 +1131,20 @@ export class PiSessionService {
859
1131
  clearParentSessionHeader(session.sessionManager);
860
1132
  this.unregisterSubsession(session.sessionId);
861
1133
  }
1134
+ async clearQueue(ref) {
1135
+ await this.assertWritable(ref);
1136
+ const session = await this.getOrOpen(ref);
1137
+ this.clearCompactionPromptQueue(session.sessionId);
1138
+ clearSessionQueue(session);
1139
+ this.publishStatus(session);
1140
+ return this.statusFromSession(session);
1141
+ }
1142
+ async dismissWarning(ref, dismissId) {
1143
+ const session = await this.getOrOpen(ref);
1144
+ dismissSessionWarning(session, dismissId);
1145
+ this.publishStatus(session);
1146
+ return this.statusFromSession(session);
1147
+ }
862
1148
  async abort(ref) {
863
1149
  const active = this.activeForLookup(ref);
864
1150
  if (active === undefined)
@@ -878,6 +1164,75 @@ export class PiSessionService {
878
1164
  // Best-effort shutdown; callers that need errors await closeActive directly.
879
1165
  });
880
1166
  }
1167
+ async bulkSessionLookupContext(refs) {
1168
+ const cwdSet = new Set();
1169
+ let needsAllSessions = false;
1170
+ for (const ref of refs) {
1171
+ if (ref.cwd === undefined)
1172
+ needsAllSessions = true;
1173
+ else
1174
+ cwdSet.add(ref.cwd);
1175
+ }
1176
+ const [sessionsByCwd, allSessions] = await Promise.all([
1177
+ this.listSessionsByCwd([...cwdSet]),
1178
+ needsAllSessions ? this.sessionManager.listAll?.() ?? Promise.resolve([]) : Promise.resolve(undefined),
1179
+ ]);
1180
+ return allSessions === undefined ? { sessionsByCwd } : { sessionsByCwd, allSessions };
1181
+ }
1182
+ async listSessionsByCwd(cwds) {
1183
+ const uniqueCwds = uniqueStrings(cwds);
1184
+ const entries = await Promise.all(uniqueCwds.map(async (cwd) => [cwd, await this.sessionManager.list(cwd)]));
1185
+ return new Map(entries);
1186
+ }
1187
+ async archiveStoreArchiveMany(inputs) {
1188
+ if (inputs.length === 0)
1189
+ return [];
1190
+ if (this.archiveStore.archiveMany !== undefined)
1191
+ return this.archiveStore.archiveMany(inputs);
1192
+ const records = [];
1193
+ for (const input of inputs)
1194
+ records.push(await this.archiveStore.archive(input));
1195
+ return records;
1196
+ }
1197
+ async archiveStoreDeleteArchivedMany(sessionIds) {
1198
+ if (sessionIds.length === 0)
1199
+ return [];
1200
+ if (this.archiveStore.deleteArchivedMany !== undefined)
1201
+ return this.archiveStore.deleteArchivedMany(sessionIds);
1202
+ if (this.archiveStore.deleteArchived === undefined)
1203
+ throw new Error("Archive store does not support deletion");
1204
+ for (const sessionId of sessionIds)
1205
+ await this.archiveStore.deleteArchived(sessionId);
1206
+ return [...sessionIds];
1207
+ }
1208
+ async moveLegacyArchivedRecordsForDelete(records) {
1209
+ const legacyRecords = records.filter((record) => record.archivePath === undefined);
1210
+ if (legacyRecords.length === 0)
1211
+ return [];
1212
+ let sessionsByCwd;
1213
+ try {
1214
+ sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
1215
+ }
1216
+ catch (error) {
1217
+ return legacyRecords.map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
1218
+ }
1219
+ const moveInputs = legacyRecords
1220
+ .map((record) => findSessionByIdOrPrefix(sessionsByCwd.get(record.cwd) ?? [], record.sessionId))
1221
+ .filter(isDefined)
1222
+ .map(archiveInputFromListEntry);
1223
+ if (moveInputs.length === 0)
1224
+ return [];
1225
+ try {
1226
+ await this.archiveStoreArchiveMany(moveInputs);
1227
+ return [];
1228
+ }
1229
+ catch (error) {
1230
+ const failedIds = new Set(moveInputs.map((input) => input.sessionId));
1231
+ return legacyRecords
1232
+ .filter((record) => failedIds.has(record.sessionId))
1233
+ .map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
1234
+ }
1235
+ }
881
1236
  async cleanupPlan(request) {
882
1237
  const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll?.() ?? [], this.archiveStore.list()]);
883
1238
  return planSessionCleanup({
@@ -922,7 +1277,19 @@ export class PiSessionService {
922
1277
  const session = (await this.sessionManager.list(record.cwd)).find((candidate) => candidate.id === record.sessionId);
923
1278
  if (session === undefined)
924
1279
  return record;
925
- return this.archiveStore.archive(archiveInputFromListEntry(session));
1280
+ const [moved] = await this.archiveStoreArchiveMany([archiveInputFromListEntry(session)]);
1281
+ return moved ?? record;
1282
+ }
1283
+ async ensureArchivedRecordsMoved(records) {
1284
+ const legacyRecords = records.filter((record) => record.archivePath === undefined);
1285
+ if (legacyRecords.length === 0)
1286
+ return;
1287
+ const sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
1288
+ const moveInputs = legacyRecords
1289
+ .map((record) => sessionsByCwd.get(record.cwd)?.find((candidate) => candidate.id === record.sessionId))
1290
+ .filter(isDefined)
1291
+ .map(archiveInputFromListEntry);
1292
+ await this.archiveStoreArchiveMany(moveInputs);
926
1293
  }
927
1294
  async archiveInputForSession(session) {
928
1295
  const cwd = session.sessionManager.getCwd();
@@ -985,6 +1352,9 @@ export class PiSessionService {
985
1352
  return [...names];
986
1353
  }
987
1354
  async closeActive(sessionId) {
1355
+ const pendingOpens = this.pendingSessionOpenPromises(sessionId);
1356
+ if (pendingOpens.length > 0)
1357
+ await Promise.allSettled(pendingOpens);
988
1358
  const active = this.active.get(sessionId);
989
1359
  if (!active)
990
1360
  return;
@@ -1019,14 +1389,40 @@ export class PiSessionService {
1019
1389
  if (active !== undefined)
1020
1390
  return active;
1021
1391
  const archived = await this.getArchived(ref);
1022
- if (archived?.archivePath !== undefined)
1023
- return this.create(this.sessionManager.open(archived.archivePath), archived.cwd);
1392
+ if (archived?.archivePath !== undefined) {
1393
+ const { archivePath } = archived;
1394
+ return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath));
1395
+ }
1024
1396
  const match = isPiSessionRef(ref)
1025
1397
  ? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
1026
1398
  : (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
1027
1399
  if (!match)
1028
1400
  throw new Error("Session not found");
1029
- return this.create(this.sessionManager.open(match.path), match.cwd);
1401
+ return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path));
1402
+ }
1403
+ openExistingSession(sessionId, cwd, openSessionManager) {
1404
+ const active = this.activeForLookup({ id: sessionId, cwd });
1405
+ if (active !== undefined)
1406
+ return Promise.resolve(active);
1407
+ const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionId]);
1408
+ const existing = this.pendingSessionOpens.get(key);
1409
+ if (existing !== undefined)
1410
+ return existing.promise;
1411
+ const pending = {
1412
+ sessionId,
1413
+ promise: this.create(openSessionManager(), cwd),
1414
+ };
1415
+ pending.promise = pending.promise.finally(() => {
1416
+ if (this.pendingSessionOpens.get(key) === pending)
1417
+ this.pendingSessionOpens.delete(key);
1418
+ });
1419
+ this.pendingSessionOpens.set(key, pending);
1420
+ return pending.promise;
1421
+ }
1422
+ pendingSessionOpenPromises(sessionId) {
1423
+ return [...this.pendingSessionOpens.values()]
1424
+ .filter((pending) => sessionId === undefined || pending.sessionId === sessionId)
1425
+ .map((pending) => pending.promise);
1030
1426
  }
1031
1427
  async getArchived(ref) {
1032
1428
  const archived = await this.archiveStore.get(sessionIdFromLookup(ref));
@@ -1047,23 +1443,77 @@ export class PiSessionService {
1047
1443
  }
1048
1444
  return undefined;
1049
1445
  }
1050
- async create(sessionManager, cwd) {
1051
- const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
1052
- await this.bindSessionExtensions(runtime.session);
1446
+ async create(sessionManager, cwd, options = {}) {
1447
+ const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
1448
+ && await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
1449
+ const runtime = await this.createAgentRuntime(this.createRuntime, {
1450
+ cwd,
1451
+ agentDir: this.agentDir,
1452
+ sessionManager,
1453
+ delegationToolsEnabled,
1454
+ ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
1455
+ });
1053
1456
  const active = { runtime, unsubscribe: noop };
1054
- this.bindRuntime(active);
1055
- runtime.setRebindSession(async (session) => {
1056
- await this.bindSessionExtensions(session);
1457
+ try {
1458
+ await this.bindSessionExtensions(runtime.session);
1057
1459
  this.bindRuntime(active);
1058
- await this.recoverSubsessionTrackingForOpenedSession(session);
1059
- });
1060
- this.active.set(runtime.session.sessionId, active);
1061
- await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
1062
- this.publishStatus(runtime.session);
1063
- return active;
1460
+ runtime.setRebindSession(async (session) => {
1461
+ await this.bindSessionExtensions(session);
1462
+ this.bindRuntime(active);
1463
+ await this.recoverSubsessionTrackingForOpenedSession(session);
1464
+ });
1465
+ this.active.set(runtime.session.sessionId, active);
1466
+ await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
1467
+ this.publishStatus(runtime.session);
1468
+ return active;
1469
+ }
1470
+ catch (error) {
1471
+ active.unsubscribe();
1472
+ let removedActive = false;
1473
+ for (const [sessionId, candidate] of this.active.entries()) {
1474
+ if (candidate !== active)
1475
+ continue;
1476
+ this.active.delete(sessionId);
1477
+ this.activities.delete(sessionId);
1478
+ this.clearAuthLossWarningsForSession(sessionId);
1479
+ this.clearCompactionPromptQueue(sessionId);
1480
+ removedActive = true;
1481
+ }
1482
+ if (removedActive) {
1483
+ this.workspaceActivity?.removeSession(runtime.session.sessionId, runtime.session.sessionManager.getCwd());
1484
+ }
1485
+ try {
1486
+ await runtime.session.abort();
1487
+ }
1488
+ finally {
1489
+ await runtime.dispose();
1490
+ }
1491
+ throw error;
1492
+ }
1064
1493
  }
1065
1494
  async bindSessionExtensions(session) {
1495
+ const baseUiContext = session.extensionRunner.getUIContext();
1496
+ const notify = (message, type) => {
1497
+ this.events.publish(session.sessionId, {
1498
+ type: "command.output",
1499
+ level: type === "error" ? "error" : "info",
1500
+ message,
1501
+ });
1502
+ };
1503
+ // PI WEB is a remote UI host, but currently only extension notifications
1504
+ // cross this boundary. Delegate every other UI method to Pi's headless
1505
+ // defaults so unsupported dialogs cancel safely instead of hanging.
1506
+ const uiContext = new Proxy(baseUiContext, {
1507
+ get(target, property, receiver) {
1508
+ if (property === "notify")
1509
+ return notify;
1510
+ const value = Reflect.get(target, property, receiver);
1511
+ return value;
1512
+ },
1513
+ });
1066
1514
  await session.bindExtensions({
1515
+ uiContext,
1516
+ mode: "rpc",
1067
1517
  onError: (error) => {
1068
1518
  const message = `${error.extensionPath}: ${error.error}`;
1069
1519
  this.publishActivity(session, "extension error", "error", message);
@@ -1156,10 +1606,15 @@ export class PiSessionService {
1156
1606
  maybeGenerateSessionName(session, firstMessage) {
1157
1607
  if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting)
1158
1608
  return;
1609
+ const deterministicName = deterministicSessionName(firstMessage);
1610
+ if (deterministicName !== undefined) {
1611
+ this.applyGeneratedSessionName(session, deterministicName);
1612
+ return;
1613
+ }
1159
1614
  const model = session.model;
1160
1615
  if (model === undefined)
1161
1616
  return;
1162
- void generateShortSessionName(this.modelRegistry, model, firstMessage).then((name) => {
1617
+ void generateShortSessionName(session.agent.streamFn, model, firstMessage).then((name) => {
1163
1618
  this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
1164
1619
  }).catch(() => {
1165
1620
  this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
@@ -1172,10 +1627,11 @@ export class PiSessionService {
1172
1627
  this.publishSessionName(session);
1173
1628
  }
1174
1629
  applyAuthChange(change = {}) {
1175
- this.modelRegistry.refresh();
1630
+ // ModelRuntime.login()/logout() refresh the shared runtime before AuthService
1631
+ // emits the change, so no refresh is needed here. Keeping this synchronous
1632
+ // also lets every active session observe the same committed auth snapshot.
1176
1633
  for (const active of this.active.values()) {
1177
1634
  const { session } = active.runtime;
1178
- session.modelRegistry.refresh();
1179
1635
  this.syncCurrentModelAuthWarning(session, change.removedProviderId);
1180
1636
  this.publishStatus(session);
1181
1637
  }
@@ -1187,10 +1643,10 @@ export class PiSessionService {
1187
1643
  if (model.provider === "unknown" && model.id === "unknown")
1188
1644
  return;
1189
1645
  const warningKey = authLossWarningKey(session.sessionId, model.provider, model.id);
1190
- const registered = session.modelRegistry.find(model.provider, model.id);
1646
+ const registered = session.modelRuntime.getModel(model.provider, model.id);
1191
1647
  if (registered === undefined)
1192
1648
  return;
1193
- if (session.modelRegistry.hasConfiguredAuth(registered)) {
1649
+ if (session.modelRuntime.hasConfiguredAuth(model.provider)) {
1194
1650
  this.authLossWarnings.delete(warningKey);
1195
1651
  return;
1196
1652
  }
@@ -1334,8 +1790,10 @@ export class PiSessionService {
1334
1790
  const stats = session.getSessionStats();
1335
1791
  const model = session.model === undefined ? undefined : modelToClientModel(session.model);
1336
1792
  const contextUsage = session.getContextUsage();
1793
+ const warnings = this.warningsForSession(session);
1337
1794
  return {
1338
1795
  sessionId: session.sessionId,
1796
+ persisted: sessionFileExists(session.sessionFile),
1339
1797
  ...(model === undefined ? {} : { model }),
1340
1798
  thinkingLevel: session.thinkingLevel,
1341
1799
  isStreaming: session.isStreaming,
@@ -1347,8 +1805,23 @@ export class PiSessionService {
1347
1805
  tokens: stats.tokens,
1348
1806
  cost: stats.cost,
1349
1807
  ...(contextUsage === undefined ? {} : { contextUsage }),
1808
+ ...(warnings.length === 0 ? {} : { warnings }),
1350
1809
  };
1351
1810
  }
1811
+ /**
1812
+ * Compute the live warning set for a session: runtime/resource diagnostics from
1813
+ * the active runtime (if any) plus the Anthropic subscription-auth notice. Read
1814
+ * fresh on each status publish so a rebuilt runtime or an auth/model change is
1815
+ * reflected without caching a stale snapshot.
1816
+ */
1817
+ warningsForSession(session) {
1818
+ const runtime = this.active.get(session.sessionId)?.runtime;
1819
+ const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
1820
+ const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
1821
+ if (anthropic !== undefined)
1822
+ warnings.push(anthropic);
1823
+ return warnings;
1824
+ }
1352
1825
  pendingMessageCount(session) {
1353
1826
  return session.pendingMessageCount + this.compactionQueuedMessages(session.sessionId).length;
1354
1827
  }
@@ -1368,6 +1841,38 @@ function previewResponseFromPlan(plan) {
1368
1841
  ...(plan.skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds: plan.skippedBusySessionIds }),
1369
1842
  };
1370
1843
  }
1844
+ function uniqueBulkSessionRefs(refs) {
1845
+ const seen = new Set();
1846
+ const unique = [];
1847
+ for (const ref of refs) {
1848
+ const key = `${ref.cwd ?? ""}\0${ref.id}`;
1849
+ if (seen.has(key))
1850
+ continue;
1851
+ seen.add(key);
1852
+ unique.push(ref);
1853
+ }
1854
+ return unique;
1855
+ }
1856
+ function bulkRefToLookup(ref) {
1857
+ return ref.cwd === undefined ? ref.id : { id: ref.id, cwd: ref.cwd };
1858
+ }
1859
+ function findArchivedRecordForBulkRef(records, ref) {
1860
+ return records.find((record) => (ref.cwd === undefined || record.cwd === ref.cwd) && (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
1861
+ }
1862
+ function findListedSessionForBulkRef(context, ref) {
1863
+ if (ref.cwd !== undefined)
1864
+ return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
1865
+ return context.allSessions === undefined ? undefined : findSessionByIdOrPrefix(context.allSessions, ref.id);
1866
+ }
1867
+ function findSessionByIdOrPrefix(sessions, sessionId) {
1868
+ return sessions.find((session) => session.id === sessionId) ?? sessions.find((session) => session.id.startsWith(sessionId));
1869
+ }
1870
+ function uniqueStrings(values) {
1871
+ return [...new Set(values)];
1872
+ }
1873
+ function errorMessage(error) {
1874
+ return error instanceof Error ? error.message : String(error);
1875
+ }
1371
1876
  function modelToClientModel(model) {
1372
1877
  if (model === undefined)
1373
1878
  return {};
@@ -1386,6 +1891,7 @@ function clientSessionFromListEntry(session) {
1386
1891
  id: session.id,
1387
1892
  path: session.path,
1388
1893
  cwd: session.cwd,
1894
+ persisted: true,
1389
1895
  ...(session.name === undefined ? {} : { name: session.name }),
1390
1896
  created: session.created.toISOString(),
1391
1897
  modified: session.modified.toISOString(),
@@ -1514,6 +2020,76 @@ function archivedTimestamp(record) {
1514
2020
  function isDefined(value) {
1515
2021
  return value !== undefined;
1516
2022
  }
2023
+ /**
2024
+ * Resolve the delegation capability from server-owned, persisted session
2025
+ * provenance. A copied marker is not enough: the child header and reciprocal
2026
+ * parent link must identify the exact same session files.
2027
+ */
2028
+ export async function sessionAllowsDelegationTools(sessionManager, managers) {
2029
+ const trackedLink = await verifiedTrackedSubsessionLink(managers, {
2030
+ sessionId: sessionManager.getSessionId(),
2031
+ sessionFile: sessionManager.getSessionFile(),
2032
+ sessionManager,
2033
+ cwd: sessionManager.getCwd(),
2034
+ });
2035
+ return trackedLink === undefined;
2036
+ }
2037
+ async function verifiedTrackedSubsessionLink(managers, session) {
2038
+ // Child markers are only hints; the current child header and reciprocal
2039
+ // parent custom link must agree on the exact ids and files before relinking.
2040
+ const entries = session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch();
2041
+ let marker;
2042
+ for (const entry of entries) {
2043
+ const parsed = parsePersistedChildSubsessionLink(entry);
2044
+ if (parsed?.spawnedSessionId === session.sessionId)
2045
+ marker = parsed;
2046
+ }
2047
+ if (marker === undefined)
2048
+ return undefined;
2049
+ const childSessionFile = nonEmptyString(session.sessionFile);
2050
+ if (childSessionFile === undefined)
2051
+ return undefined;
2052
+ const childHeader = await readSessionHeaderSummary(childSessionFile);
2053
+ if (childHeader?.id !== session.sessionId)
2054
+ return undefined;
2055
+ const parentSessionFile = nonEmptyString(childHeader.parentSession);
2056
+ if (parentSessionFile === undefined)
2057
+ return undefined;
2058
+ const parentHeader = await readSessionHeaderSummary(parentSessionFile);
2059
+ if (parentHeader?.id !== marker.spawnedBySessionId)
2060
+ return undefined;
2061
+ const parentLink = findReciprocalParentSubsessionLink(managers, parentSessionFile, marker.spawnedBySessionId, session.sessionId, childSessionFile);
2062
+ if (parentLink === undefined)
2063
+ return undefined;
2064
+ return {
2065
+ parentSessionId: marker.spawnedBySessionId,
2066
+ childSessionId: session.sessionId,
2067
+ childSessionFile,
2068
+ parentSessionFile,
2069
+ cwd: parentLink.cwd ?? session.cwd,
2070
+ };
2071
+ }
2072
+ function findReciprocalParentSubsessionLink(managers, parentSessionFile, parentSessionId, childSessionId, childSessionFile) {
2073
+ let parentManager;
2074
+ try {
2075
+ parentManager = managers.open(parentSessionFile);
2076
+ }
2077
+ catch {
2078
+ return undefined;
2079
+ }
2080
+ const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
2081
+ for (const entry of entries) {
2082
+ const link = parsePersistedParentSubsessionLink(entry);
2083
+ if (link === undefined)
2084
+ continue;
2085
+ if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId)
2086
+ continue;
2087
+ if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile))
2088
+ continue;
2089
+ return link;
2090
+ }
2091
+ return undefined;
2092
+ }
1517
2093
  function trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile) {
1518
2094
  return {
1519
2095
  parentSessionId,
@@ -1579,6 +2155,16 @@ function subsessionHydratedParentKey(parentSessionId, parentSessionFile) {
1579
2155
  function sessionPathsEqual(a, b) {
1580
2156
  return cwdPathsEqual(a, b);
1581
2157
  }
2158
+ function sessionFileExists(sessionFile) {
2159
+ if (sessionFile === undefined || sessionFile === "")
2160
+ return false;
2161
+ try {
2162
+ return statSync(sessionFile).isFile();
2163
+ }
2164
+ catch {
2165
+ return false;
2166
+ }
2167
+ }
1582
2168
  function sessionFileMatches(session, expectedSessionFile) {
1583
2169
  const sessionFile = nonEmptyString(session.sessionFile);
1584
2170
  return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile);
@@ -1697,11 +2283,13 @@ const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
1697
2283
  const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned";
1698
2284
  /** customType marking a parent-facing subsession-completion notice. */
1699
2285
  const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
1700
- const SUBSESSION_NOTIFICATION_PREVIEW_CHARS = 2000;
1701
- function truncateForNotification(text) {
1702
- if (text.length <= SUBSESSION_NOTIFICATION_PREVIEW_CHARS)
1703
- return text;
1704
- return `${text.slice(0, SUBSESSION_NOTIFICATION_PREVIEW_CHARS)}…`;
2286
+ const SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS = 2000;
2287
+ /** Avoid duplicating a partial result in context when deliberate inspection can return the full output. */
2288
+ function formatSubsessionNotificationOutput(childSessionId, text) {
2289
+ if (text.length > SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS) {
2290
+ return `Output from subsession ${childSessionId} was too long for this completion notice and was omitted. Call check_subsession with sessionId "${childSessionId}" to retrieve the final output.`;
2291
+ }
2292
+ return `--- SUBSESSION OUTPUT: ${childSessionId} ---\n${text === "" ? "(no output)" : text}`;
1705
2293
  }
1706
2294
  /** Most recent assistant text from a history message list, or "" if none. */
1707
2295
  function finalAssistantText(messages) {