Preserve OpenCode session across command completion, control SessionStart emission (#14866)

* Preserve OpenCode session across command completion

- Add session start events and launch token tracking to establish session boundaries
- Defer retiring launch authority until OpenCode process actually exits, not just when a command finishes
- Fence previous tokens after restarts to prevent status updates from stale sessions
- Maps SessionStart as a session boundary for proper turn/state management

* Emit SessionStart only from OpenCode, not mimo-code

Restrict SessionStart lifecycle events to OpenCode exclusively. Mimo-code no longer emits SessionStart, as it should rely on OpenCode for session boundary signals. This prevents duplicate lifecycle events that could interfere with pane authority tracking and session state management. Also tighten foreground process result validation to reject stale results after title observation changes, fixing a race where a delayed foreground read from a previous cycle would incorrectly retire authority.
This commit is contained in:
Jinjing
2026-08-16 10:01:27 -07:00
committed by GitHub
parent e4e54a17d0
commit 1da1bdc01c
10 changed files with 411 additions and 20 deletions
@@ -131,6 +131,22 @@ describe('OpenCode plugin lifecycle delivery', () => {
})
}
it('maps only root session.created to SessionStart', async () => {
const handler = await loadHandler()
await handler({
event: { type: 'session.created', properties: { info: { id: 'root' } } }
})
await handler({
event: {
type: 'session.created',
properties: { info: { id: 'child', parentID: 'root' } }
}
})
expect(posts).toEqual([{ hook_event_name: 'SessionStart', sessionID: 'root' }])
})
it('preserves FIFO lifecycle order while the first session lookup is delayed', async () => {
let releaseFirstLookup: (() => void) | undefined
const firstLookup = new Promise<void>((resolve) => {
+19 -2
View File
@@ -36,10 +36,13 @@ function toSafeDirName(id: string): string {
}
export function getOpenCodePluginSource(): string {
return getOpenCodeFamilyPluginSource('/hook/opencode')
return getOpenCodeFamilyPluginSource('/hook/opencode', { emitSessionStart: true })
}
export function getOpenCodeFamilyPluginSource(hookPathname: string): string {
export function getOpenCodeFamilyPluginSource(
hookPathname: string,
options: { emitSessionStart: boolean }
): string {
// Why: the plugin posts PTY environment data from OpenCode to the shared hooks server.
return [
'// Why: process-lifetime guard so a recurring parse error on a malformed',
@@ -835,6 +838,20 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string {
'',
' const sessionID = event.properties?.sessionID;',
' const updatedPart = event.properties?.part;',
...(options.emitSessionStart
? [
' if (event.type === "session.created") {',
' const info = event.properties?.info;',
' if (!info?.id || info.parentID) return;',
' rememberSessionRoot(info.id, info.id);',
' await enqueueLifecycle(() =>',
' disposed ? undefined : post("SessionStart", { sessionID: info.id })',
' );',
' return;',
' }',
''
]
: []),
' if (',
' event.type === "message.part.updated" &&',
' updatedPart?.type === "tool" &&',