@@ -18,8 +18,14 @@
|
|||||||
- Added searchable auth provider login flow with fuzzy filtering in the provider selector ([#3572](https://github.com/badlogic/pi-mono/pull/3572) by [@mitsuhiko](https://github.com/mitsuhiko))
|
- Added searchable auth provider login flow with fuzzy filtering in the provider selector ([#3572](https://github.com/badlogic/pi-mono/pull/3572) by [@mitsuhiko](https://github.com/mitsuhiko))
|
||||||
- Added GPT-5.5 Codex model
|
- Added GPT-5.5 Codex model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved stale extension context errors after session replacement or reload to tell extension authors to avoid captured `pi`/command `ctx` and use `withSession` for post-replacement work.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the handoff extension example to use the replacement-session context after creating a new session, avoiding stale `ctx` errors when it installs the generated prompt ([#3606](https://github.com/badlogic/pi-mono/issues/3606))
|
||||||
|
- Fixed session replacement and `/quit` teardown ordering to run host-owned extension UI cleanup synchronously after `session_shutdown` handlers complete but before invalidating the old extension context, preventing stale extension UI from rendering against a disposed session.
|
||||||
- Fixed crash on `/quit` when an extension registers a custom footer whose `render()` accesses `ctx`, by tearing down extension-provided UI before invalidating the extension runner during shutdown ([#3595](https://github.com/badlogic/pi-mono/issues/3595))
|
- Fixed crash on `/quit` when an extension registers a custom footer whose `render()` accesses `ctx`, by tearing down extension-provided UI before invalidating the extension runner during shutdown ([#3595](https://github.com/badlogic/pi-mono/issues/3595))
|
||||||
- Fixed auto-retry to treat Bedrock/Smithy HTTP/2 transport failures like `http2 request did not get a response` as transient errors, so the agent retries automatically instead of waiting for a manual nudge ([#3594](https://github.com/badlogic/pi-mono/issues/3594))
|
- Fixed auto-retry to treat Bedrock/Smithy HTTP/2 transport failures like `http2 request did not get a response` as transient errors, so the agent retries automatically instead of waiting for a manual nudge ([#3594](https://github.com/badlogic/pi-mono/issues/3594))
|
||||||
- Fixed the CLI/SDK tool-selection split so `--no-builtin-tools` and `createAgentSession({ noTools: "builtin" })` disable only built-in default tools while keeping extension/custom tools enabled, instead of falling through to the same "disable everything" path as `--no-tools` ([#3592](https://github.com/badlogic/pi-mono/issues/3592))
|
- Fixed the CLI/SDK tool-selection split so `--no-builtin-tools` and `createAgentSession({ noTools: "builtin" })` disable only built-in default tools while keeping extension/custom tools enabled, instead of falling through to the same "disable everything" path as `--no-tools` ([#3592](https://github.com/badlogic/pi-mono/issues/3592))
|
||||||
|
|||||||
@@ -992,14 +992,19 @@ Options:
|
|||||||
Fork from a specific entry, creating a new session file:
|
Fork from a specific entry, creating a new session file:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const result = await ctx.fork("entry-id-123");
|
const result = await ctx.fork("entry-id-123", {
|
||||||
if (!result.cancelled) {
|
withSession: async (ctx) => {
|
||||||
// Now in the forked session
|
// Use only the replacement-session ctx here.
|
||||||
|
ctx.ui.notify("Now in the forked session", "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.cancelled) {
|
||||||
|
// An extension cancelled the fork
|
||||||
}
|
}
|
||||||
|
|
||||||
const cloneResult = await ctx.fork("entry-id-456", { position: "at" });
|
const cloneResult = await ctx.fork("entry-id-456", { position: "at" });
|
||||||
if (!cloneResult.cancelled) {
|
if (cloneResult.cancelled) {
|
||||||
// New session contains the active path through entry-id-456
|
// An extension cancelled the clone
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1060,7 +1065,11 @@ pi.registerCommand("switch", {
|
|||||||
sessions.map(s => s.file),
|
sessions.map(s => s.file),
|
||||||
);
|
);
|
||||||
if (choice) {
|
if (choice) {
|
||||||
await ctx.switchSession(choice);
|
await ctx.switchSession(choice, {
|
||||||
|
withSession: async (ctx) => {
|
||||||
|
ctx.ui.notify("Switched session", "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -135,19 +135,20 @@ export default function (pi: ExtensionAPI) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new session with parent tracking
|
// Create new session with parent tracking. Use the replacement-session
|
||||||
|
// context for post-switch UI work; the original ctx is stale after a
|
||||||
|
// successful session replacement.
|
||||||
const newSessionResult = await ctx.newSession({
|
const newSessionResult = await ctx.newSession({
|
||||||
parentSession: currentSessionFile,
|
parentSession: currentSessionFile,
|
||||||
|
withSession: async (replacementCtx) => {
|
||||||
|
replacementCtx.ui.setEditorText(editedPrompt);
|
||||||
|
replacementCtx.ui.notify("Handoff ready. Submit when ready.", "info");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (newSessionResult.cancelled) {
|
if (newSessionResult.cancelled) {
|
||||||
ctx.ui.notify("New session cancelled", "info");
|
ctx.ui.notify("New session cancelled", "info");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the edited prompt in the main editor for submission
|
|
||||||
ctx.ui.setEditorText(editedPrompt);
|
|
||||||
ctx.ui.notify("Handoff ready. Submit when ready.", "info");
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ function extractUserMessageText(content: string | Array<{ type: string; text?: s
|
|||||||
*/
|
*/
|
||||||
export class AgentSessionRuntime {
|
export class AgentSessionRuntime {
|
||||||
private rebindSession?: (session: AgentSession) => Promise<void>;
|
private rebindSession?: (session: AgentSession) => Promise<void>;
|
||||||
|
private beforeSessionInvalidate?: () => void;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _session: AgentSession,
|
private _session: AgentSession,
|
||||||
@@ -99,6 +100,18 @@ export class AgentSessionRuntime {
|
|||||||
this.rebindSession = rebindSession;
|
this.rebindSession = rebindSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a synchronous callback that runs after `session_shutdown` handlers finish
|
||||||
|
* but before the current session is invalidated.
|
||||||
|
*
|
||||||
|
* This is for host-owned UI teardown that must not yield to the event loop,
|
||||||
|
* such as detaching extension-provided TUI components before the old extension
|
||||||
|
* context becomes stale.
|
||||||
|
*/
|
||||||
|
setBeforeSessionInvalidate(beforeSessionInvalidate?: () => void): void {
|
||||||
|
this.beforeSessionInvalidate = beforeSessionInvalidate;
|
||||||
|
}
|
||||||
|
|
||||||
private async emitBeforeSwitch(
|
private async emitBeforeSwitch(
|
||||||
reason: "new" | "resume",
|
reason: "new" | "resume",
|
||||||
targetSessionFile?: string,
|
targetSessionFile?: string,
|
||||||
@@ -139,6 +152,7 @@ export class AgentSessionRuntime {
|
|||||||
reason,
|
reason,
|
||||||
targetSessionFile,
|
targetSessionFile,
|
||||||
});
|
});
|
||||||
|
this.beforeSessionInvalidate?.();
|
||||||
this.session.dispose();
|
this.session.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +368,7 @@ export class AgentSessionRuntime {
|
|||||||
type: "session_shutdown",
|
type: "session_shutdown",
|
||||||
reason: "quit",
|
reason: "quit",
|
||||||
});
|
});
|
||||||
|
this.beforeSessionInvalidate?.();
|
||||||
this.session.dispose();
|
this.session.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -724,7 +724,7 @@ export class AgentSession {
|
|||||||
*/
|
*/
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this._extensionRunner.invalidate(
|
this._extensionRunner.invalidate(
|
||||||
"This extension instance is stale after session replacement or reload. Use the provided replacement-session context instead.",
|
"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().",
|
||||||
);
|
);
|
||||||
this._disconnectFromAgent();
|
this._disconnectFromAgent();
|
||||||
this._eventListeners = [];
|
this._eventListeners = [];
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export function createExtensionRuntime(): ExtensionRuntime {
|
|||||||
invalidate: (message) => {
|
invalidate: (message) => {
|
||||||
state.staleMessage ??=
|
state.staleMessage ??=
|
||||||
message ??
|
message ??
|
||||||
"This extension instance is stale after session replacement or reload. Use the provided replacement-session context instead.";
|
"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().";
|
||||||
},
|
},
|
||||||
// Pre-bind: queue registrations so bindCore() can flush them once the
|
// Pre-bind: queue registrations so bindCore() can flush them once the
|
||||||
// model registry is available. bindCore() replaces both with direct calls.
|
// model registry is available. bindCore() replaces both with direct calls.
|
||||||
|
|||||||
@@ -458,7 +458,9 @@ export class ExtensionRunner {
|
|||||||
return this.shortcutDiagnostics;
|
return this.shortcutDiagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidate(message = "This extension instance is stale after session replacement or reload."): void {
|
invalidate(
|
||||||
|
message = "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().",
|
||||||
|
): void {
|
||||||
if (!this.staleMessage) {
|
if (!this.staleMessage) {
|
||||||
this.staleMessage = message;
|
this.staleMessage = message;
|
||||||
this.runtime.invalidate(message);
|
this.runtime.invalidate(message);
|
||||||
|
|||||||
@@ -362,6 +362,9 @@ export class InteractiveMode {
|
|||||||
private options: InteractiveModeOptions = {},
|
private options: InteractiveModeOptions = {},
|
||||||
) {
|
) {
|
||||||
this.runtimeHost = runtimeHost;
|
this.runtimeHost = runtimeHost;
|
||||||
|
this.runtimeHost.setBeforeSessionInvalidate(() => {
|
||||||
|
this.resetExtensionUI();
|
||||||
|
});
|
||||||
this.runtimeHost.setRebindSession(async () => {
|
this.runtimeHost.setRebindSession(async () => {
|
||||||
await this.rebindCurrentSession();
|
await this.rebindCurrentSession();
|
||||||
});
|
});
|
||||||
@@ -1594,7 +1597,6 @@ export class InteractiveMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async rebindCurrentSession(): Promise<void> {
|
private async rebindCurrentSession(): Promise<void> {
|
||||||
this.resetExtensionUI();
|
|
||||||
this.unsubscribe?.();
|
this.unsubscribe?.();
|
||||||
this.unsubscribe = undefined;
|
this.unsubscribe = undefined;
|
||||||
this.applyRuntimeSettings();
|
this.applyRuntimeSettings();
|
||||||
@@ -3219,7 +3221,6 @@ export class InteractiveMode {
|
|||||||
if (this.isShuttingDown) return;
|
if (this.isShuttingDown) return;
|
||||||
this.isShuttingDown = true;
|
this.isShuttingDown = true;
|
||||||
this.unregisterSignalHandlers();
|
this.unregisterSignalHandlers();
|
||||||
this.resetExtensionUI();
|
|
||||||
await this.runtimeHost.dispose();
|
await this.runtimeHost.dispose();
|
||||||
|
|
||||||
// Wait for any pending renders to complete
|
// Wait for any pending renders to complete
|
||||||
|
|||||||
@@ -157,6 +157,32 @@ describe("AgentSessionRuntime session lifecycle events", () => {
|
|||||||
expect(events).toEqual([{ type: "session_before_switch", reason: "new", targetSessionFile: undefined }]);
|
expect(events).toEqual([{ type: "session_before_switch", reason: "new", targetSessionFile: undefined }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs beforeSessionInvalidate after session_shutdown and before rebindSession", async () => {
|
||||||
|
const phases: string[] = [];
|
||||||
|
const { runtimeHost } = await createRuntimeHost((pi) => {
|
||||||
|
pi.on("session_shutdown", () => {
|
||||||
|
phases.push("session_shutdown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const oldSession = runtimeHost.session;
|
||||||
|
runtimeHost.setBeforeSessionInvalidate(() => {
|
||||||
|
phases.push("beforeSessionInvalidate");
|
||||||
|
expect(oldSession.extensionRunner.createContext().cwd).toBe(oldSession.sessionManager.getCwd());
|
||||||
|
});
|
||||||
|
runtimeHost.setRebindSession(async () => {
|
||||||
|
phases.push("rebindSession");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtimeHost.newSession();
|
||||||
|
|
||||||
|
expect(phases).toEqual(["session_shutdown", "beforeSessionInvalidate", "rebindSession"]);
|
||||||
|
expect(() => oldSession.extensionRunner.createContext().cwd).toThrow(
|
||||||
|
"This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().",
|
||||||
|
);
|
||||||
|
runtimeHost.setBeforeSessionInvalidate(undefined);
|
||||||
|
runtimeHost.setRebindSession(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
it("emits session_before_fork and session_start and honors cancellation", async () => {
|
it("emits session_before_fork and session_start and honors cancellation", async () => {
|
||||||
const events: RecordedSessionEvent[] = [];
|
const events: RecordedSessionEvent[] = [];
|
||||||
let cancelNextFork = false;
|
let cancelNextFork = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user