diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1445eed3c..39013c137 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -205,6 +205,9 @@ - Fixed ACP `terminal/create` sending the bash tool's full shell line in `command` with no `args`, which broke spec-conformant clients that spawn `command`+`args` directly (no implicit shell) — any command containing a space, pipe, `&&`, redirect, or `$(...)` failed with `ENOENT` and the agent silently degraded to read-only tools. The bash tool now wraps the shell line before calling `clientBridge.createTerminal`, reusing the same shell binary + args the local `bash-executor` resolves via `settings.getShellConfig()` (Git Bash / `bash.exe` on Windows, `$SHELL` with `sh` fallback on POSIX) so bash semantics — `$VAR`, `$(...)`, `source`, POSIX quoting, `-l` — are preserved on both platforms. ([#4333](https://github.com/can1357/oh-my-pi/issues/4333)) - Fixed inference worker subprocesses (TTS, STT, tiny-model, mnemopi embeddings) discarding stderr, which left every unexpected exit — most visibly the local Kokoro TTS worker's recurring `exit code 7` crash loop — undiagnosable from the parent's logs. `createWorkerSubprocess` now pipes stderr without starting a live read while the worker is idle, then drains the stream after `onExit`, emits captured lines to `logger.debug` under an ` stderr` message, and keeps the last 16 KiB in a bounded ring that gets appended to the `Error` surfaced through `onError`. The exit surface is synchronized with the post-exit drain via `SpawnedSubprocess.stderrDrained`, so the full native trace shows up on the `tts: worker error` line without reintroducing event-loop liveness from unref'd workers. ([#4324](https://github.com/can1357/oh-my-pi/issues/4324)) - Fixed Windows session tail loss after atomic compaction rewrites by fencing append writers during full-file replacement and gating the atomic publish on a `commitGuard` that the storage backend checks synchronously before rename, so a concurrent `flushSync` (Ctrl+C / session-exit) is not overwritten by the stale body serialized before it ran. Covers post-compaction prompts, tool results, title changes, and exit diagnostics on the current JSONL path ([#4338](https://github.com/can1357/oh-my-pi/issues/4338)). +### Fixed + +- Fixed `/handoff` and auto-handoff skipping extension lifecycle hooks by emitting `session_switch` with `reason: "handoff"` before replacing the outgoing session ([#4434](https://github.com/can1357/oh-my-pi/issues/4434)). ## [16.3.4] - 2026-07-03 diff --git a/packages/coding-agent/src/extensibility/shared-events.ts b/packages/coding-agent/src/extensibility/shared-events.ts index e3bb7dcbb..e1d22f50c 100644 --- a/packages/coding-agent/src/extensibility/shared-events.ts +++ b/packages/coding-agent/src/extensibility/shared-events.ts @@ -33,7 +33,7 @@ export interface SessionStartEvent { export interface SessionBeforeSwitchEvent { type: "session_before_switch"; /** Reason for the switch */ - reason: "new" | "resume" | "fork"; + reason: "new" | "resume" | "fork" | "handoff"; /** Session file we're switching to (only for "resume") */ targetSessionFile?: string; } @@ -42,7 +42,7 @@ export interface SessionBeforeSwitchEvent { export interface SessionSwitchEvent { type: "session_switch"; /** Reason for the switch */ - reason: "new" | "resume" | "fork"; + reason: "new" | "resume" | "fork" | "handoff"; /** Session file we came from */ previousSessionFile: string | undefined; } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 4097e5ccf..be6d8cad6 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -10084,6 +10084,16 @@ export class AgentSession { // Start a new session const previousSessionFile = this.sessionFile; + if (this.#extensionRunner?.hasHandlers("session_before_switch")) { + const result = (await this.#extensionRunner.emit({ + type: "session_before_switch", + reason: "handoff", + })) as SessionBeforeSwitchResult | undefined; + + if (result?.cancel) { + return undefined; + } + } await this.sessionManager.flush(); this.#cancelOwnAsyncJobs(); await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined); @@ -10140,6 +10150,13 @@ export class AgentSession { this.agent.replaceMessages(sessionContext.messages); this.#resetAllAdvisorRuntimes(); this.#syncTodoPhasesFromBranch(); + if (this.#extensionRunner) { + await this.#extensionRunner.emit({ + type: "session_switch", + reason: "handoff", + previousSessionFile, + }); + } return { document: handoffText, savedPath }; } catch (error) { diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 3cb9a6288..5e1229aba 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -158,6 +158,90 @@ describe("AgentSession handoff", () => { expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0); }); + it("emits handoff lifecycle hooks on the outgoing and replacement sessions", async () => { + const extensionsResult = await loadExtensions([], tempDir.path()); + const extensionRunner = new ExtensionRunner( + extensionsResult.extensions, + extensionsResult.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + const observedEvents: Array<{ + type: "session_before_switch" | "session_switch"; + reason: string; + previousSessionFile: string | undefined; + activeSessionFile: string | undefined; + messageCount: number; + handoffEntryCount: number; + }> = []; + vi.spyOn(extensionRunner, "hasHandlers").mockImplementation(eventName => eventName === "session_before_switch"); + const emit = extensionRunner.emit.bind(extensionRunner); + vi.spyOn(extensionRunner, "emit").mockImplementation(event => { + if (event.type === "session_before_switch" || event.type === "session_switch") { + observedEvents.push({ + type: event.type, + reason: event.reason, + previousSessionFile: event.type === "session_switch" ? event.previousSessionFile : undefined, + activeSessionFile: session.sessionFile, + messageCount: sessionManager.getBranch().filter(entry => entry.type === "message").length, + handoffEntryCount: sessionManager + .getBranch() + .filter(entry => entry.type === "custom_message" && entry.customType === "handoff").length, + }); + } + return emit(event); + }); + + await session.dispose(); + session = new AgentSession({ + agent: new Agent({ + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + }), + sessionManager, + settings: Settings.isolated({ + "compaction.enabled": true, + "compaction.autoContinue": false, + }), + modelRegistry, + extensionRunner, + obfuscator, + }); + const previousSessionFile = session.sessionFile; + const generateHandoffSpy = vi + .spyOn(compactionModule, "generateHandoffFromContext") + .mockResolvedValue("## Goal\nContinue from here"); + + await session.handoff(); + + const nextSessionFile = session.sessionFile; + expect(generateHandoffSpy).toHaveBeenCalledTimes(1); + expect(nextSessionFile).not.toBe(previousSessionFile); + expect(observedEvents).toEqual([ + { + type: "session_before_switch", + reason: "handoff", + previousSessionFile: undefined, + activeSessionFile: previousSessionFile, + messageCount: 2, + handoffEntryCount: 0, + }, + { + type: "session_switch", + reason: "handoff", + previousSessionFile, + activeSessionFile: nextSessionFile, + messageCount: 0, + handoffEntryCount: 1, + }, + ]); + }); + it("runs handoff generation through the configured side stream function", async () => { const handoffText = "## Goal\nContinue via side stream"; let sideStreamCalls = 0;