@jmfederico/pi-web 1.202610.1 → 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 +4 -0
- 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 +30 -32
- package/dist/pi-packages/captains-log/package.json +4 -4
- 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 +49 -51
- 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 +1 -1
- package/dist/piWebUpdate.js +2 -2
- package/dist/piWebUpdate.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/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 +15 -17
- package/dist/server/sessions/askUserTool.js.map +1 -1
- 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 +71 -16
- 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 +581 -262
- 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 +25 -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/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 +99 -86
- package/docs/plugins.md +103 -30
- package/examples/session-bridge-plugin/package.json +2 -2
- package/examples/workspace-provider-plugin/package.json +1 -1
- package/package.json +33 -33
- package/dist/client/assets/index-CBjDnBIN.js +0 -4178
- 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,3 +1,5 @@
|
|
|
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";
|
|
@@ -11,10 +13,12 @@ import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
|
|
11
13
|
import { SessionCommandService } from "./sessionCommandService.js";
|
|
12
14
|
import { SessionActivityMarker } from "./sessionActivityMarker.js";
|
|
13
15
|
import { projectSessionTree } from "./sessionTreeProjection.js";
|
|
16
|
+
import { messageHistoryTargetId } from "./messageHistoryTarget.js";
|
|
14
17
|
import { SessionArchiveStore } from "./sessionArchiveStore.js";
|
|
15
|
-
import {
|
|
18
|
+
import { findArchiveCandidateByExactId, planSessionArchiveTree } from "./sessionArchiveTree.js";
|
|
16
19
|
import { PiSessionEventConnections } from "./piSessionEventConnections.js";
|
|
17
|
-
import {
|
|
20
|
+
import { fallbackSessionName, generateShortSessionName, requireInitialSessionName } from "./sessionNameGenerator.js";
|
|
21
|
+
import { createExtensionSessionDispatch } from "./extensionSessionDispatch.js";
|
|
18
22
|
import { computeEditPreview } from "./editPreview.js";
|
|
19
23
|
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
|
20
24
|
import { loadEffectiveProjectAttachmentsConfig } from "../workspaces/projectPiWebConfig.js";
|
|
@@ -22,15 +26,19 @@ import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
|
|
22
26
|
import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
|
|
23
27
|
import { parseSessionDefaults, parseSessionDefaultsUpdate } from "../../shared/sessionDefaults.js";
|
|
24
28
|
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
|
29
|
+
import { parsePersistedChildSubsessionLink, parsePersistedParentSubsessionLink } from "./subsessionProvenance.js";
|
|
25
30
|
import { readSessionHeaderSummary } from "./sessionFileHeader.js";
|
|
26
31
|
import { createAskUserToolDefinition } from "./askUserTool.js";
|
|
27
|
-
import {
|
|
32
|
+
import { createAskUserOutcome, renderAskUserAnswersText, resolveAskUserRequest } from "./askUserDomain.js";
|
|
28
33
|
import { PendingExtensionDialogStore } from "./pendingExtensionDialogStore.js";
|
|
29
34
|
import { ExtensionDialogWaiters, effectiveExtensionDialogTimeoutMs, extensionDialogCancelValue } from "./extensionDialogWaiters.js";
|
|
30
35
|
import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS } from "../../config.js";
|
|
31
36
|
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
|
|
37
|
+
import { readSessionUiMetadata, requireSessionUiMetadata, SESSION_UI_METADATA_CUSTOM_TYPE } from "./sessionMetadata.js";
|
|
32
38
|
import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
|
|
33
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";
|
|
34
42
|
import { annotateAssistantThinkingLevel, historyMessagesFromEntries } from "./transcriptMessages.js";
|
|
35
43
|
import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
|
|
36
44
|
import { SessionNotificationStore, } from "./sessionNotificationStore.js";
|
|
@@ -290,14 +298,14 @@ function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, ini
|
|
|
290
298
|
});
|
|
291
299
|
};
|
|
292
300
|
}
|
|
293
|
-
export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions,
|
|
301
|
+
export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, askUserEnabled = false) {
|
|
294
302
|
return [
|
|
295
303
|
createPiWebEditToolDefinition(cwd),
|
|
296
304
|
...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
|
|
297
305
|
...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
|
|
298
306
|
// Asking the user is not delegation: the questions land in the session the
|
|
299
307
|
// user is already watching, so tracked children may ask too.
|
|
300
|
-
...(
|
|
308
|
+
...(askUserEnabled ? [createAskUserToolDefinition()] : []),
|
|
301
309
|
];
|
|
302
310
|
}
|
|
303
311
|
/**
|
|
@@ -430,7 +438,7 @@ export function piWebResourceLoaderOptions(appendSystemPromptSections) {
|
|
|
430
438
|
return undefined;
|
|
431
439
|
return { appendSystemPromptOverride: (base) => [...base, ...appendSystemPromptSections] };
|
|
432
440
|
}
|
|
433
|
-
function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManagers, spawn, subsessions,
|
|
441
|
+
function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManagers, spawn, subsessions, askUserEnabled = false, appendSystemPromptSections = []) {
|
|
434
442
|
const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
|
|
435
443
|
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
|
|
436
444
|
// PI WEB always honors pi's project-trust model. When the workspace ships
|
|
@@ -445,6 +453,13 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
|
|
|
445
453
|
// resources skip resolution entirely and are trusted, as before.
|
|
446
454
|
const eventBus = createEventBus();
|
|
447
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
|
+
});
|
|
448
463
|
const projectTrustRequiring = hasTrustRequiringProjectResources(cwd);
|
|
449
464
|
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: !projectTrustRequiring });
|
|
450
465
|
// Pre-session-creation trust failures (`project_trust` handler errors)
|
|
@@ -456,7 +471,10 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
|
|
|
456
471
|
agentDir,
|
|
457
472
|
modelRuntime,
|
|
458
473
|
settingsManager,
|
|
459
|
-
resourceLoaderOptions: {
|
|
474
|
+
resourceLoaderOptions: {
|
|
475
|
+
...resourceLoaderOptions, eventBus,
|
|
476
|
+
extensionFactories: [...builtinFactories, sessionDispatch, ...(subsessions === undefined ? [] : [createSubsessionCompletionDelivery(subsessions)])],
|
|
477
|
+
},
|
|
460
478
|
...(projectTrustRequiring
|
|
461
479
|
? {
|
|
462
480
|
resourceLoaderReloadOptions: {
|
|
@@ -480,7 +498,8 @@ function createDefaultRuntimeFactory(sessionEvents, modelRuntime, sessionManager
|
|
|
480
498
|
services.diagnostics.push(...modelOptions.diagnostics);
|
|
481
499
|
const resolvedDelegationToolsEnabled = delegationToolsEnabled
|
|
482
500
|
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
|
483
|
-
|
|
501
|
+
dispatchEnabled = resolvedDelegationToolsEnabled;
|
|
502
|
+
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUserEnabled);
|
|
484
503
|
const result = await createAgentSessionFromServices({
|
|
485
504
|
services,
|
|
486
505
|
sessionManager,
|
|
@@ -537,8 +556,12 @@ export class PiSessionService {
|
|
|
537
556
|
* can anchor the selected branch on disk.
|
|
538
557
|
*/
|
|
539
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();
|
|
540
561
|
/** Counts async operations that may append an entry before they settle. */
|
|
541
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();
|
|
542
565
|
this.pendingPromptEchoes = new WeakMap();
|
|
543
566
|
this.publishedAssistantPartials = new WeakMap();
|
|
544
567
|
/** Settings-wide queue preventing enabled-model read/modify/write races across sessions. */
|
|
@@ -554,7 +577,11 @@ export class PiSessionService {
|
|
|
554
577
|
/** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
|
|
555
578
|
this.treeExclusiveRuntimeOperationCounts = new WeakMap();
|
|
556
579
|
this.treeExclusiveSessionOperationCounts = new Map();
|
|
557
|
-
|
|
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();
|
|
558
585
|
this.deferredGeneratedSessionNames = new WeakMap();
|
|
559
586
|
this.compactionPromptQueues = new Map();
|
|
560
587
|
this.compactionDrainTimers = new Map();
|
|
@@ -595,7 +622,6 @@ export class PiSessionService {
|
|
|
595
622
|
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
|
|
596
623
|
this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
|
|
597
624
|
this.onUnreadChanged = deps.onUnreadChanged;
|
|
598
|
-
this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
|
|
599
625
|
this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
|
|
600
626
|
this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
|
|
601
627
|
this.catalogRefreshStatus = deps.catalogRefreshStatus;
|
|
@@ -609,9 +635,11 @@ export class PiSessionService {
|
|
|
609
635
|
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.sessionEvents, this.modelRuntime, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
|
|
610
636
|
spawn: (input) => this.spawnSubsession(input),
|
|
611
637
|
list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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 ?? []);
|
|
615
643
|
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
|
616
644
|
this.workspaceActivity = deps.workspaceActivity;
|
|
617
645
|
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
|
|
@@ -728,12 +756,12 @@ export class PiSessionService {
|
|
|
728
756
|
const activeSessions = Array.from(new Set(this.active.values()));
|
|
729
757
|
for (const active of activeSessions) {
|
|
730
758
|
this.forgetUnreadActivity(active.runtime.session);
|
|
731
|
-
this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
|
|
732
759
|
this.endSessionExtensionDialogs(active.runtime.session.sessionId);
|
|
733
760
|
}
|
|
734
761
|
this.active.clear();
|
|
735
762
|
this.pendingSessionOpens.clear();
|
|
736
763
|
this.startupSessions.clear();
|
|
764
|
+
this.suggestedInputs.clear();
|
|
737
765
|
this.activities.clear();
|
|
738
766
|
this.compactionPromptQueues.clear();
|
|
739
767
|
this.authLossWarnings.clear();
|
|
@@ -742,6 +770,7 @@ export class PiSessionService {
|
|
|
742
770
|
this.subsessionLinks.clear();
|
|
743
771
|
this.subsessionHydratedParents.clear();
|
|
744
772
|
this.subsessionNotifyArmed.clear();
|
|
773
|
+
this.subsessionInboxes.clear();
|
|
745
774
|
this.notificationStore.clearAll("service-dispose");
|
|
746
775
|
await Promise.all(activeSessions.map(async (active) => {
|
|
747
776
|
active.unsubscribe();
|
|
@@ -754,6 +783,7 @@ export class PiSessionService {
|
|
|
754
783
|
await active.runtime.dispose();
|
|
755
784
|
}
|
|
756
785
|
})).finally(async () => {
|
|
786
|
+
this.extensionCommandExecution.disable();
|
|
757
787
|
this.events.mediaIndex.clear();
|
|
758
788
|
await this.activityMarker.dispose();
|
|
759
789
|
});
|
|
@@ -767,7 +797,29 @@ export class PiSessionService {
|
|
|
767
797
|
for (const record of archivedForCwd) {
|
|
768
798
|
this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
|
|
769
799
|
}
|
|
770
|
-
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));
|
|
771
823
|
const reconcilableSessionIds = this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById);
|
|
772
824
|
this.workspaceActivity?.reconcileSessionActivity(cwd, reconcilableSessionIds);
|
|
773
825
|
await this.publishUnreadMutations(this.unreadStore.reconcileCwd(canonicalizeStoredCwd(cwd), reconcilableSessionIds));
|
|
@@ -815,29 +867,37 @@ export class PiSessionService {
|
|
|
815
867
|
}
|
|
816
868
|
}
|
|
817
869
|
async startSession(cwd, options) {
|
|
818
|
-
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, {
|
|
819
884
|
startupIntent: "create",
|
|
820
885
|
...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
|
|
821
886
|
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
|
822
887
|
...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
|
|
823
888
|
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
|
|
824
889
|
});
|
|
825
|
-
return
|
|
890
|
+
return options.deferAnnouncement === true
|
|
891
|
+
? clientSessionFromActiveSession(active.runtime.session)
|
|
892
|
+
: this.announceCreatedSession(active, cwd, options.parentSession);
|
|
826
893
|
}
|
|
827
|
-
announceCreatedSession(active, cwd, parentSession) {
|
|
894
|
+
announceCreatedSession(active, cwd, parentSession, agentSubsession) {
|
|
828
895
|
const { session } = active.runtime;
|
|
829
896
|
const created = {
|
|
830
|
-
|
|
831
|
-
path: session.sessionFile ?? "",
|
|
897
|
+
...clientSessionFromActiveSession(session),
|
|
832
898
|
cwd,
|
|
833
|
-
persisted: sessionFileExists(session.sessionFile),
|
|
834
|
-
created: new Date().toISOString(),
|
|
835
|
-
modified: new Date().toISOString(),
|
|
836
|
-
messageCount: session.messages.length,
|
|
837
|
-
firstMessage: "",
|
|
838
|
-
// Include the parent so listeners can nest the new session in the tree
|
|
839
|
-
// immediately, instead of showing it flat until the next reload.
|
|
840
899
|
...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
|
|
900
|
+
...(agentSubsession === true ? { agentSubsession: true } : {}),
|
|
841
901
|
};
|
|
842
902
|
// Broadcast so other clients (and the spawning agent's UI) can add the new
|
|
843
903
|
// session to their list without a manual reload.
|
|
@@ -861,6 +921,8 @@ export class PiSessionService {
|
|
|
861
921
|
? input.model
|
|
862
922
|
: await this.resolveSpawnModel({ id: input.spawningSessionId, cwd: input.spawningCwd }, input.modelSpec);
|
|
863
923
|
const created = await this.start(decision.cwd, {
|
|
924
|
+
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
|
925
|
+
...(input.name === undefined ? {} : { name: input.name }),
|
|
864
926
|
...(model === undefined ? {} : { initialModel: model }),
|
|
865
927
|
...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
|
|
866
928
|
});
|
|
@@ -902,6 +964,7 @@ export class PiSessionService {
|
|
|
902
964
|
...(model === undefined ? {} : { initialModel: model }),
|
|
903
965
|
...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
|
|
904
966
|
creationProvenance: "tracked-subsession",
|
|
967
|
+
deferAnnouncement: true,
|
|
905
968
|
});
|
|
906
969
|
const modelUsed = this.active.get(created.id)?.runtime.session.model;
|
|
907
970
|
const parentSessionFile = nonEmptyString(input.parentSessionFile);
|
|
@@ -915,6 +978,13 @@ export class PiSessionService {
|
|
|
915
978
|
await this.registerVerifiedSubsession(link);
|
|
916
979
|
this.persistSubsessionLink(link);
|
|
917
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);
|
|
918
988
|
await this.prompt(created, input.prompt);
|
|
919
989
|
this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
|
|
920
990
|
return {
|
|
@@ -1055,83 +1125,30 @@ export class PiSessionService {
|
|
|
1055
1125
|
throw unknownSpawnModelError(modelSpec);
|
|
1056
1126
|
return model;
|
|
1057
1127
|
}
|
|
1058
|
-
/**
|
|
1059
|
-
* Register the question set an agent wants the user to answer as the session's
|
|
1060
|
-
* open ask. Deliberately does not wait for the user: `ask_user` terminates the
|
|
1061
|
-
* run and the submitted answers come back later as a follow-up message.
|
|
1062
|
-
*
|
|
1063
|
-
* Rejected question sets throw {@link PendingAskValidationError}, which the
|
|
1064
|
-
* agent loop reports to the model as an error tool result.
|
|
1065
|
-
*/
|
|
1066
|
-
// 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.
|
|
1067
|
-
async openAsk(input) {
|
|
1068
|
-
const result = this.pendingAskStore.open(input);
|
|
1069
|
-
// A supersede closes the earlier ask, so the browsers watching it must hear
|
|
1070
|
-
// that before they hear about its replacement.
|
|
1071
|
-
if (result.superseded !== undefined)
|
|
1072
|
-
this.publishAskClosed(input.sessionId, result.superseded);
|
|
1073
|
-
this.events.publish(input.sessionId, { type: "ask.opened", ask: result.ask });
|
|
1074
|
-
this.publishStatusForSessionId(input.sessionId);
|
|
1075
|
-
return result;
|
|
1076
|
-
}
|
|
1077
|
-
/**
|
|
1078
|
-
* Record the user's answers to the session's open ask and hand them to the
|
|
1079
|
-
* model. The answers travel as a system-authored custom message rather than a
|
|
1080
|
-
* user message, so they are not attributed to the human in the transcript;
|
|
1081
|
-
* they still wake an idle session (`triggerTurn`) and queue behind in-flight
|
|
1082
|
-
* work (`deliverAs: "followUp"`), which is how the run that `ask_user`
|
|
1083
|
-
* terminated continues.
|
|
1084
|
-
*/
|
|
1128
|
+
/** Reply to a durable question entry, even after later conversation or replies. */
|
|
1085
1129
|
async submitAsk(ref, askId, submission) {
|
|
1086
1130
|
await this.assertWritable(ref);
|
|
1087
1131
|
const session = await this.getOrOpen(ref);
|
|
1088
|
-
// Checked before the store closes the ask so a refused delivery cannot
|
|
1089
|
-
// discard answers the user already submitted.
|
|
1090
1132
|
this.assertTreeNavigationInactive(session, "answer questions");
|
|
1091
|
-
|
|
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);
|
|
1092
1136
|
}
|
|
1093
|
-
/**
|
|
1094
|
-
* Close the open ask without answers. The model is still told, naming every
|
|
1095
|
-
* question as unanswered: it was promised a follow-up message and would
|
|
1096
|
-
* otherwise wait for one that never comes.
|
|
1097
|
-
*/
|
|
1137
|
+
/** Skip is also a transcript reply; it does not invalidate future answers. */
|
|
1098
1138
|
async cancelAsk(ref, askId) {
|
|
1099
1139
|
await this.assertWritable(ref);
|
|
1100
1140
|
const session = await this.getOrOpen(ref);
|
|
1101
|
-
this.assertTreeNavigationInactive(session, "
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
*/
|
|
1110
|
-
async closeAsk(session, result) {
|
|
1111
|
-
if (result.status === "stale")
|
|
1112
|
-
return { result: "stale", sessionStatus: this.statusFromSession(session) };
|
|
1113
|
-
const { outcome } = result;
|
|
1114
|
-
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.
|
|
1115
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" }));
|
|
1116
1150
|
this.publishStatus(session);
|
|
1117
|
-
return
|
|
1118
|
-
}
|
|
1119
|
-
publishAskClosed(sessionId, outcome) {
|
|
1120
|
-
this.events.publish(sessionId, { type: "ask.closed", askId: outcome.askId, reason: outcome.reason });
|
|
1121
|
-
}
|
|
1122
|
-
/**
|
|
1123
|
-
* Void the session's open ask because the user sent a chat message instead of
|
|
1124
|
-
* answering it. Every browser closes the card as cancelled, and the model is
|
|
1125
|
-
* told — without being woken — so the notice rides into the turn the message
|
|
1126
|
-
* itself triggers rather than becoming a turn of its own.
|
|
1127
|
-
*/
|
|
1128
|
-
async voidOpenAskForUserMessage(session) {
|
|
1129
|
-
const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
|
|
1130
|
-
if (outcome === undefined)
|
|
1131
|
-
return;
|
|
1132
|
-
this.publishAskClosed(session.sessionId, outcome);
|
|
1133
|
-
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" }));
|
|
1134
|
-
this.publishStatus(session);
|
|
1151
|
+
return this.statusFromSession(session);
|
|
1135
1152
|
}
|
|
1136
1153
|
/**
|
|
1137
1154
|
* Record the user's answer to an open extension dialog and resolve the
|
|
@@ -1280,27 +1297,120 @@ export class PiSessionService {
|
|
|
1280
1297
|
const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId));
|
|
1281
1298
|
return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
|
|
1282
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
|
+
}
|
|
1283
1308
|
/** Status and final result of a subsession, scoped to the caller's children. */
|
|
1284
|
-
async checkSubsession(parentSessionId, sessionId, parentSessionFile) {
|
|
1309
|
+
async checkSubsession(parentSessionId, sessionId, parentSessionFile, toolCallId) {
|
|
1285
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);
|
|
1286
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);
|
|
1287
1319
|
return {
|
|
1288
1320
|
sessionId,
|
|
1289
1321
|
cwd: session.sessionManager.getCwd(),
|
|
1290
|
-
status
|
|
1322
|
+
status,
|
|
1291
1323
|
finalText: finalAssistantText(messages),
|
|
1292
1324
|
messageCount: messages.length,
|
|
1325
|
+
...(status === "working" || completion?.errorDetail === undefined ? {} : { errorDetail: completion.errorDetail }),
|
|
1293
1326
|
};
|
|
1294
1327
|
}
|
|
1295
|
-
/** Filtered
|
|
1296
|
-
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) {
|
|
1297
1330
|
const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
|
|
1298
|
-
|
|
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;
|
|
1299
1410
|
return {
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
...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) },
|
|
1304
1414
|
};
|
|
1305
1415
|
}
|
|
1306
1416
|
/** Open a session after verifying it is one of the caller's tracked children. */
|
|
@@ -1354,6 +1464,14 @@ export class PiSessionService {
|
|
|
1354
1464
|
this.subsessionLinks.set(childSessionId, link);
|
|
1355
1465
|
if (!this.subsessionNotifyArmed.has(childSessionId))
|
|
1356
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
|
+
}
|
|
1357
1475
|
const cwd = this.cwdForVerifiedSubsession(link);
|
|
1358
1476
|
await this.publishUnreadMutations(this.unreadStore.excludeSession(childSessionId, cwd));
|
|
1359
1477
|
}
|
|
@@ -1378,6 +1496,8 @@ export class PiSessionService {
|
|
|
1378
1496
|
this.subsessionParents.delete(childSessionId);
|
|
1379
1497
|
this.subsessionLinks.delete(childSessionId);
|
|
1380
1498
|
this.subsessionNotifyArmed.delete(childSessionId);
|
|
1499
|
+
for (const inbox of this.subsessionInboxes.values())
|
|
1500
|
+
inbox.forgetChild(childSessionId);
|
|
1381
1501
|
if (parentSessionId === undefined)
|
|
1382
1502
|
return;
|
|
1383
1503
|
const children = this.subsessionChildren.get(parentSessionId);
|
|
@@ -1534,7 +1654,7 @@ export class PiSessionService {
|
|
|
1534
1654
|
* working. Prompt failures before agent_start explicitly arm the same latch.
|
|
1535
1655
|
* Standalone commands and compactions must not arm a completion notice.
|
|
1536
1656
|
*/
|
|
1537
|
-
updateSubsessionTracking(session, agentStarted = false) {
|
|
1657
|
+
updateSubsessionTracking(session, agentStarted = false, agentSettled = false) {
|
|
1538
1658
|
const link = this.subsessionLinkForActiveChild(session);
|
|
1539
1659
|
if (link === undefined)
|
|
1540
1660
|
return;
|
|
@@ -1543,22 +1663,31 @@ export class PiSessionService {
|
|
|
1543
1663
|
this.subsessionNotifyArmed.set(childId, true);
|
|
1544
1664
|
return;
|
|
1545
1665
|
}
|
|
1546
|
-
|
|
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))
|
|
1547
1669
|
return;
|
|
1548
1670
|
if (this.subsessionNotifyArmed.get(childId) !== true)
|
|
1549
1671
|
return;
|
|
1550
|
-
this.subsessionNotifyArmed.set(childId, false);
|
|
1551
1672
|
const activity = this.activities.get(childId);
|
|
1552
|
-
const
|
|
1553
|
-
const
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
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);
|
|
1562
1691
|
}
|
|
1563
1692
|
async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
|
|
1564
1693
|
const link = this.subsessionLinks.get(childSessionId);
|
|
@@ -1576,31 +1705,60 @@ export class PiSessionService {
|
|
|
1576
1705
|
const sessionManager = this.sessionManager.open(parentSessionFile);
|
|
1577
1706
|
return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session;
|
|
1578
1707
|
}
|
|
1579
|
-
|
|
1580
|
-
* Deliver a subsession-completion notice to the parent as a system-authored
|
|
1581
|
-
* custom message rather than a user message, so it is not attributed to the
|
|
1582
|
-
* human in the transcript. It still wakes an idle parent (`triggerTurn`) and
|
|
1583
|
-
* queues behind in-flight work (`deliverAs: "followUp"`), preserving the
|
|
1584
|
-
* established "queue if busy, send and act if idle" behavior.
|
|
1585
|
-
*/
|
|
1586
|
-
async notifyParentOfSubsession(parentId, childId, text) {
|
|
1708
|
+
async wakeParentForSubsession(parentId, childId) {
|
|
1587
1709
|
try {
|
|
1588
1710
|
const session = await this.getOrOpenParentForSubsession(parentId, childId);
|
|
1589
|
-
|
|
1590
|
-
const pending = this.deferredSubsessionNotifications.get(session) ?? [];
|
|
1591
|
-
pending.push({ parentId, childId, text });
|
|
1592
|
-
this.deferredSubsessionNotifications.set(session, pending);
|
|
1593
|
-
return;
|
|
1594
|
-
}
|
|
1595
|
-
await this.deliverSubsessionNotification(session, { parentId, childId, text });
|
|
1711
|
+
this.scheduleSubsessionDelivery(session);
|
|
1596
1712
|
}
|
|
1597
1713
|
catch (error) {
|
|
1598
1714
|
this.logSubsessionNotificationFailure(parentId, childId, error);
|
|
1599
1715
|
}
|
|
1600
1716
|
}
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
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);
|
|
1604
1762
|
}
|
|
1605
1763
|
logSubsessionNotificationFailure(parentId, childId, error) {
|
|
1606
1764
|
this.logger.info({ parentSessionId: parentId, sessionId: childId, error: error instanceof Error ? error.message : String(error) }, "failed to notify parent of subsession completion");
|
|
@@ -1909,11 +2067,6 @@ export class PiSessionService {
|
|
|
1909
2067
|
this.publishStatus(session);
|
|
1910
2068
|
return;
|
|
1911
2069
|
}
|
|
1912
|
-
// A chat message answers the session's open ask in the user's own words, so
|
|
1913
|
-
// the form is void: keeping it open would invite answers to questions the
|
|
1914
|
-
// conversation has already moved past. Ignored duplicates skip this on
|
|
1915
|
-
// purpose: they must not void an ask posted after the queued original.
|
|
1916
|
-
await this.voidOpenAskForUserMessage(session);
|
|
1917
2070
|
if (session.isCompacting) {
|
|
1918
2071
|
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
|
|
1919
2072
|
return;
|
|
@@ -1946,7 +2099,12 @@ export class PiSessionService {
|
|
|
1946
2099
|
if (getString(event, "type") === "agent_start")
|
|
1947
2100
|
agentStarted = true;
|
|
1948
2101
|
});
|
|
1949
|
-
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;
|
|
1950
2108
|
unsubscribe?.();
|
|
1951
2109
|
if (echo !== undefined) {
|
|
1952
2110
|
const remaining = (this.pendingPromptEchoes.get(session) ?? []).filter((pending) => pending !== echo);
|
|
@@ -1957,6 +2115,7 @@ export class PiSessionService {
|
|
|
1957
2115
|
}
|
|
1958
2116
|
});
|
|
1959
2117
|
void promptPromise.catch((error) => {
|
|
2118
|
+
this.subsessionStoppedParents.add(session);
|
|
1960
2119
|
const message = error instanceof Error ? error.message : String(error);
|
|
1961
2120
|
this.publishActivity(session, "error", "error", message);
|
|
1962
2121
|
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
@@ -2099,15 +2258,20 @@ export class PiSessionService {
|
|
|
2099
2258
|
return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
|
|
2100
2259
|
}
|
|
2101
2260
|
async navigateTree(ref, request) {
|
|
2102
|
-
|
|
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() === "")
|
|
2103
2265
|
throw new Error("Session tree target is required");
|
|
2104
2266
|
if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
|
|
2105
2267
|
throw new Error("Stop current session activity before navigating the session tree");
|
|
2106
2268
|
}
|
|
2107
2269
|
await this.assertWritable(ref);
|
|
2108
|
-
const options = sessionTreeNavigationOptions(request);
|
|
2109
2270
|
const session = await this.getOrOpen(ref);
|
|
2110
|
-
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)
|
|
2111
2275
|
throw new Error("Session tree navigation is not supported by this Pi runtime");
|
|
2112
2276
|
if (this.hasActiveWork(session))
|
|
2113
2277
|
throw new Error("Stop current session activity before navigating the session tree");
|
|
@@ -2116,13 +2280,22 @@ export class PiSessionService {
|
|
|
2116
2280
|
this.treeNavigations.add(session);
|
|
2117
2281
|
try {
|
|
2118
2282
|
const oldLeafId = session.sessionManager.getLeafId();
|
|
2119
|
-
if (oldLeafId !==
|
|
2283
|
+
if (oldLeafId !== expectedLeafId) {
|
|
2120
2284
|
throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
|
|
2121
2285
|
}
|
|
2122
|
-
|
|
2123
|
-
|
|
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");
|
|
2124
2295
|
this.publishStatus(session);
|
|
2125
|
-
let result =
|
|
2296
|
+
let result = retainCustomMessage
|
|
2297
|
+
? await navigateTreeRetainingCustomMessage(session.sessionManager, targetId, checkpointTail["parentId"], () => navigateTree(targetId, options))
|
|
2298
|
+
: await navigateTree(targetId, options);
|
|
2126
2299
|
if (activeEditableTargetParentId !== undefined
|
|
2127
2300
|
&& !result.cancelled
|
|
2128
2301
|
&& result.editorText === undefined
|
|
@@ -2134,14 +2307,14 @@ export class PiSessionService {
|
|
|
2134
2307
|
// extraction, agent-context rebuilding, and tree extension events.
|
|
2135
2308
|
setSessionTreeLeaf(session.sessionManager, activeEditableTargetParentId);
|
|
2136
2309
|
try {
|
|
2137
|
-
result = await
|
|
2310
|
+
result = await navigateTree(targetId, options);
|
|
2138
2311
|
}
|
|
2139
2312
|
catch (error) {
|
|
2140
|
-
session.sessionManager.branch(
|
|
2313
|
+
session.sessionManager.branch(targetId);
|
|
2141
2314
|
throw error;
|
|
2142
2315
|
}
|
|
2143
2316
|
if (result.cancelled)
|
|
2144
|
-
session.sessionManager.branch(
|
|
2317
|
+
session.sessionManager.branch(targetId);
|
|
2145
2318
|
}
|
|
2146
2319
|
if (result.cancelled) {
|
|
2147
2320
|
if (this.isCurrentActiveSession(session)) {
|
|
@@ -2149,13 +2322,6 @@ export class PiSessionService {
|
|
|
2149
2322
|
}
|
|
2150
2323
|
return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
|
|
2151
2324
|
}
|
|
2152
|
-
if (session.sessionManager.getLeafId() !== oldLeafId) {
|
|
2153
|
-
// Questions belong to the position we left. Close them for every browser
|
|
2154
|
-
// without appending a cancellation message or waking the rewound session.
|
|
2155
|
-
const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
|
|
2156
|
-
if (outcome !== undefined)
|
|
2157
|
-
this.publishAskClosed(session.sessionId, outcome);
|
|
2158
|
-
}
|
|
2159
2325
|
if (result.summaryEntry !== undefined) {
|
|
2160
2326
|
// A summary entry durably identifies the selected branch as the file's
|
|
2161
2327
|
// newest leaf, superseding any earlier bare selection.
|
|
@@ -2169,6 +2335,8 @@ export class PiSessionService {
|
|
|
2169
2335
|
this.unpersistedTreeBranchLeaves.set(session, selectedLeafId);
|
|
2170
2336
|
}
|
|
2171
2337
|
}
|
|
2338
|
+
this.setSuggestedInput(session.sessionId, result.editorText);
|
|
2339
|
+
this.events.publish(session.sessionId, { type: "session.tree.changed" });
|
|
2172
2340
|
if (this.isCurrentActiveSession(session))
|
|
2173
2341
|
this.publishActivity(session, "session tree navigated", "idle");
|
|
2174
2342
|
return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
|
|
@@ -2189,7 +2357,6 @@ export class PiSessionService {
|
|
|
2189
2357
|
}
|
|
2190
2358
|
else {
|
|
2191
2359
|
this.deferredGeneratedSessionNames.delete(session);
|
|
2192
|
-
this.deferredSubsessionNotifications.delete(session);
|
|
2193
2360
|
}
|
|
2194
2361
|
}
|
|
2195
2362
|
}
|
|
@@ -2200,6 +2367,9 @@ export class PiSessionService {
|
|
|
2200
2367
|
* about to join rather than the forked-from record.
|
|
2201
2368
|
*/
|
|
2202
2369
|
async forkFromTree(ref, request) {
|
|
2370
|
+
return this.forkSessionTree(ref, request);
|
|
2371
|
+
}
|
|
2372
|
+
async forkSessionTree(ref, request, options, expectedSession) {
|
|
2203
2373
|
if (request.entryId.trim() === "")
|
|
2204
2374
|
throw new Error("Session tree entry is required");
|
|
2205
2375
|
if (this.isTreeExclusiveSessionIdentityActive(ref.id)) {
|
|
@@ -2207,6 +2377,8 @@ export class PiSessionService {
|
|
|
2207
2377
|
}
|
|
2208
2378
|
await this.assertWritable(ref);
|
|
2209
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");
|
|
2210
2382
|
if (this.hasActiveWork(session))
|
|
2211
2383
|
throw new Error("Stop current session activity before forking the session tree");
|
|
2212
2384
|
if (session.sessionManager.getLeafId() !== request.expectedLeafId) {
|
|
@@ -2215,8 +2387,15 @@ export class PiSessionService {
|
|
|
2215
2387
|
this.publishActivity(session, "forking session from entry", "active");
|
|
2216
2388
|
this.publishStatus(session);
|
|
2217
2389
|
try {
|
|
2218
|
-
|
|
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,
|
|
2219
2397
|
expectedLeafId: request.expectedLeafId,
|
|
2398
|
+
expectedSession: session,
|
|
2220
2399
|
});
|
|
2221
2400
|
if (result.type === "unsupported")
|
|
2222
2401
|
throw new Error(result.message);
|
|
@@ -2229,6 +2408,8 @@ export class PiSessionService {
|
|
|
2229
2408
|
}
|
|
2230
2409
|
return { cancelled: true };
|
|
2231
2410
|
}
|
|
2411
|
+
this.setSuggestedInput(result.session.id, result.promptDraft);
|
|
2412
|
+
this.events.publish(session.sessionId, { type: "session.tree.changed" });
|
|
2232
2413
|
const forkedSession = this.active.get(result.session.id)?.runtime.session;
|
|
2233
2414
|
if (forkedSession !== undefined && this.isCurrentActiveSession(forkedSession)) {
|
|
2234
2415
|
this.publishActivity(forkedSession, "session forked", "idle");
|
|
@@ -2277,9 +2458,16 @@ export class PiSessionService {
|
|
|
2277
2458
|
this.publishActivity(session, "reloading resources", "active");
|
|
2278
2459
|
const priorGeneration = this.notificationGenerationBySession.get(session);
|
|
2279
2460
|
let candidateGeneration;
|
|
2461
|
+
const reloadBoundary = { beforeSessionStart: false };
|
|
2280
2462
|
try {
|
|
2281
|
-
await session.reload(
|
|
2463
|
+
await session.reload({
|
|
2282
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;
|
|
2283
2471
|
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, notificationIdentityForSession(session));
|
|
2284
2472
|
this.notificationGenerationBySession.set(session, candidateGeneration);
|
|
2285
2473
|
this.replaceSessionNotificationContext(session, candidateGeneration);
|
|
@@ -2292,6 +2480,10 @@ export class PiSessionService {
|
|
|
2292
2480
|
this.publishStatus(session);
|
|
2293
2481
|
}
|
|
2294
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);
|
|
2295
2487
|
if (candidateGeneration !== undefined) {
|
|
2296
2488
|
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
|
|
2297
2489
|
this.notificationGenerationBySession.set(session, candidateGeneration);
|
|
@@ -2400,7 +2592,7 @@ export class PiSessionService {
|
|
|
2400
2592
|
async archiveTree(ref) {
|
|
2401
2593
|
const session = await this.getOrOpen(ref);
|
|
2402
2594
|
const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
|
|
2403
|
-
const root =
|
|
2595
|
+
const root = findArchiveCandidateByExactId(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
|
|
2404
2596
|
const plan = planSessionArchiveTree(root, catalog);
|
|
2405
2597
|
const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
|
|
2406
2598
|
if (busy !== undefined)
|
|
@@ -2546,6 +2738,7 @@ export class PiSessionService {
|
|
|
2546
2738
|
return;
|
|
2547
2739
|
const sessionId = active.runtime.session.sessionId;
|
|
2548
2740
|
this.clearCompactionPromptQueue(sessionId);
|
|
2741
|
+
this.subsessionStoppedParents.add(active.runtime.session);
|
|
2549
2742
|
clearSessionQueue(active.runtime.session);
|
|
2550
2743
|
// Settle run-scoped dialogs now, at abort-request time: pi's agent loop
|
|
2551
2744
|
// waits for a parked `tool_call` dialog handler before it can emit
|
|
@@ -2716,6 +2909,7 @@ export class PiSessionService {
|
|
|
2716
2909
|
if (pendingOpens.length > 0)
|
|
2717
2910
|
await Promise.allSettled(pendingOpens);
|
|
2718
2911
|
const active = this.active.get(sessionId);
|
|
2912
|
+
this.suggestedInputs.delete(sessionId);
|
|
2719
2913
|
if (notificationPolicy.kind === "clear") {
|
|
2720
2914
|
const generation = active === undefined ? undefined : this.notificationGenerationBySession.get(active.runtime.session);
|
|
2721
2915
|
const mutations = generation === undefined
|
|
@@ -2726,11 +2920,8 @@ export class PiSessionService {
|
|
|
2726
2920
|
if (!active)
|
|
2727
2921
|
return;
|
|
2728
2922
|
this.forgetUnreadActivity(active.runtime.session);
|
|
2729
|
-
//
|
|
2730
|
-
//
|
|
2731
|
-
this.pendingAskStore.forgetSession(sessionId);
|
|
2732
|
-
// Open dialogs share that stance, but their extension waiters are parked
|
|
2733
|
-
// 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.
|
|
2734
2925
|
this.endSessionExtensionDialogs(sessionId);
|
|
2735
2926
|
this.active.delete(sessionId);
|
|
2736
2927
|
this.events.mediaIndex.forgetSession({ id: sessionId, cwd: active.runtime.session.sessionManager.getCwd() });
|
|
@@ -2908,26 +3099,12 @@ export class PiSessionService {
|
|
|
2908
3099
|
return { id: sessionId, cwd: active.runtime.cwd };
|
|
2909
3100
|
}
|
|
2910
3101
|
activeForRef(ref) {
|
|
2911
|
-
const
|
|
2912
|
-
|
|
2913
|
-
if (exact !== undefined && refMatchesActiveSession(ref, exact))
|
|
2914
|
-
return exact;
|
|
2915
|
-
for (const [candidateId, active] of this.active.entries()) {
|
|
2916
|
-
if (candidateId.startsWith(sessionId) && refMatchesActiveSession(ref, active))
|
|
2917
|
-
return active;
|
|
2918
|
-
}
|
|
2919
|
-
return undefined;
|
|
3102
|
+
const active = this.active.get(ref.id);
|
|
3103
|
+
return active !== undefined && refMatchesActiveSession(ref, active) ? active : undefined;
|
|
2920
3104
|
}
|
|
2921
3105
|
startupSessionForRef(ref) {
|
|
2922
|
-
const
|
|
2923
|
-
|
|
2924
|
-
if (exact !== undefined && refMatchesStartupSession(ref, exact))
|
|
2925
|
-
return exact;
|
|
2926
|
-
for (const [candidateId, session] of this.startupSessions.entries()) {
|
|
2927
|
-
if (candidateId.startsWith(sessionId) && refMatchesStartupSession(ref, session))
|
|
2928
|
-
return session;
|
|
2929
|
-
}
|
|
2930
|
-
return undefined;
|
|
3106
|
+
const session = this.startupSessions.get(ref.id);
|
|
3107
|
+
return session !== undefined && refMatchesStartupSession(ref, session) ? session : undefined;
|
|
2931
3108
|
}
|
|
2932
3109
|
/**
|
|
2933
3110
|
* The session to serve a read-only status or a dialog close for, while it
|
|
@@ -3036,7 +3213,13 @@ export class PiSessionService {
|
|
|
3036
3213
|
// holds for this session; settle those waits before the new
|
|
3037
3214
|
// runtime's extensions can open fresh dialogs under the same id.
|
|
3038
3215
|
this.endSessionExtensionDialogs(boundSession.sessionId);
|
|
3216
|
+
const changedIdentity = boundSession.sessionId !== session.sessionId;
|
|
3039
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());
|
|
3040
3223
|
await this.bindSessionExtensions(session, candidateGeneration);
|
|
3041
3224
|
if (candidateGeneration !== undefined) {
|
|
3042
3225
|
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
|
|
@@ -3111,6 +3294,7 @@ export class PiSessionService {
|
|
|
3111
3294
|
await session.bindExtensions({
|
|
3112
3295
|
uiContext,
|
|
3113
3296
|
mode: "rpc",
|
|
3297
|
+
commandContextActions: this.extensionCommandActions(session),
|
|
3114
3298
|
onError: (error) => {
|
|
3115
3299
|
const message = `${error.extensionPath}: ${error.error}`;
|
|
3116
3300
|
this.publishActivity(session, "extension error", "error", message);
|
|
@@ -3125,6 +3309,83 @@ export class PiSessionService {
|
|
|
3125
3309
|
this.startupSessions.delete(session.sessionId);
|
|
3126
3310
|
}
|
|
3127
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
|
+
}
|
|
3128
3389
|
replaceSessionNotificationContext(session, generation) {
|
|
3129
3390
|
session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
|
|
3130
3391
|
}
|
|
@@ -3301,9 +3562,30 @@ export class PiSessionService {
|
|
|
3301
3562
|
this.clearCompactionPromptQueue(sessionId);
|
|
3302
3563
|
}
|
|
3303
3564
|
}
|
|
3565
|
+
this.restoreSubsessionReceipts(session);
|
|
3304
3566
|
const unsubscribe = session.subscribe((event) => {
|
|
3305
3567
|
const eventType = getString(event, "type");
|
|
3306
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);
|
|
3307
3589
|
if ((eventType === "message_start" || eventType === "message_update") && getString(message, "role") === "assistant") {
|
|
3308
3590
|
// SDK state advances before async extension hooks finish. Capture only
|
|
3309
3591
|
// published messages, detaching even blocks left intact by projection.
|
|
@@ -3318,23 +3600,18 @@ export class PiSessionService {
|
|
|
3318
3600
|
}
|
|
3319
3601
|
this.events.publish(session.sessionId, clientEvent, { id: session.sessionId, cwd: session.sessionManager.getCwd() });
|
|
3320
3602
|
this.publishActivityForEvent(session, event);
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
if (eventType === "message_start" && isRecord(event) && getString(event["message"], "role") === "user") {
|
|
3324
|
-
void this.voidOpenAskForUserMessage(session).catch((error) => {
|
|
3325
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
3326
|
-
this.publishActivity(session, "error", "error", message);
|
|
3327
|
-
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
3328
|
-
});
|
|
3329
|
-
}
|
|
3603
|
+
if (eventType === "message_start" && getString(message, "role") === "user")
|
|
3604
|
+
this.suggestedInputs.delete(session.sessionId);
|
|
3330
3605
|
if (eventType === "agent_end")
|
|
3331
3606
|
this.abortRunScopedExtensionDialogs(session.sessionId);
|
|
3332
3607
|
if (eventType === "compaction_end")
|
|
3333
3608
|
this.scheduleCompactionQueueDrain(session.sessionId);
|
|
3609
|
+
if (eventType === "compaction_end" || eventType === "bash_execution_end")
|
|
3610
|
+
this.scheduleSubsessionDelivery(session);
|
|
3334
3611
|
if (eventType === "agent_start" || eventType === "agent_end")
|
|
3335
3612
|
this.scheduleCompactionQueueDrain(session.sessionId);
|
|
3336
3613
|
this.publishStatus(session);
|
|
3337
|
-
this.updateSubsessionTracking(session, eventType === "agent_start");
|
|
3614
|
+
this.updateSubsessionTracking(session, eventType === "agent_start", eventType === "agent_settled");
|
|
3338
3615
|
});
|
|
3339
3616
|
active.unsubscribe = () => {
|
|
3340
3617
|
this.sessionEvents.close(session);
|
|
@@ -3405,11 +3682,6 @@ export class PiSessionService {
|
|
|
3405
3682
|
maybeGenerateSessionName(session, firstMessage) {
|
|
3406
3683
|
if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting)
|
|
3407
3684
|
return;
|
|
3408
|
-
const deterministicName = deterministicSessionName(firstMessage);
|
|
3409
|
-
if (deterministicName !== undefined) {
|
|
3410
|
-
this.applyGeneratedSessionName(session, deterministicName);
|
|
3411
|
-
return;
|
|
3412
|
-
}
|
|
3413
3685
|
const model = session.model;
|
|
3414
3686
|
if (model === undefined)
|
|
3415
3687
|
return;
|
|
@@ -3440,13 +3712,8 @@ export class PiSessionService {
|
|
|
3440
3712
|
this.logger.info({ sessionId: session.sessionId, error: error instanceof Error ? error.message : String(error) }, "failed to apply deferred session name");
|
|
3441
3713
|
}
|
|
3442
3714
|
}
|
|
3443
|
-
|
|
3444
|
-
this.
|
|
3445
|
-
for (const notification of notifications) {
|
|
3446
|
-
void this.deliverSubsessionNotification(session, notification).catch((error) => {
|
|
3447
|
-
this.logSubsessionNotificationFailure(notification.parentId, notification.childId, error);
|
|
3448
|
-
});
|
|
3449
|
-
}
|
|
3715
|
+
this.restoreSubsessionReceipts(session);
|
|
3716
|
+
this.scheduleSubsessionDelivery(session);
|
|
3450
3717
|
}
|
|
3451
3718
|
applyAuthChange(change = {}) {
|
|
3452
3719
|
// ModelRuntime.login()/logout() refresh the shared runtime before AuthService
|
|
@@ -3532,8 +3799,11 @@ export class PiSessionService {
|
|
|
3532
3799
|
return "active";
|
|
3533
3800
|
}
|
|
3534
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;
|
|
3535
3805
|
return this.treeNavigations.has(session)
|
|
3536
|
-
|| this.isSessionEntryMutationActive(session)
|
|
3806
|
+
|| this.isSessionEntryMutationActive(session, ownCommandReceipt)
|
|
3537
3807
|
|| this.isTreeExclusiveOperationActive(session)
|
|
3538
3808
|
|| sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
|
|
3539
3809
|
}
|
|
@@ -3615,9 +3885,11 @@ export class PiSessionService {
|
|
|
3615
3885
|
else
|
|
3616
3886
|
this.sessionEntryMutationCounts.set(session, remaining);
|
|
3617
3887
|
this.observeUnreadActivityState(session);
|
|
3888
|
+
if (remaining <= 0 && !this.subsessionDeliveringParents.has(session))
|
|
3889
|
+
this.scheduleSubsessionDelivery(session);
|
|
3618
3890
|
}
|
|
3619
|
-
isSessionEntryMutationActive(session) {
|
|
3620
|
-
return (this.sessionEntryMutationCounts.get(session) ?? 0) >
|
|
3891
|
+
isSessionEntryMutationActive(session, ignoredReceipts = 0) {
|
|
3892
|
+
return (this.sessionEntryMutationCounts.get(session) ?? 0) > ignoredReceipts;
|
|
3621
3893
|
}
|
|
3622
3894
|
publishActivityForEvent(session, event) {
|
|
3623
3895
|
const eventType = getString(event, "type");
|
|
@@ -3764,11 +4036,12 @@ export class PiSessionService {
|
|
|
3764
4036
|
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
|
|
3765
4037
|
const contextUsage = session.getContextUsage();
|
|
3766
4038
|
const warnings = this.warningsForSession(session);
|
|
3767
|
-
const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
|
|
3768
4039
|
const pendingDialogs = this.pendingExtensionDialogStore.pendingDialogs(session.sessionId);
|
|
4040
|
+
const suggestedInput = this.suggestedInputs.get(session.sessionId);
|
|
3769
4041
|
return {
|
|
3770
4042
|
sessionId: session.sessionId,
|
|
3771
4043
|
persisted: sessionFileExists(session.sessionFile),
|
|
4044
|
+
recentlyActiveElsewhere: this.activityMarker.isActiveElsewhere(session.sessionFile),
|
|
3772
4045
|
...(model === undefined ? {} : { model }),
|
|
3773
4046
|
thinkingLevel: session.thinkingLevel,
|
|
3774
4047
|
isStreaming: session.isStreaming,
|
|
@@ -3781,8 +4054,8 @@ export class PiSessionService {
|
|
|
3781
4054
|
cost: stats.cost,
|
|
3782
4055
|
...(contextUsage === undefined ? {} : { contextUsage }),
|
|
3783
4056
|
...(warnings.length === 0 ? {} : { warnings }),
|
|
3784
|
-
...(pendingAsk === undefined ? {} : { pendingAsk }),
|
|
3785
4057
|
...(pendingDialogs.length === 0 ? {} : { pendingDialogs }),
|
|
4058
|
+
...(suggestedInput === undefined ? {} : { suggestedInput }),
|
|
3786
4059
|
};
|
|
3787
4060
|
}
|
|
3788
4061
|
/**
|
|
@@ -3794,13 +4067,6 @@ export class PiSessionService {
|
|
|
3794
4067
|
warningsForSession(session) {
|
|
3795
4068
|
const runtime = this.active.get(session.sessionId)?.runtime;
|
|
3796
4069
|
const warnings = runtime === undefined ? [] : collectRuntimeWarnings(runtime);
|
|
3797
|
-
if (this.activityMarker.isActiveElsewhere(session.sessionFile)) {
|
|
3798
|
-
warnings.push({
|
|
3799
|
-
severity: "info",
|
|
3800
|
-
message: "Recently active in another PI-WEB instance. Avoid working on this session in both instances at once.",
|
|
3801
|
-
source: "PI-WEB",
|
|
3802
|
-
});
|
|
3803
|
-
}
|
|
3804
4070
|
const anthropic = anthropicSubscriptionWarning(session, join(this.agentDir, "auth.json"));
|
|
3805
4071
|
if (anthropic !== undefined)
|
|
3806
4072
|
warnings.push(anthropic);
|
|
@@ -3841,13 +4107,13 @@ function bulkRefToSessionRef(ref) {
|
|
|
3841
4107
|
return { id: ref.id, cwd: ref.cwd };
|
|
3842
4108
|
}
|
|
3843
4109
|
function findArchivedRecordForBulkRef(records, ref) {
|
|
3844
|
-
return records.find((record) => record.cwd === ref.cwd &&
|
|
4110
|
+
return records.find((record) => record.cwd === ref.cwd && record.sessionId === ref.id);
|
|
3845
4111
|
}
|
|
3846
4112
|
function findListedSessionForBulkRef(context, ref) {
|
|
3847
|
-
return
|
|
4113
|
+
return findSessionByExactId(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
|
|
3848
4114
|
}
|
|
3849
|
-
function
|
|
3850
|
-
return sessions.find((session) => session.id === sessionId)
|
|
4115
|
+
function findSessionByExactId(sessions, sessionId) {
|
|
4116
|
+
return sessions.find((session) => session.id === sessionId);
|
|
3851
4117
|
}
|
|
3852
4118
|
function uniqueStrings(values) {
|
|
3853
4119
|
return [...new Set(values)];
|
|
@@ -3888,12 +4154,35 @@ function notificationIdentityForSession(session) {
|
|
|
3888
4154
|
cwd: canonicalizeStoredCwd(session.sessionManager.getCwd()),
|
|
3889
4155
|
};
|
|
3890
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
|
+
}
|
|
3891
4178
|
function clientSessionFromListEntry(session) {
|
|
3892
4179
|
return {
|
|
3893
4180
|
id: session.id,
|
|
3894
4181
|
path: session.path,
|
|
3895
4182
|
cwd: session.cwd,
|
|
3896
4183
|
persisted: true,
|
|
4184
|
+
...(session.agentSubsession === true ? { agentSubsession: true } : {}),
|
|
4185
|
+
...(session.metadata === undefined ? {} : { metadata: requireSessionUiMetadata(session.metadata) }),
|
|
3897
4186
|
...(session.name === undefined ? {} : { name: session.name }),
|
|
3898
4187
|
created: session.created.toISOString(),
|
|
3899
4188
|
modified: session.modified.toISOString(),
|
|
@@ -3911,6 +4200,7 @@ function archiveInputFromListEntry(session) {
|
|
|
3911
4200
|
modified: session.modified.toISOString(),
|
|
3912
4201
|
messageCount: session.messageCount,
|
|
3913
4202
|
firstMessage: session.firstMessage,
|
|
4203
|
+
...(session.metadata === undefined ? {} : { metadata: session.metadata }),
|
|
3914
4204
|
...(session.name === undefined ? {} : { name: session.name }),
|
|
3915
4205
|
...(session.parentSessionPath === undefined ? {} : { parentSessionPath: session.parentSessionPath }),
|
|
3916
4206
|
};
|
|
@@ -3920,6 +4210,7 @@ function archiveInputFromActiveSession(session) {
|
|
|
3920
4210
|
if (sessionFile === undefined || sessionFile === "")
|
|
3921
4211
|
throw new Error("Session is not persisted");
|
|
3922
4212
|
const parentSessionPath = session.sessionManager.getHeader?.()?.parentSession;
|
|
4213
|
+
const metadata = metadataFromActiveSession(session);
|
|
3923
4214
|
return {
|
|
3924
4215
|
sessionId: session.sessionId,
|
|
3925
4216
|
cwd: session.sessionManager.getCwd(),
|
|
@@ -3928,6 +4219,7 @@ function archiveInputFromActiveSession(session) {
|
|
|
3928
4219
|
modified: new Date().toISOString(),
|
|
3929
4220
|
messageCount: session.messages.length,
|
|
3930
4221
|
firstMessage: "",
|
|
4222
|
+
...(metadata === undefined ? {} : { metadata }),
|
|
3931
4223
|
...(session.sessionName === undefined ? {} : { name: session.sessionName }),
|
|
3932
4224
|
...(parentSessionPath === undefined ? {} : { parentSessionPath }),
|
|
3933
4225
|
};
|
|
@@ -3992,11 +4284,13 @@ function clientSessionFromArchivedRecord(record, fallback) {
|
|
|
3992
4284
|
if (path === undefined || created === undefined || modified === undefined || messageCount === undefined || firstMessage === undefined)
|
|
3993
4285
|
return undefined;
|
|
3994
4286
|
const name = record.name ?? fallback?.name;
|
|
4287
|
+
const metadata = record.metadata ?? fallback?.metadata;
|
|
3995
4288
|
const parentSessionPath = record.parentSessionPath ?? fallback?.parentSessionPath;
|
|
3996
4289
|
return {
|
|
3997
4290
|
id: record.sessionId,
|
|
3998
4291
|
path,
|
|
3999
4292
|
cwd: record.cwd,
|
|
4293
|
+
...(metadata === undefined ? {} : { metadata: requireSessionUiMetadata(metadata) }),
|
|
4000
4294
|
...(name === undefined ? {} : { name }),
|
|
4001
4295
|
created,
|
|
4002
4296
|
modified,
|
|
@@ -4110,37 +4404,6 @@ function persistedChildSubsessionLinkData(parentSessionId, childSessionId) {
|
|
|
4110
4404
|
spawnedSessionId: childSessionId,
|
|
4111
4405
|
};
|
|
4112
4406
|
}
|
|
4113
|
-
function parsePersistedParentSubsessionLink(entry) {
|
|
4114
|
-
if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE)
|
|
4115
|
-
return undefined;
|
|
4116
|
-
const data = entry["data"];
|
|
4117
|
-
if (!isRecord(data))
|
|
4118
|
-
return undefined;
|
|
4119
|
-
const spawnedBySessionId = getString(data, "spawnedBySessionId");
|
|
4120
|
-
const spawnedSessionId = getString(data, "spawnedSessionId");
|
|
4121
|
-
if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
|
|
4122
|
-
return undefined;
|
|
4123
|
-
const spawnedSessionFile = getString(data, "spawnedSessionFile");
|
|
4124
|
-
const cwd = getString(data, "cwd");
|
|
4125
|
-
return {
|
|
4126
|
-
spawnedBySessionId,
|
|
4127
|
-
spawnedSessionId,
|
|
4128
|
-
...(spawnedSessionFile === undefined || spawnedSessionFile === "" ? {} : { spawnedSessionFile }),
|
|
4129
|
-
...(cwd === undefined || cwd === "" ? {} : { cwd }),
|
|
4130
|
-
};
|
|
4131
|
-
}
|
|
4132
|
-
function parsePersistedChildSubsessionLink(entry) {
|
|
4133
|
-
if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE)
|
|
4134
|
-
return undefined;
|
|
4135
|
-
const data = entry["data"];
|
|
4136
|
-
if (!isRecord(data))
|
|
4137
|
-
return undefined;
|
|
4138
|
-
const spawnedBySessionId = getString(data, "spawnedBySessionId");
|
|
4139
|
-
const spawnedSessionId = getString(data, "spawnedSessionId");
|
|
4140
|
-
if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
|
|
4141
|
-
return undefined;
|
|
4142
|
-
return { spawnedBySessionId, spawnedSessionId };
|
|
4143
|
-
}
|
|
4144
4407
|
function nonEmptyString(value) {
|
|
4145
4408
|
return value === undefined || value === "" ? undefined : value;
|
|
4146
4409
|
}
|
|
@@ -4258,6 +4521,55 @@ function activeEditableTreeTargetParentId(manager, targetId, activeLeafId) {
|
|
|
4258
4521
|
return undefined;
|
|
4259
4522
|
return parentId === null || typeof parentId === "string" ? parentId : undefined;
|
|
4260
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
|
+
}
|
|
4261
4573
|
function setSessionTreeLeaf(manager, leafId) {
|
|
4262
4574
|
if (leafId === null)
|
|
4263
4575
|
manager.resetLeaf();
|
|
@@ -4271,15 +4583,22 @@ function transcriptBranchIncludesEntry(entries, entryId) {
|
|
|
4271
4583
|
const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
|
|
4272
4584
|
/** custom entry type used to mark a child as created by spawn_subsession. */
|
|
4273
4585
|
const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned";
|
|
4274
|
-
/**
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
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;
|
|
4283
4602
|
}
|
|
4284
4603
|
/** Most recent assistant text from a history message list, or "" if none. */
|
|
4285
4604
|
function finalAssistantText(messages) {
|