@hyperdreamer/pi-webui 1.6.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.
- package/LICENSE +23 -0
- package/README.md +148 -0
- package/dist/cli.js +975 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/CodeViewer-aB4ZVzEu.js +4 -0
- package/dist/client/assets/UnifiedDiffViewer-f8X7aTkU.js +39 -0
- package/dist/client/assets/index-Cffeusjy.js +6022 -0
- package/dist/client/assets/vendor-editor-core-vUi74DnD.js +12 -0
- package/dist/client/assets/vendor-editor-languages-DoaXUodB.js +46 -0
- package/dist/client/assets/vendor-editor-legacy-CYBnW6ZU.js +1 -0
- package/dist/client/assets/vendor-terminal-BrP-ENHg.css +1 -0
- package/dist/client/assets/vendor-terminal-D8k4UKM2.js +35 -0
- package/dist/client/favicon.svg +8 -0
- package/dist/client/index.html +65 -0
- package/dist/client/manifest.webmanifest +24 -0
- package/dist/client/pwa-icon-192.png +0 -0
- package/dist/client/pwa-icon-512.png +0 -0
- package/dist/config.js +417 -0
- package/dist/config.js.map +1 -0
- package/dist/docker/piWebUiDockerCommandPlan.js +211 -0
- package/dist/docker/piWebUiDockerCommandPlan.js.map +1 -0
- package/dist/nativeServices/serviceDoctor.js +480 -0
- package/dist/nativeServices/serviceDoctor.js.map +1 -0
- package/dist/nativeServices/serviceInstall.js +35 -0
- package/dist/nativeServices/serviceInstall.js.map +1 -0
- package/dist/nativeServices/servicePlan.js +358 -0
- package/dist/nativeServices/servicePlan.js.map +1 -0
- package/dist/nativeServices/serviceProbe.js +447 -0
- package/dist/nativeServices/serviceProbe.js.map +1 -0
- package/dist/nativeServices/serviceRendering.js +122 -0
- package/dist/nativeServices/serviceRendering.js.map +1 -0
- package/dist/pi-webui-plugins/info/package.json +9 -0
- package/dist/pi-webui-plugins/info/pi-webui-plugin.js +51 -0
- package/dist/pi-webui-plugins/updates/package.json +9 -0
- package/dist/pi-webui-plugins/updates/pi-webui-plugin.js +185 -0
- package/dist/pi-webui-plugins/updates/updatesLogic.js +97 -0
- package/dist/pi-webui-plugins/workspace-tasks/config.js +91 -0
- package/dist/pi-webui-plugins/workspace-tasks/package.json +9 -0
- package/dist/pi-webui-plugins/workspace-tasks/pi-webui-plugin.js +48 -0
- package/dist/pi-webui-plugins/workspace-tasks/taskRunner.js +12 -0
- package/dist/pi-webui-plugins/workspace-tasks/tasksPanelElement.js +288 -0
- package/dist/pi-webui-plugins/workspace-tasks/workspaceTasksClient.js +47 -0
- package/dist/piWebUiVersionReport.js +225 -0
- package/dist/piWebUiVersionReport.js.map +1 -0
- package/dist/plugin-api/unstable.d.ts +22 -0
- package/dist/plugin-api.d.ts +199 -0
- package/dist/server/activeAgentProfileProvider.js +25 -0
- package/dist/server/activeAgentProfileProvider.js.map +1 -0
- package/dist/server/activity/workspaceActivityRoutes.js +4 -0
- package/dist/server/activity/workspaceActivityRoutes.js.map +1 -0
- package/dist/server/activity/workspaceActivityService.js +98 -0
- package/dist/server/activity/workspaceActivityService.js.map +1 -0
- package/dist/server/app.js +211 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/browserMessageProjection.js +56 -0
- package/dist/server/browserMessageProjection.js.map +1 -0
- package/dist/server/configRoutes.js +309 -0
- package/dist/server/configRoutes.js.map +1 -0
- package/dist/server/diagnostics/nodePtyNativeModule.js +36 -0
- package/dist/server/diagnostics/nodePtyNativeModule.js.map +1 -0
- package/dist/server/diagnostics/nodePtySpawnHelper.js +135 -0
- package/dist/server/diagnostics/nodePtySpawnHelper.js.map +1 -0
- package/dist/server/git/gitEnv.js +15 -0
- package/dist/server/git/gitEnv.js.map +1 -0
- package/dist/server/git/gitService.js +123 -0
- package/dist/server/git/gitService.js.map +1 -0
- package/dist/server/gitRoutes.js +23 -0
- package/dist/server/gitRoutes.js.map +1 -0
- package/dist/server/index.js +7 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/machines/machineClient.js +172 -0
- package/dist/server/machines/machineClient.js.map +1 -0
- package/dist/server/machines/machinePluginProxyRoutes.js +190 -0
- package/dist/server/machines/machinePluginProxyRoutes.js.map +1 -0
- package/dist/server/machines/machineProxyRoutes.js +172 -0
- package/dist/server/machines/machineProxyRoutes.js.map +1 -0
- package/dist/server/machines/machineRoutes.js +56 -0
- package/dist/server/machines/machineRoutes.js.map +1 -0
- package/dist/server/machines/machineService.js +238 -0
- package/dist/server/machines/machineService.js.map +1 -0
- package/dist/server/machines/machineStore.js +128 -0
- package/dist/server/machines/machineStore.js.map +1 -0
- package/dist/server/models/modelsConfigRoutes.js +40 -0
- package/dist/server/models/modelsConfigRoutes.js.map +1 -0
- package/dist/server/models/modelsConfigService.js +339 -0
- package/dist/server/models/modelsConfigService.js.map +1 -0
- package/dist/server/piPackagePluginsConfigRoutes.js +86 -0
- package/dist/server/piPackagePluginsConfigRoutes.js.map +1 -0
- package/dist/server/piPackagePluginsConfigService.js +311 -0
- package/dist/server/piPackagePluginsConfigService.js.map +1 -0
- package/dist/server/piPackageRoutes.js +91 -0
- package/dist/server/piPackageRoutes.js.map +1 -0
- package/dist/server/piPackageService.js +101 -0
- package/dist/server/piPackageService.js.map +1 -0
- package/dist/server/piWebUiPluginService.js +326 -0
- package/dist/server/piWebUiPluginService.js.map +1 -0
- package/dist/server/piWebUiReleaseLookupCache.js +37 -0
- package/dist/server/piWebUiReleaseLookupCache.js.map +1 -0
- package/dist/server/piWebUiStatus.js +622 -0
- package/dist/server/piWebUiStatus.js.map +1 -0
- package/dist/server/piWebUiStatusCache.js +45 -0
- package/dist/server/piWebUiStatusCache.js.map +1 -0
- package/dist/server/projects/directorySuggestions.js +37 -0
- package/dist/server/projects/directorySuggestions.js.map +1 -0
- package/dist/server/projects/projectService.js +31 -0
- package/dist/server/projects/projectService.js.map +1 -0
- package/dist/server/realtime/sessionEventHub.js +70 -0
- package/dist/server/realtime/sessionEventHub.js.map +1 -0
- package/dist/server/sessiond/sessionDaemonStartup.js +49 -0
- package/dist/server/sessiond/sessionDaemonStartup.js.map +1 -0
- package/dist/server/sessiond/sessionProxyRoutes.js +87 -0
- package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -0
- package/dist/server/sessiond/skillsGithubToken.js +7 -0
- package/dist/server/sessiond/skillsGithubToken.js.map +1 -0
- package/dist/server/sessiond.js +152 -0
- package/dist/server/sessiond.js.map +1 -0
- package/dist/server/sessions/attachmentService.js +119 -0
- package/dist/server/sessions/attachmentService.js.map +1 -0
- package/dist/server/sessions/authProviderOptions.js +50 -0
- package/dist/server/sessions/authProviderOptions.js.map +1 -0
- package/dist/server/sessions/authRoutes.js +69 -0
- package/dist/server/sessions/authRoutes.js.map +1 -0
- package/dist/server/sessions/authService.js +134 -0
- package/dist/server/sessions/authService.js.map +1 -0
- package/dist/server/sessions/builtinCommands.js +27 -0
- package/dist/server/sessions/builtinCommands.js.map +1 -0
- package/dist/server/sessions/editPreview.js +196 -0
- package/dist/server/sessions/editPreview.js.map +1 -0
- package/dist/server/sessions/messagePaging.js +43 -0
- package/dist/server/sessions/messagePaging.js.map +1 -0
- package/dist/server/sessions/oauthLoginFlowService.js +349 -0
- package/dist/server/sessions/oauthLoginFlowService.js.map +1 -0
- package/dist/server/sessions/piSessionManagerGateway.js +115 -0
- package/dist/server/sessions/piSessionManagerGateway.js.map +1 -0
- package/dist/server/sessions/piSessionService.js +3230 -0
- package/dist/server/sessions/piSessionService.js.map +1 -0
- package/dist/server/sessions/plainTextTheme.js +112 -0
- package/dist/server/sessions/plainTextTheme.js.map +1 -0
- package/dist/server/sessions/sessionArchiveMigration.js +590 -0
- package/dist/server/sessions/sessionArchiveMigration.js.map +1 -0
- package/dist/server/sessions/sessionArchiveStore.js +262 -0
- package/dist/server/sessions/sessionArchiveStore.js.map +1 -0
- package/dist/server/sessions/sessionArchiveTree.js +35 -0
- package/dist/server/sessions/sessionArchiveTree.js.map +1 -0
- package/dist/server/sessions/sessionCleanup.js +157 -0
- package/dist/server/sessions/sessionCleanup.js.map +1 -0
- package/dist/server/sessions/sessionCommandService.js +313 -0
- package/dist/server/sessions/sessionCommandService.js.map +1 -0
- package/dist/server/sessions/sessionDefaultsRoutes.js +65 -0
- package/dist/server/sessions/sessionDefaultsRoutes.js.map +1 -0
- package/dist/server/sessions/sessionDefaultsService.js +90 -0
- package/dist/server/sessions/sessionDefaultsService.js.map +1 -0
- package/dist/server/sessions/sessionHistoryExport.js +115 -0
- package/dist/server/sessions/sessionHistoryExport.js.map +1 -0
- package/dist/server/sessions/sessionMetadataStore.js +102 -0
- package/dist/server/sessions/sessionMetadataStore.js.map +1 -0
- package/dist/server/sessions/sessionNameGenerator.js +90 -0
- package/dist/server/sessions/sessionNameGenerator.js.map +1 -0
- package/dist/server/sessions/sessionNotificationStore.js +484 -0
- package/dist/server/sessions/sessionNotificationStore.js.map +1 -0
- package/dist/server/sessions/sessionRoutes.js +590 -0
- package/dist/server/sessions/sessionRoutes.js.map +1 -0
- package/dist/server/sessions/sessionRuntimeStore.js +2 -0
- package/dist/server/sessions/sessionRuntimeStore.js.map +1 -0
- package/dist/server/sessions/sessionService.js +2 -0
- package/dist/server/sessions/sessionService.js.map +1 -0
- package/dist/server/sessions/sessionTreeProjection.js +250 -0
- package/dist/server/sessions/sessionTreeProjection.js.map +1 -0
- package/dist/server/sessions/sessionUnreadStore.js +520 -0
- package/dist/server/sessions/sessionUnreadStore.js.map +1 -0
- package/dist/server/sessions/spawnSessionTool.js +41 -0
- package/dist/server/sessions/spawnSessionTool.js.map +1 -0
- package/dist/server/sessions/spawnSubsessionTool.js +214 -0
- package/dist/server/sessions/spawnSubsessionTool.js.map +1 -0
- package/dist/server/sessions/spawnTargetResolver.js +36 -0
- package/dist/server/sessions/spawnTargetResolver.js.map +1 -0
- package/dist/server/sessions/subsessionTranscript.js +235 -0
- package/dist/server/sessions/subsessionTranscript.js.map +1 -0
- package/dist/server/skills/runNpx.js +30 -0
- package/dist/server/skills/runNpx.js.map +1 -0
- package/dist/server/skills/skillLock.js +115 -0
- package/dist/server/skills/skillLock.js.map +1 -0
- package/dist/server/skills/skillUpdates.js +194 -0
- package/dist/server/skills/skillUpdates.js.map +1 -0
- package/dist/server/skills/skillsConfigRoutes.js +64 -0
- package/dist/server/skills/skillsConfigRoutes.js.map +1 -0
- package/dist/server/skills/skillsConfigService.js +391 -0
- package/dist/server/skills/skillsConfigService.js.map +1 -0
- package/dist/server/storage/projectStore.js +88 -0
- package/dist/server/storage/projectStore.js.map +1 -0
- package/dist/server/systemInfoRoutes.js +265 -0
- package/dist/server/systemInfoRoutes.js.map +1 -0
- package/dist/server/terminalProxyRoutes.js +140 -0
- package/dist/server/terminalProxyRoutes.js.map +1 -0
- package/dist/server/terminals/terminalRoutes.js +155 -0
- package/dist/server/terminals/terminalRoutes.js.map +1 -0
- package/dist/server/terminals/terminalService.js +305 -0
- package/dist/server/terminals/terminalService.js.map +1 -0
- package/dist/server/terminals/terminalSize.js +17 -0
- package/dist/server/terminals/terminalSize.js.map +1 -0
- package/dist/server/types.js +2 -0
- package/dist/server/types.js.map +1 -0
- package/dist/server/webSocketBridge.js +32 -0
- package/dist/server/webSocketBridge.js.map +1 -0
- package/dist/server/workingDirectory.js +44 -0
- package/dist/server/workingDirectory.js.map +1 -0
- package/dist/server/workspaceExplorerRoutes.js +109 -0
- package/dist/server/workspaceExplorerRoutes.js.map +1 -0
- package/dist/server/workspaces/effectivePathAccess.js +27 -0
- package/dist/server/workspaces/effectivePathAccess.js.map +1 -0
- package/dist/server/workspaces/fileContentService.js +181 -0
- package/dist/server/workspaces/fileContentService.js.map +1 -0
- package/dist/server/workspaces/fileSuggestions.js +520 -0
- package/dist/server/workspaces/fileSuggestions.js.map +1 -0
- package/dist/server/workspaces/fileTreeService.js +35 -0
- package/dist/server/workspaces/fileTreeService.js.map +1 -0
- package/dist/server/workspaces/gitWorktreeDiscovery.js +34 -0
- package/dist/server/workspaces/gitWorktreeDiscovery.js.map +1 -0
- package/dist/server/workspaces/imagePreviewService.js +40 -0
- package/dist/server/workspaces/imagePreviewService.js.map +1 -0
- package/dist/server/workspaces/pathAccessPolicy.js +90 -0
- package/dist/server/workspaces/pathAccessPolicy.js.map +1 -0
- package/dist/server/workspaces/pathSafety.js +45 -0
- package/dist/server/workspaces/pathSafety.js.map +1 -0
- package/dist/server/workspaces/projectPiWebUiConfig.js +61 -0
- package/dist/server/workspaces/projectPiWebUiConfig.js.map +1 -0
- package/dist/server/workspaces/workspaceContext.js +8 -0
- package/dist/server/workspaces/workspaceContext.js.map +1 -0
- package/dist/server/workspaces/workspaceDeletionRoutes.js +113 -0
- package/dist/server/workspaces/workspaceDeletionRoutes.js.map +1 -0
- package/dist/server/workspaces/workspaceService.js +39 -0
- package/dist/server/workspaces/workspaceService.js.map +1 -0
- package/dist/sessiond/activeAgentProfile.js +31 -0
- package/dist/sessiond/activeAgentProfile.js.map +1 -0
- package/dist/sessiond/config.js +9 -0
- package/dist/sessiond/config.js.map +1 -0
- package/dist/sessiond/sessionDaemonClient.js +103 -0
- package/dist/sessiond/sessionDaemonClient.js.map +1 -0
- package/dist/shared/activeAgentProfile.js +76 -0
- package/dist/shared/activeAgentProfile.js.map +1 -0
- package/dist/shared/activity.js +11 -0
- package/dist/shared/activity.js.map +1 -0
- package/dist/shared/apiTypes.d.ts +1212 -0
- package/dist/shared/apiTypes.js +25 -0
- package/dist/shared/apiTypes.js.map +1 -0
- package/dist/shared/capabilities.js +69 -0
- package/dist/shared/capabilities.js.map +1 -0
- package/dist/shared/federatedRoutes.js +109 -0
- package/dist/shared/federatedRoutes.js.map +1 -0
- package/dist/shared/machinePluginIds.js +41 -0
- package/dist/shared/machinePluginIds.js.map +1 -0
- package/dist/shared/piWebUiStatusParsing.js +108 -0
- package/dist/shared/piWebUiStatusParsing.js.map +1 -0
- package/dist/shared/pluginIds.js +5 -0
- package/dist/shared/pluginIds.js.map +1 -0
- package/dist/shared/promptAttachments.js +92 -0
- package/dist/shared/promptAttachments.js.map +1 -0
- package/dist/shared/thinkingLevels.d.ts +27 -0
- package/dist/shared/thinkingLevels.js +31 -0
- package/dist/shared/thinkingLevels.js.map +1 -0
- package/dist/shared/workspaceDeletion.js +12 -0
- package/dist/shared/workspaceDeletion.js.map +1 -0
- package/dist/shared/workspaceFiles.js +3 -0
- package/dist/shared/workspaceFiles.js.map +1 -0
- package/docs/assets/favicon.svg +8 -0
- package/docs/assets/pi-webui-banner.png +0 -0
- package/docs/assets/pi-webui-desktop.png +0 -0
- package/docs/assets/pi-webui-mobile.png +0 -0
- package/docs/assets/pi-webui-tablet.png +0 -0
- package/docs/config.md +265 -0
- package/docs/plugins.md +988 -0
- package/extensions/pi-webui.ts +133 -0
- package/package.json +143 -0
- package/plugin-api/unstable.d.ts +1 -0
- package/plugin-api.d.ts +1 -0
|
@@ -0,0 +1,3230 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { open, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, readStoredCredential, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { projectBrowserMessage } from "../browserMessageProjection.js";
|
|
6
|
+
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
|
7
|
+
import { exportSessionHistoryHtml } from "./sessionHistoryExport.js";
|
|
8
|
+
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
|
9
|
+
import { SessionCommandService } from "./sessionCommandService.js";
|
|
10
|
+
import { projectSessionTree } from "./sessionTreeProjection.js";
|
|
11
|
+
import { SessionArchiveStore } from "./sessionArchiveStore.js";
|
|
12
|
+
import { SessionMetadataStore } from "./sessionMetadataStore.js";
|
|
13
|
+
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
|
|
14
|
+
import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
|
15
|
+
import { computeEditPreview } from "./editPreview.js";
|
|
16
|
+
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
|
17
|
+
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
|
18
|
+
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
|
|
19
|
+
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
|
20
|
+
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
|
|
21
|
+
import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
|
|
22
|
+
import { buildTranscriptView } from "./subsessionTranscript.js";
|
|
23
|
+
import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
|
|
24
|
+
import { SessionNotificationStore, } from "./sessionNotificationStore.js";
|
|
25
|
+
import { plainTextTheme } from "./plainTextTheme.js";
|
|
26
|
+
import { SessionUnreadStore } from "./sessionUnreadStore.js";
|
|
27
|
+
const noopLogger = { info() { } };
|
|
28
|
+
const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
|
|
29
|
+
const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
|
|
30
|
+
const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
|
|
31
|
+
const ESTIMATED_CHARS_PER_TOKEN = 4;
|
|
32
|
+
const MIN_GENERATION_RATE_ELAPSED_MS = 500;
|
|
33
|
+
function noop() {
|
|
34
|
+
// Intentionally empty default unsubscribe callback.
|
|
35
|
+
}
|
|
36
|
+
function spawnTargetError(decision) {
|
|
37
|
+
if (decision.reason === "not-registered")
|
|
38
|
+
return new Error("Spawning session is not in a registered project");
|
|
39
|
+
return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
function authLossWarningKey(sessionId, provider, modelId) {
|
|
42
|
+
return `${sessionId}:${provider}/${modelId}`;
|
|
43
|
+
}
|
|
44
|
+
function sessionIdFromLookup(ref) {
|
|
45
|
+
return typeof ref === "string" ? ref : ref.id;
|
|
46
|
+
}
|
|
47
|
+
function isPiSessionRef(ref) {
|
|
48
|
+
return typeof ref !== "string";
|
|
49
|
+
}
|
|
50
|
+
function lookupMatchesActiveSession(ref, active) {
|
|
51
|
+
return !isPiSessionRef(ref) || cwdPathsEqual(active.runtime.cwd, ref.cwd);
|
|
52
|
+
}
|
|
53
|
+
function sessionTreeNavigationOptions(request) {
|
|
54
|
+
switch (request.summary.mode) {
|
|
55
|
+
case "none":
|
|
56
|
+
return { summarize: false };
|
|
57
|
+
case "default":
|
|
58
|
+
return { summarize: true };
|
|
59
|
+
case "custom": {
|
|
60
|
+
const customInstructions = request.summary.instructions.trim();
|
|
61
|
+
if (customInstructions === "")
|
|
62
|
+
throw new Error("Custom branch-summary instructions are required");
|
|
63
|
+
if (customInstructions.length > SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH) {
|
|
64
|
+
throw new Error(`Custom branch-summary instructions must be at most ${String(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH)} characters`);
|
|
65
|
+
}
|
|
66
|
+
return { summarize: true, customInstructions };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function decrementWeakCount(counts, key) {
|
|
71
|
+
const remaining = (counts.get(key) ?? 1) - 1;
|
|
72
|
+
if (remaining <= 0)
|
|
73
|
+
counts.delete(key);
|
|
74
|
+
else
|
|
75
|
+
counts.set(key, remaining);
|
|
76
|
+
}
|
|
77
|
+
function decrementMapCount(counts, key) {
|
|
78
|
+
const remaining = (counts.get(key) ?? 1) - 1;
|
|
79
|
+
if (remaining <= 0)
|
|
80
|
+
counts.delete(key);
|
|
81
|
+
else
|
|
82
|
+
counts.set(key, remaining);
|
|
83
|
+
}
|
|
84
|
+
function requirePromptText(value) {
|
|
85
|
+
if (typeof value !== "string")
|
|
86
|
+
throw new Error("Prompt text is required");
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
function parsePromptStreamingBehavior(value) {
|
|
90
|
+
if (value === undefined)
|
|
91
|
+
return undefined;
|
|
92
|
+
if (value === "steer" || value === "followUp")
|
|
93
|
+
return value;
|
|
94
|
+
throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
|
|
95
|
+
}
|
|
96
|
+
const CLEAR_RUNTIME_NOTIFICATIONS = { kind: "clear", reason: "runtime-close" };
|
|
97
|
+
const DEFER_RUNTIME_NOTIFICATIONS = { kind: "defer" };
|
|
98
|
+
function resourceDiagnosticToWarning(diagnostic, source) {
|
|
99
|
+
return {
|
|
100
|
+
severity: diagnostic.type === "error" ? "error" : "warning",
|
|
101
|
+
message: diagnostic.message,
|
|
102
|
+
source,
|
|
103
|
+
...(diagnostic.path === undefined ? {} : { path: diagnostic.path }),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function runtimeDiagnosticToWarning(diagnostic) {
|
|
107
|
+
return { severity: diagnostic.type, message: diagnostic.message, source: "runtime" };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Compute the live warnings for a runtime by re-reading its current resource
|
|
111
|
+
* loader diagnostics, extension load errors, and runtime setup diagnostics.
|
|
112
|
+
*
|
|
113
|
+
* This mimics the TUI recomputing warnings on every (re)bind: it reads the
|
|
114
|
+
* runtime's current state rather than a cached snapshot, so a rebuilt runtime
|
|
115
|
+
* yields fresh warnings. Runtimes without SDK services (e.g. test fakes)
|
|
116
|
+
* contribute no warnings.
|
|
117
|
+
*/
|
|
118
|
+
export function collectRuntimeWarnings(runtime) {
|
|
119
|
+
const warnings = [];
|
|
120
|
+
for (const diagnostic of runtime.diagnostics ?? [])
|
|
121
|
+
warnings.push(runtimeDiagnosticToWarning(diagnostic));
|
|
122
|
+
const resourceLoader = runtime.services?.resourceLoader;
|
|
123
|
+
if (resourceLoader !== undefined) {
|
|
124
|
+
for (const diagnostic of resourceLoader.getSkills().diagnostics)
|
|
125
|
+
warnings.push(resourceDiagnosticToWarning(diagnostic, "skill"));
|
|
126
|
+
for (const diagnostic of resourceLoader.getPrompts().diagnostics)
|
|
127
|
+
warnings.push(resourceDiagnosticToWarning(diagnostic, "prompt"));
|
|
128
|
+
for (const diagnostic of resourceLoader.getThemes().diagnostics)
|
|
129
|
+
warnings.push(resourceDiagnosticToWarning(diagnostic, "theme"));
|
|
130
|
+
for (const error of resourceLoader.getExtensions().errors) {
|
|
131
|
+
warnings.push({ severity: "error", message: `${error.path}: ${error.error}`, source: "extension", path: error.path });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return warnings;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Verbatim TUI wording for the Anthropic subscription-auth billing notice. Kept
|
|
138
|
+
* character-for-character in sync with `ANTHROPIC_SUBSCRIPTION_AUTH_WARNING` in
|
|
139
|
+
* the SDK's interactive mode so the browser shows the same message the TUI does.
|
|
140
|
+
*/
|
|
141
|
+
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.";
|
|
142
|
+
/** Mirror of the SDK TUI `isAnthropicSubscriptionAuthKey` (subscription API keys start with `sk-ant-oat`). */
|
|
143
|
+
function isAnthropicSubscriptionAuthKey(apiKey) {
|
|
144
|
+
return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Dismiss id for the Anthropic subscription-auth billing notice. This is `pi`'s
|
|
148
|
+
* own `WarningSettings` key verbatim (`anthropicExtraUsage`): we carry the
|
|
149
|
+
* coupling `pi` already defines rather than inventing a parallel vocabulary, and
|
|
150
|
+
* {@link dismissSessionWarning} maps it back to `setWarnings`.
|
|
151
|
+
*/
|
|
152
|
+
const ANTHROPIC_EXTRA_USAGE_DISMISS_ID = "anthropicExtraUsage";
|
|
153
|
+
/**
|
|
154
|
+
* Port of the TUI `maybeWarnAboutAnthropicSubscriptionAuth` gate/trigger, computed
|
|
155
|
+
* live from the session's current model, stored Anthropic credential, and warning
|
|
156
|
+
* settings. Returns the billing warning when the active provider is `anthropic`
|
|
157
|
+
* and auth is a subscription credential (stored `oauth`, or an `sk-ant-oat` API
|
|
158
|
+
* key), unless suppressed via `getWarnings().anthropicExtraUsage === false`.
|
|
159
|
+
*
|
|
160
|
+
* The stored credential is read synchronously (matching the TUI's `oauth` branch
|
|
161
|
+
* and the documented `sk-ant-oat` key trigger) so warnings stay part of the
|
|
162
|
+
* synchronous live status computation.
|
|
163
|
+
*/
|
|
164
|
+
export function anthropicSubscriptionWarning(session, authPath) {
|
|
165
|
+
if (session.settingsManager.getWarnings().anthropicExtraUsage === false)
|
|
166
|
+
return undefined;
|
|
167
|
+
if (session.model?.provider !== "anthropic")
|
|
168
|
+
return undefined;
|
|
169
|
+
const credential = readStoredCredential("anthropic", authPath);
|
|
170
|
+
if (credential === undefined)
|
|
171
|
+
return undefined;
|
|
172
|
+
const isSubscriptionAuth = credential.type === "oauth"
|
|
173
|
+
? true
|
|
174
|
+
: isAnthropicSubscriptionAuthKey(credential.key);
|
|
175
|
+
if (!isSubscriptionAuth)
|
|
176
|
+
return undefined;
|
|
177
|
+
return {
|
|
178
|
+
severity: "warning",
|
|
179
|
+
message: ANTHROPIC_SUBSCRIPTION_AUTH_WARNING,
|
|
180
|
+
source: "anthropic",
|
|
181
|
+
dismiss: { id: ANTHROPIC_EXTRA_USAGE_DISMISS_ID },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Durably suppress a dismissable session warning by mapping its opaque dismiss
|
|
186
|
+
* id back to the concrete `pi` suppression it represents. Only known ids are
|
|
187
|
+
* honored; unknown ids throw so a stale/forged client cannot silently no-op.
|
|
188
|
+
*
|
|
189
|
+
* This is the single place provider-specific suppression lives: the wire type,
|
|
190
|
+
* parser, and UI stay agnostic. Adding a future dismissable warning is a
|
|
191
|
+
* server-only change here plus a `dismiss` id on its producer.
|
|
192
|
+
*/
|
|
193
|
+
export function dismissSessionWarning(session, dismissId) {
|
|
194
|
+
if (dismissId !== ANTHROPIC_EXTRA_USAGE_DISMISS_ID) {
|
|
195
|
+
throw new Error(`Unknown session warning dismiss id: ${dismissId}`);
|
|
196
|
+
}
|
|
197
|
+
session.settingsManager.setWarnings({ ...session.settingsManager.getWarnings(), anthropicExtraUsage: false });
|
|
198
|
+
}
|
|
199
|
+
function defaultCreateAgentRuntime(createRuntime, options) {
|
|
200
|
+
if (!(options.sessionManager instanceof SessionManager))
|
|
201
|
+
throw new Error("Default runtime creation requires an SDK SessionManager");
|
|
202
|
+
const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.delegationToolsEnabled);
|
|
203
|
+
return createAgentSessionRuntime(runtimeFactory, {
|
|
204
|
+
cwd: options.cwd,
|
|
205
|
+
agentDir: options.agentDir,
|
|
206
|
+
sessionManager: options.sessionManager,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, delegationToolsEnabled) {
|
|
210
|
+
// These inputs belong only to the session being opened. A later runtime
|
|
211
|
+
// replacement resolves its own model and delegation capability.
|
|
212
|
+
let pendingInitialModel = initialModel;
|
|
213
|
+
let pendingDelegationToolsEnabled = delegationToolsEnabled;
|
|
214
|
+
return async (options) => {
|
|
215
|
+
const model = pendingInitialModel;
|
|
216
|
+
const toolsEnabled = pendingDelegationToolsEnabled;
|
|
217
|
+
pendingInitialModel = undefined;
|
|
218
|
+
pendingDelegationToolsEnabled = undefined;
|
|
219
|
+
return createRuntime({
|
|
220
|
+
...options,
|
|
221
|
+
...(model === undefined ? {} : { initialModel: model }),
|
|
222
|
+
...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }),
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
export function createPiWebUiCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions) {
|
|
227
|
+
return [
|
|
228
|
+
createPiWebUiEditToolDefinition(cwd),
|
|
229
|
+
...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
|
|
230
|
+
...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions) {
|
|
234
|
+
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
|
|
235
|
+
const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
|
|
236
|
+
const resolvedDelegationToolsEnabled = delegationToolsEnabled
|
|
237
|
+
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
|
238
|
+
const customTools = createPiWebUiCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
|
|
239
|
+
const result = await createAgentSessionFromServices({
|
|
240
|
+
services,
|
|
241
|
+
sessionManager,
|
|
242
|
+
customTools,
|
|
243
|
+
...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
|
|
244
|
+
...(initialModel === undefined ? {} : { model: initialModel }),
|
|
245
|
+
});
|
|
246
|
+
return { ...result, services, diagnostics: services.diagnostics };
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function createPiWebUiEditToolDefinition(cwd) {
|
|
250
|
+
const editTool = createEditToolDefinition(cwd);
|
|
251
|
+
return defineTool({
|
|
252
|
+
name: editTool.name,
|
|
253
|
+
label: editTool.label,
|
|
254
|
+
description: editTool.description,
|
|
255
|
+
...(editTool.promptSnippet === undefined ? {} : { promptSnippet: editTool.promptSnippet }),
|
|
256
|
+
...(editTool.promptGuidelines === undefined ? {} : { promptGuidelines: editTool.promptGuidelines }),
|
|
257
|
+
parameters: editTool.parameters,
|
|
258
|
+
...(editTool.renderShell === undefined ? {} : { renderShell: editTool.renderShell }),
|
|
259
|
+
...(editTool.prepareArguments === undefined ? {} : { prepareArguments: editTool.prepareArguments }),
|
|
260
|
+
...(editTool.executionMode === undefined ? {} : { executionMode: editTool.executionMode }),
|
|
261
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
262
|
+
const preview = await computeEditPreview(params.path, params.edits, cwd);
|
|
263
|
+
if (signal?.aborted !== true) {
|
|
264
|
+
onUpdate?.({ content: [{ type: "text", text: "Edit preview computed." }], details: { preview } });
|
|
265
|
+
}
|
|
266
|
+
return editTool.execute(toolCallId, params, signal, onUpdate, ctx);
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
export class PiSessionService {
|
|
271
|
+
constructor(events, deps) {
|
|
272
|
+
this.events = events;
|
|
273
|
+
this.active = new Map();
|
|
274
|
+
this.pendingSessionOpens = new Map();
|
|
275
|
+
this.activities = new Map();
|
|
276
|
+
this.generationMetrics = new WeakMap();
|
|
277
|
+
/** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
|
|
278
|
+
this.treeNavigations = new WeakSet();
|
|
279
|
+
/** Counts async operations that may append an entry before they settle. */
|
|
280
|
+
this.sessionEntryMutationCounts = new WeakMap();
|
|
281
|
+
/** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
|
|
282
|
+
this.treeExclusiveRuntimeOperationCounts = new WeakMap();
|
|
283
|
+
this.treeExclusiveSessionOperationCounts = new Map();
|
|
284
|
+
this.deferredSubsessionNotifications = new WeakMap();
|
|
285
|
+
this.deferredGeneratedSessionNames = new WeakMap();
|
|
286
|
+
this.compactionPromptQueues = new Map();
|
|
287
|
+
this.compactionDrainTimers = new Map();
|
|
288
|
+
this.authLossWarnings = new Set();
|
|
289
|
+
/** Tracked subsession id -> the parent session id that spawned it. */
|
|
290
|
+
this.subsessionParents = new Map();
|
|
291
|
+
/** Parent session id -> the set of tracked subsession ids it spawned. */
|
|
292
|
+
this.subsessionChildren = new Map();
|
|
293
|
+
/** Tracked subsession id -> persisted recovery details for the child. */
|
|
294
|
+
this.subsessionLinks = new Map();
|
|
295
|
+
/** Parent id/file identities whose persisted links have already been loaded. */
|
|
296
|
+
this.subsessionHydratedParents = new Set();
|
|
297
|
+
/**
|
|
298
|
+
* Tracked subsession id -> whether a completion notification is armed.
|
|
299
|
+
* Armed when the child starts working; firing on completion disarms it so a
|
|
300
|
+
* child that works again (and stops again) notifies the parent each time.
|
|
301
|
+
*/
|
|
302
|
+
this.subsessionNotifyArmed = new Map();
|
|
303
|
+
this.notificationGenerationBySession = new WeakMap();
|
|
304
|
+
this.pendingUnreadMutations = [];
|
|
305
|
+
this.unreadPublicationFlushRequested = false;
|
|
306
|
+
this.unreadPublicationStopped = false;
|
|
307
|
+
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
|
308
|
+
this.metadataStore = deps.metadataStore ?? new SessionMetadataStore();
|
|
309
|
+
this.agentDir = deps.agentDir;
|
|
310
|
+
this.sessionManager = deps.sessionManager;
|
|
311
|
+
this.modelRuntime = deps.modelRuntime;
|
|
312
|
+
this.spawnTargets = deps.spawnTargets;
|
|
313
|
+
this.logger = deps.logger ?? noopLogger;
|
|
314
|
+
this.now = deps.now ?? (() => new Date());
|
|
315
|
+
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
|
|
316
|
+
this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
|
|
317
|
+
this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
|
|
318
|
+
this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
|
|
319
|
+
// Subsessions are a beta capability gated behind their own flag, and they
|
|
320
|
+
// also require the spawn capability (they share its project-scope resolver).
|
|
321
|
+
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
|
|
322
|
+
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
|
|
323
|
+
spawn: (input) => this.spawnSubsession(input),
|
|
324
|
+
list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
|
|
325
|
+
check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
|
|
326
|
+
read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
|
|
327
|
+
});
|
|
328
|
+
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
|
329
|
+
this.workspaceActivity = deps.workspaceActivity;
|
|
330
|
+
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
|
|
331
|
+
this.commandService = new SessionCommandService((sessionId) => this.getActive(sessionId), (sessionId, text) => this.prompt(sessionId, text, undefined, undefined, { echoUserMessage: false }), events, {
|
|
332
|
+
onCompactionStart: (session) => {
|
|
333
|
+
this.beginSessionEntryMutation(session, "compact the session");
|
|
334
|
+
this.publishActivity(session, "compacting", "active");
|
|
335
|
+
this.publishStatus(session);
|
|
336
|
+
},
|
|
337
|
+
onCompactionEnd: (session, result, detail) => {
|
|
338
|
+
this.endSessionEntryMutation(session);
|
|
339
|
+
this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
|
|
340
|
+
this.publishStatus(session);
|
|
341
|
+
},
|
|
342
|
+
reloadSession: (session) => this.reloadSessionRuntime(session),
|
|
343
|
+
getSessionTree: (session) => {
|
|
344
|
+
if (typeof session.sessionManager.getTree !== "function" || typeof session.navigateTree !== "function")
|
|
345
|
+
return undefined;
|
|
346
|
+
return projectSessionTree(session.sessionManager.getTree(), session.sessionManager.getLeafId());
|
|
347
|
+
},
|
|
348
|
+
hasActiveWork: (session) => this.hasActiveWork(session),
|
|
349
|
+
isTreeNavigationActive: (session) => this.treeNavigations.has(session),
|
|
350
|
+
runSessionReplacement: (session, operation) => this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before replacing the session", operation),
|
|
351
|
+
}, { listSessionNames: (cwd) => this.listSessionNames(cwd) });
|
|
352
|
+
}
|
|
353
|
+
activeCount() {
|
|
354
|
+
return this.active.size;
|
|
355
|
+
}
|
|
356
|
+
notificationCatalog() {
|
|
357
|
+
return this.notificationStore.catalogSnapshot();
|
|
358
|
+
}
|
|
359
|
+
async unreadCatalog() {
|
|
360
|
+
await this.publishUnreadMutations([]);
|
|
361
|
+
return this.unreadStore.durableCatalogSnapshot();
|
|
362
|
+
}
|
|
363
|
+
async acknowledgeUnread(sessionId, request) {
|
|
364
|
+
const result = this.unreadStore.acknowledge(sessionId, {
|
|
365
|
+
...request,
|
|
366
|
+
cwd: canonicalizeStoredCwd(request.cwd),
|
|
367
|
+
});
|
|
368
|
+
await this.publishUnreadMutations(result.mutations);
|
|
369
|
+
return this.unreadStore.durableCatalogSnapshot();
|
|
370
|
+
}
|
|
371
|
+
notificationInbox(ref) {
|
|
372
|
+
return this.notificationStore.inboxSnapshot(ref.id, canonicalizeStoredCwd(ref.cwd));
|
|
373
|
+
}
|
|
374
|
+
dismissNotification(ref, request) {
|
|
375
|
+
const result = this.notificationStore.dismissNotification(ref.id, canonicalizeStoredCwd(ref.cwd), request.daemonInstanceId, request.notificationId);
|
|
376
|
+
this.publishNotificationMutations(result.mutations);
|
|
377
|
+
return result.snapshot;
|
|
378
|
+
}
|
|
379
|
+
dismissAllNotifications(ref, request) {
|
|
380
|
+
const result = this.notificationStore.dismissAll(ref.id, canonicalizeStoredCwd(ref.cwd), request.daemonInstanceId, request.throughOrder, request.throughOverflowWatermark);
|
|
381
|
+
this.publishNotificationMutations(result.mutations);
|
|
382
|
+
return result.snapshot;
|
|
383
|
+
}
|
|
384
|
+
async cleanupPreview(request) {
|
|
385
|
+
return previewResponseFromPlan(await this.cleanupPlan(request));
|
|
386
|
+
}
|
|
387
|
+
async cleanup(request) {
|
|
388
|
+
const plan = await this.cleanupPlan(request);
|
|
389
|
+
if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
|
|
390
|
+
throw new Error("Archive store does not support deletion");
|
|
391
|
+
const archiveInputs = [];
|
|
392
|
+
const readyArchiveInputs = [];
|
|
393
|
+
const deleteRecords = [];
|
|
394
|
+
const readyDeleteRecords = [];
|
|
395
|
+
const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
|
|
396
|
+
for (const input of plan.archiveInputs) {
|
|
397
|
+
if (this.activeSessionHasWork(input.sessionId)) {
|
|
398
|
+
skippedBusySessionIds.add(input.sessionId);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
|
|
402
|
+
readyArchiveInputs.push(input);
|
|
403
|
+
}
|
|
404
|
+
await this.archiveStoreArchiveMany(readyArchiveInputs);
|
|
405
|
+
archiveInputs.push(...readyArchiveInputs);
|
|
406
|
+
await this.forgetUnreadSessions(readyArchiveInputs);
|
|
407
|
+
for (const record of plan.deleteRecords) {
|
|
408
|
+
if (this.activeSessionHasWork(record.sessionId)) {
|
|
409
|
+
skippedBusySessionIds.add(record.sessionId);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
|
|
413
|
+
readyDeleteRecords.push(record);
|
|
414
|
+
}
|
|
415
|
+
await this.ensureArchivedRecordsMoved(readyDeleteRecords);
|
|
416
|
+
const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
|
|
417
|
+
deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
|
|
418
|
+
await this.forgetUnreadSessions(deleteRecords);
|
|
419
|
+
return summarizeSessionCleanupExecution({
|
|
420
|
+
archiveInputs,
|
|
421
|
+
deleteRecords,
|
|
422
|
+
thresholds: plan.thresholds,
|
|
423
|
+
generatedAt: plan.generatedAt,
|
|
424
|
+
skippedBusySessionIds: [...skippedBusySessionIds],
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
async dispose() {
|
|
428
|
+
this.unreadPublicationStopped = true;
|
|
429
|
+
this.clearUnreadPublicationRetry();
|
|
430
|
+
clearInterval(this.heartbeat);
|
|
431
|
+
this.clearCompactionDrainTimers();
|
|
432
|
+
const pendingOpens = this.pendingSessionOpenPromises();
|
|
433
|
+
if (pendingOpens.length > 0)
|
|
434
|
+
await Promise.allSettled(pendingOpens);
|
|
435
|
+
const activeSessions = Array.from(new Set(this.active.values()));
|
|
436
|
+
for (const active of activeSessions)
|
|
437
|
+
this.forgetUnreadActivity(active.runtime.session);
|
|
438
|
+
this.active.clear();
|
|
439
|
+
this.pendingSessionOpens.clear();
|
|
440
|
+
this.activities.clear();
|
|
441
|
+
this.compactionPromptQueues.clear();
|
|
442
|
+
this.authLossWarnings.clear();
|
|
443
|
+
this.subsessionParents.clear();
|
|
444
|
+
this.subsessionChildren.clear();
|
|
445
|
+
this.subsessionLinks.clear();
|
|
446
|
+
this.subsessionHydratedParents.clear();
|
|
447
|
+
this.subsessionNotifyArmed.clear();
|
|
448
|
+
this.notificationStore.clearAll("service-dispose");
|
|
449
|
+
await Promise.all(activeSessions.map(async (active) => {
|
|
450
|
+
active.unsubscribe();
|
|
451
|
+
active.runtime.setRebindSession(undefined);
|
|
452
|
+
this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
|
|
453
|
+
try {
|
|
454
|
+
await this.abortSessionOperations(active.runtime.session);
|
|
455
|
+
}
|
|
456
|
+
finally {
|
|
457
|
+
await active.runtime.dispose();
|
|
458
|
+
}
|
|
459
|
+
}));
|
|
460
|
+
await this.publishUnreadMutations([]);
|
|
461
|
+
}
|
|
462
|
+
async list(cwd) {
|
|
463
|
+
const [sessions, archivedRecords, pinnedPaths] = await Promise.all([
|
|
464
|
+
this.sessionManager.list(cwd),
|
|
465
|
+
this.archiveStore.list(),
|
|
466
|
+
this.metadataStore.pinnedPaths(),
|
|
467
|
+
]);
|
|
468
|
+
const pinnedPathSet = new Set(pinnedPaths);
|
|
469
|
+
const sessionsById = new Map(sessions.map((session) => [session.id, session]));
|
|
470
|
+
const archivedForCwd = await Promise.all(archivedRecords
|
|
471
|
+
.filter((record) => record.cwd === cwd)
|
|
472
|
+
.map((record) => this.ensureArchivedSessionMoved(record, sessionsById.get(record.sessionId))));
|
|
473
|
+
const archivedById = new Map(archivedForCwd.map((record) => [record.sessionId, record]));
|
|
474
|
+
for (const record of archivedForCwd) {
|
|
475
|
+
this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
|
|
476
|
+
}
|
|
477
|
+
const unarchivedSessions = sessions
|
|
478
|
+
.filter((session) => !archivedById.has(session.id))
|
|
479
|
+
.map((entry) => mergeSessionMetadata(clientSessionFromListEntry(entry), pinnedPathSet));
|
|
480
|
+
const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
|
|
481
|
+
this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
|
|
482
|
+
await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
|
|
483
|
+
const archivedSessions = archivedForCwd
|
|
484
|
+
.sort(compareArchivedRecords)
|
|
485
|
+
.map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
|
|
486
|
+
.filter(isDefined)
|
|
487
|
+
.map((session) => mergeSessionMetadata(session, pinnedPathSet));
|
|
488
|
+
return [...unarchivedSessions, ...archivedSessions];
|
|
489
|
+
}
|
|
490
|
+
async start(cwd, options = {}) {
|
|
491
|
+
return this.startSession(cwd, options);
|
|
492
|
+
}
|
|
493
|
+
async startSession(cwd, options) {
|
|
494
|
+
const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
|
|
495
|
+
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
|
496
|
+
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
|
|
497
|
+
});
|
|
498
|
+
const { session } = active.runtime;
|
|
499
|
+
const created = {
|
|
500
|
+
id: session.sessionId,
|
|
501
|
+
path: session.sessionFile ?? "",
|
|
502
|
+
cwd,
|
|
503
|
+
persisted: sessionFileExists(session.sessionFile),
|
|
504
|
+
created: new Date().toISOString(),
|
|
505
|
+
modified: new Date().toISOString(),
|
|
506
|
+
messageCount: session.messages.length,
|
|
507
|
+
firstMessage: "",
|
|
508
|
+
// Include the parent so listeners can nest the new session in the tree
|
|
509
|
+
// immediately, instead of showing it flat until the next reload.
|
|
510
|
+
...(options.parentSession === undefined ? {} : { parentSessionPath: options.parentSession }),
|
|
511
|
+
};
|
|
512
|
+
// Broadcast so other clients (and the spawning agent's UI) can add the new
|
|
513
|
+
// session to their list without a manual reload.
|
|
514
|
+
this.events.publishGlobal({ type: "session.created", session: created });
|
|
515
|
+
return created;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Start a new session on behalf of a LLM and deliver an initial prompt to it.
|
|
519
|
+
* The target cwd is constrained to a workspace of the same registered project
|
|
520
|
+
* as the spawning session so the new session is visible in the web UI.
|
|
521
|
+
*/
|
|
522
|
+
async spawnSession(input) {
|
|
523
|
+
if (this.spawnTargets === undefined)
|
|
524
|
+
throw new Error("Spawning sessions is disabled");
|
|
525
|
+
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
|
526
|
+
if (!decision.allowed)
|
|
527
|
+
throw spawnTargetError(decision);
|
|
528
|
+
const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
|
|
529
|
+
await this.prompt(created.id, input.prompt);
|
|
530
|
+
this.logger.info({ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_session started a new session");
|
|
531
|
+
return { sessionId: created.id, cwd: decision.cwd };
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Start a *tracked* child session on behalf of a LLM. Identical to
|
|
535
|
+
* {@link spawnSession} in how the target cwd is resolved, but the child
|
|
536
|
+
* records its parent (so it shows in the session tree) and is registered so
|
|
537
|
+
* the parent is notified when it stops working and can inspect it later.
|
|
538
|
+
*/
|
|
539
|
+
async spawnSubsession(input) {
|
|
540
|
+
if (this.spawnTargets === undefined)
|
|
541
|
+
throw new Error("Spawning sessions is disabled");
|
|
542
|
+
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
|
543
|
+
if (!decision.allowed)
|
|
544
|
+
throw spawnTargetError(decision);
|
|
545
|
+
const created = await this.startSession(decision.cwd, {
|
|
546
|
+
...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
|
|
547
|
+
...(input.model === undefined ? {} : { initialModel: input.model }),
|
|
548
|
+
creationProvenance: "tracked-subsession",
|
|
549
|
+
});
|
|
550
|
+
const parentSessionFile = nonEmptyString(input.parentSessionFile);
|
|
551
|
+
const link = {
|
|
552
|
+
parentSessionId: input.parentSessionId,
|
|
553
|
+
childSessionId: created.id,
|
|
554
|
+
...(created.path === "" ? {} : { childSessionFile: created.path }),
|
|
555
|
+
...(parentSessionFile === undefined ? {} : { parentSessionFile }),
|
|
556
|
+
cwd: decision.cwd,
|
|
557
|
+
};
|
|
558
|
+
await this.registerVerifiedSubsession(link);
|
|
559
|
+
this.persistSubsessionLink(link);
|
|
560
|
+
this.persistSubsessionChildMarker(input.parentSessionId, created.id);
|
|
561
|
+
await this.prompt(created.id, input.prompt);
|
|
562
|
+
this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
|
|
563
|
+
return { sessionId: created.id, cwd: decision.cwd };
|
|
564
|
+
}
|
|
565
|
+
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */
|
|
566
|
+
async listSubsessions(parentSessionId, parentSessionFile) {
|
|
567
|
+
const parentFile = nonEmptyString(parentSessionFile);
|
|
568
|
+
await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
|
|
569
|
+
const childIds = this.subsessionChildren.get(parentSessionId);
|
|
570
|
+
if (childIds === undefined)
|
|
571
|
+
return [];
|
|
572
|
+
const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId));
|
|
573
|
+
return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
|
|
574
|
+
}
|
|
575
|
+
/** Status and final result of a subsession, scoped to the caller's children. */
|
|
576
|
+
async checkSubsession(parentSessionId, sessionId, parentSessionFile) {
|
|
577
|
+
const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
|
|
578
|
+
const messages = historyMessages(session);
|
|
579
|
+
return {
|
|
580
|
+
sessionId,
|
|
581
|
+
cwd: session.sessionManager.getCwd(),
|
|
582
|
+
status: this.subsessionStatus(session),
|
|
583
|
+
finalText: finalAssistantText(messages),
|
|
584
|
+
messageCount: messages.length,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/** Filtered, paginated transcript of a subsession, scoped to the caller's children. */
|
|
588
|
+
async readSubsession(parentSessionId, sessionId, query, parentSessionFile) {
|
|
589
|
+
const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
|
|
590
|
+
const view = buildTranscriptView(historyMessages(session), query);
|
|
591
|
+
return {
|
|
592
|
+
sessionId,
|
|
593
|
+
cwd: session.sessionManager.getCwd(),
|
|
594
|
+
status: this.subsessionStatus(session),
|
|
595
|
+
...view,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/** Open a session after verifying it is one of the caller's tracked children. */
|
|
599
|
+
async openSubsession(parentSessionId, sessionId, parentSessionFile) {
|
|
600
|
+
const parentFile = nonEmptyString(parentSessionFile);
|
|
601
|
+
await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
|
|
602
|
+
if (this.subsessionParents.get(sessionId) !== parentSessionId || !this.subsessionLinkBelongsToParent(parentSessionId, parentFile, sessionId)) {
|
|
603
|
+
throw new Error(`Session ${sessionId} is not one of your subsessions`);
|
|
604
|
+
}
|
|
605
|
+
return this.getOrOpenTrackedSubsession(sessionId);
|
|
606
|
+
}
|
|
607
|
+
subsessionLinkBelongsToParent(parentSessionId, parentSessionFile, childSessionId) {
|
|
608
|
+
const link = this.subsessionLinks.get(childSessionId);
|
|
609
|
+
if (link?.parentSessionId !== parentSessionId)
|
|
610
|
+
return false;
|
|
611
|
+
return parentSessionFile === undefined || trackedLinkParentFileMatches(link, parentSessionFile);
|
|
612
|
+
}
|
|
613
|
+
activeChildForSubsessionLink(link) {
|
|
614
|
+
const active = this.active.get(link.childSessionId);
|
|
615
|
+
if (active === undefined)
|
|
616
|
+
return undefined;
|
|
617
|
+
return activeSessionFileMatches(active, link.childSessionFile) ? active : undefined;
|
|
618
|
+
}
|
|
619
|
+
activeParentForSubsessionLink(link) {
|
|
620
|
+
const active = this.active.get(link.parentSessionId);
|
|
621
|
+
if (active === undefined)
|
|
622
|
+
return undefined;
|
|
623
|
+
return activeSessionFileMatches(active, link.parentSessionFile) ? active : undefined;
|
|
624
|
+
}
|
|
625
|
+
subsessionLinkForActiveChild(session) {
|
|
626
|
+
const childId = session.sessionId;
|
|
627
|
+
const parentId = this.subsessionParents.get(childId);
|
|
628
|
+
const link = this.subsessionLinks.get(childId);
|
|
629
|
+
if (parentId === undefined || link?.parentSessionId !== parentId)
|
|
630
|
+
return undefined;
|
|
631
|
+
return sessionFileMatches(session, link.childSessionFile) ? link : undefined;
|
|
632
|
+
}
|
|
633
|
+
async registerVerifiedSubsession(link) {
|
|
634
|
+
const { childSessionId, parentSessionId } = link;
|
|
635
|
+
const previousParentId = this.subsessionParents.get(childSessionId);
|
|
636
|
+
if (previousParentId !== undefined && previousParentId !== parentSessionId) {
|
|
637
|
+
const previousChildren = this.subsessionChildren.get(previousParentId);
|
|
638
|
+
previousChildren?.delete(childSessionId);
|
|
639
|
+
if (previousChildren?.size === 0)
|
|
640
|
+
this.subsessionChildren.delete(previousParentId);
|
|
641
|
+
}
|
|
642
|
+
this.subsessionParents.set(childSessionId, parentSessionId);
|
|
643
|
+
const children = this.subsessionChildren.get(parentSessionId) ?? new Set();
|
|
644
|
+
children.add(childSessionId);
|
|
645
|
+
this.subsessionChildren.set(parentSessionId, children);
|
|
646
|
+
this.subsessionLinks.set(childSessionId, link);
|
|
647
|
+
if (!this.subsessionNotifyArmed.has(childSessionId))
|
|
648
|
+
this.subsessionNotifyArmed.set(childSessionId, false);
|
|
649
|
+
const cwd = this.cwdForVerifiedSubsession(link);
|
|
650
|
+
await this.publishUnreadMutations(this.unreadStore.excludeSession(childSessionId, cwd));
|
|
651
|
+
}
|
|
652
|
+
cwdForVerifiedSubsession(link) {
|
|
653
|
+
const activeCwd = this.activeChildForSubsessionLink(link)?.runtime.session.sessionManager.getCwd();
|
|
654
|
+
const linkedCwd = nonEmptyString(activeCwd) ?? nonEmptyString(link.cwd);
|
|
655
|
+
if (linkedCwd !== undefined)
|
|
656
|
+
return canonicalizeStoredCwd(linkedCwd);
|
|
657
|
+
const childSessionFile = link.childSessionFile;
|
|
658
|
+
if (childSessionFile !== undefined) {
|
|
659
|
+
try {
|
|
660
|
+
return canonicalizeStoredCwd(this.sessionManager.open(childSessionFile).getCwd());
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
throw new Error("Could not resolve cwd for verified tracked sub-session", { cause: error });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
throw new Error("Could not resolve cwd for verified tracked sub-session");
|
|
667
|
+
}
|
|
668
|
+
unregisterSubsession(childSessionId) {
|
|
669
|
+
const parentSessionId = this.subsessionParents.get(childSessionId);
|
|
670
|
+
this.subsessionParents.delete(childSessionId);
|
|
671
|
+
this.subsessionLinks.delete(childSessionId);
|
|
672
|
+
this.subsessionNotifyArmed.delete(childSessionId);
|
|
673
|
+
if (parentSessionId === undefined)
|
|
674
|
+
return;
|
|
675
|
+
const children = this.subsessionChildren.get(parentSessionId);
|
|
676
|
+
children?.delete(childSessionId);
|
|
677
|
+
if (children?.size === 0)
|
|
678
|
+
this.subsessionChildren.delete(parentSessionId);
|
|
679
|
+
}
|
|
680
|
+
persistSubsessionLink(link) {
|
|
681
|
+
const parent = this.activeParentForSubsessionLink(link)?.runtime.session;
|
|
682
|
+
if (parent === undefined)
|
|
683
|
+
return;
|
|
684
|
+
if (parent.sessionManager.appendCustomEntry === undefined)
|
|
685
|
+
return;
|
|
686
|
+
try {
|
|
687
|
+
parent.sessionManager.appendCustomEntry(SUBSESSION_LINK_CUSTOM_TYPE, persistedParentSubsessionLinkData(link));
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
this.logger.info({ parentSessionId: link.parentSessionId, sessionId: link.childSessionId, error: error instanceof Error ? error.message : String(error) }, "failed to persist subsession link");
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
persistSubsessionChildMarker(parentSessionId, childSessionId) {
|
|
694
|
+
const child = this.active.get(childSessionId)?.runtime.session;
|
|
695
|
+
if (child === undefined)
|
|
696
|
+
return;
|
|
697
|
+
if (child.sessionManager.appendCustomEntry === undefined)
|
|
698
|
+
return;
|
|
699
|
+
try {
|
|
700
|
+
child.sessionManager.appendCustomEntry(SUBSESSION_CHILD_LINK_CUSTOM_TYPE, persistedChildSubsessionLinkData(parentSessionId, childSessionId));
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
this.logger.info({ parentSessionId, sessionId: childSessionId, error: error instanceof Error ? error.message : String(error) }, "failed to persist subsession child marker");
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
async hydrateSubsessionsForParent(parentSessionId, parentSessionFile) {
|
|
707
|
+
const hydrationKey = subsessionHydratedParentKey(parentSessionId, parentSessionFile);
|
|
708
|
+
if (this.subsessionHydratedParents.has(hydrationKey))
|
|
709
|
+
return;
|
|
710
|
+
const activeParent = this.active.get(parentSessionId);
|
|
711
|
+
if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) {
|
|
712
|
+
const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile);
|
|
713
|
+
const complete = await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
|
|
714
|
+
if (complete)
|
|
715
|
+
this.subsessionHydratedParents.add(hydrationKey);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (parentSessionFile === undefined)
|
|
719
|
+
return;
|
|
720
|
+
if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId)
|
|
721
|
+
return;
|
|
722
|
+
let parentManager;
|
|
723
|
+
try {
|
|
724
|
+
parentManager = this.sessionManager.open(parentSessionFile);
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const complete = await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
|
|
730
|
+
if (complete)
|
|
731
|
+
this.subsessionHydratedParents.add(hydrationKey);
|
|
732
|
+
}
|
|
733
|
+
async registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile) {
|
|
734
|
+
// Parent custom links are the authoritative recovery record: verify the
|
|
735
|
+
// exact live child file/header before tracking. Do not negatively cache a
|
|
736
|
+
// scan while a candidate child is temporarily unavailable.
|
|
737
|
+
const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
|
|
738
|
+
let complete = true;
|
|
739
|
+
for (const entry of entries) {
|
|
740
|
+
const link = parsePersistedParentSubsessionLink(entry);
|
|
741
|
+
if (link?.spawnedBySessionId !== parentSessionId)
|
|
742
|
+
continue;
|
|
743
|
+
const verified = await this.verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link);
|
|
744
|
+
if (verified === undefined) {
|
|
745
|
+
complete = false;
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
await this.registerVerifiedSubsession(verified);
|
|
749
|
+
}
|
|
750
|
+
return complete;
|
|
751
|
+
}
|
|
752
|
+
async verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link) {
|
|
753
|
+
if (parentSessionFile === undefined)
|
|
754
|
+
return undefined;
|
|
755
|
+
if (link.spawnedBySessionId !== parentSessionId)
|
|
756
|
+
return undefined;
|
|
757
|
+
if (!(await this.parentLinkHasValidChildTarget(parentSessionFile, link)))
|
|
758
|
+
return undefined;
|
|
759
|
+
return trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile);
|
|
760
|
+
}
|
|
761
|
+
async parentLinkHasValidChildTarget(parentSessionFile, link) {
|
|
762
|
+
return link.spawnedSessionFile !== undefined
|
|
763
|
+
&& await sessionFileHeaderMatches(link.spawnedSessionFile, { sessionId: link.spawnedSessionId, parentSessionFile });
|
|
764
|
+
}
|
|
765
|
+
async recoverSubsessionTrackingForOpenedSession(session) {
|
|
766
|
+
const link = await this.verifiedSubsessionLinkFromOpenedChild(session);
|
|
767
|
+
if (link === undefined)
|
|
768
|
+
return;
|
|
769
|
+
await this.registerVerifiedSubsession(link);
|
|
770
|
+
}
|
|
771
|
+
verifiedSubsessionLinkFromOpenedChild(session) {
|
|
772
|
+
return verifiedTrackedSubsessionLink(this.sessionManager, {
|
|
773
|
+
sessionId: session.sessionId,
|
|
774
|
+
sessionFile: session.sessionFile,
|
|
775
|
+
sessionManager: session.sessionManager,
|
|
776
|
+
cwd: session.sessionManager.getCwd(),
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
async getOrOpenTrackedSubsession(sessionId) {
|
|
780
|
+
const link = this.subsessionLinks.get(sessionId);
|
|
781
|
+
if (link === undefined)
|
|
782
|
+
throw new Error("Session not found");
|
|
783
|
+
const active = this.activeChildForSubsessionLink(link);
|
|
784
|
+
if (active !== undefined)
|
|
785
|
+
return active.runtime.session;
|
|
786
|
+
if (link.childSessionFile !== undefined) {
|
|
787
|
+
if (!(await sessionFileHeaderMatches(link.childSessionFile, { sessionId, parentSessionFile: link.parentSessionFile })))
|
|
788
|
+
throw new Error("Session not found");
|
|
789
|
+
const sessionManager = this.sessionManager.open(link.childSessionFile);
|
|
790
|
+
return (await this.create(sessionManager, link.cwd ?? sessionManager.getCwd())).runtime.session;
|
|
791
|
+
}
|
|
792
|
+
throw new Error("Session not found");
|
|
793
|
+
}
|
|
794
|
+
async subsessionSummaryFields(childSessionId) {
|
|
795
|
+
const link = this.subsessionLinks.get(childSessionId);
|
|
796
|
+
const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
|
|
797
|
+
if (active !== undefined) {
|
|
798
|
+
return { cwd: active.runtime.cwd, status: this.subsessionStatus(active.runtime.session) };
|
|
799
|
+
}
|
|
800
|
+
if (link?.childSessionFile !== undefined && (await sessionFileHeaderMatches(link.childSessionFile, { sessionId: childSessionId, parentSessionFile: link.parentSessionFile }))) {
|
|
801
|
+
return { cwd: link.cwd ?? "", status: "idle" };
|
|
802
|
+
}
|
|
803
|
+
if (link?.cwd !== undefined)
|
|
804
|
+
return { cwd: link.cwd, status: "unknown" };
|
|
805
|
+
return { cwd: "", status: "unknown" };
|
|
806
|
+
}
|
|
807
|
+
subsessionStatus(session) {
|
|
808
|
+
if (this.hasActiveWork(session))
|
|
809
|
+
return "working";
|
|
810
|
+
if (this.activities.get(session.sessionId)?.phase === "error")
|
|
811
|
+
return "error";
|
|
812
|
+
return "idle";
|
|
813
|
+
}
|
|
814
|
+
workingSubsessionIds(parentSessionId) {
|
|
815
|
+
const childIds = this.subsessionChildren.get(parentSessionId);
|
|
816
|
+
if (childIds === undefined)
|
|
817
|
+
return [];
|
|
818
|
+
return [...childIds].filter((childId) => {
|
|
819
|
+
const link = this.subsessionLinks.get(childId);
|
|
820
|
+
const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
|
|
821
|
+
return active !== undefined && this.hasActiveWork(active.runtime.session);
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Drive parent notifications from a tracked child's status. Arms a pending
|
|
826
|
+
* notification while the child is working, and when it stops fires a single
|
|
827
|
+
* follow-up message to the parent via {@link prompt} (which queues if the
|
|
828
|
+
* parent is busy and delivers immediately when it is idle).
|
|
829
|
+
*/
|
|
830
|
+
updateSubsessionTracking(session) {
|
|
831
|
+
const link = this.subsessionLinkForActiveChild(session);
|
|
832
|
+
if (link === undefined)
|
|
833
|
+
return;
|
|
834
|
+
const childId = link.childSessionId;
|
|
835
|
+
if (this.hasActiveWork(session)) {
|
|
836
|
+
this.subsessionNotifyArmed.set(childId, true);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (this.subsessionNotifyArmed.get(childId) !== true)
|
|
840
|
+
return;
|
|
841
|
+
this.subsessionNotifyArmed.set(childId, false);
|
|
842
|
+
const status = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
|
|
843
|
+
const finalText = finalAssistantText(historyMessages(session));
|
|
844
|
+
const outputSection = formatSubsessionNotificationOutput(childId, finalText);
|
|
845
|
+
const workingIds = this.workingSubsessionIds(link.parentSessionId);
|
|
846
|
+
const next = workingIds.length === 0
|
|
847
|
+
? "No other tracked subsessions are working."
|
|
848
|
+
: `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.`;
|
|
849
|
+
const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n${outputSection}`;
|
|
850
|
+
void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
|
|
851
|
+
}
|
|
852
|
+
async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
|
|
853
|
+
const link = this.subsessionLinks.get(childSessionId);
|
|
854
|
+
if (link?.parentSessionId !== parentSessionId)
|
|
855
|
+
throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
|
|
856
|
+
const active = this.activeParentForSubsessionLink(link);
|
|
857
|
+
if (active !== undefined)
|
|
858
|
+
return active.runtime.session;
|
|
859
|
+
const parentSessionFile = link.parentSessionFile;
|
|
860
|
+
if (parentSessionFile === undefined)
|
|
861
|
+
throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
|
|
862
|
+
if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
|
|
863
|
+
throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
|
|
864
|
+
}
|
|
865
|
+
const sessionManager = this.sessionManager.open(parentSessionFile);
|
|
866
|
+
return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Deliver a subsession-completion notice to the parent as a system-authored
|
|
870
|
+
* custom message rather than a user message, so it is not attributed to the
|
|
871
|
+
* human in the transcript. It still wakes an idle parent (`triggerTurn`) and
|
|
872
|
+
* queues behind in-flight work (`deliverAs: "followUp"`), preserving the
|
|
873
|
+
* established "queue if busy, send and act if idle" behavior.
|
|
874
|
+
*/
|
|
875
|
+
async notifyParentOfSubsession(parentId, childId, text) {
|
|
876
|
+
try {
|
|
877
|
+
const session = await this.getOrOpenParentForSubsession(parentId, childId);
|
|
878
|
+
if (this.treeNavigations.has(session)) {
|
|
879
|
+
const pending = this.deferredSubsessionNotifications.get(session) ?? [];
|
|
880
|
+
pending.push({ parentId, childId, text });
|
|
881
|
+
this.deferredSubsessionNotifications.set(session, pending);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
await this.deliverSubsessionNotification(session, { parentId, childId, text });
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
this.logSubsessionNotificationFailure(parentId, childId, error);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
async deliverSubsessionNotification(session, notification) {
|
|
891
|
+
await this.runSessionEntryMutation(session, "deliver a subsession notification", () => session.sendCustomMessage({ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: notification.text, display: true, details: { sessionId: notification.childId } }, { triggerTurn: true, deliverAs: "followUp" }));
|
|
892
|
+
this.publishStatus(session);
|
|
893
|
+
}
|
|
894
|
+
logSubsessionNotificationFailure(parentId, childId, error) {
|
|
895
|
+
this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
|
|
896
|
+
}
|
|
897
|
+
async exportHistory(ref) {
|
|
898
|
+
return exportSessionHistoryHtml(await this.sessionFileForHistory(ref));
|
|
899
|
+
}
|
|
900
|
+
async messages(ref, page) {
|
|
901
|
+
const session = await this.getOrOpen(ref);
|
|
902
|
+
return pageMessagesAtSafeBoundary(historyMessages(session), page);
|
|
903
|
+
}
|
|
904
|
+
async status(ref) {
|
|
905
|
+
return this.statusFromSession(await this.getOrOpen(ref));
|
|
906
|
+
}
|
|
907
|
+
async systemPrompt(ref) {
|
|
908
|
+
const systemPrompt = (await this.getOrOpen(ref)).state.systemPrompt;
|
|
909
|
+
return systemPrompt === undefined ? {} : { systemPrompt };
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Join-time snapshot of the in-flight assistant stream. The `seq` watermark and
|
|
913
|
+
* the partial are read together in one synchronous tick (no await between the
|
|
914
|
+
* `currentSeq` read and the `state.streamingMessage` read) so a joining client
|
|
915
|
+
* can seed the partial and then apply only buffered live events with
|
|
916
|
+
* `seq > snapshot.seq`. The partial is browser-projected to strip thinking
|
|
917
|
+
* signatures; it is `null` when no assistant message is mid-stream.
|
|
918
|
+
*/
|
|
919
|
+
async streamSnapshot(ref) {
|
|
920
|
+
const session = await this.getOrOpen(ref);
|
|
921
|
+
// Single consistent tick: capture the watermark and the partial together so
|
|
922
|
+
// the seq matches the partial the client seeds against.
|
|
923
|
+
const seq = this.events.currentSeq(session.sessionId);
|
|
924
|
+
const streamingMessage = session.state.streamingMessage;
|
|
925
|
+
const partial = streamingMessage === undefined || streamingMessage === null
|
|
926
|
+
? null
|
|
927
|
+
: projectBrowserMessage(streamingMessage);
|
|
928
|
+
return { seq, partial };
|
|
929
|
+
}
|
|
930
|
+
async availableModels(ref) {
|
|
931
|
+
const session = await this.getOrOpen(ref);
|
|
932
|
+
await session.modelRuntime.reloadConfig();
|
|
933
|
+
const models = session.scopedModels.length > 0
|
|
934
|
+
? session.scopedModels.map((scoped) => scoped.model)
|
|
935
|
+
: session.modelRuntime.getAvailableSnapshot();
|
|
936
|
+
return models.map(modelToClientModel);
|
|
937
|
+
}
|
|
938
|
+
async setModel(ref, provider, modelId) {
|
|
939
|
+
await this.assertWritable(ref);
|
|
940
|
+
const session = await this.getOrOpen(ref);
|
|
941
|
+
this.assertTreeNavigationInactive(session, "change models");
|
|
942
|
+
await session.modelRuntime.reloadConfig();
|
|
943
|
+
this.assertTreeNavigationInactive(session, "change models");
|
|
944
|
+
const candidates = session.scopedModels.length > 0
|
|
945
|
+
? session.scopedModels.map((scoped) => scoped.model)
|
|
946
|
+
: session.modelRuntime.getAvailableSnapshot();
|
|
947
|
+
const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
|
|
948
|
+
?? session.modelRuntime.getModel(provider, modelId);
|
|
949
|
+
if (model === undefined)
|
|
950
|
+
throw new Error(`Model not found: ${provider}/${modelId}`);
|
|
951
|
+
await this.runSessionEntryMutation(session, "change models", () => session.setModel(model));
|
|
952
|
+
this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
|
|
953
|
+
this.publishStatus(session);
|
|
954
|
+
return this.statusFromSession(session);
|
|
955
|
+
}
|
|
956
|
+
async cycleModel(ref, direction) {
|
|
957
|
+
await this.assertWritable(ref);
|
|
958
|
+
const session = await this.getOrOpen(ref);
|
|
959
|
+
const result = await this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
|
|
960
|
+
if (result === undefined)
|
|
961
|
+
throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
|
|
962
|
+
this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
|
|
963
|
+
this.publishStatus(session);
|
|
964
|
+
return this.statusFromSession(session);
|
|
965
|
+
}
|
|
966
|
+
async availableThinkingLevels(ref) {
|
|
967
|
+
const session = await this.getOrOpen(ref);
|
|
968
|
+
return session.getAvailableThinkingLevels();
|
|
969
|
+
}
|
|
970
|
+
async setThinkingLevel(ref, level) {
|
|
971
|
+
await this.assertWritable(ref);
|
|
972
|
+
const session = await this.getOrOpen(ref);
|
|
973
|
+
this.assertTreeNavigationInactive(session, "change the thinking level");
|
|
974
|
+
// pi owns the valid set; validate against the session's live levels rather
|
|
975
|
+
// than a hardcoded union so this stays correct if pi changes the set.
|
|
976
|
+
const available = session.getAvailableThinkingLevels();
|
|
977
|
+
const match = available.find((candidate) => candidate === level);
|
|
978
|
+
if (match === undefined)
|
|
979
|
+
throw new Error(`Invalid thinking level: ${level}`);
|
|
980
|
+
session.setThinkingLevel(match);
|
|
981
|
+
this.publishActivity(session, `thinking: ${session.thinkingLevel}`, "idle");
|
|
982
|
+
this.publishStatus(session);
|
|
983
|
+
return this.statusFromSession(session);
|
|
984
|
+
}
|
|
985
|
+
async cycleThinkingLevel(ref) {
|
|
986
|
+
await this.assertWritable(ref);
|
|
987
|
+
const session = await this.getOrOpen(ref);
|
|
988
|
+
this.assertTreeNavigationInactive(session, "change the thinking level");
|
|
989
|
+
const level = session.cycleThinkingLevel();
|
|
990
|
+
if (level === undefined)
|
|
991
|
+
throw new Error("Current model does not support thinking");
|
|
992
|
+
this.publishActivity(session, `thinking: ${level}`, "idle");
|
|
993
|
+
this.publishStatus(session);
|
|
994
|
+
return this.statusFromSession(session);
|
|
995
|
+
}
|
|
996
|
+
async commands(ref) {
|
|
997
|
+
const session = await this.getOrOpen(ref);
|
|
998
|
+
const commands = [...BUILTIN_COMMANDS];
|
|
999
|
+
for (const command of session.extensionRunner.getRegisteredCommands()) {
|
|
1000
|
+
commands.push({ name: command.invocationName, ...(command.description === undefined ? {} : { description: command.description }), source: "extension" });
|
|
1001
|
+
}
|
|
1002
|
+
for (const template of session.promptTemplates) {
|
|
1003
|
+
commands.push({ name: template.name, ...(template.description === undefined ? {} : { description: template.description }), source: "prompt" });
|
|
1004
|
+
}
|
|
1005
|
+
for (const skill of session.resourceLoader.getSkills().skills) {
|
|
1006
|
+
commands.push({ name: `skill:${skill.name}`, ...(skill.description === undefined ? {} : { description: skill.description }), source: "skill" });
|
|
1007
|
+
}
|
|
1008
|
+
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
|
1009
|
+
}
|
|
1010
|
+
async prompt(ref, text, streamingBehavior, attachments, options) {
|
|
1011
|
+
const promptText = requirePromptText(text);
|
|
1012
|
+
// Command-forwarded prompts (e.g. /skill:*) are expanded by the agent, which
|
|
1013
|
+
// streams the canonical message back. The client doesn't render the raw
|
|
1014
|
+
// command text, so the server must not echo it either, or it would show up
|
|
1015
|
+
// as a transient line that vanishes on reload.
|
|
1016
|
+
const echoUserMessage = options?.echoUserMessage !== false;
|
|
1017
|
+
const requestedBehavior = parsePromptStreamingBehavior(streamingBehavior);
|
|
1018
|
+
const parsedAttachments = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
|
|
1019
|
+
const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
|
|
1020
|
+
await this.assertWritable(ref);
|
|
1021
|
+
const session = await this.getOrOpen(ref);
|
|
1022
|
+
this.assertTreeNavigationInactive(session, "send a prompt");
|
|
1023
|
+
this.maybeGenerateSessionName(session, promptText);
|
|
1024
|
+
const isQueued = session.isStreaming || session.isCompacting;
|
|
1025
|
+
const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
|
|
1026
|
+
if (isQueued && images.length === 0 && this.hasQueuedMessageText(session, promptText)) {
|
|
1027
|
+
this.publishActivity(session, "duplicate queued message ignored", "active");
|
|
1028
|
+
this.publishStatus(session);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
if (session.isCompacting) {
|
|
1032
|
+
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
void this.submitPrompt(session, promptText, behavior, images, echoUserMessage);
|
|
1036
|
+
}
|
|
1037
|
+
submitPrompt(session, text, behavior, images = [], echoUserMessage = true) {
|
|
1038
|
+
this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
|
|
1039
|
+
if (behavior === undefined && echoUserMessage)
|
|
1040
|
+
this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
|
|
1041
|
+
const promptOptions = buildPromptOptions(behavior, images);
|
|
1042
|
+
const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).catch((error) => {
|
|
1043
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1044
|
+
this.publishActivity(session, "error", "error", message);
|
|
1045
|
+
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
1046
|
+
});
|
|
1047
|
+
void promptPromise;
|
|
1048
|
+
return promptPromise;
|
|
1049
|
+
}
|
|
1050
|
+
enqueuePromptDuringCompaction(session, text, kind, images = [], echoUserMessage = true) {
|
|
1051
|
+
const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
|
|
1052
|
+
queue.push({ kind, text, ...(images.length > 0 ? { images } : {}), ...(echoUserMessage ? {} : { echoUserMessage: false }) });
|
|
1053
|
+
this.compactionPromptQueues.set(session.sessionId, queue);
|
|
1054
|
+
this.publishActivity(session, "message queued during compaction", "active");
|
|
1055
|
+
this.publishStatus(session);
|
|
1056
|
+
}
|
|
1057
|
+
async saveAttachments(ref, attachments, folder) {
|
|
1058
|
+
const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
|
|
1059
|
+
if (parsed.length === 0)
|
|
1060
|
+
return [];
|
|
1061
|
+
await this.assertWritable(ref);
|
|
1062
|
+
const active = await this.getActive(ref);
|
|
1063
|
+
return saveAttachmentsToWorkspace(active.runtime.cwd, parsed, folder === undefined ? {} : { folder });
|
|
1064
|
+
}
|
|
1065
|
+
async shell(ref, text) {
|
|
1066
|
+
await this.assertWritable(ref);
|
|
1067
|
+
const active = await this.getActive(ref);
|
|
1068
|
+
const { session } = active.runtime;
|
|
1069
|
+
this.assertTreeNavigationInactive(session, "run a shell command");
|
|
1070
|
+
const isExcluded = text.startsWith("!!");
|
|
1071
|
+
const command = (isExcluded ? text.slice(2) : text.slice(1)).trim();
|
|
1072
|
+
if (!command)
|
|
1073
|
+
throw new Error("Usage: !<shell command>");
|
|
1074
|
+
if (session.isBashRunning)
|
|
1075
|
+
throw new Error("A bash command is already running");
|
|
1076
|
+
this.publishActivity(session, "running bash", "active", command);
|
|
1077
|
+
this.events.publish(session.sessionId, { type: "shell.start", command, excludeFromContext: isExcluded });
|
|
1078
|
+
void this.runSessionEntryMutation(session, "run a shell command", () => session.executeBash(command, (chunk) => {
|
|
1079
|
+
this.events.publish(session.sessionId, { type: "shell.chunk", chunk });
|
|
1080
|
+
this.publishActivity(session, "running bash", "active", command);
|
|
1081
|
+
this.publishStatus(session);
|
|
1082
|
+
}, { excludeFromContext: isExcluded })).then((result) => {
|
|
1083
|
+
this.events.publish(session.sessionId, {
|
|
1084
|
+
type: "shell.end",
|
|
1085
|
+
output: result.output,
|
|
1086
|
+
...(result.exitCode === undefined ? {} : { exitCode: result.exitCode }),
|
|
1087
|
+
cancelled: result.cancelled,
|
|
1088
|
+
truncated: result.truncated,
|
|
1089
|
+
...(result.fullOutputPath === undefined ? {} : { fullOutputPath: result.fullOutputPath }),
|
|
1090
|
+
});
|
|
1091
|
+
this.publishActivity(session, "bash complete", result.exitCode === 0 ? "idle" : "error", command);
|
|
1092
|
+
this.publishStatus(session);
|
|
1093
|
+
}).catch((error) => {
|
|
1094
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1095
|
+
this.events.publish(session.sessionId, { type: "shell.end", output: message, isError: true });
|
|
1096
|
+
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
1097
|
+
this.publishActivity(session, "bash failed", "error", message);
|
|
1098
|
+
this.publishStatus(session);
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
async runCommand(ref, text) {
|
|
1102
|
+
await this.assertWritable(ref);
|
|
1103
|
+
const active = await this.getActive(ref);
|
|
1104
|
+
return this.commandService.run(active.runtime.session.sessionId, text);
|
|
1105
|
+
}
|
|
1106
|
+
async respondToCommand(ref, requestId, value) {
|
|
1107
|
+
await this.assertWritable(ref);
|
|
1108
|
+
const active = await this.getActive(ref);
|
|
1109
|
+
return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
|
|
1110
|
+
}
|
|
1111
|
+
async navigateTree(ref, request) {
|
|
1112
|
+
if (request.targetId.trim() === "")
|
|
1113
|
+
throw new Error("Session tree target is required");
|
|
1114
|
+
if (this.isTreeExclusiveSessionIdentityActive(sessionIdFromLookup(ref))) {
|
|
1115
|
+
throw new Error("Stop current session activity before navigating the session tree");
|
|
1116
|
+
}
|
|
1117
|
+
await this.assertWritable(ref);
|
|
1118
|
+
const options = sessionTreeNavigationOptions(request);
|
|
1119
|
+
const session = await this.getOrOpen(ref);
|
|
1120
|
+
if (typeof session.navigateTree !== "function")
|
|
1121
|
+
throw new Error("Session tree navigation is not supported by this Pi runtime");
|
|
1122
|
+
if (this.hasActiveWork(session))
|
|
1123
|
+
throw new Error("Stop current session activity before navigating the session tree");
|
|
1124
|
+
// Acquire synchronously after the active-work check. No leaf-producing work
|
|
1125
|
+
// may enter this runtime until Pi's potentially asynchronous summary settles.
|
|
1126
|
+
this.treeNavigations.add(session);
|
|
1127
|
+
try {
|
|
1128
|
+
if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
|
|
1129
|
+
throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
|
|
1130
|
+
}
|
|
1131
|
+
this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
|
|
1132
|
+
this.publishStatus(session);
|
|
1133
|
+
const result = await session.navigateTree(request.targetId, options);
|
|
1134
|
+
if (result.cancelled) {
|
|
1135
|
+
if (this.isCurrentActiveSession(session)) {
|
|
1136
|
+
this.publishActivity(session, result.aborted === true ? "branch summary aborted" : "tree navigation cancelled", "idle");
|
|
1137
|
+
}
|
|
1138
|
+
return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
|
|
1139
|
+
}
|
|
1140
|
+
if (this.isCurrentActiveSession(session))
|
|
1141
|
+
this.publishActivity(session, "session tree navigated", "idle");
|
|
1142
|
+
return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
|
|
1143
|
+
}
|
|
1144
|
+
catch (error) {
|
|
1145
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1146
|
+
if (this.isCurrentActiveSession(session)) {
|
|
1147
|
+
this.publishActivity(session, "tree navigation failed", "error", message);
|
|
1148
|
+
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
1149
|
+
}
|
|
1150
|
+
throw error;
|
|
1151
|
+
}
|
|
1152
|
+
finally {
|
|
1153
|
+
this.treeNavigations.delete(session);
|
|
1154
|
+
if (this.isCurrentActiveSession(session)) {
|
|
1155
|
+
this.flushDeferredTreeNavigationWork(session);
|
|
1156
|
+
this.publishStatus(session);
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
this.deferredGeneratedSessionNames.delete(session);
|
|
1160
|
+
this.deferredSubsessionNotifications.delete(session);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
async reloadSessionRuntime(session) {
|
|
1165
|
+
if (this.hasActiveWork(session))
|
|
1166
|
+
throw new Error("Stop current session activity before reloading");
|
|
1167
|
+
await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before reloading", async () => {
|
|
1168
|
+
this.publishActivity(session, "reloading resources", "active");
|
|
1169
|
+
const priorGeneration = this.notificationGenerationBySession.get(session);
|
|
1170
|
+
let candidateGeneration;
|
|
1171
|
+
try {
|
|
1172
|
+
await session.reload(priorGeneration === undefined ? undefined : {
|
|
1173
|
+
beforeSessionStart: () => {
|
|
1174
|
+
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
|
|
1175
|
+
this.notificationGenerationBySession.set(session, candidateGeneration);
|
|
1176
|
+
this.replaceSessionNotificationContext(session, candidateGeneration);
|
|
1177
|
+
},
|
|
1178
|
+
});
|
|
1179
|
+
if (candidateGeneration !== undefined) {
|
|
1180
|
+
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
|
|
1181
|
+
}
|
|
1182
|
+
this.publishActivity(session, "resources reloaded", "idle");
|
|
1183
|
+
this.publishStatus(session);
|
|
1184
|
+
}
|
|
1185
|
+
catch (error) {
|
|
1186
|
+
if (candidateGeneration !== undefined) {
|
|
1187
|
+
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
|
|
1188
|
+
this.notificationGenerationBySession.set(session, candidateGeneration);
|
|
1189
|
+
}
|
|
1190
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1191
|
+
this.publishActivity(session, "reload failed", "error", message);
|
|
1192
|
+
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
1193
|
+
this.publishStatus(session);
|
|
1194
|
+
throw error;
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
async archive(ref) {
|
|
1199
|
+
const session = await this.getOrOpen(ref);
|
|
1200
|
+
if (this.hasActiveWork(session))
|
|
1201
|
+
throw new Error("Stop current session activity before archiving");
|
|
1202
|
+
await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before archiving", async () => {
|
|
1203
|
+
const archiveInput = await this.archiveInputForSession(session);
|
|
1204
|
+
await this.closeActive(session.sessionId, { kind: "clear", reason: "archive" });
|
|
1205
|
+
await this.archiveStore.archive(archiveInput);
|
|
1206
|
+
await this.forgetUnreadSessions([archiveInput]);
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
async archiveMany(refs) {
|
|
1210
|
+
const uniqueRefs = uniqueBulkSessionRefs(refs);
|
|
1211
|
+
const [archivedRecords, sessionContext] = await Promise.all([
|
|
1212
|
+
this.archiveStore.list(),
|
|
1213
|
+
this.bulkSessionLookupContext(uniqueRefs),
|
|
1214
|
+
]);
|
|
1215
|
+
const failures = [];
|
|
1216
|
+
const alreadyArchivedSessionIds = [];
|
|
1217
|
+
const unreadArchivedIdentities = [];
|
|
1218
|
+
const planItems = [];
|
|
1219
|
+
for (const ref of uniqueRefs) {
|
|
1220
|
+
const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
|
|
1221
|
+
if (archived !== undefined) {
|
|
1222
|
+
this.publishNotificationMutations(this.notificationStore.clearSession(archived.sessionId, "archive"));
|
|
1223
|
+
alreadyArchivedSessionIds.push(archived.sessionId);
|
|
1224
|
+
unreadArchivedIdentities.push(archived);
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
const active = this.activeForLookup(bulkRefToLookup(ref));
|
|
1228
|
+
const listed = findListedSessionForBulkRef(sessionContext, ref);
|
|
1229
|
+
const resolvedSessionId = active?.runtime.session.sessionId ?? listed?.id ?? ref.id;
|
|
1230
|
+
if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
|
|
1231
|
+
failures.push({ sessionId: resolvedSessionId, error: "Stop current session activity before archiving" });
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
try {
|
|
1235
|
+
if (listed !== undefined) {
|
|
1236
|
+
planItems.push({ input: archiveInputFromListEntry(listed) });
|
|
1237
|
+
}
|
|
1238
|
+
else if (active !== undefined) {
|
|
1239
|
+
planItems.push({ input: archiveInputFromActiveSession(active.runtime.session) });
|
|
1240
|
+
}
|
|
1241
|
+
else {
|
|
1242
|
+
failures.push({ sessionId: ref.id, error: "Session not found" });
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
catch (error) {
|
|
1246
|
+
failures.push({ sessionId: resolvedSessionId, error: errorMessage(error) });
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
const readyPlanItems = [];
|
|
1250
|
+
for (const item of planItems) {
|
|
1251
|
+
const active = this.activeForLookup({ id: item.input.sessionId, cwd: item.input.cwd });
|
|
1252
|
+
if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
|
|
1253
|
+
failures.push({ sessionId: item.input.sessionId, error: "Stop current session activity before archiving" });
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
readyPlanItems.push(active === undefined ? item : { ...item, active });
|
|
1257
|
+
}
|
|
1258
|
+
const readyInputs = [];
|
|
1259
|
+
const archivedSessionIds = [...alreadyArchivedSessionIds];
|
|
1260
|
+
await this.runTreeExclusiveOperation(readyPlanItems.map(({ input, active }) => ({
|
|
1261
|
+
sessionId: input.sessionId,
|
|
1262
|
+
...(active === undefined ? {} : { session: active.runtime.session, runtime: active.runtime }),
|
|
1263
|
+
})), "Stop current session activity before archiving", async () => {
|
|
1264
|
+
for (const item of readyPlanItems) {
|
|
1265
|
+
try {
|
|
1266
|
+
await this.closeActive(item.input.sessionId, { kind: "clear", reason: "archive" });
|
|
1267
|
+
readyInputs.push(item.input);
|
|
1268
|
+
}
|
|
1269
|
+
catch (error) {
|
|
1270
|
+
failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
try {
|
|
1274
|
+
const archived = await this.archiveStoreArchiveMany(readyInputs);
|
|
1275
|
+
archivedSessionIds.push(...archived.map((record) => record.sessionId));
|
|
1276
|
+
unreadArchivedIdentities.push(...archived);
|
|
1277
|
+
}
|
|
1278
|
+
catch (error) {
|
|
1279
|
+
for (const input of readyInputs)
|
|
1280
|
+
failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
await this.forgetUnreadSessions(unreadArchivedIdentities);
|
|
1284
|
+
return {
|
|
1285
|
+
archived: true,
|
|
1286
|
+
archivedSessionIds: uniqueStrings(archivedSessionIds),
|
|
1287
|
+
failures,
|
|
1288
|
+
generatedAt: new Date().toISOString(),
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
async archiveTree(ref) {
|
|
1292
|
+
const session = await this.getOrOpen(ref);
|
|
1293
|
+
const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
|
|
1294
|
+
const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
|
|
1295
|
+
const plan = planSessionArchiveTree(root, catalog);
|
|
1296
|
+
const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
|
|
1297
|
+
if (busy !== undefined)
|
|
1298
|
+
throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
|
|
1299
|
+
const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
|
|
1300
|
+
await this.runTreeExclusiveOperation(plan.unarchivedTargets.map((target) => ({
|
|
1301
|
+
sessionId: target.id,
|
|
1302
|
+
...(target.activeSession === undefined ? {} : { session: target.activeSession }),
|
|
1303
|
+
})), `Stop current session activity before archiving ${sessionDisplayName(session)}`, async () => {
|
|
1304
|
+
for (const target of plan.targets) {
|
|
1305
|
+
if (target.archived)
|
|
1306
|
+
this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
|
|
1307
|
+
}
|
|
1308
|
+
for (const input of archiveInputs)
|
|
1309
|
+
await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
|
|
1310
|
+
await this.archiveStoreArchiveMany(archiveInputs);
|
|
1311
|
+
});
|
|
1312
|
+
await this.forgetUnreadSessions(plan.targets.map((target) => ({ sessionId: target.id, cwd: target.cwd })));
|
|
1313
|
+
return {
|
|
1314
|
+
archived: true,
|
|
1315
|
+
sessionIds: archiveInputs.map((input) => input.sessionId),
|
|
1316
|
+
archivedCount: archiveInputs.length,
|
|
1317
|
+
skippedAlreadyArchivedCount: plan.skippedAlreadyArchivedCount,
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
async restore(ref) {
|
|
1321
|
+
const archived = await this.getArchived(ref);
|
|
1322
|
+
if (archived === undefined)
|
|
1323
|
+
throw new Error("Session not found");
|
|
1324
|
+
await this.closeActive(archived.sessionId, { kind: "clear", reason: "restore" });
|
|
1325
|
+
await this.archiveStore.restore(archived.sessionId);
|
|
1326
|
+
await this.forgetUnreadSessions([archived]);
|
|
1327
|
+
}
|
|
1328
|
+
async deleteArchived(ref) {
|
|
1329
|
+
const record = await this.getArchived(ref);
|
|
1330
|
+
if (record === undefined)
|
|
1331
|
+
throw new Error("Archived session not found");
|
|
1332
|
+
if (this.archiveStore.deleteArchived === undefined)
|
|
1333
|
+
throw new Error("Archive store does not support deletion");
|
|
1334
|
+
await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
|
|
1335
|
+
if (record.archivePath === undefined)
|
|
1336
|
+
await this.ensureArchivedRecordMoved(record);
|
|
1337
|
+
await this.archiveStore.deleteArchived(record.sessionId);
|
|
1338
|
+
await this.forgetUnreadSessions([record]);
|
|
1339
|
+
}
|
|
1340
|
+
async deleteArchivedMany(refs) {
|
|
1341
|
+
if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
|
|
1342
|
+
throw new Error("Archive store does not support deletion");
|
|
1343
|
+
const uniqueRefs = uniqueBulkSessionRefs(refs);
|
|
1344
|
+
const archivedRecords = await this.archiveStore.list();
|
|
1345
|
+
const failures = [];
|
|
1346
|
+
const planItems = [];
|
|
1347
|
+
for (const ref of uniqueRefs) {
|
|
1348
|
+
const record = findArchivedRecordForBulkRef(archivedRecords, ref);
|
|
1349
|
+
if (record === undefined) {
|
|
1350
|
+
failures.push({ sessionId: ref.id, error: "Archived session not found" });
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
const active = this.activeForLookup({ id: record.sessionId, cwd: record.cwd });
|
|
1354
|
+
if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
|
|
1355
|
+
failures.push({ sessionId: record.sessionId, error: "Stop current session activity before deleting archived session" });
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
planItems.push({ record });
|
|
1359
|
+
}
|
|
1360
|
+
const readyRecords = [];
|
|
1361
|
+
for (const item of planItems) {
|
|
1362
|
+
try {
|
|
1363
|
+
await this.closeActive(item.record.sessionId, { kind: "clear", reason: "delete" });
|
|
1364
|
+
readyRecords.push(item.record);
|
|
1365
|
+
}
|
|
1366
|
+
catch (error) {
|
|
1367
|
+
failures.push({ sessionId: item.record.sessionId, error: errorMessage(error) });
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const moveFailures = await this.moveLegacyArchivedRecordsForDelete(readyRecords);
|
|
1371
|
+
failures.push(...moveFailures);
|
|
1372
|
+
const moveFailureIds = new Set(moveFailures.map((failure) => failure.sessionId));
|
|
1373
|
+
const deleteIds = readyRecords
|
|
1374
|
+
.map((record) => record.sessionId)
|
|
1375
|
+
.filter((sessionId) => !moveFailureIds.has(sessionId));
|
|
1376
|
+
let deletedSessionIds = [];
|
|
1377
|
+
try {
|
|
1378
|
+
deletedSessionIds = await this.archiveStoreDeleteArchivedMany(deleteIds);
|
|
1379
|
+
}
|
|
1380
|
+
catch (error) {
|
|
1381
|
+
for (const sessionId of deleteIds)
|
|
1382
|
+
failures.push({ sessionId, error: errorMessage(error) });
|
|
1383
|
+
}
|
|
1384
|
+
const deletedIdSet = new Set(deletedSessionIds);
|
|
1385
|
+
await this.forgetUnreadSessions(readyRecords.filter((record) => deletedIdSet.has(record.sessionId)));
|
|
1386
|
+
return {
|
|
1387
|
+
deleted: true,
|
|
1388
|
+
deletedSessionIds,
|
|
1389
|
+
failures,
|
|
1390
|
+
generatedAt: new Date().toISOString(),
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
async reload(ref) {
|
|
1394
|
+
await this.assertWritable(ref);
|
|
1395
|
+
const session = await this.getOrOpen(ref);
|
|
1396
|
+
if (this.hasActiveWork(session))
|
|
1397
|
+
throw new Error("Stop current session activity before reloading");
|
|
1398
|
+
const reopenedSession = await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before reloading", async () => {
|
|
1399
|
+
const priorGeneration = this.notificationGenerationBySession.get(session);
|
|
1400
|
+
const { sessionId, cwd } = notificationIdentityForSession(session);
|
|
1401
|
+
let candidateGeneration;
|
|
1402
|
+
try {
|
|
1403
|
+
await this.closeActive(sessionId, priorGeneration === undefined ? CLEAR_RUNTIME_NOTIFICATIONS : DEFER_RUNTIME_NOTIFICATIONS);
|
|
1404
|
+
candidateGeneration = priorGeneration === undefined
|
|
1405
|
+
? undefined
|
|
1406
|
+
: this.notificationStore.beginReplacement(priorGeneration, { sessionId, cwd });
|
|
1407
|
+
const reopened = await this.getActive(ref, candidateGeneration === undefined ? {} : { notificationGeneration: candidateGeneration });
|
|
1408
|
+
if (candidateGeneration !== undefined) {
|
|
1409
|
+
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
|
|
1410
|
+
}
|
|
1411
|
+
return reopened.runtime.session;
|
|
1412
|
+
}
|
|
1413
|
+
catch (error) {
|
|
1414
|
+
if (candidateGeneration !== undefined) {
|
|
1415
|
+
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration));
|
|
1416
|
+
}
|
|
1417
|
+
throw error;
|
|
1418
|
+
}
|
|
1419
|
+
});
|
|
1420
|
+
this.publishStatus(reopenedSession);
|
|
1421
|
+
}
|
|
1422
|
+
async detachParent(ref) {
|
|
1423
|
+
const session = await this.getOrOpen(ref);
|
|
1424
|
+
const sessionFile = session.sessionFile;
|
|
1425
|
+
if (sessionFile === undefined || sessionFile === "")
|
|
1426
|
+
throw new Error("Session is not persisted");
|
|
1427
|
+
await clearParentSession(sessionFile);
|
|
1428
|
+
clearParentSessionHeader(session.sessionManager);
|
|
1429
|
+
this.unregisterSubsession(session.sessionId);
|
|
1430
|
+
await this.forgetUnreadSessions([{ sessionId: session.sessionId, cwd: session.sessionManager.getCwd() }]);
|
|
1431
|
+
}
|
|
1432
|
+
async pin(ref) {
|
|
1433
|
+
const session = await this.getOrOpen(ref);
|
|
1434
|
+
const sessionFile = session.sessionFile;
|
|
1435
|
+
if (sessionFile === undefined || sessionFile === "")
|
|
1436
|
+
throw new Error("Session is not persisted");
|
|
1437
|
+
await this.metadataStore.pin(sessionFile);
|
|
1438
|
+
return this.sessionInfoFromActive(session, { pinned: true });
|
|
1439
|
+
}
|
|
1440
|
+
async unpin(ref) {
|
|
1441
|
+
const session = await this.getOrOpen(ref);
|
|
1442
|
+
const sessionFile = session.sessionFile;
|
|
1443
|
+
if (sessionFile === undefined || sessionFile === "")
|
|
1444
|
+
throw new Error("Session is not persisted");
|
|
1445
|
+
await this.metadataStore.unpin(sessionFile);
|
|
1446
|
+
return this.sessionInfoFromActive(session, { pinned: false });
|
|
1447
|
+
}
|
|
1448
|
+
sessionInfoFromActive(session, overrides) {
|
|
1449
|
+
const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
|
|
1450
|
+
return {
|
|
1451
|
+
id: session.sessionId,
|
|
1452
|
+
path: session.sessionFile ?? "",
|
|
1453
|
+
cwd: session.sessionManager.getCwd(),
|
|
1454
|
+
persisted: session.sessionFile !== undefined && session.sessionFile !== "",
|
|
1455
|
+
created: new Date().toISOString(),
|
|
1456
|
+
modified: new Date().toISOString(),
|
|
1457
|
+
messageCount: session.messages.length,
|
|
1458
|
+
firstMessage: "",
|
|
1459
|
+
...(session.sessionName === undefined ? {} : { name: session.sessionName }),
|
|
1460
|
+
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
|
|
1461
|
+
...overrides,
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
async clearQueue(ref) {
|
|
1465
|
+
await this.assertWritable(ref);
|
|
1466
|
+
const session = await this.getOrOpen(ref);
|
|
1467
|
+
this.clearCompactionPromptQueue(session.sessionId);
|
|
1468
|
+
clearSessionQueue(session);
|
|
1469
|
+
this.publishStatus(session);
|
|
1470
|
+
return this.statusFromSession(session);
|
|
1471
|
+
}
|
|
1472
|
+
async dismissWarning(ref, dismissId) {
|
|
1473
|
+
const session = await this.getOrOpen(ref);
|
|
1474
|
+
dismissSessionWarning(session, dismissId);
|
|
1475
|
+
this.publishStatus(session);
|
|
1476
|
+
return this.statusFromSession(session);
|
|
1477
|
+
}
|
|
1478
|
+
async abort(ref) {
|
|
1479
|
+
const active = this.activeForLookup(ref);
|
|
1480
|
+
if (active === undefined)
|
|
1481
|
+
return;
|
|
1482
|
+
const sessionId = active.runtime.session.sessionId;
|
|
1483
|
+
this.clearCompactionPromptQueue(sessionId);
|
|
1484
|
+
clearSessionQueue(active.runtime.session);
|
|
1485
|
+
try {
|
|
1486
|
+
await this.abortSessionOperations(active.runtime.session);
|
|
1487
|
+
this.publishActivity(active.runtime.session, "stopped", "idle");
|
|
1488
|
+
}
|
|
1489
|
+
catch (error) {
|
|
1490
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1491
|
+
this.publishActivity(active.runtime.session, "stop failed", "error", message);
|
|
1492
|
+
throw error;
|
|
1493
|
+
}
|
|
1494
|
+
finally {
|
|
1495
|
+
this.publishStatus(active.runtime.session);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
async stop(ref) {
|
|
1499
|
+
const active = this.activeForLookup(ref);
|
|
1500
|
+
if (active !== undefined) {
|
|
1501
|
+
await this.closeActive(active.runtime.session.sessionId);
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
if (isPiSessionRef(ref)) {
|
|
1505
|
+
this.publishNotificationMutations(this.notificationStore.clearSessionIdentity(ref.id, canonicalizeStoredCwd(ref.cwd), "runtime-close"));
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
await this.closeActive(ref);
|
|
1509
|
+
}
|
|
1510
|
+
async bulkSessionLookupContext(refs) {
|
|
1511
|
+
const cwdSet = new Set();
|
|
1512
|
+
let needsAllSessions = false;
|
|
1513
|
+
for (const ref of refs) {
|
|
1514
|
+
if (ref.cwd === undefined)
|
|
1515
|
+
needsAllSessions = true;
|
|
1516
|
+
else
|
|
1517
|
+
cwdSet.add(ref.cwd);
|
|
1518
|
+
}
|
|
1519
|
+
const [sessionsByCwd, allSessions] = await Promise.all([
|
|
1520
|
+
this.listSessionsByCwd([...cwdSet]),
|
|
1521
|
+
needsAllSessions ? this.sessionManager.listAll?.() ?? Promise.resolve([]) : Promise.resolve(undefined),
|
|
1522
|
+
]);
|
|
1523
|
+
return allSessions === undefined ? { sessionsByCwd } : { sessionsByCwd, allSessions };
|
|
1524
|
+
}
|
|
1525
|
+
async listSessionsByCwd(cwds) {
|
|
1526
|
+
const uniqueCwds = uniqueStrings(cwds);
|
|
1527
|
+
const entries = await Promise.all(uniqueCwds.map(async (cwd) => [cwd, await this.sessionManager.list(cwd)]));
|
|
1528
|
+
return new Map(entries);
|
|
1529
|
+
}
|
|
1530
|
+
async archiveStoreArchiveMany(inputs) {
|
|
1531
|
+
if (inputs.length === 0)
|
|
1532
|
+
return [];
|
|
1533
|
+
if (this.archiveStore.archiveMany !== undefined)
|
|
1534
|
+
return this.archiveStore.archiveMany(inputs);
|
|
1535
|
+
const records = [];
|
|
1536
|
+
for (const input of inputs)
|
|
1537
|
+
records.push(await this.archiveStore.archive(input));
|
|
1538
|
+
return records;
|
|
1539
|
+
}
|
|
1540
|
+
async archiveStoreDeleteArchivedMany(sessionIds) {
|
|
1541
|
+
if (sessionIds.length === 0)
|
|
1542
|
+
return [];
|
|
1543
|
+
if (this.archiveStore.deleteArchivedMany !== undefined)
|
|
1544
|
+
return this.archiveStore.deleteArchivedMany(sessionIds);
|
|
1545
|
+
if (this.archiveStore.deleteArchived === undefined)
|
|
1546
|
+
throw new Error("Archive store does not support deletion");
|
|
1547
|
+
for (const sessionId of sessionIds)
|
|
1548
|
+
await this.archiveStore.deleteArchived(sessionId);
|
|
1549
|
+
return [...sessionIds];
|
|
1550
|
+
}
|
|
1551
|
+
async moveLegacyArchivedRecordsForDelete(records) {
|
|
1552
|
+
const legacyRecords = records.filter((record) => record.archivePath === undefined);
|
|
1553
|
+
if (legacyRecords.length === 0)
|
|
1554
|
+
return [];
|
|
1555
|
+
let sessionsByCwd;
|
|
1556
|
+
try {
|
|
1557
|
+
sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
|
|
1558
|
+
}
|
|
1559
|
+
catch (error) {
|
|
1560
|
+
return legacyRecords.map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
|
|
1561
|
+
}
|
|
1562
|
+
const moveInputs = legacyRecords
|
|
1563
|
+
.map((record) => findSessionByIdOrPrefix(sessionsByCwd.get(record.cwd) ?? [], record.sessionId))
|
|
1564
|
+
.filter(isDefined)
|
|
1565
|
+
.map(archiveInputFromListEntry);
|
|
1566
|
+
if (moveInputs.length === 0)
|
|
1567
|
+
return [];
|
|
1568
|
+
try {
|
|
1569
|
+
await this.archiveStoreArchiveMany(moveInputs);
|
|
1570
|
+
return [];
|
|
1571
|
+
}
|
|
1572
|
+
catch (error) {
|
|
1573
|
+
const failedIds = new Set(moveInputs.map((input) => input.sessionId));
|
|
1574
|
+
return legacyRecords
|
|
1575
|
+
.filter((record) => failedIds.has(record.sessionId))
|
|
1576
|
+
.map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
async cleanupPlan(request) {
|
|
1580
|
+
const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll?.() ?? [], this.archiveStore.list()]);
|
|
1581
|
+
return planSessionCleanup({
|
|
1582
|
+
sessions,
|
|
1583
|
+
archivedRecords,
|
|
1584
|
+
activeSessions: this.cleanupActiveSessionStatuses(),
|
|
1585
|
+
thresholds: request.thresholds,
|
|
1586
|
+
...(request.projectCwds === undefined ? {} : { projectCwds: request.projectCwds }),
|
|
1587
|
+
now: this.now(),
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
cleanupActiveSessionStatuses() {
|
|
1591
|
+
return [...new Set(this.active.values())].map((active) => ({
|
|
1592
|
+
sessionId: active.runtime.session.sessionId,
|
|
1593
|
+
hasActiveWork: this.hasActiveWork(active.runtime.session),
|
|
1594
|
+
}));
|
|
1595
|
+
}
|
|
1596
|
+
activeSessionHasWork(sessionId) {
|
|
1597
|
+
const active = this.active.get(sessionId);
|
|
1598
|
+
return active !== undefined && this.hasActiveWork(active.runtime.session);
|
|
1599
|
+
}
|
|
1600
|
+
reconcilableSessionIds(cwd, listedSessionIds, archivedById) {
|
|
1601
|
+
const sessionIds = new Set(listedSessionIds);
|
|
1602
|
+
for (const active of new Set(this.active.values())) {
|
|
1603
|
+
const session = active.runtime.session;
|
|
1604
|
+
if (session.sessionManager.getCwd() === cwd && !archivedById.has(session.sessionId))
|
|
1605
|
+
sessionIds.add(session.sessionId);
|
|
1606
|
+
}
|
|
1607
|
+
return [...sessionIds];
|
|
1608
|
+
}
|
|
1609
|
+
async ensureArchivedSessionMoved(record, session) {
|
|
1610
|
+
if (session === undefined || this.active.has(record.sessionId))
|
|
1611
|
+
return record;
|
|
1612
|
+
try {
|
|
1613
|
+
return await this.archiveStore.archive(archiveInputFromListEntry(session));
|
|
1614
|
+
}
|
|
1615
|
+
catch {
|
|
1616
|
+
return record;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
async ensureArchivedRecordMoved(record) {
|
|
1620
|
+
const session = (await this.sessionManager.list(record.cwd)).find((candidate) => candidate.id === record.sessionId);
|
|
1621
|
+
if (session === undefined)
|
|
1622
|
+
return record;
|
|
1623
|
+
const [moved] = await this.archiveStoreArchiveMany([archiveInputFromListEntry(session)]);
|
|
1624
|
+
return moved ?? record;
|
|
1625
|
+
}
|
|
1626
|
+
async ensureArchivedRecordsMoved(records) {
|
|
1627
|
+
const legacyRecords = records.filter((record) => record.archivePath === undefined);
|
|
1628
|
+
if (legacyRecords.length === 0)
|
|
1629
|
+
return;
|
|
1630
|
+
const sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
|
|
1631
|
+
const moveInputs = legacyRecords
|
|
1632
|
+
.map((record) => sessionsByCwd.get(record.cwd)?.find((candidate) => candidate.id === record.sessionId))
|
|
1633
|
+
.filter(isDefined)
|
|
1634
|
+
.map(archiveInputFromListEntry);
|
|
1635
|
+
await this.archiveStoreArchiveMany(moveInputs);
|
|
1636
|
+
}
|
|
1637
|
+
async archiveInputForSession(session) {
|
|
1638
|
+
const cwd = session.sessionManager.getCwd();
|
|
1639
|
+
const sessionFile = session.sessionFile;
|
|
1640
|
+
if (sessionFile === undefined || sessionFile === "")
|
|
1641
|
+
throw new Error("Session is not persisted");
|
|
1642
|
+
const listed = (await this.sessionManager.list(cwd)).find((candidate) => candidate.id === session.sessionId);
|
|
1643
|
+
if (listed !== undefined)
|
|
1644
|
+
return archiveInputFromListEntry(listed);
|
|
1645
|
+
return archiveInputFromActiveSession(session);
|
|
1646
|
+
}
|
|
1647
|
+
async workspaceArchiveCandidates(cwd) {
|
|
1648
|
+
const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
|
|
1649
|
+
const candidates = new Map();
|
|
1650
|
+
const archivedById = new Map();
|
|
1651
|
+
for (const record of archivedRecords) {
|
|
1652
|
+
if (record.cwd === cwd)
|
|
1653
|
+
archivedById.set(record.sessionId, record);
|
|
1654
|
+
}
|
|
1655
|
+
for (const session of sessions) {
|
|
1656
|
+
const archived = archivedById.get(session.id);
|
|
1657
|
+
if (archived === undefined)
|
|
1658
|
+
candidates.set(session.id, archiveCandidateFromListEntry(session));
|
|
1659
|
+
else {
|
|
1660
|
+
const candidate = archiveCandidateFromArchivedRecord(archived, session);
|
|
1661
|
+
if (candidate !== undefined)
|
|
1662
|
+
candidates.set(candidate.id, candidate);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
for (const record of archivedById.values()) {
|
|
1666
|
+
if (candidates.has(record.sessionId))
|
|
1667
|
+
continue;
|
|
1668
|
+
const candidate = archiveCandidateFromArchivedRecord(record, undefined);
|
|
1669
|
+
if (candidate !== undefined)
|
|
1670
|
+
candidates.set(candidate.id, candidate);
|
|
1671
|
+
}
|
|
1672
|
+
for (const active of new Set(this.active.values())) {
|
|
1673
|
+
const session = active.runtime.session;
|
|
1674
|
+
if (session.sessionManager.getCwd() !== cwd || archivedById.has(session.sessionId))
|
|
1675
|
+
continue;
|
|
1676
|
+
const existing = candidates.get(session.sessionId);
|
|
1677
|
+
candidates.set(session.sessionId, { ...(existing ?? archiveCandidateFromActiveSession(session, false)), activeSession: session });
|
|
1678
|
+
}
|
|
1679
|
+
return [...candidates.values()];
|
|
1680
|
+
}
|
|
1681
|
+
async listSessionNames(cwd) {
|
|
1682
|
+
const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
|
|
1683
|
+
const names = new Set();
|
|
1684
|
+
for (const session of sessions)
|
|
1685
|
+
addSessionName(names, session.name);
|
|
1686
|
+
for (const record of archivedRecords) {
|
|
1687
|
+
if (record.cwd === cwd)
|
|
1688
|
+
addSessionName(names, record.name);
|
|
1689
|
+
}
|
|
1690
|
+
for (const active of new Set(this.active.values())) {
|
|
1691
|
+
const session = active.runtime.session;
|
|
1692
|
+
if (session.sessionManager.getCwd() === cwd)
|
|
1693
|
+
addSessionName(names, session.sessionName);
|
|
1694
|
+
}
|
|
1695
|
+
return [...names];
|
|
1696
|
+
}
|
|
1697
|
+
async closeActive(sessionId, notificationPolicy = CLEAR_RUNTIME_NOTIFICATIONS) {
|
|
1698
|
+
const pendingOpens = this.pendingSessionOpenPromises(sessionId);
|
|
1699
|
+
if (pendingOpens.length > 0)
|
|
1700
|
+
await Promise.allSettled(pendingOpens);
|
|
1701
|
+
const active = this.active.get(sessionId);
|
|
1702
|
+
if (notificationPolicy.kind === "clear") {
|
|
1703
|
+
const generation = active === undefined ? undefined : this.notificationGenerationBySession.get(active.runtime.session);
|
|
1704
|
+
const mutations = generation === undefined
|
|
1705
|
+
? this.notificationStore.clearSession(sessionId, notificationPolicy.reason)
|
|
1706
|
+
: this.notificationStore.clearGeneration(generation, notificationPolicy.reason);
|
|
1707
|
+
this.publishNotificationMutations(mutations);
|
|
1708
|
+
}
|
|
1709
|
+
if (!active)
|
|
1710
|
+
return;
|
|
1711
|
+
this.forgetUnreadActivity(active.runtime.session);
|
|
1712
|
+
this.active.delete(sessionId);
|
|
1713
|
+
this.activities.delete(sessionId);
|
|
1714
|
+
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
|
|
1715
|
+
this.clearAuthLossWarningsForSession(sessionId);
|
|
1716
|
+
this.clearCompactionPromptQueue(sessionId);
|
|
1717
|
+
// Disarm subsession notification before teardown so the abort below cannot
|
|
1718
|
+
// emit a "stopped working" event that notifies the parent (e.g. on archive).
|
|
1719
|
+
// The parent/children link is kept so the parent can still see the child.
|
|
1720
|
+
if (this.subsessionLinkForActiveChild(active.runtime.session) !== undefined)
|
|
1721
|
+
this.subsessionNotifyArmed.delete(sessionId);
|
|
1722
|
+
clearSessionQueue(active.runtime.session);
|
|
1723
|
+
active.unsubscribe();
|
|
1724
|
+
active.runtime.setRebindSession(undefined);
|
|
1725
|
+
try {
|
|
1726
|
+
await this.abortSessionOperations(active.runtime.session);
|
|
1727
|
+
}
|
|
1728
|
+
finally {
|
|
1729
|
+
await active.runtime.dispose();
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
async abortSessionOperations(session) {
|
|
1733
|
+
let branchSummaryAbortFailed = false;
|
|
1734
|
+
let branchSummaryAbortError;
|
|
1735
|
+
try {
|
|
1736
|
+
session.abortBranchSummary?.();
|
|
1737
|
+
}
|
|
1738
|
+
catch (error) {
|
|
1739
|
+
branchSummaryAbortFailed = true;
|
|
1740
|
+
branchSummaryAbortError = error;
|
|
1741
|
+
}
|
|
1742
|
+
try {
|
|
1743
|
+
await session.abort();
|
|
1744
|
+
}
|
|
1745
|
+
catch (abortError) {
|
|
1746
|
+
if (branchSummaryAbortFailed) {
|
|
1747
|
+
throw new AggregateError([branchSummaryAbortError, abortError], "Failed to abort session operations", { cause: abortError });
|
|
1748
|
+
}
|
|
1749
|
+
throw abortError;
|
|
1750
|
+
}
|
|
1751
|
+
if (branchSummaryAbortFailed)
|
|
1752
|
+
throw branchSummaryAbortError;
|
|
1753
|
+
}
|
|
1754
|
+
async assertWritable(ref) {
|
|
1755
|
+
if (await this.getArchived(ref) !== undefined)
|
|
1756
|
+
throw new Error("Archived sessions are read-only. Restore the session to continue.");
|
|
1757
|
+
}
|
|
1758
|
+
async getOrOpen(ref) {
|
|
1759
|
+
return (await this.getActive(ref)).runtime.session;
|
|
1760
|
+
}
|
|
1761
|
+
async sessionFileForHistory(ref) {
|
|
1762
|
+
const active = this.activeForLookup(ref);
|
|
1763
|
+
if (active !== undefined) {
|
|
1764
|
+
const sessionFile = active.runtime.session.sessionFile;
|
|
1765
|
+
if (sessionFile === undefined || sessionFile === "")
|
|
1766
|
+
throw new Error("Session is not persisted");
|
|
1767
|
+
return sessionFile;
|
|
1768
|
+
}
|
|
1769
|
+
const archived = await this.getArchived(ref);
|
|
1770
|
+
if (archived?.archivePath !== undefined && archived.archivePath !== "")
|
|
1771
|
+
return archived.archivePath;
|
|
1772
|
+
const match = isPiSessionRef(ref)
|
|
1773
|
+
? (await this.sessionManager.list(ref.cwd)).find((session) => session.id === ref.id || session.id.startsWith(ref.id))
|
|
1774
|
+
: (await this.sessionManager.listAll?.() ?? []).find((session) => session.id === ref || session.id.startsWith(ref));
|
|
1775
|
+
if (match === undefined || match.path === "")
|
|
1776
|
+
throw new Error("Session not found");
|
|
1777
|
+
return match.path;
|
|
1778
|
+
}
|
|
1779
|
+
async getActive(ref, options = {}) {
|
|
1780
|
+
const active = this.activeForLookup(ref);
|
|
1781
|
+
if (active !== undefined)
|
|
1782
|
+
return active;
|
|
1783
|
+
const archived = await this.getArchived(ref);
|
|
1784
|
+
if (archived?.archivePath !== undefined) {
|
|
1785
|
+
const { archivePath } = archived;
|
|
1786
|
+
return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath), { notifications: "disabled" });
|
|
1787
|
+
}
|
|
1788
|
+
const match = isPiSessionRef(ref)
|
|
1789
|
+
? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
|
|
1790
|
+
: (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
|
|
1791
|
+
if (!match)
|
|
1792
|
+
throw new Error("Session not found");
|
|
1793
|
+
return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
|
|
1794
|
+
}
|
|
1795
|
+
openExistingSession(sessionId, cwd, openSessionManager, options = {}) {
|
|
1796
|
+
const active = this.activeForLookup({ id: sessionId, cwd });
|
|
1797
|
+
if (active !== undefined)
|
|
1798
|
+
return Promise.resolve(active);
|
|
1799
|
+
const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionId]);
|
|
1800
|
+
const existing = this.pendingSessionOpens.get(key);
|
|
1801
|
+
if (existing !== undefined)
|
|
1802
|
+
return existing.promise;
|
|
1803
|
+
const pending = {
|
|
1804
|
+
sessionId,
|
|
1805
|
+
promise: this.create(openSessionManager(), cwd, options),
|
|
1806
|
+
};
|
|
1807
|
+
pending.promise = pending.promise.finally(() => {
|
|
1808
|
+
if (this.pendingSessionOpens.get(key) === pending)
|
|
1809
|
+
this.pendingSessionOpens.delete(key);
|
|
1810
|
+
});
|
|
1811
|
+
this.pendingSessionOpens.set(key, pending);
|
|
1812
|
+
return pending.promise;
|
|
1813
|
+
}
|
|
1814
|
+
pendingSessionOpenPromises(sessionId) {
|
|
1815
|
+
return [...this.pendingSessionOpens.values()]
|
|
1816
|
+
.filter((pending) => sessionId === undefined || pending.sessionId === sessionId)
|
|
1817
|
+
.map((pending) => pending.promise);
|
|
1818
|
+
}
|
|
1819
|
+
async getArchived(ref) {
|
|
1820
|
+
const archived = await this.archiveStore.get(sessionIdFromLookup(ref));
|
|
1821
|
+
if (archived === undefined)
|
|
1822
|
+
return undefined;
|
|
1823
|
+
if (isPiSessionRef(ref) && archived.cwd !== ref.cwd)
|
|
1824
|
+
return undefined;
|
|
1825
|
+
return archived;
|
|
1826
|
+
}
|
|
1827
|
+
isCurrentActiveSession(session) {
|
|
1828
|
+
return this.active.get(session.sessionId)?.runtime.session === session;
|
|
1829
|
+
}
|
|
1830
|
+
activeForLookup(ref) {
|
|
1831
|
+
const sessionId = sessionIdFromLookup(ref);
|
|
1832
|
+
const exact = this.active.get(sessionId);
|
|
1833
|
+
if (exact !== undefined && lookupMatchesActiveSession(ref, exact))
|
|
1834
|
+
return exact;
|
|
1835
|
+
for (const [candidateId, active] of this.active.entries()) {
|
|
1836
|
+
if (candidateId.startsWith(sessionId) && lookupMatchesActiveSession(ref, active))
|
|
1837
|
+
return active;
|
|
1838
|
+
}
|
|
1839
|
+
return undefined;
|
|
1840
|
+
}
|
|
1841
|
+
async create(sessionManager, cwd, options = {}) {
|
|
1842
|
+
const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
|
|
1843
|
+
&& await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
|
|
1844
|
+
const runtime = await this.createAgentRuntime(this.createRuntime, {
|
|
1845
|
+
cwd,
|
|
1846
|
+
agentDir: this.agentDir,
|
|
1847
|
+
sessionManager,
|
|
1848
|
+
delegationToolsEnabled,
|
|
1849
|
+
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
|
1850
|
+
});
|
|
1851
|
+
const active = { runtime, unsubscribe: noop };
|
|
1852
|
+
let boundSession = runtime.session;
|
|
1853
|
+
let notificationGeneration = options.notificationGeneration;
|
|
1854
|
+
let notificationOwnership = options.notifications === "disabled"
|
|
1855
|
+
? "disabled"
|
|
1856
|
+
: notificationGeneration === undefined
|
|
1857
|
+
? "registered"
|
|
1858
|
+
: "external";
|
|
1859
|
+
if (notificationOwnership === "registered") {
|
|
1860
|
+
const notificationIdentity = notificationIdentityForSession(runtime.session);
|
|
1861
|
+
const existingCandidate = this.notificationStore.beginReplacementForSession(notificationIdentity.sessionId, notificationIdentity.cwd);
|
|
1862
|
+
if (existingCandidate !== undefined) {
|
|
1863
|
+
notificationGeneration = existingCandidate;
|
|
1864
|
+
notificationOwnership = "replacement";
|
|
1865
|
+
}
|
|
1866
|
+
else {
|
|
1867
|
+
const registration = this.notificationStore.registerSession(notificationIdentity.sessionId, notificationIdentity.cwd);
|
|
1868
|
+
notificationGeneration = registration.generation;
|
|
1869
|
+
this.publishNotificationMutations(registration.mutations);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
if (notificationGeneration !== undefined)
|
|
1873
|
+
this.notificationGenerationBySession.set(runtime.session, notificationGeneration);
|
|
1874
|
+
try {
|
|
1875
|
+
if (options.creationProvenance === "tracked-subsession") {
|
|
1876
|
+
await this.publishUnreadMutations(this.unreadStore.excludeSession(runtime.session.sessionId, canonicalizeStoredCwd(runtime.session.sessionManager.getCwd())));
|
|
1877
|
+
}
|
|
1878
|
+
else {
|
|
1879
|
+
await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
|
|
1880
|
+
}
|
|
1881
|
+
await this.bindSessionExtensions(runtime.session, notificationGeneration);
|
|
1882
|
+
this.bindRuntime(active);
|
|
1883
|
+
runtime.setRebindSession(async (session) => {
|
|
1884
|
+
const priorGeneration = notificationGeneration;
|
|
1885
|
+
let candidateGeneration;
|
|
1886
|
+
try {
|
|
1887
|
+
await this.prepareUnreadRuntimeRebind(boundSession, session);
|
|
1888
|
+
await this.recoverSubsessionTrackingForOpenedSession(session);
|
|
1889
|
+
if (priorGeneration !== undefined) {
|
|
1890
|
+
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
|
|
1891
|
+
this.notificationGenerationBySession.set(session, candidateGeneration);
|
|
1892
|
+
}
|
|
1893
|
+
this.bindRuntime(active, session);
|
|
1894
|
+
boundSession = session;
|
|
1895
|
+
await this.bindSessionExtensions(session, candidateGeneration);
|
|
1896
|
+
if (candidateGeneration !== undefined) {
|
|
1897
|
+
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
|
|
1898
|
+
notificationGeneration = candidateGeneration;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
catch (error) {
|
|
1902
|
+
if (candidateGeneration !== undefined) {
|
|
1903
|
+
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
|
|
1904
|
+
notificationGeneration = candidateGeneration;
|
|
1905
|
+
this.notificationGenerationBySession.set(session, candidateGeneration);
|
|
1906
|
+
}
|
|
1907
|
+
throw error;
|
|
1908
|
+
}
|
|
1909
|
+
});
|
|
1910
|
+
this.active.set(runtime.session.sessionId, active);
|
|
1911
|
+
if (notificationOwnership === "replacement" && notificationGeneration !== undefined) {
|
|
1912
|
+
this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
|
|
1913
|
+
notificationOwnership = "external";
|
|
1914
|
+
}
|
|
1915
|
+
this.publishStatus(runtime.session);
|
|
1916
|
+
return active;
|
|
1917
|
+
}
|
|
1918
|
+
catch (error) {
|
|
1919
|
+
if (notificationGeneration !== undefined) {
|
|
1920
|
+
if (notificationOwnership === "registered") {
|
|
1921
|
+
this.publishNotificationMutations(this.notificationStore.clearSession(runtime.session.sessionId, "initialization-failed"));
|
|
1922
|
+
}
|
|
1923
|
+
else if (notificationOwnership === "replacement") {
|
|
1924
|
+
this.publishNotificationMutations(this.notificationStore.abortReplacement(notificationGeneration));
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
active.unsubscribe();
|
|
1928
|
+
this.forgetUnreadActivity(boundSession);
|
|
1929
|
+
let removedActive = false;
|
|
1930
|
+
for (const [sessionId, candidate] of this.active.entries()) {
|
|
1931
|
+
if (candidate !== active)
|
|
1932
|
+
continue;
|
|
1933
|
+
this.active.delete(sessionId);
|
|
1934
|
+
this.activities.delete(sessionId);
|
|
1935
|
+
this.clearAuthLossWarningsForSession(sessionId);
|
|
1936
|
+
this.clearCompactionPromptQueue(sessionId);
|
|
1937
|
+
removedActive = true;
|
|
1938
|
+
}
|
|
1939
|
+
if (removedActive) {
|
|
1940
|
+
this.workspaceActivity?.removeSession(runtime.session.sessionId, runtime.session.sessionManager.getCwd());
|
|
1941
|
+
}
|
|
1942
|
+
try {
|
|
1943
|
+
await runtime.session.abort();
|
|
1944
|
+
}
|
|
1945
|
+
finally {
|
|
1946
|
+
await runtime.dispose();
|
|
1947
|
+
}
|
|
1948
|
+
throw error;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
async bindSessionExtensions(session, generation) {
|
|
1952
|
+
const uiContext = this.sessionUiContext(session, generation);
|
|
1953
|
+
await session.bindExtensions({
|
|
1954
|
+
uiContext,
|
|
1955
|
+
mode: "rpc",
|
|
1956
|
+
onError: (error) => {
|
|
1957
|
+
const message = `${error.extensionPath}: ${error.error}`;
|
|
1958
|
+
this.publishActivity(session, "extension error", "error", message);
|
|
1959
|
+
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
1960
|
+
},
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
replaceSessionNotificationContext(session, generation) {
|
|
1964
|
+
session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
|
|
1965
|
+
}
|
|
1966
|
+
sessionUiContext(session, generation) {
|
|
1967
|
+
const baseUiContext = session.extensionRunner.getUIContext();
|
|
1968
|
+
const notify = (message, type) => {
|
|
1969
|
+
if (generation === undefined) {
|
|
1970
|
+
this.events.publish(session.sessionId, {
|
|
1971
|
+
type: "command.output",
|
|
1972
|
+
level: type === "error" ? "error" : "info",
|
|
1973
|
+
message,
|
|
1974
|
+
});
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
const added = this.notificationStore.addNotification(generation, message, type);
|
|
1978
|
+
this.publishNotificationMutations(added.mutations);
|
|
1979
|
+
if (added.notification === undefined)
|
|
1980
|
+
return;
|
|
1981
|
+
this.events.publish(session.sessionId, {
|
|
1982
|
+
type: "command.output",
|
|
1983
|
+
level: type === "error" ? "error" : "info",
|
|
1984
|
+
message,
|
|
1985
|
+
notificationId: added.notification.id,
|
|
1986
|
+
});
|
|
1987
|
+
};
|
|
1988
|
+
// PI WEBUI owns the browser-facing notification and text-formatting
|
|
1989
|
+
// boundaries. Delegate every other UI method to Pi's headless defaults so
|
|
1990
|
+
// unsupported dialogs cancel safely instead of hanging.
|
|
1991
|
+
return new Proxy(baseUiContext, {
|
|
1992
|
+
get(target, property, receiver) {
|
|
1993
|
+
if (property === "notify")
|
|
1994
|
+
return notify;
|
|
1995
|
+
if (property === "theme")
|
|
1996
|
+
return plainTextTheme;
|
|
1997
|
+
const value = Reflect.get(target, property, receiver);
|
|
1998
|
+
return value;
|
|
1999
|
+
},
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
publishNotificationMutations(mutations) {
|
|
2003
|
+
for (const mutation of mutations) {
|
|
2004
|
+
this.events.publish(mutation.sessionId, mutation.inboxEvent);
|
|
2005
|
+
this.events.publishNotificationSummary(mutation.summaryEvent);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
async prepareUnreadRuntimeRebind(previous, next) {
|
|
2009
|
+
const previousCwd = canonicalizeStoredCwd(previous.sessionManager.getCwd());
|
|
2010
|
+
this.unreadStore.forgetActivity(previous.sessionId, previousCwd);
|
|
2011
|
+
const nextCwd = canonicalizeStoredCwd(next.sessionManager.getCwd());
|
|
2012
|
+
if (previous.sessionId === next.sessionId && cwdPathsEqual(previousCwd, nextCwd))
|
|
2013
|
+
return;
|
|
2014
|
+
await this.publishUnreadMutations(this.unreadStore.forgetSession(previous.sessionId, previousCwd));
|
|
2015
|
+
}
|
|
2016
|
+
forgetUnreadActivity(session) {
|
|
2017
|
+
this.unreadStore.forgetActivity(session.sessionId, canonicalizeStoredCwd(session.sessionManager.getCwd()));
|
|
2018
|
+
}
|
|
2019
|
+
async forgetUnreadSessions(identities) {
|
|
2020
|
+
const mutations = [];
|
|
2021
|
+
for (const identity of identities) {
|
|
2022
|
+
mutations.push(...this.unreadStore.forgetSession(identity.sessionId, canonicalizeStoredCwd(identity.cwd)));
|
|
2023
|
+
}
|
|
2024
|
+
await this.publishUnreadMutations(mutations);
|
|
2025
|
+
}
|
|
2026
|
+
observeUnreadActivityState(session) {
|
|
2027
|
+
const mutations = this.unreadStore.observeActivityState(session.sessionId, canonicalizeStoredCwd(session.sessionManager.getCwd()), this.hasActiveWork(session));
|
|
2028
|
+
if (mutations.length === 0)
|
|
2029
|
+
return;
|
|
2030
|
+
void this.publishUnreadMutations(mutations).catch(() => undefined);
|
|
2031
|
+
}
|
|
2032
|
+
publishUnreadMutations(mutations) {
|
|
2033
|
+
this.enqueueUnreadMutations(mutations);
|
|
2034
|
+
this.unreadPublicationFlushRequested = true;
|
|
2035
|
+
if (this.unreadPublication === undefined && this.unreadPublicationRetryTimer !== undefined) {
|
|
2036
|
+
const failure = this.unreadPublicationFailure;
|
|
2037
|
+
return Promise.reject(failure instanceof Error
|
|
2038
|
+
? failure
|
|
2039
|
+
: new Error("Session unread publication is awaiting retry", { cause: failure }));
|
|
2040
|
+
}
|
|
2041
|
+
return this.ensureUnreadPublication();
|
|
2042
|
+
}
|
|
2043
|
+
ensureUnreadPublication() {
|
|
2044
|
+
const existing = this.unreadPublication;
|
|
2045
|
+
if (existing !== undefined)
|
|
2046
|
+
return existing;
|
|
2047
|
+
const publication = this.drainUnreadPublication();
|
|
2048
|
+
this.unreadPublication = publication;
|
|
2049
|
+
void publication.then(() => {
|
|
2050
|
+
if (this.unreadPublication === publication)
|
|
2051
|
+
this.unreadPublication = undefined;
|
|
2052
|
+
}, (error) => {
|
|
2053
|
+
if (this.unreadPublication === publication)
|
|
2054
|
+
this.unreadPublication = undefined;
|
|
2055
|
+
this.unreadPublicationFailure = error;
|
|
2056
|
+
this.logger.info({ error: error instanceof Error ? error.message : String(error) }, "failed to publish durable session unread mutations");
|
|
2057
|
+
this.scheduleUnreadPublicationRetry();
|
|
2058
|
+
});
|
|
2059
|
+
return publication;
|
|
2060
|
+
}
|
|
2061
|
+
async drainUnreadPublication() {
|
|
2062
|
+
while (this.unreadPublicationFlushRequested || this.pendingUnreadMutations.length > 0) {
|
|
2063
|
+
this.unreadPublicationFlushRequested = false;
|
|
2064
|
+
const batch = this.pendingUnreadMutations.splice(0);
|
|
2065
|
+
let publishedCount = 0;
|
|
2066
|
+
try {
|
|
2067
|
+
await this.unreadStore.flush();
|
|
2068
|
+
for (const mutation of batch) {
|
|
2069
|
+
this.events.publishGlobal(mutation.event);
|
|
2070
|
+
publishedCount += 1;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
catch (error) {
|
|
2074
|
+
this.prependUnreadMutations(batch.slice(publishedCount));
|
|
2075
|
+
this.unreadPublicationFlushRequested = true;
|
|
2076
|
+
throw error;
|
|
2077
|
+
}
|
|
2078
|
+
this.unreadPublicationFailure = undefined;
|
|
2079
|
+
this.clearUnreadPublicationRetry();
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
enqueueUnreadMutations(mutations) {
|
|
2083
|
+
this.pendingUnreadMutations.push(...mutations);
|
|
2084
|
+
this.trimPendingUnreadMutations();
|
|
2085
|
+
}
|
|
2086
|
+
prependUnreadMutations(mutations) {
|
|
2087
|
+
this.pendingUnreadMutations.unshift(...mutations);
|
|
2088
|
+
this.trimPendingUnreadMutations();
|
|
2089
|
+
}
|
|
2090
|
+
trimPendingUnreadMutations() {
|
|
2091
|
+
const excess = this.pendingUnreadMutations.length - MAX_PENDING_UNREAD_MUTATIONS;
|
|
2092
|
+
if (excess > 0)
|
|
2093
|
+
this.pendingUnreadMutations.splice(0, excess);
|
|
2094
|
+
}
|
|
2095
|
+
scheduleUnreadPublicationRetry() {
|
|
2096
|
+
if (this.unreadPublicationStopped || this.unreadPublicationRetryTimer !== undefined)
|
|
2097
|
+
return;
|
|
2098
|
+
const delay = this.unreadPublicationRetryDelayMs;
|
|
2099
|
+
this.unreadPublicationRetryDelayMs = Math.min(Math.max(delay * 2, this.unreadPublicationRetryInitialMs), Math.max(MAX_UNREAD_PUBLICATION_RETRY_MS, this.unreadPublicationRetryInitialMs));
|
|
2100
|
+
this.unreadPublicationRetryTimer = setTimeout(() => {
|
|
2101
|
+
this.unreadPublicationRetryTimer = undefined;
|
|
2102
|
+
void this.ensureUnreadPublication().catch(() => undefined);
|
|
2103
|
+
}, delay);
|
|
2104
|
+
this.unreadPublicationRetryTimer.unref();
|
|
2105
|
+
}
|
|
2106
|
+
clearUnreadPublicationRetry() {
|
|
2107
|
+
if (this.unreadPublicationRetryTimer !== undefined)
|
|
2108
|
+
clearTimeout(this.unreadPublicationRetryTimer);
|
|
2109
|
+
this.unreadPublicationRetryTimer = undefined;
|
|
2110
|
+
this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
|
|
2111
|
+
}
|
|
2112
|
+
bindRuntime(active, session = active.runtime.session) {
|
|
2113
|
+
active.unsubscribe();
|
|
2114
|
+
for (const [sessionId, candidate] of this.active.entries()) {
|
|
2115
|
+
if (candidate === active) {
|
|
2116
|
+
this.active.delete(sessionId);
|
|
2117
|
+
if (sessionId !== session.sessionId)
|
|
2118
|
+
this.clearCompactionPromptQueue(sessionId);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
active.unsubscribe = session.subscribe((event) => {
|
|
2122
|
+
this.recordGenerationMetrics(session, event);
|
|
2123
|
+
this.events.publish(session.sessionId, toClientEvent(event));
|
|
2124
|
+
this.publishActivityForEvent(session, event);
|
|
2125
|
+
const eventType = getString(event, "type");
|
|
2126
|
+
if (eventType === "compaction_end")
|
|
2127
|
+
this.scheduleCompactionQueueDrain(session.sessionId);
|
|
2128
|
+
if (eventType === "agent_start" || eventType === "agent_end")
|
|
2129
|
+
this.scheduleCompactionQueueDrain(session.sessionId);
|
|
2130
|
+
this.publishStatus(session);
|
|
2131
|
+
this.updateSubsessionTracking(session);
|
|
2132
|
+
});
|
|
2133
|
+
this.active.set(session.sessionId, active);
|
|
2134
|
+
}
|
|
2135
|
+
/**
|
|
2136
|
+
* Track a response only while its assistant message is live. Positive
|
|
2137
|
+
* provider usage takes precedence; otherwise use Pi Web's streamed-content
|
|
2138
|
+
* estimate so providers that report usage only at completion still have live
|
|
2139
|
+
* dock metrics. Clear the metric when the response or agent ends.
|
|
2140
|
+
*/
|
|
2141
|
+
recordGenerationMetrics(session, event) {
|
|
2142
|
+
const eventType = getString(event, "type");
|
|
2143
|
+
if (eventType === "agent_start" || eventType === "agent_end") {
|
|
2144
|
+
this.generationMetrics.delete(session);
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
const message = getProperty(event, "message");
|
|
2148
|
+
if (getString(message, "role") !== "assistant")
|
|
2149
|
+
return;
|
|
2150
|
+
if (eventType === "message_start") {
|
|
2151
|
+
this.generationMetrics.delete(session);
|
|
2152
|
+
this.recordGenerationProgress(session, message);
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
if (eventType === "message_end") {
|
|
2156
|
+
this.generationMetrics.delete(session);
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
if (eventType === "message_update")
|
|
2160
|
+
this.recordGenerationProgress(session, message);
|
|
2161
|
+
}
|
|
2162
|
+
recordGenerationProgress(session, message) {
|
|
2163
|
+
const current = this.generationMetrics.get(session);
|
|
2164
|
+
const providerTokens = providerOutputTokens(message);
|
|
2165
|
+
const estimatedTokens = providerTokens === undefined && current?.estimated !== false ? estimatedOutputTokens(message) : undefined;
|
|
2166
|
+
const outputTokens = providerTokens ?? (current?.estimated === false ? current.outputTokens : estimatedTokens);
|
|
2167
|
+
if (outputTokens === undefined)
|
|
2168
|
+
return;
|
|
2169
|
+
this.generationMetrics.set(session, {
|
|
2170
|
+
startedAt: current?.startedAt ?? this.now().getTime(),
|
|
2171
|
+
outputTokens,
|
|
2172
|
+
estimated: providerTokens === undefined && current?.estimated !== false,
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
generationForStatus(session) {
|
|
2176
|
+
const metrics = this.generationMetrics.get(session);
|
|
2177
|
+
if (metrics === undefined)
|
|
2178
|
+
return undefined;
|
|
2179
|
+
const elapsedMs = this.now().getTime() - metrics.startedAt;
|
|
2180
|
+
const tokensPerSecond = elapsedMs >= MIN_GENERATION_RATE_ELAPSED_MS ? metrics.outputTokens * 1_000 / elapsedMs : undefined;
|
|
2181
|
+
return {
|
|
2182
|
+
outputTokens: metrics.outputTokens,
|
|
2183
|
+
...(tokensPerSecond === undefined || !Number.isFinite(tokensPerSecond) ? {} : { tokensPerSecond }),
|
|
2184
|
+
...(metrics.estimated ? { estimated: true } : {}),
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
scheduleCompactionQueueDrain(sessionId, delayMs = 0) {
|
|
2188
|
+
if (!this.compactionPromptQueues.has(sessionId) || this.compactionDrainTimers.has(sessionId))
|
|
2189
|
+
return;
|
|
2190
|
+
const timer = setTimeout(() => {
|
|
2191
|
+
this.compactionDrainTimers.delete(sessionId);
|
|
2192
|
+
this.drainCompactionPromptQueue(sessionId);
|
|
2193
|
+
}, delayMs);
|
|
2194
|
+
this.compactionDrainTimers.set(sessionId, timer);
|
|
2195
|
+
}
|
|
2196
|
+
drainCompactionPromptQueue(sessionId) {
|
|
2197
|
+
const active = this.active.get(sessionId);
|
|
2198
|
+
if (active === undefined)
|
|
2199
|
+
return;
|
|
2200
|
+
const { session } = active.runtime;
|
|
2201
|
+
if (session.isCompacting) {
|
|
2202
|
+
this.scheduleCompactionQueueDrain(sessionId, 100);
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
if (session.isStreaming) {
|
|
2206
|
+
const queued = this.takeCompactionPromptQueue(sessionId);
|
|
2207
|
+
if (queued.length === 0)
|
|
2208
|
+
return;
|
|
2209
|
+
this.publishStatus(session);
|
|
2210
|
+
for (const prompt of queued)
|
|
2211
|
+
void this.submitPrompt(session, prompt.text, prompt.kind, prompt.images, prompt.echoUserMessage ?? true);
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
const prompt = this.shiftCompactionPrompt(sessionId);
|
|
2215
|
+
if (prompt === undefined)
|
|
2216
|
+
return;
|
|
2217
|
+
this.publishStatus(session);
|
|
2218
|
+
const submitted = this.submitPrompt(session, prompt.text, undefined, prompt.images, prompt.echoUserMessage ?? true);
|
|
2219
|
+
void submitted.finally(() => { this.scheduleCompactionQueueDrain(sessionId); });
|
|
2220
|
+
}
|
|
2221
|
+
takeCompactionPromptQueue(sessionId) {
|
|
2222
|
+
const queued = this.compactionPromptQueues.get(sessionId) ?? [];
|
|
2223
|
+
this.compactionPromptQueues.delete(sessionId);
|
|
2224
|
+
return queued;
|
|
2225
|
+
}
|
|
2226
|
+
shiftCompactionPrompt(sessionId) {
|
|
2227
|
+
const queue = this.compactionPromptQueues.get(sessionId);
|
|
2228
|
+
const prompt = queue?.shift();
|
|
2229
|
+
if (queue === undefined || queue.length === 0)
|
|
2230
|
+
this.compactionPromptQueues.delete(sessionId);
|
|
2231
|
+
return prompt;
|
|
2232
|
+
}
|
|
2233
|
+
clearCompactionPromptQueue(sessionId) {
|
|
2234
|
+
this.compactionPromptQueues.delete(sessionId);
|
|
2235
|
+
const timer = this.compactionDrainTimers.get(sessionId);
|
|
2236
|
+
if (timer !== undefined) {
|
|
2237
|
+
clearTimeout(timer);
|
|
2238
|
+
this.compactionDrainTimers.delete(sessionId);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
clearCompactionDrainTimers() {
|
|
2242
|
+
for (const timer of this.compactionDrainTimers.values())
|
|
2243
|
+
clearTimeout(timer);
|
|
2244
|
+
this.compactionDrainTimers.clear();
|
|
2245
|
+
}
|
|
2246
|
+
maybeGenerateSessionName(session, firstMessage) {
|
|
2247
|
+
if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting)
|
|
2248
|
+
return;
|
|
2249
|
+
const deterministicName = deterministicSessionName(firstMessage);
|
|
2250
|
+
if (deterministicName !== undefined) {
|
|
2251
|
+
this.applyGeneratedSessionName(session, deterministicName);
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
const model = session.model;
|
|
2255
|
+
if (model === undefined)
|
|
2256
|
+
return;
|
|
2257
|
+
void generateShortSessionName(session.agent.streamFn, model, firstMessage).then((name) => {
|
|
2258
|
+
this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
|
|
2259
|
+
}).catch(() => {
|
|
2260
|
+
this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
applyGeneratedSessionName(session, name) {
|
|
2264
|
+
if (name === undefined || session.sessionName !== undefined)
|
|
2265
|
+
return;
|
|
2266
|
+
if (this.treeNavigations.has(session)) {
|
|
2267
|
+
this.deferredGeneratedSessionNames.set(session, name);
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
session.setSessionName(name);
|
|
2271
|
+
this.publishSessionName(session);
|
|
2272
|
+
}
|
|
2273
|
+
flushDeferredTreeNavigationWork(session) {
|
|
2274
|
+
const generatedName = this.deferredGeneratedSessionNames.get(session);
|
|
2275
|
+
this.deferredGeneratedSessionNames.delete(session);
|
|
2276
|
+
if (generatedName !== undefined) {
|
|
2277
|
+
try {
|
|
2278
|
+
this.applyGeneratedSessionName(session, generatedName);
|
|
2279
|
+
}
|
|
2280
|
+
catch (error) {
|
|
2281
|
+
this.logger.info({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) }, "failed to apply deferred session name");
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
const notifications = this.deferredSubsessionNotifications.get(session) ?? [];
|
|
2285
|
+
this.deferredSubsessionNotifications.delete(session);
|
|
2286
|
+
for (const notification of notifications) {
|
|
2287
|
+
void this.deliverSubsessionNotification(session, notification).catch((error) => {
|
|
2288
|
+
this.logSubsessionNotificationFailure(notification.parentId, notification.childId, error);
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
applyAuthChange(change = {}) {
|
|
2293
|
+
// ModelRuntime.login()/logout() refresh the shared runtime before AuthService
|
|
2294
|
+
// emits the change, so no refresh is needed here. Keeping this synchronous
|
|
2295
|
+
// also lets every active session observe the same committed auth snapshot.
|
|
2296
|
+
for (const active of this.active.values()) {
|
|
2297
|
+
const { session } = active.runtime;
|
|
2298
|
+
this.syncCurrentModelAuthWarning(session, change.removedProviderId);
|
|
2299
|
+
this.publishStatus(session);
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
syncCurrentModelAuthWarning(session, removedProviderId) {
|
|
2303
|
+
const model = session.model;
|
|
2304
|
+
if (model === undefined)
|
|
2305
|
+
return;
|
|
2306
|
+
if (model.provider === "unknown" && model.id === "unknown")
|
|
2307
|
+
return;
|
|
2308
|
+
const warningKey = authLossWarningKey(session.sessionId, model.provider, model.id);
|
|
2309
|
+
const registered = session.modelRuntime.getModel(model.provider, model.id);
|
|
2310
|
+
if (registered === undefined)
|
|
2311
|
+
return;
|
|
2312
|
+
if (session.modelRuntime.hasConfiguredAuth(model.provider)) {
|
|
2313
|
+
this.authLossWarnings.delete(warningKey);
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
if (removedProviderId === undefined || model.provider !== removedProviderId || this.authLossWarnings.has(warningKey))
|
|
2317
|
+
return;
|
|
2318
|
+
this.authLossWarnings.add(warningKey);
|
|
2319
|
+
this.events.publish(session.sessionId, {
|
|
2320
|
+
type: "command.output",
|
|
2321
|
+
level: "error",
|
|
2322
|
+
message: `Authentication for ${model.provider}/${model.id} was removed. Use /model to select another model.`,
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
clearAuthLossWarningsForSession(sessionId) {
|
|
2326
|
+
const prefix = `${sessionId}:`;
|
|
2327
|
+
for (const key of this.authLossWarnings) {
|
|
2328
|
+
if (key.startsWith(prefix))
|
|
2329
|
+
this.authLossWarnings.delete(key);
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
publishSessionName(session) {
|
|
2333
|
+
const event = session.sessionName === undefined
|
|
2334
|
+
? { type: "session.name", sessionId: session.sessionId }
|
|
2335
|
+
: { type: "session.name", sessionId: session.sessionId, name: session.sessionName };
|
|
2336
|
+
this.events.publish(session.sessionId, event);
|
|
2337
|
+
this.events.publishGlobal(event);
|
|
2338
|
+
}
|
|
2339
|
+
publishHeartbeats() {
|
|
2340
|
+
for (const active of this.active.values()) {
|
|
2341
|
+
const { session } = active.runtime;
|
|
2342
|
+
// Re-evaluate subsession completion here too: agent_end can arrive while
|
|
2343
|
+
// the session still reports active work transiently, so the event-driven
|
|
2344
|
+
// latch may not fire. The heartbeat re-checks once the session settles.
|
|
2345
|
+
this.updateSubsessionTracking(session);
|
|
2346
|
+
const activity = this.activities.get(session.sessionId);
|
|
2347
|
+
if (!this.hasActiveWork(session)) {
|
|
2348
|
+
if (activity?.phase === "active")
|
|
2349
|
+
this.publishStatus(session);
|
|
2350
|
+
continue;
|
|
2351
|
+
}
|
|
2352
|
+
this.publishStatus(session);
|
|
2353
|
+
if (activity?.phase === "active")
|
|
2354
|
+
this.publishActivity(session, activity.label, "active", activity.detail);
|
|
2355
|
+
else
|
|
2356
|
+
this.publishActivity(session, this.activityLabelFromStatus(session), "active");
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
activityLabelFromStatus(session) {
|
|
2360
|
+
if (this.treeNavigations.has(session))
|
|
2361
|
+
return "navigating session tree";
|
|
2362
|
+
if (this.isSessionEntryMutationActive(session))
|
|
2363
|
+
return "updating session";
|
|
2364
|
+
if (session.isCompacting)
|
|
2365
|
+
return "compacting";
|
|
2366
|
+
if (session.isBashRunning)
|
|
2367
|
+
return "running bash";
|
|
2368
|
+
if (session.isStreaming)
|
|
2369
|
+
return "agent running";
|
|
2370
|
+
if (this.pendingMessageCount(session) > 0)
|
|
2371
|
+
return "queued";
|
|
2372
|
+
return "active";
|
|
2373
|
+
}
|
|
2374
|
+
hasActiveWork(session) {
|
|
2375
|
+
return this.treeNavigations.has(session)
|
|
2376
|
+
|| this.isSessionEntryMutationActive(session)
|
|
2377
|
+
|| this.isTreeExclusiveOperationActive(session)
|
|
2378
|
+
|| sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
|
|
2379
|
+
}
|
|
2380
|
+
async runTreeExclusiveOperation(targets, activeError, operation) {
|
|
2381
|
+
const sessionIds = new Set();
|
|
2382
|
+
const runtimes = new Set();
|
|
2383
|
+
const sessions = new Set();
|
|
2384
|
+
for (const target of targets) {
|
|
2385
|
+
const runtime = target.runtime ?? (target.session === undefined ? undefined : this.activeRuntimeForSession(target.session));
|
|
2386
|
+
const session = target.session ?? runtime?.session;
|
|
2387
|
+
if (session !== undefined && this.hasActiveWork(session))
|
|
2388
|
+
throw new Error(activeError);
|
|
2389
|
+
sessionIds.add(target.sessionId);
|
|
2390
|
+
if (runtime !== undefined)
|
|
2391
|
+
runtimes.add(runtime);
|
|
2392
|
+
if (session !== undefined)
|
|
2393
|
+
sessions.add(session);
|
|
2394
|
+
}
|
|
2395
|
+
for (const sessionId of sessionIds) {
|
|
2396
|
+
this.treeExclusiveSessionOperationCounts.set(sessionId, (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) + 1);
|
|
2397
|
+
}
|
|
2398
|
+
for (const runtime of runtimes) {
|
|
2399
|
+
this.treeExclusiveRuntimeOperationCounts.set(runtime, (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) + 1);
|
|
2400
|
+
}
|
|
2401
|
+
for (const session of sessions)
|
|
2402
|
+
this.observeUnreadActivityState(session);
|
|
2403
|
+
try {
|
|
2404
|
+
return await operation();
|
|
2405
|
+
}
|
|
2406
|
+
finally {
|
|
2407
|
+
for (const runtime of runtimes)
|
|
2408
|
+
decrementWeakCount(this.treeExclusiveRuntimeOperationCounts, runtime);
|
|
2409
|
+
for (const sessionId of sessionIds)
|
|
2410
|
+
decrementMapCount(this.treeExclusiveSessionOperationCounts, sessionId);
|
|
2411
|
+
for (const session of sessions) {
|
|
2412
|
+
if (this.isCurrentActiveSession(session))
|
|
2413
|
+
this.observeUnreadActivityState(session);
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
isTreeExclusiveSessionIdentityActive(sessionId) {
|
|
2418
|
+
return (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) > 0;
|
|
2419
|
+
}
|
|
2420
|
+
isTreeExclusiveOperationActive(session) {
|
|
2421
|
+
if (this.isTreeExclusiveSessionIdentityActive(session.sessionId))
|
|
2422
|
+
return true;
|
|
2423
|
+
const runtime = this.activeRuntimeForSession(session);
|
|
2424
|
+
return runtime !== undefined && (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) > 0;
|
|
2425
|
+
}
|
|
2426
|
+
activeRuntimeForSession(session) {
|
|
2427
|
+
for (const active of new Set(this.active.values())) {
|
|
2428
|
+
if (active.runtime.session === session)
|
|
2429
|
+
return active.runtime;
|
|
2430
|
+
}
|
|
2431
|
+
return undefined;
|
|
2432
|
+
}
|
|
2433
|
+
assertTreeNavigationInactive(session, action) {
|
|
2434
|
+
if (this.treeNavigations.has(session))
|
|
2435
|
+
throw new Error(`Cannot ${action} while session tree navigation is active`);
|
|
2436
|
+
}
|
|
2437
|
+
async runSessionEntryMutation(session, action, operation) {
|
|
2438
|
+
this.beginSessionEntryMutation(session, action);
|
|
2439
|
+
try {
|
|
2440
|
+
return await operation();
|
|
2441
|
+
}
|
|
2442
|
+
finally {
|
|
2443
|
+
this.endSessionEntryMutation(session);
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
beginSessionEntryMutation(session, action) {
|
|
2447
|
+
this.assertTreeNavigationInactive(session, action);
|
|
2448
|
+
this.sessionEntryMutationCounts.set(session, (this.sessionEntryMutationCounts.get(session) ?? 0) + 1);
|
|
2449
|
+
this.observeUnreadActivityState(session);
|
|
2450
|
+
}
|
|
2451
|
+
endSessionEntryMutation(session) {
|
|
2452
|
+
const remaining = (this.sessionEntryMutationCounts.get(session) ?? 1) - 1;
|
|
2453
|
+
if (remaining <= 0)
|
|
2454
|
+
this.sessionEntryMutationCounts.delete(session);
|
|
2455
|
+
else
|
|
2456
|
+
this.sessionEntryMutationCounts.set(session, remaining);
|
|
2457
|
+
this.observeUnreadActivityState(session);
|
|
2458
|
+
}
|
|
2459
|
+
isSessionEntryMutationActive(session) {
|
|
2460
|
+
return (this.sessionEntryMutationCounts.get(session) ?? 0) > 0;
|
|
2461
|
+
}
|
|
2462
|
+
publishActivityForEvent(session, event) {
|
|
2463
|
+
const eventType = getString(event, "type");
|
|
2464
|
+
if (eventType === undefined)
|
|
2465
|
+
return;
|
|
2466
|
+
if (eventType === "agent_start") {
|
|
2467
|
+
this.publishActivity(session, "agent running", "active");
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
if (eventType === "agent_end") {
|
|
2471
|
+
this.publishActivity(session, "idle", "idle");
|
|
2472
|
+
setTimeout(() => {
|
|
2473
|
+
this.publishActivity(session, "idle", "idle");
|
|
2474
|
+
this.publishStatus(session);
|
|
2475
|
+
}, 250);
|
|
2476
|
+
return;
|
|
2477
|
+
}
|
|
2478
|
+
if (eventType === "turn_end") {
|
|
2479
|
+
this.publishActivity(session, "turn complete", "idle");
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
if (eventType === "message_start") {
|
|
2483
|
+
this.publishActivity(session, "message started", "active");
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
if (eventType === "message_end") {
|
|
2487
|
+
this.publishActivity(session, "message complete", "idle");
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
if (eventType === "message_update") {
|
|
2491
|
+
this.publishActivity(session, "receiving response", "active");
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
if (eventType === "tool_execution_start") {
|
|
2495
|
+
this.publishActivity(session, "running tool", "active", getString(event, "toolName"));
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
if (eventType === "tool_execution_end") {
|
|
2499
|
+
const isError = getBoolean(event, "isError") === true;
|
|
2500
|
+
this.publishActivity(session, isError ? "tool failed" : "tool complete", isError ? "error" : "idle", getString(event, "toolName"));
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
if (eventType === "bash_execution_start") {
|
|
2504
|
+
this.publishActivity(session, "running bash", "active");
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
2507
|
+
if (eventType === "bash_execution_end") {
|
|
2508
|
+
this.publishActivity(session, "bash complete", "idle");
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
if (this.hasActiveWork(session))
|
|
2512
|
+
this.publishActivity(session, eventType.replaceAll("_", " "), "active");
|
|
2513
|
+
}
|
|
2514
|
+
publishActivity(session, label, phase, detail) {
|
|
2515
|
+
const at = new Date().toISOString();
|
|
2516
|
+
const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
|
|
2517
|
+
this.activities.set(session.sessionId, stored);
|
|
2518
|
+
const activity = detail === undefined ? { sessionId: session.sessionId, phase, label, at } : { sessionId: session.sessionId, phase, label, detail, at };
|
|
2519
|
+
this.workspaceActivity?.applySessionActivity(session.sessionManager.getCwd(), activity);
|
|
2520
|
+
this.events.publish(session.sessionId, { type: "activity.update", activity });
|
|
2521
|
+
this.events.publishGlobal({ type: "activity.update", activity });
|
|
2522
|
+
this.observeUnreadActivityState(session);
|
|
2523
|
+
}
|
|
2524
|
+
publishStatus(session) {
|
|
2525
|
+
const status = this.statusFromSession(session);
|
|
2526
|
+
this.clearStaleActiveActivity(session);
|
|
2527
|
+
this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status);
|
|
2528
|
+
this.events.publish(session.sessionId, { type: "status.update", status });
|
|
2529
|
+
this.events.publishGlobal({ type: "status.update", status });
|
|
2530
|
+
this.observeUnreadActivityState(session);
|
|
2531
|
+
}
|
|
2532
|
+
clearStaleActiveActivity(session) {
|
|
2533
|
+
const current = this.activities.get(session.sessionId);
|
|
2534
|
+
if (current?.phase !== "active" || this.hasActiveWork(session))
|
|
2535
|
+
return;
|
|
2536
|
+
const at = new Date().toISOString();
|
|
2537
|
+
const stored = { phase: "idle", label: "idle", at };
|
|
2538
|
+
this.activities.set(session.sessionId, stored);
|
|
2539
|
+
const activity = { sessionId: session.sessionId, ...stored };
|
|
2540
|
+
this.events.publish(session.sessionId, { type: "activity.update", activity });
|
|
2541
|
+
this.events.publishGlobal({ type: "activity.update", activity });
|
|
2542
|
+
}
|
|
2543
|
+
statusFromSession(session) {
|
|
2544
|
+
const stats = session.getSessionStats();
|
|
2545
|
+
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
|
|
2546
|
+
const generation = this.generationForStatus(session);
|
|
2547
|
+
const contextUsage = session.getContextUsage();
|
|
2548
|
+
const warnings = this.warningsForSession(session);
|
|
2549
|
+
return {
|
|
2550
|
+
sessionId: session.sessionId,
|
|
2551
|
+
persisted: sessionFileExists(session.sessionFile),
|
|
2552
|
+
...(model === undefined ? {} : { model }),
|
|
2553
|
+
thinkingLevel: session.thinkingLevel,
|
|
2554
|
+
isStreaming: session.isStreaming,
|
|
2555
|
+
isCompacting: session.isCompacting,
|
|
2556
|
+
isBashRunning: session.isBashRunning,
|
|
2557
|
+
pendingMessageCount: this.pendingMessageCount(session),
|
|
2558
|
+
queuedMessages: queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)),
|
|
2559
|
+
messageCount: session.messages.length,
|
|
2560
|
+
tokens: stats.tokens,
|
|
2561
|
+
cost: stats.cost,
|
|
2562
|
+
...(generation === undefined ? {} : { generation }),
|
|
2563
|
+
...(contextUsage === undefined ? {} : { contextUsage }),
|
|
2564
|
+
...(warnings.length === 0 ? {} : { warnings }),
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2567
|
+
/**
|
|
2568
|
+
* Compute the live warning set for a session: runtime/resource diagnostics from
|
|
2569
|
+
* the active runtime (if any) plus the Anthropic subscription-auth notice. Read
|
|
2570
|
+
* fresh on each status publish so a rebuilt runtime or an auth/model change is
|
|
2571
|
+
* reflected without caching a stale snapshot.
|
|
2572
|
+
*/
|
|
2573
|
+
warningsForSession(session) {
|
|
2574
|
+
const runtime = this.active.get(session.sessionId)?.runtime;
|
|
2575
|
+
const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
|
|
2576
|
+
const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
|
|
2577
|
+
if (anthropic !== undefined)
|
|
2578
|
+
warnings.push(anthropic);
|
|
2579
|
+
return warnings;
|
|
2580
|
+
}
|
|
2581
|
+
pendingMessageCount(session) {
|
|
2582
|
+
return session.pendingMessageCount + this.compactionQueuedMessages(session.sessionId).length;
|
|
2583
|
+
}
|
|
2584
|
+
compactionQueuedMessages(sessionId) {
|
|
2585
|
+
return this.compactionPromptQueues.get(sessionId) ?? [];
|
|
2586
|
+
}
|
|
2587
|
+
hasQueuedMessageText(session, text) {
|
|
2588
|
+
return queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)).some((message) => message.text === text);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
function previewResponseFromPlan(plan) {
|
|
2592
|
+
return {
|
|
2593
|
+
generatedAt: plan.generatedAt,
|
|
2594
|
+
thresholds: plan.thresholds,
|
|
2595
|
+
projects: plan.projects,
|
|
2596
|
+
totals: plan.totals,
|
|
2597
|
+
...(plan.skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds: plan.skippedBusySessionIds }),
|
|
2598
|
+
};
|
|
2599
|
+
}
|
|
2600
|
+
function uniqueBulkSessionRefs(refs) {
|
|
2601
|
+
const seen = new Set();
|
|
2602
|
+
const unique = [];
|
|
2603
|
+
for (const ref of refs) {
|
|
2604
|
+
const key = `${ref.cwd ?? ""}\0${ref.id}`;
|
|
2605
|
+
if (seen.has(key))
|
|
2606
|
+
continue;
|
|
2607
|
+
seen.add(key);
|
|
2608
|
+
unique.push(ref);
|
|
2609
|
+
}
|
|
2610
|
+
return unique;
|
|
2611
|
+
}
|
|
2612
|
+
function bulkRefToLookup(ref) {
|
|
2613
|
+
return ref.cwd === undefined ? ref.id : { id: ref.id, cwd: ref.cwd };
|
|
2614
|
+
}
|
|
2615
|
+
function findArchivedRecordForBulkRef(records, ref) {
|
|
2616
|
+
return records.find((record) => (ref.cwd === undefined || record.cwd === ref.cwd) && (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
|
|
2617
|
+
}
|
|
2618
|
+
function findListedSessionForBulkRef(context, ref) {
|
|
2619
|
+
if (ref.cwd !== undefined)
|
|
2620
|
+
return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
|
|
2621
|
+
return context.allSessions === undefined ? undefined : findSessionByIdOrPrefix(context.allSessions, ref.id);
|
|
2622
|
+
}
|
|
2623
|
+
function findSessionByIdOrPrefix(sessions, sessionId) {
|
|
2624
|
+
return sessions.find((session) => session.id === sessionId) ?? sessions.find((session) => session.id.startsWith(sessionId));
|
|
2625
|
+
}
|
|
2626
|
+
function uniqueStrings(values) {
|
|
2627
|
+
return [...new Set(values)];
|
|
2628
|
+
}
|
|
2629
|
+
function errorMessage(error) {
|
|
2630
|
+
return error instanceof Error ? error.message : String(error);
|
|
2631
|
+
}
|
|
2632
|
+
function modelToClientModel(model) {
|
|
2633
|
+
if (model === undefined)
|
|
2634
|
+
return {};
|
|
2635
|
+
const name = getString(model, "name");
|
|
2636
|
+
const reasoning = getProperty(model, "reasoning");
|
|
2637
|
+
return {
|
|
2638
|
+
provider: model.provider,
|
|
2639
|
+
id: model.id,
|
|
2640
|
+
...(name === undefined ? {} : { name }),
|
|
2641
|
+
contextWindow: model.contextWindow,
|
|
2642
|
+
...(reasoning === undefined ? {} : { reasoning }),
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
function notificationIdentityForSession(session) {
|
|
2646
|
+
return {
|
|
2647
|
+
sessionId: session.sessionId,
|
|
2648
|
+
cwd: canonicalizeStoredCwd(session.sessionManager.getCwd()),
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
2651
|
+
function clientSessionFromListEntry(session) {
|
|
2652
|
+
return {
|
|
2653
|
+
id: session.id,
|
|
2654
|
+
path: session.path,
|
|
2655
|
+
cwd: session.cwd,
|
|
2656
|
+
persisted: true,
|
|
2657
|
+
...(session.name === undefined ? {} : { name: session.name }),
|
|
2658
|
+
created: session.created.toISOString(),
|
|
2659
|
+
modified: session.modified.toISOString(),
|
|
2660
|
+
messageCount: session.messageCount,
|
|
2661
|
+
firstMessage: session.firstMessage,
|
|
2662
|
+
...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
function archiveInputFromListEntry(session) {
|
|
2666
|
+
return {
|
|
2667
|
+
sessionId: session.id,
|
|
2668
|
+
cwd: session.cwd,
|
|
2669
|
+
path: session.path,
|
|
2670
|
+
created: session.created.toISOString(),
|
|
2671
|
+
modified: session.modified.toISOString(),
|
|
2672
|
+
messageCount: session.messageCount,
|
|
2673
|
+
firstMessage: session.firstMessage,
|
|
2674
|
+
...(session.name === undefined ? {} : { name: session.name }),
|
|
2675
|
+
...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
|
|
2676
|
+
};
|
|
2677
|
+
}
|
|
2678
|
+
function archiveInputFromActiveSession(session) {
|
|
2679
|
+
const sessionFile = session.sessionFile;
|
|
2680
|
+
if (sessionFile === undefined || sessionFile === "")
|
|
2681
|
+
throw new Error("Session is not persisted");
|
|
2682
|
+
const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
|
|
2683
|
+
return {
|
|
2684
|
+
sessionId: session.sessionId,
|
|
2685
|
+
cwd: session.sessionManager.getCwd(),
|
|
2686
|
+
path: sessionFile,
|
|
2687
|
+
created: new Date().toISOString(),
|
|
2688
|
+
modified: new Date().toISOString(),
|
|
2689
|
+
messageCount: session.messages.length,
|
|
2690
|
+
firstMessage: "",
|
|
2691
|
+
...(session.sessionName === undefined ? {} : { name: session.sessionName }),
|
|
2692
|
+
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
function archiveCandidateFromListEntry(session) {
|
|
2696
|
+
return {
|
|
2697
|
+
id: session.id,
|
|
2698
|
+
path: session.path,
|
|
2699
|
+
cwd: session.cwd,
|
|
2700
|
+
archived: false,
|
|
2701
|
+
listEntry: session,
|
|
2702
|
+
...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
|
|
2703
|
+
};
|
|
2704
|
+
}
|
|
2705
|
+
function archiveCandidateFromArchivedRecord(record, fallback) {
|
|
2706
|
+
const path = record.originalPath ?? fallback?.path;
|
|
2707
|
+
if (path === undefined)
|
|
2708
|
+
return undefined;
|
|
2709
|
+
const parentSessionPath = record.parentSessionPath ?? fallback?.parentSessionPath;
|
|
2710
|
+
return {
|
|
2711
|
+
id: record.sessionId,
|
|
2712
|
+
path,
|
|
2713
|
+
cwd: record.cwd,
|
|
2714
|
+
archived: true,
|
|
2715
|
+
...(fallback === undefined ? {} : { listEntry: fallback }),
|
|
2716
|
+
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
|
|
2717
|
+
};
|
|
2718
|
+
}
|
|
2719
|
+
function archiveCandidateFromActiveSession(session, archived) {
|
|
2720
|
+
const sessionFile = session.sessionFile;
|
|
2721
|
+
if (sessionFile === undefined || sessionFile === "")
|
|
2722
|
+
throw new Error("Session is not persisted");
|
|
2723
|
+
const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
|
|
2724
|
+
return {
|
|
2725
|
+
id: session.sessionId,
|
|
2726
|
+
path: sessionFile,
|
|
2727
|
+
cwd: session.sessionManager.getCwd(),
|
|
2728
|
+
archived,
|
|
2729
|
+
activeSession: session,
|
|
2730
|
+
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
function archiveInputFromCandidate(candidate) {
|
|
2734
|
+
if (candidate.listEntry !== undefined)
|
|
2735
|
+
return archiveInputFromListEntry(candidate.listEntry);
|
|
2736
|
+
if (candidate.activeSession !== undefined)
|
|
2737
|
+
return archiveInputFromActiveSession(candidate.activeSession);
|
|
2738
|
+
throw new Error(`Session is not available for archiving: ${candidate.id}`);
|
|
2739
|
+
}
|
|
2740
|
+
function sessionHasActiveWork(session, extraQueuedMessageCount = 0) {
|
|
2741
|
+
return session.isStreaming || session.isCompacting || session.isBashRunning || session.pendingMessageCount + extraQueuedMessageCount > 0;
|
|
2742
|
+
}
|
|
2743
|
+
function sessionDisplayName(session) {
|
|
2744
|
+
return session.sessionName ?? session.sessionId;
|
|
2745
|
+
}
|
|
2746
|
+
function clientSessionFromArchivedRecord(record, fallback) {
|
|
2747
|
+
const path = record.originalPath ?? fallback?.path;
|
|
2748
|
+
const created = record.created ?? fallback?.created.toISOString();
|
|
2749
|
+
const modified = record.modified ?? fallback?.modified.toISOString();
|
|
2750
|
+
const messageCount = record.messageCount ?? fallback?.messageCount;
|
|
2751
|
+
const firstMessage = record.firstMessage ?? fallback?.firstMessage;
|
|
2752
|
+
if (path === undefined || created === undefined || modified === undefined || messageCount === undefined || firstMessage === undefined)
|
|
2753
|
+
return undefined;
|
|
2754
|
+
const name = record.name ?? fallback?.name;
|
|
2755
|
+
const parentSessionPath = record.parentSessionPath ?? fallback?.parentSessionPath;
|
|
2756
|
+
return {
|
|
2757
|
+
id: record.sessionId,
|
|
2758
|
+
path,
|
|
2759
|
+
cwd: record.cwd,
|
|
2760
|
+
...(name === undefined ? {} : { name }),
|
|
2761
|
+
created,
|
|
2762
|
+
modified,
|
|
2763
|
+
messageCount,
|
|
2764
|
+
firstMessage,
|
|
2765
|
+
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
|
|
2766
|
+
archived: true,
|
|
2767
|
+
archivedAt: record.archivedAt,
|
|
2768
|
+
};
|
|
2769
|
+
}
|
|
2770
|
+
function addSessionName(names, name) {
|
|
2771
|
+
const trimmed = name?.replace(/\s+/g, " ").trim();
|
|
2772
|
+
if (trimmed !== undefined && trimmed !== "")
|
|
2773
|
+
names.add(trimmed);
|
|
2774
|
+
}
|
|
2775
|
+
function compareArchivedRecords(a, b) {
|
|
2776
|
+
return archivedTimestamp(b) - archivedTimestamp(a);
|
|
2777
|
+
}
|
|
2778
|
+
function archivedTimestamp(record) {
|
|
2779
|
+
const time = Date.parse(record.archivedAt);
|
|
2780
|
+
return Number.isNaN(time) ? 0 : time;
|
|
2781
|
+
}
|
|
2782
|
+
function isDefined(value) {
|
|
2783
|
+
return value !== undefined;
|
|
2784
|
+
}
|
|
2785
|
+
function mergeSessionMetadata(session, pinnedPaths) {
|
|
2786
|
+
if (!pinnedPaths.has(session.path))
|
|
2787
|
+
return session;
|
|
2788
|
+
return { ...session, pinned: true };
|
|
2789
|
+
}
|
|
2790
|
+
/**
|
|
2791
|
+
* Resolve the delegation capability from server-owned, persisted session
|
|
2792
|
+
* provenance. A copied marker is not enough: the child header and reciprocal
|
|
2793
|
+
* parent link must identify the exact same session files.
|
|
2794
|
+
*/
|
|
2795
|
+
export async function sessionAllowsDelegationTools(sessionManager, managers) {
|
|
2796
|
+
const trackedLink = await verifiedTrackedSubsessionLink(managers, {
|
|
2797
|
+
sessionId: sessionManager.getSessionId(),
|
|
2798
|
+
sessionFile: sessionManager.getSessionFile(),
|
|
2799
|
+
sessionManager,
|
|
2800
|
+
cwd: sessionManager.getCwd(),
|
|
2801
|
+
});
|
|
2802
|
+
return trackedLink === undefined;
|
|
2803
|
+
}
|
|
2804
|
+
async function verifiedTrackedSubsessionLink(managers, session) {
|
|
2805
|
+
// Child markers are only hints; the current child header and reciprocal
|
|
2806
|
+
// parent custom link must agree on the exact ids and files before relinking.
|
|
2807
|
+
const entries = session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch();
|
|
2808
|
+
let marker;
|
|
2809
|
+
for (const entry of entries) {
|
|
2810
|
+
const parsed = parsePersistedChildSubsessionLink(entry);
|
|
2811
|
+
if (parsed?.spawnedSessionId === session.sessionId)
|
|
2812
|
+
marker = parsed;
|
|
2813
|
+
}
|
|
2814
|
+
if (marker === undefined)
|
|
2815
|
+
return undefined;
|
|
2816
|
+
const childSessionFile = nonEmptyString(session.sessionFile);
|
|
2817
|
+
if (childSessionFile === undefined)
|
|
2818
|
+
return undefined;
|
|
2819
|
+
const childHeader = await readSessionHeaderSummary(childSessionFile);
|
|
2820
|
+
if (childHeader?.id !== session.sessionId)
|
|
2821
|
+
return undefined;
|
|
2822
|
+
const parentSessionFile = nonEmptyString(childHeader.parentSession);
|
|
2823
|
+
if (parentSessionFile === undefined)
|
|
2824
|
+
return undefined;
|
|
2825
|
+
const parentHeader = await readSessionHeaderSummary(parentSessionFile);
|
|
2826
|
+
if (parentHeader?.id !== marker.spawnedBySessionId)
|
|
2827
|
+
return undefined;
|
|
2828
|
+
const parentLink = findReciprocalParentSubsessionLink(managers, parentSessionFile, marker.spawnedBySessionId, session.sessionId, childSessionFile);
|
|
2829
|
+
if (parentLink === undefined)
|
|
2830
|
+
return undefined;
|
|
2831
|
+
return {
|
|
2832
|
+
parentSessionId: marker.spawnedBySessionId,
|
|
2833
|
+
childSessionId: session.sessionId,
|
|
2834
|
+
childSessionFile,
|
|
2835
|
+
parentSessionFile,
|
|
2836
|
+
cwd: parentLink.cwd ?? session.cwd,
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
function findReciprocalParentSubsessionLink(managers, parentSessionFile, parentSessionId, childSessionId, childSessionFile) {
|
|
2840
|
+
let parentManager;
|
|
2841
|
+
try {
|
|
2842
|
+
parentManager = managers.open(parentSessionFile);
|
|
2843
|
+
}
|
|
2844
|
+
catch {
|
|
2845
|
+
return undefined;
|
|
2846
|
+
}
|
|
2847
|
+
const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
|
|
2848
|
+
for (const entry of entries) {
|
|
2849
|
+
const link = parsePersistedParentSubsessionLink(entry);
|
|
2850
|
+
if (link === undefined)
|
|
2851
|
+
continue;
|
|
2852
|
+
if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId)
|
|
2853
|
+
continue;
|
|
2854
|
+
if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile))
|
|
2855
|
+
continue;
|
|
2856
|
+
return link;
|
|
2857
|
+
}
|
|
2858
|
+
return undefined;
|
|
2859
|
+
}
|
|
2860
|
+
function trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile) {
|
|
2861
|
+
return {
|
|
2862
|
+
parentSessionId,
|
|
2863
|
+
childSessionId: link.spawnedSessionId,
|
|
2864
|
+
...(link.spawnedSessionFile === undefined ? {} : { childSessionFile: link.spawnedSessionFile }),
|
|
2865
|
+
parentSessionFile,
|
|
2866
|
+
...(link.cwd === undefined ? {} : { cwd: link.cwd }),
|
|
2867
|
+
};
|
|
2868
|
+
}
|
|
2869
|
+
function persistedParentSubsessionLinkData(link) {
|
|
2870
|
+
return {
|
|
2871
|
+
version: 1,
|
|
2872
|
+
spawnedBySessionId: link.parentSessionId,
|
|
2873
|
+
spawnedSessionId: link.childSessionId,
|
|
2874
|
+
...(link.childSessionFile === undefined ? {} : { spawnedSessionFile: link.childSessionFile }),
|
|
2875
|
+
...(link.cwd === undefined ? {} : { cwd: link.cwd }),
|
|
2876
|
+
};
|
|
2877
|
+
}
|
|
2878
|
+
function persistedChildSubsessionLinkData(parentSessionId, childSessionId) {
|
|
2879
|
+
return {
|
|
2880
|
+
version: 1,
|
|
2881
|
+
spawnedBySessionId: parentSessionId,
|
|
2882
|
+
spawnedSessionId: childSessionId,
|
|
2883
|
+
};
|
|
2884
|
+
}
|
|
2885
|
+
function parsePersistedParentSubsessionLink(entry) {
|
|
2886
|
+
if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE)
|
|
2887
|
+
return undefined;
|
|
2888
|
+
const data = entry["data"];
|
|
2889
|
+
if (!isRecord(data))
|
|
2890
|
+
return undefined;
|
|
2891
|
+
const spawnedBySessionId = getString(data, "spawnedBySessionId");
|
|
2892
|
+
const spawnedSessionId = getString(data, "spawnedSessionId");
|
|
2893
|
+
if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
|
|
2894
|
+
return undefined;
|
|
2895
|
+
const spawnedSessionFile = getString(data, "spawnedSessionFile");
|
|
2896
|
+
const cwd = getString(data, "cwd");
|
|
2897
|
+
return {
|
|
2898
|
+
spawnedBySessionId,
|
|
2899
|
+
spawnedSessionId,
|
|
2900
|
+
...(spawnedSessionFile === undefined || spawnedSessionFile === "" ? {} : { spawnedSessionFile }),
|
|
2901
|
+
...(cwd === undefined || cwd === "" ? {} : { cwd }),
|
|
2902
|
+
};
|
|
2903
|
+
}
|
|
2904
|
+
function parsePersistedChildSubsessionLink(entry) {
|
|
2905
|
+
if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE)
|
|
2906
|
+
return undefined;
|
|
2907
|
+
const data = entry["data"];
|
|
2908
|
+
if (!isRecord(data))
|
|
2909
|
+
return undefined;
|
|
2910
|
+
const spawnedBySessionId = getString(data, "spawnedBySessionId");
|
|
2911
|
+
const spawnedSessionId = getString(data, "spawnedSessionId");
|
|
2912
|
+
if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
|
|
2913
|
+
return undefined;
|
|
2914
|
+
return { spawnedBySessionId, spawnedSessionId };
|
|
2915
|
+
}
|
|
2916
|
+
function nonEmptyString(value) {
|
|
2917
|
+
return value === undefined || value === "" ? undefined : value;
|
|
2918
|
+
}
|
|
2919
|
+
function subsessionHydratedParentKey(parentSessionId, parentSessionFile) {
|
|
2920
|
+
return `${parentSessionId}\0${parentSessionFile ?? ""}`;
|
|
2921
|
+
}
|
|
2922
|
+
function sessionPathsEqual(a, b) {
|
|
2923
|
+
return cwdPathsEqual(a, b);
|
|
2924
|
+
}
|
|
2925
|
+
function sessionFileExists(sessionFile) {
|
|
2926
|
+
if (sessionFile === undefined || sessionFile === "")
|
|
2927
|
+
return false;
|
|
2928
|
+
try {
|
|
2929
|
+
return statSync(sessionFile).isFile();
|
|
2930
|
+
}
|
|
2931
|
+
catch {
|
|
2932
|
+
return false;
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
function sessionFileMatches(session, expectedSessionFile) {
|
|
2936
|
+
const sessionFile = nonEmptyString(session.sessionFile);
|
|
2937
|
+
return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile);
|
|
2938
|
+
}
|
|
2939
|
+
function activeSessionFileMatches(active, expectedSessionFile) {
|
|
2940
|
+
return sessionFileMatches(active.runtime.session, expectedSessionFile);
|
|
2941
|
+
}
|
|
2942
|
+
function trackedLinkParentFileMatches(link, parentSessionFile) {
|
|
2943
|
+
return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
|
|
2944
|
+
}
|
|
2945
|
+
async function readSessionHeaderSummary(sessionFile) {
|
|
2946
|
+
let file;
|
|
2947
|
+
try {
|
|
2948
|
+
file = await open(sessionFile, "r");
|
|
2949
|
+
const buffer = Buffer.alloc(4096);
|
|
2950
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
2951
|
+
const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
|
|
2952
|
+
if (firstLine === undefined || firstLine === "")
|
|
2953
|
+
return undefined;
|
|
2954
|
+
const header = JSON.parse(firstLine);
|
|
2955
|
+
if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string")
|
|
2956
|
+
return undefined;
|
|
2957
|
+
const parentSession = getString(header, "parentSession");
|
|
2958
|
+
return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) };
|
|
2959
|
+
}
|
|
2960
|
+
catch {
|
|
2961
|
+
return undefined;
|
|
2962
|
+
}
|
|
2963
|
+
finally {
|
|
2964
|
+
await file?.close().catch(() => undefined);
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
async function sessionFileHeaderMatches(sessionFile, expected) {
|
|
2968
|
+
const header = await readSessionHeaderSummary(sessionFile);
|
|
2969
|
+
if (header?.id !== expected.sessionId)
|
|
2970
|
+
return false;
|
|
2971
|
+
if (expected.parentSessionFile === undefined)
|
|
2972
|
+
return true;
|
|
2973
|
+
return header.parentSession !== undefined && sessionPathsEqual(header.parentSession, expected.parentSessionFile);
|
|
2974
|
+
}
|
|
2975
|
+
async function clearParentSession(sessionFile) {
|
|
2976
|
+
const content = await readFile(sessionFile, "utf8");
|
|
2977
|
+
const newlineIndex = content.indexOf("\n");
|
|
2978
|
+
const firstLine = newlineIndex === -1 ? content : content.slice(0, newlineIndex);
|
|
2979
|
+
const rest = newlineIndex === -1 ? "" : content.slice(newlineIndex);
|
|
2980
|
+
const header = JSON.parse(firstLine);
|
|
2981
|
+
if (!isRecord(header) || header["type"] !== "session")
|
|
2982
|
+
throw new Error("Invalid session file header");
|
|
2983
|
+
if (header["parentSession"] === undefined)
|
|
2984
|
+
return;
|
|
2985
|
+
delete header["parentSession"];
|
|
2986
|
+
await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8");
|
|
2987
|
+
}
|
|
2988
|
+
function clearParentSessionHeader(sessionManager) {
|
|
2989
|
+
const header = sessionManager.getHeader?.();
|
|
2990
|
+
if (header !== undefined && header !== null)
|
|
2991
|
+
delete header.parentSession;
|
|
2992
|
+
}
|
|
2993
|
+
function clearSessionQueue(session) {
|
|
2994
|
+
session.clearQueue();
|
|
2995
|
+
}
|
|
2996
|
+
function queuedMessagesFromSession(session, extraQueuedMessages = []) {
|
|
2997
|
+
return [
|
|
2998
|
+
...session.getSteeringMessages().map((text) => ({ kind: "steer", text })),
|
|
2999
|
+
...session.getFollowUpMessages().map((text) => ({ kind: "followUp", text })),
|
|
3000
|
+
...extraQueuedMessages,
|
|
3001
|
+
];
|
|
3002
|
+
}
|
|
3003
|
+
function userTextMessage(text) {
|
|
3004
|
+
return { role: "user", content: text };
|
|
3005
|
+
}
|
|
3006
|
+
/**
|
|
3007
|
+
* Build the optimistic user message echoed to clients. When images are present
|
|
3008
|
+
* we mirror pi's content-array shape (`[{type:"text"}, {type:"image"}, ...]`) so
|
|
3009
|
+
* the local echo matches what pi persists in the session branch.
|
|
3010
|
+
*/
|
|
3011
|
+
function userMessage(text, images) {
|
|
3012
|
+
if (images.length === 0)
|
|
3013
|
+
return userTextMessage(text);
|
|
3014
|
+
const content = [];
|
|
3015
|
+
if (text !== "")
|
|
3016
|
+
content.push({ type: "text", text });
|
|
3017
|
+
content.push(...images);
|
|
3018
|
+
return { role: "user", content };
|
|
3019
|
+
}
|
|
3020
|
+
function buildPromptOptions(behavior, images) {
|
|
3021
|
+
const options = {};
|
|
3022
|
+
if (behavior !== undefined)
|
|
3023
|
+
options.streamingBehavior = behavior;
|
|
3024
|
+
if (images.length > 0)
|
|
3025
|
+
options.images = images;
|
|
3026
|
+
return Object.keys(options).length > 0 ? options : undefined;
|
|
3027
|
+
}
|
|
3028
|
+
function stringValue(value) {
|
|
3029
|
+
return typeof value === "string" ? value : "";
|
|
3030
|
+
}
|
|
3031
|
+
function historyMessages(session) {
|
|
3032
|
+
const messages = [];
|
|
3033
|
+
for (const entry of session.sessionManager.getBranch()) {
|
|
3034
|
+
if (!isRecord(entry))
|
|
3035
|
+
continue;
|
|
3036
|
+
if (entry["type"] === "message")
|
|
3037
|
+
messages.push(entry["message"]);
|
|
3038
|
+
else if (entry["type"] === "custom_message" && entry["display"] === true)
|
|
3039
|
+
messages.push({ role: "custom", content: entry["content"], customType: entry["customType"], details: entry["details"] });
|
|
3040
|
+
else if (entry["type"] === "compaction")
|
|
3041
|
+
messages.push({ role: "system", source: "compaction", content: `Compacted history:\n\n${stringValue(entry["summary"])}` });
|
|
3042
|
+
else if (entry["type"] === "branch_summary")
|
|
3043
|
+
messages.push({ role: "system", source: "branch_summary", content: `Branch summary:\n\n${stringValue(entry["summary"])}` });
|
|
3044
|
+
}
|
|
3045
|
+
return messages;
|
|
3046
|
+
}
|
|
3047
|
+
/** custom entry type used to persist parent -> child subsession links outside LLM context. */
|
|
3048
|
+
const SUBSESSION_LINK_CUSTOM_TYPE = "pi-webui.subsession.link";
|
|
3049
|
+
/** custom entry type used to mark a child as created by spawn_subsession. */
|
|
3050
|
+
const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-webui.subsession.spawned";
|
|
3051
|
+
/** customType marking a parent-facing subsession-completion notice. */
|
|
3052
|
+
const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
|
|
3053
|
+
const SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS = 2000;
|
|
3054
|
+
/** Avoid duplicating a partial result in context when deliberate inspection can return the full output. */
|
|
3055
|
+
function formatSubsessionNotificationOutput(childSessionId, text) {
|
|
3056
|
+
if (text.length > SUBSESSION_NOTIFICATION_MAX_OUTPUT_CHARS) {
|
|
3057
|
+
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.`;
|
|
3058
|
+
}
|
|
3059
|
+
return `--- SUBSESSION OUTPUT: ${childSessionId} ---\n${text === "" ? "(no output)" : text}`;
|
|
3060
|
+
}
|
|
3061
|
+
/** Most recent assistant text from a history message list, or "" if none. */
|
|
3062
|
+
function finalAssistantText(messages) {
|
|
3063
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
3064
|
+
const message = messages[i];
|
|
3065
|
+
if (!isRecord(message) || message["role"] !== "assistant")
|
|
3066
|
+
continue;
|
|
3067
|
+
const content = message["content"];
|
|
3068
|
+
if (typeof content === "string")
|
|
3069
|
+
return content;
|
|
3070
|
+
if (!Array.isArray(content))
|
|
3071
|
+
continue;
|
|
3072
|
+
const texts = [];
|
|
3073
|
+
for (const part of content) {
|
|
3074
|
+
if (isRecord(part) && part["type"] === "text" && typeof part["text"] === "string")
|
|
3075
|
+
texts.push(part["text"]);
|
|
3076
|
+
}
|
|
3077
|
+
if (texts.length > 0)
|
|
3078
|
+
return texts.join("\n").trim();
|
|
3079
|
+
}
|
|
3080
|
+
return "";
|
|
3081
|
+
}
|
|
3082
|
+
function toClientEvent(event) {
|
|
3083
|
+
const eventType = getString(event, "type");
|
|
3084
|
+
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
|
|
3085
|
+
if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
|
|
3086
|
+
return { type: "assistant.delta", text: getString(assistantMessageEvent, "delta") ?? "" };
|
|
3087
|
+
}
|
|
3088
|
+
if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "thinking_delta") {
|
|
3089
|
+
return { type: "assistant.thinking.delta", text: getString(assistantMessageEvent, "delta") ?? "" };
|
|
3090
|
+
}
|
|
3091
|
+
if (eventType === "tool_execution_start") {
|
|
3092
|
+
const args = getProperty(event, "args");
|
|
3093
|
+
return { type: "tool.start", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", summary: summarizeToolArgs(args), args };
|
|
3094
|
+
}
|
|
3095
|
+
if (eventType === "tool_execution_update") {
|
|
3096
|
+
const partialResult = getProperty(event, "partialResult");
|
|
3097
|
+
return { type: "tool.update", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(partialResult), content: toolResultContent(partialResult), details: toolResultDetails(partialResult) };
|
|
3098
|
+
}
|
|
3099
|
+
if (eventType === "tool_execution_end") {
|
|
3100
|
+
const result = getProperty(event, "result");
|
|
3101
|
+
return { type: "tool.end", toolName: getString(event, "toolName") ?? "", toolCallId: getString(event, "toolCallId") ?? "", text: stringifyToolResult(result), content: toolResultContent(result), details: toolResultDetails(result), isError: getBoolean(event, "isError") === true };
|
|
3102
|
+
}
|
|
3103
|
+
if (eventType === "agent_start")
|
|
3104
|
+
return { type: "agent.start" };
|
|
3105
|
+
if (eventType === "agent_end")
|
|
3106
|
+
return { type: "agent.end" };
|
|
3107
|
+
if (eventType === "message_end") {
|
|
3108
|
+
const message = getProperty(event, "message");
|
|
3109
|
+
return message === undefined ? { type: "message.end" } : { type: "message.end", message };
|
|
3110
|
+
}
|
|
3111
|
+
return { type: "pi.event", eventType: eventType ?? "unknown" };
|
|
3112
|
+
}
|
|
3113
|
+
function summarizeToolArgs(args) {
|
|
3114
|
+
if (!isRecord(args))
|
|
3115
|
+
return stringifyPrimitive(args);
|
|
3116
|
+
const command = getString(args, "command");
|
|
3117
|
+
if (command !== undefined)
|
|
3118
|
+
return command;
|
|
3119
|
+
const path = getString(args, "path");
|
|
3120
|
+
if (path !== undefined)
|
|
3121
|
+
return path;
|
|
3122
|
+
if (typeof args["oldText"] === "string" && typeof args["newText"] === "string")
|
|
3123
|
+
return "edit text replacement";
|
|
3124
|
+
const edits = args["edits"];
|
|
3125
|
+
if (Array.isArray(edits))
|
|
3126
|
+
return `${String(edits.length)} edit${edits.length === 1 ? "" : "s"}`;
|
|
3127
|
+
const entries = Object.entries(args).filter(([, value]) => value != null).slice(0, 3);
|
|
3128
|
+
return entries.map(([key, value]) => `${key}: ${shortToolValue(value)}`).join(" · ");
|
|
3129
|
+
}
|
|
3130
|
+
function shortToolValue(value) {
|
|
3131
|
+
if (typeof value === "string")
|
|
3132
|
+
return value.length > 80 ? `${value.slice(0, 77)}…` : value;
|
|
3133
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
3134
|
+
return String(value);
|
|
3135
|
+
if (Array.isArray(value))
|
|
3136
|
+
return `${String(value.length)} item${value.length === 1 ? "" : "s"}`;
|
|
3137
|
+
if (typeof value === "object" && value !== null)
|
|
3138
|
+
return "object";
|
|
3139
|
+
return "";
|
|
3140
|
+
}
|
|
3141
|
+
function toolResultContent(result) {
|
|
3142
|
+
if (isRecord(result)) {
|
|
3143
|
+
const content = getProperty(result, "content");
|
|
3144
|
+
if (content !== undefined)
|
|
3145
|
+
return content;
|
|
3146
|
+
const text = getString(result, "text") ?? getString(result, "output");
|
|
3147
|
+
if (text !== undefined)
|
|
3148
|
+
return [{ type: "text", text }];
|
|
3149
|
+
}
|
|
3150
|
+
if (typeof result === "string")
|
|
3151
|
+
return [{ type: "text", text: result }];
|
|
3152
|
+
return result;
|
|
3153
|
+
}
|
|
3154
|
+
function toolResultDetails(result) {
|
|
3155
|
+
return isRecord(result) ? getProperty(result, "details") : undefined;
|
|
3156
|
+
}
|
|
3157
|
+
function stringifyToolResult(result) {
|
|
3158
|
+
if (typeof result === "string")
|
|
3159
|
+
return result;
|
|
3160
|
+
if (Array.isArray(result))
|
|
3161
|
+
return result.map(stringifyToolResult).filter((text) => text !== "").join("\n");
|
|
3162
|
+
if (isRecord(result)) {
|
|
3163
|
+
if (getString(result, "type") === "image")
|
|
3164
|
+
return "[image]";
|
|
3165
|
+
const text = getString(result, "text") ?? getString(result, "content") ?? getString(result, "output");
|
|
3166
|
+
if (text !== undefined)
|
|
3167
|
+
return text;
|
|
3168
|
+
const content = getProperty(result, "content");
|
|
3169
|
+
if (Array.isArray(content))
|
|
3170
|
+
return stringifyToolResult(content);
|
|
3171
|
+
return JSON.stringify(result, null, 2);
|
|
3172
|
+
}
|
|
3173
|
+
return stringifyPrimitive(result);
|
|
3174
|
+
}
|
|
3175
|
+
function isRecord(value) {
|
|
3176
|
+
return typeof value === "object" && value !== null;
|
|
3177
|
+
}
|
|
3178
|
+
function getProperty(value, key) {
|
|
3179
|
+
return isRecord(value) ? value[key] : undefined;
|
|
3180
|
+
}
|
|
3181
|
+
function getString(value, key) {
|
|
3182
|
+
const property = getProperty(value, key);
|
|
3183
|
+
return typeof property === "string" ? property : undefined;
|
|
3184
|
+
}
|
|
3185
|
+
function getBoolean(value, key) {
|
|
3186
|
+
const property = getProperty(value, key);
|
|
3187
|
+
return typeof property === "boolean" ? property : undefined;
|
|
3188
|
+
}
|
|
3189
|
+
function providerOutputTokens(message) {
|
|
3190
|
+
const output = getProperty(getProperty(message, "usage"), "output");
|
|
3191
|
+
return typeof output === "number" && Number.isFinite(output) && output > 0 ? output : undefined;
|
|
3192
|
+
}
|
|
3193
|
+
/** Match Pi Web's 4-characters-per-token live approximation. */
|
|
3194
|
+
function estimatedOutputTokens(message) {
|
|
3195
|
+
const content = getProperty(message, "content");
|
|
3196
|
+
if (!Array.isArray(content))
|
|
3197
|
+
return undefined;
|
|
3198
|
+
let characters = 0;
|
|
3199
|
+
for (const block of content) {
|
|
3200
|
+
const type = getString(block, "type");
|
|
3201
|
+
if (type === "text")
|
|
3202
|
+
characters += getString(block, "text")?.length ?? 0;
|
|
3203
|
+
else if (type === "thinking")
|
|
3204
|
+
characters += getString(block, "thinking")?.length ?? 0;
|
|
3205
|
+
else if (type === "toolCall")
|
|
3206
|
+
characters += serializedCharacterCount(getProperty(block, "arguments"));
|
|
3207
|
+
}
|
|
3208
|
+
const tokens = Math.round(characters / ESTIMATED_CHARS_PER_TOKEN);
|
|
3209
|
+
return tokens > 0 ? tokens : undefined;
|
|
3210
|
+
}
|
|
3211
|
+
function serializedCharacterCount(value) {
|
|
3212
|
+
if (value == null)
|
|
3213
|
+
return JSON.stringify({}).length;
|
|
3214
|
+
try {
|
|
3215
|
+
return JSON.stringify(value).length;
|
|
3216
|
+
}
|
|
3217
|
+
catch {
|
|
3218
|
+
return 0;
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
function stringifyPrimitive(value) {
|
|
3222
|
+
if (value == null)
|
|
3223
|
+
return "";
|
|
3224
|
+
if (typeof value === "string")
|
|
3225
|
+
return value;
|
|
3226
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint")
|
|
3227
|
+
return String(value);
|
|
3228
|
+
return "";
|
|
3229
|
+
}
|
|
3230
|
+
//# sourceMappingURL=piSessionService.js.map
|