@jmfederico/pi-web 1.202607.1 → 1.202607.3
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/README.md +8 -8
- package/dist/cli.js +17 -5
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/{CodeViewer-uASb28Y1.js → CodeViewer-CDVbMiN9.js} +1 -1
- package/dist/client/assets/{TerminalPanel-DfTmykFw.js → TerminalPanel-CTS1CgqF.js} +2 -2
- package/dist/client/assets/{UnifiedDiffViewer-laQN1x7P.js → UnifiedDiffViewer-Cqke12ZX.js} +1 -1
- package/dist/client/assets/index-LME0LfPb.js +4068 -0
- package/dist/client/index.html +1 -1
- package/dist/config.js +52 -0
- package/dist/config.js.map +1 -1
- package/dist/pi-web-plugins/info/infoInternals.js +208 -0
- package/dist/pi-web-plugins/info/pi-web-plugin.js +12 -15
- package/dist/pi-web-plugins/relays/markdownDocument.js +81 -0
- package/dist/pi-web-plugins/relays/package.json +9 -0
- package/dist/pi-web-plugins/relays/pi-web-plugin.js +44 -0
- package/dist/pi-web-plugins/relays/relayDiscovery.js +96 -0
- package/dist/pi-web-plugins/relays/relaysPanelElement.js +393 -0
- package/dist/pi-web-plugins/relays/vendor/README.md +24 -0
- package/dist/pi-web-plugins/relays/vendor/marked.esm.js +76 -0
- package/dist/plugin-api.d.ts +7 -1
- package/dist/server/configRoutes.js +11 -0
- package/dist/server/configRoutes.js.map +1 -1
- package/dist/server/diagnostics/nodePtyNativeModule.js +36 -0
- package/dist/server/diagnostics/nodePtyNativeModule.js.map +1 -0
- package/dist/server/git/gitService.js +176 -5
- package/dist/server/git/gitService.js.map +1 -1
- package/dist/server/piWebStatus.js +6 -3
- package/dist/server/piWebStatus.js.map +1 -1
- package/dist/server/realtime/sessionEventHub.js +4 -0
- package/dist/server/realtime/sessionEventHub.js.map +1 -1
- package/dist/server/sessiond/sessionServiceDependencies.js +32 -0
- package/dist/server/sessiond/sessionServiceDependencies.js.map +1 -0
- package/dist/server/sessiond.js +127 -79
- package/dist/server/sessiond.js.map +1 -1
- package/dist/server/sessions/askUserTool.js +105 -0
- package/dist/server/sessions/askUserTool.js.map +1 -0
- package/dist/server/sessions/authService.js +58 -7
- package/dist/server/sessions/authService.js.map +1 -1
- package/dist/server/sessions/extensionDialogWaiters.js +76 -0
- package/dist/server/sessions/extensionDialogWaiters.js.map +1 -0
- package/dist/server/sessions/globalProviderPolicy.js +167 -0
- package/dist/server/sessions/globalProviderPolicy.js.map +1 -0
- package/dist/server/sessions/modelCatalogRefresher.js +192 -0
- package/dist/server/sessions/modelCatalogRefresher.js.map +1 -0
- package/dist/server/sessions/parentSessionLocator.js +75 -0
- package/dist/server/sessions/parentSessionLocator.js.map +1 -0
- package/dist/server/sessions/pendingAskStore.js +278 -0
- package/dist/server/sessions/pendingAskStore.js.map +1 -0
- package/dist/server/sessions/pendingExtensionDialogStore.js +196 -0
- package/dist/server/sessions/pendingExtensionDialogStore.js.map +1 -0
- package/dist/server/sessions/piSessionService.js +1226 -130
- package/dist/server/sessions/piSessionService.js.map +1 -1
- package/dist/server/sessions/plainTextTheme.js +112 -0
- package/dist/server/sessions/plainTextTheme.js.map +1 -0
- package/dist/server/sessions/sessionCommandService.js +68 -9
- package/dist/server/sessions/sessionCommandService.js.map +1 -1
- package/dist/server/sessions/sessionFileHeader.js +45 -0
- package/dist/server/sessions/sessionFileHeader.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 +273 -1
- package/dist/server/sessions/sessionRoutes.js.map +1 -1
- 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 +2 -2
- package/dist/server/sessions/spawnSessionTool.js.map +1 -1
- package/dist/server/sessions/spawnSubsessionTool.js +10 -12
- package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
- package/dist/server/sessions/spawnTargetResolver.js +3 -16
- package/dist/server/sessions/spawnTargetResolver.js.map +1 -1
- package/dist/server/workspaces/gitWorktreeDiscovery.js +9 -0
- package/dist/server/workspaces/gitWorktreeDiscovery.js.map +1 -1
- package/dist/server/workspaces/projectWorkspaceCwds.js +22 -0
- package/dist/server/workspaces/projectWorkspaceCwds.js.map +1 -0
- package/dist/server/workspaces/workspaceService.js +15 -2
- package/dist/server/workspaces/workspaceService.js.map +1 -1
- package/dist/shared/activity.js +9 -1
- package/dist/shared/activity.js.map +1 -1
- package/dist/shared/apiTypes.d.ts +452 -2
- package/dist/shared/apiTypes.js +34 -0
- package/dist/shared/apiTypes.js.map +1 -1
- package/dist/shared/capabilities.js +9 -0
- package/dist/shared/capabilities.js.map +1 -1
- package/dist/shared/federatedRoutes.js +12 -0
- package/dist/shared/federatedRoutes.js.map +1 -1
- package/docs/config.md +84 -7
- package/docs/plugins.md +87 -17
- package/install.sh +1 -1
- package/package.json +9 -7
- package/dist/client/assets/index-Dhapi0oR.js +0 -3093
- package/dist/server/sessiond/sessionDaemonStartup.js +0 -49
- package/dist/server/sessiond/sessionDaemonStartup.js.map +0 -1
- package/dist/server/sessions/sessionArchiveMigration.js +0 -590
- package/dist/server/sessions/sessionArchiveMigration.js.map +0 -1
|
@@ -1,23 +1,53 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, readStoredCredential, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { projectBrowserMessage } from "../browserMessageProjection.js";
|
|
6
6
|
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
|
7
7
|
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
|
8
8
|
import { SessionCommandService } from "./sessionCommandService.js";
|
|
9
|
+
import { projectSessionTree } from "./sessionTreeProjection.js";
|
|
9
10
|
import { SessionArchiveStore } from "./sessionArchiveStore.js";
|
|
10
11
|
import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
|
|
11
12
|
import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
|
12
13
|
import { computeEditPreview } from "./editPreview.js";
|
|
13
14
|
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
|
14
15
|
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
|
16
|
+
import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
|
|
15
17
|
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
|
18
|
+
import { readSessionHeaderSummary } from "./sessionFileHeader.js";
|
|
19
|
+
import { countOutOfListingChildren, locateOutOfListingParents } from "./parentSessionLocator.js";
|
|
20
|
+
import { siblingWorkspaceCwds } from "../workspaces/projectWorkspaceCwds.js";
|
|
21
|
+
import { createAskUserToolDefinition } from "./askUserTool.js";
|
|
22
|
+
import { PendingAskStore, renderAskUserAnswersText } from "./pendingAskStore.js";
|
|
23
|
+
import { PendingExtensionDialogStore } from "./pendingExtensionDialogStore.js";
|
|
24
|
+
import { ExtensionDialogWaiters, effectiveExtensionDialogTimeoutMs, extensionDialogCancelValue } from "./extensionDialogWaiters.js";
|
|
25
|
+
import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS } from "../../config.js";
|
|
16
26
|
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
|
|
17
27
|
import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
|
|
18
28
|
import { buildTranscriptView } from "./subsessionTranscript.js";
|
|
19
29
|
import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
|
|
30
|
+
import { SessionNotificationStore, } from "./sessionNotificationStore.js";
|
|
31
|
+
import { plainTextTheme } from "./plainTextTheme.js";
|
|
32
|
+
import { SessionUnreadStore } from "./sessionUnreadStore.js";
|
|
20
33
|
const noopLogger = { info() { } };
|
|
34
|
+
const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
|
|
35
|
+
/**
|
|
36
|
+
* User-facing names for the two phases of session startup PI WEB can prove it
|
|
37
|
+
* is inside: it awaits exactly one call for each, so the phase is a fact rather
|
|
38
|
+
* than a guess. Deliberately free of internal symbol names and file paths.
|
|
39
|
+
*/
|
|
40
|
+
const STARTUP_PHASE_RUNTIME = "Starting the Pi session";
|
|
41
|
+
const STARTUP_PHASE_EXTENSIONS = "Loading session extensions";
|
|
42
|
+
/**
|
|
43
|
+
* Appended to whichever phase is running when a background provider catalog
|
|
44
|
+
* refresh happens to be in flight. It is stated as a concurrent fact, never as
|
|
45
|
+
* the cause: PI WEB can verify that a refresh is running, but not that this
|
|
46
|
+
* particular startup is waiting on it.
|
|
47
|
+
*/
|
|
48
|
+
const STARTUP_CONCURRENT_CATALOG_REFRESH = "provider model lists are refreshing";
|
|
49
|
+
const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
|
|
50
|
+
const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
|
|
21
51
|
function noop() {
|
|
22
52
|
// Intentionally empty default unsubscribe callback.
|
|
23
53
|
}
|
|
@@ -38,6 +68,40 @@ function isPiSessionRef(ref) {
|
|
|
38
68
|
function lookupMatchesActiveSession(ref, active) {
|
|
39
69
|
return !isPiSessionRef(ref) || cwdPathsEqual(active.runtime.cwd, ref.cwd);
|
|
40
70
|
}
|
|
71
|
+
function lookupMatchesStartupSession(ref, session) {
|
|
72
|
+
return !isPiSessionRef(ref) || cwdPathsEqual(session.sessionManager.getCwd(), ref.cwd);
|
|
73
|
+
}
|
|
74
|
+
function sessionTreeNavigationOptions(request) {
|
|
75
|
+
switch (request.summary.mode) {
|
|
76
|
+
case "none":
|
|
77
|
+
return { summarize: false };
|
|
78
|
+
case "default":
|
|
79
|
+
return { summarize: true };
|
|
80
|
+
case "custom": {
|
|
81
|
+
const customInstructions = request.summary.instructions.trim();
|
|
82
|
+
if (customInstructions === "")
|
|
83
|
+
throw new Error("Custom branch-summary instructions are required");
|
|
84
|
+
if (customInstructions.length > SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH) {
|
|
85
|
+
throw new Error(`Custom branch-summary instructions must be at most ${String(SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH)} characters`);
|
|
86
|
+
}
|
|
87
|
+
return { summarize: true, customInstructions };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function decrementWeakCount(counts, key) {
|
|
92
|
+
const remaining = (counts.get(key) ?? 1) - 1;
|
|
93
|
+
if (remaining <= 0)
|
|
94
|
+
counts.delete(key);
|
|
95
|
+
else
|
|
96
|
+
counts.set(key, remaining);
|
|
97
|
+
}
|
|
98
|
+
function decrementMapCount(counts, key) {
|
|
99
|
+
const remaining = (counts.get(key) ?? 1) - 1;
|
|
100
|
+
if (remaining <= 0)
|
|
101
|
+
counts.delete(key);
|
|
102
|
+
else
|
|
103
|
+
counts.set(key, remaining);
|
|
104
|
+
}
|
|
41
105
|
function requirePromptText(value) {
|
|
42
106
|
if (typeof value !== "string")
|
|
43
107
|
throw new Error("Prompt text is required");
|
|
@@ -50,6 +114,8 @@ function parsePromptStreamingBehavior(value) {
|
|
|
50
114
|
return value;
|
|
51
115
|
throw new Error('Prompt streamingBehavior must be "steer" or "followUp"');
|
|
52
116
|
}
|
|
117
|
+
const CLEAR_RUNTIME_NOTIFICATIONS = { kind: "clear", reason: "runtime-close" };
|
|
118
|
+
const DEFER_RUNTIME_NOTIFICATIONS = { kind: "defer" };
|
|
53
119
|
function resourceDiagnosticToWarning(diagnostic, source) {
|
|
54
120
|
return {
|
|
55
121
|
severity: diagnostic.type === "error" ? "error" : "warning",
|
|
@@ -178,19 +244,22 @@ function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, del
|
|
|
178
244
|
});
|
|
179
245
|
};
|
|
180
246
|
}
|
|
181
|
-
export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions) {
|
|
247
|
+
export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, askUser) {
|
|
182
248
|
return [
|
|
183
249
|
createPiWebEditToolDefinition(cwd),
|
|
184
250
|
...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
|
|
185
251
|
...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
|
|
252
|
+
// Asking the user is not delegation: the questions land in the session the
|
|
253
|
+
// user is already watching, so tracked children may ask too.
|
|
254
|
+
...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
|
|
186
255
|
];
|
|
187
256
|
}
|
|
188
|
-
function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions) {
|
|
257
|
+
function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser) {
|
|
189
258
|
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
|
|
190
259
|
const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
|
|
191
260
|
const resolvedDelegationToolsEnabled = delegationToolsEnabled
|
|
192
261
|
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
|
193
|
-
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
|
|
262
|
+
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
|
|
194
263
|
const result = await createAgentSessionFromServices({
|
|
195
264
|
services,
|
|
196
265
|
sessionManager,
|
|
@@ -227,7 +296,24 @@ export class PiSessionService {
|
|
|
227
296
|
this.events = events;
|
|
228
297
|
this.active = new Map();
|
|
229
298
|
this.pendingSessionOpens = new Map();
|
|
299
|
+
/**
|
|
300
|
+
* Sessions whose extension binding is still in flight. A `session_start`
|
|
301
|
+
* dialog parks that window before the session ever becomes active, so this
|
|
302
|
+
* is the only way the dialog answer/cancel and status paths can reach it;
|
|
303
|
+
* {@link getOrOpen} never consults it, keeping every other operation gated
|
|
304
|
+
* on full readiness.
|
|
305
|
+
*/
|
|
306
|
+
this.startupSessions = new Map();
|
|
230
307
|
this.activities = new Map();
|
|
308
|
+
/** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
|
|
309
|
+
this.treeNavigations = new WeakSet();
|
|
310
|
+
/** Counts async operations that may append an entry before they settle. */
|
|
311
|
+
this.sessionEntryMutationCounts = new WeakMap();
|
|
312
|
+
/** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
|
|
313
|
+
this.treeExclusiveRuntimeOperationCounts = new WeakMap();
|
|
314
|
+
this.treeExclusiveSessionOperationCounts = new Map();
|
|
315
|
+
this.deferredSubsessionNotifications = new WeakMap();
|
|
316
|
+
this.deferredGeneratedSessionNames = new WeakMap();
|
|
231
317
|
this.compactionPromptQueues = new Map();
|
|
232
318
|
this.compactionDrainTimers = new Map();
|
|
233
319
|
this.authLossWarnings = new Set();
|
|
@@ -239,19 +325,50 @@ export class PiSessionService {
|
|
|
239
325
|
this.subsessionLinks = new Map();
|
|
240
326
|
/** Parent id/file identities whose persisted links have already been loaded. */
|
|
241
327
|
this.subsessionHydratedParents = new Set();
|
|
328
|
+
/** Session file path -> its parsed header. Headers are written once, so successful reads are cached. */
|
|
329
|
+
this.sessionHeaderCache = new Map();
|
|
242
330
|
/**
|
|
243
331
|
* Tracked subsession id -> whether a completion notification is armed.
|
|
244
332
|
* Armed when the child starts working; firing on completion disarms it so a
|
|
245
333
|
* child that works again (and stops again) notifies the parent each time.
|
|
246
334
|
*/
|
|
247
335
|
this.subsessionNotifyArmed = new Map();
|
|
336
|
+
this.notificationGenerationBySession = new WeakMap();
|
|
337
|
+
/** The parked extension Promise resolvers behind the store's open dialogs. */
|
|
338
|
+
this.dialogWaiters = new ExtensionDialogWaiters();
|
|
339
|
+
this.pendingUnreadMutations = [];
|
|
340
|
+
this.unreadPublicationFlushRequested = false;
|
|
341
|
+
this.unreadPublicationStopped = false;
|
|
342
|
+
/**
|
|
343
|
+
* Read a session file header, memoized per path. Pi writes the header once at
|
|
344
|
+
* session creation, so a successful read stays valid for the process lifetime;
|
|
345
|
+
* failures are not cached so a session file that appears later is picked up.
|
|
346
|
+
*/
|
|
347
|
+
this.readCachedSessionHeader = async (sessionFile) => {
|
|
348
|
+
const cached = this.sessionHeaderCache.get(sessionFile);
|
|
349
|
+
if (cached !== undefined)
|
|
350
|
+
return cached;
|
|
351
|
+
const header = await readSessionHeaderSummary(sessionFile);
|
|
352
|
+
if (header !== undefined)
|
|
353
|
+
this.sessionHeaderCache.set(sessionFile, header);
|
|
354
|
+
return header;
|
|
355
|
+
};
|
|
248
356
|
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
|
249
357
|
this.agentDir = deps.agentDir;
|
|
250
358
|
this.sessionManager = deps.sessionManager;
|
|
251
359
|
this.modelRuntime = deps.modelRuntime;
|
|
252
360
|
this.spawnTargets = deps.spawnTargets;
|
|
361
|
+
this.projectWorkspaces = deps.projectWorkspaces;
|
|
253
362
|
this.logger = deps.logger ?? noopLogger;
|
|
254
363
|
this.now = deps.now ?? (() => new Date());
|
|
364
|
+
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
|
|
365
|
+
this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
|
|
366
|
+
this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
|
|
367
|
+
this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
|
|
368
|
+
this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
|
|
369
|
+
this.catalogRefreshStatus = deps.catalogRefreshStatus;
|
|
370
|
+
this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
|
|
371
|
+
this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
|
|
255
372
|
// Subsessions are a beta capability gated behind their own flag, and they
|
|
256
373
|
// also require the spawn capability (they share its project-scope resolver).
|
|
257
374
|
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
|
|
@@ -260,25 +377,63 @@ export class PiSessionService {
|
|
|
260
377
|
list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
|
|
261
378
|
check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
|
|
262
379
|
read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
|
|
263
|
-
});
|
|
380
|
+
}, deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined);
|
|
264
381
|
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
|
265
382
|
this.workspaceActivity = deps.workspaceActivity;
|
|
266
383
|
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
|
|
267
384
|
this.commandService = new SessionCommandService((sessionId) => this.getActive(sessionId), (sessionId, text) => this.prompt(sessionId, text, undefined, undefined, { echoUserMessage: false }), events, {
|
|
268
385
|
onCompactionStart: (session) => {
|
|
386
|
+
this.beginSessionEntryMutation(session, "compact the session");
|
|
269
387
|
this.publishActivity(session, "compacting", "active");
|
|
270
388
|
this.publishStatus(session);
|
|
271
389
|
},
|
|
272
390
|
onCompactionEnd: (session, result, detail) => {
|
|
391
|
+
this.endSessionEntryMutation(session);
|
|
273
392
|
this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
|
|
274
393
|
this.publishStatus(session);
|
|
275
394
|
},
|
|
276
395
|
reloadSession: (session) => this.reloadSessionRuntime(session),
|
|
396
|
+
getSessionTree: (session) => {
|
|
397
|
+
if (typeof session.sessionManager.getTree !== "function" || typeof session.navigateTree !== "function")
|
|
398
|
+
return undefined;
|
|
399
|
+
return projectSessionTree(session.sessionManager.getTree(), session.sessionManager.getLeafId());
|
|
400
|
+
},
|
|
401
|
+
hasActiveWork: (session) => this.hasActiveWork(session),
|
|
402
|
+
isTreeNavigationActive: (session) => this.treeNavigations.has(session),
|
|
403
|
+
runSessionReplacement: (session, operation) => this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before replacing the session", operation),
|
|
277
404
|
}, { listSessionNames: (cwd) => this.listSessionNames(cwd) });
|
|
278
405
|
}
|
|
279
406
|
activeCount() {
|
|
280
407
|
return this.active.size;
|
|
281
408
|
}
|
|
409
|
+
notificationCatalog() {
|
|
410
|
+
return this.notificationStore.catalogSnapshot();
|
|
411
|
+
}
|
|
412
|
+
async unreadCatalog() {
|
|
413
|
+
await this.publishUnreadMutations([]);
|
|
414
|
+
return this.unreadStore.durableCatalogSnapshot();
|
|
415
|
+
}
|
|
416
|
+
async acknowledgeUnread(sessionId, request) {
|
|
417
|
+
const result = this.unreadStore.acknowledge(sessionId, {
|
|
418
|
+
...request,
|
|
419
|
+
cwd: canonicalizeStoredCwd(request.cwd),
|
|
420
|
+
});
|
|
421
|
+
await this.publishUnreadMutations(result.mutations);
|
|
422
|
+
return this.unreadStore.durableCatalogSnapshot();
|
|
423
|
+
}
|
|
424
|
+
notificationInbox(ref) {
|
|
425
|
+
return this.notificationStore.inboxSnapshot(ref.id, canonicalizeStoredCwd(ref.cwd));
|
|
426
|
+
}
|
|
427
|
+
dismissNotification(ref, request) {
|
|
428
|
+
const result = this.notificationStore.dismissNotification(ref.id, canonicalizeStoredCwd(ref.cwd), request.daemonInstanceId, request.notificationId);
|
|
429
|
+
this.publishNotificationMutations(result.mutations);
|
|
430
|
+
return result.snapshot;
|
|
431
|
+
}
|
|
432
|
+
dismissAllNotifications(ref, request) {
|
|
433
|
+
const result = this.notificationStore.dismissAll(ref.id, canonicalizeStoredCwd(ref.cwd), request.daemonInstanceId, request.throughOrder, request.throughOverflowWatermark);
|
|
434
|
+
this.publishNotificationMutations(result.mutations);
|
|
435
|
+
return result.snapshot;
|
|
436
|
+
}
|
|
282
437
|
async cleanupPreview(request) {
|
|
283
438
|
return previewResponseFromPlan(await this.cleanupPlan(request));
|
|
284
439
|
}
|
|
@@ -296,22 +451,24 @@ export class PiSessionService {
|
|
|
296
451
|
skippedBusySessionIds.add(input.sessionId);
|
|
297
452
|
continue;
|
|
298
453
|
}
|
|
299
|
-
await this.closeActive(input.sessionId);
|
|
454
|
+
await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
|
|
300
455
|
readyArchiveInputs.push(input);
|
|
301
456
|
}
|
|
302
457
|
await this.archiveStoreArchiveMany(readyArchiveInputs);
|
|
303
458
|
archiveInputs.push(...readyArchiveInputs);
|
|
459
|
+
await this.forgetUnreadSessions(readyArchiveInputs);
|
|
304
460
|
for (const record of plan.deleteRecords) {
|
|
305
461
|
if (this.activeSessionHasWork(record.sessionId)) {
|
|
306
462
|
skippedBusySessionIds.add(record.sessionId);
|
|
307
463
|
continue;
|
|
308
464
|
}
|
|
309
|
-
await this.closeActive(record.sessionId);
|
|
465
|
+
await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
|
|
310
466
|
readyDeleteRecords.push(record);
|
|
311
467
|
}
|
|
312
468
|
await this.ensureArchivedRecordsMoved(readyDeleteRecords);
|
|
313
469
|
const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
|
|
314
470
|
deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
|
|
471
|
+
await this.forgetUnreadSessions(deleteRecords);
|
|
315
472
|
return summarizeSessionCleanupExecution({
|
|
316
473
|
archiveInputs,
|
|
317
474
|
deleteRecords,
|
|
@@ -321,14 +478,26 @@ export class PiSessionService {
|
|
|
321
478
|
});
|
|
322
479
|
}
|
|
323
480
|
async dispose() {
|
|
481
|
+
this.unreadPublicationStopped = true;
|
|
482
|
+
this.clearUnreadPublicationRetry();
|
|
324
483
|
clearInterval(this.heartbeat);
|
|
325
484
|
this.clearCompactionDrainTimers();
|
|
485
|
+
// Same startup-park hazard as closeActive(): settle `session_start` dialogs
|
|
486
|
+
// of sessions still binding extensions before awaiting their pending opens.
|
|
487
|
+
for (const sessionId of this.startupSessions.keys())
|
|
488
|
+
this.endSessionExtensionDialogs(sessionId);
|
|
326
489
|
const pendingOpens = this.pendingSessionOpenPromises();
|
|
327
490
|
if (pendingOpens.length > 0)
|
|
328
491
|
await Promise.allSettled(pendingOpens);
|
|
329
492
|
const activeSessions = Array.from(new Set(this.active.values()));
|
|
493
|
+
for (const active of activeSessions) {
|
|
494
|
+
this.forgetUnreadActivity(active.runtime.session);
|
|
495
|
+
this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
|
|
496
|
+
this.endSessionExtensionDialogs(active.runtime.session.sessionId);
|
|
497
|
+
}
|
|
330
498
|
this.active.clear();
|
|
331
499
|
this.pendingSessionOpens.clear();
|
|
500
|
+
this.startupSessions.clear();
|
|
332
501
|
this.activities.clear();
|
|
333
502
|
this.compactionPromptQueues.clear();
|
|
334
503
|
this.authLossWarnings.clear();
|
|
@@ -337,16 +506,20 @@ export class PiSessionService {
|
|
|
337
506
|
this.subsessionLinks.clear();
|
|
338
507
|
this.subsessionHydratedParents.clear();
|
|
339
508
|
this.subsessionNotifyArmed.clear();
|
|
509
|
+
this.sessionHeaderCache.clear();
|
|
510
|
+
this.notificationStore.clearAll("service-dispose");
|
|
340
511
|
await Promise.all(activeSessions.map(async (active) => {
|
|
341
512
|
active.unsubscribe();
|
|
513
|
+
active.runtime.setRebindSession(undefined);
|
|
342
514
|
this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
|
|
343
515
|
try {
|
|
344
|
-
await active.runtime.session
|
|
516
|
+
await this.abortSessionOperations(active.runtime.session);
|
|
345
517
|
}
|
|
346
518
|
finally {
|
|
347
519
|
await active.runtime.dispose();
|
|
348
520
|
}
|
|
349
521
|
}));
|
|
522
|
+
await this.publishUnreadMutations([]);
|
|
350
523
|
}
|
|
351
524
|
async list(cwd) {
|
|
352
525
|
const [sessions, archivedRecords] = await Promise.all([this.sessionManager.list(cwd), this.archiveStore.list()]);
|
|
@@ -355,19 +528,82 @@ export class PiSessionService {
|
|
|
355
528
|
.filter((record) => record.cwd === cwd)
|
|
356
529
|
.map((record) => this.ensureArchivedSessionMoved(record, sessionsById.get(record.sessionId))));
|
|
357
530
|
const archivedById = new Map(archivedForCwd.map((record) => [record.sessionId, record]));
|
|
531
|
+
for (const record of archivedForCwd) {
|
|
532
|
+
this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
|
|
533
|
+
}
|
|
358
534
|
const unarchivedSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry);
|
|
359
|
-
|
|
535
|
+
const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
|
|
536
|
+
this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
|
|
537
|
+
await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
|
|
360
538
|
const archivedSessions = archivedForCwd
|
|
361
539
|
.sort(compareArchivedRecords)
|
|
362
540
|
.map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
|
|
363
541
|
.filter(isDefined);
|
|
364
|
-
return [...unarchivedSessions, ...archivedSessions];
|
|
542
|
+
return await this.withRelatedSessionsElsewhere([...unarchivedSessions, ...archivedSessions], cwd);
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Annotate a listing with the session relationships that cross workspace
|
|
546
|
+
* boundaries: where an out-of-listing parent lives, and how many children a
|
|
547
|
+
* listed session has in sibling workspaces.
|
|
548
|
+
*
|
|
549
|
+
* Both directions are best-effort. An unreadable parent header or a sibling
|
|
550
|
+
* workspace that cannot be listed leaves the session unannotated rather than
|
|
551
|
+
* failing the listing, and the browser falls back to its generic states.
|
|
552
|
+
*/
|
|
553
|
+
async withRelatedSessionsElsewhere(sessions, cwd) {
|
|
554
|
+
const [parentLocations, childCounts] = await Promise.all([
|
|
555
|
+
locateOutOfListingParents(sessions, cwd, this.readCachedSessionHeader),
|
|
556
|
+
this.countChildrenInSiblingWorkspaces(sessions, cwd),
|
|
557
|
+
]);
|
|
558
|
+
return sessions.map((session) => {
|
|
559
|
+
const parent = session.parentSessionPath === undefined ? undefined : parentLocations.get(session.parentSessionPath);
|
|
560
|
+
const childrenElsewhere = childCounts.get(session.path);
|
|
561
|
+
if (parent === undefined && childrenElsewhere === undefined)
|
|
562
|
+
return session;
|
|
563
|
+
const annotated = { ...session };
|
|
564
|
+
if (parent !== undefined) {
|
|
565
|
+
annotated.parentSessionId = parent.parentSessionId;
|
|
566
|
+
annotated.parentSessionCwd = parent.parentSessionCwd;
|
|
567
|
+
}
|
|
568
|
+
if (childrenElsewhere !== undefined)
|
|
569
|
+
annotated.childSessionsElsewhere = childrenElsewhere;
|
|
570
|
+
return annotated;
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Count children of the listed sessions that live in other workspaces of the
|
|
575
|
+
* same project.
|
|
576
|
+
*
|
|
577
|
+
* Only sibling workspaces are scanned: agents may only spawn into workspaces
|
|
578
|
+
* of the spawning session's own project, so that bounds where a child can be.
|
|
579
|
+
* Listing is skipped entirely when no project-workspace locator is configured
|
|
580
|
+
* or the cwd belongs to no registered project.
|
|
581
|
+
*/
|
|
582
|
+
async countChildrenInSiblingWorkspaces(sessions, cwd) {
|
|
583
|
+
if (this.projectWorkspaces === undefined || sessions.length === 0)
|
|
584
|
+
return new Map();
|
|
585
|
+
try {
|
|
586
|
+
const siblingCwds = await siblingWorkspaceCwds(this.projectWorkspaces, cwd);
|
|
587
|
+
if (siblingCwds.length === 0)
|
|
588
|
+
return new Map();
|
|
589
|
+
const listings = await Promise.all(siblingCwds.map(async (siblingCwd) => {
|
|
590
|
+
const entries = await this.sessionManager.list(siblingCwd);
|
|
591
|
+
return entries.flatMap((entry) => entry.parentSessionPath === undefined ? [] : [entry.parentSessionPath]);
|
|
592
|
+
}));
|
|
593
|
+
return countOutOfListingChildren(sessions, listings.flat());
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
this.logger.info({ cwd, error: error instanceof Error ? error.message : String(error) }, "failed to count child sessions in sibling workspaces");
|
|
597
|
+
return new Map();
|
|
598
|
+
}
|
|
365
599
|
}
|
|
366
600
|
async start(cwd, options = {}) {
|
|
367
601
|
return this.startSession(cwd, options);
|
|
368
602
|
}
|
|
369
603
|
async startSession(cwd, options) {
|
|
370
604
|
const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
|
|
605
|
+
startupIntent: "create",
|
|
606
|
+
...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
|
|
371
607
|
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
|
372
608
|
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
|
|
373
609
|
});
|
|
@@ -431,13 +667,224 @@ export class PiSessionService {
|
|
|
431
667
|
...(parentSessionFile === undefined ? {} : { parentSessionFile }),
|
|
432
668
|
cwd: decision.cwd,
|
|
433
669
|
};
|
|
434
|
-
this.registerVerifiedSubsession(link);
|
|
670
|
+
await this.registerVerifiedSubsession(link);
|
|
435
671
|
this.persistSubsessionLink(link);
|
|
436
672
|
this.persistSubsessionChildMarker(input.parentSessionId, created.id);
|
|
437
673
|
await this.prompt(created.id, input.prompt);
|
|
438
674
|
this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
|
|
439
675
|
return { sessionId: created.id, cwd: decision.cwd };
|
|
440
676
|
}
|
|
677
|
+
/**
|
|
678
|
+
* Register the question set an agent wants the user to answer as the session's
|
|
679
|
+
* open ask. Deliberately does not wait for the user: `ask_user` terminates the
|
|
680
|
+
* run and the submitted answers come back later as a follow-up message.
|
|
681
|
+
*
|
|
682
|
+
* Rejected question sets throw {@link PendingAskValidationError}, which the
|
|
683
|
+
* agent loop reports to the model as an error tool result.
|
|
684
|
+
*/
|
|
685
|
+
// eslint-disable-next-line @typescript-eslint/require-await -- async so a rejected question set becomes a rejection rather than a synchronous throw from a promise-returning method.
|
|
686
|
+
async openAsk(input) {
|
|
687
|
+
const result = this.pendingAskStore.open(input);
|
|
688
|
+
// A supersede closes the earlier ask, so the browsers watching it must hear
|
|
689
|
+
// that before they hear about its replacement.
|
|
690
|
+
if (result.superseded !== undefined)
|
|
691
|
+
this.publishAskClosed(input.sessionId, result.superseded);
|
|
692
|
+
this.events.publish(input.sessionId, { type: "ask.opened", ask: result.ask });
|
|
693
|
+
this.publishStatusForSessionId(input.sessionId);
|
|
694
|
+
return result;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Record the user's answers to the session's open ask and hand them to the
|
|
698
|
+
* model. The answers travel as a system-authored custom message rather than a
|
|
699
|
+
* user message, so they are not attributed to the human in the transcript;
|
|
700
|
+
* they still wake an idle session (`triggerTurn`) and queue behind in-flight
|
|
701
|
+
* work (`deliverAs: "followUp"`), which is how the run that `ask_user`
|
|
702
|
+
* terminated continues.
|
|
703
|
+
*/
|
|
704
|
+
async submitAsk(ref, askId, submission) {
|
|
705
|
+
await this.assertWritable(ref);
|
|
706
|
+
const session = await this.getOrOpen(ref);
|
|
707
|
+
// Checked before the store closes the ask so a refused delivery cannot
|
|
708
|
+
// discard answers the user already submitted.
|
|
709
|
+
this.assertTreeNavigationInactive(session, "answer questions");
|
|
710
|
+
return this.closeAsk(session, this.pendingAskStore.submit(session.sessionId, askId, submission));
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Close the open ask without answers. The model is still told, naming every
|
|
714
|
+
* question as unanswered: it was promised a follow-up message and would
|
|
715
|
+
* otherwise wait for one that never comes.
|
|
716
|
+
*/
|
|
717
|
+
async cancelAsk(ref, askId) {
|
|
718
|
+
await this.assertWritable(ref);
|
|
719
|
+
const session = await this.getOrOpen(ref);
|
|
720
|
+
this.assertTreeNavigationInactive(session, "dismiss questions");
|
|
721
|
+
return this.closeAsk(session, this.pendingAskStore.cancel(session.sessionId, askId));
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Publish and deliver a closed ask. A stale close is reported rather than
|
|
725
|
+
* thrown: losing the race against a supersede, another browser, or a session
|
|
726
|
+
* that went away is ordinary, and the returned status tells the browser what
|
|
727
|
+
* the session's open ask is now.
|
|
728
|
+
*/
|
|
729
|
+
async closeAsk(session, result) {
|
|
730
|
+
if (result.status === "stale")
|
|
731
|
+
return { result: "stale", sessionStatus: this.statusFromSession(session) };
|
|
732
|
+
const { outcome } = result;
|
|
733
|
+
this.publishAskClosed(session.sessionId, outcome);
|
|
734
|
+
await this.runSessionEntryMutation(session, "deliver answers to your questions", () => session.sendCustomMessage({ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, { triggerTurn: true, deliverAs: "followUp" }));
|
|
735
|
+
this.publishStatus(session);
|
|
736
|
+
return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
|
|
737
|
+
}
|
|
738
|
+
publishAskClosed(sessionId, outcome) {
|
|
739
|
+
this.events.publish(sessionId, { type: "ask.closed", askId: outcome.askId, reason: outcome.reason });
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Void the session's open ask because the user sent a chat message instead of
|
|
743
|
+
* answering it. Every browser closes the card as cancelled, and the model is
|
|
744
|
+
* told — without being woken — so the notice rides into the turn the message
|
|
745
|
+
* itself triggers rather than becoming a turn of its own.
|
|
746
|
+
*/
|
|
747
|
+
async voidOpenAskForUserMessage(session) {
|
|
748
|
+
const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
|
|
749
|
+
if (outcome === undefined)
|
|
750
|
+
return;
|
|
751
|
+
this.publishAskClosed(session.sessionId, outcome);
|
|
752
|
+
await this.runSessionEntryMutation(session, "void the open questions", () => session.sendCustomMessage({ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, { triggerTurn: false, deliverAs: "followUp" }));
|
|
753
|
+
this.publishStatus(session);
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Record the user's answer to an open extension dialog and resolve the
|
|
757
|
+
* extension's parked Promise with it. Unlike an ask, nothing is delivered to
|
|
758
|
+
* the model: the waiter is extension code inside an already in-flight run
|
|
759
|
+
* (or an idle handler), so no custom message and no turn are triggered.
|
|
760
|
+
*/
|
|
761
|
+
async answerDialog(ref, dialogId, value) {
|
|
762
|
+
await this.assertWritable(ref);
|
|
763
|
+
const session = await this.sessionForStatusOrDialogClose(ref);
|
|
764
|
+
const result = this.pendingExtensionDialogStore.answer(session.sessionId, dialogId, value);
|
|
765
|
+
if (result.status === "stale")
|
|
766
|
+
return { result: "stale", sessionStatus: this.statusFromSession(session) };
|
|
767
|
+
const { outcome } = result;
|
|
768
|
+
this.publishDialogClosed(session.sessionId, outcome);
|
|
769
|
+
// `value` is what the store validated and recorded as the outcome's answer.
|
|
770
|
+
this.dialogWaiters.settleWithAnswer(dialogId, value);
|
|
771
|
+
this.publishStatus(session);
|
|
772
|
+
return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
|
|
773
|
+
}
|
|
774
|
+
/** Close an open extension dialog without an answer; the extension's wait settles with its kind's cancel value. */
|
|
775
|
+
async cancelDialog(ref, dialogId) {
|
|
776
|
+
await this.assertWritable(ref);
|
|
777
|
+
const session = await this.sessionForStatusOrDialogClose(ref);
|
|
778
|
+
const result = this.pendingExtensionDialogStore.cancel(session.sessionId, dialogId, "cancelled");
|
|
779
|
+
if (result.status === "stale")
|
|
780
|
+
return { result: "stale", sessionStatus: this.statusFromSession(session) };
|
|
781
|
+
const { outcome } = result;
|
|
782
|
+
this.publishDialogClosed(session.sessionId, outcome);
|
|
783
|
+
this.dialogWaiters.settleWithCancelValue(dialogId);
|
|
784
|
+
this.publishStatus(session);
|
|
785
|
+
return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Implement one `ctx.ui.select()`/`confirm()`/`input()` call from extension
|
|
789
|
+
* code: open the store record, tell the browsers, and park a Promise that
|
|
790
|
+
* settles when the browser answers or cancels, the extension's own
|
|
791
|
+
* `signal`/`timeout` dismisses the dialog, the daemon default timeout
|
|
792
|
+
* elapses, or the runtime goes away. `store.open` validates the dialog, so a
|
|
793
|
+
* malformed one rejects the extension's call rather than rendering garbage.
|
|
794
|
+
* `async` so a rejected dialog becomes a rejection rather than a synchronous
|
|
795
|
+
* throw from a promise-returning method.
|
|
796
|
+
*/
|
|
797
|
+
async openExtensionDialog(session, request, opts) {
|
|
798
|
+
const signal = opts?.signal;
|
|
799
|
+
// A pre-aborted signal dismisses the dialog before it ever opens.
|
|
800
|
+
if (signal?.aborted === true)
|
|
801
|
+
return extensionDialogCancelValue(request.kind);
|
|
802
|
+
const timeoutMs = effectiveExtensionDialogTimeoutMs(opts?.timeout, this.extensionDialogsTimeoutMs);
|
|
803
|
+
const dialog = this.pendingExtensionDialogStore.open({
|
|
804
|
+
sessionId: session.sessionId,
|
|
805
|
+
kind: request.kind,
|
|
806
|
+
title: request.title,
|
|
807
|
+
...(request.message === undefined ? {} : { message: request.message }),
|
|
808
|
+
...(request.options === undefined ? {} : { options: request.options }),
|
|
809
|
+
...(request.placeholder === undefined ? {} : { placeholder: request.placeholder }),
|
|
810
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
811
|
+
runScoped: session.isStreaming,
|
|
812
|
+
});
|
|
813
|
+
this.events.publish(session.sessionId, { type: "dialog.opened", dialog });
|
|
814
|
+
this.publishStatus(session);
|
|
815
|
+
return this.dialogWaiters.park(dialog, {
|
|
816
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
817
|
+
...(signal === undefined ? {} : { signal }),
|
|
818
|
+
onTrigger: (reason) => {
|
|
819
|
+
if (this.closeExtensionDialogFromTrigger(session.sessionId, dialog.dialogId, reason))
|
|
820
|
+
this.publishStatusForSessionId(session.sessionId);
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Close a dialog whose wait ended without the browser (timeout, signal
|
|
826
|
+
* abort, run end, runtime teardown) and settle its parked Promise. Returns
|
|
827
|
+
* whether this call closed the dialog; a stale close means a browser answer
|
|
828
|
+
* or an earlier trigger already settled everything.
|
|
829
|
+
*/
|
|
830
|
+
closeExtensionDialogFromTrigger(sessionId, dialogId, reason) {
|
|
831
|
+
const result = this.pendingExtensionDialogStore.cancel(sessionId, dialogId, reason);
|
|
832
|
+
if (result.status !== "closed")
|
|
833
|
+
return false;
|
|
834
|
+
this.publishDialogClosed(sessionId, result.outcome);
|
|
835
|
+
this.dialogWaiters.settleWithCancelValue(dialogId);
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Settle the session's run-scoped dialogs as `"aborted"`. Runs at
|
|
840
|
+
* abort-request time (a user abort parks the agent loop behind the dialog
|
|
841
|
+
* handler, so `agent_end` would never arrive on its own) and again from
|
|
842
|
+
* the `agent_end` observer as the run-crash backstop — the store makes the
|
|
843
|
+
* second settlement a stale no-op. Idle-opened dialogs (a `session_start`
|
|
844
|
+
* probe, say) are not run-scoped and survive, because their waiter
|
|
845
|
+
* outlives the run.
|
|
846
|
+
*/
|
|
847
|
+
abortRunScopedExtensionDialogs(sessionId) {
|
|
848
|
+
let closedAny = false;
|
|
849
|
+
for (const dialog of this.pendingExtensionDialogStore.pendingDialogs(sessionId)) {
|
|
850
|
+
if (dialog.runScoped)
|
|
851
|
+
closedAny = this.closeExtensionDialogFromTrigger(sessionId, dialog.dialogId, "aborted") || closedAny;
|
|
852
|
+
}
|
|
853
|
+
if (closedAny)
|
|
854
|
+
this.publishStatusForSessionId(sessionId);
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Settle every dialog of the session as `"session-ended"`: the runtime
|
|
858
|
+
* whose extension code is parked on them is being closed, replaced, or
|
|
859
|
+
* disposed, so those Promises would otherwise never settle.
|
|
860
|
+
*/
|
|
861
|
+
endSessionExtensionDialogs(sessionId) {
|
|
862
|
+
let closedAny = false;
|
|
863
|
+
for (const dialog of this.pendingExtensionDialogStore.pendingDialogs(sessionId)) {
|
|
864
|
+
closedAny = this.closeExtensionDialogFromTrigger(sessionId, dialog.dialogId, "session-ended") || closedAny;
|
|
865
|
+
}
|
|
866
|
+
// Publishes only while the session is still (or already re-)registered as
|
|
867
|
+
// active, so teardown paths stay silent and runtime replacement refreshes.
|
|
868
|
+
if (closedAny)
|
|
869
|
+
this.publishStatusForSessionId(sessionId);
|
|
870
|
+
}
|
|
871
|
+
publishDialogClosed(sessionId, outcome) {
|
|
872
|
+
this.events.publish(sessionId, {
|
|
873
|
+
type: "dialog.closed",
|
|
874
|
+
dialogId: outcome.dialogId,
|
|
875
|
+
reason: outcome.reason,
|
|
876
|
+
...(outcome.answer === undefined ? {} : { answer: outcome.answer }),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Publish status for a session known only by id, as the ask tools are: they
|
|
881
|
+
* run inside the session's own runtime, so the active entry is the session.
|
|
882
|
+
*/
|
|
883
|
+
publishStatusForSessionId(sessionId) {
|
|
884
|
+
const session = this.active.get(sessionId)?.runtime.session;
|
|
885
|
+
if (session !== undefined)
|
|
886
|
+
this.publishStatus(session);
|
|
887
|
+
}
|
|
441
888
|
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */
|
|
442
889
|
async listSubsessions(parentSessionId, parentSessionFile) {
|
|
443
890
|
const parentFile = nonEmptyString(parentSessionFile);
|
|
@@ -506,7 +953,7 @@ export class PiSessionService {
|
|
|
506
953
|
return undefined;
|
|
507
954
|
return sessionFileMatches(session, link.childSessionFile) ? link : undefined;
|
|
508
955
|
}
|
|
509
|
-
registerVerifiedSubsession(link) {
|
|
956
|
+
async registerVerifiedSubsession(link) {
|
|
510
957
|
const { childSessionId, parentSessionId } = link;
|
|
511
958
|
const previousParentId = this.subsessionParents.get(childSessionId);
|
|
512
959
|
if (previousParentId !== undefined && previousParentId !== parentSessionId) {
|
|
@@ -522,6 +969,24 @@ export class PiSessionService {
|
|
|
522
969
|
this.subsessionLinks.set(childSessionId, link);
|
|
523
970
|
if (!this.subsessionNotifyArmed.has(childSessionId))
|
|
524
971
|
this.subsessionNotifyArmed.set(childSessionId, false);
|
|
972
|
+
const cwd = this.cwdForVerifiedSubsession(link);
|
|
973
|
+
await this.publishUnreadMutations(this.unreadStore.excludeSession(childSessionId, cwd));
|
|
974
|
+
}
|
|
975
|
+
cwdForVerifiedSubsession(link) {
|
|
976
|
+
const activeCwd = this.activeChildForSubsessionLink(link)?.runtime.session.sessionManager.getCwd();
|
|
977
|
+
const linkedCwd = nonEmptyString(activeCwd) ?? nonEmptyString(link.cwd);
|
|
978
|
+
if (linkedCwd !== undefined)
|
|
979
|
+
return canonicalizeStoredCwd(linkedCwd);
|
|
980
|
+
const childSessionFile = link.childSessionFile;
|
|
981
|
+
if (childSessionFile !== undefined) {
|
|
982
|
+
try {
|
|
983
|
+
return canonicalizeStoredCwd(this.sessionManager.open(childSessionFile).getCwd());
|
|
984
|
+
}
|
|
985
|
+
catch (error) {
|
|
986
|
+
throw new Error("Could not resolve cwd for verified tracked sub-session", { cause: error });
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
throw new Error("Could not resolve cwd for verified tracked sub-session");
|
|
525
990
|
}
|
|
526
991
|
unregisterSubsession(childSessionId) {
|
|
527
992
|
const parentSessionId = this.subsessionParents.get(childSessionId);
|
|
@@ -568,40 +1033,44 @@ export class PiSessionService {
|
|
|
568
1033
|
const activeParent = this.active.get(parentSessionId);
|
|
569
1034
|
if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) {
|
|
570
1035
|
const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile);
|
|
571
|
-
await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
|
|
572
|
-
|
|
1036
|
+
const complete = await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
|
|
1037
|
+
if (complete)
|
|
1038
|
+
this.subsessionHydratedParents.add(hydrationKey);
|
|
573
1039
|
return;
|
|
574
1040
|
}
|
|
575
1041
|
if (parentSessionFile === undefined)
|
|
576
1042
|
return;
|
|
577
|
-
if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId)
|
|
578
|
-
this.subsessionHydratedParents.add(hydrationKey);
|
|
1043
|
+
if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId)
|
|
579
1044
|
return;
|
|
580
|
-
}
|
|
581
1045
|
let parentManager;
|
|
582
1046
|
try {
|
|
583
1047
|
parentManager = this.sessionManager.open(parentSessionFile);
|
|
584
1048
|
}
|
|
585
1049
|
catch {
|
|
586
|
-
this.subsessionHydratedParents.add(hydrationKey);
|
|
587
1050
|
return;
|
|
588
1051
|
}
|
|
589
|
-
await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
|
|
590
|
-
|
|
1052
|
+
const complete = await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
|
|
1053
|
+
if (complete)
|
|
1054
|
+
this.subsessionHydratedParents.add(hydrationKey);
|
|
591
1055
|
}
|
|
592
1056
|
async registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile) {
|
|
593
1057
|
// Parent custom links are the authoritative recovery record: verify the
|
|
594
|
-
// exact live child file/header before tracking.
|
|
1058
|
+
// exact live child file/header before tracking. Do not negatively cache a
|
|
1059
|
+
// scan while a candidate child is temporarily unavailable.
|
|
595
1060
|
const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
|
|
1061
|
+
let complete = true;
|
|
596
1062
|
for (const entry of entries) {
|
|
597
1063
|
const link = parsePersistedParentSubsessionLink(entry);
|
|
598
|
-
if (link
|
|
1064
|
+
if (link?.spawnedBySessionId !== parentSessionId)
|
|
599
1065
|
continue;
|
|
600
1066
|
const verified = await this.verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link);
|
|
601
|
-
if (verified === undefined)
|
|
1067
|
+
if (verified === undefined) {
|
|
1068
|
+
complete = false;
|
|
602
1069
|
continue;
|
|
603
|
-
|
|
1070
|
+
}
|
|
1071
|
+
await this.registerVerifiedSubsession(verified);
|
|
604
1072
|
}
|
|
1073
|
+
return complete;
|
|
605
1074
|
}
|
|
606
1075
|
async verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link) {
|
|
607
1076
|
if (parentSessionFile === undefined)
|
|
@@ -620,7 +1089,7 @@ export class PiSessionService {
|
|
|
620
1089
|
const link = await this.verifiedSubsessionLinkFromOpenedChild(session);
|
|
621
1090
|
if (link === undefined)
|
|
622
1091
|
return;
|
|
623
|
-
this.registerVerifiedSubsession(link);
|
|
1092
|
+
await this.registerVerifiedSubsession(link);
|
|
624
1093
|
}
|
|
625
1094
|
verifiedSubsessionLinkFromOpenedChild(session) {
|
|
626
1095
|
return verifiedTrackedSubsessionLink(this.sessionManager, {
|
|
@@ -729,19 +1198,31 @@ export class PiSessionService {
|
|
|
729
1198
|
async notifyParentOfSubsession(parentId, childId, text) {
|
|
730
1199
|
try {
|
|
731
1200
|
const session = await this.getOrOpenParentForSubsession(parentId, childId);
|
|
732
|
-
|
|
733
|
-
|
|
1201
|
+
if (this.treeNavigations.has(session)) {
|
|
1202
|
+
const pending = this.deferredSubsessionNotifications.get(session) ?? [];
|
|
1203
|
+
pending.push({ parentId, childId, text });
|
|
1204
|
+
this.deferredSubsessionNotifications.set(session, pending);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
await this.deliverSubsessionNotification(session, { parentId, childId, text });
|
|
734
1208
|
}
|
|
735
1209
|
catch (error) {
|
|
736
|
-
this.
|
|
1210
|
+
this.logSubsessionNotificationFailure(parentId, childId, error);
|
|
737
1211
|
}
|
|
738
1212
|
}
|
|
1213
|
+
async deliverSubsessionNotification(session, notification) {
|
|
1214
|
+
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" }));
|
|
1215
|
+
this.publishStatus(session);
|
|
1216
|
+
}
|
|
1217
|
+
logSubsessionNotificationFailure(parentId, childId, error) {
|
|
1218
|
+
this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
|
|
1219
|
+
}
|
|
739
1220
|
async messages(ref, page) {
|
|
740
1221
|
const session = await this.getOrOpen(ref);
|
|
741
1222
|
return pageMessagesAtSafeBoundary(historyMessages(session), page);
|
|
742
1223
|
}
|
|
743
1224
|
async status(ref) {
|
|
744
|
-
return this.statusFromSession(await this.
|
|
1225
|
+
return this.statusFromSession(await this.sessionForStatusOrDialogClose(ref));
|
|
745
1226
|
}
|
|
746
1227
|
/**
|
|
747
1228
|
* Join-time snapshot of the in-flight assistant stream. The `seq` watermark and
|
|
@@ -764,7 +1245,7 @@ export class PiSessionService {
|
|
|
764
1245
|
}
|
|
765
1246
|
async availableModels(ref) {
|
|
766
1247
|
const session = await this.getOrOpen(ref);
|
|
767
|
-
await session.modelRuntime.
|
|
1248
|
+
await session.modelRuntime.refresh();
|
|
768
1249
|
const models = session.scopedModels.length > 0
|
|
769
1250
|
? session.scopedModels.map((scoped) => scoped.model)
|
|
770
1251
|
: session.modelRuntime.getAvailableSnapshot();
|
|
@@ -773,7 +1254,9 @@ export class PiSessionService {
|
|
|
773
1254
|
async setModel(ref, provider, modelId) {
|
|
774
1255
|
await this.assertWritable(ref);
|
|
775
1256
|
const session = await this.getOrOpen(ref);
|
|
776
|
-
|
|
1257
|
+
this.assertTreeNavigationInactive(session, "change models");
|
|
1258
|
+
await session.modelRuntime.refresh();
|
|
1259
|
+
this.assertTreeNavigationInactive(session, "change models");
|
|
777
1260
|
const candidates = session.scopedModels.length > 0
|
|
778
1261
|
? session.scopedModels.map((scoped) => scoped.model)
|
|
779
1262
|
: session.modelRuntime.getAvailableSnapshot();
|
|
@@ -781,7 +1264,7 @@ export class PiSessionService {
|
|
|
781
1264
|
?? session.modelRuntime.getModel(provider, modelId);
|
|
782
1265
|
if (model === undefined)
|
|
783
1266
|
throw new Error(`Model not found: ${provider}/${modelId}`);
|
|
784
|
-
await session.setModel(model);
|
|
1267
|
+
await this.runSessionEntryMutation(session, "change models", () => session.setModel(model));
|
|
785
1268
|
this.publishActivity(session, `model: ${model.id}`, "idle", model.provider);
|
|
786
1269
|
this.publishStatus(session);
|
|
787
1270
|
return this.statusFromSession(session);
|
|
@@ -789,7 +1272,7 @@ export class PiSessionService {
|
|
|
789
1272
|
async cycleModel(ref, direction) {
|
|
790
1273
|
await this.assertWritable(ref);
|
|
791
1274
|
const session = await this.getOrOpen(ref);
|
|
792
|
-
const result = await session.cycleModel(direction);
|
|
1275
|
+
const result = await this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
|
|
793
1276
|
if (result === undefined)
|
|
794
1277
|
throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
|
|
795
1278
|
this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
|
|
@@ -803,6 +1286,7 @@ export class PiSessionService {
|
|
|
803
1286
|
async setThinkingLevel(ref, level) {
|
|
804
1287
|
await this.assertWritable(ref);
|
|
805
1288
|
const session = await this.getOrOpen(ref);
|
|
1289
|
+
this.assertTreeNavigationInactive(session, "change the thinking level");
|
|
806
1290
|
// pi owns the valid set; validate against the session's live levels rather
|
|
807
1291
|
// than a hardcoded union so this stays correct if pi changes the set.
|
|
808
1292
|
const available = session.getAvailableThinkingLevels();
|
|
@@ -817,6 +1301,7 @@ export class PiSessionService {
|
|
|
817
1301
|
async cycleThinkingLevel(ref) {
|
|
818
1302
|
await this.assertWritable(ref);
|
|
819
1303
|
const session = await this.getOrOpen(ref);
|
|
1304
|
+
this.assertTreeNavigationInactive(session, "change the thinking level");
|
|
820
1305
|
const level = session.cycleThinkingLevel();
|
|
821
1306
|
if (level === undefined)
|
|
822
1307
|
throw new Error("Current model does not support thinking");
|
|
@@ -850,6 +1335,7 @@ export class PiSessionService {
|
|
|
850
1335
|
const images = (await attachmentsToInlineImages(parsedAttachments)).map((entry) => entry.image);
|
|
851
1336
|
await this.assertWritable(ref);
|
|
852
1337
|
const session = await this.getOrOpen(ref);
|
|
1338
|
+
this.assertTreeNavigationInactive(session, "send a prompt");
|
|
853
1339
|
this.maybeGenerateSessionName(session, promptText);
|
|
854
1340
|
const isQueued = session.isStreaming || session.isCompacting;
|
|
855
1341
|
const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined;
|
|
@@ -858,6 +1344,11 @@ export class PiSessionService {
|
|
|
858
1344
|
this.publishStatus(session);
|
|
859
1345
|
return;
|
|
860
1346
|
}
|
|
1347
|
+
// A chat message answers the session's open ask in the user's own words, so
|
|
1348
|
+
// the form is void: keeping it open would invite answers to questions the
|
|
1349
|
+
// conversation has already moved past. Ignored duplicates skip this on
|
|
1350
|
+
// purpose: they must not void an ask posted after the queued original.
|
|
1351
|
+
await this.voidOpenAskForUserMessage(session);
|
|
861
1352
|
if (session.isCompacting) {
|
|
862
1353
|
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
|
|
863
1354
|
return;
|
|
@@ -869,7 +1360,7 @@ export class PiSessionService {
|
|
|
869
1360
|
if (behavior === undefined && echoUserMessage)
|
|
870
1361
|
this.events.publish(session.sessionId, { type: "message.append", message: userMessage(text, images) });
|
|
871
1362
|
const promptOptions = buildPromptOptions(behavior, images);
|
|
872
|
-
const promptPromise = session.prompt(text, promptOptions).catch((error) => {
|
|
1363
|
+
const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => session.prompt(text, promptOptions)).catch((error) => {
|
|
873
1364
|
const message = error instanceof Error ? error.message : String(error);
|
|
874
1365
|
this.publishActivity(session, "error", "error", message);
|
|
875
1366
|
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
@@ -896,6 +1387,7 @@ export class PiSessionService {
|
|
|
896
1387
|
await this.assertWritable(ref);
|
|
897
1388
|
const active = await this.getActive(ref);
|
|
898
1389
|
const { session } = active.runtime;
|
|
1390
|
+
this.assertTreeNavigationInactive(session, "run a shell command");
|
|
899
1391
|
const isExcluded = text.startsWith("!!");
|
|
900
1392
|
const command = (isExcluded ? text.slice(2) : text.slice(1)).trim();
|
|
901
1393
|
if (!command)
|
|
@@ -904,11 +1396,11 @@ export class PiSessionService {
|
|
|
904
1396
|
throw new Error("A bash command is already running");
|
|
905
1397
|
this.publishActivity(session, "running bash", "active", command);
|
|
906
1398
|
this.events.publish(session.sessionId, { type: "shell.start", command, excludeFromContext: isExcluded });
|
|
907
|
-
void session.executeBash(command, (chunk) => {
|
|
1399
|
+
void this.runSessionEntryMutation(session, "run a shell command", () => session.executeBash(command, (chunk) => {
|
|
908
1400
|
this.events.publish(session.sessionId, { type: "shell.chunk", chunk });
|
|
909
1401
|
this.publishActivity(session, "running bash", "active", command);
|
|
910
1402
|
this.publishStatus(session);
|
|
911
|
-
}, { excludeFromContext: isExcluded }).then((result) => {
|
|
1403
|
+
}, { excludeFromContext: isExcluded })).then((result) => {
|
|
912
1404
|
this.events.publish(session.sessionId, {
|
|
913
1405
|
type: "shell.end",
|
|
914
1406
|
output: result.output,
|
|
@@ -937,30 +1429,103 @@ export class PiSessionService {
|
|
|
937
1429
|
const active = await this.getActive(ref);
|
|
938
1430
|
return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
|
|
939
1431
|
}
|
|
940
|
-
async
|
|
1432
|
+
async navigateTree(ref, request) {
|
|
1433
|
+
if (request.targetId.trim() === "")
|
|
1434
|
+
throw new Error("Session tree target is required");
|
|
1435
|
+
if (this.isTreeExclusiveSessionIdentityActive(sessionIdFromLookup(ref))) {
|
|
1436
|
+
throw new Error("Stop current session activity before navigating the session tree");
|
|
1437
|
+
}
|
|
1438
|
+
await this.assertWritable(ref);
|
|
1439
|
+
const options = sessionTreeNavigationOptions(request);
|
|
1440
|
+
const session = await this.getOrOpen(ref);
|
|
1441
|
+
if (typeof session.navigateTree !== "function")
|
|
1442
|
+
throw new Error("Session tree navigation is not supported by this Pi runtime");
|
|
941
1443
|
if (this.hasActiveWork(session))
|
|
942
|
-
throw new Error("Stop current session activity before
|
|
943
|
-
|
|
1444
|
+
throw new Error("Stop current session activity before navigating the session tree");
|
|
1445
|
+
// Acquire synchronously after the active-work check. No leaf-producing work
|
|
1446
|
+
// may enter this runtime until Pi's potentially asynchronous summary settles.
|
|
1447
|
+
this.treeNavigations.add(session);
|
|
944
1448
|
try {
|
|
945
|
-
|
|
946
|
-
|
|
1449
|
+
if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
|
|
1450
|
+
throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
|
|
1451
|
+
}
|
|
1452
|
+
this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
|
|
947
1453
|
this.publishStatus(session);
|
|
1454
|
+
const result = await session.navigateTree(request.targetId, options);
|
|
1455
|
+
if (result.cancelled) {
|
|
1456
|
+
if (this.isCurrentActiveSession(session)) {
|
|
1457
|
+
this.publishActivity(session, result.aborted === true ? "branch summary aborted" : "tree navigation cancelled", "idle");
|
|
1458
|
+
}
|
|
1459
|
+
return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
|
|
1460
|
+
}
|
|
1461
|
+
if (this.isCurrentActiveSession(session))
|
|
1462
|
+
this.publishActivity(session, "session tree navigated", "idle");
|
|
1463
|
+
return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
|
|
948
1464
|
}
|
|
949
1465
|
catch (error) {
|
|
950
1466
|
const message = error instanceof Error ? error.message : String(error);
|
|
951
|
-
this.
|
|
952
|
-
|
|
953
|
-
|
|
1467
|
+
if (this.isCurrentActiveSession(session)) {
|
|
1468
|
+
this.publishActivity(session, "tree navigation failed", "error", message);
|
|
1469
|
+
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
1470
|
+
}
|
|
954
1471
|
throw error;
|
|
955
1472
|
}
|
|
1473
|
+
finally {
|
|
1474
|
+
this.treeNavigations.delete(session);
|
|
1475
|
+
if (this.isCurrentActiveSession(session)) {
|
|
1476
|
+
this.flushDeferredTreeNavigationWork(session);
|
|
1477
|
+
this.publishStatus(session);
|
|
1478
|
+
}
|
|
1479
|
+
else {
|
|
1480
|
+
this.deferredGeneratedSessionNames.delete(session);
|
|
1481
|
+
this.deferredSubsessionNotifications.delete(session);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
async reloadSessionRuntime(session) {
|
|
1486
|
+
if (this.hasActiveWork(session))
|
|
1487
|
+
throw new Error("Stop current session activity before reloading");
|
|
1488
|
+
await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before reloading", async () => {
|
|
1489
|
+
this.publishActivity(session, "reloading resources", "active");
|
|
1490
|
+
const priorGeneration = this.notificationGenerationBySession.get(session);
|
|
1491
|
+
let candidateGeneration;
|
|
1492
|
+
try {
|
|
1493
|
+
await session.reload(priorGeneration === undefined ? undefined : {
|
|
1494
|
+
beforeSessionStart: () => {
|
|
1495
|
+
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
|
|
1496
|
+
this.notificationGenerationBySession.set(session, candidateGeneration);
|
|
1497
|
+
this.replaceSessionNotificationContext(session, candidateGeneration);
|
|
1498
|
+
},
|
|
1499
|
+
});
|
|
1500
|
+
if (candidateGeneration !== undefined) {
|
|
1501
|
+
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
|
|
1502
|
+
}
|
|
1503
|
+
this.publishActivity(session, "resources reloaded", "idle");
|
|
1504
|
+
this.publishStatus(session);
|
|
1505
|
+
}
|
|
1506
|
+
catch (error) {
|
|
1507
|
+
if (candidateGeneration !== undefined) {
|
|
1508
|
+
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
|
|
1509
|
+
this.notificationGenerationBySession.set(session, candidateGeneration);
|
|
1510
|
+
}
|
|
1511
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1512
|
+
this.publishActivity(session, "reload failed", "error", message);
|
|
1513
|
+
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
1514
|
+
this.publishStatus(session);
|
|
1515
|
+
throw error;
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
956
1518
|
}
|
|
957
1519
|
async archive(ref) {
|
|
958
1520
|
const session = await this.getOrOpen(ref);
|
|
959
1521
|
if (this.hasActiveWork(session))
|
|
960
1522
|
throw new Error("Stop current session activity before archiving");
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1523
|
+
await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before archiving", async () => {
|
|
1524
|
+
const archiveInput = await this.archiveInputForSession(session);
|
|
1525
|
+
await this.closeActive(session.sessionId, { kind: "clear", reason: "archive" });
|
|
1526
|
+
await this.archiveStore.archive(archiveInput);
|
|
1527
|
+
await this.forgetUnreadSessions([archiveInput]);
|
|
1528
|
+
});
|
|
964
1529
|
}
|
|
965
1530
|
async archiveMany(refs) {
|
|
966
1531
|
const uniqueRefs = uniqueBulkSessionRefs(refs);
|
|
@@ -970,11 +1535,14 @@ export class PiSessionService {
|
|
|
970
1535
|
]);
|
|
971
1536
|
const failures = [];
|
|
972
1537
|
const alreadyArchivedSessionIds = [];
|
|
1538
|
+
const unreadArchivedIdentities = [];
|
|
973
1539
|
const planItems = [];
|
|
974
1540
|
for (const ref of uniqueRefs) {
|
|
975
1541
|
const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
|
|
976
1542
|
if (archived !== undefined) {
|
|
1543
|
+
this.publishNotificationMutations(this.notificationStore.clearSession(archived.sessionId, "archive"));
|
|
977
1544
|
alreadyArchivedSessionIds.push(archived.sessionId);
|
|
1545
|
+
unreadArchivedIdentities.push(archived);
|
|
978
1546
|
continue;
|
|
979
1547
|
}
|
|
980
1548
|
const active = this.activeForLookup(bulkRefToLookup(ref));
|
|
@@ -999,25 +1567,41 @@ export class PiSessionService {
|
|
|
999
1567
|
failures.push({ sessionId: resolvedSessionId, error: errorMessage(error) });
|
|
1000
1568
|
}
|
|
1001
1569
|
}
|
|
1002
|
-
const
|
|
1570
|
+
const readyPlanItems = [];
|
|
1003
1571
|
for (const item of planItems) {
|
|
1572
|
+
const active = this.activeForLookup({ id: item.input.sessionId, cwd: item.input.cwd });
|
|
1573
|
+
if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
|
|
1574
|
+
failures.push({ sessionId: item.input.sessionId, error: "Stop current session activity before archiving" });
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
readyPlanItems.push(active === undefined ? item : { ...item, active });
|
|
1578
|
+
}
|
|
1579
|
+
const readyInputs = [];
|
|
1580
|
+
const archivedSessionIds = [...alreadyArchivedSessionIds];
|
|
1581
|
+
await this.runTreeExclusiveOperation(readyPlanItems.map(({ input, active }) => ({
|
|
1582
|
+
sessionId: input.sessionId,
|
|
1583
|
+
...(active === undefined ? {} : { session: active.runtime.session, runtime: active.runtime }),
|
|
1584
|
+
})), "Stop current session activity before archiving", async () => {
|
|
1585
|
+
for (const item of readyPlanItems) {
|
|
1586
|
+
try {
|
|
1587
|
+
await this.closeActive(item.input.sessionId, { kind: "clear", reason: "archive" });
|
|
1588
|
+
readyInputs.push(item.input);
|
|
1589
|
+
}
|
|
1590
|
+
catch (error) {
|
|
1591
|
+
failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1004
1594
|
try {
|
|
1005
|
-
await this.
|
|
1006
|
-
|
|
1595
|
+
const archived = await this.archiveStoreArchiveMany(readyInputs);
|
|
1596
|
+
archivedSessionIds.push(...archived.map((record) => record.sessionId));
|
|
1597
|
+
unreadArchivedIdentities.push(...archived);
|
|
1007
1598
|
}
|
|
1008
1599
|
catch (error) {
|
|
1009
|
-
|
|
1600
|
+
for (const input of readyInputs)
|
|
1601
|
+
failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
|
|
1010
1602
|
}
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
try {
|
|
1014
|
-
const archived = await this.archiveStoreArchiveMany(readyInputs);
|
|
1015
|
-
archivedSessionIds.push(...archived.map((record) => record.sessionId));
|
|
1016
|
-
}
|
|
1017
|
-
catch (error) {
|
|
1018
|
-
for (const input of readyInputs)
|
|
1019
|
-
failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
|
|
1020
|
-
}
|
|
1603
|
+
});
|
|
1604
|
+
await this.forgetUnreadSessions(unreadArchivedIdentities);
|
|
1021
1605
|
return {
|
|
1022
1606
|
archived: true,
|
|
1023
1607
|
archivedSessionIds: uniqueStrings(archivedSessionIds),
|
|
@@ -1034,9 +1618,19 @@ export class PiSessionService {
|
|
|
1034
1618
|
if (busy !== undefined)
|
|
1035
1619
|
throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
|
|
1036
1620
|
const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1621
|
+
await this.runTreeExclusiveOperation(plan.unarchivedTargets.map((target) => ({
|
|
1622
|
+
sessionId: target.id,
|
|
1623
|
+
...(target.activeSession === undefined ? {} : { session: target.activeSession }),
|
|
1624
|
+
})), `Stop current session activity before archiving ${sessionDisplayName(session)}`, async () => {
|
|
1625
|
+
for (const target of plan.targets) {
|
|
1626
|
+
if (target.archived)
|
|
1627
|
+
this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
|
|
1628
|
+
}
|
|
1629
|
+
for (const input of archiveInputs)
|
|
1630
|
+
await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
|
|
1631
|
+
await this.archiveStoreArchiveMany(archiveInputs);
|
|
1632
|
+
});
|
|
1633
|
+
await this.forgetUnreadSessions(plan.targets.map((target) => ({ sessionId: target.id, cwd: target.cwd })));
|
|
1040
1634
|
return {
|
|
1041
1635
|
archived: true,
|
|
1042
1636
|
sessionIds: archiveInputs.map((input) => input.sessionId),
|
|
@@ -1048,8 +1642,9 @@ export class PiSessionService {
|
|
|
1048
1642
|
const archived = await this.getArchived(ref);
|
|
1049
1643
|
if (archived === undefined)
|
|
1050
1644
|
throw new Error("Session not found");
|
|
1051
|
-
await this.closeActive(archived.sessionId);
|
|
1645
|
+
await this.closeActive(archived.sessionId, { kind: "clear", reason: "restore" });
|
|
1052
1646
|
await this.archiveStore.restore(archived.sessionId);
|
|
1647
|
+
await this.forgetUnreadSessions([archived]);
|
|
1053
1648
|
}
|
|
1054
1649
|
async deleteArchived(ref) {
|
|
1055
1650
|
const record = await this.getArchived(ref);
|
|
@@ -1057,10 +1652,11 @@ export class PiSessionService {
|
|
|
1057
1652
|
throw new Error("Archived session not found");
|
|
1058
1653
|
if (this.archiveStore.deleteArchived === undefined)
|
|
1059
1654
|
throw new Error("Archive store does not support deletion");
|
|
1060
|
-
await this.closeActive(record.sessionId);
|
|
1655
|
+
await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
|
|
1061
1656
|
if (record.archivePath === undefined)
|
|
1062
1657
|
await this.ensureArchivedRecordMoved(record);
|
|
1063
1658
|
await this.archiveStore.deleteArchived(record.sessionId);
|
|
1659
|
+
await this.forgetUnreadSessions([record]);
|
|
1064
1660
|
}
|
|
1065
1661
|
async deleteArchivedMany(refs) {
|
|
1066
1662
|
if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
|
|
@@ -1085,7 +1681,7 @@ export class PiSessionService {
|
|
|
1085
1681
|
const readyRecords = [];
|
|
1086
1682
|
for (const item of planItems) {
|
|
1087
1683
|
try {
|
|
1088
|
-
await this.closeActive(item.record.sessionId);
|
|
1684
|
+
await this.closeActive(item.record.sessionId, { kind: "clear", reason: "delete" });
|
|
1089
1685
|
readyRecords.push(item.record);
|
|
1090
1686
|
}
|
|
1091
1687
|
catch (error) {
|
|
@@ -1106,6 +1702,8 @@ export class PiSessionService {
|
|
|
1106
1702
|
for (const sessionId of deleteIds)
|
|
1107
1703
|
failures.push({ sessionId, error: errorMessage(error) });
|
|
1108
1704
|
}
|
|
1705
|
+
const deletedIdSet = new Set(deletedSessionIds);
|
|
1706
|
+
await this.forgetUnreadSessions(readyRecords.filter((record) => deletedIdSet.has(record.sessionId)));
|
|
1109
1707
|
return {
|
|
1110
1708
|
deleted: true,
|
|
1111
1709
|
deletedSessionIds,
|
|
@@ -1118,9 +1716,29 @@ export class PiSessionService {
|
|
|
1118
1716
|
const session = await this.getOrOpen(ref);
|
|
1119
1717
|
if (this.hasActiveWork(session))
|
|
1120
1718
|
throw new Error("Stop current session activity before reloading");
|
|
1121
|
-
await this.
|
|
1122
|
-
|
|
1123
|
-
|
|
1719
|
+
const reopenedSession = await this.runTreeExclusiveOperation([{ sessionId: session.sessionId, session }], "Stop current session activity before reloading", async () => {
|
|
1720
|
+
const priorGeneration = this.notificationGenerationBySession.get(session);
|
|
1721
|
+
const { sessionId, cwd } = notificationIdentityForSession(session);
|
|
1722
|
+
let candidateGeneration;
|
|
1723
|
+
try {
|
|
1724
|
+
await this.closeActive(sessionId, priorGeneration === undefined ? CLEAR_RUNTIME_NOTIFICATIONS : DEFER_RUNTIME_NOTIFICATIONS);
|
|
1725
|
+
candidateGeneration = priorGeneration === undefined
|
|
1726
|
+
? undefined
|
|
1727
|
+
: this.notificationStore.beginReplacement(priorGeneration, { sessionId, cwd });
|
|
1728
|
+
const reopened = await this.getActive(ref, candidateGeneration === undefined ? {} : { notificationGeneration: candidateGeneration });
|
|
1729
|
+
if (candidateGeneration !== undefined) {
|
|
1730
|
+
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
|
|
1731
|
+
}
|
|
1732
|
+
return reopened.runtime.session;
|
|
1733
|
+
}
|
|
1734
|
+
catch (error) {
|
|
1735
|
+
if (candidateGeneration !== undefined) {
|
|
1736
|
+
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration));
|
|
1737
|
+
}
|
|
1738
|
+
throw error;
|
|
1739
|
+
}
|
|
1740
|
+
});
|
|
1741
|
+
this.publishStatus(reopenedSession);
|
|
1124
1742
|
}
|
|
1125
1743
|
async detachParent(ref) {
|
|
1126
1744
|
const session = await this.getOrOpen(ref);
|
|
@@ -1130,6 +1748,7 @@ export class PiSessionService {
|
|
|
1130
1748
|
await clearParentSession(sessionFile);
|
|
1131
1749
|
clearParentSessionHeader(session.sessionManager);
|
|
1132
1750
|
this.unregisterSubsession(session.sessionId);
|
|
1751
|
+
await this.forgetUnreadSessions([{ sessionId: session.sessionId, cwd: session.sessionManager.getCwd() }]);
|
|
1133
1752
|
}
|
|
1134
1753
|
async clearQueue(ref) {
|
|
1135
1754
|
await this.assertWritable(ref);
|
|
@@ -1152,17 +1771,36 @@ export class PiSessionService {
|
|
|
1152
1771
|
const sessionId = active.runtime.session.sessionId;
|
|
1153
1772
|
this.clearCompactionPromptQueue(sessionId);
|
|
1154
1773
|
clearSessionQueue(active.runtime.session);
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1774
|
+
// Settle run-scoped dialogs now, at abort-request time: pi's agent loop
|
|
1775
|
+
// waits for a parked `tool_call` dialog handler before it can emit
|
|
1776
|
+
// `agent_end`, so leaving settlement to the `agent_end` observer would
|
|
1777
|
+
// strand the dialog until its timeout. Settling before the runtime abort
|
|
1778
|
+
// also means a failing or hung abort cannot strand the parked waiter.
|
|
1779
|
+
this.abortRunScopedExtensionDialogs(sessionId);
|
|
1780
|
+
try {
|
|
1781
|
+
await this.abortSessionOperations(active.runtime.session);
|
|
1782
|
+
this.publishActivity(active.runtime.session, "stopped", "idle");
|
|
1783
|
+
}
|
|
1784
|
+
catch (error) {
|
|
1785
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1786
|
+
this.publishActivity(active.runtime.session, "stop failed", "error", message);
|
|
1787
|
+
throw error;
|
|
1788
|
+
}
|
|
1789
|
+
finally {
|
|
1790
|
+
this.publishStatus(active.runtime.session);
|
|
1791
|
+
}
|
|
1158
1792
|
}
|
|
1159
|
-
stop(ref) {
|
|
1793
|
+
async stop(ref) {
|
|
1160
1794
|
const active = this.activeForLookup(ref);
|
|
1161
|
-
if (active
|
|
1795
|
+
if (active !== undefined) {
|
|
1796
|
+
await this.closeActive(active.runtime.session.sessionId);
|
|
1162
1797
|
return;
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1798
|
+
}
|
|
1799
|
+
if (isPiSessionRef(ref)) {
|
|
1800
|
+
this.publishNotificationMutations(this.notificationStore.clearSessionIdentity(ref.id, canonicalizeStoredCwd(ref.cwd), "runtime-close"));
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
await this.closeActive(ref);
|
|
1166
1804
|
}
|
|
1167
1805
|
async bulkSessionLookupContext(refs) {
|
|
1168
1806
|
const cwdSet = new Set();
|
|
@@ -1351,13 +1989,32 @@ export class PiSessionService {
|
|
|
1351
1989
|
}
|
|
1352
1990
|
return [...names];
|
|
1353
1991
|
}
|
|
1354
|
-
async closeActive(sessionId) {
|
|
1992
|
+
async closeActive(sessionId, notificationPolicy = CLEAR_RUNTIME_NOTIFICATIONS) {
|
|
1993
|
+
// A session whose open is parked on a `session_start` dialog holds its
|
|
1994
|
+
// pending open until the dialog settles; settle it first so closing cannot
|
|
1995
|
+
// block behind the dialog timeout (which `0` makes infinite).
|
|
1996
|
+
if (this.startupSessions.has(sessionId))
|
|
1997
|
+
this.endSessionExtensionDialogs(sessionId);
|
|
1355
1998
|
const pendingOpens = this.pendingSessionOpenPromises(sessionId);
|
|
1356
1999
|
if (pendingOpens.length > 0)
|
|
1357
2000
|
await Promise.allSettled(pendingOpens);
|
|
1358
2001
|
const active = this.active.get(sessionId);
|
|
2002
|
+
if (notificationPolicy.kind === "clear") {
|
|
2003
|
+
const generation = active === undefined ? undefined : this.notificationGenerationBySession.get(active.runtime.session);
|
|
2004
|
+
const mutations = generation === undefined
|
|
2005
|
+
? this.notificationStore.clearSession(sessionId, notificationPolicy.reason)
|
|
2006
|
+
: this.notificationStore.clearGeneration(generation, notificationPolicy.reason);
|
|
2007
|
+
this.publishNotificationMutations(mutations);
|
|
2008
|
+
}
|
|
1359
2009
|
if (!active)
|
|
1360
2010
|
return;
|
|
2011
|
+
this.forgetUnreadActivity(active.runtime.session);
|
|
2012
|
+
// An open ask is meaningful only while the runtime that posted it exists: no
|
|
2013
|
+
// one is left to receive the answers, so it is dropped without an outcome.
|
|
2014
|
+
this.pendingAskStore.forgetSession(sessionId);
|
|
2015
|
+
// Open dialogs share that stance, but their extension waiters are parked
|
|
2016
|
+
// Promises inside the dying runtime: settle them rather than dropping them.
|
|
2017
|
+
this.endSessionExtensionDialogs(sessionId);
|
|
1361
2018
|
this.active.delete(sessionId);
|
|
1362
2019
|
this.activities.delete(sessionId);
|
|
1363
2020
|
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
|
|
@@ -1370,13 +2027,36 @@ export class PiSessionService {
|
|
|
1370
2027
|
this.subsessionNotifyArmed.delete(sessionId);
|
|
1371
2028
|
clearSessionQueue(active.runtime.session);
|
|
1372
2029
|
active.unsubscribe();
|
|
2030
|
+
active.runtime.setRebindSession(undefined);
|
|
1373
2031
|
try {
|
|
1374
|
-
await active.runtime.session
|
|
2032
|
+
await this.abortSessionOperations(active.runtime.session);
|
|
1375
2033
|
}
|
|
1376
2034
|
finally {
|
|
1377
2035
|
await active.runtime.dispose();
|
|
1378
2036
|
}
|
|
1379
2037
|
}
|
|
2038
|
+
async abortSessionOperations(session) {
|
|
2039
|
+
let branchSummaryAbortFailed = false;
|
|
2040
|
+
let branchSummaryAbortError;
|
|
2041
|
+
try {
|
|
2042
|
+
session.abortBranchSummary?.();
|
|
2043
|
+
}
|
|
2044
|
+
catch (error) {
|
|
2045
|
+
branchSummaryAbortFailed = true;
|
|
2046
|
+
branchSummaryAbortError = error;
|
|
2047
|
+
}
|
|
2048
|
+
try {
|
|
2049
|
+
await session.abort();
|
|
2050
|
+
}
|
|
2051
|
+
catch (abortError) {
|
|
2052
|
+
if (branchSummaryAbortFailed) {
|
|
2053
|
+
throw new AggregateError([branchSummaryAbortError, abortError], "Failed to abort session operations", { cause: abortError });
|
|
2054
|
+
}
|
|
2055
|
+
throw abortError;
|
|
2056
|
+
}
|
|
2057
|
+
if (branchSummaryAbortFailed)
|
|
2058
|
+
throw branchSummaryAbortError;
|
|
2059
|
+
}
|
|
1380
2060
|
async assertWritable(ref) {
|
|
1381
2061
|
if (await this.getArchived(ref) !== undefined)
|
|
1382
2062
|
throw new Error("Archived sessions are read-only. Restore the session to continue.");
|
|
@@ -1384,23 +2064,23 @@ export class PiSessionService {
|
|
|
1384
2064
|
async getOrOpen(ref) {
|
|
1385
2065
|
return (await this.getActive(ref)).runtime.session;
|
|
1386
2066
|
}
|
|
1387
|
-
async getActive(ref) {
|
|
2067
|
+
async getActive(ref, options = {}) {
|
|
1388
2068
|
const active = this.activeForLookup(ref);
|
|
1389
2069
|
if (active !== undefined)
|
|
1390
2070
|
return active;
|
|
1391
2071
|
const archived = await this.getArchived(ref);
|
|
1392
2072
|
if (archived?.archivePath !== undefined) {
|
|
1393
2073
|
const { archivePath } = archived;
|
|
1394
|
-
return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath));
|
|
2074
|
+
return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath), { notifications: "disabled" });
|
|
1395
2075
|
}
|
|
1396
2076
|
const match = isPiSessionRef(ref)
|
|
1397
2077
|
? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
|
|
1398
2078
|
: (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
|
|
1399
2079
|
if (!match)
|
|
1400
2080
|
throw new Error("Session not found");
|
|
1401
|
-
return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path));
|
|
2081
|
+
return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
|
|
1402
2082
|
}
|
|
1403
|
-
openExistingSession(sessionId, cwd, openSessionManager) {
|
|
2083
|
+
openExistingSession(sessionId, cwd, openSessionManager, options = {}) {
|
|
1404
2084
|
const active = this.activeForLookup({ id: sessionId, cwd });
|
|
1405
2085
|
if (active !== undefined)
|
|
1406
2086
|
return Promise.resolve(active);
|
|
@@ -1410,7 +2090,7 @@ export class PiSessionService {
|
|
|
1410
2090
|
return existing.promise;
|
|
1411
2091
|
const pending = {
|
|
1412
2092
|
sessionId,
|
|
1413
|
-
promise: this.create(openSessionManager(), cwd),
|
|
2093
|
+
promise: this.create(openSessionManager(), cwd, options),
|
|
1414
2094
|
};
|
|
1415
2095
|
pending.promise = pending.promise.finally(() => {
|
|
1416
2096
|
if (this.pendingSessionOpens.get(key) === pending)
|
|
@@ -1432,6 +2112,9 @@ export class PiSessionService {
|
|
|
1432
2112
|
return undefined;
|
|
1433
2113
|
return archived;
|
|
1434
2114
|
}
|
|
2115
|
+
isCurrentActiveSession(session) {
|
|
2116
|
+
return this.active.get(session.sessionId)?.runtime.session === session;
|
|
2117
|
+
}
|
|
1435
2118
|
activeForLookup(ref) {
|
|
1436
2119
|
const sessionId = sessionIdFromLookup(ref);
|
|
1437
2120
|
const exact = this.active.get(sessionId);
|
|
@@ -1443,7 +2126,47 @@ export class PiSessionService {
|
|
|
1443
2126
|
}
|
|
1444
2127
|
return undefined;
|
|
1445
2128
|
}
|
|
2129
|
+
startupSessionForLookup(ref) {
|
|
2130
|
+
const sessionId = sessionIdFromLookup(ref);
|
|
2131
|
+
const exact = this.startupSessions.get(sessionId);
|
|
2132
|
+
if (exact !== undefined && lookupMatchesStartupSession(ref, exact))
|
|
2133
|
+
return exact;
|
|
2134
|
+
for (const [candidateId, session] of this.startupSessions.entries()) {
|
|
2135
|
+
if (candidateId.startsWith(sessionId) && lookupMatchesStartupSession(ref, session))
|
|
2136
|
+
return session;
|
|
2137
|
+
}
|
|
2138
|
+
return undefined;
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* The session to serve a read-only status or a dialog close for, while it
|
|
2142
|
+
* can still be found: active first, then still starting up, and only then
|
|
2143
|
+
* the on-demand open path (which a stale close on an idle session needs for
|
|
2144
|
+
* its status projection).
|
|
2145
|
+
*/
|
|
2146
|
+
async sessionForStatusOrDialogClose(ref) {
|
|
2147
|
+
const reachable = this.activeForLookup(ref)?.runtime.session ?? this.startupSessionForLookup(ref);
|
|
2148
|
+
if (reachable !== undefined)
|
|
2149
|
+
return reachable;
|
|
2150
|
+
return this.getOrOpen(ref);
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Construct a session while telling waiting browsers which phase of startup
|
|
2154
|
+
* they are waiting on. The reporting wraps the *whole* construction rather
|
|
2155
|
+
* than the inner bookkeeping `try`, because the runtime construction that runs
|
|
2156
|
+
* first is both the slowest phase and one that can fail on its own; a clear
|
|
2157
|
+
* that only ran for the later phases would leave a stale label behind.
|
|
2158
|
+
*/
|
|
1446
2159
|
async create(sessionManager, cwd, options = {}) {
|
|
2160
|
+
const startup = this.startupProgress(sessionManager, options.startupIntent ?? "open", options.startupToken);
|
|
2161
|
+
try {
|
|
2162
|
+
return await this.createSessionRuntime(sessionManager, cwd, options, startup);
|
|
2163
|
+
}
|
|
2164
|
+
finally {
|
|
2165
|
+
startup.end();
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
async createSessionRuntime(sessionManager, cwd, options, startup) {
|
|
2169
|
+
startup.report(STARTUP_PHASE_RUNTIME);
|
|
1447
2170
|
const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
|
|
1448
2171
|
&& await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
|
|
1449
2172
|
const runtime = await this.createAgentRuntime(this.createRuntime, {
|
|
@@ -1454,21 +2177,91 @@ export class PiSessionService {
|
|
|
1454
2177
|
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
|
1455
2178
|
});
|
|
1456
2179
|
const active = { runtime, unsubscribe: noop };
|
|
2180
|
+
let boundSession = runtime.session;
|
|
2181
|
+
let notificationGeneration = options.notificationGeneration;
|
|
2182
|
+
let notificationOwnership = options.notifications === "disabled"
|
|
2183
|
+
? "disabled"
|
|
2184
|
+
: notificationGeneration === undefined
|
|
2185
|
+
? "registered"
|
|
2186
|
+
: "external";
|
|
2187
|
+
if (notificationOwnership === "registered") {
|
|
2188
|
+
const notificationIdentity = notificationIdentityForSession(runtime.session);
|
|
2189
|
+
const existingCandidate = this.notificationStore.beginReplacementForSession(notificationIdentity.sessionId, notificationIdentity.cwd);
|
|
2190
|
+
if (existingCandidate !== undefined) {
|
|
2191
|
+
notificationGeneration = existingCandidate;
|
|
2192
|
+
notificationOwnership = "replacement";
|
|
2193
|
+
}
|
|
2194
|
+
else {
|
|
2195
|
+
const registration = this.notificationStore.registerSession(notificationIdentity.sessionId, notificationIdentity.cwd);
|
|
2196
|
+
notificationGeneration = registration.generation;
|
|
2197
|
+
this.publishNotificationMutations(registration.mutations);
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
if (notificationGeneration !== undefined)
|
|
2201
|
+
this.notificationGenerationBySession.set(runtime.session, notificationGeneration);
|
|
1457
2202
|
try {
|
|
1458
|
-
|
|
2203
|
+
if (options.creationProvenance === "tracked-subsession") {
|
|
2204
|
+
await this.publishUnreadMutations(this.unreadStore.excludeSession(runtime.session.sessionId, canonicalizeStoredCwd(runtime.session.sessionManager.getCwd())));
|
|
2205
|
+
}
|
|
2206
|
+
else {
|
|
2207
|
+
await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
|
|
2208
|
+
}
|
|
2209
|
+
startup.report(STARTUP_PHASE_EXTENSIONS);
|
|
2210
|
+
await this.bindSessionExtensions(runtime.session, notificationGeneration);
|
|
1459
2211
|
this.bindRuntime(active);
|
|
1460
2212
|
runtime.setRebindSession(async (session) => {
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
2213
|
+
const priorGeneration = notificationGeneration;
|
|
2214
|
+
let candidateGeneration;
|
|
2215
|
+
try {
|
|
2216
|
+
await this.prepareUnreadRuntimeRebind(boundSession, session);
|
|
2217
|
+
await this.recoverSubsessionTrackingForOpenedSession(session);
|
|
2218
|
+
if (priorGeneration !== undefined) {
|
|
2219
|
+
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
|
|
2220
|
+
this.notificationGenerationBySession.set(session, candidateGeneration);
|
|
2221
|
+
}
|
|
2222
|
+
this.bindRuntime(active, session);
|
|
2223
|
+
// The runtime being replaced parked every dialog the store still
|
|
2224
|
+
// holds for this session; settle those waits before the new
|
|
2225
|
+
// runtime's extensions can open fresh dialogs under the same id.
|
|
2226
|
+
this.endSessionExtensionDialogs(boundSession.sessionId);
|
|
2227
|
+
boundSession = session;
|
|
2228
|
+
await this.bindSessionExtensions(session, candidateGeneration);
|
|
2229
|
+
if (candidateGeneration !== undefined) {
|
|
2230
|
+
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
|
|
2231
|
+
notificationGeneration = candidateGeneration;
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
catch (error) {
|
|
2235
|
+
if (candidateGeneration !== undefined) {
|
|
2236
|
+
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
|
|
2237
|
+
notificationGeneration = candidateGeneration;
|
|
2238
|
+
this.notificationGenerationBySession.set(session, candidateGeneration);
|
|
2239
|
+
}
|
|
2240
|
+
throw error;
|
|
2241
|
+
}
|
|
1464
2242
|
});
|
|
1465
2243
|
this.active.set(runtime.session.sessionId, active);
|
|
1466
|
-
|
|
2244
|
+
if (notificationOwnership === "replacement" && notificationGeneration !== undefined) {
|
|
2245
|
+
this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
|
|
2246
|
+
notificationOwnership = "external";
|
|
2247
|
+
}
|
|
1467
2248
|
this.publishStatus(runtime.session);
|
|
1468
2249
|
return active;
|
|
1469
2250
|
}
|
|
1470
2251
|
catch (error) {
|
|
2252
|
+
if (notificationGeneration !== undefined) {
|
|
2253
|
+
if (notificationOwnership === "registered") {
|
|
2254
|
+
this.publishNotificationMutations(this.notificationStore.clearSession(runtime.session.sessionId, "initialization-failed"));
|
|
2255
|
+
}
|
|
2256
|
+
else if (notificationOwnership === "replacement") {
|
|
2257
|
+
this.publishNotificationMutations(this.notificationStore.abortReplacement(notificationGeneration));
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
1471
2260
|
active.unsubscribe();
|
|
2261
|
+
this.forgetUnreadActivity(boundSession);
|
|
2262
|
+
// A session_start dialog may already be parked when a later startup
|
|
2263
|
+
// step fails; its waiter dies with the runtime being torn down here.
|
|
2264
|
+
this.endSessionExtensionDialogs(boundSession.sessionId);
|
|
1472
2265
|
let removedActive = false;
|
|
1473
2266
|
for (const [sessionId, candidate] of this.active.entries()) {
|
|
1474
2267
|
if (candidate !== active)
|
|
@@ -1491,39 +2284,190 @@ export class PiSessionService {
|
|
|
1491
2284
|
throw error;
|
|
1492
2285
|
}
|
|
1493
2286
|
}
|
|
1494
|
-
async bindSessionExtensions(session) {
|
|
2287
|
+
async bindSessionExtensions(session, generation) {
|
|
2288
|
+
const uiContext = this.sessionUiContext(session, generation);
|
|
2289
|
+
// A `session_start` hook can park this bind on a dialog the browser has
|
|
2290
|
+
// not answered yet. On the initial create/open path the session becomes
|
|
2291
|
+
// active only after this returns, so register it for the duration: the
|
|
2292
|
+
// answer that unblocks startup has to be reachable while it waits.
|
|
2293
|
+
this.startupSessions.set(session.sessionId, session);
|
|
2294
|
+
try {
|
|
2295
|
+
await session.bindExtensions({
|
|
2296
|
+
uiContext,
|
|
2297
|
+
mode: "rpc",
|
|
2298
|
+
onError: (error) => {
|
|
2299
|
+
const message = `${error.extensionPath}: ${error.error}`;
|
|
2300
|
+
this.publishActivity(session, "extension error", "error", message);
|
|
2301
|
+
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
2302
|
+
},
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
finally {
|
|
2306
|
+
this.startupSessions.delete(session.sessionId);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
replaceSessionNotificationContext(session, generation) {
|
|
2310
|
+
session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
|
|
2311
|
+
}
|
|
2312
|
+
sessionUiContext(session, generation) {
|
|
1495
2313
|
const baseUiContext = session.extensionRunner.getUIContext();
|
|
1496
2314
|
const notify = (message, type) => {
|
|
2315
|
+
if (generation === undefined) {
|
|
2316
|
+
this.events.publish(session.sessionId, {
|
|
2317
|
+
type: "command.output",
|
|
2318
|
+
level: type === "error" ? "error" : "info",
|
|
2319
|
+
message,
|
|
2320
|
+
});
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
const added = this.notificationStore.addNotification(generation, message, type);
|
|
2324
|
+
this.publishNotificationMutations(added.mutations);
|
|
2325
|
+
if (added.notification === undefined)
|
|
2326
|
+
return;
|
|
1497
2327
|
this.events.publish(session.sessionId, {
|
|
1498
2328
|
type: "command.output",
|
|
1499
2329
|
level: type === "error" ? "error" : "info",
|
|
1500
2330
|
message,
|
|
2331
|
+
notificationId: added.notification.id,
|
|
1501
2332
|
});
|
|
1502
2333
|
};
|
|
1503
|
-
// PI WEB
|
|
1504
|
-
//
|
|
1505
|
-
//
|
|
1506
|
-
|
|
1507
|
-
|
|
2334
|
+
// PI WEB owns the browser-facing dialog, notification, and text-formatting
|
|
2335
|
+
// boundaries: the three dialog primitives park daemon-held Promises that
|
|
2336
|
+
// the browser answers, while every other UI method delegates to Pi's
|
|
2337
|
+
// headless defaults so unsupported surfaces cancel safely instead of
|
|
2338
|
+
// hanging.
|
|
2339
|
+
return new Proxy(baseUiContext, {
|
|
2340
|
+
get: (target, property, receiver) => {
|
|
1508
2341
|
if (property === "notify")
|
|
1509
2342
|
return notify;
|
|
2343
|
+
if (property === "theme")
|
|
2344
|
+
return plainTextTheme;
|
|
2345
|
+
if (property === "confirm") {
|
|
2346
|
+
return (title, message, opts) => this.openExtensionDialog(session, { kind: "confirm", title, message }, opts);
|
|
2347
|
+
}
|
|
2348
|
+
if (property === "select") {
|
|
2349
|
+
return (title, options, opts) => this.openExtensionDialog(session, { kind: "select", title, options }, opts);
|
|
2350
|
+
}
|
|
2351
|
+
if (property === "input") {
|
|
2352
|
+
return (title, placeholder, opts) => this.openExtensionDialog(session, { kind: "input", title, placeholder }, opts);
|
|
2353
|
+
}
|
|
1510
2354
|
const value = Reflect.get(target, property, receiver);
|
|
1511
2355
|
return value;
|
|
1512
2356
|
},
|
|
1513
2357
|
});
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
2358
|
+
}
|
|
2359
|
+
publishNotificationMutations(mutations) {
|
|
2360
|
+
for (const mutation of mutations) {
|
|
2361
|
+
this.events.publish(mutation.sessionId, mutation.inboxEvent);
|
|
2362
|
+
this.events.publishNotificationSummary(mutation.summaryEvent);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
async prepareUnreadRuntimeRebind(previous, next) {
|
|
2366
|
+
const previousCwd = canonicalizeStoredCwd(previous.sessionManager.getCwd());
|
|
2367
|
+
this.unreadStore.forgetActivity(previous.sessionId, previousCwd);
|
|
2368
|
+
const nextCwd = canonicalizeStoredCwd(next.sessionManager.getCwd());
|
|
2369
|
+
if (previous.sessionId === next.sessionId && cwdPathsEqual(previousCwd, nextCwd))
|
|
2370
|
+
return;
|
|
2371
|
+
await this.publishUnreadMutations(this.unreadStore.forgetSession(previous.sessionId, previousCwd));
|
|
2372
|
+
}
|
|
2373
|
+
forgetUnreadActivity(session) {
|
|
2374
|
+
this.unreadStore.forgetActivity(session.sessionId, canonicalizeStoredCwd(session.sessionManager.getCwd()));
|
|
2375
|
+
}
|
|
2376
|
+
async forgetUnreadSessions(identities) {
|
|
2377
|
+
const mutations = [];
|
|
2378
|
+
for (const identity of identities) {
|
|
2379
|
+
mutations.push(...this.unreadStore.forgetSession(identity.sessionId, canonicalizeStoredCwd(identity.cwd)));
|
|
2380
|
+
}
|
|
2381
|
+
await this.publishUnreadMutations(mutations);
|
|
2382
|
+
}
|
|
2383
|
+
observeUnreadActivityState(session) {
|
|
2384
|
+
const mutations = this.unreadStore.observeActivityState(session.sessionId, canonicalizeStoredCwd(session.sessionManager.getCwd()), this.hasActiveWork(session));
|
|
2385
|
+
if (mutations.length === 0)
|
|
2386
|
+
return;
|
|
2387
|
+
void this.publishUnreadMutations(mutations).catch(() => undefined);
|
|
2388
|
+
}
|
|
2389
|
+
publishUnreadMutations(mutations) {
|
|
2390
|
+
this.enqueueUnreadMutations(mutations);
|
|
2391
|
+
this.unreadPublicationFlushRequested = true;
|
|
2392
|
+
if (this.unreadPublication === undefined && this.unreadPublicationRetryTimer !== undefined) {
|
|
2393
|
+
const failure = this.unreadPublicationFailure;
|
|
2394
|
+
return Promise.reject(failure instanceof Error
|
|
2395
|
+
? failure
|
|
2396
|
+
: new Error("Session unread publication is awaiting retry", { cause: failure }));
|
|
2397
|
+
}
|
|
2398
|
+
return this.ensureUnreadPublication();
|
|
2399
|
+
}
|
|
2400
|
+
ensureUnreadPublication() {
|
|
2401
|
+
const existing = this.unreadPublication;
|
|
2402
|
+
if (existing !== undefined)
|
|
2403
|
+
return existing;
|
|
2404
|
+
const publication = this.drainUnreadPublication();
|
|
2405
|
+
this.unreadPublication = publication;
|
|
2406
|
+
void publication.then(() => {
|
|
2407
|
+
if (this.unreadPublication === publication)
|
|
2408
|
+
this.unreadPublication = undefined;
|
|
2409
|
+
}, (error) => {
|
|
2410
|
+
if (this.unreadPublication === publication)
|
|
2411
|
+
this.unreadPublication = undefined;
|
|
2412
|
+
this.unreadPublicationFailure = error;
|
|
2413
|
+
this.logger.info({ error: error instanceof Error ? error.message : String(error) }, "failed to publish durable session unread mutations");
|
|
2414
|
+
this.scheduleUnreadPublicationRetry();
|
|
1522
2415
|
});
|
|
2416
|
+
return publication;
|
|
2417
|
+
}
|
|
2418
|
+
async drainUnreadPublication() {
|
|
2419
|
+
while (this.unreadPublicationFlushRequested || this.pendingUnreadMutations.length > 0) {
|
|
2420
|
+
this.unreadPublicationFlushRequested = false;
|
|
2421
|
+
const batch = this.pendingUnreadMutations.splice(0);
|
|
2422
|
+
let publishedCount = 0;
|
|
2423
|
+
try {
|
|
2424
|
+
await this.unreadStore.flush();
|
|
2425
|
+
for (const mutation of batch) {
|
|
2426
|
+
this.events.publishGlobal(mutation.event);
|
|
2427
|
+
publishedCount += 1;
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
catch (error) {
|
|
2431
|
+
this.prependUnreadMutations(batch.slice(publishedCount));
|
|
2432
|
+
this.unreadPublicationFlushRequested = true;
|
|
2433
|
+
throw error;
|
|
2434
|
+
}
|
|
2435
|
+
this.unreadPublicationFailure = undefined;
|
|
2436
|
+
this.clearUnreadPublicationRetry();
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
enqueueUnreadMutations(mutations) {
|
|
2440
|
+
this.pendingUnreadMutations.push(...mutations);
|
|
2441
|
+
this.trimPendingUnreadMutations();
|
|
1523
2442
|
}
|
|
1524
|
-
|
|
2443
|
+
prependUnreadMutations(mutations) {
|
|
2444
|
+
this.pendingUnreadMutations.unshift(...mutations);
|
|
2445
|
+
this.trimPendingUnreadMutations();
|
|
2446
|
+
}
|
|
2447
|
+
trimPendingUnreadMutations() {
|
|
2448
|
+
const excess = this.pendingUnreadMutations.length - MAX_PENDING_UNREAD_MUTATIONS;
|
|
2449
|
+
if (excess > 0)
|
|
2450
|
+
this.pendingUnreadMutations.splice(0, excess);
|
|
2451
|
+
}
|
|
2452
|
+
scheduleUnreadPublicationRetry() {
|
|
2453
|
+
if (this.unreadPublicationStopped || this.unreadPublicationRetryTimer !== undefined)
|
|
2454
|
+
return;
|
|
2455
|
+
const delay = this.unreadPublicationRetryDelayMs;
|
|
2456
|
+
this.unreadPublicationRetryDelayMs = Math.min(Math.max(delay * 2, this.unreadPublicationRetryInitialMs), Math.max(MAX_UNREAD_PUBLICATION_RETRY_MS, this.unreadPublicationRetryInitialMs));
|
|
2457
|
+
this.unreadPublicationRetryTimer = setTimeout(() => {
|
|
2458
|
+
this.unreadPublicationRetryTimer = undefined;
|
|
2459
|
+
void this.ensureUnreadPublication().catch(() => undefined);
|
|
2460
|
+
}, delay);
|
|
2461
|
+
this.unreadPublicationRetryTimer.unref();
|
|
2462
|
+
}
|
|
2463
|
+
clearUnreadPublicationRetry() {
|
|
2464
|
+
if (this.unreadPublicationRetryTimer !== undefined)
|
|
2465
|
+
clearTimeout(this.unreadPublicationRetryTimer);
|
|
2466
|
+
this.unreadPublicationRetryTimer = undefined;
|
|
2467
|
+
this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
|
|
2468
|
+
}
|
|
2469
|
+
bindRuntime(active, session = active.runtime.session) {
|
|
1525
2470
|
active.unsubscribe();
|
|
1526
|
-
const { session } = active.runtime;
|
|
1527
2471
|
for (const [sessionId, candidate] of this.active.entries()) {
|
|
1528
2472
|
if (candidate === active) {
|
|
1529
2473
|
this.active.delete(sessionId);
|
|
@@ -1535,6 +2479,8 @@ export class PiSessionService {
|
|
|
1535
2479
|
this.events.publish(session.sessionId, toClientEvent(event));
|
|
1536
2480
|
this.publishActivityForEvent(session, event);
|
|
1537
2481
|
const eventType = getString(event, "type");
|
|
2482
|
+
if (eventType === "agent_end")
|
|
2483
|
+
this.abortRunScopedExtensionDialogs(session.sessionId);
|
|
1538
2484
|
if (eventType === "compaction_end")
|
|
1539
2485
|
this.scheduleCompactionQueueDrain(session.sessionId);
|
|
1540
2486
|
if (eventType === "agent_start" || eventType === "agent_end")
|
|
@@ -1614,7 +2560,7 @@ export class PiSessionService {
|
|
|
1614
2560
|
const model = session.model;
|
|
1615
2561
|
if (model === undefined)
|
|
1616
2562
|
return;
|
|
1617
|
-
void generateShortSessionName(session.agent.
|
|
2563
|
+
void generateShortSessionName(session.agent.streamFunction, model, firstMessage).then((name) => {
|
|
1618
2564
|
this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
|
|
1619
2565
|
}).catch(() => {
|
|
1620
2566
|
this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
|
|
@@ -1623,9 +2569,32 @@ export class PiSessionService {
|
|
|
1623
2569
|
applyGeneratedSessionName(session, name) {
|
|
1624
2570
|
if (name === undefined || session.sessionName !== undefined)
|
|
1625
2571
|
return;
|
|
2572
|
+
if (this.treeNavigations.has(session)) {
|
|
2573
|
+
this.deferredGeneratedSessionNames.set(session, name);
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
1626
2576
|
session.setSessionName(name);
|
|
1627
2577
|
this.publishSessionName(session);
|
|
1628
2578
|
}
|
|
2579
|
+
flushDeferredTreeNavigationWork(session) {
|
|
2580
|
+
const generatedName = this.deferredGeneratedSessionNames.get(session);
|
|
2581
|
+
this.deferredGeneratedSessionNames.delete(session);
|
|
2582
|
+
if (generatedName !== undefined) {
|
|
2583
|
+
try {
|
|
2584
|
+
this.applyGeneratedSessionName(session, generatedName);
|
|
2585
|
+
}
|
|
2586
|
+
catch (error) {
|
|
2587
|
+
this.logger.info({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) }, "failed to apply deferred session name");
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
const notifications = this.deferredSubsessionNotifications.get(session) ?? [];
|
|
2591
|
+
this.deferredSubsessionNotifications.delete(session);
|
|
2592
|
+
for (const notification of notifications) {
|
|
2593
|
+
void this.deliverSubsessionNotification(session, notification).catch((error) => {
|
|
2594
|
+
this.logSubsessionNotificationFailure(notification.parentId, notification.childId, error);
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
1629
2598
|
applyAuthChange(change = {}) {
|
|
1630
2599
|
// ModelRuntime.login()/logout() refresh the shared runtime before AuthService
|
|
1631
2600
|
// emits the change, so no refresh is needed here. Keeping this synchronous
|
|
@@ -1694,6 +2663,10 @@ export class PiSessionService {
|
|
|
1694
2663
|
}
|
|
1695
2664
|
}
|
|
1696
2665
|
activityLabelFromStatus(session) {
|
|
2666
|
+
if (this.treeNavigations.has(session))
|
|
2667
|
+
return "navigating session tree";
|
|
2668
|
+
if (this.isSessionEntryMutationActive(session))
|
|
2669
|
+
return "updating session";
|
|
1697
2670
|
if (session.isCompacting)
|
|
1698
2671
|
return "compacting";
|
|
1699
2672
|
if (session.isBashRunning)
|
|
@@ -1705,7 +2678,92 @@ export class PiSessionService {
|
|
|
1705
2678
|
return "active";
|
|
1706
2679
|
}
|
|
1707
2680
|
hasActiveWork(session) {
|
|
1708
|
-
return
|
|
2681
|
+
return this.treeNavigations.has(session)
|
|
2682
|
+
|| this.isSessionEntryMutationActive(session)
|
|
2683
|
+
|| this.isTreeExclusiveOperationActive(session)
|
|
2684
|
+
|| sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
|
|
2685
|
+
}
|
|
2686
|
+
async runTreeExclusiveOperation(targets, activeError, operation) {
|
|
2687
|
+
const sessionIds = new Set();
|
|
2688
|
+
const runtimes = new Set();
|
|
2689
|
+
const sessions = new Set();
|
|
2690
|
+
for (const target of targets) {
|
|
2691
|
+
const runtime = target.runtime ?? (target.session === undefined ? undefined : this.activeRuntimeForSession(target.session));
|
|
2692
|
+
const session = target.session ?? runtime?.session;
|
|
2693
|
+
if (session !== undefined && this.hasActiveWork(session))
|
|
2694
|
+
throw new Error(activeError);
|
|
2695
|
+
sessionIds.add(target.sessionId);
|
|
2696
|
+
if (runtime !== undefined)
|
|
2697
|
+
runtimes.add(runtime);
|
|
2698
|
+
if (session !== undefined)
|
|
2699
|
+
sessions.add(session);
|
|
2700
|
+
}
|
|
2701
|
+
for (const sessionId of sessionIds) {
|
|
2702
|
+
this.treeExclusiveSessionOperationCounts.set(sessionId, (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) + 1);
|
|
2703
|
+
}
|
|
2704
|
+
for (const runtime of runtimes) {
|
|
2705
|
+
this.treeExclusiveRuntimeOperationCounts.set(runtime, (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) + 1);
|
|
2706
|
+
}
|
|
2707
|
+
for (const session of sessions)
|
|
2708
|
+
this.observeUnreadActivityState(session);
|
|
2709
|
+
try {
|
|
2710
|
+
return await operation();
|
|
2711
|
+
}
|
|
2712
|
+
finally {
|
|
2713
|
+
for (const runtime of runtimes)
|
|
2714
|
+
decrementWeakCount(this.treeExclusiveRuntimeOperationCounts, runtime);
|
|
2715
|
+
for (const sessionId of sessionIds)
|
|
2716
|
+
decrementMapCount(this.treeExclusiveSessionOperationCounts, sessionId);
|
|
2717
|
+
for (const session of sessions) {
|
|
2718
|
+
if (this.isCurrentActiveSession(session))
|
|
2719
|
+
this.observeUnreadActivityState(session);
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
isTreeExclusiveSessionIdentityActive(sessionId) {
|
|
2724
|
+
return (this.treeExclusiveSessionOperationCounts.get(sessionId) ?? 0) > 0;
|
|
2725
|
+
}
|
|
2726
|
+
isTreeExclusiveOperationActive(session) {
|
|
2727
|
+
if (this.isTreeExclusiveSessionIdentityActive(session.sessionId))
|
|
2728
|
+
return true;
|
|
2729
|
+
const runtime = this.activeRuntimeForSession(session);
|
|
2730
|
+
return runtime !== undefined && (this.treeExclusiveRuntimeOperationCounts.get(runtime) ?? 0) > 0;
|
|
2731
|
+
}
|
|
2732
|
+
activeRuntimeForSession(session) {
|
|
2733
|
+
for (const active of new Set(this.active.values())) {
|
|
2734
|
+
if (active.runtime.session === session)
|
|
2735
|
+
return active.runtime;
|
|
2736
|
+
}
|
|
2737
|
+
return undefined;
|
|
2738
|
+
}
|
|
2739
|
+
assertTreeNavigationInactive(session, action) {
|
|
2740
|
+
if (this.treeNavigations.has(session))
|
|
2741
|
+
throw new Error(`Cannot ${action} while session tree navigation is active`);
|
|
2742
|
+
}
|
|
2743
|
+
async runSessionEntryMutation(session, action, operation) {
|
|
2744
|
+
this.beginSessionEntryMutation(session, action);
|
|
2745
|
+
try {
|
|
2746
|
+
return await operation();
|
|
2747
|
+
}
|
|
2748
|
+
finally {
|
|
2749
|
+
this.endSessionEntryMutation(session);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
beginSessionEntryMutation(session, action) {
|
|
2753
|
+
this.assertTreeNavigationInactive(session, action);
|
|
2754
|
+
this.sessionEntryMutationCounts.set(session, (this.sessionEntryMutationCounts.get(session) ?? 0) + 1);
|
|
2755
|
+
this.observeUnreadActivityState(session);
|
|
2756
|
+
}
|
|
2757
|
+
endSessionEntryMutation(session) {
|
|
2758
|
+
const remaining = (this.sessionEntryMutationCounts.get(session) ?? 1) - 1;
|
|
2759
|
+
if (remaining <= 0)
|
|
2760
|
+
this.sessionEntryMutationCounts.delete(session);
|
|
2761
|
+
else
|
|
2762
|
+
this.sessionEntryMutationCounts.set(session, remaining);
|
|
2763
|
+
this.observeUnreadActivityState(session);
|
|
2764
|
+
}
|
|
2765
|
+
isSessionEntryMutationActive(session) {
|
|
2766
|
+
return (this.sessionEntryMutationCounts.get(session) ?? 0) > 0;
|
|
1709
2767
|
}
|
|
1710
2768
|
publishActivityForEvent(session, event) {
|
|
1711
2769
|
const eventType = getString(event, "type");
|
|
@@ -1759,6 +2817,54 @@ export class PiSessionService {
|
|
|
1759
2817
|
if (this.hasActiveWork(session))
|
|
1760
2818
|
this.publishActivity(session, eventType.replaceAll("_", " "), "active");
|
|
1761
2819
|
}
|
|
2820
|
+
/**
|
|
2821
|
+
* Build the reporter for one session construction.
|
|
2822
|
+
*
|
|
2823
|
+
* The session id is known before any await — a `SessionManager` has its id
|
|
2824
|
+
* from construction — so the daemon can name what it is starting even though
|
|
2825
|
+
* the `PiAgentSession` that {@link publishActivity} needs does not exist yet.
|
|
2826
|
+
* Without an id there is nothing to report against, so the reporter stays
|
|
2827
|
+
* silent and the browser keeps its own generic wording.
|
|
2828
|
+
*/
|
|
2829
|
+
startupProgress(sessionManager, intent, startupToken) {
|
|
2830
|
+
const sessionId = sessionManager.getSessionId();
|
|
2831
|
+
if (sessionId === "")
|
|
2832
|
+
return { report: noop, end: noop };
|
|
2833
|
+
const label = intent === "create" ? "Creating session" : "Opening session";
|
|
2834
|
+
return {
|
|
2835
|
+
report: (phase) => { this.publishStartupProgress(sessionId, startupToken, label, "active", this.startupDetail(phase)); },
|
|
2836
|
+
end: () => {
|
|
2837
|
+
// A real activity published during the window (an extension error, say)
|
|
2838
|
+
// is the truth about this session and must survive the clear.
|
|
2839
|
+
if (this.activities.has(sessionId))
|
|
2840
|
+
return;
|
|
2841
|
+
this.publishStartupProgress(sessionId, startupToken, "idle", "idle", undefined);
|
|
2842
|
+
},
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
startupDetail(phase) {
|
|
2846
|
+
return this.catalogRefreshStatus?.isRefreshInFlight() === true
|
|
2847
|
+
? `${phase} · ${STARTUP_CONCURRENT_CATALOG_REFRESH}`
|
|
2848
|
+
: phase;
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* Report startup progress on the global channel only, echoing the caller's
|
|
2852
|
+
* correlation token so a waiting browser row recognises its own construction.
|
|
2853
|
+
*
|
|
2854
|
+
* Unlike {@link publishActivity} this deliberately records nothing: no
|
|
2855
|
+
* `activities` entry, no workspace activity, no unread observation. There is
|
|
2856
|
+
* no session to own that state, and a failed creation would leave it stranded.
|
|
2857
|
+
*
|
|
2858
|
+
* Every report is marked `startup`, which is what keeps a session that is
|
|
2859
|
+
* merely opening from counting as one doing work. This is the only publisher
|
|
2860
|
+
* that sets the marker, and because it writes no `activities` entry no later
|
|
2861
|
+
* heartbeat re-publication can carry it.
|
|
2862
|
+
*/
|
|
2863
|
+
publishStartupProgress(sessionId, startupToken, label, phase, detail) {
|
|
2864
|
+
const at = new Date().toISOString();
|
|
2865
|
+
const activity = detail === undefined ? { sessionId, phase, label, at, startup: true } : { sessionId, phase, label, detail, at, startup: true };
|
|
2866
|
+
this.events.publishGlobal(startupToken === undefined ? { type: "session.startup", activity } : { type: "session.startup", startupToken, activity });
|
|
2867
|
+
}
|
|
1762
2868
|
publishActivity(session, label, phase, detail) {
|
|
1763
2869
|
const at = new Date().toISOString();
|
|
1764
2870
|
const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
|
|
@@ -1767,6 +2873,7 @@ export class PiSessionService {
|
|
|
1767
2873
|
this.workspaceActivity?.applySessionActivity(session.sessionManager.getCwd(), activity);
|
|
1768
2874
|
this.events.publish(session.sessionId, { type: "activity.update", activity });
|
|
1769
2875
|
this.events.publishGlobal({ type: "activity.update", activity });
|
|
2876
|
+
this.observeUnreadActivityState(session);
|
|
1770
2877
|
}
|
|
1771
2878
|
publishStatus(session) {
|
|
1772
2879
|
const status = this.statusFromSession(session);
|
|
@@ -1774,6 +2881,7 @@ export class PiSessionService {
|
|
|
1774
2881
|
this.workspaceActivity?.applySessionStatus(session.sessionManager.getCwd(), status);
|
|
1775
2882
|
this.events.publish(session.sessionId, { type: "status.update", status });
|
|
1776
2883
|
this.events.publishGlobal({ type: "status.update", status });
|
|
2884
|
+
this.observeUnreadActivityState(session);
|
|
1777
2885
|
}
|
|
1778
2886
|
clearStaleActiveActivity(session) {
|
|
1779
2887
|
const current = this.activities.get(session.sessionId);
|
|
@@ -1791,6 +2899,8 @@ export class PiSessionService {
|
|
|
1791
2899
|
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
|
|
1792
2900
|
const contextUsage = session.getContextUsage();
|
|
1793
2901
|
const warnings = this.warningsForSession(session);
|
|
2902
|
+
const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
|
|
2903
|
+
const pendingDialogs = this.pendingExtensionDialogStore.pendingDialogs(session.sessionId);
|
|
1794
2904
|
return {
|
|
1795
2905
|
sessionId: session.sessionId,
|
|
1796
2906
|
persisted: sessionFileExists(session.sessionFile),
|
|
@@ -1806,6 +2916,8 @@ export class PiSessionService {
|
|
|
1806
2916
|
cost: stats.cost,
|
|
1807
2917
|
...(contextUsage === undefined ? {} : { contextUsage }),
|
|
1808
2918
|
...(warnings.length === 0 ? {} : { warnings }),
|
|
2919
|
+
...(pendingAsk === undefined ? {} : { pendingAsk }),
|
|
2920
|
+
...(pendingDialogs.length === 0 ? {} : { pendingDialogs }),
|
|
1809
2921
|
};
|
|
1810
2922
|
}
|
|
1811
2923
|
/**
|
|
@@ -1886,6 +2998,12 @@ function modelToClientModel(model) {
|
|
|
1886
2998
|
...(reasoning === undefined ? {} : { reasoning }),
|
|
1887
2999
|
};
|
|
1888
3000
|
}
|
|
3001
|
+
function notificationIdentityForSession(session) {
|
|
3002
|
+
return {
|
|
3003
|
+
sessionId: session.sessionId,
|
|
3004
|
+
cwd: canonicalizeStoredCwd(session.sessionManager.getCwd()),
|
|
3005
|
+
};
|
|
3006
|
+
}
|
|
1889
3007
|
function clientSessionFromListEntry(session) {
|
|
1890
3008
|
return {
|
|
1891
3009
|
id: session.id,
|
|
@@ -2175,28 +3293,6 @@ function activeSessionFileMatches(active, expectedSessionFile) {
|
|
|
2175
3293
|
function trackedLinkParentFileMatches(link, parentSessionFile) {
|
|
2176
3294
|
return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
|
|
2177
3295
|
}
|
|
2178
|
-
async function readSessionHeaderSummary(sessionFile) {
|
|
2179
|
-
let file;
|
|
2180
|
-
try {
|
|
2181
|
-
file = await open(sessionFile, "r");
|
|
2182
|
-
const buffer = Buffer.alloc(4096);
|
|
2183
|
-
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
2184
|
-
const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
|
|
2185
|
-
if (firstLine === undefined || firstLine === "")
|
|
2186
|
-
return undefined;
|
|
2187
|
-
const header = JSON.parse(firstLine);
|
|
2188
|
-
if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string")
|
|
2189
|
-
return undefined;
|
|
2190
|
-
const parentSession = getString(header, "parentSession");
|
|
2191
|
-
return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) };
|
|
2192
|
-
}
|
|
2193
|
-
catch {
|
|
2194
|
-
return undefined;
|
|
2195
|
-
}
|
|
2196
|
-
finally {
|
|
2197
|
-
await file?.close().catch(() => undefined);
|
|
2198
|
-
}
|
|
2199
|
-
}
|
|
2200
3296
|
async function sessionFileHeaderMatches(sessionFile, expected) {
|
|
2201
3297
|
const header = await readSessionHeaderSummary(sessionFile);
|
|
2202
3298
|
if (header?.id !== expected.sessionId)
|