feat(sessions): add daemon notification inbox protocol

This commit is contained in:
Federico Jaramillo Martinez
2026-07-19 01:28:45 +02:00
parent d77ca66f28
commit 6e09df8329
21 changed files with 2290 additions and 59 deletions
+275 -49
View File
@@ -32,7 +32,19 @@ import { deterministicSessionName, fallbackSessionName, generateShortSessionName
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionWarning } from "../../shared/apiTypes.js";
import type {
SavedPromptAttachment,
SessionBulkArchiveResponse,
SessionBulkDeleteArchivedResponse,
SessionBulkFailure,
SessionBulkMutationRef,
SessionNotificationCatalogSnapshot,
SessionNotificationClearReason,
SessionNotificationDismissAllRequest,
SessionNotificationDismissRequest,
SessionNotificationInboxSnapshot,
SessionWarning,
} from "../../shared/apiTypes.js";
import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from "./sessionService.js";
import { type AuthChange } from "./authService.js";
@@ -43,6 +55,11 @@ import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type S
import { buildTranscriptView } from "./subsessionTranscript.js";
import { planSessionCleanup, summarizeSessionCleanupExecution, type NormalizedSessionCleanupRequest, type SessionCleanupPlan } from "./sessionCleanup.js";
import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js";
import {
SessionNotificationStore,
type SessionNotificationGeneration,
type SessionNotificationMutation,
} from "./sessionNotificationStore.js";
/**
* Minimal structured-logging seam, shaped like Fastify's logger so sessiond can
@@ -245,6 +262,7 @@ export interface PiAgentSession {
extensionRunner: {
getRegisteredCommands(): readonly { invocationName: string; description?: string }[];
getUIContext(): ExtensionUIContext;
setUIContext(uiContext?: ExtensionUIContext, mode?: "rpc"): void;
};
promptTemplates: readonly { name: string; description?: string }[];
resourceLoader: { getSkills(): { skills: readonly { name: string; description?: string }[] } };
@@ -253,7 +271,7 @@ export interface PiAgentSession {
compact(instructions?: string): Promise<{ summary: string; tokensBefore: number }>;
getUserMessagesForForking(): readonly { entryId: string; text: string }[];
getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number };
reload(): Promise<void>;
reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void>;
getContextUsage(): ClientSessionStatus["contextUsage"] | undefined;
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>;
sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>;
@@ -301,6 +319,18 @@ interface PendingSessionOpen {
promise: Promise<ActiveSession<PiSessionRuntime>>;
}
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance"> {
notificationGeneration?: SessionNotificationGeneration;
notifications?: "enabled" | "disabled";
}
type NotificationClosePolicy =
| { kind: "clear"; reason: SessionNotificationClearReason }
| { kind: "defer" };
const CLEAR_RUNTIME_NOTIFICATIONS: NotificationClosePolicy = { kind: "clear", reason: "runtime-close" };
const DEFER_RUNTIME_NOTIFICATIONS: NotificationClosePolicy = { kind: "defer" };
function resourceDiagnosticToWarning(diagnostic: ResourceDiagnostic, source: string): SessionWarning {
return {
severity: diagnostic.type === "error" ? "error" : "warning",
@@ -565,6 +595,8 @@ export interface PiSessionServiceDependencies {
logger?: PiSessionLogger;
/** Clock seam for cleanup planning tests. */
now?: () => Date;
/** Daemon-lifetime notification state, injected by sessiond in production. */
notificationStore?: SessionNotificationStore;
}
export class PiSessionService implements SessionRouteService {
@@ -600,6 +632,8 @@ export class PiSessionService implements SessionRouteService {
private readonly spawnTargets: SpawnTargetResolver | undefined;
private readonly logger: PiSessionLogger;
private readonly now: () => Date;
private readonly notificationStore: SessionNotificationStore;
private readonly notificationGenerationBySession = new WeakMap<PiAgentSession, SessionNotificationGeneration>();
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies) {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
@@ -609,6 +643,7 @@ export class PiSessionService implements SessionRouteService {
this.spawnTargets = deps.spawnTargets;
this.logger = deps.logger ?? noopLogger;
this.now = deps.now ?? (() => new Date());
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
// Subsessions are a beta capability gated behind their own flag, and they
// also require the spawn capability (they share its project-scope resolver).
const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
@@ -649,6 +684,43 @@ export class PiSessionService implements SessionRouteService {
return this.active.size;
}
notificationCatalog(): SessionNotificationCatalogSnapshot {
return this.notificationStore.catalogSnapshot();
}
notificationInbox(ref: PiSessionRef): SessionNotificationInboxSnapshot {
return this.notificationStore.inboxSnapshot(ref.id, ref.cwd);
}
dismissNotification(
ref: PiSessionRef,
request: Omit<SessionNotificationDismissRequest, "cwd">,
): SessionNotificationInboxSnapshot {
const result = this.notificationStore.dismissNotification(
ref.id,
ref.cwd,
request.daemonInstanceId,
request.notificationId,
);
this.publishNotificationMutations(result.mutations);
return result.snapshot;
}
dismissAllNotifications(
ref: PiSessionRef,
request: Omit<SessionNotificationDismissAllRequest, "cwd">,
): SessionNotificationInboxSnapshot {
const result = this.notificationStore.dismissAll(
ref.id,
ref.cwd,
request.daemonInstanceId,
request.throughOrder,
request.throughOverflowWatermark,
);
this.publishNotificationMutations(result.mutations);
return result.snapshot;
}
async cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<ClientSessionCleanupPreviewResponse> {
return previewResponseFromPlan(await this.cleanupPlan(request));
}
@@ -668,7 +740,7 @@ export class PiSessionService implements SessionRouteService {
skippedBusySessionIds.add(input.sessionId);
continue;
}
await this.closeActive(input.sessionId);
await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
readyArchiveInputs.push(input);
}
await this.archiveStoreArchiveMany(readyArchiveInputs);
@@ -679,7 +751,7 @@ export class PiSessionService implements SessionRouteService {
skippedBusySessionIds.add(record.sessionId);
continue;
}
await this.closeActive(record.sessionId);
await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
readyDeleteRecords.push(record);
}
await this.ensureArchivedRecordsMoved(readyDeleteRecords);
@@ -711,8 +783,10 @@ export class PiSessionService implements SessionRouteService {
this.subsessionLinks.clear();
this.subsessionHydratedParents.clear();
this.subsessionNotifyArmed.clear();
this.notificationStore.clearAll("service-dispose");
await Promise.all(activeSessions.map(async (active) => {
active.unsubscribe();
active.runtime.setRebindSession(undefined);
this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
try {
await active.runtime.session.abort();
@@ -731,6 +805,9 @@ export class PiSessionService implements SessionRouteService {
.map((record) => this.ensureArchivedSessionMoved(record, sessionsById.get(record.sessionId))),
);
const archivedById = new Map(archivedForCwd.map((record) => [record.sessionId, record]));
for (const record of archivedForCwd) {
this.publishNotificationMutations(this.notificationStore.clearSession(record.sessionId, "archive-reconcile"));
}
const unarchivedSessions = sessions.filter((session) => !archivedById.has(session.id)).map(clientSessionFromListEntry);
this.workspaceActivity?.reconcileSessionActivity(cwd, this.reconcilableSessionIds(cwd, unarchivedSessions.map((session) => session.id), archivedById));
const archivedSessions = archivedForCwd
@@ -1347,11 +1424,29 @@ export class PiSessionService implements SessionRouteService {
private async reloadSessionRuntime(session: PiAgentSession): Promise<void> {
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
this.publishActivity(session, "reloading resources", "active");
const priorGeneration = this.notificationGenerationBySession.get(session);
let candidateGeneration: SessionNotificationGeneration | undefined;
try {
await session.reload();
await session.reload(priorGeneration === undefined ? undefined : {
beforeSessionStart: () => {
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, {
sessionId: session.sessionId,
cwd: session.sessionManager.getCwd(),
});
this.notificationGenerationBySession.set(session, candidateGeneration);
this.replaceSessionNotificationContext(session, candidateGeneration);
},
});
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
}
this.publishActivity(session, "resources reloaded", "idle");
this.publishStatus(session);
} catch (error: unknown) {
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
this.notificationGenerationBySession.set(session, candidateGeneration);
}
const message = error instanceof Error ? error.message : String(error);
this.publishActivity(session, "reload failed", "error", message);
this.events.publish(session.sessionId, { type: "session.error", message });
@@ -1364,7 +1459,7 @@ export class PiSessionService implements SessionRouteService {
const session = await this.getOrOpen(ref);
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving");
const archiveInput = await this.archiveInputForSession(session);
await this.closeActive(session.sessionId);
await this.closeActive(session.sessionId, { kind: "clear", reason: "archive" });
await this.archiveStore.archive(archiveInput);
}
@@ -1381,6 +1476,7 @@ export class PiSessionService implements SessionRouteService {
for (const ref of uniqueRefs) {
const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
if (archived !== undefined) {
this.publishNotificationMutations(this.notificationStore.clearSession(archived.sessionId, "archive"));
alreadyArchivedSessionIds.push(archived.sessionId);
continue;
}
@@ -1409,7 +1505,7 @@ export class PiSessionService implements SessionRouteService {
const readyInputs: ArchiveSessionInput[] = [];
for (const item of planItems) {
try {
await this.closeActive(item.input.sessionId);
await this.closeActive(item.input.sessionId, { kind: "clear", reason: "archive" });
readyInputs.push(item.input);
} catch (error: unknown) {
failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
@@ -1440,8 +1536,11 @@ export class PiSessionService implements SessionRouteService {
const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
if (busy !== undefined) throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
for (const target of plan.targets) {
if (target.archived) this.publishNotificationMutations(this.notificationStore.clearSession(target.id, "archive"));
}
const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
for (const input of archiveInputs) await this.closeActive(input.sessionId);
for (const input of archiveInputs) await this.closeActive(input.sessionId, { kind: "clear", reason: "archive" });
await this.archiveStoreArchiveMany(archiveInputs);
return {
@@ -1455,7 +1554,7 @@ export class PiSessionService implements SessionRouteService {
async restore(ref: PiSessionLookup): Promise<void> {
const archived = await this.getArchived(ref);
if (archived === undefined) throw new Error("Session not found");
await this.closeActive(archived.sessionId);
await this.closeActive(archived.sessionId, { kind: "clear", reason: "restore" });
await this.archiveStore.restore(archived.sessionId);
}
@@ -1464,7 +1563,7 @@ export class PiSessionService implements SessionRouteService {
if (record === undefined) throw new Error("Archived session not found");
if (this.archiveStore.deleteArchived === undefined) throw new Error("Archive store does not support deletion");
await this.closeActive(record.sessionId);
await this.closeActive(record.sessionId, { kind: "clear", reason: "delete" });
if (record.archivePath === undefined) await this.ensureArchivedRecordMoved(record);
await this.archiveStore.deleteArchived(record.sessionId);
}
@@ -1495,7 +1594,7 @@ export class PiSessionService implements SessionRouteService {
const readyRecords: ArchivedSessionRecord[] = [];
for (const item of planItems) {
try {
await this.closeActive(item.record.sessionId);
await this.closeActive(item.record.sessionId, { kind: "clear", reason: "delete" });
readyRecords.push(item.record);
} catch (error: unknown) {
failures.push({ sessionId: item.record.sessionId, error: errorMessage(error) });
@@ -1528,9 +1627,30 @@ export class PiSessionService implements SessionRouteService {
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
await this.closeActive(session.sessionId);
const reopened = await this.getActive(ref);
this.publishStatus(reopened.runtime.session);
const priorGeneration = this.notificationGenerationBySession.get(session);
const sessionId = session.sessionId;
const cwd = session.sessionManager.getCwd();
let candidateGeneration: SessionNotificationGeneration | undefined;
try {
await this.closeActive(
sessionId,
priorGeneration === undefined ? CLEAR_RUNTIME_NOTIFICATIONS : DEFER_RUNTIME_NOTIFICATIONS,
);
candidateGeneration = priorGeneration === undefined
? undefined
: this.notificationStore.beginReplacement(priorGeneration, { sessionId, cwd });
const reopened = await this.getActive(ref, candidateGeneration === undefined ? {} : { notificationGeneration: candidateGeneration });
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
}
this.publishStatus(reopened.runtime.session);
} catch (error: unknown) {
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration));
}
throw error;
}
}
async detachParent(ref: PiSessionLookup): Promise<void> {
@@ -1569,12 +1689,17 @@ export class PiSessionService implements SessionRouteService {
this.publishStatus(active.runtime.session);
}
stop(ref: PiSessionLookup): void {
async stop(ref: PiSessionLookup): Promise<void> {
const active = this.activeForLookup(ref);
if (active === undefined) return;
void this.closeActive(active.runtime.session.sessionId).catch(() => {
// Best-effort shutdown; callers that need errors await closeActive directly.
});
if (active !== undefined) {
await this.closeActive(active.runtime.session.sessionId);
return;
}
if (isPiSessionRef(ref)) {
this.publishNotificationMutations(this.notificationStore.clearSessionIdentity(ref.id, ref.cwd, "runtime-close"));
return;
}
await this.closeActive(ref);
}
private async bulkSessionLookupContext(refs: readonly SessionBulkMutationRef[]): Promise<BulkSessionLookupContext> {
@@ -1760,10 +1885,17 @@ export class PiSessionService implements SessionRouteService {
return [...names];
}
private async closeActive(sessionId: string): Promise<void> {
private async closeActive(sessionId: string, notificationPolicy: NotificationClosePolicy = CLEAR_RUNTIME_NOTIFICATIONS): Promise<void> {
const pendingOpens = this.pendingSessionOpenPromises(sessionId);
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
const active = this.active.get(sessionId);
if (notificationPolicy.kind === "clear") {
const generation = active === undefined ? undefined : this.notificationGenerationBySession.get(active.runtime.session);
const mutations = generation === undefined
? this.notificationStore.clearSession(sessionId, notificationPolicy.reason)
: this.notificationStore.clearGeneration(generation, notificationPolicy.reason);
this.publishNotificationMutations(mutations);
}
if (!active) return;
this.active.delete(sessionId);
this.activities.delete(sessionId);
@@ -1776,6 +1908,7 @@ export class PiSessionService implements SessionRouteService {
if (this.subsessionLinkForActiveChild(active.runtime.session) !== undefined) this.subsessionNotifyArmed.delete(sessionId);
clearSessionQueue(active.runtime.session);
active.unsubscribe();
active.runtime.setRebindSession(undefined);
try {
await active.runtime.session.abort();
} finally {
@@ -1791,7 +1924,7 @@ export class PiSessionService implements SessionRouteService {
return (await this.getActive(ref)).runtime.session;
}
private async getActive(ref: PiSessionLookup): Promise<ActiveSession<PiSessionRuntime>> {
private async getActive(ref: PiSessionLookup, options: Pick<CreateSessionRuntimeOptions, "notificationGeneration"> = {}): Promise<ActiveSession<PiSessionRuntime>> {
const active = this.activeForLookup(ref);
if (active !== undefined) return active;
@@ -1802,6 +1935,7 @@ export class PiSessionService implements SessionRouteService {
archived.sessionId,
archived.cwd,
() => this.sessionManager.open(archivePath),
{ notifications: "disabled" },
);
}
@@ -1809,13 +1943,14 @@ export class PiSessionService implements SessionRouteService {
? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
: (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
if (!match) throw new Error("Session not found");
return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path));
return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path), options);
}
private openExistingSession(
sessionId: string,
cwd: string,
openSessionManager: () => PiSessionManager,
options: Pick<CreateSessionRuntimeOptions, "notificationGeneration" | "notifications"> = {},
): Promise<ActiveSession<PiSessionRuntime>> {
const active = this.activeForLookup({ id: sessionId, cwd });
if (active !== undefined) return Promise.resolve(active);
@@ -1826,7 +1961,7 @@ export class PiSessionService implements SessionRouteService {
const pending: PendingSessionOpen = {
sessionId,
promise: this.create(openSessionManager(), cwd),
promise: this.create(openSessionManager(), cwd, options),
};
pending.promise = pending.promise.finally(() => {
if (this.pendingSessionOpens.get(key) === pending) this.pendingSessionOpens.delete(key);
@@ -1861,7 +1996,7 @@ export class PiSessionService implements SessionRouteService {
private async create(
sessionManager: PiSessionManager,
cwd: string,
options: Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance"> = {},
options: CreateSessionRuntimeOptions = {},
): Promise<ActiveSession<PiSessionRuntime>> {
const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
&& await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
@@ -1873,19 +2008,78 @@ export class PiSessionService implements SessionRouteService {
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
});
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
let notificationGeneration = options.notificationGeneration;
let notificationOwnership: "disabled" | "external" | "registered" | "replacement" = options.notifications === "disabled"
? "disabled"
: notificationGeneration === undefined
? "registered"
: "external";
if (notificationOwnership === "registered") {
const existingCandidate = this.notificationStore.beginReplacementForSession(
runtime.session.sessionId,
runtime.session.sessionManager.getCwd(),
);
if (existingCandidate !== undefined) {
notificationGeneration = existingCandidate;
notificationOwnership = "replacement";
} else {
const registration = this.notificationStore.registerSession(
runtime.session.sessionId,
runtime.session.sessionManager.getCwd(),
);
notificationGeneration = registration.generation;
this.publishNotificationMutations(registration.mutations);
}
}
if (notificationGeneration !== undefined) this.notificationGenerationBySession.set(runtime.session, notificationGeneration);
try {
await this.bindSessionExtensions(runtime.session);
await this.bindSessionExtensions(runtime.session, notificationGeneration);
this.bindRuntime(active);
runtime.setRebindSession(async (session) => {
await this.bindSessionExtensions(session);
this.bindRuntime(active);
await this.recoverSubsessionTrackingForOpenedSession(session);
const priorGeneration = notificationGeneration;
let candidateGeneration: SessionNotificationGeneration | undefined;
try {
if (priorGeneration !== undefined) {
candidateGeneration = this.notificationStore.beginReplacement(priorGeneration, {
sessionId: session.sessionId,
cwd: session.sessionManager.getCwd(),
});
this.notificationGenerationBySession.set(session, candidateGeneration);
}
this.bindRuntime(active, session);
await this.bindSessionExtensions(session, candidateGeneration);
await this.recoverSubsessionTrackingForOpenedSession(session);
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(candidateGeneration));
notificationGeneration = candidateGeneration;
}
} catch (error: unknown) {
if (candidateGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.abortReplacement(candidateGeneration, "candidate"));
notificationGeneration = candidateGeneration;
this.notificationGenerationBySession.set(session, candidateGeneration);
}
throw error;
}
});
this.active.set(runtime.session.sessionId, active);
await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
if (notificationOwnership === "replacement" && notificationGeneration !== undefined) {
this.publishNotificationMutations(this.notificationStore.commitReplacement(notificationGeneration));
notificationOwnership = "external";
}
this.publishStatus(runtime.session);
return active;
} catch (error: unknown) {
if (notificationGeneration !== undefined) {
if (notificationOwnership === "registered") {
this.publishNotificationMutations(this.notificationStore.clearSession(runtime.session.sessionId, "initialization-failed"));
} else if (notificationOwnership === "replacement") {
this.publishNotificationMutations(this.notificationStore.abortReplacement(notificationGeneration));
}
}
active.unsubscribe();
let removedActive = false;
for (const [sessionId, candidate] of this.active.entries()) {
@@ -1908,25 +2102,11 @@ export class PiSessionService implements SessionRouteService {
}
}
private async bindSessionExtensions(session: PiAgentSession): Promise<void> {
const baseUiContext = session.extensionRunner.getUIContext();
const notify: ExtensionUIContext["notify"] = (message, type) => {
this.events.publish(session.sessionId, {
type: "command.output",
level: type === "error" ? "error" : "info",
message,
});
};
// PI WEB is a remote UI host, but currently only extension notifications
// cross this boundary. Delegate every other UI method to Pi's headless
// defaults so unsupported dialogs cancel safely instead of hanging.
const uiContext = new Proxy(baseUiContext, {
get(target, property, receiver): unknown {
if (property === "notify") return notify;
const value: unknown = Reflect.get(target, property, receiver);
return value;
},
});
private async bindSessionExtensions(
session: PiAgentSession,
generation: SessionNotificationGeneration | undefined,
): Promise<void> {
const uiContext = this.sessionUiContext(session, generation);
await session.bindExtensions({
uiContext,
mode: "rpc",
@@ -1938,9 +2118,55 @@ export class PiSessionService implements SessionRouteService {
});
}
private bindRuntime(active: ActiveSession<PiSessionRuntime>): void {
private replaceSessionNotificationContext(session: PiAgentSession, generation: SessionNotificationGeneration): void {
session.extensionRunner.setUIContext(this.sessionUiContext(session, generation), "rpc");
}
private sessionUiContext(
session: PiAgentSession,
generation: SessionNotificationGeneration | undefined,
): ExtensionUIContext {
const baseUiContext = session.extensionRunner.getUIContext();
const notify: ExtensionUIContext["notify"] = (message, type) => {
if (generation === undefined) {
this.events.publish(session.sessionId, {
type: "command.output",
level: type === "error" ? "error" : "info",
message,
});
return;
}
const added = this.notificationStore.addNotification(generation, message, type);
this.publishNotificationMutations(added.mutations);
if (added.notification === undefined) return;
this.events.publish(session.sessionId, {
type: "command.output",
level: type === "error" ? "error" : "info",
message,
notificationId: added.notification.id,
});
};
// PI WEB is a remote UI host, but currently only extension notifications
// cross this boundary. Delegate every other UI method to Pi's headless
// defaults so unsupported dialogs cancel safely instead of hanging.
return new Proxy(baseUiContext, {
get(target, property, receiver): unknown {
if (property === "notify") return notify;
const value: unknown = Reflect.get(target, property, receiver);
return value;
},
});
}
private publishNotificationMutations(mutations: readonly SessionNotificationMutation[]): void {
for (const mutation of mutations) {
this.events.publish(mutation.sessionId, mutation.inboxEvent);
this.events.publishNotificationSummary(mutation.summaryEvent);
}
}
private bindRuntime(active: ActiveSession<PiSessionRuntime>, session: PiAgentSession = active.runtime.session): void {
active.unsubscribe();
const { session } = active.runtime;
for (const [sessionId, candidate] of this.active.entries()) {
if (candidate === active) {
this.active.delete(sessionId);