@jmfederico/pi-web 1.202610.0 → 1.202610.2
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 +23 -134
- package/dist/cli.js +23 -4
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/index-DUsqiytv.js +4408 -0
- package/dist/client/assets/vendor-editor-core-C2PJX3uW.js +10 -0
- package/dist/client/assets/{vendor-editor-languages-DOCw3vc9.js → vendor-editor-languages-CS6n1J8n.js} +1 -1
- package/dist/client/index.html +4 -48
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -1
- package/dist/pi-packages/captains-log/dist/browser/index.js +34 -34
- package/dist/pi-packages/captains-log/package.json +5 -5
- package/dist/pi-packages/relays/extensions/index.js +60 -0
- package/dist/pi-packages/relays/package.json +10 -1
- package/dist/pi-packages/relays/pi-web-plugin.js +31 -1
- package/dist/pi-packages/relays/prompts/relay-worktree.md +6 -16
- package/dist/pi-packages/relays/prompts/relay.md +6 -33
- package/dist/pi-packages/relays/relayDispatch.js +67 -0
- package/dist/pi-packages/relays/relayIdentity.js +37 -0
- package/dist/pi-packages/relays/relaysPanelElement.js +37 -12
- package/dist/pi-packages/relays/skills/relay/SKILL.md +24 -68
- package/dist/pi-packages/relays/skills/relay-runner/SKILL.md +40 -219
- package/dist/pi-packages/workflows/browser/pi-web-plugin.js +33 -0
- package/dist/pi-packages/workflows/package.json +22 -0
- package/dist/pi-packages/workflows/prompts/worktree.md +12 -0
- package/dist/pi-web-plugins/files/browser/pi-web-plugin.js +53 -53
- package/dist/pi-web-plugins/git/browser/git-panel.js +0 -1
- package/dist/pi-web-plugins/git/server-plugin.js +11 -0
- package/dist/pi-web-plugins/git/workspace-discovery-notify.cjs +14 -0
- package/dist/pi-web-plugins/git/workspaceDiscoveryHooks.js +180 -0
- package/dist/pi-web-plugins/info/infoInternals.js +3 -3
- package/dist/pi-web-plugins/info/pi-web-plugin.js +1 -1
- package/dist/pi-web-plugins/mermaid/browser/mermaid-engine.js +1077 -414
- package/dist/pi-web-plugins/terminal/browser/pi-web-plugin.js +30 -30
- package/dist/pi-web-plugins/updates/pi-web-plugin.js +2 -2
- package/dist/pi-web-plugins/updates/updatesLogic.js +6 -5
- package/dist/piWebUpdate.js +405 -0
- package/dist/piWebUpdate.js.map +1 -0
- package/dist/piWebVersionReport.js +43 -1
- package/dist/piWebVersionReport.js.map +1 -1
- package/dist/plugin-api.d.ts +229 -8
- package/dist/server/activity/workspaceActivityService.js +4 -1
- package/dist/server/activity/workspaceActivityService.js.map +1 -1
- package/dist/server/app.js +5 -0
- package/dist/server/app.js.map +1 -1
- package/dist/server/configRoutes.js +6 -0
- package/dist/server/configRoutes.js.map +1 -1
- package/dist/server/knownAutoInstallPiPackages.js +6 -0
- package/dist/server/knownAutoInstallPiPackages.js.map +1 -1
- package/dist/server/navigationPreferenceRoutes.js +39 -0
- package/dist/server/navigationPreferenceRoutes.js.map +1 -0
- package/dist/server/piWebStatus.js +19 -25
- package/dist/server/piWebStatus.js.map +1 -1
- package/dist/server/plugins/projectPluginSettings.js +95 -0
- package/dist/server/plugins/projectPluginSettings.js.map +1 -0
- package/dist/server/plugins/serverPluginRuntime.js +26 -3
- package/dist/server/plugins/serverPluginRuntime.js.map +1 -1
- package/dist/server/sessiond/autoInstallPiPackages.js +3 -3
- package/dist/server/sessiond/workspaceCatalogRoutes.js +3 -1
- package/dist/server/sessiond/workspaceCatalogRoutes.js.map +1 -1
- package/dist/server/sessiond/workspaceNotificationRoutes.js +41 -0
- package/dist/server/sessiond/workspaceNotificationRoutes.js.map +1 -0
- package/dist/server/sessiond.js +40 -7
- package/dist/server/sessiond.js.map +1 -1
- package/dist/server/sessions/askUserDomain.js +232 -0
- package/dist/server/sessions/askUserDomain.js.map +1 -0
- package/dist/server/sessions/askUserTool.js +17 -17
- package/dist/server/sessions/askUserTool.js.map +1 -1
- package/dist/server/sessions/builtinExtensionFactories.js +13 -0
- package/dist/server/sessions/builtinExtensionFactories.js.map +1 -0
- package/dist/server/sessions/dockerEnvironmentFacts.js +8 -0
- package/dist/server/sessions/dockerEnvironmentFacts.js.map +1 -1
- package/dist/server/sessions/extensionSessionDispatch.js +76 -0
- package/dist/server/sessions/extensionSessionDispatch.js.map +1 -0
- package/dist/server/sessions/globalProviderPolicy.js +94 -18
- package/dist/server/sessions/globalProviderPolicy.js.map +1 -1
- package/dist/server/sessions/messageHistoryTarget.js +61 -0
- package/dist/server/sessions/messageHistoryTarget.js.map +1 -0
- package/dist/server/sessions/piSessionManagerGateway.js +23 -24
- package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
- package/dist/server/sessions/piSessionService.js +604 -266
- package/dist/server/sessions/piSessionService.js.map +1 -1
- package/dist/server/sessions/sessionArchiveStore.js +5 -1
- package/dist/server/sessions/sessionArchiveStore.js.map +1 -1
- package/dist/server/sessions/sessionArchiveTree.js +2 -2
- package/dist/server/sessions/sessionArchiveTree.js.map +1 -1
- package/dist/server/sessions/sessionCommandService.js +9 -3
- package/dist/server/sessions/sessionCommandService.js.map +1 -1
- package/dist/server/sessions/sessionImageDimensions.js +31 -0
- package/dist/server/sessions/sessionImageDimensions.js.map +1 -0
- package/dist/server/sessions/sessionMediaIndex.js +7 -1
- package/dist/server/sessions/sessionMediaIndex.js.map +1 -1
- package/dist/server/sessions/sessionMetadata.js +51 -0
- package/dist/server/sessions/sessionMetadata.js.map +1 -0
- package/dist/server/sessions/sessionNameGenerator.js +8 -28
- package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
- package/dist/server/sessions/sessionRoutes.js +10 -2
- package/dist/server/sessions/sessionRoutes.js.map +1 -1
- package/dist/server/sessions/sessionSummaryScanner.js +62 -5
- package/dist/server/sessions/sessionSummaryScanner.js.map +1 -1
- package/dist/server/sessions/spawnSessionTool.js +3 -1
- package/dist/server/sessions/spawnSessionTool.js.map +1 -1
- package/dist/server/sessions/spawnSubsessionTool.js +27 -17
- package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
- package/dist/server/sessions/subsessionCompletionDelivery.js +15 -0
- package/dist/server/sessions/subsessionCompletionDelivery.js.map +1 -0
- package/dist/server/sessions/subsessionCompletionInbox.js +134 -0
- package/dist/server/sessions/subsessionCompletionInbox.js.map +1 -0
- package/dist/server/sessions/subsessionProvenance.js +27 -0
- package/dist/server/sessions/subsessionProvenance.js.map +1 -0
- package/dist/server/sessions/transcriptMarkdown.js +48 -0
- package/dist/server/sessions/transcriptMarkdown.js.map +1 -0
- package/dist/server/sessions/transcriptMessages.js +3 -0
- package/dist/server/sessions/transcriptMessages.js.map +1 -1
- package/dist/server/status/machineStatusService.js +17 -5
- package/dist/server/status/machineStatusService.js.map +1 -1
- package/dist/server/storage/navigationPreferenceStore.js +137 -0
- package/dist/server/storage/navigationPreferenceStore.js.map +1 -0
- package/dist/server/workspaces/projectPiWebConfig.js +88 -6
- package/dist/server/workspaces/projectPiWebConfig.js.map +1 -1
- package/dist/server/workspaces/workspaceChangeNotifier.js +12 -0
- package/dist/server/workspaces/workspaceChangeNotifier.js.map +1 -0
- package/dist/server/workspaces/workspaceNotificationCommand.js +64 -0
- package/dist/server/workspaces/workspaceNotificationCommand.js.map +1 -0
- package/dist/server/workspaces/workspaceProviderRegistry.js +55 -0
- package/dist/server/workspaces/workspaceProviderRegistry.js.map +1 -1
- package/dist/server-plugin-api.d.ts +18 -0
- package/dist/server-plugin-api.js.map +1 -1
- package/dist/sessiond/sessionDaemonClient.js +3 -3
- package/dist/sessiond/sessionDaemonClient.js.map +1 -1
- package/dist/shared/apiTypes.js +2 -2
- package/dist/shared/apiTypes.js.map +1 -1
- package/dist/shared/federatedRoutes.js +5 -0
- package/dist/shared/federatedRoutes.js.map +1 -1
- package/dist/shared/machineStatus.js +11 -2
- package/dist/shared/machineStatus.js.map +1 -1
- package/dist/shared/navigationPreferences.js +6 -0
- package/dist/shared/navigationPreferences.js.map +1 -0
- package/dist/shared/pluginApiTypes.d.ts +2 -0
- package/dist/shared/sessionMedia.js +6 -0
- package/dist/shared/sessionMedia.js.map +1 -1
- package/dist/shared/streamingBehavior.js +7 -0
- package/dist/shared/streamingBehavior.js.map +1 -0
- package/dist/shared/themePreference.js +23 -0
- package/dist/shared/themePreference.js.map +1 -0
- package/dist/workspaceNotificationCli.js +95 -0
- package/dist/workspaceNotificationCli.js.map +1 -0
- package/docs/config.md +100 -85
- package/docs/plugins.md +109 -30
- package/examples/session-bridge-plugin/README.md +1 -1
- package/examples/session-bridge-plugin/package.json +3 -3
- package/examples/workspace-provider-plugin/package.json +1 -1
- package/package.json +36 -36
- package/dist/client/assets/index-_1GwJfSs.js +0 -4176
- package/dist/client/assets/vendor-editor-core-YWog6mAR.js +0 -10
- package/dist/server/sessions/pendingAskStore.js +0 -275
- package/dist/server/sessions/pendingAskStore.js.map +0 -1
|
@@ -1,19 +1,24 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
1
3
|
import { statSync } from "node:fs";
|
|
2
4
|
import { join } from "node:path";
|
|
3
5
|
import { readFile, writeFile } from "node:fs/promises";
|
|
4
6
|
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEventBus, createEditToolDefinition, defineTool, hasTrustRequiringProjectResources, ProjectTrustStore, readStoredCredential, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
5
7
|
import { projectBrowserMessage } from "../browserMessageProjection.js";
|
|
6
8
|
import { isSessionMediaId } from "../../shared/sessionMedia.js";
|
|
9
|
+
import { getBuiltinExtensionFactories } from "./builtinExtensionFactories.js";
|
|
7
10
|
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
|
8
11
|
import { clientSessionFirstMessagePreview } from "./clientSessionPreview.js";
|
|
9
12
|
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
|
10
13
|
import { SessionCommandService } from "./sessionCommandService.js";
|
|
11
14
|
import { SessionActivityMarker } from "./sessionActivityMarker.js";
|
|
12
15
|
import { projectSessionTree } from "./sessionTreeProjection.js";
|
|
16
|
+
import { messageHistoryTargetId } from "./messageHistoryTarget.js";
|
|
13
17
|
import { SessionArchiveStore } from "./sessionArchiveStore.js";
|
|
14
|
-
import {
|
|
18
|
+
import { findArchiveCandidateByExactId, planSessionArchiveTree } from "./sessionArchiveTree.js";
|
|
15
19
|
import { PiSessionEventConnections } from "./piSessionEventConnections.js";
|
|
16
|
-
import {
|
|
20
|
+
import { fallbackSessionName, generateShortSessionName, requireInitialSessionName } from "./sessionNameGenerator.js";
|
|
21
|
+
import { createExtensionSessionDispatch } from "./extensionSessionDispatch.js";
|
|
17
22
|
import { computeEditPreview } from "./editPreview.js";
|
|
18
23
|
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
|
19
24
|
import { loadEffectiveProjectAttachmentsConfig } from "../workspaces/projectPiWebConfig.js";
|
|
@@ -21,19 +26,24 @@ import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
|
|
21
26
|
import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
|
|
22
27
|
import { parseSessionDefaults, parseSessionDefaultsUpdate } from "../../shared/sessionDefaults.js";
|
|
23
28
|
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
|
29
|
+
import { parsePersistedChildSubsessionLink, parsePersistedParentSubsessionLink } from "./subsessionProvenance.js";
|
|
24
30
|
import { readSessionHeaderSummary } from "./sessionFileHeader.js";
|
|
25
31
|
import { createAskUserToolDefinition } from "./askUserTool.js";
|
|
26
|
-
import {
|
|
32
|
+
import { createAskUserOutcome, renderAskUserAnswersText, resolveAskUserRequest } from "./askUserDomain.js";
|
|
27
33
|
import { PendingExtensionDialogStore } from "./pendingExtensionDialogStore.js";
|
|
28
34
|
import { ExtensionDialogWaiters, effectiveExtensionDialogTimeoutMs, extensionDialogCancelValue } from "./extensionDialogWaiters.js";
|
|
29
35
|
import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS } from "../../config.js";
|
|
30
36
|
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
|
|
37
|
+
import { readSessionUiMetadata, requireSessionUiMetadata, SESSION_UI_METADATA_CUSTOM_TYPE } from "./sessionMetadata.js";
|
|
31
38
|
import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
|
|
32
39
|
import { buildTranscriptView } from "./subsessionTranscript.js";
|
|
40
|
+
import { createSubsessionCompletionDelivery } from "./subsessionCompletionDelivery.js";
|
|
41
|
+
import { completionReceiptIds, persistedSubsessionCompletions, renderSubsessionCompletions, SUBSESSION_COMPLETION_ENTRY_TYPE, SUBSESSION_NOTIFICATION_CUSTOM_TYPE, SUBSESSION_RECEIPT_ENTRY_TYPE, SubsessionCompletionInbox } from "./subsessionCompletionInbox.js";
|
|
33
42
|
import { annotateAssistantThinkingLevel, historyMessagesFromEntries } from "./transcriptMessages.js";
|
|
34
43
|
import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
|
|
35
44
|
import { SessionNotificationStore, } from "./sessionNotificationStore.js";
|
|
36
45
|
import { plainTextTheme } from "./plainTextTheme.js";
|
|
46
|
+
import { projectTranscriptMarkdown } from "./transcriptMarkdown.js";
|
|
37
47
|
import { SessionUnreadStore } from "./sessionUnreadStore.js";
|
|
38
48
|
import { applyEnabledModelToggle, catalogWithEnabledFirst, modelScopeId, persistedEnabledModelPatterns, resolveEnabledModelIds, resolveSessionModelOptions, scopedModelsFromEnabledIds } from "./sessionModelScope.js";
|
|
39
49
|
const noopLogger = { info() { } };
|
|
@@ -288,14 +298,14 @@ function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, ini
|
|
|
288
298
|
});
|
|
289
299
|
};
|
|
290
300
|
}
|
|
291
|
-
export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions,
|
|
301
|
+
export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, askUserEnabled = false) {
|
|
292
302
|
return [
|
|
293
303
|
createPiWebEditToolDefinition(cwd),
|
|
294
304
|
...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
|
|
295
305
|
...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
|
|
296
306
|
// Asking the user is not delegation: the questions land in the session the
|
|
297
307
|
// user is already watching, so tracked children may ask too.
|
|
298
|
-
...(
|
|
308
|
+
...(askUserEnabled ? [createAskUserToolDefinition()] : []),
|
|
299
309
|
];
|
|
300
310
|
}
|
|
301
311
|
/**
|
|
@@ -428,7 +438,7 @@ export function piWebResourceLoaderOptions(appendSystemPromptSections) {
|
|
|
428
438
|
return undefined;
|
|
429
439
|
return { appendSystemPromptOverride: (base) => [...base, ...appendSystemPromptSections] };
|
|
430
440
|
}
|
|
431
|
-
function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManagers, spawn, subsessions,
|
|
441
|
+
function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManagers, spawn, subsessions, askUserEnabled = false, appendSystemPromptSections = []) {
|
|
432
442
|
const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
|
|
433
443
|
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
|
|
434
444
|
// PI WEB always honors pi's project-trust model. When the workspace ships
|
|
@@ -442,6 +452,14 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
|
|
|
442
452
|
// (matching `pi` run without a UI). Projects without trust-requiring
|
|
443
453
|
// resources skip resolution entirely and are trusted, as before.
|
|
444
454
|
const eventBus = createEventBus();
|
|
455
|
+
const builtinFactories = await getBuiltinExtensionFactories();
|
|
456
|
+
// Resolved before session_start. The bridge shares the same delegation
|
|
457
|
+
// policy as core tools, including tracked-child and global disablement.
|
|
458
|
+
let dispatchEnabled = false;
|
|
459
|
+
const sessionDispatch = createExtensionSessionDispatch(cwd, {
|
|
460
|
+
...(spawn === undefined ? {} : { spawn }),
|
|
461
|
+
isEnabled: () => dispatchEnabled,
|
|
462
|
+
});
|
|
445
463
|
const projectTrustRequiring = hasTrustRequiringProjectResources(cwd);
|
|
446
464
|
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: !projectTrustRequiring });
|
|
447
465
|
// Pre-session-creation trust failures (`project_trust` handler errors)
|
|
@@ -453,7 +471,10 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
|
|
|
453
471
|
agentDir,
|
|
454
472
|
modelRuntime,
|
|
455
473
|
settingsManager,
|
|
456
|
-
resourceLoaderOptions: {
|
|
474
|
+
resourceLoaderOptions: {
|
|
475
|
+
...resourceLoaderOptions, eventBus,
|
|
476
|
+
extensionFactories: [...builtinFactories, sessionDispatch, ...(subsessions === undefined ? [] : [createSubsessionCompletionDelivery(subsessions)])],
|
|
477
|
+
},
|
|
457
478
|
...(projectTrustRequiring
|
|
458
479
|
? {
|
|
459
480
|
resourceLoaderReloadOptions: {
|
|
@@ -477,7 +498,8 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
|
|
|
477
498
|
services.diagnostics.push(...modelOptions.diagnostics);
|
|
478
499
|
const resolvedDelegationToolsEnabled = delegationToolsEnabled
|
|
479
500
|
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
|
480
|
-
|
|
501
|
+
dispatchEnabled = resolvedDelegationToolsEnabled;
|
|
502
|
+
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUserEnabled);
|
|
481
503
|
const result = await createAgentSessionFromServices({
|
|
482
504
|
services,
|
|
483
505
|
sessionManager,
|
|
@@ -534,8 +556,12 @@ export class PiSessionService {
|
|
|
534
556
|
* can anchor the selected branch on disk.
|
|
535
557
|
*/
|
|
536
558
|
this.unpersistedTreeBranchLeaves = new WeakMap();
|
|
559
|
+
/** Latest prepared text belongs to the session, never to a browser's unsent draft. */
|
|
560
|
+
this.suggestedInputs = new Map();
|
|
537
561
|
/** Counts async operations that may append an entry before they settle. */
|
|
538
562
|
this.sessionEntryMutationCounts = new WeakMap();
|
|
563
|
+
/** Exempt only a command's own prompt receipt while it invokes guarded session control. */
|
|
564
|
+
this.extensionCommandExecution = new AsyncLocalStorage();
|
|
539
565
|
this.pendingPromptEchoes = new WeakMap();
|
|
540
566
|
this.publishedAssistantPartials = new WeakMap();
|
|
541
567
|
/** Settings-wide queue preventing enabled-model read/modify/write races across sessions. */
|
|
@@ -551,7 +577,11 @@ export class PiSessionService {
|
|
|
551
577
|
/** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
|
|
552
578
|
this.treeExclusiveRuntimeOperationCounts = new WeakMap();
|
|
553
579
|
this.treeExclusiveSessionOperationCounts = new Map();
|
|
554
|
-
|
|
580
|
+
/** Parent id/file identity -> one inbox shared by all completion delivery paths. */
|
|
581
|
+
this.subsessionInboxes = new Map();
|
|
582
|
+
this.subsessionStoppedParents = new WeakSet();
|
|
583
|
+
this.subsessionDeliveringParents = new WeakSet();
|
|
584
|
+
this.subsessionDeliveryScheduled = new WeakSet();
|
|
555
585
|
this.deferredGeneratedSessionNames = new WeakMap();
|
|
556
586
|
this.compactionPromptQueues = new Map();
|
|
557
587
|
this.compactionDrainTimers = new Map();
|
|
@@ -592,7 +622,6 @@ export class PiSessionService {
|
|
|
592
622
|
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
|
|
593
623
|
this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
|
|
594
624
|
this.onUnreadChanged = deps.onUnreadChanged;
|
|
595
|
-
this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
|
|
596
625
|
this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
|
|
597
626
|
this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
|
|
598
627
|
this.catalogRefreshStatus = deps.catalogRefreshStatus;
|
|
@@ -606,9 +635,11 @@ export class PiSessionService {
|
|
|
606
635
|
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.sessionEvents, this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
|
|
607
636
|
spawn: (input) => this.spawnSubsession(input),
|
|
608
637
|
list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
638
|
+
join: (parentSessionId, parentSessionFile, toolCallId) => this.joinSubsessions(parentSessionId, parentSessionFile, toolCallId),
|
|
639
|
+
prepare: (parentSessionId, parentSessionFile) => this.prepareSubsessionDelivery(parentSessionId, parentSessionFile),
|
|
640
|
+
check: (parentSessionId, sessionId, parentSessionFile, toolCallId) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile, toolCallId),
|
|
641
|
+
read: (parentSessionId, sessionId, query, parentSessionFile, toolCallId) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile, toolCallId),
|
|
642
|
+
}, deps.askUserEnabled === true, deps.appendSystemPromptSections ?? []);
|
|
612
643
|
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
|
613
644
|
this.workspaceActivity = deps.workspaceActivity;
|
|
614
645
|
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
|
|
@@ -725,12 +756,12 @@ export class PiSessionService {
|
|
|
725
756
|
const activeSessions = Array.from(new Set(this.active.values()));
|
|
726
757
|
for (const active of activeSessions) {
|
|
727
758
|
this.forgetUnreadActivity(active.runtime.session);
|
|
728
|
-
this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
|
|
729
759
|
this.endSessionExtensionDialogs(active.runtime.session.sessionId);
|
|
730
760
|
}
|
|
731
761
|
this.active.clear();
|
|
732
762
|
this.pendingSessionOpens.clear();
|
|
733
763
|
this.startupSessions.clear();
|
|
764
|
+
this.suggestedInputs.clear();
|
|
734
765
|
this.activities.clear();
|
|
735
766
|
this.compactionPromptQueues.clear();
|
|
736
767
|
this.authLossWarnings.clear();
|
|
@@ -739,6 +770,7 @@ export class PiSessionService {
|
|
|
739
770
|
this.subsessionLinks.clear();
|
|
740
771
|
this.subsessionHydratedParents.clear();
|
|
741
772
|
this.subsessionNotifyArmed.clear();
|
|
773
|
+
this.subsessionInboxes.clear();
|
|
742
774
|
this.notificationStore.clearAll("service-dispose");
|
|
743
775
|
await Promise.all(activeSessions.map(async (active) => {
|
|
744
776
|
active.unsubscribe();
|
|
@@ -751,6 +783,7 @@ export class PiSessionService {
|
|
|
751
783
|
await active.runtime.dispose();
|
|
752
784
|
}
|
|
753
785
|
})).finally(async () => {
|
|
786
|
+
this.extensionCommandExecution.disable();
|
|
754
787
|
this.events.mediaIndex.clear();
|
|
755
788
|
await this.activityMarker.dispose();
|
|
756
789
|
});
|
|
@@ -764,7 +797,29 @@ export class PiSessionService {
|
|
|
764
797
|
for (const record of archivedForCwd) {
|
|
765
798
|
this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
|
|
766
799
|
}
|
|
767
|
-
const
|
|
800
|
+
const unarchivedById = new Map(sessions.filter((session) => !archivedById.has(session.id))
|
|
801
|
+
.map((session) => [session.id, clientSessionFromListEntry(session)]));
|
|
802
|
+
// Pi can fork before the first conversational entry without creating a file.
|
|
803
|
+
// Enumerate already-hosted runtimes only; listing must not open cold sessions.
|
|
804
|
+
for (const active of new Set(this.active.values())) {
|
|
805
|
+
const session = active.runtime.session;
|
|
806
|
+
if (!cwdPathsEqual(session.sessionManager.getCwd(), cwd) || archivedById.has(session.sessionId))
|
|
807
|
+
continue;
|
|
808
|
+
const listed = unarchivedById.get(session.sessionId);
|
|
809
|
+
const tracked = this.subsessionLinkForActiveChild(session) !== undefined;
|
|
810
|
+
if (listed !== undefined) {
|
|
811
|
+
// Match the file too: a clone with the same id must not inherit a
|
|
812
|
+
// live runtime's classification. Disk rows otherwise use the scanner.
|
|
813
|
+
if (tracked && session.sessionFile !== undefined && sessionPathsEqual(listed.path, session.sessionFile))
|
|
814
|
+
listed.agentSubsession = true;
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
const info = clientSessionFromActiveSession(session);
|
|
818
|
+
if (tracked)
|
|
819
|
+
info.agentSubsession = true;
|
|
820
|
+
unarchivedById.set(session.sessionId, info);
|
|
821
|
+
}
|
|
822
|
+
const unarchivedSessions = [...unarchivedById.values()].sort((a, b) => Date.parse(b.modified) - Date.parse(a.modified));
|
|
768
823
|
const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
|
|
769
824
|
this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
|
|
770
825
|
await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
|
|
@@ -812,29 +867,37 @@ export class PiSessionService {
|
|
|
812
867
|
}
|
|
813
868
|
}
|
|
814
869
|
async startSession(cwd, options) {
|
|
815
|
-
const
|
|
870
|
+
const metadata = options.metadata === undefined ? undefined : requireSessionUiMetadata(options.metadata);
|
|
871
|
+
const name = options.name === undefined ? undefined : requireInitialSessionName(options.name);
|
|
872
|
+
const manager = this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession });
|
|
873
|
+
if (metadata !== undefined) {
|
|
874
|
+
if (manager.appendCustomEntry === undefined)
|
|
875
|
+
throw new Error("Session manager does not support UI metadata initialization");
|
|
876
|
+
manager.appendCustomEntry(SESSION_UI_METADATA_CUSTOM_TYPE, { version: 1, metadata });
|
|
877
|
+
}
|
|
878
|
+
if (name !== undefined) {
|
|
879
|
+
if (manager.appendSessionInfo === undefined)
|
|
880
|
+
throw new Error("Session manager does not support initial naming");
|
|
881
|
+
manager.appendSessionInfo(name);
|
|
882
|
+
}
|
|
883
|
+
const active = await this.create(manager, cwd, {
|
|
816
884
|
startupIntent: "create",
|
|
817
885
|
...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
|
|
818
886
|
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
|
819
887
|
...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
|
|
820
888
|
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
|
|
821
889
|
});
|
|
822
|
-
return
|
|
890
|
+
return options.deferAnnouncement === true
|
|
891
|
+
? clientSessionFromActiveSession(active.runtime.session)
|
|
892
|
+
: this.announceCreatedSession(active, cwd, options.parentSession);
|
|
823
893
|
}
|
|
824
|
-
announceCreatedSession(active, cwd, parentSession) {
|
|
894
|
+
announceCreatedSession(active, cwd, parentSession, agentSubsession) {
|
|
825
895
|
const { session } = active.runtime;
|
|
826
896
|
const created = {
|
|
827
|
-
|
|
828
|
-
path: session.sessionFile ?? "",
|
|
897
|
+
...clientSessionFromActiveSession(session),
|
|
829
898
|
cwd,
|
|
830
|
-
persisted: sessionFileExists(session.sessionFile),
|
|
831
|
-
created: new Date().toISOString(),
|
|
832
|
-
modified: new Date().toISOString(),
|
|
833
|
-
messageCount: session.messages.length,
|
|
834
|
-
firstMessage: "",
|
|
835
|
-
// Include the parent so listeners can nest the new session in the tree
|
|
836
|
-
// immediately, instead of showing it flat until the next reload.
|
|
837
899
|
...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
|
|
900
|
+
...(agentSubsession === true ? { agentSubsession: true } : {}),
|
|
838
901
|
};
|
|
839
902
|
// Broadcast so other clients (and the spawning agent's UI) can add the new
|
|
840
903
|
// session to their list without a manual reload.
|
|
@@ -858,6 +921,8 @@ export class PiSessionService {
|
|
|
858
921
|
? input.model
|
|
859
922
|
: await this.resolveSpawnModel({ id: input.spawningSessionId, cwd: input.spawningCwd }, input.modelSpec);
|
|
860
923
|
const created = await this.start(decision.cwd, {
|
|
924
|
+
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
|
925
|
+
...(input.name === undefined ? {} : { name: input.name }),
|
|
861
926
|
...(model === undefined ? {} : { initialModel: model }),
|
|
862
927
|
...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
|
|
863
928
|
});
|
|
@@ -899,6 +964,7 @@ export class PiSessionService {
|
|
|
899
964
|
...(model === undefined ? {} : { initialModel: model }),
|
|
900
965
|
...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
|
|
901
966
|
creationProvenance: "tracked-subsession",
|
|
967
|
+
deferAnnouncement: true,
|
|
902
968
|
});
|
|
903
969
|
const modelUsed = this.active.get(created.id)?.runtime.session.model;
|
|
904
970
|
const parentSessionFile = nonEmptyString(input.parentSessionFile);
|
|
@@ -912,6 +978,13 @@ export class PiSessionService {
|
|
|
912
978
|
await this.registerVerifiedSubsession(link);
|
|
913
979
|
this.persistSubsessionLink(link);
|
|
914
980
|
this.persistSubsessionChildMarker(input.parentSessionId, created.id);
|
|
981
|
+
const active = this.active.get(created.id);
|
|
982
|
+
if (active === undefined)
|
|
983
|
+
throw new Error("Tracked subsession disappeared during creation");
|
|
984
|
+
// Freshly created children are known by this exact runtime/file link even
|
|
985
|
+
// before Pi flushes its buffered custom entries. Reopened sessions only
|
|
986
|
+
// enter this registry after reciprocal persisted provenance validation.
|
|
987
|
+
this.announceCreatedSession(active, decision.cwd, input.parentSessionFile, this.subsessionLinkForActiveChild(active.runtime.session) !== undefined);
|
|
915
988
|
await this.prompt(created, input.prompt);
|
|
916
989
|
this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
|
|
917
990
|
return {
|
|
@@ -1052,83 +1125,30 @@ export class PiSessionService {
|
|
|
1052
1125
|
throw unknownSpawnModelError(modelSpec);
|
|
1053
1126
|
return model;
|
|
1054
1127
|
}
|
|
1055
|
-
/**
|
|
1056
|
-
* Register the question set an agent wants the user to answer as the session's
|
|
1057
|
-
* open ask. Deliberately does not wait for the user: `ask_user` terminates the
|
|
1058
|
-
* run and the submitted answers come back later as a follow-up message.
|
|
1059
|
-
*
|
|
1060
|
-
* Rejected question sets throw {@link PendingAskValidationError}, which the
|
|
1061
|
-
* agent loop reports to the model as an error tool result.
|
|
1062
|
-
*/
|
|
1063
|
-
// 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.
|
|
1064
|
-
async openAsk(input) {
|
|
1065
|
-
const result = this.pendingAskStore.open(input);
|
|
1066
|
-
// A supersede closes the earlier ask, so the browsers watching it must hear
|
|
1067
|
-
// that before they hear about its replacement.
|
|
1068
|
-
if (result.superseded !== undefined)
|
|
1069
|
-
this.publishAskClosed(input.sessionId, result.superseded);
|
|
1070
|
-
this.events.publish(input.sessionId, { type: "ask.opened", ask: result.ask });
|
|
1071
|
-
this.publishStatusForSessionId(input.sessionId);
|
|
1072
|
-
return result;
|
|
1073
|
-
}
|
|
1074
|
-
/**
|
|
1075
|
-
* Record the user's answers to the session's open ask and hand them to the
|
|
1076
|
-
* model. The answers travel as a system-authored custom message rather than a
|
|
1077
|
-
* user message, so they are not attributed to the human in the transcript;
|
|
1078
|
-
* they still wake an idle session (`triggerTurn`) and queue behind in-flight
|
|
1079
|
-
* work (`deliverAs: "followUp"`), which is how the run that `ask_user`
|
|
1080
|
-
* terminated continues.
|
|
1081
|
-
*/
|
|
1128
|
+
/** Reply to a durable question entry, even after later conversation or replies. */
|
|
1082
1129
|
async submitAsk(ref, askId, submission) {
|
|
1083
1130
|
await this.assertWritable(ref);
|
|
1084
1131
|
const session = await this.getOrOpen(ref);
|
|
1085
|
-
// Checked before the store closes the ask so a refused delivery cannot
|
|
1086
|
-
// discard answers the user already submitted.
|
|
1087
1132
|
this.assertTreeNavigationInactive(session, "answer questions");
|
|
1088
|
-
|
|
1133
|
+
const ask = resolveAskUserRequest(await this.readableSessionBranch(ref, session), askId);
|
|
1134
|
+
const outcome = createAskUserOutcome(ask, submission, this.now().toISOString());
|
|
1135
|
+
return this.deliverAskOutcome(session, outcome);
|
|
1089
1136
|
}
|
|
1090
|
-
/**
|
|
1091
|
-
* Close the open ask without answers. The model is still told, naming every
|
|
1092
|
-
* question as unanswered: it was promised a follow-up message and would
|
|
1093
|
-
* otherwise wait for one that never comes.
|
|
1094
|
-
*/
|
|
1137
|
+
/** Skip is also a transcript reply; it does not invalidate future answers. */
|
|
1095
1138
|
async cancelAsk(ref, askId) {
|
|
1096
1139
|
await this.assertWritable(ref);
|
|
1097
1140
|
const session = await this.getOrOpen(ref);
|
|
1098
|
-
this.assertTreeNavigationInactive(session, "
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
*/
|
|
1107
|
-
async closeAsk(session, result) {
|
|
1108
|
-
if (result.status === "stale")
|
|
1109
|
-
return { result: "stale", sessionStatus: this.statusFromSession(session) };
|
|
1110
|
-
const { outcome } = result;
|
|
1111
|
-
this.publishAskClosed(session.sessionId, outcome);
|
|
1141
|
+
this.assertTreeNavigationInactive(session, "skip questions");
|
|
1142
|
+
const ask = resolveAskUserRequest(await this.readableSessionBranch(ref, session), askId);
|
|
1143
|
+
return this.deliverAskOutcome(session, createAskUserOutcome(ask, undefined, this.now().toISOString()));
|
|
1144
|
+
}
|
|
1145
|
+
async deliverAskOutcome(session, outcome) {
|
|
1146
|
+
// Pi persists/displays the structured message and wakes an idle agent, or
|
|
1147
|
+
// queues it behind in-flight work. Ordinary entry-mutation protection still
|
|
1148
|
+
// applies, but there is no answer-specific stale/duplicate gate.
|
|
1112
1149
|
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" }));
|
|
1113
1150
|
this.publishStatus(session);
|
|
1114
|
-
return
|
|
1115
|
-
}
|
|
1116
|
-
publishAskClosed(sessionId, outcome) {
|
|
1117
|
-
this.events.publish(sessionId, { type: "ask.closed", askId: outcome.askId, reason: outcome.reason });
|
|
1118
|
-
}
|
|
1119
|
-
/**
|
|
1120
|
-
* Void the session's open ask because the user sent a chat message instead of
|
|
1121
|
-
* answering it. Every browser closes the card as cancelled, and the model is
|
|
1122
|
-
* told — without being woken — so the notice rides into the turn the message
|
|
1123
|
-
* itself triggers rather than becoming a turn of its own.
|
|
1124
|
-
*/
|
|
1125
|
-
async voidOpenAskForUserMessage(session) {
|
|
1126
|
-
const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
|
|
1127
|
-
if (outcome === undefined)
|
|
1128
|
-
return;
|
|
1129
|
-
this.publishAskClosed(session.sessionId, outcome);
|
|
1130
|
-
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" }));
|
|
1131
|
-
this.publishStatus(session);
|
|
1151
|
+
return this.statusFromSession(session);
|
|
1132
1152
|
}
|
|
1133
1153
|
/**
|
|
1134
1154
|
* Record the user's answer to an open extension dialog and resolve the
|
|
@@ -1277,27 +1297,120 @@ export class PiSessionService {
|
|
|
1277
1297
|
const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId));
|
|
1278
1298
|
return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
|
|
1279
1299
|
}
|
|
1300
|
+
/** Collect ready answers before deciding whether the parent needs to wait. */
|
|
1301
|
+
async joinSubsessions(parentSessionId, parentSessionFile, toolCallId) {
|
|
1302
|
+
const subsessions = await this.listSubsessions(parentSessionId, parentSessionFile);
|
|
1303
|
+
this.reconcileSubsessionCompletions(parentSessionId, parentSessionFile);
|
|
1304
|
+
const completions = this.subsessionInbox(parentSessionId, parentSessionFile).pending();
|
|
1305
|
+
this.receiveSubsessionCompletions(parentSessionId, parentSessionFile, completions, toolCallId);
|
|
1306
|
+
return { subsessions, completions };
|
|
1307
|
+
}
|
|
1280
1308
|
/** Status and final result of a subsession, scoped to the caller's children. */
|
|
1281
|
-
async checkSubsession(parentSessionId, sessionId, parentSessionFile) {
|
|
1309
|
+
async checkSubsession(parentSessionId, sessionId, parentSessionFile, toolCallId) {
|
|
1282
1310
|
const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
|
|
1311
|
+
// Finalize the same completion even if its event/heartbeat notification has
|
|
1312
|
+
// not run yet. Inspection must not leave a future notice armed for this run.
|
|
1313
|
+
this.reconcileSubsessionCompletion(session);
|
|
1314
|
+
const status = this.subsessionStatus(session);
|
|
1283
1315
|
const messages = historyMessages(session);
|
|
1316
|
+
const completion = this.subsessionCompletionForOutput(parentSessionId, parentSessionFile, session, messages);
|
|
1317
|
+
if (status !== "working" && completion !== undefined)
|
|
1318
|
+
this.receiveSubsessionCompletions(parentSessionId, parentSessionFile, [completion], toolCallId);
|
|
1284
1319
|
return {
|
|
1285
1320
|
sessionId,
|
|
1286
1321
|
cwd: session.sessionManager.getCwd(),
|
|
1287
|
-
status
|
|
1322
|
+
status,
|
|
1288
1323
|
finalText: finalAssistantText(messages),
|
|
1289
1324
|
messageCount: messages.length,
|
|
1325
|
+
...(status === "working" || completion?.errorDetail === undefined ? {} : { errorDetail: completion.errorDetail }),
|
|
1290
1326
|
};
|
|
1291
1327
|
}
|
|
1292
|
-
/** Filtered
|
|
1293
|
-
async readSubsession(parentSessionId, sessionId, query, parentSessionFile) {
|
|
1328
|
+
/** Filtered transcript reads acknowledge only the final answer actually returned. */
|
|
1329
|
+
async readSubsession(parentSessionId, sessionId, query, parentSessionFile, toolCallId) {
|
|
1294
1330
|
const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
|
|
1295
|
-
|
|
1331
|
+
this.reconcileSubsessionCompletion(session);
|
|
1332
|
+
const messages = historyMessages(session);
|
|
1333
|
+
const view = buildTranscriptView(messages, query);
|
|
1334
|
+
const status = this.subsessionStatus(session);
|
|
1335
|
+
const completion = this.subsessionCompletionForOutput(parentSessionId, parentSessionFile, session, messages);
|
|
1336
|
+
const errorDetail = status === "working" ? undefined : completion?.errorDetail;
|
|
1337
|
+
if (status !== "working" && completion !== undefined
|
|
1338
|
+
&& (transcriptReturnsFinalOutput(messages, view, completion.finalText) || (completion.finalText === "" && errorDetail !== undefined))) {
|
|
1339
|
+
this.receiveSubsessionCompletions(parentSessionId, parentSessionFile, [completion], toolCallId);
|
|
1340
|
+
}
|
|
1341
|
+
return { sessionId, cwd: session.sessionManager.getCwd(), status, ...view, ...(errorDetail === undefined ? {} : { errorDetail }) };
|
|
1342
|
+
}
|
|
1343
|
+
subsessionInbox(parentSessionId, parentSessionFile) {
|
|
1344
|
+
const file = nonEmptyString(parentSessionFile) ?? nonEmptyString(this.active.get(parentSessionId)?.runtime.session.sessionFile);
|
|
1345
|
+
const key = subsessionHydratedParentKey(parentSessionId, file);
|
|
1346
|
+
let inbox = this.subsessionInboxes.get(key);
|
|
1347
|
+
if (inbox === undefined) {
|
|
1348
|
+
inbox = new SubsessionCompletionInbox();
|
|
1349
|
+
this.subsessionInboxes.set(key, inbox);
|
|
1350
|
+
}
|
|
1351
|
+
return inbox;
|
|
1352
|
+
}
|
|
1353
|
+
restoreSubsessionReceipts(session, releaseClaims = true) {
|
|
1354
|
+
this.subsessionInbox(session.sessionId, session.sessionFile).restoreReceipts(session.sessionManager.getBranch(), releaseClaims);
|
|
1355
|
+
}
|
|
1356
|
+
subsessionCompletionForOutput(parentId, parentFile, child, messages) {
|
|
1357
|
+
const branch = persistedSubsessionCompletions(child.sessionManager.getBranch()).filter(({ sessionId }) => sessionId === child.sessionId);
|
|
1358
|
+
// A child can be rewound independently of its parent. Never acknowledge an
|
|
1359
|
+
// abandoned branch's newer result while returning the current branch's text.
|
|
1360
|
+
const completion = branch.at(-1) ?? (child.sessionManager.appendCustomEntry === undefined
|
|
1361
|
+
? this.subsessionInbox(parentId, parentFile).latest(child.sessionId) : undefined);
|
|
1362
|
+
return completion?.finalText === finalAssistantText(messages) ? completion : undefined;
|
|
1363
|
+
}
|
|
1364
|
+
receiveSubsessionCompletions(parentId, parentFile, completions, toolCallId) {
|
|
1365
|
+
if (completions.length === 0)
|
|
1366
|
+
return;
|
|
1367
|
+
const parent = this.active.get(parentId)?.runtime.session;
|
|
1368
|
+
if (parent === undefined || (parentFile !== undefined && !sessionFileMatches(parent, parentFile))) {
|
|
1369
|
+
throw new Error(`Parent session ${parentId} is not available to receive subsession results`);
|
|
1370
|
+
}
|
|
1371
|
+
const inbox = this.subsessionInbox(parentId, parentFile);
|
|
1372
|
+
const completionIds = completions.map(({ completionId }) => completionId).filter((id) => !inbox.hasReceived(id));
|
|
1373
|
+
if (completionIds.length === 0)
|
|
1374
|
+
return;
|
|
1375
|
+
// Persist before removing unread results. A storage failure leaves the
|
|
1376
|
+
// answer unread and fails the tool instead of losing the notification.
|
|
1377
|
+
this.assertTreeNavigationInactive(parent, "receive subsession results");
|
|
1378
|
+
parent.sessionManager.appendCustomEntry?.(SUBSESSION_RECEIPT_ENTRY_TYPE, { completionIds, ...(toolCallId === undefined ? {} : { toolCallId }) });
|
|
1379
|
+
inbox.receive(completionIds);
|
|
1380
|
+
}
|
|
1381
|
+
reconcileSubsessionCompletions(parentId, parentFile) {
|
|
1382
|
+
for (const childId of this.subsessionChildren.get(parentId) ?? []) {
|
|
1383
|
+
if (!this.subsessionLinkBelongsToParent(parentId, nonEmptyString(parentFile), childId))
|
|
1384
|
+
continue;
|
|
1385
|
+
const link = this.subsessionLinks.get(childId);
|
|
1386
|
+
const child = link === undefined ? undefined : this.activeChildForSubsessionLink(link)?.runtime.session;
|
|
1387
|
+
if (child !== undefined)
|
|
1388
|
+
this.reconcileSubsessionCompletion(child);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
reconcileSubsessionCompletion(session) {
|
|
1392
|
+
this.updateSubsessionTracking(session);
|
|
1393
|
+
if (!this.hasActiveWork(session) && this.subsessionNotifyArmed.get(session.sessionId) === true) {
|
|
1394
|
+
throw new Error(`Could not record completion of subsession ${session.sessionId}; its result remains unread. Retry after fixing session storage.`);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
/** Claims commit on entry_appended (boundary drafts) or message_end (idle wakes). */
|
|
1398
|
+
async prepareSubsessionDelivery(parentId, parentFile) {
|
|
1399
|
+
await this.hydrateSubsessionsForParent(parentId, nonEmptyString(parentFile));
|
|
1400
|
+
const parent = this.active.get(parentId)?.runtime.session;
|
|
1401
|
+
if (parent === undefined || (parentFile !== undefined && !sessionFileMatches(parent, parentFile)))
|
|
1402
|
+
return undefined;
|
|
1403
|
+
// Tool receipts are intents until the successful (possibly nested) result
|
|
1404
|
+
// is persisted. Reconcile those intents without releasing in-flight drafts.
|
|
1405
|
+
this.restoreSubsessionReceipts(parent, false);
|
|
1406
|
+
this.reconcileSubsessionCompletions(parentId, parentFile);
|
|
1407
|
+
const completions = this.subsessionInbox(parentId, parentFile).claim();
|
|
1408
|
+
if (completions.length === 0)
|
|
1409
|
+
return undefined;
|
|
1296
1410
|
return {
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
...view,
|
|
1411
|
+
type: "custom_message", customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, display: true,
|
|
1412
|
+
content: renderSubsessionCompletions(completions, this.workingSubsessionIds(parentId)),
|
|
1413
|
+
details: { sessionId: completions[0]?.sessionId, completionIds: completions.map(({ completionId }) => completionId) },
|
|
1301
1414
|
};
|
|
1302
1415
|
}
|
|
1303
1416
|
/** Open a session after verifying it is one of the caller's tracked children. */
|
|
@@ -1351,6 +1464,14 @@ export class PiSessionService {
|
|
|
1351
1464
|
this.subsessionLinks.set(childSessionId, link);
|
|
1352
1465
|
if (!this.subsessionNotifyArmed.has(childSessionId))
|
|
1353
1466
|
this.subsessionNotifyArmed.set(childSessionId, false);
|
|
1467
|
+
const childManager = this.activeChildForSubsessionLink(link)?.runtime.session.sessionManager
|
|
1468
|
+
?? (link.childSessionFile === undefined ? undefined : this.sessionManager.open(link.childSessionFile));
|
|
1469
|
+
if (childManager !== undefined) {
|
|
1470
|
+
for (const completion of persistedSubsessionCompletions(childManager.getEntries?.() ?? childManager.getBranch())) {
|
|
1471
|
+
if (completion.sessionId === childSessionId)
|
|
1472
|
+
this.subsessionInbox(parentSessionId, link.parentSessionFile).record(completion);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1354
1475
|
const cwd = this.cwdForVerifiedSubsession(link);
|
|
1355
1476
|
await this.publishUnreadMutations(this.unreadStore.excludeSession(childSessionId, cwd));
|
|
1356
1477
|
}
|
|
@@ -1375,6 +1496,8 @@ export class PiSessionService {
|
|
|
1375
1496
|
this.subsessionParents.delete(childSessionId);
|
|
1376
1497
|
this.subsessionLinks.delete(childSessionId);
|
|
1377
1498
|
this.subsessionNotifyArmed.delete(childSessionId);
|
|
1499
|
+
for (const inbox of this.subsessionInboxes.values())
|
|
1500
|
+
inbox.forgetChild(childSessionId);
|
|
1378
1501
|
if (parentSessionId === undefined)
|
|
1379
1502
|
return;
|
|
1380
1503
|
const children = this.subsessionChildren.get(parentSessionId);
|
|
@@ -1531,7 +1654,7 @@ export class PiSessionService {
|
|
|
1531
1654
|
* working. Prompt failures before agent_start explicitly arm the same latch.
|
|
1532
1655
|
* Standalone commands and compactions must not arm a completion notice.
|
|
1533
1656
|
*/
|
|
1534
|
-
updateSubsessionTracking(session, agentStarted = false) {
|
|
1657
|
+
updateSubsessionTracking(session, agentStarted = false, agentSettled = false) {
|
|
1535
1658
|
const link = this.subsessionLinkForActiveChild(session);
|
|
1536
1659
|
if (link === undefined)
|
|
1537
1660
|
return;
|
|
@@ -1540,22 +1663,31 @@ export class PiSessionService {
|
|
|
1540
1663
|
this.subsessionNotifyArmed.set(childId, true);
|
|
1541
1664
|
return;
|
|
1542
1665
|
}
|
|
1543
|
-
|
|
1666
|
+
// Native settlement is authoritative even while the host prompt Promise's
|
|
1667
|
+
// mutation receipt is unwinding. Capture now, before another run can start.
|
|
1668
|
+
if (!agentSettled && this.hasActiveWork(session))
|
|
1544
1669
|
return;
|
|
1545
1670
|
if (this.subsessionNotifyArmed.get(childId) !== true)
|
|
1546
1671
|
return;
|
|
1547
|
-
this.subsessionNotifyArmed.set(childId, false);
|
|
1548
1672
|
const activity = this.activities.get(childId);
|
|
1549
|
-
const
|
|
1550
|
-
const
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1673
|
+
const messages = historyMessages(session);
|
|
1674
|
+
const completion = {
|
|
1675
|
+
completionId: randomUUID(), sessionId: childId, cwd: session.sessionManager.getCwd(),
|
|
1676
|
+
status: activity?.phase === "error" ? "error" : "idle",
|
|
1677
|
+
finalText: finalAssistantText(messages), messageCount: messages.length,
|
|
1678
|
+
...(activity?.phase !== "error" || activity.detail === undefined ? {} : { errorDetail: activity.detail }),
|
|
1679
|
+
};
|
|
1680
|
+
try {
|
|
1681
|
+
session.sessionManager.appendCustomEntry?.(SUBSESSION_COMPLETION_ENTRY_TYPE, completion);
|
|
1682
|
+
}
|
|
1683
|
+
catch (error) {
|
|
1684
|
+
// Keep the latch armed so a later reconciliation can retry persistence.
|
|
1685
|
+
this.logSubsessionNotificationFailure(link.parentSessionId, childId, error);
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
this.subsessionNotifyArmed.set(childId, false);
|
|
1689
|
+
this.subsessionInbox(link.parentSessionId, link.parentSessionFile).record(completion);
|
|
1690
|
+
void this.wakeParentForSubsession(link.parentSessionId, childId);
|
|
1559
1691
|
}
|
|
1560
1692
|
async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
|
|
1561
1693
|
const link = this.subsessionLinks.get(childSessionId);
|
|
@@ -1573,35 +1705,69 @@ export class PiSessionService {
|
|
|
1573
1705
|
const sessionManager = this.sessionManager.open(parentSessionFile);
|
|
1574
1706
|
return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session;
|
|
1575
1707
|
}
|
|
1576
|
-
|
|
1577
|
-
* Deliver a subsession-completion notice to the parent as a system-authored
|
|
1578
|
-
* custom message rather than a user message, so it is not attributed to the
|
|
1579
|
-
* human in the transcript. It still wakes an idle parent (`triggerTurn`) and
|
|
1580
|
-
* queues behind in-flight work (`deliverAs: "followUp"`), preserving the
|
|
1581
|
-
* established "queue if busy, send and act if idle" behavior.
|
|
1582
|
-
*/
|
|
1583
|
-
async notifyParentOfSubsession(parentId, childId, text) {
|
|
1708
|
+
async wakeParentForSubsession(parentId, childId) {
|
|
1584
1709
|
try {
|
|
1585
1710
|
const session = await this.getOrOpenParentForSubsession(parentId, childId);
|
|
1586
|
-
|
|
1587
|
-
const pending = this.deferredSubsessionNotifications.get(session) ?? [];
|
|
1588
|
-
pending.push({ parentId, childId, text });
|
|
1589
|
-
this.deferredSubsessionNotifications.set(session, pending);
|
|
1590
|
-
return;
|
|
1591
|
-
}
|
|
1592
|
-
await this.deliverSubsessionNotification(session, { parentId, childId, text });
|
|
1711
|
+
this.scheduleSubsessionDelivery(session);
|
|
1593
1712
|
}
|
|
1594
1713
|
catch (error) {
|
|
1595
1714
|
this.logSubsessionNotificationFailure(parentId, childId, error);
|
|
1596
1715
|
}
|
|
1597
1716
|
}
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
this.
|
|
1717
|
+
/** Busy parents keep results in the inbox; never enqueue a second copy in Pi. */
|
|
1718
|
+
async deliverPendingSubsessionCompletions(session) {
|
|
1719
|
+
if (this.hasActiveWork(session) || !this.isCurrentActiveSession(session) || this.subsessionStoppedParents.has(session))
|
|
1720
|
+
return;
|
|
1721
|
+
const inbox = this.subsessionInbox(session.sessionId, session.sessionFile);
|
|
1722
|
+
const completions = inbox.claim();
|
|
1723
|
+
if (completions.length === 0)
|
|
1724
|
+
return;
|
|
1725
|
+
const completionIds = completions.map(({ completionId }) => completionId);
|
|
1726
|
+
this.subsessionDeliveringParents.add(session);
|
|
1727
|
+
let delivered = false;
|
|
1728
|
+
try {
|
|
1729
|
+
await this.runSessionEntryMutation(session, "deliver subsession results", () => session.sendCustomMessage({
|
|
1730
|
+
customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, display: true,
|
|
1731
|
+
content: renderSubsessionCompletions(completions, this.workingSubsessionIds(session.sessionId)),
|
|
1732
|
+
details: { sessionId: completions[0]?.sessionId, completionIds },
|
|
1733
|
+
}, { triggerTurn: true }));
|
|
1734
|
+
inbox.receive(completionIds);
|
|
1735
|
+
delivered = true;
|
|
1736
|
+
}
|
|
1737
|
+
catch (error) {
|
|
1738
|
+
this.subsessionStoppedParents.add(session);
|
|
1739
|
+
throw error;
|
|
1740
|
+
}
|
|
1741
|
+
finally {
|
|
1742
|
+
inbox.release(completionIds);
|
|
1743
|
+
this.subsessionDeliveringParents.delete(session);
|
|
1744
|
+
if (delivered)
|
|
1745
|
+
this.scheduleSubsessionDelivery(session);
|
|
1746
|
+
this.publishStatus(session);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
scheduleSubsessionDelivery(session) {
|
|
1750
|
+
if (this.subsessionDeliveryScheduled.has(session) || this.subsessionInbox(session.sessionId, session.sessionFile).pending().length === 0)
|
|
1751
|
+
return;
|
|
1752
|
+
this.subsessionDeliveryScheduled.add(session);
|
|
1753
|
+
// Settlement can precede the host prompt-mutation receipt's finally block.
|
|
1754
|
+
// Recheck on the next tick, closing the yield/child-completion race without
|
|
1755
|
+
// restarting a stopped run or putting rendered answers in a follow-up queue.
|
|
1756
|
+
setTimeout(() => {
|
|
1757
|
+
this.subsessionDeliveryScheduled.delete(session);
|
|
1758
|
+
void this.deliverPendingSubsessionCompletions(session).catch((error) => {
|
|
1759
|
+
this.logSubsessionNotificationFailure(session.sessionId, "inbox", error);
|
|
1760
|
+
});
|
|
1761
|
+
}, 0);
|
|
1601
1762
|
}
|
|
1602
1763
|
logSubsessionNotificationFailure(parentId, childId, error) {
|
|
1603
1764
|
this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
|
|
1604
1765
|
}
|
|
1766
|
+
browserTranscriptMessage(session, message) {
|
|
1767
|
+
return projectTranscriptMarkdown(message, session.extensionRunner.getMarkdownTransformers(), (error, transformerIndex) => {
|
|
1768
|
+
this.logger.info({ err: error, sessionId: session.sessionId, transformerIndex }, "Transcript Markdown transformer failed");
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1605
1771
|
async transcriptSnapshot(ref, page) {
|
|
1606
1772
|
const session = await this.getOrOpen(ref);
|
|
1607
1773
|
const seqBeforeRead = this.events.currentSeq(session.sessionId);
|
|
@@ -1616,8 +1782,9 @@ export class PiSessionService {
|
|
|
1616
1782
|
const userCount = messages.filter((message) => isRecord(message) && message["role"] === "user").length;
|
|
1617
1783
|
const echoes = (this.pendingPromptEchoes.get(session) ?? []).filter((echo) => echo.userIndex >= userCount);
|
|
1618
1784
|
messages.push(...echoes.map((echo) => echo.message));
|
|
1785
|
+
const messagePage = pageMessagesAtSafeBoundary(messages, page);
|
|
1619
1786
|
return {
|
|
1620
|
-
page:
|
|
1787
|
+
page: { ...messagePage, messages: messagePage.messages.map((message) => this.browserTranscriptMessage(session, message)) },
|
|
1621
1788
|
status: this.statusFromSession(session, transcriptMessageCount(branch) + echoes.length),
|
|
1622
1789
|
seq: this.events.currentSeq(session.sessionId),
|
|
1623
1790
|
partial: this.publishedAssistantPartials.get(session) ?? null,
|
|
@@ -1625,7 +1792,8 @@ export class PiSessionService {
|
|
|
1625
1792
|
}
|
|
1626
1793
|
async messages(ref, page) {
|
|
1627
1794
|
const session = await this.getOrOpen(ref);
|
|
1628
|
-
|
|
1795
|
+
const result = pageMessagesAtSafeBoundary(historyMessagesFromEntries(await this.readableSessionBranch(ref, session)), page);
|
|
1796
|
+
return { ...result, messages: result.messages.map((message) => this.browserTranscriptMessage(session, message)) };
|
|
1629
1797
|
}
|
|
1630
1798
|
async media(ref, mediaId) {
|
|
1631
1799
|
if (!isSessionMediaId(mediaId))
|
|
@@ -1899,11 +2067,6 @@ export class PiSessionService {
|
|
|
1899
2067
|
this.publishStatus(session);
|
|
1900
2068
|
return;
|
|
1901
2069
|
}
|
|
1902
|
-
// A chat message answers the session's open ask in the user's own words, so
|
|
1903
|
-
// the form is void: keeping it open would invite answers to questions the
|
|
1904
|
-
// conversation has already moved past. Ignored duplicates skip this on
|
|
1905
|
-
// purpose: they must not void an ask posted after the queued original.
|
|
1906
|
-
await this.voidOpenAskForUserMessage(session);
|
|
1907
2070
|
if (session.isCompacting) {
|
|
1908
2071
|
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
|
|
1909
2072
|
return;
|
|
@@ -1924,7 +2087,7 @@ export class PiSessionService {
|
|
|
1924
2087
|
// SDK input hooks may await before appending the user message. Keep the
|
|
1925
2088
|
// already-published echo visible in snapshots until that append occurs.
|
|
1926
2089
|
this.pendingPromptEchoes.set(session, [...echoes, echo]);
|
|
1927
|
-
this.events.publish(session.sessionId, { type: "message.append", message: echo.message }, { id: session.sessionId, cwd: session.sessionManager.getCwd() });
|
|
2090
|
+
this.events.publish(session.sessionId, { type: "message.append", message: this.browserTranscriptMessage(session, echo.message) }, { id: session.sessionId, cwd: session.sessionManager.getCwd() });
|
|
1928
2091
|
}
|
|
1929
2092
|
const promptOptions = buildPromptOptions(behavior, images);
|
|
1930
2093
|
const commandName = text.startsWith("/") ? text.slice(1).split(" ")[0] : undefined;
|
|
@@ -1936,7 +2099,12 @@ export class PiSessionService {
|
|
|
1936
2099
|
if (getString(event, "type") === "agent_start")
|
|
1937
2100
|
agentStarted = true;
|
|
1938
2101
|
});
|
|
1939
|
-
const
|
|
2102
|
+
const submission = { session, active: true };
|
|
2103
|
+
const isExtensionCommand = session.extensionRunner.getRegisteredCommands().some((command) => command.invocationName === commandName);
|
|
2104
|
+
const promptPromise = this.runSessionEntryMutation(session, "send a prompt", () => isExtensionCommand
|
|
2105
|
+
? this.extensionCommandExecution.run({ submission }, () => session.prompt(text, promptOptions))
|
|
2106
|
+
: session.prompt(text, promptOptions)).finally(() => {
|
|
2107
|
+
submission.active = false;
|
|
1940
2108
|
unsubscribe?.();
|
|
1941
2109
|
if (echo !== undefined) {
|
|
1942
2110
|
const remaining = (this.pendingPromptEchoes.get(session) ?? []).filter((pending) => pending !== echo);
|
|
@@ -1947,6 +2115,7 @@ export class PiSessionService {
|
|
|
1947
2115
|
}
|
|
1948
2116
|
});
|
|
1949
2117
|
void promptPromise.catch((error) => {
|
|
2118
|
+
this.subsessionStoppedParents.add(session);
|
|
1950
2119
|
const message = error instanceof Error ? error.message : String(error);
|
|
1951
2120
|
this.publishActivity(session, "error", "error", message);
|
|
1952
2121
|
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
@@ -2089,15 +2258,20 @@ export class PiSessionService {
|
|
|
2089
2258
|
return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
|
|
2090
2259
|
}
|
|
2091
2260
|
async navigateTree(ref, request) {
|
|
2092
|
-
|
|
2261
|
+
return this.navigateSessionTree(ref, request.targetId, request.expectedLeafId, sessionTreeNavigationOptions(request), undefined, request.retainCheckpoint);
|
|
2262
|
+
}
|
|
2263
|
+
async navigateSessionTree(ref, targetId, expectedLeafId, options, expectedSession, retainCheckpoint = false) {
|
|
2264
|
+
if (targetId.trim() === "")
|
|
2093
2265
|
throw new Error("Session tree target is required");
|
|
2094
2266
|
if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
|
|
2095
2267
|
throw new Error("Stop current session activity before navigating the session tree");
|
|
2096
2268
|
}
|
|
2097
2269
|
await this.assertWritable(ref);
|
|
2098
|
-
const options = sessionTreeNavigationOptions(request);
|
|
2099
2270
|
const session = await this.getOrOpen(ref);
|
|
2100
|
-
if (
|
|
2271
|
+
if (expectedSession !== undefined && session !== expectedSession)
|
|
2272
|
+
throw new Error("This extension command belongs to an unavailable session runtime");
|
|
2273
|
+
const navigateTree = session.navigateTree?.bind(session);
|
|
2274
|
+
if (navigateTree === undefined)
|
|
2101
2275
|
throw new Error("Session tree navigation is not supported by this Pi runtime");
|
|
2102
2276
|
if (this.hasActiveWork(session))
|
|
2103
2277
|
throw new Error("Stop current session activity before navigating the session tree");
|
|
@@ -2106,13 +2280,22 @@ export class PiSessionService {
|
|
|
2106
2280
|
this.treeNavigations.add(session);
|
|
2107
2281
|
try {
|
|
2108
2282
|
const oldLeafId = session.sessionManager.getLeafId();
|
|
2109
|
-
if (oldLeafId !==
|
|
2283
|
+
if (oldLeafId !== expectedLeafId) {
|
|
2110
2284
|
throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
|
|
2111
2285
|
}
|
|
2112
|
-
|
|
2113
|
-
|
|
2286
|
+
if (retainCheckpoint)
|
|
2287
|
+
targetId = messageHistoryTargetId(session.sessionManager.getBranch(), targetId);
|
|
2288
|
+
const checkpointTail = retainCheckpoint ? session.sessionManager.getBranch().find((entry) => isRecord(entry) && entry["id"] === targetId) : undefined;
|
|
2289
|
+
const retainCustomMessage = isRecord(checkpointTail) && checkpointTail["type"] === "custom_message";
|
|
2290
|
+
if (retainCustomMessage && options.summarize === true) {
|
|
2291
|
+
throw new Error("Retaining a custom-message checkpoint requires navigation without a branch summary");
|
|
2292
|
+
}
|
|
2293
|
+
const activeEditableTargetParentId = retainCustomMessage ? undefined : activeEditableTreeTargetParentId(session.sessionManager, targetId, oldLeafId);
|
|
2294
|
+
this.publishActivity(session, options.summarize === true ? "summarizing branch" : "navigating session tree", "active");
|
|
2114
2295
|
this.publishStatus(session);
|
|
2115
|
-
let result =
|
|
2296
|
+
let result = retainCustomMessage
|
|
2297
|
+
? await navigateTreeRetainingCustomMessage(session.sessionManager, targetId, checkpointTail["parentId"], () => navigateTree(targetId, options))
|
|
2298
|
+
: await navigateTree(targetId, options);
|
|
2116
2299
|
if (activeEditableTargetParentId !== undefined
|
|
2117
2300
|
&& !result.cancelled
|
|
2118
2301
|
&& result.editorText === undefined
|
|
@@ -2124,14 +2307,14 @@ export class PiSessionService {
|
|
|
2124
2307
|
// extraction, agent-context rebuilding, and tree extension events.
|
|
2125
2308
|
setSessionTreeLeaf(session.sessionManager, activeEditableTargetParentId);
|
|
2126
2309
|
try {
|
|
2127
|
-
result = await
|
|
2310
|
+
result = await navigateTree(targetId, options);
|
|
2128
2311
|
}
|
|
2129
2312
|
catch (error) {
|
|
2130
|
-
session.sessionManager.branch(
|
|
2313
|
+
session.sessionManager.branch(targetId);
|
|
2131
2314
|
throw error;
|
|
2132
2315
|
}
|
|
2133
2316
|
if (result.cancelled)
|
|
2134
|
-
session.sessionManager.branch(
|
|
2317
|
+
session.sessionManager.branch(targetId);
|
|
2135
2318
|
}
|
|
2136
2319
|
if (result.cancelled) {
|
|
2137
2320
|
if (this.isCurrentActiveSession(session)) {
|
|
@@ -2139,13 +2322,6 @@ export class PiSessionService {
|
|
|
2139
2322
|
}
|
|
2140
2323
|
return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
|
|
2141
2324
|
}
|
|
2142
|
-
if (session.sessionManager.getLeafId() !== oldLeafId) {
|
|
2143
|
-
// Questions belong to the position we left. Close them for every browser
|
|
2144
|
-
// without appending a cancellation message or waking the rewound session.
|
|
2145
|
-
const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
|
|
2146
|
-
if (outcome !== undefined)
|
|
2147
|
-
this.publishAskClosed(session.sessionId, outcome);
|
|
2148
|
-
}
|
|
2149
2325
|
if (result.summaryEntry !== undefined) {
|
|
2150
2326
|
// A summary entry durably identifies the selected branch as the file's
|
|
2151
2327
|
// newest leaf, superseding any earlier bare selection.
|
|
@@ -2159,6 +2335,8 @@ export class PiSessionService {
|
|
|
2159
2335
|
this.unpersistedTreeBranchLeaves.set(session, selectedLeafId);
|
|
2160
2336
|
}
|
|
2161
2337
|
}
|
|
2338
|
+
this.setSuggestedInput(session.sessionId, result.editorText);
|
|
2339
|
+
this.events.publish(session.sessionId, { type: "session.tree.changed" });
|
|
2162
2340
|
if (this.isCurrentActiveSession(session))
|
|
2163
2341
|
this.publishActivity(session, "session tree navigated", "idle");
|
|
2164
2342
|
return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
|
|
@@ -2179,7 +2357,6 @@ export class PiSessionService {
|
|
|
2179
2357
|
}
|
|
2180
2358
|
else {
|
|
2181
2359
|
this.deferredGeneratedSessionNames.delete(session);
|
|
2182
|
-
this.deferredSubsessionNotifications.delete(session);
|
|
2183
2360
|
}
|
|
2184
2361
|
}
|
|
2185
2362
|
}
|
|
@@ -2190,6 +2367,9 @@ export class PiSessionService {
|
|
|
2190
2367
|
* about to join rather than the forked-from record.
|
|
2191
2368
|
*/
|
|
2192
2369
|
async forkFromTree(ref, request) {
|
|
2370
|
+
return this.forkSessionTree(ref, request);
|
|
2371
|
+
}
|
|
2372
|
+
async forkSessionTree(ref, request, options, expectedSession) {
|
|
2193
2373
|
if (request.entryId.trim() === "")
|
|
2194
2374
|
throw new Error("Session tree entry is required");
|
|
2195
2375
|
if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
|
|
@@ -2197,6 +2377,8 @@ export class PiSessionService {
|
|
|
2197
2377
|
}
|
|
2198
2378
|
await this.assertWritable(ref);
|
|
2199
2379
|
const session = await this.getOrOpen(ref);
|
|
2380
|
+
if (expectedSession !== undefined && session !== expectedSession)
|
|
2381
|
+
throw new Error("This extension command belongs to an unavailable session runtime");
|
|
2200
2382
|
if (this.hasActiveWork(session))
|
|
2201
2383
|
throw new Error("Stop current session activity before forking the session tree");
|
|
2202
2384
|
if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
|
|
@@ -2205,8 +2387,15 @@ export class PiSessionService {
|
|
|
2205
2387
|
this.publishActivity(session, "forking session from entry", "active");
|
|
2206
2388
|
this.publishStatus(session);
|
|
2207
2389
|
try {
|
|
2208
|
-
|
|
2390
|
+
// forkEntry rechecks this same leaf after asynchronous naming and inside
|
|
2391
|
+
// the replacement gate, so the resolved branch cutoff cannot go stale.
|
|
2392
|
+
const entryId = request.retainCheckpoint === true
|
|
2393
|
+
? messageHistoryTargetId(session.sessionManager.getBranch(), request.entryId)
|
|
2394
|
+
: request.entryId;
|
|
2395
|
+
const result = await this.commandService.forkEntry(session.sessionId, entryId, {
|
|
2396
|
+
...options,
|
|
2209
2397
|
expectedLeafId: request.expectedLeafId,
|
|
2398
|
+
expectedSession: session,
|
|
2210
2399
|
});
|
|
2211
2400
|
if (result.type === "unsupported")
|
|
2212
2401
|
throw new Error(result.message);
|
|
@@ -2219,6 +2408,8 @@ export class PiSessionService {
|
|
|
2219
2408
|
}
|
|
2220
2409
|
return { cancelled: true };
|
|
2221
2410
|
}
|
|
2411
|
+
this.setSuggestedInput(result.session.id, result.promptDraft);
|
|
2412
|
+
this.events.publish(session.sessionId, { type: "session.tree.changed" });
|
|
2222
2413
|
const forkedSession = this.active.get(result.session.id)?.runtime.session;
|
|
2223
2414
|
if (forkedSession !== undefined && this.isCurrentActiveSession(forkedSession)) {
|
|
2224
2415
|
this.publishActivity(forkedSession, "session forked", "idle");
|
|
@@ -2267,9 +2458,16 @@ export class PiSessionService {
|
|
|
2267
2458
|
this.publishActivity(session, "reloading resources", "active");
|
|
2268
2459
|
const priorGeneration = this.notificationGenerationBySession.get(session);
|
|
2269
2460
|
let candidateGeneration;
|
|
2461
|
+
const reloadBoundary = { beforeSessionStart: false };
|
|
2270
2462
|
try {
|
|
2271
|
-
await session.reload(
|
|
2463
|
+
await session.reload({
|
|
2272
2464
|
beforeSessionStart: () => {
|
|
2465
|
+
// The SDK invalidated the old runner. Settle its dialogs before
|
|
2466
|
+
// replacement session_start handlers can open new ones.
|
|
2467
|
+
this.endSessionExtensionDialogs(session.sessionId);
|
|
2468
|
+
reloadBoundary.beforeSessionStart = true;
|
|
2469
|
+
if (priorGeneration === undefined)
|
|
2470
|
+
return;
|
|
2273
2471
|
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
|
|
2274
2472
|
this.notificationGenerationBySession.set(session, candidateGeneration);
|
|
2275
2473
|
this.replaceSessionNotificationContext(session, candidateGeneration);
|
|
@@ -2282,6 +2480,10 @@ export class PiSessionService {
|
|
|
2282
2480
|
this.publishStatus(session);
|
|
2283
2481
|
}
|
|
2284
2482
|
catch (error) {
|
|
2483
|
+
// Settings/resource loading can fail after SDK invalidation but
|
|
2484
|
+
// before the hook. Never sweep replacement session_start dialogs.
|
|
2485
|
+
if (!reloadBoundary.beforeSessionStart)
|
|
2486
|
+
this.endSessionExtensionDialogs(session.sessionId);
|
|
2285
2487
|
if (candidateGeneration !== undefined) {
|
|
2286
2488
|
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
|
|
2287
2489
|
this.notificationGenerationBySession.set(session, candidateGeneration);
|
|
@@ -2390,7 +2592,7 @@ export class PiSessionService {
|
|
|
2390
2592
|
async archiveTree(ref) {
|
|
2391
2593
|
const session = await this.getOrOpen(ref);
|
|
2392
2594
|
const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
|
|
2393
|
-
const root =
|
|
2595
|
+
const root = findArchiveCandidateByExactId(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
|
|
2394
2596
|
const plan = planSessionArchiveTree(root, catalog);
|
|
2395
2597
|
const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
|
|
2396
2598
|
if (busy !== undefined)
|
|
@@ -2536,6 +2738,7 @@ export class PiSessionService {
|
|
|
2536
2738
|
return;
|
|
2537
2739
|
const sessionId = active.runtime.session.sessionId;
|
|
2538
2740
|
this.clearCompactionPromptQueue(sessionId);
|
|
2741
|
+
this.subsessionStoppedParents.add(active.runtime.session);
|
|
2539
2742
|
clearSessionQueue(active.runtime.session);
|
|
2540
2743
|
// Settle run-scoped dialogs now, at abort-request time: pi's agent loop
|
|
2541
2744
|
// waits for a parked `tool_call` dialog handler before it can emit
|
|
@@ -2706,6 +2909,7 @@ export class PiSessionService {
|
|
|
2706
2909
|
if (pendingOpens.length > 0)
|
|
2707
2910
|
await Promise.allSettled(pendingOpens);
|
|
2708
2911
|
const active = this.active.get(sessionId);
|
|
2912
|
+
this.suggestedInputs.delete(sessionId);
|
|
2709
2913
|
if (notificationPolicy.kind === "clear") {
|
|
2710
2914
|
const generation = active === undefined ? undefined : this.notificationGenerationBySession.get(active.runtime.session);
|
|
2711
2915
|
const mutations = generation === undefined
|
|
@@ -2716,11 +2920,8 @@ export class PiSessionService {
|
|
|
2716
2920
|
if (!active)
|
|
2717
2921
|
return;
|
|
2718
2922
|
this.forgetUnreadActivity(active.runtime.session);
|
|
2719
|
-
//
|
|
2720
|
-
//
|
|
2721
|
-
this.pendingAskStore.forgetSession(sessionId);
|
|
2722
|
-
// Open dialogs share that stance, but their extension waiters are parked
|
|
2723
|
-
// Promises inside the dying runtime: settle them rather than dropping them.
|
|
2923
|
+
// Extension dialog waiters are parked Promises inside the dying runtime:
|
|
2924
|
+
// settle them rather than dropping them.
|
|
2724
2925
|
this.endSessionExtensionDialogs(sessionId);
|
|
2725
2926
|
this.active.delete(sessionId);
|
|
2726
2927
|
this.events.mediaIndex.forgetSession({ id: sessionId, cwd: active.runtime.session.sessionManager.getCwd() });
|
|
@@ -2898,26 +3099,12 @@ export class PiSessionService {
|
|
|
2898
3099
|
return { id: sessionId, cwd: active.runtime.cwd };
|
|
2899
3100
|
}
|
|
2900
3101
|
activeForRef(ref) {
|
|
2901
|
-
const
|
|
2902
|
-
|
|
2903
|
-
if (exact !== undefined && refMatchesActiveSession(ref, exact))
|
|
2904
|
-
return exact;
|
|
2905
|
-
for (const [candidateId, active] of this.active.entries()) {
|
|
2906
|
-
if (candidateId.startsWith(sessionId) && refMatchesActiveSession(ref, active))
|
|
2907
|
-
return active;
|
|
2908
|
-
}
|
|
2909
|
-
return undefined;
|
|
3102
|
+
const active = this.active.get(ref.id);
|
|
3103
|
+
return active !== undefined && refMatchesActiveSession(ref, active) ? active : undefined;
|
|
2910
3104
|
}
|
|
2911
3105
|
startupSessionForRef(ref) {
|
|
2912
|
-
const
|
|
2913
|
-
|
|
2914
|
-
if (exact !== undefined && refMatchesStartupSession(ref, exact))
|
|
2915
|
-
return exact;
|
|
2916
|
-
for (const [candidateId, session] of this.startupSessions.entries()) {
|
|
2917
|
-
if (candidateId.startsWith(sessionId) && refMatchesStartupSession(ref, session))
|
|
2918
|
-
return session;
|
|
2919
|
-
}
|
|
2920
|
-
return undefined;
|
|
3106
|
+
const session = this.startupSessions.get(ref.id);
|
|
3107
|
+
return session !== undefined && refMatchesStartupSession(ref, session) ? session : undefined;
|
|
2921
3108
|
}
|
|
2922
3109
|
/**
|
|
2923
3110
|
* The session to serve a read-only status or a dialog close for, while it
|
|
@@ -3026,7 +3213,13 @@ export class PiSessionService {
|
|
|
3026
3213
|
// holds for this session; settle those waits before the new
|
|
3027
3214
|
// runtime's extensions can open fresh dialogs under the same id.
|
|
3028
3215
|
this.endSessionExtensionDialogs(boundSession.sessionId);
|
|
3216
|
+
const changedIdentity = boundSession.sessionId !== session.sessionId;
|
|
3029
3217
|
boundSession = session;
|
|
3218
|
+
// Publish existence as soon as status/dialog APIs can address it,
|
|
3219
|
+
// before session_start or arbitrary withSession code can park on a question.
|
|
3220
|
+
this.startupSessions.set(session.sessionId, session);
|
|
3221
|
+
if (changedIdentity)
|
|
3222
|
+
this.announceCreatedSession(active, session.sessionManager.getCwd());
|
|
3030
3223
|
await this.bindSessionExtensions(session, candidateGeneration);
|
|
3031
3224
|
if (candidateGeneration !== undefined) {
|
|
3032
3225
|
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
|
|
@@ -3101,6 +3294,7 @@ export class PiSessionService {
|
|
|
3101
3294
|
await session.bindExtensions({
|
|
3102
3295
|
uiContext,
|
|
3103
3296
|
mode: "rpc",
|
|
3297
|
+
commandContextActions: this.extensionCommandActions(session),
|
|
3104
3298
|
onError: (error) => {
|
|
3105
3299
|
const message = `${error.extensionPath}: ${error.error}`;
|
|
3106
3300
|
this.publishActivity(session, "extension error", "error", message);
|
|
@@ -3115,6 +3309,83 @@ export class PiSessionService {
|
|
|
3115
3309
|
this.startupSessions.delete(session.sessionId);
|
|
3116
3310
|
}
|
|
3117
3311
|
}
|
|
3312
|
+
extensionCommandActions(session) {
|
|
3313
|
+
const ref = { id: session.sessionId, cwd: session.sessionManager.getCwd() };
|
|
3314
|
+
return {
|
|
3315
|
+
waitForIdle: () => {
|
|
3316
|
+
this.assertCurrentExtensionSession(session);
|
|
3317
|
+
return session.waitForIdle();
|
|
3318
|
+
},
|
|
3319
|
+
navigateTree: (targetId, options) => {
|
|
3320
|
+
const expectedLeafId = session.sessionManager.getLeafId();
|
|
3321
|
+
return this.runExtensionSessionControl(session, async () => {
|
|
3322
|
+
const result = await this.navigateSessionTree(ref, targetId, expectedLeafId, options ?? { summarize: false }, session);
|
|
3323
|
+
return { cancelled: result.cancelled };
|
|
3324
|
+
});
|
|
3325
|
+
},
|
|
3326
|
+
fork: (entryId, options) => {
|
|
3327
|
+
const expectedLeafId = session.sessionManager.getLeafId();
|
|
3328
|
+
const withSession = options?.withSession;
|
|
3329
|
+
let completeReplacement;
|
|
3330
|
+
const forkOptions = {
|
|
3331
|
+
...options,
|
|
3332
|
+
position: options?.position ?? "before",
|
|
3333
|
+
// Capture Pi's fresh context, but run user code after the host's
|
|
3334
|
+
// replacement reservation is released so fresh controls can work.
|
|
3335
|
+
...(withSession === undefined ? {} : { withSession: (ctx) => {
|
|
3336
|
+
completeReplacement = () => withSession(ctx);
|
|
3337
|
+
return Promise.resolve();
|
|
3338
|
+
} }),
|
|
3339
|
+
};
|
|
3340
|
+
return this.runExtensionSessionControl(session, async () => {
|
|
3341
|
+
const runtime = this.active.get(ref.id)?.runtime;
|
|
3342
|
+
const result = await this.forkSessionTree(ref, { entryId, expectedLeafId }, forkOptions, session);
|
|
3343
|
+
if (!result.cancelled) {
|
|
3344
|
+
try {
|
|
3345
|
+
await completeReplacement?.();
|
|
3346
|
+
}
|
|
3347
|
+
catch (error) {
|
|
3348
|
+
// The fork is committed. Report callback failure on the current
|
|
3349
|
+
// runtime (which may have forked again), including its recoverable inbox.
|
|
3350
|
+
const current = runtime?.session;
|
|
3351
|
+
if (current !== undefined && this.isCurrentActiveSession(current)) {
|
|
3352
|
+
const message = errorMessage(error);
|
|
3353
|
+
current.extensionRunner.getUIContext().notify(message, "error");
|
|
3354
|
+
this.publishActivity(current, "extension callback failed", "error", message);
|
|
3355
|
+
this.events.publish(current.sessionId, { type: "session.error", message });
|
|
3356
|
+
}
|
|
3357
|
+
throw error;
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
return { cancelled: result.cancelled };
|
|
3361
|
+
});
|
|
3362
|
+
},
|
|
3363
|
+
reload: () => this.runExtensionSessionControl(session, () => this.reloadSessionRuntime(session)),
|
|
3364
|
+
// These need host-owned creation/selection policies, not SDK runtime-only
|
|
3365
|
+
// replacement. Fail visibly until PI WEB supplies that integration.
|
|
3366
|
+
newSession: () => Promise.reject(new Error("ctx.newSession() is not supported in PI WEB. Start a new session from the web UI.")),
|
|
3367
|
+
switchSession: () => Promise.reject(new Error("ctx.switchSession() is not supported in PI WEB. Select a session from the web UI.")),
|
|
3368
|
+
};
|
|
3369
|
+
}
|
|
3370
|
+
setSuggestedInput(sessionId, text) {
|
|
3371
|
+
if (text === undefined || text === "")
|
|
3372
|
+
this.suggestedInputs.delete(sessionId);
|
|
3373
|
+
else
|
|
3374
|
+
this.suggestedInputs.set(sessionId, text);
|
|
3375
|
+
}
|
|
3376
|
+
assertCurrentExtensionSession(session) {
|
|
3377
|
+
if (!this.isCurrentActiveSession(session) || this.startupSessions.has(session.sessionId)) {
|
|
3378
|
+
throw new Error("This extension command belongs to an unavailable session runtime");
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
runExtensionSessionControl(session, operation) {
|
|
3382
|
+
return this.extensionCommandExecution.run({ ...this.extensionCommandExecution.getStore(), sessionControl: true }, async () => {
|
|
3383
|
+
this.assertCurrentExtensionSession(session);
|
|
3384
|
+
await this.assertWritable({ id: session.sessionId, cwd: session.sessionManager.getCwd() });
|
|
3385
|
+
this.assertCurrentExtensionSession(session);
|
|
3386
|
+
return operation();
|
|
3387
|
+
});
|
|
3388
|
+
}
|
|
3118
3389
|
replaceSessionNotificationContext(session, generation) {
|
|
3119
3390
|
session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
|
|
3120
3391
|
}
|
|
@@ -3291,9 +3562,30 @@ export class PiSessionService {
|
|
|
3291
3562
|
this.clearCompactionPromptQueue(sessionId);
|
|
3292
3563
|
}
|
|
3293
3564
|
}
|
|
3565
|
+
this.restoreSubsessionReceipts(session);
|
|
3294
3566
|
const unsubscribe = session.subscribe((event) => {
|
|
3295
3567
|
const eventType = getString(event, "type");
|
|
3296
3568
|
const message = getProperty(event, "message");
|
|
3569
|
+
const inbox = this.subsessionInbox(session.sessionId, session.sessionFile);
|
|
3570
|
+
if (eventType === "agent_start") {
|
|
3571
|
+
this.restoreSubsessionReceipts(session, false);
|
|
3572
|
+
this.subsessionStoppedParents.delete(session);
|
|
3573
|
+
}
|
|
3574
|
+
if (eventType === "message_end" && getString(message, "role") === "assistant"
|
|
3575
|
+
&& (getString(message, "stopReason") === "aborted" || getString(message, "stopReason") === "error")) {
|
|
3576
|
+
this.subsessionStoppedParents.add(session);
|
|
3577
|
+
}
|
|
3578
|
+
const entry = getProperty(event, "entry");
|
|
3579
|
+
if (eventType === "entry_appended" && getString(entry, "type") === "custom_message"
|
|
3580
|
+
&& getString(entry, "customType") === SUBSESSION_NOTIFICATION_CUSTOM_TYPE) {
|
|
3581
|
+
inbox.receive(completionReceiptIds(getProperty(entry, "details")));
|
|
3582
|
+
}
|
|
3583
|
+
// message_end listeners run before SDK persistence. These later
|
|
3584
|
+
// boundaries can safely reconcile committed messages and failed intents.
|
|
3585
|
+
if (eventType === "turn_end" || eventType === "agent_settled")
|
|
3586
|
+
this.restoreSubsessionReceipts(session);
|
|
3587
|
+
if (eventType === "agent_settled")
|
|
3588
|
+
this.scheduleSubsessionDelivery(session);
|
|
3297
3589
|
if ((eventType === "message_start" || eventType === "message_update") && getString(message, "role") === "assistant") {
|
|
3298
3590
|
// SDK state advances before async extension hooks finish. Capture only
|
|
3299
3591
|
// published messages, detaching even blocks left intact by projection.
|
|
@@ -3302,25 +3594,24 @@ export class PiSessionService {
|
|
|
3302
3594
|
else if (eventType === "message_start" || eventType === "message_end" || eventType === "agent_end") {
|
|
3303
3595
|
this.publishedAssistantPartials.delete(session);
|
|
3304
3596
|
}
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
// there was no ask to dismiss when the user originally submitted them.
|
|
3309
|
-
if (eventType === "message_start" && isRecord(event) && getString(event["message"], "role") === "user") {
|
|
3310
|
-
void this.voidOpenAskForUserMessage(session).catch((error) => {
|
|
3311
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
3312
|
-
this.publishActivity(session, "error", "error", message);
|
|
3313
|
-
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
3314
|
-
});
|
|
3597
|
+
const clientEvent = toClientEvent(event, session.thinkingLevel);
|
|
3598
|
+
if ((clientEvent.type === "message.end" || clientEvent.type === "message.append") && clientEvent.message !== undefined) {
|
|
3599
|
+
clientEvent.message = this.browserTranscriptMessage(session, clientEvent.message);
|
|
3315
3600
|
}
|
|
3601
|
+
this.events.publish(session.sessionId, clientEvent, { id: session.sessionId, cwd: session.sessionManager.getCwd() });
|
|
3602
|
+
this.publishActivityForEvent(session, event);
|
|
3603
|
+
if (eventType === "message_start" && getString(message, "role") === "user")
|
|
3604
|
+
this.suggestedInputs.delete(session.sessionId);
|
|
3316
3605
|
if (eventType === "agent_end")
|
|
3317
3606
|
this.abortRunScopedExtensionDialogs(session.sessionId);
|
|
3318
3607
|
if (eventType === "compaction_end")
|
|
3319
3608
|
this.scheduleCompactionQueueDrain(session.sessionId);
|
|
3609
|
+
if (eventType === "compaction_end" || eventType === "bash_execution_end")
|
|
3610
|
+
this.scheduleSubsessionDelivery(session);
|
|
3320
3611
|
if (eventType === "agent_start" || eventType === "agent_end")
|
|
3321
3612
|
this.scheduleCompactionQueueDrain(session.sessionId);
|
|
3322
3613
|
this.publishStatus(session);
|
|
3323
|
-
this.updateSubsessionTracking(session, eventType === "agent_start");
|
|
3614
|
+
this.updateSubsessionTracking(session, eventType === "agent_start", eventType === "agent_settled");
|
|
3324
3615
|
});
|
|
3325
3616
|
active.unsubscribe = () => {
|
|
3326
3617
|
this.sessionEvents.close(session);
|
|
@@ -3391,11 +3682,6 @@ export class PiSessionService {
|
|
|
3391
3682
|
maybeGenerateSessionName(session, firstMessage) {
|
|
3392
3683
|
if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting)
|
|
3393
3684
|
return;
|
|
3394
|
-
const deterministicName = deterministicSessionName(firstMessage);
|
|
3395
|
-
if (deterministicName !== undefined) {
|
|
3396
|
-
this.applyGeneratedSessionName(session, deterministicName);
|
|
3397
|
-
return;
|
|
3398
|
-
}
|
|
3399
3685
|
const model = session.model;
|
|
3400
3686
|
if (model === undefined)
|
|
3401
3687
|
return;
|
|
@@ -3426,13 +3712,8 @@ export class PiSessionService {
|
|
|
3426
3712
|
this.logger.info({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) }, "failed to apply deferred session name");
|
|
3427
3713
|
}
|
|
3428
3714
|
}
|
|
3429
|
-
|
|
3430
|
-
this.
|
|
3431
|
-
for (const notification of notifications) {
|
|
3432
|
-
void this.deliverSubsessionNotification(session, notification).catch((error) => {
|
|
3433
|
-
this.logSubsessionNotificationFailure(notification.parentId, notification.childId, error);
|
|
3434
|
-
});
|
|
3435
|
-
}
|
|
3715
|
+
this.restoreSubsessionReceipts(session);
|
|
3716
|
+
this.scheduleSubsessionDelivery(session);
|
|
3436
3717
|
}
|
|
3437
3718
|
applyAuthChange(change = {}) {
|
|
3438
3719
|
// ModelRuntime.login()/logout() refresh the shared runtime before AuthService
|
|
@@ -3518,8 +3799,11 @@ export class PiSessionService {
|
|
|
3518
3799
|
return "active";
|
|
3519
3800
|
}
|
|
3520
3801
|
hasActiveWork(session) {
|
|
3802
|
+
const execution = this.extensionCommandExecution.getStore();
|
|
3803
|
+
const ownCommandReceipt = execution?.sessionControl === true
|
|
3804
|
+
&& execution.submission?.active === true && execution.submission.session === session ? 1 : 0;
|
|
3521
3805
|
return this.treeNavigations.has(session)
|
|
3522
|
-
|| this.isSessionEntryMutationActive(session)
|
|
3806
|
+
|| this.isSessionEntryMutationActive(session, ownCommandReceipt)
|
|
3523
3807
|
|| this.isTreeExclusiveOperationActive(session)
|
|
3524
3808
|
|| sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
|
|
3525
3809
|
}
|
|
@@ -3601,9 +3885,11 @@ export class PiSessionService {
|
|
|
3601
3885
|
else
|
|
3602
3886
|
this.sessionEntryMutationCounts.set(session, remaining);
|
|
3603
3887
|
this.observeUnreadActivityState(session);
|
|
3888
|
+
if (remaining <= 0 && !this.subsessionDeliveringParents.has(session))
|
|
3889
|
+
this.scheduleSubsessionDelivery(session);
|
|
3604
3890
|
}
|
|
3605
|
-
isSessionEntryMutationActive(session) {
|
|
3606
|
-
return (this.sessionEntryMutationCounts.get(session) ?? 0) >
|
|
3891
|
+
isSessionEntryMutationActive(session, ignoredReceipts = 0) {
|
|
3892
|
+
return (this.sessionEntryMutationCounts.get(session) ?? 0) > ignoredReceipts;
|
|
3607
3893
|
}
|
|
3608
3894
|
publishActivityForEvent(session, event) {
|
|
3609
3895
|
const eventType = getString(event, "type");
|
|
@@ -3750,11 +4036,12 @@ export class PiSessionService {
|
|
|
3750
4036
|
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
|
|
3751
4037
|
const contextUsage = session.getContextUsage();
|
|
3752
4038
|
const warnings = this.warningsForSession(session);
|
|
3753
|
-
const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
|
|
3754
4039
|
const pendingDialogs = this.pendingExtensionDialogStore.pendingDialogs(session.sessionId);
|
|
4040
|
+
const suggestedInput = this.suggestedInputs.get(session.sessionId);
|
|
3755
4041
|
return {
|
|
3756
4042
|
sessionId: session.sessionId,
|
|
3757
4043
|
persisted: sessionFileExists(session.sessionFile),
|
|
4044
|
+
recentlyActiveElsewhere: this.activityMarker.isActiveElsewhere(session.sessionFile),
|
|
3758
4045
|
...(model === undefined ? {} : { model }),
|
|
3759
4046
|
thinkingLevel: session.thinkingLevel,
|
|
3760
4047
|
isStreaming: session.isStreaming,
|
|
@@ -3767,8 +4054,8 @@ export class PiSessionService {
|
|
|
3767
4054
|
cost: stats.cost,
|
|
3768
4055
|
...(contextUsage === undefined ? {} : { contextUsage }),
|
|
3769
4056
|
...(warnings.length === 0 ? {} : { warnings }),
|
|
3770
|
-
...(pendingAsk === undefined ? {} : { pendingAsk }),
|
|
3771
4057
|
...(pendingDialogs.length === 0 ? {} : { pendingDialogs }),
|
|
4058
|
+
...(suggestedInput === undefined ? {} : { suggestedInput }),
|
|
3772
4059
|
};
|
|
3773
4060
|
}
|
|
3774
4061
|
/**
|
|
@@ -3780,13 +4067,6 @@ export class PiSessionService {
|
|
|
3780
4067
|
warningsForSession(session) {
|
|
3781
4068
|
const runtime = this.active.get(session.sessionId)?.runtime;
|
|
3782
4069
|
const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
|
|
3783
|
-
if (this.activityMarker.isActiveElsewhere(session.sessionFile)) {
|
|
3784
|
-
warnings.push({
|
|
3785
|
-
severity: "info",
|
|
3786
|
-
message: "Recently active in another PI-WEB instance. Avoid working on this session in both instances at once.",
|
|
3787
|
-
source: "PI-WEB",
|
|
3788
|
-
});
|
|
3789
|
-
}
|
|
3790
4070
|
const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
|
|
3791
4071
|
if (anthropic !== undefined)
|
|
3792
4072
|
warnings.push(anthropic);
|
|
@@ -3827,13 +4107,13 @@ function bulkRefToSessionRef(ref) {
|
|
|
3827
4107
|
return { id: ref.id, cwd: ref.cwd };
|
|
3828
4108
|
}
|
|
3829
4109
|
function findArchivedRecordForBulkRef(records, ref) {
|
|
3830
|
-
return records.find((record) => record.cwd === ref.cwd &&
|
|
4110
|
+
return records.find((record) => record.cwd === ref.cwd && record.sessionId === ref.id);
|
|
3831
4111
|
}
|
|
3832
4112
|
function findListedSessionForBulkRef(context, ref) {
|
|
3833
|
-
return
|
|
4113
|
+
return findSessionByExactId(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
|
|
3834
4114
|
}
|
|
3835
|
-
function
|
|
3836
|
-
return sessions.find((session) => session.id === sessionId)
|
|
4115
|
+
function findSessionByExactId(sessions, sessionId) {
|
|
4116
|
+
return sessions.find((session) => session.id === sessionId);
|
|
3837
4117
|
}
|
|
3838
4118
|
function uniqueStrings(values) {
|
|
3839
4119
|
return [...new Set(values)];
|
|
@@ -3874,12 +4154,35 @@ function notificationIdentityForSession(session) {
|
|
|
3874
4154
|
cwd: canonicalizeStoredCwd(session.sessionManager.getCwd()),
|
|
3875
4155
|
};
|
|
3876
4156
|
}
|
|
4157
|
+
function metadataFromActiveSession(session) {
|
|
4158
|
+
return readSessionUiMetadata(session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch());
|
|
4159
|
+
}
|
|
4160
|
+
function clientSessionFromActiveSession(session) {
|
|
4161
|
+
const header = session.sessionManager.getHeader?.();
|
|
4162
|
+
const created = header?.timestamp ?? new Date().toISOString();
|
|
4163
|
+
const metadata = metadataFromActiveSession(session);
|
|
4164
|
+
return {
|
|
4165
|
+
id: session.sessionId,
|
|
4166
|
+
path: session.sessionFile ?? "",
|
|
4167
|
+
cwd: session.sessionManager.getCwd(),
|
|
4168
|
+
persisted: sessionFileExists(session.sessionFile),
|
|
4169
|
+
created,
|
|
4170
|
+
modified: created,
|
|
4171
|
+
messageCount: session.messages.length,
|
|
4172
|
+
firstMessage: "",
|
|
4173
|
+
...(metadata === undefined ? {} : { metadata }),
|
|
4174
|
+
...(session.sessionName === undefined ? {} : { name: session.sessionName }),
|
|
4175
|
+
...(header?.parentSession === undefined ? {} : { parentSessionPath: header.parentSession }),
|
|
4176
|
+
};
|
|
4177
|
+
}
|
|
3877
4178
|
function clientSessionFromListEntry(session) {
|
|
3878
4179
|
return {
|
|
3879
4180
|
id: session.id,
|
|
3880
4181
|
path: session.path,
|
|
3881
4182
|
cwd: session.cwd,
|
|
3882
4183
|
persisted: true,
|
|
4184
|
+
...(session.agentSubsession === true ? { agentSubsession: true } : {}),
|
|
4185
|
+
...(session.metadata === undefined ? {} : { metadata: requireSessionUiMetadata(session.metadata) }),
|
|
3883
4186
|
...(session.name === undefined ? {} : { name: session.name }),
|
|
3884
4187
|
created: session.created.toISOString(),
|
|
3885
4188
|
modified: session.modified.toISOString(),
|
|
@@ -3897,6 +4200,7 @@ function archiveInputFromListEntry(session) {
|
|
|
3897
4200
|
modified: session.modified.toISOString(),
|
|
3898
4201
|
messageCount: session.messageCount,
|
|
3899
4202
|
firstMessage: session.firstMessage,
|
|
4203
|
+
...(session.metadata === undefined ? {} : { metadata: session.metadata }),
|
|
3900
4204
|
...(session.name === undefined ? {} : { name: session.name }),
|
|
3901
4205
|
...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
|
|
3902
4206
|
};
|
|
@@ -3906,6 +4210,7 @@ function archiveInputFromActiveSession(session) {
|
|
|
3906
4210
|
if (sessionFile === undefined || sessionFile === "")
|
|
3907
4211
|
throw new Error("Session is not persisted");
|
|
3908
4212
|
const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
|
|
4213
|
+
const metadata = metadataFromActiveSession(session);
|
|
3909
4214
|
return {
|
|
3910
4215
|
sessionId: session.sessionId,
|
|
3911
4216
|
cwd: session.sessionManager.getCwd(),
|
|
@@ -3914,6 +4219,7 @@ function archiveInputFromActiveSession(session) {
|
|
|
3914
4219
|
modified: new Date().toISOString(),
|
|
3915
4220
|
messageCount: session.messages.length,
|
|
3916
4221
|
firstMessage: "",
|
|
4222
|
+
...(metadata === undefined ? {} : { metadata }),
|
|
3917
4223
|
...(session.sessionName === undefined ? {} : { name: session.sessionName }),
|
|
3918
4224
|
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
|
|
3919
4225
|
};
|
|
@@ -3978,11 +4284,13 @@ function clientSessionFromArchivedRecord(record, fallback) {
|
|
|
3978
4284
|
if (path === undefined || created === undefined || modified === undefined || messageCount === undefined || firstMessage === undefined)
|
|
3979
4285
|
return undefined;
|
|
3980
4286
|
const name = record.name ?? fallback?.name;
|
|
4287
|
+
const metadata = record.metadata ?? fallback?.metadata;
|
|
3981
4288
|
const parentSessionPath = record.parentSessionPath ?? fallback?.parentSessionPath;
|
|
3982
4289
|
return {
|
|
3983
4290
|
id: record.sessionId,
|
|
3984
4291
|
path,
|
|
3985
4292
|
cwd: record.cwd,
|
|
4293
|
+
...(metadata === undefined ? {} : { metadata: requireSessionUiMetadata(metadata) }),
|
|
3986
4294
|
...(name === undefined ? {} : { name }),
|
|
3987
4295
|
created,
|
|
3988
4296
|
modified,
|
|
@@ -4096,37 +4404,6 @@ function persistedChildSubsessionLinkData(parentSessionId, childSessionId) {
|
|
|
4096
4404
|
spawnedSessionId: childSessionId,
|
|
4097
4405
|
};
|
|
4098
4406
|
}
|
|
4099
|
-
function parsePersistedParentSubsessionLink(entry) {
|
|
4100
|
-
if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE)
|
|
4101
|
-
return undefined;
|
|
4102
|
-
const data = entry["data"];
|
|
4103
|
-
if (!isRecord(data))
|
|
4104
|
-
return undefined;
|
|
4105
|
-
const spawnedBySessionId = getString(data, "spawnedBySessionId");
|
|
4106
|
-
const spawnedSessionId = getString(data, "spawnedSessionId");
|
|
4107
|
-
if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
|
|
4108
|
-
return undefined;
|
|
4109
|
-
const spawnedSessionFile = getString(data, "spawnedSessionFile");
|
|
4110
|
-
const cwd = getString(data, "cwd");
|
|
4111
|
-
return {
|
|
4112
|
-
spawnedBySessionId,
|
|
4113
|
-
spawnedSessionId,
|
|
4114
|
-
...(spawnedSessionFile === undefined || spawnedSessionFile === "" ? {} : { spawnedSessionFile }),
|
|
4115
|
-
...(cwd === undefined || cwd === "" ? {} : { cwd }),
|
|
4116
|
-
};
|
|
4117
|
-
}
|
|
4118
|
-
function parsePersistedChildSubsessionLink(entry) {
|
|
4119
|
-
if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE)
|
|
4120
|
-
return undefined;
|
|
4121
|
-
const data = entry["data"];
|
|
4122
|
-
if (!isRecord(data))
|
|
4123
|
-
return undefined;
|
|
4124
|
-
const spawnedBySessionId = getString(data, "spawnedBySessionId");
|
|
4125
|
-
const spawnedSessionId = getString(data, "spawnedSessionId");
|
|
4126
|
-
if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
|
|
4127
|
-
return undefined;
|
|
4128
|
-
return { spawnedBySessionId, spawnedSessionId };
|
|
4129
|
-
}
|
|
4130
4407
|
function nonEmptyString(value) {
|
|
4131
4408
|
return value === undefined || value === "" ? undefined : value;
|
|
4132
4409
|
}
|
|
@@ -4244,6 +4521,55 @@ function activeEditableTreeTargetParentId(manager, targetId, activeLeafId) {
|
|
|
4244
4521
|
return undefined;
|
|
4245
4522
|
return parentId === null || typeof parentId === "string" ? parentId : undefined;
|
|
4246
4523
|
}
|
|
4524
|
+
/**
|
|
4525
|
+
* Pi edits custom messages from their parent. For a retained checkpoint, remap
|
|
4526
|
+
* that single leaf movement instead, before Pi rebuilds context/restores tools
|
|
4527
|
+
* and emits session_tree. Restore the manager methods before extension handlers
|
|
4528
|
+
* run: their appended history must not be overwritten by a post-event repair.
|
|
4529
|
+
* The caller holds the tree-navigation gate and excludes branch summarization.
|
|
4530
|
+
*/
|
|
4531
|
+
async function navigateTreeRetainingCustomMessage(manager, targetId, parentId, navigate) {
|
|
4532
|
+
if (parentId !== null && typeof parentId !== "string")
|
|
4533
|
+
throw new Error("This checkpoint has no valid history parent");
|
|
4534
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method -- Restore exact identities; calls below explicitly bind the manager.
|
|
4535
|
+
const { branch, resetLeaf } = manager;
|
|
4536
|
+
let pending = true;
|
|
4537
|
+
const restore = () => {
|
|
4538
|
+
if (!pending)
|
|
4539
|
+
return;
|
|
4540
|
+
pending = false;
|
|
4541
|
+
manager.branch = branch;
|
|
4542
|
+
manager.resetLeaf = resetLeaf;
|
|
4543
|
+
};
|
|
4544
|
+
try {
|
|
4545
|
+
manager.branch = (entryId) => {
|
|
4546
|
+
if (entryId === parentId) {
|
|
4547
|
+
restore();
|
|
4548
|
+
branch.call(manager, targetId);
|
|
4549
|
+
}
|
|
4550
|
+
else
|
|
4551
|
+
branch.call(manager, entryId);
|
|
4552
|
+
};
|
|
4553
|
+
manager.resetLeaf = () => {
|
|
4554
|
+
if (parentId === null) {
|
|
4555
|
+
restore();
|
|
4556
|
+
branch.call(manager, targetId);
|
|
4557
|
+
}
|
|
4558
|
+
else
|
|
4559
|
+
resetLeaf.call(manager);
|
|
4560
|
+
};
|
|
4561
|
+
const result = await navigate();
|
|
4562
|
+
if (result.cancelled)
|
|
4563
|
+
return result;
|
|
4564
|
+
// The retained notice is context, not text to restore into the editor.
|
|
4565
|
+
const { editorText, ...retainedResult } = result;
|
|
4566
|
+
void editorText;
|
|
4567
|
+
return retainedResult;
|
|
4568
|
+
}
|
|
4569
|
+
finally {
|
|
4570
|
+
restore();
|
|
4571
|
+
}
|
|
4572
|
+
}
|
|
4247
4573
|
function setSessionTreeLeaf(manager, leafId) {
|
|
4248
4574
|
if (leafId === null)
|
|
4249
4575
|
manager.resetLeaf();
|
|
@@ -4257,15 +4583,22 @@ function transcriptBranchIncludesEntry(entries, entryId) {
|
|
|
4257
4583
|
const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
|
|
4258
4584
|
/** custom entry type used to mark a child as created by spawn_subsession. */
|
|
4259
4585
|
const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned";
|
|
4260
|
-
/**
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4586
|
+
/** A filtered read is a receipt only when it contains the complete latest final text. */
|
|
4587
|
+
function transcriptReturnsFinalOutput(messages, view, finalText) {
|
|
4588
|
+
if (finalText === "")
|
|
4589
|
+
return false;
|
|
4590
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
4591
|
+
const text = finalAssistantText([messages[index]]);
|
|
4592
|
+
if (text === "")
|
|
4593
|
+
continue;
|
|
4594
|
+
const entry = view.entries.find((candidate) => candidate.index === index && candidate.role === "assistant");
|
|
4595
|
+
if (entry === undefined || text !== finalText)
|
|
4596
|
+
return false;
|
|
4597
|
+
const texts = entry.parts.filter((part) => part.kind === "text");
|
|
4598
|
+
return texts.every((part) => part.truncated === undefined)
|
|
4599
|
+
&& texts.map((part) => part.text).join("\n").trim() === finalText.trim();
|
|
4600
|
+
}
|
|
4601
|
+
return false;
|
|
4269
4602
|
}
|
|
4270
4603
|
/** Most recent assistant text from a history message list, or "" if none. */
|
|
4271
4604
|
function finalAssistantText(messages) {
|
|
@@ -4290,6 +4623,11 @@ function finalAssistantText(messages) {
|
|
|
4290
4623
|
}
|
|
4291
4624
|
function toClientEvent(event, thinkingLevel) {
|
|
4292
4625
|
const eventType = getString(event, "type");
|
|
4626
|
+
// Nested execution belongs to the parent tool result, not a durable message.
|
|
4627
|
+
// Do not create standalone transcript rows that disappear on reconnect.
|
|
4628
|
+
if (getString(event, "parentToolCallId") !== undefined && eventType?.startsWith("tool_execution_") === true) {
|
|
4629
|
+
return { type: "pi.event", eventType };
|
|
4630
|
+
}
|
|
4293
4631
|
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
|
|
4294
4632
|
if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
|
|
4295
4633
|
return { type: "assistant.delta", text: getString(assistantMessageEvent, "delta") ?? "" };
|