fix(sessions): correlate startup progress by token instead of workspace

Startup progress could still be shown on the wrong session's row. Routing by
known session id first closed the case where the browser knew the other
session, but left open the case where it does not -- which the browser is
designed to produce. While a create is pending for a workspace,
applyCreatedSession deliberately withholds a session.created event for that
workspace and stashes it, to avoid a duplicate row. So during exactly the
window this feature exists for, a session created by an agent's spawn or by
another tab is intentionally absent from the session list. Its startup events
carried an unrecognised id and a matching cwd, and were routed onto the user's
pending create row, showing a phase and a label belonging to another session.

Workspace path was never evidence of identity; it was the only key both sides
happened to share. Give them a real one. The browser already invents a
temporary row id for a pending create, so it now sends that id with the create
request as an opaque startupToken; the daemon carries it through construction,
echoes it on the startup events it publishes for that construction, and the
browser matches it exactly. The token is a throwaway label the daemon never
interprets. It never becomes the session id: activity.sessionId still carries
Pi's SessionManager id, which remains how an open of an already-known session
is routed.

With exact identity available, the guessing is deleted rather than gated.
startupProgressPendingStart goes entirely, and with it the selected-machine
comparison, the cwd filter, and the single-match ambiguity rule: a second
concurrent create carries a different token, and a foreign workspace or
non-selected machine carries no token this browser is waiting on, so those
cases stop existing rather than needing detection. One Map lookup replaces a
filtered scan. cwd comes off the event, since it existed only as the routing
key and nothing else read it.

No compatibility path is needed. session.startup is unreleased -- checked
against the published tarball, not only git tags -- so no deployed daemon
emits these events and no deployed browser parses them. An older daemon
ignores the extra request field; a newer daemon talking to an older browser
degrades to the pre-existing generic wording, as does any unmatched token.

One silent behaviour change to state plainly: startupProgress guarded on
`sessionId === "" || cwd === ""`. Removing cwd from the event removes the
meaningful half of that guard, and that half had no test. The session-id half
is kept, which is the half that actually protects honest reporting.

The replaced ambiguity test is rewritten rather than dropped, so the same three
scenarios still pin the user-visible guarantee -- no match means the generic
wording stays -- now including the reproduced foreign-session case, which fails
against the previous code. Session creation ordering, semantics, and queueing
are unchanged; the token is a passthrough label read only to build an event.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-26 22:10:58 +02:00
parent 2a05d67436
commit bd4a891b95
13 changed files with 231 additions and 102 deletions
+20
View File
@@ -253,6 +253,22 @@ describe("session API compatibility", () => {
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] }); expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] });
}); });
it("carries a create's correlation token in the start request body when one is supplied", async () => {
const fetchMock = stubSequenceFetch([
jsonResponse(sessionInfoResponse("s 1")),
jsonResponse(sessionInfoResponse("s 2")),
]);
await sessionsApi.startSession("/repo", "remote a", "pending-session-3-k2x9");
await sessionsApi.startSession("/repo", "remote a");
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions");
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ cwd: "/repo", startupToken: "pending-session-3-k2x9" });
// The token is optional, so a caller with no row to label sends none rather
// than an empty one.
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ cwd: "/repo" });
});
it("keeps legacy session-id calls free of cwd context", async () => { it("keeps legacy session-id calls free of cwd context", async () => {
const fetchMock = stubJsonFetch({ accepted: true }); const fetchMock = stubJsonFetch({ accepted: true });
@@ -572,6 +588,10 @@ function requestBody(init: RequestInit | undefined): string {
return init.body; return init.body;
} }
function sessionInfoResponse(id: string) {
return { id, path: `/tmp/${id}.jsonl`, cwd: "/repo", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
}
function piWebConfigResponse(config: PiWebConfigValues) { function piWebConfigResponse(config: PiWebConfigValues) {
return { return {
path: "/tmp/pi-web/config.json", path: "/tmp/pi-web/config.json",
+1 -1
View File
@@ -214,7 +214,7 @@ export const sessionsApi = {
notificationInbox: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "notifications", machineId), parseSessionNotificationInboxSnapshot), notificationInbox: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "notifications", machineId), parseSessionNotificationInboxSnapshot),
dismissNotification: (session: SessionLookup, daemonInstanceId: string, notificationId: string, machineId = "local") => request(sessionPath(session, "notifications/dismiss", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, notificationId }) }), dismissNotification: (session: SessionLookup, daemonInstanceId: string, notificationId: string, machineId = "local") => request(sessionPath(session, "notifications/dismiss", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, notificationId }) }),
dismissAllNotifications: (session: SessionLookup, daemonInstanceId: string, through: SessionNotificationDismissThrough, machineId = "local") => request(sessionPath(session, "notifications/dismiss-all", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, throughOrder: through.order, throughOverflowWatermark: through.overflowWatermark }) }), dismissAllNotifications: (session: SessionLookup, daemonInstanceId: string, through: SessionNotificationDismissThrough, machineId = "local") => request(sessionPath(session, "notifications/dismiss-all", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, throughOrder: through.order, throughOverflowWatermark: through.overflowWatermark }) }),
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }), startSession: (cwd: string, machineId = "local", startupToken?: string) => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify(startupToken === undefined ? { cwd } : { cwd, startupToken }) }),
cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }), cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }),
cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }), cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }),
archiveMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/archive`, parseSessionBulkArchiveResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }), archiveMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/archive`, parseSessionBulkArchiveResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
+16 -14
View File
@@ -289,18 +289,18 @@ describe("API parsers", () => {
})).toThrow("positive safe integer"); })).toThrow("positive safe integer");
}); });
it("parses session startup progress with and without a wait detail", () => { it("parses session startup progress with and without a correlation token", () => {
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" }; const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" };
expect(parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity })).toEqual({ expect(parseSessionStartupProgressEvent({ type: "session.startup", startupToken: "pending-session-1-abc", activity })).toEqual({
type: "session.startup", type: "session.startup",
cwd: "/repo", startupToken: "pending-session-1-abc",
activity, activity,
}); });
// An open carries no token: the activity's own session id is the only route.
const idle = { sessionId: "session-1", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" }; const idle = { sessionId: "session-1", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" };
expect(parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: idle })).toEqual({ expect(parseSessionStartupProgressEvent({ type: "session.startup", activity: idle })).toEqual({
type: "session.startup", type: "session.startup",
cwd: "/repo",
activity: idle, activity: idle,
}); });
}); });
@@ -308,15 +308,17 @@ describe("API parsers", () => {
it("rejects session startup progress that cannot be routed or rendered honestly", () => { it("rejects session startup progress that cannot be routed or rendered honestly", () => {
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", at: "2026-07-20T00:00:01.000Z" }; const activity = { sessionId: "session-1", phase: "active", label: "Creating session", at: "2026-07-20T00:00:01.000Z" };
expect(() => parseSessionStartupProgressEvent({ type: "activity.update", cwd: "/repo", activity })).toThrow("Invalid session startup event type"); expect(() => parseSessionStartupProgressEvent({ type: "activity.update", activity })).toThrow("Invalid session startup event type");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity })).toThrow("Expected string field: cwd"); expect(() => parseSessionStartupProgressEvent({ type: "session.startup" })).toThrow("Expected object response");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "", activity })).toThrow("Expected non-empty string field: cwd"); expect(() => parseSessionStartupProgressEvent({ type: "session.startup", startupToken: 7, activity })).toThrow("Expected optional string field: startupToken");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo" })).toThrow("Expected object response"); // An empty token would match nothing but must still be rejected rather than
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, phase: "waiting" } })).toThrow("Expected session activity phase field: phase"); // silently carried, so a malformed frame never reaches the routing at all.
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, label: 7 } })).toThrow("Expected string field: label"); expect(() => parseSessionStartupProgressEvent({ type: "session.startup", startupToken: "", activity })).toThrow("Expected non-empty string field: startupToken");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, label: "" } })).toThrow("Expected non-empty string field: label"); expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, phase: "waiting" } })).toThrow("Expected session activity phase field: phase");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, detail: 7 } })).toThrow("Expected optional string field: detail"); expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, label: 7 } })).toThrow("Expected string field: label");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, sessionId: "" } })).toThrow("Expected non-empty string field: sessionId"); expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, label: "" } })).toThrow("Expected non-empty string field: label");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, detail: 7 } })).toThrow("Expected optional string field: detail");
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, sessionId: "" } })).toThrow("Expected non-empty string field: sessionId");
}); });
it("parses session cleanup preview and execute responses", () => { it("parses session cleanup preview and execute responses", () => {
+7 -3
View File
@@ -275,15 +275,19 @@ export function parseSessionUnreadEvent(value: unknown): SessionUnreadEvent {
/** /**
* Validate a startup progress frame. The browser substitutes its own wording * Validate a startup progress frame. The browser substitutes its own wording
* from this event, so a malformed frame must be dropped rather than rendered: * from this event, so a malformed frame must be dropped rather than rendered:
* `cwd` is the routing key, and an activity missing its phase or label could * `startupToken` is the routing key when present, and an activity missing its
* otherwise blank out or freeze the text a user is reading while they wait. * phase or label could otherwise blank out or freeze the text a user is reading
* while they wait. An absent token is valid — an open routes by session id — but
* a present empty one is not, since it could match no row honestly.
*/ */
export function parseSessionStartupProgressEvent(value: unknown): SessionStartupProgressEvent { export function parseSessionStartupProgressEvent(value: unknown): SessionStartupProgressEvent {
const record = requireRecord(value); const record = requireRecord(value);
if (record["type"] !== "session.startup") throw new Error("Invalid session startup event type"); if (record["type"] !== "session.startup") throw new Error("Invalid session startup event type");
const startupToken = optionalString(record, "startupToken");
if (startupToken === "") throw new Error("Expected non-empty string field: startupToken");
return { return {
type: "session.startup", type: "session.startup",
cwd: requireNonEmptyString(record, "cwd"), ...optionalField("startupToken", startupToken),
activity: parseSessionActivity(record["activity"]), activity: parseSessionActivity(record["activity"]),
}; };
} }
@@ -3,8 +3,6 @@ import { initialAppState } from "../appState";
import { SessionController } from "./sessionController"; import { SessionController } from "./sessionController";
import { defaultApi, deferred, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, sessionLookupId, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport"; import { defaultApi, deferred, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, sessionLookupId, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport";
const REMOTE_MACHINE = { id: "remote", name: "Remote", kind: "remote" as const, createdAt: "now", updatedAt: "now" };
function startupActivity(patch: Partial<SessionActivity> = {}): SessionActivity { function startupActivity(patch: Partial<SessionActivity> = {}): SessionActivity {
return { return {
sessionId: "backend-session", sessionId: "backend-session",
@@ -21,8 +19,15 @@ function idleStartupActivity(): SessionActivity {
return { sessionId: "backend-session", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" }; return { sessionId: "backend-session", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" };
} }
interface StartCall {
cwd: string;
machineId: string | undefined;
startupToken: string | undefined;
}
function pendingStartController(state: { current: AppState }, api: Partial<typeof defaultApi> = {}) { function pendingStartController(state: { current: AppState }, api: Partial<typeof defaultApi> = {}) {
const startRequest = deferred<SessionInfo>(); const startRequest = deferred<SessionInfo>();
const startCalls: StartCall[] = [];
const controller = new SessionController( const controller = new SessionController(
() => state.current, () => state.current,
(patch) => { state.current = { ...state.current, ...patch }; }, (patch) => { state.current = { ...state.current, ...patch }; },
@@ -31,7 +36,10 @@ function pendingStartController(state: { current: AppState }, api: Partial<typeo
{ {
api: { api: {
...defaultApi, ...defaultApi,
startSession: () => startRequest.promise, startSession: (cwd: string, machineId?: string, startupToken?: string) => {
startCalls.push({ cwd, machineId, startupToken });
return startRequest.promise;
},
messages: () => Promise.resolve(emptyPage), messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))), status: (session) => Promise.resolve(status(sessionLookupId(session))),
...api, ...api,
@@ -39,7 +47,7 @@ function pendingStartController(state: { current: AppState }, api: Partial<typeo
socket: new FakeSocket(), socket: new FakeSocket(),
}, },
); );
return { controller, startRequest }; return { controller, startRequest, startCalls };
} }
describe("SessionController session startup progress", () => { describe("SessionController session startup progress", () => {
@@ -51,7 +59,7 @@ describe("SessionController session startup progress", () => {
const temporaryId = state.current.selectedSession?.id; const temporaryId = state.current.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id"); if (temporaryId === undefined) throw new Error("Expected temporary session id");
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() }); controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
runPendingAnimationFrames(); runPendingAnimationFrames();
// The label changes while the user is waiting, before the start resolves, // The label changes while the user is waiting, before the start resolves,
@@ -59,7 +67,7 @@ describe("SessionController session startup progress", () => {
expect(state.current.activity).toMatchObject({ sessionId: temporaryId, phase: "active", label: "Creating session", detail: "Starting the Pi session" }); expect(state.current.activity).toMatchObject({ sessionId: temporaryId, phase: "active", label: "Creating session", detail: "Starting the Pi session" });
expect(state.current.sessionActivities[temporaryId]).toMatchObject({ detail: "Starting the Pi session" }); expect(state.current.sessionActivities[temporaryId]).toMatchObject({ detail: "Starting the Pi session" });
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity({ detail: "Loading session extensions" }) }); controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity({ detail: "Loading session extensions" }) });
runPendingAnimationFrames(); runPendingAnimationFrames();
expect(state.current.activity?.detail).toBe("Loading session extensions"); expect(state.current.activity?.detail).toBe("Loading session extensions");
@@ -75,10 +83,10 @@ describe("SessionController session startup progress", () => {
const start = controller.startSession(); const start = controller.startSession();
const temporaryId = state.current.selectedSession?.id; const temporaryId = state.current.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id"); if (temporaryId === undefined) throw new Error("Expected temporary session id");
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() }); controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
runPendingAnimationFrames(); runPendingAnimationFrames();
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: idleStartupActivity() }); controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: idleStartupActivity() });
runPendingAnimationFrames(); runPendingAnimationFrames();
expect(state.current.activity).toMatchObject({ expect(state.current.activity).toMatchObject({
@@ -98,7 +106,9 @@ describe("SessionController session startup progress", () => {
const start = controller.startSession(); const start = controller.startSession();
await controller.send("queued while starting"); await controller.send("queued while starting");
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: idleStartupActivity() }); const temporaryId = state.current.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id");
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: idleStartupActivity() });
runPendingAnimationFrames(); runPendingAnimationFrames();
expect(state.current.activity?.detail).toBe("1 queued message will send when the backend session is ready"); expect(state.current.activity?.detail).toBe("1 queued message will send when the backend session is ready");
@@ -119,7 +129,6 @@ describe("SessionController session startup progress", () => {
controller.applyGlobalEvent({ controller.applyGlobalEvent({
type: "session.startup", type: "session.startup",
cwd: oldSession.cwd,
activity: startupActivity({ sessionId: oldSession.id, label: "Opening session" }), activity: startupActivity({ sessionId: oldSession.id, label: "Opening session" }),
}); });
runPendingAnimationFrames(); runPendingAnimationFrames();
@@ -136,12 +145,11 @@ describe("SessionController session startup progress", () => {
const temporaryId = state.current.selectedSession?.id; const temporaryId = state.current.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id"); if (temporaryId === undefined) throw new Error("Expected temporary session id");
// Opening an existing session in the same workspace publishes the same cwd as // Opening an existing session in the same workspace carries no create token,
// the pending create. The known id is the proof of which row it belongs to, so // so the known id is the only proof of which row it belongs to and the pending
// the pending row must keep its own wording instead of the other row's phase. // row must keep its own wording instead of the other row's phase.
controller.applyGlobalEvent({ controller.applyGlobalEvent({
type: "session.startup", type: "session.startup",
cwd: workspace.path,
activity: startupActivity({ sessionId: existing.id, label: "Opening session" }), activity: startupActivity({ sessionId: existing.id, label: "Opening session" }),
}); });
runPendingAnimationFrames(); runPendingAnimationFrames();
@@ -153,7 +161,7 @@ describe("SessionController session startup progress", () => {
await start; await start;
}); });
it("keeps the generic wording when the startup progress cannot be attributed to one row", async () => { it("keeps the generic wording when no pending row's token matches the startup progress", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } }; const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const { controller, startRequest } = pendingStartController(state); const { controller, startRequest } = pendingStartController(state);
@@ -161,29 +169,54 @@ describe("SessionController session startup progress", () => {
const temporaryId = state.current.selectedSession?.id; const temporaryId = state.current.selectedSession?.id;
if (temporaryId === undefined) throw new Error("Expected temporary session id"); if (temporaryId === undefined) throw new Error("Expected temporary session id");
// Another workspace's startup. // Another browser tab's create, or another workspace's: its token is one this
controller.applyGlobalEvent({ type: "session.startup", cwd: "/elsewhere", activity: startupActivity() }); // browser never minted, so there is no row here that it belongs to.
// The selected machine's socket is the only feed for these events, so a cwd controller.applyGlobalEvent({ type: "session.startup", startupToken: "pending-session-9-other-tab", activity: startupActivity() });
// that matches while another machine is selected belongs to a different row. // A session this browser has not been told about — an agent's spawned
state.current = { ...state.current, selectedMachine: REMOTE_MACHINE }; // subsession, say, whose `session.created` a pending create suppresses — is
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() }); // opened rather than created, so it carries no token at all.
state.current = { ...state.current, selectedMachine: undefined }; controller.applyGlobalEvent({ type: "session.startup", activity: startupActivity({ sessionId: "foreign-session", label: "Opening session", detail: "Loading session extensions" }) });
runPendingAnimationFrames(); runPendingAnimationFrames();
expect(state.current.activity?.detail).toBe("Waiting for the backend session to be ready"); expect(state.current.activity?.detail).toBe("Waiting for the backend session to be ready");
expect(state.current.activity?.label).toBe("Creating session");
// A second concurrent start in the same workspace makes the target ambiguous, startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
// so neither row is given a phase that might belong to the other. await start;
});
it("gives each of two concurrent creates only the progress its own token carries", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const { controller, startRequest } = pendingStartController(state);
const start = controller.startSession();
const firstId = state.current.selectedSession?.id;
const secondStart = controller.startSession(); const secondStart = controller.startSession();
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() }); const secondId = state.current.selectedSession?.id;
if (firstId === undefined || secondId === undefined || firstId === secondId) throw new Error("Expected two distinct temporary session ids");
// Two creates in the same workspace are indistinguishable by workspace path;
// the token each request carried is what tells them apart.
controller.applyGlobalEvent({ type: "session.startup", startupToken: secondId, activity: startupActivity({ detail: "Loading session extensions" }) });
runPendingAnimationFrames(); runPendingAnimationFrames();
const secondTemporaryId = state.current.selectedSession?.id; expect(state.current.sessionActivities[secondId]).toMatchObject({ sessionId: secondId, detail: "Loading session extensions" });
expect(secondTemporaryId).not.toBe(temporaryId); expect(state.current.sessionActivities[firstId]?.detail).toBe("Waiting for the backend session to be ready");
expect(state.current.sessionActivities[temporaryId]?.detail).toBe("Waiting for the backend session to be ready");
expect(state.current.sessionActivities[secondTemporaryId ?? ""]?.detail).toBe("Waiting for the backend session to be ready");
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" }); startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
await Promise.all([start, secondStart]); await Promise.all([start, secondStart]);
}); });
it("sends the pending row's own id as the create request's correlation token", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const { controller, startRequest, startCalls } = pendingStartController(state);
const start = controller.startSession();
const temporaryId = state.current.selectedSession?.id;
expect(startCalls).toEqual([{ cwd: workspace.path, machineId: "local", startupToken: temporaryId }]);
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
await start;
});
}); });
+10 -22
View File
@@ -185,7 +185,7 @@ export class SessionController {
this.pendingSessionStarts.set(pending.tempId, pending); this.pendingSessionStarts.set(pending.tempId, pending);
this.insertAndSelectPendingSession(pending.session); this.insertAndSelectPendingSession(pending.session);
try { try {
const session = await this.api.startSession(workspace.path, machineId); const session = await this.api.startSession(workspace.path, machineId, pending.tempId);
await this.resolvePendingSessionStart(pending.tempId, session); await this.resolvePendingSessionStart(pending.tempId, session);
} catch (error) { } catch (error) {
this.failPendingSessionStart(pending.tempId, error); this.failPendingSessionStart(pending.tempId, error);
@@ -1309,25 +1309,20 @@ export class SessionController {
} }
// Session startup progress arrives while the daemon is still constructing the // Session startup progress arrives while the daemon is still constructing the
// session, so the target row is resolved by session id when the browser knows // session, so the target row is resolved by exact identity only: a session id
// it and by workspace path when it does not: a pending start knows its cwd but // the browser already knows (an open), else the correlation token this browser
// not the session id the daemon is creating. Once the row is resolved the // minted for its own create and the daemon echoed back. Matching neither means
// progress goes through the normal activity buffer, so it renders exactly like // the row is not one this browser shows — an agent's or another tab's session is
// any other activity and stays batched per frame. // *deliberately* absent while a create is pending — so it is ignored rather than
// guessed at. A resolved row goes through the normal activity buffer, rendering
// like any other activity and staying batched per frame.
private queueStartupProgress(event: SessionStartupProgressEvent): void { private queueStartupProgress(event: SessionStartupProgressEvent): void {
// A known session id is the strongest possible proof of the target, so it is
// checked first: while a create is pending in a workspace, an *existing*
// session in that same workspace can be opened too (another row selected,
// another tab, a subsession), and that open publishes the same cwd. Matching
// on cwd first would paint the pending row with another session's phase.
if (this.getState().sessions.some((session) => session.id === event.activity.sessionId)) { if (this.getState().sessions.some((session) => session.id === event.activity.sessionId)) {
this.queueActivityUpdate(event.activity); this.queueActivityUpdate(event.activity);
return; return;
} }
// The id is unknown, so this can only be a create whose id the browser has const pending = event.startupToken === undefined ? undefined : this.pendingSessionStarts.get(event.startupToken);
// not been told yet. Route it by workspace path, the one key both sides share. if (pending === undefined || pending.discarded) return;
const pending = this.startupProgressPendingStart(event.cwd);
if (pending === undefined) return;
// An idle startup phase means the daemon has nothing left to attribute, so // An idle startup phase means the daemon has nothing left to attribute, so
// restore this row's own generic wording rather than clearing the text of a // restore this row's own generic wording rather than clearing the text of a
// creation request that has not returned yet. // creation request that has not returned yet.
@@ -1336,13 +1331,6 @@ export class SessionController {
: { ...event.activity, sessionId: pending.tempId }); : { ...event.activity, sessionId: pending.tempId });
} }
private startupProgressPendingStart(cwd: string): PendingSessionStart | undefined {
const machineId = selectedMachineId(this.getState());
const matches = Array.from(this.pendingSessionStarts.values())
.filter((pending) => pending.cwd === cwd && pending.machineId === machineId && !pending.discarded);
return matches.length === 1 ? matches[0] : undefined;
}
private schedulePendingFlush(): void { private schedulePendingFlush(): void {
if (this.pendingFrame !== undefined) return; if (this.pendingFrame !== undefined) return;
this.pendingFrame = requestAnimationFrame(() => { this.pendingFrame = requestAnimationFrame(() => {
+7 -6
View File
@@ -93,14 +93,15 @@ describe("notification socket guards", () => {
it("accepts validated session startup progress and drops malformed frames", () => { it("accepts validated session startup progress and drops malformed frames", () => {
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" }; const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" };
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo", activity })) expect(parseRealtimeSocketEvent({ type: "session.startup", startupToken: "pending-session-1-abc", activity }))
.toMatchObject({ type: "session.startup", cwd: "/repo", activity }); .toMatchObject({ type: "session.startup", startupToken: "pending-session-1-abc", activity });
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "", activity })).toBeUndefined(); expect(parseRealtimeSocketEvent({ type: "session.startup", activity })).toMatchObject({ type: "session.startup", activity });
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo" })).toBeUndefined(); expect(parseRealtimeSocketEvent({ type: "session.startup", startupToken: "", activity })).toBeUndefined();
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, phase: "waiting" } })).toBeUndefined(); expect(parseRealtimeSocketEvent({ type: "session.startup" })).toBeUndefined();
expect(parseRealtimeSocketEvent({ type: "session.startup", activity: { ...activity, phase: "waiting" } })).toBeUndefined();
// Startup progress is global-only, so it must not be accepted as a // Startup progress is global-only, so it must not be accepted as a
// per-session frame even when it is well formed. // per-session frame even when it is well formed.
expect(parseSessionSocketEvent({ type: "session.startup", cwd: "/repo", activity })).toBeUndefined(); expect(parseSessionSocketEvent({ type: "session.startup", activity })).toBeUndefined();
}); });
it("preserves existing event acceptance without treating unknown types as realtime events", () => { it("preserves existing event acceptance without treating unknown types as realtime events", () => {
@@ -77,7 +77,7 @@ describe("PiSessionService session startup progress", () => {
// The proof that matters: the user is told what is being waited on before // The proof that matters: the user is told what is being waited on before
// the wait ends, not after it. // the wait ends, not after it.
expect(startupText(hub)).toEqual(["Creating session: Starting the Pi session"]); expect(startupText(hub)).toEqual(["Creating session: Starting the Pi session"]);
expect(startupEvents(hub).at(0)).toMatchObject({ cwd: "/workspace", activity: { sessionId: "session-1", phase: "active" } }); expect(startupEvents(hub).at(0)).toMatchObject({ activity: { sessionId: "session-1", phase: "active" } });
runtimeResult.resolve(fake.runtime); runtimeResult.resolve(fake.runtime);
await started; await started;
@@ -157,11 +157,42 @@ describe("PiSessionService session startup progress", () => {
await service.start("/workspace"); await service.start("/workspace");
expect(startupEvents(hub).at(-1)).toMatchObject({ cwd: "/workspace", activity: { sessionId: "session-1", phase: "idle", label: "idle" } }); expect(startupEvents(hub).at(-1)).toMatchObject({ activity: { sessionId: "session-1", phase: "idle", label: "idle" } });
expect(startupEvents(hub).at(-1)?.activity.detail).toBeUndefined(); expect(startupEvents(hub).at(-1)?.activity.detail).toBeUndefined();
await service.dispose(); await service.dispose();
}); });
it("echoes a create's correlation token on every startup report of that construction", async () => {
const { hub, service } = startupService();
await service.start("/workspace", { startupToken: "pending-session-3-k2x9" });
// The token labels the browser row that is waiting, so it must ride every
// report of this construction, the closing idle one included.
expect(startupEvents(hub).map((event) => event.startupToken)).toEqual([
"pending-session-3-k2x9",
"pending-session-3-k2x9",
"pending-session-3-k2x9",
]);
// The token is an opaque throwaway label, never the session's identity.
expect(startupEvents(hub).map((event) => event.activity.sessionId)).toEqual(["session-1", "session-1", "session-1"]);
await service.dispose();
});
it("publishes no correlation token when a create supplies none, and none for an open", async () => {
const created = startupService();
await created.service.start("/workspace");
const opened = startupService({ sessionRecords: [sessionRecord("session-1")] });
await opened.service.status(sessionRef("session-1"));
for (const hub of [created.hub, opened.hub]) {
expect(startupEvents(hub).length).toBeGreaterThan(0);
expect(startupEvents(hub).every((event) => event.startupToken === undefined)).toBe(true);
}
await created.service.dispose();
await opened.service.dispose();
});
it("ends the startup window when the runtime construction itself fails", async () => { it("ends the startup window when the runtime construction itself fails", async () => {
const failure = new Error("runtime unavailable"); const failure = new Error("runtime unavailable");
const { hub, service } = startupService({ createAgentRuntime: () => Promise.reject(failure) }); const { hub, service } = startupService({ createAgentRuntime: () => Promise.reject(failure) });
+21 -15
View File
@@ -199,6 +199,11 @@ type SessionCreationProvenance = "tracked-subsession";
interface StartSessionOptions { interface StartSessionOptions {
parentSession?: string; parentSession?: string;
initialModel?: AgentModel; initialModel?: AgentModel;
/**
* Opaque label, echoed on this construction's startup progress so a browser
* row with no session id yet can recognise its own.
*/
startupToken?: string;
} }
interface InternalStartSessionOptions extends StartSessionOptions { interface InternalStartSessionOptions extends StartSessionOptions {
@@ -390,7 +395,7 @@ interface PendingSessionOpen {
promise: Promise<ActiveSession<PiSessionRuntime>>; promise: Promise<ActiveSession<PiSessionRuntime>>;
} }
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance"> { interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance" | "startupToken"> {
notificationGeneration?: SessionNotificationGeneration; notificationGeneration?: SessionNotificationGeneration;
notifications?: "enabled" | "disabled"; notifications?: "enabled" | "disabled";
/** /**
@@ -992,6 +997,7 @@ export class PiSessionService implements SessionRouteService {
cwd, cwd,
{ {
startupIntent: "create", startupIntent: "create",
...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }), ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }), ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
}, },
@@ -2353,7 +2359,7 @@ export class PiSessionService implements SessionRouteService {
cwd: string, cwd: string,
options: CreateSessionRuntimeOptions = {}, options: CreateSessionRuntimeOptions = {},
): Promise<ActiveSession<PiSessionRuntime>> { ): Promise<ActiveSession<PiSessionRuntime>> {
const startup = this.startupProgress(sessionManager, cwd, options.startupIntent ?? "open"); const startup = this.startupProgress(sessionManager, options.startupIntent ?? "open", options.startupToken);
try { try {
return await this.createSessionRuntime(sessionManager, cwd, options, startup); return await this.createSessionRuntime(sessionManager, cwd, options, startup);
} finally { } finally {
@@ -2999,23 +3005,23 @@ export class PiSessionService implements SessionRouteService {
/** /**
* Build the reporter for one session construction. * Build the reporter for one session construction.
* *
* The session id and cwd are both known before any await — a `SessionManager` * The session id is known before any await — a `SessionManager` has its id
* has its id from construction — so the daemon can name what it is starting * from construction — so the daemon can name what it is starting even though
* even though the `PiAgentSession` that {@link publishActivity} needs does not * the `PiAgentSession` that {@link publishActivity} needs does not exist yet.
* exist yet. When either is missing there is nothing honest to route on, so * Without an id there is nothing to report against, so the reporter stays
* the reporter stays silent and the browser keeps its own generic wording. * silent and the browser keeps its own generic wording.
*/ */
private startupProgress(sessionManager: PiSessionManager, cwd: string, intent: "create" | "open"): SessionStartupProgressReporter { private startupProgress(sessionManager: PiSessionManager, intent: "create" | "open", startupToken: string | undefined): SessionStartupProgressReporter {
const sessionId = sessionManager.getSessionId(); const sessionId = sessionManager.getSessionId();
if (sessionId === "" || cwd === "") return { report: noop, end: noop }; if (sessionId === "") return { report: noop, end: noop };
const label = intent === "create" ? "Creating session" : "Opening session"; const label = intent === "create" ? "Creating session" : "Opening session";
return { return {
report: (phase) => { this.publishStartupProgress(sessionId, cwd, label, "active", this.startupDetail(phase)); }, report: (phase) => { this.publishStartupProgress(sessionId, startupToken, label, "active", this.startupDetail(phase)); },
end: () => { end: () => {
// A real activity published during the window (an extension error, say) // A real activity published during the window (an extension error, say)
// is the truth about this session and must survive the clear. // is the truth about this session and must survive the clear.
if (this.activities.has(sessionId)) return; if (this.activities.has(sessionId)) return;
this.publishStartupProgress(sessionId, cwd, "idle", "idle", undefined); this.publishStartupProgress(sessionId, startupToken, "idle", "idle", undefined);
}, },
}; };
} }
@@ -3027,17 +3033,17 @@ export class PiSessionService implements SessionRouteService {
} }
/** /**
* Report startup progress on the global channel only, keyed by `cwd` so a * Report startup progress on the global channel only, echoing the caller's
* browser row that has no session id yet can find it. * correlation token so a waiting browser row recognises its own construction.
* *
* Unlike {@link publishActivity} this deliberately records nothing: no * Unlike {@link publishActivity} this deliberately records nothing: no
* `activities` entry, no workspace activity, no unread observation. There is * `activities` entry, no workspace activity, no unread observation. There is
* no session to own that state, and a failed creation would leave it stranded. * no session to own that state, and a failed creation would leave it stranded.
*/ */
private publishStartupProgress(sessionId: string, cwd: string, label: string, phase: "active" | "idle", detail: string | undefined): void { private publishStartupProgress(sessionId: string, startupToken: string | undefined, label: string, phase: "active" | "idle", detail: string | undefined): void {
const at = new Date().toISOString(); const at = new Date().toISOString();
const activity = detail === undefined ? { sessionId, phase, label, at } : { sessionId, phase, label, detail, at }; const activity = detail === undefined ? { sessionId, phase, label, at } : { sessionId, phase, label, detail, at };
this.events.publishGlobal({ type: "session.startup", cwd, activity }); this.events.publishGlobal(startupToken === undefined ? { type: "session.startup", activity } : { type: "session.startup", startupToken, activity });
} }
private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void { private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
+36 -1
View File
@@ -26,6 +26,7 @@ import { PiSessionService, type PiSessionManagerGateway } from "./piSessionServi
import { testModelRuntime } from "./piSessionService.testSupport.js"; import { testModelRuntime } from "./piSessionService.testSupport.js";
import { SessionNotificationStore } from "./sessionNotificationStore.js"; import { SessionNotificationStore } from "./sessionNotificationStore.js";
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js"; import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
import type { ClientSession } from "../types.js";
import { registerSessionRoutes } from "./sessionRoutes.js"; import { registerSessionRoutes } from "./sessionRoutes.js";
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js"; import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
@@ -666,6 +667,35 @@ describe("session routes", () => {
} }
}); });
it("forwards a create's optional correlation token alongside the normalized cwd", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const requestCwd = resolve("/repo");
const withToken = await routeApp.inject({ method: "POST", url: "/sessions", payload: { cwd: requestCwd, startupToken: "pending-session-3-k2x9" } });
const withoutToken = await routeApp.inject({ method: "POST", url: "/sessions", payload: { cwd: requestCwd } });
// An older browser, or any non-browser caller, sends no token; and a
// malformed one must not reach the service as a label it would echo.
const malformedToken = await routeApp.inject({ method: "POST", url: "/sessions", payload: { cwd: requestCwd, startupToken: 7 } });
expect(withToken.statusCode).toBe(200);
expect(withoutToken.statusCode).toBe(200);
expect(malformedToken.statusCode).toBe(400);
expect(malformedToken.json()).toEqual({ error: "startupToken field must be a string" });
expect(routeService.startCalls).toEqual([
{ cwd: requestCwd, startupToken: "pending-session-3-k2x9" },
{ cwd: requestCwd, startupToken: undefined },
]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("rejects malformed bulk mutation bodies before calling the service", async () => { it("rejects malformed bulk mutation bodies before calling the service", async () => {
const routeApp = Fastify({ logger: false }); const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket); await routeApp.register(fastifyWebsocket);
@@ -706,6 +736,7 @@ class CapturingRouteSessionService implements SessionRouteService {
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = []; readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = []; readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = []; readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = [];
readonly startCalls: { cwd: string; startupToken: string | undefined }[] = [];
reloadError: Error | undefined; reloadError: Error | undefined;
clearQueueError: Error | undefined; clearQueueError: Error | undefined;
@@ -772,7 +803,11 @@ class CapturingRouteSessionService implements SessionRouteService {
} }
list(): never { throw unusedRouteMethod("list"); } list(): never { throw unusedRouteMethod("list"); }
start(): never { throw unusedRouteMethod("start"); }
start(cwd: string, options?: { startupToken?: string }): Promise<ClientSession> {
this.startCalls.push({ cwd, startupToken: options?.startupToken });
return Promise.resolve({ id: "session-1", path: "/tmp/session-1.jsonl", cwd, created: "2026-06-25T00:00:00.000Z", modified: "2026-06-25T00:00:00.000Z", messageCount: 0, firstMessage: "" });
}
dismissWarning(lookup: SessionRouteLookup, dismissId: string): Promise<SessionStatus> { dismissWarning(lookup: SessionRouteLookup, dismissId: string): Promise<SessionStatus> {
this.dismissWarningCalls.push({ lookup, dismissId }); this.dismissWarningCalls.push({ lookup, dismissId });
+6 -2
View File
@@ -45,10 +45,14 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
} }
}); });
app.post<{ Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions`, async (request, reply) => { app.post<{ Body: { cwd?: unknown; startupToken?: unknown } | undefined }>(`${prefix}/sessions`, async (request, reply) => {
try { try {
const body = requireRecord(request.body); const body = requireRecord(request.body);
return await sessions.start(normalizeRequestCwd(requireString(body, "cwd"))); // An opaque label the caller uses to recognise its own construction's
// startup reports. Optional: only a browser row waiting for a session id
// has anything to correlate.
const startupToken = body["startupToken"] === undefined ? undefined : requireNonEmptyString(body, "startupToken");
return await sessions.start(normalizeRequestCwd(requireString(body, "cwd")), optionalField("startupToken", startupToken));
} catch (error) { } catch (error) {
return reply.code(400).send({ error: errorMessage(error) }); return reply.code(400).send({ error: errorMessage(error) });
} }
+6 -1
View File
@@ -40,7 +40,12 @@ export type SessionRouteLookup = string | SessionRouteRef;
*/ */
export interface SessionRouteService { export interface SessionRouteService {
list(cwd: string): Promise<ClientSession[]>; list(cwd: string): Promise<ClientSession[]>;
start(cwd: string): Promise<ClientSession>; /**
* Create a session. `startupToken` is an opaque label the caller supplies so
* it can recognise this construction's startup progress reports; the service
* echoes it and never interprets it.
*/
start(cwd: string, options?: { startupToken?: string }): Promise<ClientSession>;
messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>; messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>;
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>; status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
streamSnapshot(ref: SessionRouteLookup): Promise<SessionStreamSnapshot>; streamSnapshot(ref: SessionRouteLookup): Promise<SessionStreamSnapshot>;
+6 -6
View File
@@ -438,18 +438,18 @@ export interface QueuedSessionMessage {
* constructing the agent session and no `PiAgentSession` exists yet, so * constructing the agent session and no `PiAgentSession` exists yet, so
* `activity.update` cannot be published for it. * `activity.update` cannot be published for it.
* *
* `cwd` is the routing key for a browser row that is still waiting for a * `startupToken` is the opaque label a create request supplied, echoed back so a
* session id: a client-invented pending start knows its workspace path but not * browser row still waiting for a session id recognises its own construction.
* the daemon's session id. `activity.sessionId` carries the daemon's real id, so * The daemon never interprets it and it never becomes the session id:
* the same event also serves the case where the browser already knows it (an * `activity.sessionId` always carries the real id, which is how an *open* of a
* open of an existing session). * session the browser already knows is routed instead.
* *
* `activity.phase === "idle"` means the startup window ended with nothing left * `activity.phase === "idle"` means the startup window ended with nothing left
* to report, so a browser that substituted its own text should restore it. * to report, so a browser that substituted its own text should restore it.
*/ */
export interface SessionStartupProgressEvent { export interface SessionStartupProgressEvent {
type: "session.startup"; type: "session.startup";
cwd: string; startupToken?: string;
activity: SessionActivity; activity: SessionActivity;
} }