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