@jmfederico/pi-web 1.202607.2 → 1.202607.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/{CodeViewer-B4M13447.js → CodeViewer-CDVbMiN9.js} +1 -1
- package/dist/client/assets/{TerminalPanel-Bmvj-Ecn.js → TerminalPanel-CTS1CgqF.js} +2 -2
- package/dist/client/assets/{UnifiedDiffViewer-C0hygwRo.js → UnifiedDiffViewer-Cqke12ZX.js} +1 -1
- package/dist/client/assets/index-LME0LfPb.js +4068 -0
- package/dist/client/index.html +1 -1
- package/dist/config.js +39 -0
- package/dist/config.js.map +1 -1
- package/dist/pi-web-plugins/info/infoInternals.js +208 -0
- package/dist/pi-web-plugins/info/pi-web-plugin.js +12 -15
- package/dist/pi-web-plugins/relays/markdownDocument.js +81 -0
- package/dist/pi-web-plugins/relays/package.json +9 -0
- package/dist/pi-web-plugins/relays/pi-web-plugin.js +44 -0
- package/dist/pi-web-plugins/relays/relayDiscovery.js +96 -0
- package/dist/pi-web-plugins/relays/relaysPanelElement.js +393 -0
- package/dist/pi-web-plugins/relays/vendor/README.md +24 -0
- package/dist/pi-web-plugins/relays/vendor/marked.esm.js +76 -0
- package/dist/plugin-api.d.ts +7 -1
- package/dist/server/configRoutes.js +11 -0
- package/dist/server/configRoutes.js.map +1 -1
- package/dist/server/sessiond/sessionServiceDependencies.js +32 -0
- package/dist/server/sessiond/sessionServiceDependencies.js.map +1 -0
- package/dist/server/sessiond.js +122 -114
- package/dist/server/sessiond.js.map +1 -1
- package/dist/server/sessions/askUserTool.js +105 -0
- package/dist/server/sessions/askUserTool.js.map +1 -0
- package/dist/server/sessions/extensionDialogWaiters.js +76 -0
- package/dist/server/sessions/extensionDialogWaiters.js.map +1 -0
- package/dist/server/sessions/globalProviderPolicy.js +79 -10
- package/dist/server/sessions/globalProviderPolicy.js.map +1 -1
- package/dist/server/sessions/modelCatalogRefresher.js +8 -0
- package/dist/server/sessions/modelCatalogRefresher.js.map +1 -1
- package/dist/server/sessions/parentSessionLocator.js +75 -0
- package/dist/server/sessions/parentSessionLocator.js.map +1 -0
- package/dist/server/sessions/pendingAskStore.js +278 -0
- package/dist/server/sessions/pendingAskStore.js.map +1 -0
- package/dist/server/sessions/pendingExtensionDialogStore.js +196 -0
- package/dist/server/sessions/pendingExtensionDialogStore.js.map +1 -0
- package/dist/server/sessions/piSessionService.js +504 -44
- package/dist/server/sessions/piSessionService.js.map +1 -1
- package/dist/server/sessions/sessionFileHeader.js +45 -0
- package/dist/server/sessions/sessionFileHeader.js.map +1 -0
- package/dist/server/sessions/sessionRoutes.js +99 -2
- package/dist/server/sessions/sessionRoutes.js.map +1 -1
- package/dist/server/sessions/spawnTargetResolver.js +3 -16
- package/dist/server/sessions/spawnTargetResolver.js.map +1 -1
- package/dist/server/workspaces/gitWorktreeDiscovery.js +9 -0
- package/dist/server/workspaces/gitWorktreeDiscovery.js.map +1 -1
- package/dist/server/workspaces/projectWorkspaceCwds.js +22 -0
- package/dist/server/workspaces/projectWorkspaceCwds.js.map +1 -0
- package/dist/server/workspaces/workspaceService.js +15 -2
- package/dist/server/workspaces/workspaceService.js.map +1 -1
- package/dist/shared/activity.js +9 -1
- package/dist/shared/activity.js.map +1 -1
- package/dist/shared/apiTypes.d.ts +294 -1
- package/dist/shared/apiTypes.js +24 -0
- package/dist/shared/apiTypes.js.map +1 -1
- package/dist/shared/capabilities.js +3 -0
- package/dist/shared/capabilities.js.map +1 -1
- package/dist/shared/federatedRoutes.js +4 -0
- package/dist/shared/federatedRoutes.js.map +1 -1
- package/docs/config.md +56 -7
- package/docs/plugins.md +77 -11
- package/package.json +2 -1
- package/dist/client/assets/index-TvPsRyS1.js +0 -3545
- package/dist/server/sessiond/sessionDaemonStartup.js +0 -49
- package/dist/server/sessiond/sessionDaemonStartup.js.map +0 -1
- package/dist/server/sessions/sessionArchiveMigration.js +0 -590
- package/dist/server/sessions/sessionArchiveMigration.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, readStoredCredential, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { projectBrowserMessage } from "../browserMessageProjection.js";
|
|
6
6
|
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
|
@@ -13,8 +13,16 @@ import { deterministicSessionName, fallbackSessionName, generateShortSessionName
|
|
|
13
13
|
import { computeEditPreview } from "./editPreview.js";
|
|
14
14
|
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
|
15
15
|
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
|
16
|
-
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
|
|
16
|
+
import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
|
|
17
17
|
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
|
18
|
+
import { readSessionHeaderSummary } from "./sessionFileHeader.js";
|
|
19
|
+
import { countOutOfListingChildren, locateOutOfListingParents } from "./parentSessionLocator.js";
|
|
20
|
+
import { siblingWorkspaceCwds } from "../workspaces/projectWorkspaceCwds.js";
|
|
21
|
+
import { createAskUserToolDefinition } from "./askUserTool.js";
|
|
22
|
+
import { PendingAskStore, renderAskUserAnswersText } from "./pendingAskStore.js";
|
|
23
|
+
import { PendingExtensionDialogStore } from "./pendingExtensionDialogStore.js";
|
|
24
|
+
import { ExtensionDialogWaiters, effectiveExtensionDialogTimeoutMs, extensionDialogCancelValue } from "./extensionDialogWaiters.js";
|
|
25
|
+
import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS } from "../../config.js";
|
|
18
26
|
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
|
|
19
27
|
import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
|
|
20
28
|
import { buildTranscriptView } from "./subsessionTranscript.js";
|
|
@@ -24,6 +32,20 @@ import { plainTextTheme } from "./plainTextTheme.js";
|
|
|
24
32
|
import { SessionUnreadStore } from "./sessionUnreadStore.js";
|
|
25
33
|
const noopLogger = { info() { } };
|
|
26
34
|
const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
|
|
35
|
+
/**
|
|
36
|
+
* User-facing names for the two phases of session startup PI WEB can prove it
|
|
37
|
+
* is inside: it awaits exactly one call for each, so the phase is a fact rather
|
|
38
|
+
* than a guess. Deliberately free of internal symbol names and file paths.
|
|
39
|
+
*/
|
|
40
|
+
const STARTUP_PHASE_RUNTIME = "Starting the Pi session";
|
|
41
|
+
const STARTUP_PHASE_EXTENSIONS = "Loading session extensions";
|
|
42
|
+
/**
|
|
43
|
+
* Appended to whichever phase is running when a background provider catalog
|
|
44
|
+
* refresh happens to be in flight. It is stated as a concurrent fact, never as
|
|
45
|
+
* the cause: PI WEB can verify that a refresh is running, but not that this
|
|
46
|
+
* particular startup is waiting on it.
|
|
47
|
+
*/
|
|
48
|
+
const STARTUP_CONCURRENT_CATALOG_REFRESH = "provider model lists are refreshing";
|
|
27
49
|
const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
|
|
28
50
|
const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
|
|
29
51
|
function noop() {
|
|
@@ -46,6 +68,9 @@ function isPiSessionRef(ref) {
|
|
|
46
68
|
function lookupMatchesActiveSession(ref, active) {
|
|
47
69
|
return !isPiSessionRef(ref) || cwdPathsEqual(active.runtime.cwd, ref.cwd);
|
|
48
70
|
}
|
|
71
|
+
function lookupMatchesStartupSession(ref, session) {
|
|
72
|
+
return !isPiSessionRef(ref) || cwdPathsEqual(session.sessionManager.getCwd(), ref.cwd);
|
|
73
|
+
}
|
|
49
74
|
function sessionTreeNavigationOptions(request) {
|
|
50
75
|
switch (request.summary.mode) {
|
|
51
76
|
case "none":
|
|
@@ -219,19 +244,22 @@ function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, del
|
|
|
219
244
|
});
|
|
220
245
|
};
|
|
221
246
|
}
|
|
222
|
-
export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions) {
|
|
247
|
+
export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions, askUser) {
|
|
223
248
|
return [
|
|
224
249
|
createPiWebEditToolDefinition(cwd),
|
|
225
250
|
...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
|
|
226
251
|
...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
|
|
252
|
+
// Asking the user is not delegation: the questions land in the session the
|
|
253
|
+
// user is already watching, so tracked children may ask too.
|
|
254
|
+
...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
|
|
227
255
|
];
|
|
228
256
|
}
|
|
229
|
-
function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions) {
|
|
257
|
+
function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser) {
|
|
230
258
|
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
|
|
231
259
|
const services = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
|
|
232
260
|
const resolvedDelegationToolsEnabled = delegationToolsEnabled
|
|
233
261
|
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
|
234
|
-
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
|
|
262
|
+
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
|
|
235
263
|
const result = await createAgentSessionFromServices({
|
|
236
264
|
services,
|
|
237
265
|
sessionManager,
|
|
@@ -268,6 +296,14 @@ export class PiSessionService {
|
|
|
268
296
|
this.events = events;
|
|
269
297
|
this.active = new Map();
|
|
270
298
|
this.pendingSessionOpens = new Map();
|
|
299
|
+
/**
|
|
300
|
+
* Sessions whose extension binding is still in flight. A `session_start`
|
|
301
|
+
* dialog parks that window before the session ever becomes active, so this
|
|
302
|
+
* is the only way the dialog answer/cancel and status paths can reach it;
|
|
303
|
+
* {@link getOrOpen} never consults it, keeping every other operation gated
|
|
304
|
+
* on full readiness.
|
|
305
|
+
*/
|
|
306
|
+
this.startupSessions = new Map();
|
|
271
307
|
this.activities = new Map();
|
|
272
308
|
/** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
|
|
273
309
|
this.treeNavigations = new WeakSet();
|
|
@@ -289,6 +325,8 @@ export class PiSessionService {
|
|
|
289
325
|
this.subsessionLinks = new Map();
|
|
290
326
|
/** Parent id/file identities whose persisted links have already been loaded. */
|
|
291
327
|
this.subsessionHydratedParents = new Set();
|
|
328
|
+
/** Session file path -> its parsed header. Headers are written once, so successful reads are cached. */
|
|
329
|
+
this.sessionHeaderCache = new Map();
|
|
292
330
|
/**
|
|
293
331
|
* Tracked subsession id -> whether a completion notification is armed.
|
|
294
332
|
* Armed when the child starts working; firing on completion disarms it so a
|
|
@@ -296,18 +334,39 @@ export class PiSessionService {
|
|
|
296
334
|
*/
|
|
297
335
|
this.subsessionNotifyArmed = new Map();
|
|
298
336
|
this.notificationGenerationBySession = new WeakMap();
|
|
337
|
+
/** The parked extension Promise resolvers behind the store's open dialogs. */
|
|
338
|
+
this.dialogWaiters = new ExtensionDialogWaiters();
|
|
299
339
|
this.pendingUnreadMutations = [];
|
|
300
340
|
this.unreadPublicationFlushRequested = false;
|
|
301
341
|
this.unreadPublicationStopped = false;
|
|
342
|
+
/**
|
|
343
|
+
* Read a session file header, memoized per path. Pi writes the header once at
|
|
344
|
+
* session creation, so a successful read stays valid for the process lifetime;
|
|
345
|
+
* failures are not cached so a session file that appears later is picked up.
|
|
346
|
+
*/
|
|
347
|
+
this.readCachedSessionHeader = async (sessionFile) => {
|
|
348
|
+
const cached = this.sessionHeaderCache.get(sessionFile);
|
|
349
|
+
if (cached !== undefined)
|
|
350
|
+
return cached;
|
|
351
|
+
const header = await readSessionHeaderSummary(sessionFile);
|
|
352
|
+
if (header !== undefined)
|
|
353
|
+
this.sessionHeaderCache.set(sessionFile, header);
|
|
354
|
+
return header;
|
|
355
|
+
};
|
|
302
356
|
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
|
|
303
357
|
this.agentDir = deps.agentDir;
|
|
304
358
|
this.sessionManager = deps.sessionManager;
|
|
305
359
|
this.modelRuntime = deps.modelRuntime;
|
|
306
360
|
this.spawnTargets = deps.spawnTargets;
|
|
361
|
+
this.projectWorkspaces = deps.projectWorkspaces;
|
|
307
362
|
this.logger = deps.logger ?? noopLogger;
|
|
308
363
|
this.now = deps.now ?? (() => new Date());
|
|
309
364
|
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
|
|
310
365
|
this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
|
|
366
|
+
this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
|
|
367
|
+
this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
|
|
368
|
+
this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
|
|
369
|
+
this.catalogRefreshStatus = deps.catalogRefreshStatus;
|
|
311
370
|
this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
|
|
312
371
|
this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
|
|
313
372
|
// Subsessions are a beta capability gated behind their own flag, and they
|
|
@@ -318,7 +377,7 @@ export class PiSessionService {
|
|
|
318
377
|
list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
|
|
319
378
|
check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
|
|
320
379
|
read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
|
|
321
|
-
});
|
|
380
|
+
}, deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined);
|
|
322
381
|
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
|
323
382
|
this.workspaceActivity = deps.workspaceActivity;
|
|
324
383
|
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
|
|
@@ -423,14 +482,22 @@ export class PiSessionService {
|
|
|
423
482
|
this.clearUnreadPublicationRetry();
|
|
424
483
|
clearInterval(this.heartbeat);
|
|
425
484
|
this.clearCompactionDrainTimers();
|
|
485
|
+
// Same startup-park hazard as closeActive(): settle `session_start` dialogs
|
|
486
|
+
// of sessions still binding extensions before awaiting their pending opens.
|
|
487
|
+
for (const sessionId of this.startupSessions.keys())
|
|
488
|
+
this.endSessionExtensionDialogs(sessionId);
|
|
426
489
|
const pendingOpens = this.pendingSessionOpenPromises();
|
|
427
490
|
if (pendingOpens.length > 0)
|
|
428
491
|
await Promise.allSettled(pendingOpens);
|
|
429
492
|
const activeSessions = Array.from(new Set(this.active.values()));
|
|
430
|
-
for (const active of activeSessions)
|
|
493
|
+
for (const active of activeSessions) {
|
|
431
494
|
this.forgetUnreadActivity(active.runtime.session);
|
|
495
|
+
this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
|
|
496
|
+
this.endSessionExtensionDialogs(active.runtime.session.sessionId);
|
|
497
|
+
}
|
|
432
498
|
this.active.clear();
|
|
433
499
|
this.pendingSessionOpens.clear();
|
|
500
|
+
this.startupSessions.clear();
|
|
434
501
|
this.activities.clear();
|
|
435
502
|
this.compactionPromptQueues.clear();
|
|
436
503
|
this.authLossWarnings.clear();
|
|
@@ -439,6 +506,7 @@ export class PiSessionService {
|
|
|
439
506
|
this.subsessionLinks.clear();
|
|
440
507
|
this.subsessionHydratedParents.clear();
|
|
441
508
|
this.subsessionNotifyArmed.clear();
|
|
509
|
+
this.sessionHeaderCache.clear();
|
|
442
510
|
this.notificationStore.clearAll("service-dispose");
|
|
443
511
|
await Promise.all(activeSessions.map(async (active) => {
|
|
444
512
|
active.unsubscribe();
|
|
@@ -471,13 +539,71 @@ export class PiSessionService {
|
|
|
471
539
|
.sort(compareArchivedRecords)
|
|
472
540
|
.map((record) => clientSessionFromArchivedRecord(record, sessionsById.get(record.sessionId)))
|
|
473
541
|
.filter(isDefined);
|
|
474
|
-
return [...unarchivedSessions, ...archivedSessions];
|
|
542
|
+
return await this.withRelatedSessionsElsewhere([...unarchivedSessions, ...archivedSessions], cwd);
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Annotate a listing with the session relationships that cross workspace
|
|
546
|
+
* boundaries: where an out-of-listing parent lives, and how many children a
|
|
547
|
+
* listed session has in sibling workspaces.
|
|
548
|
+
*
|
|
549
|
+
* Both directions are best-effort. An unreadable parent header or a sibling
|
|
550
|
+
* workspace that cannot be listed leaves the session unannotated rather than
|
|
551
|
+
* failing the listing, and the browser falls back to its generic states.
|
|
552
|
+
*/
|
|
553
|
+
async withRelatedSessionsElsewhere(sessions, cwd) {
|
|
554
|
+
const [parentLocations, childCounts] = await Promise.all([
|
|
555
|
+
locateOutOfListingParents(sessions, cwd, this.readCachedSessionHeader),
|
|
556
|
+
this.countChildrenInSiblingWorkspaces(sessions, cwd),
|
|
557
|
+
]);
|
|
558
|
+
return sessions.map((session) => {
|
|
559
|
+
const parent = session.parentSessionPath === undefined ? undefined : parentLocations.get(session.parentSessionPath);
|
|
560
|
+
const childrenElsewhere = childCounts.get(session.path);
|
|
561
|
+
if (parent === undefined && childrenElsewhere === undefined)
|
|
562
|
+
return session;
|
|
563
|
+
const annotated = { ...session };
|
|
564
|
+
if (parent !== undefined) {
|
|
565
|
+
annotated.parentSessionId = parent.parentSessionId;
|
|
566
|
+
annotated.parentSessionCwd = parent.parentSessionCwd;
|
|
567
|
+
}
|
|
568
|
+
if (childrenElsewhere !== undefined)
|
|
569
|
+
annotated.childSessionsElsewhere = childrenElsewhere;
|
|
570
|
+
return annotated;
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Count children of the listed sessions that live in other workspaces of the
|
|
575
|
+
* same project.
|
|
576
|
+
*
|
|
577
|
+
* Only sibling workspaces are scanned: agents may only spawn into workspaces
|
|
578
|
+
* of the spawning session's own project, so that bounds where a child can be.
|
|
579
|
+
* Listing is skipped entirely when no project-workspace locator is configured
|
|
580
|
+
* or the cwd belongs to no registered project.
|
|
581
|
+
*/
|
|
582
|
+
async countChildrenInSiblingWorkspaces(sessions, cwd) {
|
|
583
|
+
if (this.projectWorkspaces === undefined || sessions.length === 0)
|
|
584
|
+
return new Map();
|
|
585
|
+
try {
|
|
586
|
+
const siblingCwds = await siblingWorkspaceCwds(this.projectWorkspaces, cwd);
|
|
587
|
+
if (siblingCwds.length === 0)
|
|
588
|
+
return new Map();
|
|
589
|
+
const listings = await Promise.all(siblingCwds.map(async (siblingCwd) => {
|
|
590
|
+
const entries = await this.sessionManager.list(siblingCwd);
|
|
591
|
+
return entries.flatMap((entry) => entry.parentSessionPath === undefined ? [] : [entry.parentSessionPath]);
|
|
592
|
+
}));
|
|
593
|
+
return countOutOfListingChildren(sessions, listings.flat());
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
this.logger.info({ cwd, error: error instanceof Error ? error.message : String(error) }, "failed to count child sessions in sibling workspaces");
|
|
597
|
+
return new Map();
|
|
598
|
+
}
|
|
475
599
|
}
|
|
476
600
|
async start(cwd, options = {}) {
|
|
477
601
|
return this.startSession(cwd, options);
|
|
478
602
|
}
|
|
479
603
|
async startSession(cwd, options) {
|
|
480
604
|
const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
|
|
605
|
+
startupIntent: "create",
|
|
606
|
+
...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
|
|
481
607
|
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
|
482
608
|
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
|
|
483
609
|
});
|
|
@@ -548,6 +674,217 @@ export class PiSessionService {
|
|
|
548
674
|
this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
|
|
549
675
|
return { sessionId: created.id, cwd: decision.cwd };
|
|
550
676
|
}
|
|
677
|
+
/**
|
|
678
|
+
* Register the question set an agent wants the user to answer as the session's
|
|
679
|
+
* open ask. Deliberately does not wait for the user: `ask_user` terminates the
|
|
680
|
+
* run and the submitted answers come back later as a follow-up message.
|
|
681
|
+
*
|
|
682
|
+
* Rejected question sets throw {@link PendingAskValidationError}, which the
|
|
683
|
+
* agent loop reports to the model as an error tool result.
|
|
684
|
+
*/
|
|
685
|
+
// eslint-disable-next-line @typescript-eslint/require-await -- async so a rejected question set becomes a rejection rather than a synchronous throw from a promise-returning method.
|
|
686
|
+
async openAsk(input) {
|
|
687
|
+
const result = this.pendingAskStore.open(input);
|
|
688
|
+
// A supersede closes the earlier ask, so the browsers watching it must hear
|
|
689
|
+
// that before they hear about its replacement.
|
|
690
|
+
if (result.superseded !== undefined)
|
|
691
|
+
this.publishAskClosed(input.sessionId, result.superseded);
|
|
692
|
+
this.events.publish(input.sessionId, { type: "ask.opened", ask: result.ask });
|
|
693
|
+
this.publishStatusForSessionId(input.sessionId);
|
|
694
|
+
return result;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Record the user's answers to the session's open ask and hand them to the
|
|
698
|
+
* model. The answers travel as a system-authored custom message rather than a
|
|
699
|
+
* user message, so they are not attributed to the human in the transcript;
|
|
700
|
+
* they still wake an idle session (`triggerTurn`) and queue behind in-flight
|
|
701
|
+
* work (`deliverAs: "followUp"`), which is how the run that `ask_user`
|
|
702
|
+
* terminated continues.
|
|
703
|
+
*/
|
|
704
|
+
async submitAsk(ref, askId, submission) {
|
|
705
|
+
await this.assertWritable(ref);
|
|
706
|
+
const session = await this.getOrOpen(ref);
|
|
707
|
+
// Checked before the store closes the ask so a refused delivery cannot
|
|
708
|
+
// discard answers the user already submitted.
|
|
709
|
+
this.assertTreeNavigationInactive(session, "answer questions");
|
|
710
|
+
return this.closeAsk(session, this.pendingAskStore.submit(session.sessionId, askId, submission));
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Close the open ask without answers. The model is still told, naming every
|
|
714
|
+
* question as unanswered: it was promised a follow-up message and would
|
|
715
|
+
* otherwise wait for one that never comes.
|
|
716
|
+
*/
|
|
717
|
+
async cancelAsk(ref, askId) {
|
|
718
|
+
await this.assertWritable(ref);
|
|
719
|
+
const session = await this.getOrOpen(ref);
|
|
720
|
+
this.assertTreeNavigationInactive(session, "dismiss questions");
|
|
721
|
+
return this.closeAsk(session, this.pendingAskStore.cancel(session.sessionId, askId));
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Publish and deliver a closed ask. A stale close is reported rather than
|
|
725
|
+
* thrown: losing the race against a supersede, another browser, or a session
|
|
726
|
+
* that went away is ordinary, and the returned status tells the browser what
|
|
727
|
+
* the session's open ask is now.
|
|
728
|
+
*/
|
|
729
|
+
async closeAsk(session, result) {
|
|
730
|
+
if (result.status === "stale")
|
|
731
|
+
return { result: "stale", sessionStatus: this.statusFromSession(session) };
|
|
732
|
+
const { outcome } = result;
|
|
733
|
+
this.publishAskClosed(session.sessionId, outcome);
|
|
734
|
+
await this.runSessionEntryMutation(session, "deliver answers to your questions", () => session.sendCustomMessage({ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, { triggerTurn: true, deliverAs: "followUp" }));
|
|
735
|
+
this.publishStatus(session);
|
|
736
|
+
return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
|
|
737
|
+
}
|
|
738
|
+
publishAskClosed(sessionId, outcome) {
|
|
739
|
+
this.events.publish(sessionId, { type: "ask.closed", askId: outcome.askId, reason: outcome.reason });
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Void the session's open ask because the user sent a chat message instead of
|
|
743
|
+
* answering it. Every browser closes the card as cancelled, and the model is
|
|
744
|
+
* told — without being woken — so the notice rides into the turn the message
|
|
745
|
+
* itself triggers rather than becoming a turn of its own.
|
|
746
|
+
*/
|
|
747
|
+
async voidOpenAskForUserMessage(session) {
|
|
748
|
+
const outcome = this.pendingAskStore.cancelOpen(session.sessionId);
|
|
749
|
+
if (outcome === undefined)
|
|
750
|
+
return;
|
|
751
|
+
this.publishAskClosed(session.sessionId, outcome);
|
|
752
|
+
await this.runSessionEntryMutation(session, "void the open questions", () => session.sendCustomMessage({ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, { triggerTurn: false, deliverAs: "followUp" }));
|
|
753
|
+
this.publishStatus(session);
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Record the user's answer to an open extension dialog and resolve the
|
|
757
|
+
* extension's parked Promise with it. Unlike an ask, nothing is delivered to
|
|
758
|
+
* the model: the waiter is extension code inside an already in-flight run
|
|
759
|
+
* (or an idle handler), so no custom message and no turn are triggered.
|
|
760
|
+
*/
|
|
761
|
+
async answerDialog(ref, dialogId, value) {
|
|
762
|
+
await this.assertWritable(ref);
|
|
763
|
+
const session = await this.sessionForStatusOrDialogClose(ref);
|
|
764
|
+
const result = this.pendingExtensionDialogStore.answer(session.sessionId, dialogId, value);
|
|
765
|
+
if (result.status === "stale")
|
|
766
|
+
return { result: "stale", sessionStatus: this.statusFromSession(session) };
|
|
767
|
+
const { outcome } = result;
|
|
768
|
+
this.publishDialogClosed(session.sessionId, outcome);
|
|
769
|
+
// `value` is what the store validated and recorded as the outcome's answer.
|
|
770
|
+
this.dialogWaiters.settleWithAnswer(dialogId, value);
|
|
771
|
+
this.publishStatus(session);
|
|
772
|
+
return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
|
|
773
|
+
}
|
|
774
|
+
/** Close an open extension dialog without an answer; the extension's wait settles with its kind's cancel value. */
|
|
775
|
+
async cancelDialog(ref, dialogId) {
|
|
776
|
+
await this.assertWritable(ref);
|
|
777
|
+
const session = await this.sessionForStatusOrDialogClose(ref);
|
|
778
|
+
const result = this.pendingExtensionDialogStore.cancel(session.sessionId, dialogId, "cancelled");
|
|
779
|
+
if (result.status === "stale")
|
|
780
|
+
return { result: "stale", sessionStatus: this.statusFromSession(session) };
|
|
781
|
+
const { outcome } = result;
|
|
782
|
+
this.publishDialogClosed(session.sessionId, outcome);
|
|
783
|
+
this.dialogWaiters.settleWithCancelValue(dialogId);
|
|
784
|
+
this.publishStatus(session);
|
|
785
|
+
return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Implement one `ctx.ui.select()`/`confirm()`/`input()` call from extension
|
|
789
|
+
* code: open the store record, tell the browsers, and park a Promise that
|
|
790
|
+
* settles when the browser answers or cancels, the extension's own
|
|
791
|
+
* `signal`/`timeout` dismisses the dialog, the daemon default timeout
|
|
792
|
+
* elapses, or the runtime goes away. `store.open` validates the dialog, so a
|
|
793
|
+
* malformed one rejects the extension's call rather than rendering garbage.
|
|
794
|
+
* `async` so a rejected dialog becomes a rejection rather than a synchronous
|
|
795
|
+
* throw from a promise-returning method.
|
|
796
|
+
*/
|
|
797
|
+
async openExtensionDialog(session, request, opts) {
|
|
798
|
+
const signal = opts?.signal;
|
|
799
|
+
// A pre-aborted signal dismisses the dialog before it ever opens.
|
|
800
|
+
if (signal?.aborted === true)
|
|
801
|
+
return extensionDialogCancelValue(request.kind);
|
|
802
|
+
const timeoutMs = effectiveExtensionDialogTimeoutMs(opts?.timeout, this.extensionDialogsTimeoutMs);
|
|
803
|
+
const dialog = this.pendingExtensionDialogStore.open({
|
|
804
|
+
sessionId: session.sessionId,
|
|
805
|
+
kind: request.kind,
|
|
806
|
+
title: request.title,
|
|
807
|
+
...(request.message === undefined ? {} : { message: request.message }),
|
|
808
|
+
...(request.options === undefined ? {} : { options: request.options }),
|
|
809
|
+
...(request.placeholder === undefined ? {} : { placeholder: request.placeholder }),
|
|
810
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
811
|
+
runScoped: session.isStreaming,
|
|
812
|
+
});
|
|
813
|
+
this.events.publish(session.sessionId, { type: "dialog.opened", dialog });
|
|
814
|
+
this.publishStatus(session);
|
|
815
|
+
return this.dialogWaiters.park(dialog, {
|
|
816
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
817
|
+
...(signal === undefined ? {} : { signal }),
|
|
818
|
+
onTrigger: (reason) => {
|
|
819
|
+
if (this.closeExtensionDialogFromTrigger(session.sessionId, dialog.dialogId, reason))
|
|
820
|
+
this.publishStatusForSessionId(session.sessionId);
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Close a dialog whose wait ended without the browser (timeout, signal
|
|
826
|
+
* abort, run end, runtime teardown) and settle its parked Promise. Returns
|
|
827
|
+
* whether this call closed the dialog; a stale close means a browser answer
|
|
828
|
+
* or an earlier trigger already settled everything.
|
|
829
|
+
*/
|
|
830
|
+
closeExtensionDialogFromTrigger(sessionId, dialogId, reason) {
|
|
831
|
+
const result = this.pendingExtensionDialogStore.cancel(sessionId, dialogId, reason);
|
|
832
|
+
if (result.status !== "closed")
|
|
833
|
+
return false;
|
|
834
|
+
this.publishDialogClosed(sessionId, result.outcome);
|
|
835
|
+
this.dialogWaiters.settleWithCancelValue(dialogId);
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Settle the session's run-scoped dialogs as `"aborted"`. Runs at
|
|
840
|
+
* abort-request time (a user abort parks the agent loop behind the dialog
|
|
841
|
+
* handler, so `agent_end` would never arrive on its own) and again from
|
|
842
|
+
* the `agent_end` observer as the run-crash backstop — the store makes the
|
|
843
|
+
* second settlement a stale no-op. Idle-opened dialogs (a `session_start`
|
|
844
|
+
* probe, say) are not run-scoped and survive, because their waiter
|
|
845
|
+
* outlives the run.
|
|
846
|
+
*/
|
|
847
|
+
abortRunScopedExtensionDialogs(sessionId) {
|
|
848
|
+
let closedAny = false;
|
|
849
|
+
for (const dialog of this.pendingExtensionDialogStore.pendingDialogs(sessionId)) {
|
|
850
|
+
if (dialog.runScoped)
|
|
851
|
+
closedAny = this.closeExtensionDialogFromTrigger(sessionId, dialog.dialogId, "aborted") || closedAny;
|
|
852
|
+
}
|
|
853
|
+
if (closedAny)
|
|
854
|
+
this.publishStatusForSessionId(sessionId);
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Settle every dialog of the session as `"session-ended"`: the runtime
|
|
858
|
+
* whose extension code is parked on them is being closed, replaced, or
|
|
859
|
+
* disposed, so those Promises would otherwise never settle.
|
|
860
|
+
*/
|
|
861
|
+
endSessionExtensionDialogs(sessionId) {
|
|
862
|
+
let closedAny = false;
|
|
863
|
+
for (const dialog of this.pendingExtensionDialogStore.pendingDialogs(sessionId)) {
|
|
864
|
+
closedAny = this.closeExtensionDialogFromTrigger(sessionId, dialog.dialogId, "session-ended") || closedAny;
|
|
865
|
+
}
|
|
866
|
+
// Publishes only while the session is still (or already re-)registered as
|
|
867
|
+
// active, so teardown paths stay silent and runtime replacement refreshes.
|
|
868
|
+
if (closedAny)
|
|
869
|
+
this.publishStatusForSessionId(sessionId);
|
|
870
|
+
}
|
|
871
|
+
publishDialogClosed(sessionId, outcome) {
|
|
872
|
+
this.events.publish(sessionId, {
|
|
873
|
+
type: "dialog.closed",
|
|
874
|
+
dialogId: outcome.dialogId,
|
|
875
|
+
reason: outcome.reason,
|
|
876
|
+
...(outcome.answer === undefined ? {} : { answer: outcome.answer }),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Publish status for a session known only by id, as the ask tools are: they
|
|
881
|
+
* run inside the session's own runtime, so the active entry is the session.
|
|
882
|
+
*/
|
|
883
|
+
publishStatusForSessionId(sessionId) {
|
|
884
|
+
const session = this.active.get(sessionId)?.runtime.session;
|
|
885
|
+
if (session !== undefined)
|
|
886
|
+
this.publishStatus(session);
|
|
887
|
+
}
|
|
551
888
|
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */
|
|
552
889
|
async listSubsessions(parentSessionId, parentSessionFile) {
|
|
553
890
|
const parentFile = nonEmptyString(parentSessionFile);
|
|
@@ -885,7 +1222,7 @@ export class PiSessionService {
|
|
|
885
1222
|
return pageMessagesAtSafeBoundary(historyMessages(session), page);
|
|
886
1223
|
}
|
|
887
1224
|
async status(ref) {
|
|
888
|
-
return this.statusFromSession(await this.
|
|
1225
|
+
return this.statusFromSession(await this.sessionForStatusOrDialogClose(ref));
|
|
889
1226
|
}
|
|
890
1227
|
/**
|
|
891
1228
|
* Join-time snapshot of the in-flight assistant stream. The `seq` watermark and
|
|
@@ -1007,6 +1344,11 @@ export class PiSessionService {
|
|
|
1007
1344
|
this.publishStatus(session);
|
|
1008
1345
|
return;
|
|
1009
1346
|
}
|
|
1347
|
+
// A chat message answers the session's open ask in the user's own words, so
|
|
1348
|
+
// the form is void: keeping it open would invite answers to questions the
|
|
1349
|
+
// conversation has already moved past. Ignored duplicates skip this on
|
|
1350
|
+
// purpose: they must not void an ask posted after the queued original.
|
|
1351
|
+
await this.voidOpenAskForUserMessage(session);
|
|
1010
1352
|
if (session.isCompacting) {
|
|
1011
1353
|
this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage);
|
|
1012
1354
|
return;
|
|
@@ -1429,6 +1771,12 @@ export class PiSessionService {
|
|
|
1429
1771
|
const sessionId = active.runtime.session.sessionId;
|
|
1430
1772
|
this.clearCompactionPromptQueue(sessionId);
|
|
1431
1773
|
clearSessionQueue(active.runtime.session);
|
|
1774
|
+
// Settle run-scoped dialogs now, at abort-request time: pi's agent loop
|
|
1775
|
+
// waits for a parked `tool_call` dialog handler before it can emit
|
|
1776
|
+
// `agent_end`, so leaving settlement to the `agent_end` observer would
|
|
1777
|
+
// strand the dialog until its timeout. Settling before the runtime abort
|
|
1778
|
+
// also means a failing or hung abort cannot strand the parked waiter.
|
|
1779
|
+
this.abortRunScopedExtensionDialogs(sessionId);
|
|
1432
1780
|
try {
|
|
1433
1781
|
await this.abortSessionOperations(active.runtime.session);
|
|
1434
1782
|
this.publishActivity(active.runtime.session, "stopped", "idle");
|
|
@@ -1642,6 +1990,11 @@ export class PiSessionService {
|
|
|
1642
1990
|
return [...names];
|
|
1643
1991
|
}
|
|
1644
1992
|
async closeActive(sessionId, notificationPolicy = CLEAR_RUNTIME_NOTIFICATIONS) {
|
|
1993
|
+
// A session whose open is parked on a `session_start` dialog holds its
|
|
1994
|
+
// pending open until the dialog settles; settle it first so closing cannot
|
|
1995
|
+
// block behind the dialog timeout (which `0` makes infinite).
|
|
1996
|
+
if (this.startupSessions.has(sessionId))
|
|
1997
|
+
this.endSessionExtensionDialogs(sessionId);
|
|
1645
1998
|
const pendingOpens = this.pendingSessionOpenPromises(sessionId);
|
|
1646
1999
|
if (pendingOpens.length > 0)
|
|
1647
2000
|
await Promise.allSettled(pendingOpens);
|
|
@@ -1656,6 +2009,12 @@ export class PiSessionService {
|
|
|
1656
2009
|
if (!active)
|
|
1657
2010
|
return;
|
|
1658
2011
|
this.forgetUnreadActivity(active.runtime.session);
|
|
2012
|
+
// An open ask is meaningful only while the runtime that posted it exists: no
|
|
2013
|
+
// one is left to receive the answers, so it is dropped without an outcome.
|
|
2014
|
+
this.pendingAskStore.forgetSession(sessionId);
|
|
2015
|
+
// Open dialogs share that stance, but their extension waiters are parked
|
|
2016
|
+
// Promises inside the dying runtime: settle them rather than dropping them.
|
|
2017
|
+
this.endSessionExtensionDialogs(sessionId);
|
|
1659
2018
|
this.active.delete(sessionId);
|
|
1660
2019
|
this.activities.delete(sessionId);
|
|
1661
2020
|
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
|
|
@@ -1767,7 +2126,47 @@ export class PiSessionService {
|
|
|
1767
2126
|
}
|
|
1768
2127
|
return undefined;
|
|
1769
2128
|
}
|
|
2129
|
+
startupSessionForLookup(ref) {
|
|
2130
|
+
const sessionId = sessionIdFromLookup(ref);
|
|
2131
|
+
const exact = this.startupSessions.get(sessionId);
|
|
2132
|
+
if (exact !== undefined && lookupMatchesStartupSession(ref, exact))
|
|
2133
|
+
return exact;
|
|
2134
|
+
for (const [candidateId, session] of this.startupSessions.entries()) {
|
|
2135
|
+
if (candidateId.startsWith(sessionId) && lookupMatchesStartupSession(ref, session))
|
|
2136
|
+
return session;
|
|
2137
|
+
}
|
|
2138
|
+
return undefined;
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* The session to serve a read-only status or a dialog close for, while it
|
|
2142
|
+
* can still be found: active first, then still starting up, and only then
|
|
2143
|
+
* the on-demand open path (which a stale close on an idle session needs for
|
|
2144
|
+
* its status projection).
|
|
2145
|
+
*/
|
|
2146
|
+
async sessionForStatusOrDialogClose(ref) {
|
|
2147
|
+
const reachable = this.activeForLookup(ref)?.runtime.session ?? this.startupSessionForLookup(ref);
|
|
2148
|
+
if (reachable !== undefined)
|
|
2149
|
+
return reachable;
|
|
2150
|
+
return this.getOrOpen(ref);
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Construct a session while telling waiting browsers which phase of startup
|
|
2154
|
+
* they are waiting on. The reporting wraps the *whole* construction rather
|
|
2155
|
+
* than the inner bookkeeping `try`, because the runtime construction that runs
|
|
2156
|
+
* first is both the slowest phase and one that can fail on its own; a clear
|
|
2157
|
+
* that only ran for the later phases would leave a stale label behind.
|
|
2158
|
+
*/
|
|
1770
2159
|
async create(sessionManager, cwd, options = {}) {
|
|
2160
|
+
const startup = this.startupProgress(sessionManager, options.startupIntent ?? "open", options.startupToken);
|
|
2161
|
+
try {
|
|
2162
|
+
return await this.createSessionRuntime(sessionManager, cwd, options, startup);
|
|
2163
|
+
}
|
|
2164
|
+
finally {
|
|
2165
|
+
startup.end();
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
async createSessionRuntime(sessionManager, cwd, options, startup) {
|
|
2169
|
+
startup.report(STARTUP_PHASE_RUNTIME);
|
|
1771
2170
|
const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
|
|
1772
2171
|
&& await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
|
|
1773
2172
|
const runtime = await this.createAgentRuntime(this.createRuntime, {
|
|
@@ -1807,6 +2206,7 @@ export class PiSessionService {
|
|
|
1807
2206
|
else {
|
|
1808
2207
|
await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
|
|
1809
2208
|
}
|
|
2209
|
+
startup.report(STARTUP_PHASE_EXTENSIONS);
|
|
1810
2210
|
await this.bindSessionExtensions(runtime.session, notificationGeneration);
|
|
1811
2211
|
this.bindRuntime(active);
|
|
1812
2212
|
runtime.setRebindSession(async (session) => {
|
|
@@ -1820,6 +2220,10 @@ export class PiSessionService {
|
|
|
1820
2220
|
this.notificationGenerationBySession.set(session, candidateGeneration);
|
|
1821
2221
|
}
|
|
1822
2222
|
this.bindRuntime(active, session);
|
|
2223
|
+
// The runtime being replaced parked every dialog the store still
|
|
2224
|
+
// holds for this session; settle those waits before the new
|
|
2225
|
+
// runtime's extensions can open fresh dialogs under the same id.
|
|
2226
|
+
this.endSessionExtensionDialogs(boundSession.sessionId);
|
|
1823
2227
|
boundSession = session;
|
|
1824
2228
|
await this.bindSessionExtensions(session, candidateGeneration);
|
|
1825
2229
|
if (candidateGeneration !== undefined) {
|
|
@@ -1855,6 +2259,9 @@ export class PiSessionService {
|
|
|
1855
2259
|
}
|
|
1856
2260
|
active.unsubscribe();
|
|
1857
2261
|
this.forgetUnreadActivity(boundSession);
|
|
2262
|
+
// A session_start dialog may already be parked when a later startup
|
|
2263
|
+
// step fails; its waiter dies with the runtime being torn down here.
|
|
2264
|
+
this.endSessionExtensionDialogs(boundSession.sessionId);
|
|
1858
2265
|
let removedActive = false;
|
|
1859
2266
|
for (const [sessionId, candidate] of this.active.entries()) {
|
|
1860
2267
|
if (candidate !== active)
|
|
@@ -1879,15 +2286,25 @@ export class PiSessionService {
|
|
|
1879
2286
|
}
|
|
1880
2287
|
async bindSessionExtensions(session, generation) {
|
|
1881
2288
|
const uiContext = this.sessionUiContext(session, generation);
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
2289
|
+
// A `session_start` hook can park this bind on a dialog the browser has
|
|
2290
|
+
// not answered yet. On the initial create/open path the session becomes
|
|
2291
|
+
// active only after this returns, so register it for the duration: the
|
|
2292
|
+
// answer that unblocks startup has to be reachable while it waits.
|
|
2293
|
+
this.startupSessions.set(session.sessionId, session);
|
|
2294
|
+
try {
|
|
2295
|
+
await session.bindExtensions({
|
|
2296
|
+
uiContext,
|
|
2297
|
+
mode: "rpc",
|
|
2298
|
+
onError: (error) => {
|
|
2299
|
+
const message = `${error.extensionPath}: ${error.error}`;
|
|
2300
|
+
this.publishActivity(session, "extension error", "error", message);
|
|
2301
|
+
this.events.publish(session.sessionId, { type: "session.error", message });
|
|
2302
|
+
},
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
finally {
|
|
2306
|
+
this.startupSessions.delete(session.sessionId);
|
|
2307
|
+
}
|
|
1891
2308
|
}
|
|
1892
2309
|
replaceSessionNotificationContext(session, generation) {
|
|
1893
2310
|
session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
|
|
@@ -1914,15 +2331,26 @@ export class PiSessionService {
|
|
|
1914
2331
|
notificationId: added.notification.id,
|
|
1915
2332
|
});
|
|
1916
2333
|
};
|
|
1917
|
-
// PI WEB owns the browser-facing notification and text-formatting
|
|
1918
|
-
// boundaries
|
|
1919
|
-
//
|
|
2334
|
+
// PI WEB owns the browser-facing dialog, notification, and text-formatting
|
|
2335
|
+
// boundaries: the three dialog primitives park daemon-held Promises that
|
|
2336
|
+
// the browser answers, while every other UI method delegates to Pi's
|
|
2337
|
+
// headless defaults so unsupported surfaces cancel safely instead of
|
|
2338
|
+
// hanging.
|
|
1920
2339
|
return new Proxy(baseUiContext, {
|
|
1921
|
-
get(target, property, receiver) {
|
|
2340
|
+
get: (target, property, receiver) => {
|
|
1922
2341
|
if (property === "notify")
|
|
1923
2342
|
return notify;
|
|
1924
2343
|
if (property === "theme")
|
|
1925
2344
|
return plainTextTheme;
|
|
2345
|
+
if (property === "confirm") {
|
|
2346
|
+
return (title, message, opts) => this.openExtensionDialog(session, { kind: "confirm", title, message }, opts);
|
|
2347
|
+
}
|
|
2348
|
+
if (property === "select") {
|
|
2349
|
+
return (title, options, opts) => this.openExtensionDialog(session, { kind: "select", title, options }, opts);
|
|
2350
|
+
}
|
|
2351
|
+
if (property === "input") {
|
|
2352
|
+
return (title, placeholder, opts) => this.openExtensionDialog(session, { kind: "input", title, placeholder }, opts);
|
|
2353
|
+
}
|
|
1926
2354
|
const value = Reflect.get(target, property, receiver);
|
|
1927
2355
|
return value;
|
|
1928
2356
|
},
|
|
@@ -2051,6 +2479,8 @@ export class PiSessionService {
|
|
|
2051
2479
|
this.events.publish(session.sessionId, toClientEvent(event));
|
|
2052
2480
|
this.publishActivityForEvent(session, event);
|
|
2053
2481
|
const eventType = getString(event, "type");
|
|
2482
|
+
if (eventType === "agent_end")
|
|
2483
|
+
this.abortRunScopedExtensionDialogs(session.sessionId);
|
|
2054
2484
|
if (eventType === "compaction_end")
|
|
2055
2485
|
this.scheduleCompactionQueueDrain(session.sessionId);
|
|
2056
2486
|
if (eventType === "agent_start" || eventType === "agent_end")
|
|
@@ -2387,6 +2817,54 @@ export class PiSessionService {
|
|
|
2387
2817
|
if (this.hasActiveWork(session))
|
|
2388
2818
|
this.publishActivity(session, eventType.replaceAll("_", " "), "active");
|
|
2389
2819
|
}
|
|
2820
|
+
/**
|
|
2821
|
+
* Build the reporter for one session construction.
|
|
2822
|
+
*
|
|
2823
|
+
* The session id is known before any await — a `SessionManager` has its id
|
|
2824
|
+
* from construction — so the daemon can name what it is starting even though
|
|
2825
|
+
* the `PiAgentSession` that {@link publishActivity} needs does not exist yet.
|
|
2826
|
+
* Without an id there is nothing to report against, so the reporter stays
|
|
2827
|
+
* silent and the browser keeps its own generic wording.
|
|
2828
|
+
*/
|
|
2829
|
+
startupProgress(sessionManager, intent, startupToken) {
|
|
2830
|
+
const sessionId = sessionManager.getSessionId();
|
|
2831
|
+
if (sessionId === "")
|
|
2832
|
+
return { report: noop, end: noop };
|
|
2833
|
+
const label = intent === "create" ? "Creating session" : "Opening session";
|
|
2834
|
+
return {
|
|
2835
|
+
report: (phase) => { this.publishStartupProgress(sessionId, startupToken, label, "active", this.startupDetail(phase)); },
|
|
2836
|
+
end: () => {
|
|
2837
|
+
// A real activity published during the window (an extension error, say)
|
|
2838
|
+
// is the truth about this session and must survive the clear.
|
|
2839
|
+
if (this.activities.has(sessionId))
|
|
2840
|
+
return;
|
|
2841
|
+
this.publishStartupProgress(sessionId, startupToken, "idle", "idle", undefined);
|
|
2842
|
+
},
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
startupDetail(phase) {
|
|
2846
|
+
return this.catalogRefreshStatus?.isRefreshInFlight() === true
|
|
2847
|
+
? `${phase} · ${STARTUP_CONCURRENT_CATALOG_REFRESH}`
|
|
2848
|
+
: phase;
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* Report startup progress on the global channel only, echoing the caller's
|
|
2852
|
+
* correlation token so a waiting browser row recognises its own construction.
|
|
2853
|
+
*
|
|
2854
|
+
* Unlike {@link publishActivity} this deliberately records nothing: no
|
|
2855
|
+
* `activities` entry, no workspace activity, no unread observation. There is
|
|
2856
|
+
* no session to own that state, and a failed creation would leave it stranded.
|
|
2857
|
+
*
|
|
2858
|
+
* Every report is marked `startup`, which is what keeps a session that is
|
|
2859
|
+
* merely opening from counting as one doing work. This is the only publisher
|
|
2860
|
+
* that sets the marker, and because it writes no `activities` entry no later
|
|
2861
|
+
* heartbeat re-publication can carry it.
|
|
2862
|
+
*/
|
|
2863
|
+
publishStartupProgress(sessionId, startupToken, label, phase, detail) {
|
|
2864
|
+
const at = new Date().toISOString();
|
|
2865
|
+
const activity = detail === undefined ? { sessionId, phase, label, at, startup: true } : { sessionId, phase, label, detail, at, startup: true };
|
|
2866
|
+
this.events.publishGlobal(startupToken === undefined ? { type: "session.startup", activity } : { type: "session.startup", startupToken, activity });
|
|
2867
|
+
}
|
|
2390
2868
|
publishActivity(session, label, phase, detail) {
|
|
2391
2869
|
const at = new Date().toISOString();
|
|
2392
2870
|
const stored = detail === undefined ? { phase, label, at } : { phase, label, detail, at };
|
|
@@ -2421,6 +2899,8 @@ export class PiSessionService {
|
|
|
2421
2899
|
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
|
|
2422
2900
|
const contextUsage = session.getContextUsage();
|
|
2423
2901
|
const warnings = this.warningsForSession(session);
|
|
2902
|
+
const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
|
|
2903
|
+
const pendingDialogs = this.pendingExtensionDialogStore.pendingDialogs(session.sessionId);
|
|
2424
2904
|
return {
|
|
2425
2905
|
sessionId: session.sessionId,
|
|
2426
2906
|
persisted: sessionFileExists(session.sessionFile),
|
|
@@ -2436,6 +2916,8 @@ export class PiSessionService {
|
|
|
2436
2916
|
cost: stats.cost,
|
|
2437
2917
|
...(contextUsage === undefined ? {} : { contextUsage }),
|
|
2438
2918
|
...(warnings.length === 0 ? {} : { warnings }),
|
|
2919
|
+
...(pendingAsk === undefined ? {} : { pendingAsk }),
|
|
2920
|
+
...(pendingDialogs.length === 0 ? {} : { pendingDialogs }),
|
|
2439
2921
|
};
|
|
2440
2922
|
}
|
|
2441
2923
|
/**
|
|
@@ -2811,28 +3293,6 @@ function activeSessionFileMatches(active, expectedSessionFile) {
|
|
|
2811
3293
|
function trackedLinkParentFileMatches(link, parentSessionFile) {
|
|
2812
3294
|
return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
|
|
2813
3295
|
}
|
|
2814
|
-
async function readSessionHeaderSummary(sessionFile) {
|
|
2815
|
-
let file;
|
|
2816
|
-
try {
|
|
2817
|
-
file = await open(sessionFile, "r");
|
|
2818
|
-
const buffer = Buffer.alloc(4096);
|
|
2819
|
-
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
2820
|
-
const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
|
|
2821
|
-
if (firstLine === undefined || firstLine === "")
|
|
2822
|
-
return undefined;
|
|
2823
|
-
const header = JSON.parse(firstLine);
|
|
2824
|
-
if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string")
|
|
2825
|
-
return undefined;
|
|
2826
|
-
const parentSession = getString(header, "parentSession");
|
|
2827
|
-
return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) };
|
|
2828
|
-
}
|
|
2829
|
-
catch {
|
|
2830
|
-
return undefined;
|
|
2831
|
-
}
|
|
2832
|
-
finally {
|
|
2833
|
-
await file?.close().catch(() => undefined);
|
|
2834
|
-
}
|
|
2835
|
-
}
|
|
2836
3296
|
async function sessionFileHeaderMatches(sessionFile, expected) {
|
|
2837
3297
|
const header = await readSessionHeaderSummary(sessionFile);
|
|
2838
3298
|
if (header?.id !== expected.sessionId)
|