Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
29cb4b6
fix(opencode): report OpenCode 2 status from each pane's TUI
brennanb2025 Sep 28, 2026
14948dc
fix(opencode): settle missed OpenCode 2 turn ends and order TUI installs
brennanb2025 Sep 29, 2026
2ff1fcc
fix(opencode): reconcile OpenCode 2 TUI status only once queued event…
brennanb2025 Sep 29, 2026
6db4708
fix(opencode): keep OpenCode 2 TUI panes silent on plugin dispose
brennanb2025 Sep 29, 2026
85e9b7b
fix(opencode): refresh installed OpenCode status plugins at app start
brennanb2025 Sep 29, 2026
7ae65a9
fix(opencode): derive each OpenCode 2 pane's status from its TUI sess…
brennanb2025 Sep 29, 2026
59ef3ec
fix(opencode): keep an OpenCode 2 pane on Needs input while its root …
brennanb2025 Sep 29, 2026
a0307fc
fix(opencode): leave OpenCode 2 step events to the server path's own …
brennanb2025 Sep 29, 2026
1e05e1e
fix(opencode): reset an OpenCode 2 pane to idle when its TUI starts
brennanb2025 Sep 29, 2026
218e66a
fix(opencode): keep OpenCode 1 serve + attach sessions on the attachi…
brennanb2025 Sep 29, 2026
38903a9
fix(opencode): show OpenCode 2 `opencode run` as Working, then Done, …
brennanb2025 Sep 30, 2026
d7a2b5b
fixup! fix(opencode): show OpenCode 2 `opencode run` as Working, then…
brennanb2025 Sep 30, 2026
c8e37a8
fix(opencode): keep an `opencode run` pane's Done after the command e…
brennanb2025 Sep 30, 2026
45a0661
fix(opencode): let an `opencode run` revive a pane Orca retired
brennanb2025 Sep 30, 2026
d8eb1ea
fix(opencode): report `opencode run` on local Windows panes too
brennanb2025 Sep 30, 2026
19e732e
fix(opencode): re-read an `opencode run` foreground on the pane track…
brennanb2025 Sep 30, 2026
57a83ae
fix(opencode): end an armed `opencode run` when the next command starts
brennanb2025 Sep 30, 2026
362b2c1
fix(opencode): skip the session-start row inside an OpenCode 1 `run` …
brennanb2025 Sep 30, 2026
a868d49
chore(opencode): say where the plugin's opencodeMajor comes from
brennanb2025 Sep 30, 2026
ca40d52
test(opencode): type the late-Done pane fixture without bare casts
brennanb2025 Sep 30, 2026
f69636d
fix(opencode): keep `opencode run` silent when OpenCode status is tur…
brennanb2025 Sep 30, 2026
51ebfbb
test(opencode): read the late-Done row without an untyped property ac…
brennanb2025 Sep 30, 2026
5bb3190
perf(opencode): read a command's foreground only while it could becom…
brennanb2025 Sep 30, 2026
565feb7
fix(opencode): let a new hook turn end a pane's process-exit completion
brennanb2025 Sep 30, 2026
6568aa6
Merge remote-tracking branch 'origin/main' into brennanb2025/opencode…
brennanb2025 Sep 30, 2026
7b2d326
refactor(opencode): share the foreground read schedule as one value
brennanb2025 Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 101 additions & 20 deletions src/main/agent-hooks/server-opencode-binder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,25 @@ class BinderTestServer extends AgentHookServer {
this.startOpenCodeBinderLoop()
}

public ingest(source: AgentHookSource, body: unknown): void {
this.normalizeLocalHookPayload(source, body)
public ingest(source: AgentHookSource, body: unknown): string | undefined {
return this.normalizeLocalHookPayload(source, body).event?.paneKey
}

public readRegistry(sessionId: string): string | undefined {
return lookupOpenCodeSessionPane(this._getStateForTests(), sessionId)?.paneKey
}
}

function writeDb(dbPath: string, table: 'session_v2' | 'session'): void {
function writeDb(dbPath: string, table: 'session_v2' | 'session', id = 'ses_live'): void {
const db = new SyncDatabase(dbPath)
try {
db.exec(
`CREATE TABLE ${table} (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)`
`CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_created INTEGER NOT NULL, parent_id TEXT)`
)
const insert = db.prepare(
`INSERT INTO ${table} (id, directory, time_created, parent_id) VALUES (?, ?, ?, ?)`
)
insert.run('ses_live', DIR, Date.now() - 60_000, null)
insert.run(id, DIR, Date.now() - 60_000, null)
} finally {
db.close()
}
Expand Down Expand Up @@ -85,22 +85,21 @@ describe('opencode binder loop', () => {
rmSync(dir, { recursive: true, force: true })
})

it('binds a fresh session to its pane', async () => {
writeDb(dbPath, 'session_v2')
it('binds a fresh OpenCode 1 session to its pane', async () => {
writeDb(dbPath, 'session')
const applied = await server.runBinderRound()
expect(applied).toBe(1)
expect(server.readRegistry('ses_live')).toBe(PANE_A)
})

it('falls back to the v1 session table', async () => {
writeDb(dbPath, 'session')
const applied = await server.runBinderRound()
expect(applied).toBe(1)
expect(server.readRegistry('ses_live')).toBe(PANE_A)
it('never binds an OpenCode 2 session', async () => {
writeDb(dbPath, 'session_v2')
expect(await server.runBinderRound()).toBe(0)
expect(server.readRegistry('ses_live')).toBeUndefined()
})

it('an opencode SessionStart kicks a round that binds before the poll', async () => {
writeDb(dbPath, 'session_v2')
writeDb(dbPath, 'session')
vi.useFakeTimers()
try {
// Birth arrives stamped with the wrong (server-starter) pane.
Expand All @@ -117,21 +116,40 @@ describe('opencode binder loop', () => {
}
})

it('an OpenCode 2 SessionStart kicks no round', async () => {
writeDb(dbPath, 'session')
const sweep = vi.fn(async () => [])
server.bindDeps({ sweep })
vi.useFakeTimers()
try {
server.ingest('opencode', {
paneKey: PANE_B,
launchToken: '',
opencodeMajor: 2,
payload: { hook_event_name: 'SessionStart', sessionID: 'ses_live' }
})
await vi.advanceTimersByTimeAsync(10_000)
expect(sweep).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})

it('pane teardown unbinds its sessions', async () => {
writeDb(dbPath, 'session_v2')
writeDb(dbPath, 'session')
await server.runBinderRound()
expect(server.readRegistry('ses_live')).toBe(PANE_A)
server.clearPaneState(PANE_A)
expect(server.readRegistry('ses_live')).toBeUndefined()
})

it('stops the loop without hanging the process', () => {
writeDb(dbPath, 'session_v2')
writeDb(dbPath, 'session')
expect(() => server.stop()).not.toThrow()
})

it('runs a round immediately on loop start', async () => {
writeDb(dbPath, 'session_v2')
writeDb(dbPath, 'session')
server.startBinderLoop()
try {
await vi.waitFor(() => expect(server.readRegistry('ses_live')).toBe(PANE_A))
Expand All @@ -141,7 +159,7 @@ describe('opencode binder loop', () => {
})

it('discards a round that was in flight across stop', async () => {
writeDb(dbPath, 'session_v2')
writeDb(dbPath, 'session')
let releaseSweep!: () => void
const sweepGate = new Promise<void>((resolve) => {
releaseSweep = resolve
Expand All @@ -168,7 +186,7 @@ describe('opencode binder loop', () => {
})

it('an obsolete round does not clear the new round running flag', async () => {
writeDb(dbPath, 'session_v2')
writeDb(dbPath, 'session')
let releaseFirst!: () => void
let releaseLater!: () => void
const firstGate = new Promise<void>((resolve) => {
Expand Down Expand Up @@ -212,6 +230,61 @@ describe('opencode binder loop', () => {
})
})

// OpenCode 1 `serve` in pane A stamps every post with pane A; `attach` in pane B drives the session.
describe('OpenCode 1 serve + attach', () => {
let dir = ''
let dbPath = ''
let server: BinderTestServer

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'binder-attach-'))
dbPath = join(dir, 'opencode.db')
writeDb(dbPath, 'session')
const startedAtMs = Date.now() - 120_000
server = new BinderTestServer()
server.bindDeps({
now: () => Date.now(),
dbPath: () => dbPath,
listPanes: () => [
{ paneKey: PANE_A, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111 },
{ paneKey: PANE_B, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 211 }
],
sweep: async () => [
{ pid: 112, ppid: 111, startedAtMs, executable: 'opencode', argv: ['opencode', 'serve'] },
{
pid: 212,
ppid: 211,
startedAtMs,
executable: 'opencode',
argv: ['opencode', 'attach', 'http://127.0.0.1:4096']
}
]
})
})

afterEach(() => {
server.stop()
rmSync(dir, { recursive: true, force: true })
})

const busy = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
paneKey: PANE_A,
launchToken: '',
...extra,
payload: { hook_event_name: 'SessionBusy', sessionID: 'ses_live' }
})

it("reports the session on the attaching pane, not the server's", async () => {
expect(await server.runBinderRound()).toBe(1)
expect(server.ingest('opencode', busy())).toBe(PANE_B)
})

it('never moves an OpenCode 2 post with the same shape', async () => {
await server.runBinderRound()
expect(server.ingest('opencode', busy({ opencodeMajor: 2 }))).toBe(PANE_A)
})
})

describe('listOpenCodeDbSessions', () => {
let dir = ''
let dbPath = ''
Expand All @@ -225,14 +298,22 @@ describe('listOpenCodeDbSessions', () => {
rmSync(dir, { recursive: true, force: true })
})

it('reads session_v2 rows newer than the watermark', () => {
writeDb(dbPath, 'session_v2')
it('reads OpenCode 1 session rows newer than the watermark', () => {
writeDb(dbPath, 'session')
const rows = listOpenCodeDbSessions(dbPath, { ms: 0, id: '' })
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ id: 'ses_live', directory: DIR, parentId: null })
expect(listOpenCodeDbSessions(dbPath, { ms: Date.now(), id: '' })).toEqual([])
})

it('skips OpenCode 2 rows in a database both versions wrote', () => {
writeDb(dbPath, 'session', 'ses_v1')
writeDb(dbPath, 'session_v2', 'ses_v2')
expect(listOpenCodeDbSessions(dbPath, { ms: 0, id: '' }).map((row) => row.id)).toEqual([
'ses_v1'
])
})

it('returns [] for a missing database instead of throwing', () => {
expect(listOpenCodeDbSessions(join(dir, 'absent.db'), { ms: 0, id: '' })).toEqual([])
})
Expand Down
112 changes: 112 additions & 0 deletions src/main/agent-hooks/server-process-lifetime-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { AgentHookServer } from './server'
import { buildBody, PANE, postHookEvent } from './server.test-fixtures'

let dir: string
let server: AgentHookServer

beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'orca-process-lifetime-'))
server = new AgentHookServer()
await server.start({ env: 'production', userDataPath: dir })
})

afterEach(() => {
server.stop()
rmSync(dir, { recursive: true, force: true })
})

function processLifetime(state: 'working' | 'done', yieldsToHookSince: number): void {
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
origin: 'process',
yieldsToHookSince,
payload: { state, prompt: '', agentType: 'opencode' }
})
}

async function openCodeHook(hookEventName: string): Promise<void> {
const response = await postHookEvent(
server,
buildBody({ hook_event_name: hookEventName, sessionID: 'ses_1' }),
'/hook/opencode'
)
expect(response.status).toBe(204)
}

function paneState(): string {
return server.getStatusSnapshotForPane(PANE)[0]?.state ?? 'missing'
}

async function nextMillisecond(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 5))
}

// One `opencode run` command in one pane: the host reports it from the process lifetime unless a
// hook producer (OpenCode 1's in-process plugin) reports the same pane during that command.
describe('process-lifetime status', () => {
it('reports Working, then Done, when no hook speaks for the command', () => {
const commandStartedAt = Date.now()
processLifetime('working', commandStartedAt)
expect(paneState()).toBe('working')
processLifetime('done', commandStartedAt)
expect(paneState()).toBe('done')
})

it('yields the rest of the command once a hook reports the pane', async () => {
const commandStartedAt = Date.now()
processLifetime('working', commandStartedAt)
await openCodeHook('SessionBusy')
// The hook is still Working, so the process exit must not write a second Done over it.
processLifetime('done', commandStartedAt)
expect(paneState()).toBe('working')
await openCodeHook('SessionIdle')
expect(paneState()).toBe('done')
})

it('writes no Working over a hook that already claimed the command', async () => {
const commandStartedAt = Date.now()
await openCodeHook('SessionIdle')
processLifetime('working', commandStartedAt)
expect(paneState()).toBe('done')
})

it('does not yield to a hook row from before the command started', async () => {
await openCodeHook('SessionIdle')
await nextMillisecond()
const commandStartedAt = Date.now()
processLifetime('working', commandStartedAt)
expect(paneState()).toBe('working')
processLifetime('done', commandStartedAt)
expect(paneState()).toBe('done')
})

// Why: a pane where an Orca-launched agent exited is retired; a new `opencode run` there is a new run.
it('revives a retired pane on its Working, as a hook new-turn event does', () => {
server.retirePaneAuthority(PANE)
const commandStartedAt = Date.now()
processLifetime('working', commandStartedAt)
expect(paneState()).toBe('working')
processLifetime('done', commandStartedAt)
expect(paneState()).toBe('done')
})

it('keeps OSC status and a lone process Done out of a retired pane', () => {
server.retirePaneAuthority(PANE)
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: null,
payload: { state: 'working', prompt: '', agentType: 'opencode' }
})
processLifetime('done', Date.now())
expect(paneState()).toBe('missing')
})
})
5 changes: 3 additions & 2 deletions src/main/agent-hooks/server/server-ingest-normalization.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { buildSpoolHookBody, type SpoolRecord } from '../../../shared/agent-hook-spool'
import { normalizeHookPayload } from '../../../shared/agent-hook-listener'
import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay'
import { isOpenCodeSharedServerPost } from '../../../shared/agent-hook-listener/opencode-session-registry'
import type { NormalizedLocalHook } from './server-types'
import { AgentHookServerOpenCodeBinder } from './server-opencode-binder'

Expand All @@ -27,8 +28,8 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer
const event = normalizeHookPayload(this.state, source, body, this.env)
if (
event &&
(source === 'opencode' || source === 'mimo-code') &&
event.hookEventName === 'SessionStart'
event.hookEventName === 'SessionStart' &&
isOpenCodeSharedServerPost(source, body)
) {
// Why: a birth just arrived; bind it now instead of waiting out the poll interval.
this.kickOpenCodeBinder()
Expand Down
Loading
Loading