fix(opencode): report OpenCode 2 status from each pane's own TUI (#23722)

* fix(opencode): report OpenCode 2 status from each pane's TUI

OpenCode 2 serves every pane from one shared server whose env names only
the pane that started it, so every same-folder pane's work showed on that
pane, and the plugin's single aggregate swallowed the Idle of a pane whose
turn ended while another pane was busy.

Install the status plugin a second time as an OpenCode 2 TUI plugin
(plugins/<name>-tui/tui.js, written only when its bytes differ, locally,
in overlays and in the SSH/WSL relay installs). In a TUI process setup()
runs the same engine, fed through the same event translation as the
server, but only for root sessions this pane owns: the route's session
from when it starts (or when the route reaches it while running, hydrated
from the TUI's session status) until it settles, is deleted, or the TUI
exits. The server plugin stands down in any serve process when the TUI
copy is installed beside it; a relay that predates the TUI copy keeps the
old behavior. OpenCode 1 loads no plugin directories and is unchanged.

Delete the session-to-pane binder, registry, client sweep and ingest
reattribution: with every post stamped by its own pane nothing is left
for them to correct.

Known gap: OpenCode 2 `opencode run` in a pane has no TUI, so it reports
no pane status.

* fix(opencode): settle missed OpenCode 2 turn ends and order TUI installs

- The TUI copy now settles an owned root whose run the TUI's session data
  reports ended, with no pending permission or form, when the engine still
  holds it busy. An execution end missed across a service restart or
  reconnect no longer leaves the pane Working until the TUI exits.
- The TUI copy stays idle without ORCA_PANE_KEY. post() cannot report without
  it, so OpenCode 2 TUIs outside Orca no longer run the route poll and engine.
- Installers write the TUI copy before the server plugin file. The server
  decides at load whether to stand down, so a reload between the two writes
  now finds the TUI copy.

* fix(opencode): reconcile OpenCode 2 TUI status only once queued events drain

The TUI's session data applies each event before this plugin's queued handling
reaches it, so settling against it mid-backlog published a false Done before a
fast turn's later steps (Working, Done, Working, Done). Reconcile only when no
event is queued; a mismatch then is a start or end missed across a reconnect,
and both directions are now re-derived (a missed start left the pane on Done).

Also install the TUI copy beside the server plugin in the retired shared hooks
dir, so a TUI or service still loading that dir reports per pane instead of
leaving the service reporting under its starter pane.

* fix(opencode): keep OpenCode 2 TUI panes silent on plugin dispose

A TUI plugin hot reload disposes the plugin while the pane's turn keeps
running. The server path already passes sessionsOutliveDispose so dispose
publishes nothing; the TUI adapter now does the same, or every TUI reload
would still show a false Done. Also refreshes the generated-bytes digest
after rebasing onto the write-if-changed installers.

* fix(opencode): refresh installed OpenCode status plugins at app start

After an Orca upgrade, an OpenCode 2 service that was already running kept
the previous plugin, and with it the old wrong-pane status, until any new
terminal pane rewrote the file. OpenCode 2 reloads a plugin whose file
changes, so Orca now refreshes its existing installs once after the first
window shows: the global config dir, source overlays and the retired shared
dir, TUI copy first. It reuses the per-pane writers, which skip unchanged
files, never creates an install the user did not have, and honours the
status-hook and per-agent switches. An SSH relay does the same for its
canonical install when Orca connects and ships the plugin sources.

The plugin source assembly moves to its own module (re-exported unchanged)
to keep hook-service.ts under the line limit.

* fix(opencode): derive each OpenCode 2 pane's status from its TUI session data

The TUI copy of the status plugin translated OpenCode events into the
server-side engine and then patched the engine's latches back toward the
TUI's own session data: a settle on every tick, a queued-event counter, a
re-assert, synthetic Busy and Idle. Each review found another place where
the two copies disagreed: a missed end left the pane Working, a backlog of
slow posts flickered Done, a missed start showed Done mid-turn, a turn held
only by a subagent never settled, and a request answered while disconnected
pinned Needs input.

The TUI reporter now reads the pane's level straight from the session data
OpenCode keeps current (and re-hydrates on reconnect) on every event and on
a 100 ms tick: for each root session this pane owns, Needs input (an open
permission, else form, anywhere in its family while it runs) outranks
Working (any family member running) outranks Done. It posts one status per
level change through the plugin's existing delivery functions (retry,
dedupe, message-part throttle, ordering), so the wire and Orca's ingest are
unchanged. Ownership is kept in OpenCode's storage.memory, which survives a
plugin hot reload, so a turn that ends during a reload still shows Done;
dispose publishes nothing, and a level the old generation could not deliver
is re-posted by the next. On reconnect it re-syncs blockers for the owned
sessions OpenCode would not re-sync itself, ignoring requests already
answered. Events are handled synchronously, so a slow post can no longer
hold up event processing. The server path, OpenCode 1 and mimo are
unchanged.

* fix(opencode): keep an OpenCode 2 pane on Needs input while its root streams text

Orca treats every OpenCode MessagePart as Working. While a background
subagent waits on a permission or form, the root session can keep streaming
reply text, and the TUI reporter forwarded that text as a MessagePart. The
pane then flipped from Needs input to Working, and nothing restored it until
the level changed, so the user could miss the open request.

Skip reply text while the pane's level is Needs input, as the reporter
already does for queued prompts.

* fix(opencode): leave OpenCode 2 step events to the server path's own change

The shared-server translation re-derived Working from session.step.started.
The pane reporter no longer uses it, so it only changed the server path and
duplicated a separate open change. Drop it; server behaviour matches main.

* fix(opencode): reset an OpenCode 2 pane to idle when its TUI starts

Before this change, a pane could keep a status an earlier process left on
it. The case that matters is an upgrade mid-turn: the old shared-service
plugin posted pane B's turn under pane A's key, then stood down, and pane
A's TUI loaded the new reporter owning nothing, so it never posted and A
showed a wrong Working until its own next turn.

A freshly started reporter that owns no running turn now posts the host's
existing session-start boundary once, which the host shows as connected
idle: no completion, no notification, and an unseen Done it lands on stays
unread. A plugin hot reload keeps its memory and skips it, and where
OpenCode keeps no plugin memory it is never sent.

* fix(opencode): keep OpenCode 1 serve + attach sessions on the attaching pane

OpenCode 1 `opencode serve` in one pane plus `opencode attach` in others
reports every session from the serve process, whose environment names only
the serve pane. Before this branch the session-to-pane binder moved those
posts to the attaching pane; deleting it for OpenCode 2 put them back on the
serve pane.

Restore the binder, registry, correlation and client sweep for OpenCode 1
(and mimo-code, which it also served), gated so OpenCode 2 never uses it:

- The status plugin now sends `opencodeMajor: 2` on every post from a process
  whose loader called setup(), which only OpenCode 2 does. The host skips the
  binder (no rewrite, no kicked round) for any post that carries it. OpenCode 1
  and older plugins send nothing and keep the previous behaviour.
- The binder reads only OpenCode 1's `session` table. OpenCode 2 writes
  `session_v2`, so its sessions never bind; on a database both versions wrote,
  OpenCode 1 sessions are no longer hidden behind the v2 table.

OpenCode-1-only; it goes when OpenCode 1 support is removed.

* fix(opencode): show OpenCode 2 `opencode run` as Working, then Done, on its own pane

OpenCode 2's `opencode run` loads no plugin, and the shared service that
runs its session cannot tell which pane it belongs to, so a pane running it
showed nothing.

The runtime now reports it from the pane's own process lifetime. On the
pane's OSC 133 command start (main already parses 133 for every local PTY;
the start callback was never wired), after the pane tracker's 350 ms settle
it reads the foreground process name through the existing foreground
reader, and only when that name is OpenCode, the foreground command line
from one fresh shell-foreground process-table capture. An `opencode run`
posts Working through the existing terminal-status path into the hook
server's store; the pane's 133;D (or the daemon's background fact) posts
Done, marked interrupted on Ctrl-C. No polling.

Exclusive with plugin reporting: each write carries the command's start
time, and the store drops it once a hook has written the pane since then,
so an OpenCode 1 `run` (in-process plugin) or a server plugin without the
TUI copy owns its command alone and there is never a second Done.

Local macOS and Linux panes only; SSH, WSL and Windows panes stay silent
because their foreground cannot be read on this host.

* fixup! fix(opencode): show OpenCode 2 `opencode run` as Working, then Done, on its own pane

Type the selected descendants as process-table rows; ReturnType of the generic collector widened them to bare identity rows.

* fix(opencode): keep an `opencode run` pane's Done after the command exits

The run's Done was published inside the chunk that carried its OSC 133;D,
before the chunk's command-finished fact. The renderer drops an exited
agent's row on command-finished when the row has not changed since that
fact arrived, so it took the Done as the stale row and dropped it, in the
renderer and in main.

Publish the run's Done after the chunk's side-effect facts are emitted (and
after the daemon's background fact). The renderer then sees command-finished
while the row is still Working, and the Done that follows counts as a change,
so it stays, the same way a hook Done that lands after exit already does.

* fix(opencode): let an `opencode run` revive a pane Orca retired

When an agent Orca launched exits, command completion retires the pane, and
only a hook new-turn event revived it. A later `opencode run` in that pane
posts no hook, so its process-lifetime Working was refused and the pane
stayed silent.

A process-lifetime Working is posted only after a fresh OSC 133;C and the
pane's own foreground argv prove a new OpenCode run, which is at least as
strong as a hook new turn. The store now revives the retired pane on it the
same way: it clears the retirement, drops the launch-token fence, and rebinds
the observation. OSC status and a lone process Done still cannot write a
retired pane, and a closed tab stays closed.

* fix(opencode): report `opencode run` on local Windows panes too

The run producer skipped every Windows pane, although Orca already resolves
a Windows pane's foreground agent from the native process table. On main an
OpenCode 2 `run` showed there (on the service's pane); on this branch it was
silent.

The argv read now has a Windows branch: one fresh native table read
(windows-process-table, no interpreter spawn), the same foreground identity
the Windows resolver already computes, and the command line of the process
that identity names. It runs only after the name read says OpenCode. Local
Windows panes whose shell prints OSC 133 C/D (PowerShell with PSReadLine,
Git Bash with Orca's wrapper) now go Working, then Done; cmd.exe prints no
markers and stays silent. SSH and WSL panes stay silent.

* fix(opencode): re-read an `opencode run` foreground on the pane tracker's ladder

The producer read the pane's foreground once, 350 ms after the command
started. A wrapper, a shim or `sleep 1; opencode run` execs OpenCode later,
so those runs stayed silent. The renderer's pane tracker already re-reads a
command's foreground at 350, then 1200, then 6000 ms for exactly this.

Move those delays to one shared module and use it from both. The producer
re-reads only while the foreground is a non-shell process that is not
OpenCode, and at most on those three rungs; no new polling.

* fix(opencode): end an armed `opencode run` when the next command starts

A new OSC 133;C in a pane whose `opencode run` was still armed dropped the
armed state without a Done, so a run whose 133;D never arrived left the pane
on Working. A new command start proves the previous command ended, so it now
posts that run's Done (not marked interrupted: no exit code is known) before
the new command is inspected.

* fix(opencode): skip the session-start row inside an OpenCode 1 `run` process

OpenCode 1 `run` loads the status plugin in its own process, and the plugin
posts SessionStart when the run's session is created. The host lands that as
an idle session boundary, so a pane the run producer had already shown as
Working blinked idle before the plugin's Busy.

The plugin now skips SessionStart when its own process is a `run`, read from
its argv the same way isOpenCodeRunCommand reads a pane's foreground (first
positional after global options, past the compiled binary's entry path). A
`run` session goes Busy at once, and its first prompt part still revives a
retired pane and resets the turn caches. The TUI and `serve` still post it,
and OpenCode 2's `run` loads no plugin.

* chore(opencode): say where the plugin's opencodeMajor comes from

The field is set when OpenCode's loader calls setup(), which only OpenCode 2
does; the plugin never reads OpenCode's version. Say so at the field. Comment
only; the generated plugin is unchanged.

* test(opencode): type the late-Done pane fixture without bare casts

The new renderer test passed its fixtures with `as never`; use the checked
fixture-tuple cast with a SAFETY note, like the sibling pty-connection tests.

* fix(opencode): keep `opencode run` silent when OpenCode status is turned off

The run producer ignored the status-hooks switch, so a user who turned
status off globally or for OpenCode (#23667) still got a run's Working and
Done. It now checks isAgentStatusHooksEnabledForAgent for the run's agent
(opencode or opencode2), the same predicate the plugin install honours,
before reading argv or posting anything.

* test(opencode): read the late-Done row without an untyped property access

The mock store types agentStatusByPaneKey values as unknown, so reading
`.state` failed tc:web. Assert the row with toMatchObject instead.

* perf(opencode): read a command's foreground only while it could become `opencode run`

Two costs the run producer paid on every command in every local pane:

- The retry ladder re-read the foreground (a process-table capture on the
  local provider) for any non-shell program still running, including other
  agents, editors and dev servers, up to three times. It now re-reads only
  while the foreground is still the shell (the command has not exec'd yet)
  or an unrecognised launcher that may still exec OpenCode (node, bun, bunx,
  npx, npm, pnpm, pnpx, yarn). Any other program is read once.
- With OpenCode status turned off for both opencode and opencode2, it still
  set the timer and read the foreground only to discard the result. It now
  returns before any timer or read. The per-agent check after the name read
  stays for the case where only one of them is off.

* fix(opencode): let a new hook turn end a pane's process-exit completion

A confirmed agent process exit records a pane-wide completion identity that
names only the agent. Hook Dones are matched against it by agent alone, and
only a working title cleared it, so in a pane whose agent paints no working
title (OpenCode) every later hook-reported Done was treated as already
notified: no notification and no unread mark. An unreported exit (a status-off
`opencode run`, or quitting an idle client) was enough to set it.

A fresh hook Working now clears a process-exit identity, since a new turn
cannot be a duplicate of an earlier exit. Hook identities stay, so
same-turn and replay dedupe is unchanged.

* refactor(opencode): share the foreground read schedule as one value

The pane tracker's import of the two shared foreground-read delays took
four lines where its old local constants took three, which put the file
one line over max-lines once merged with main. Export the settle delay
and retry ladder as one object so each consumer imports a single name.
No behaviour change: same 350 ms settle and 1200/6000 ms retries.
This commit is contained in:
Brennan Benson
2026-09-30 15:37:18 -07:00
committed by GitHub
parent 8dab53fb5a
commit 3727100cc9
59 changed files with 3275 additions and 187 deletions
@@ -205,6 +205,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
expect(posts).toEqual(
expect.arrayContaining([
expect.objectContaining({
opencodeMajor: 2,
payload: expect.objectContaining({ hook_event_name: 'SessionBusy' })
}),
expect.objectContaining({
@@ -222,6 +223,33 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag
expect(subscriptionSignal?.aborted).toBe(true)
})
// Why: the host's OpenCode 1 session binder must stay off OpenCode 2 posts only.
it('declares no OpenCode major on posts from the OpenCode 1 server() entry', async () => {
process.env.ORCA_PANE_KEY = 'tab-1:leaf-1'
const bodies: Record<string, unknown>[] = []
globalThis.fetch = vi.fn(async (_input, init) => {
bodies.push(record(JSON.parse(String(init?.body))) ?? {})
return new Response('{}', { status: 200 })
})
const module = await loadPluginModule(
agent === 'opencode2'
? _internals.getOpenCode2PluginSource()
: _internals.getOpenCodePluginSource()
)
const hooks = await module.default?.server?.({
client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } }
})
await hooks?.event({
event: {
type: 'session.status',
properties: { sessionID: 'ses_root', status: { type: 'busy' } }
}
})
await vi.waitFor(() => expect(bodies.length).toBeGreaterThan(0))
expect(bodies.filter((body) => 'opencodeMajor' in body)).toEqual([])
await hooks?.dispose?.()
})
it('maps permission, form, and text events through the live setup bridge', async () => {
process.env.ORCA_PANE_KEY = 'tab-1:leaf-1'
const posts: { body: PostBody }[] = []
@@ -0,0 +1,772 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>()
}))
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
import { _internals } from './hook-service'
type Post = {
paneKey?: string
opencodeMajor?: number
payload?: { hook_event_name?: string; sessionID?: string }
}
type BusEvent = { type: string; data: Record<string, unknown> }
type PluginModule = {
default?: { setup?: (ctx: unknown) => Promise<(() => Promise<void>) | undefined> }
}
const PANE_A = 'tabA:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const PANE_B = 'tabB:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const SES_A = 'ses_f161fd85fffeieT0zYt80ZKorS'
const SES_B = 'ses_f161fcd7affeffV0OOrMCrTxtQ'
const ENV_KEYS = [
'ORCA_PANE_KEY',
'ORCA_OPENCODE_AGENT',
'ORCA_AGENT_HOOK_ENDPOINT',
'ORCA_AGENT_HOOK_PORT',
'ORCA_AGENT_HOOK_TOKEN'
] as const
// Event shapes as OpenCode 2.0.14 delivers them to both server and TUI plugins.
function turn(sessionID: string, text: string): { start: BusEvent[]; finish: BusEvent[] } {
const assistantMessageID = `msg_${sessionID}_a`
return {
start: [
{
type: 'session.created',
data: { sessionID, projectID: 'global', location: { directory: '/proj' }, subpath: '' }
},
{
type: 'session.inbox.enqueued',
data: {
sessionID,
inboxID: `msg_${sessionID}_u`,
item: { type: 'user', payload: { text, files: [] }, delivery: 'steer' }
}
},
{ type: 'session.execution.started', data: { sessionID } },
{ type: 'session.step.started', data: { sessionID, assistantMessageID, agent: 'build' } },
{ type: 'session.text.started', data: { sessionID, assistantMessageID, ordinal: 0 } }
],
finish: [
{
type: 'session.text.ended',
data: { sessionID, assistantMessageID, ordinal: 0, text: 'tick0 tick1 ' }
},
{ type: 'session.step.ended', data: { sessionID, assistantMessageID, finish: 'stop' } },
{ type: 'session.execution.succeeded', data: { sessionID } }
]
}
}
type Blocker = { id: string; sessionID: string; [key: string]: unknown }
function toBlocker(value: unknown): Blocker {
const record = typeof value === 'object' && value !== null ? { ...value } : {}
const id = 'id' in record ? String(record.id) : ''
const sessionID = 'sessionID' in record ? String(record.sessionID) : ''
return { ...record, id, sessionID }
}
/** One pane's TUI: its route, its view of the shared session store, and the shared event bus. */
function fakeTui(version = '2.0.14') {
const listeners = new Set<(event: { details: BusEvent }) => void>()
const sessions = new Map<string, { id: string; parentID?: string }>()
const running = new Set<string>()
const permissions = new Map<string, Blocker[]>()
const forms = new Map<string, Blocker[]>()
// What the server answers when the TUI re-fetches a permission list (reconnect).
const serverPermissions = new Map<string, Blocker[]>()
let permissionFetch: Promise<void> = Promise.resolve()
// Why: OpenCode keeps storage.memory across plugin hot reloads within one TUI process.
const memories = new Map<string, unknown>()
let route: { type: string; sessionID?: string } = { type: 'home' }
const rootOf = (id: string): string => {
let current = sessions.get(id)
while (current?.parentID && sessions.has(current.parentID)) {
current = sessions.get(current.parentID)
}
return current?.id ?? id
}
const without = (map: Map<string, Blocker[]>, sessionID: string, id: unknown): void => {
map.set(
sessionID,
(map.get(sessionID) ?? []).filter((item) => item.id !== id)
)
}
const listen = vi.fn((handler: (event: { details: BusEvent }) => void) => {
listeners.add(handler)
return () => listeners.delete(handler)
})
const ctx = {
app: { version, channel: 'latest' },
ui: { router: { current: () => route } },
storage: {
memory: (key: string, options: { initial: Record<string, unknown> }) => {
if (!memories.has(key)) {
const value = structuredClone(options.initial)
memories.set(key, [value, (mutate: (draft: typeof value) => void) => mutate(value)])
}
return memories.get(key)
}
},
client: {
session: { get: async ({ sessionID }: { sessionID: string }) => sessions.get(sessionID) }
},
data: {
listen,
session: {
get: (id: string) => sessions.get(id),
root: rootOf,
family: (id: string) =>
[...sessions.keys()].filter((member) => rootOf(member) === rootOf(id)),
status: (id: string) => (running.has(id) ? 'running' : 'idle'),
permission: {
list: (id: string) => permissions.get(id),
sync: async (id: string) => {
await permissionFetch
permissions.set(id, [...(serverPermissions.get(id) ?? [])])
}
},
form: { list: (id: string) => forms.get(id), sync: async () => {} }
}
}
}
return {
ctx,
listen,
serverPermissions,
permissions,
navigate(sessionID: string) {
route = { type: 'session', sessionID }
},
// The session data stops reporting a run without this TUI seeing its end event.
loseEnd(sessionID: string) {
running.delete(sessionID)
},
// The session data reports a run whose start this TUI never saw.
loseStart(sessionID: string) {
running.add(sessionID)
},
// Holds the next permission list fetch until the returned release is called.
holdPermissionFetch(): () => void {
let release = (): void => {}
permissionFetch = new Promise((resolve) => {
release = resolve
})
return release
},
// Applies an event to the session data first, then to plugin listeners, as OpenCode does.
emit(event: BusEvent) {
const sessionID = String(event.data.sessionID)
if (event.type === 'session.created') {
const parentID = typeof event.data.parentID === 'string' ? event.data.parentID : undefined
sessions.set(sessionID, { id: sessionID, parentID })
} else if (event.type === 'session.execution.started') {
running.add(sessionID)
} else if (event.type === 'session.execution.succeeded') {
running.delete(sessionID)
} else if (event.type === 'permission.asked') {
permissions.set(sessionID, [...(permissions.get(sessionID) ?? []), toBlocker(event.data)])
} else if (event.type === 'permission.replied') {
without(permissions, sessionID, event.data.requestID)
} else if (event.type === 'form.created') {
const form = toBlocker(event.data.form)
forms.set(form.sessionID, [...(forms.get(form.sessionID) ?? []), form])
} else if (event.type === 'form.replied' || event.type === 'form.cancelled') {
without(forms, sessionID, event.data.id)
}
for (const handler of listeners) {
handler({ details: event })
}
}
}
}
describe('OpenCode 2 TUI reporter: each pane reports its own sessions', () => {
let tempDir: string
let savedFetch: typeof globalThis.fetch
let savedEnv: Record<string, string | undefined>
let savedArgv: string[]
let posts: Post[]
// Every post in arrival order, including the start boundary each fresh TUI lands.
let allPosts: Post[]
let failPosts: boolean
let postDelayMs: number
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-tui-adapter-'))
savedFetch = globalThis.fetch
savedArgv = process.argv
savedEnv = {}
for (const key of ENV_KEYS) {
savedEnv[key] = process.env[key]
}
process.env.ORCA_OPENCODE_AGENT = 'opencode'
delete process.env.ORCA_AGENT_HOOK_ENDPOINT
process.env.ORCA_AGENT_HOOK_PORT = '59999'
process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token'
posts = []
allPosts = []
failPosts = false
postDelayMs = 0
globalThis.fetch = vi.fn(async (_input, init) => {
const body = JSON.parse(String(init?.body))
if (postDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, postDelayMs))
}
if (failPosts) {
return new Response('{}', { status: 500 })
}
allPosts.push(body)
// Why apart: a fresh TUI's start boundary names no session; per-session cases assert what follows it.
if (
body.payload?.hook_event_name !== 'SessionStart' ||
body.payload.sessionID !== undefined
) {
posts.push(body)
}
return new Response('{}', { status: 200 })
})
})
afterEach(() => {
globalThis.fetch = savedFetch
process.argv = savedArgv
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = savedEnv[key]
}
}
rmSync(tempDir, { recursive: true, force: true })
})
async function loadPlugin(dir = tempDir): Promise<PluginModule> {
// Why: a unique basename per load defeats the ESM module cache, like a separate process.
const pluginPath = join(dir, `orca-opencode-status-${Math.random().toString(36).slice(2)}.mjs`)
writeFileSync(pluginPath, _internals.getOpenCodePluginSource())
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated module's default export is exercised below and fails the test if absent.
return (await import(pathToFileURL(pluginPath).href)) as PluginModule
}
const summary = (list: Post[]): string[] =>
list.map((post) => `${post.payload?.hook_event_name}:${post.payload?.sessionID}`)
async function runPane(
paneKey: string,
ownSession: string,
script: (tui: ReturnType<typeof fakeTui>) => Promise<void>
): Promise<Post[]> {
process.env.ORCA_PANE_KEY = paneKey
const start = posts.length
const tui = fakeTui()
const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx)
await script(tui)
await vi.waitFor(() => {
expect(summary(posts.slice(start)).at(-1)).toBe(`SessionIdle:${ownSession}`)
})
await cleanup?.()
return posts.slice(start)
}
const tick = (ms = 20): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
const pump = async (tui: ReturnType<typeof fakeTui>, events: BusEvent[]): Promise<void> => {
for (const event of events) {
tui.emit(event)
await tick()
}
}
// Captured s2 shape: pane A's long turn overlapped by pane B's short one on one server.
it('gives each overlapping pane only its own session and its own Idle', async () => {
const a = turn(SES_A, 'A long SLEEP=12')
const b = turn(SES_B, 'B short SLEEP=3')
const bus = [...a.start, ...b.start, ...b.finish, ...a.finish]
const paneA = await runPane(PANE_A, SES_A, async (tui) => {
tui.navigate(SES_A)
await pump(tui, bus)
})
const paneB = await runPane(PANE_B, SES_B, async (tui) => {
await pump(tui, [...a.start])
tui.navigate(SES_B)
await pump(tui, [...b.start, ...b.finish, ...a.finish])
})
expect(paneA.every((post) => post.paneKey === PANE_A)).toBe(true)
expect(paneB.every((post) => post.paneKey === PANE_B)).toBe(true)
// Keeps the host's OpenCode 1 session binder off these posts.
expect([...paneA, ...paneB].every((post) => post.opencodeMajor === 2)).toBe(true)
expect(new Set(paneA.map((post) => post.payload?.sessionID))).toEqual(new Set([SES_A]))
expect(new Set(paneB.map((post) => post.payload?.sessionID))).toEqual(new Set([SES_B]))
for (const [list, session] of [
[paneA, SES_A],
[paneB, SES_B]
] as const) {
const names = summary(list)
expect(names[0]).toBe(`SessionStart:${session}`)
expect(names).toContain(`SessionBusy:${session}`)
expect(names.at(-1)).toBe(`SessionIdle:${session}`)
}
})
// Captured s5 shape: the TUI's route reached the new session after execution had started.
it('hydrates a session whose route switch lands after execution started', async () => {
const b = turn(SES_B, 'B tui SLEEP=4')
const paneB = await runPane(PANE_B, SES_B, async (tui) => {
await pump(tui, b.start)
expect(posts).toHaveLength(0)
tui.navigate(SES_B)
await vi.waitFor(() => {
expect(summary(posts)).toContain(`SessionBusy:${SES_B}`)
})
await pump(tui, b.finish)
})
const names = summary(paneB)
expect(names.slice(0, 3)).toEqual([
`SessionStart:${SES_B}`,
`MessagePart:${SES_B}`,
`SessionBusy:${SES_B}`
])
expect(names.at(-1)).toBe(`SessionIdle:${SES_B}`)
})
it('keeps a started turn until it settles after the route moves on', async () => {
const a = turn(SES_A, 'A long')
const paneA = await runPane(PANE_A, SES_A, async (tui) => {
tui.navigate(SES_A)
await pump(tui, a.start)
tui.navigate('ses_other_idle_session')
await tick(250)
await pump(tui, a.finish)
})
expect(summary(paneA)).toContain(`SessionBusy:${SES_A}`)
expect(summary(paneA).at(-1)).toBe(`SessionIdle:${SES_A}`)
})
it('reports nothing for sessions the pane never showed', async () => {
process.env.ORCA_PANE_KEY = PANE_A
const tui = fakeTui()
const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx)
const b = turn(SES_B, 'another pane')
await pump(tui, [...b.start, ...b.finish])
await tick(250)
await cleanup?.()
expect(posts).toEqual([])
})
// Why: a turn the TUI did not see end must not hold the pane Working (e.g. across a reconnect).
it('settles an owned turn once the session data says it ended without an end event', async () => {
process.env.ORCA_PANE_KEY = PANE_A
const tui = fakeTui()
const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx)
tui.navigate(SES_A)
await pump(tui, turn(SES_A, 'A').start)
tui.loseEnd(SES_A)
await vi.waitFor(() => {
expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)
})
await cleanup?.()
})
// Why: a turn that fails fast arrives as one burst; each level must post once, not flicker.
it('does not settle a turn early while its events are still queued', async () => {
process.env.ORCA_PANE_KEY = PANE_A
const tui = fakeTui()
const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx)
tui.navigate(SES_A)
await tick()
const a = turn(SES_A, 'A fails fast')
for (const event of [...a.start, ...a.finish]) {
tui.emit(event)
}
await vi.waitFor(() => {
expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)
})
await tick(250)
await cleanup?.()
const statuses = posts
.map((post) => post.payload?.hook_event_name)
.filter((name) => name === 'SessionBusy' || name === 'SessionIdle')
expect(statuses).toEqual(['SessionBusy', 'SessionIdle'])
})
it('re-derives Working for an owned session whose start it missed', async () => {
process.env.ORCA_PANE_KEY = PANE_A
const tui = fakeTui()
const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx)
tui.navigate(SES_A)
const first = turn(SES_A, 'A')
await pump(tui, [...first.start, ...first.finish])
await vi.waitFor(() => {
expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)
})
tui.loseStart(SES_A)
await vi.waitFor(() => {
expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`)
})
tui.loseEnd(SES_A)
await vi.waitFor(() => {
expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)
})
await cleanup?.()
})
describe('derived from the session data', () => {
const SES_CHILD = 'ses_f161fe00affeChildSubagent1'
const statuses = (list: Post[]): string[] =>
summary(list).filter((name) =>
/^(SessionBusy|SessionIdle|PermissionRequest|AskUserQuestion):/.test(name)
)
const start = async (
tui: ReturnType<typeof fakeTui>
): Promise<(() => Promise<void>) | undefined> => {
process.env.ORCA_PANE_KEY = PANE_A
return (await loadPlugin()).default?.setup?.(tui.ctx)
}
const childStart = (parentID: string): BusEvent[] => [
{ type: 'session.created', data: { sessionID: SES_CHILD, parentID } },
{ type: 'session.execution.started', data: { sessionID: SES_CHILD } }
]
const permission = (sessionID: string): BusEvent => ({
type: 'permission.asked',
data: { id: 'per_1', sessionID, action: 'bash', resources: ['rm -rf build'] }
})
// Why: #23700 left this for TUI panes; a reload that misses the end must still reach Done.
it('shows Done for a turn that ended while the plugin was reloading', async () => {
const tui = fakeTui()
const firstGeneration = await start(tui)
tui.navigate(SES_A)
const a = turn(SES_A, 'A')
await pump(tui, a.start)
await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]))
const beforeReload = posts.length
await firstGeneration?.()
expect(posts).toHaveLength(beforeReload)
await pump(tui, a.finish)
const secondGeneration = await start(tui)
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`))
await secondGeneration?.()
expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionIdle:${SES_A}`])
})
it('keeps a navigated-away turn across a reload and settles it after', async () => {
const tui = fakeTui()
const firstGeneration = await start(tui)
tui.navigate(SES_A)
const a = turn(SES_A, 'A')
await pump(tui, a.start)
tui.navigate(SES_B)
await tick(150)
await firstGeneration?.()
const secondGeneration = await start(tui)
await tick(150)
expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])
await pump(tui, a.finish)
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`))
await secondGeneration?.()
})
it('re-posts a level the previous generation could not deliver', async () => {
const tui = fakeTui()
failPosts = true
const firstGeneration = await start(tui)
tui.navigate(SES_A)
await pump(tui, turn(SES_A, 'A').start)
await tick(50)
await firstGeneration?.()
failPosts = false
const secondGeneration = await start(tui)
await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]))
await secondGeneration?.()
})
// Why: with posts slower than events, the old adapter's decisions trailed the data and flickered.
it('posts each level once and in order while posts are slow', async () => {
postDelayMs = 120
const tui = fakeTui()
const cleanup = await start(tui)
tui.navigate(SES_A)
const a = turn(SES_A, 'A')
const b = turn(SES_B, 'another pane')
for (const event of [...a.start, ...b.start, ...b.finish, ...a.finish]) {
tui.emit(event)
}
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`), {
timeout: 3000
})
await tick(300)
await cleanup?.()
// The reply text lands after Busy and before Idle, so Done shows it.
expect(summary(posts)).toEqual([
`SessionStart:${SES_A}`,
`MessagePart:${SES_A}`,
`SessionBusy:${SES_A}`,
`MessagePart:${SES_A}`,
`SessionIdle:${SES_A}`
])
expect(posts.at(-2)?.payload).toMatchObject({ role: 'assistant', text: 'tick0 tick1 ' })
expect(summary(allPosts)[0]).toBe('SessionStart:undefined')
})
// Why: Orca may still show this pane a status another process left, e.g. an older shared-service
// plugin that posted another pane's turn here before an upgrade.
it('lands one start boundary in a freshly started TUI, and none on a reload after Done', async () => {
const tui = fakeTui()
const firstGeneration = await start(tui)
await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined']))
tui.navigate(SES_A)
const a = turn(SES_A, 'A')
await pump(tui, [...a.start, ...a.finish])
await vi.waitFor(() => expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`))
await firstGeneration?.()
const secondGeneration = await start(tui)
await tick(250)
await secondGeneration?.()
expect(summary(allPosts).at(-1)).toBe(`SessionIdle:${SES_A}`)
expect(summary(allPosts).filter((name) => name.startsWith('SessionStart:'))).toEqual([
'SessionStart:undefined',
`SessionStart:${SES_A}`
])
})
it('lands the start boundary only once across reloads of an idle pane', async () => {
const tui = fakeTui()
const firstGeneration = await start(tui)
await vi.waitFor(() => expect(summary(allPosts)).toEqual(['SessionStart:undefined']))
await firstGeneration?.()
const secondGeneration = await start(tui)
await tick(150)
await secondGeneration?.()
expect(summary(allPosts)).toEqual(['SessionStart:undefined'])
})
it('names the idle session a freshly started TUI shows', async () => {
const tui = fakeTui()
tui.navigate(SES_B)
const cleanup = await start(tui)
await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionStart:${SES_B}`]))
await cleanup?.()
})
it('posts Working instead of the start boundary when the route shows a running turn', async () => {
const tui = fakeTui()
tui.navigate(SES_A)
tui.loseStart(SES_A)
const cleanup = await start(tui)
await vi.waitFor(() => expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`]))
await tick(150)
await cleanup?.()
expect(summary(allPosts)).toEqual([`SessionBusy:${SES_A}`])
})
// Why: without plugin memory a reload cannot be told from a start, so it must not reset a Done.
it('lands no start boundary where OpenCode keeps no plugin memory', async () => {
const tui = fakeTui()
Reflect.deleteProperty(tui.ctx, 'storage')
const cleanup = await start(tui)
await tick(150)
await cleanup?.()
expect(allPosts).toEqual([])
})
it('stays Working while a child session holds the turn, even if its end is missed', async () => {
const tui = fakeTui()
const cleanup = await start(tui)
tui.navigate(SES_A)
const a = turn(SES_A, 'A spawns a background task')
await pump(tui, [...a.start, ...childStart(SES_A), ...a.finish])
await tick(250)
expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`])
tui.loseEnd(SES_CHILD)
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`))
await cleanup?.()
expect(posts.every((post) => post.payload?.sessionID === SES_A)).toBe(true)
})
it("shows a child's permission as Needs input on the root, then Working after the reply", async () => {
const tui = fakeTui()
const cleanup = await start(tui)
tui.navigate(SES_A)
const a = turn(SES_A, 'A')
await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)])
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`))
await pump(tui, [
{ type: 'permission.replied', data: { sessionID: SES_CHILD, requestID: 'per_1' } },
{ type: 'session.execution.succeeded', data: { sessionID: SES_CHILD } },
...a.finish
])
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`))
await cleanup?.()
expect(statuses(posts)).toEqual([
`SessionBusy:${SES_A}`,
`PermissionRequest:${SES_A}`,
`SessionBusy:${SES_A}`,
`SessionIdle:${SES_A}`
])
})
// Why: Orca reads every MessagePart as Working, so reply text must not bury an open request.
it('keeps Needs input while the root streams text beside a waiting background child', async () => {
const tui = fakeTui()
const cleanup = await start(tui)
tui.navigate(SES_A)
const a = turn(SES_A, 'A spawns a background task')
await pump(tui, [...a.start, ...childStart(SES_A), permission(SES_CHILD)])
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`))
await pump(tui, a.finish)
await tick(300)
expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`)
await cleanup?.()
})
it('clears Needs input answered while disconnected once the TUI reconnects', async () => {
const tui = fakeTui()
const cleanup = await start(tui)
tui.navigate(SES_A)
await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)])
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`))
// The user browses away; the reply lands while this TUI is disconnected.
tui.navigate(SES_B)
await pump(tui, [{ type: 'server.connected', data: {} }])
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`))
await cleanup?.()
})
it('ignores a reply that a reconnect fetch restores', async () => {
const tui = fakeTui()
const cleanup = await start(tui)
tui.navigate(SES_A)
await pump(tui, [...turn(SES_A, 'A').start, permission(SES_A)])
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`))
const release = tui.holdPermissionFetch()
tui.serverPermissions.set(SES_A, [...(tui.permissions.get(SES_A) ?? [])])
await pump(tui, [
{ type: 'server.connected', data: {} },
{ type: 'permission.replied', data: { sessionID: SES_A, requestID: 'per_1' } }
])
release()
await tick(250)
expect(tui.permissions.get(SES_A)).toHaveLength(1)
expect(summary(posts).at(-1)).toBe(`SessionBusy:${SES_A}`)
await cleanup?.()
})
// Why: an Orca restart moves the hook endpoint while the pane's level stays the same.
it('re-posts the current level once the hook endpoint moves', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
try {
const tui = fakeTui()
const cleanup = await start(tui)
tui.navigate(SES_A)
await pump(tui, turn(SES_A, 'A').start)
vi.advanceTimersByTime(200)
await vi.waitFor(() => expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`]))
process.env.ORCA_AGENT_HOOK_PORT = '59998'
vi.advanceTimersByTime(5000)
await vi.waitFor(() =>
expect(statuses(posts)).toEqual([`SessionBusy:${SES_A}`, `SessionBusy:${SES_A}`])
)
vi.advanceTimersByTime(5000)
await tick(50)
expect(statuses(posts)).toHaveLength(2)
await cleanup?.()
} finally {
vi.useRealTimers()
}
})
it('does not pin Needs input on a request the data kept after its turn ended', async () => {
const tui = fakeTui()
const cleanup = await start(tui)
tui.navigate(SES_A)
const a = turn(SES_A, 'A')
await pump(tui, [...a.start, permission(SES_A)])
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`PermissionRequest:${SES_A}`))
await pump(tui, a.finish)
await vi.waitFor(() => expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`))
await tick(250)
expect(summary(posts).at(-1)).toBe(`SessionIdle:${SES_A}`)
await cleanup?.()
})
})
it.each([
['an OpenCode 1 TUI', () => fakeTui('1.18.33')],
['the other pane variant', () => fakeTui()],
['a TUI outside an Orca pane', () => fakeTui()]
])('stays silent in %s', async (label, make) => {
if (label === 'the other pane variant') {
process.env.ORCA_OPENCODE_AGENT = 'opencode2'
}
process.env.ORCA_PANE_KEY = PANE_A
if (label === 'a TUI outside an Orca pane') {
delete process.env.ORCA_PANE_KEY
}
const tui = make()
const cleanup = await (await loadPlugin()).default?.setup?.(tui.ctx)
expect(tui.listen).not.toHaveBeenCalled()
await cleanup?.()
})
describe('server plugin stand-down', () => {
function serverContext() {
const subscribe = vi.fn(async function* () {})
const hook = vi.fn(async () => ({ dispose: vi.fn() }))
return {
subscribe,
hook,
ctx: {
session: { hook, get: async () => undefined },
event: { subscribe }
}
}
}
const installTuiCopy = (): void => {
mkdirSync(join(tempDir, 'orca-opencode-status-tui'))
writeFileSync(join(tempDir, 'orca-opencode-status-tui', 'tui.js'), '')
}
it('reports nothing from a serve process when the TUI copy is installed beside it', async () => {
installTuiCopy()
process.argv = [...savedArgv, 'serve', '--service']
const server = serverContext()
const cleanup = await (await loadPlugin()).default?.setup?.(server.ctx)
expect(server.subscribe).not.toHaveBeenCalled()
expect(server.hook).not.toHaveBeenCalled()
await cleanup?.()
})
it('stands down in a --standalone private server too', async () => {
installTuiCopy()
process.argv = [...savedArgv, 'serve', '--stdio', '--port', '0']
const server = serverContext()
const cleanup = await (await loadPlugin()).default?.setup?.(server.ctx)
expect(server.subscribe).not.toHaveBeenCalled()
await cleanup?.()
})
// Why: an older SSH relay installs this file but not the TUI copy; nothing else would report.
it('keeps reporting from a serve process whose installer wrote no TUI copy', async () => {
process.argv = [...savedArgv, 'serve', '--service']
const server = serverContext()
const cleanup = await (await loadPlugin()).default?.setup?.(server.ctx)
expect(server.subscribe).toHaveBeenCalled()
await cleanup?.()
})
})
})
@@ -0,0 +1,97 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>()
}))
vi.mock('electron', () => ({
app: { getPath: getPathMock }
}))
import { _internals } from './hook-service'
type PluginHooks = {
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
}
type PluginModule = { OrcaOpenCodeStatusPlugin?: (ctx: unknown) => Promise<PluginHooks> }
const ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN'] as const
// OpenCode 1 `run` loads the plugin in-process; the host already shows the run as Working from the
// process, so a session-start idle row there would only blink the pane.
describe('OpenCode plugin session start inside a `run` process', () => {
let tempDir: string
let events: string[]
let savedEnv: Record<string, string | undefined>
let savedArgv: string[]
let savedFetch: typeof globalThis.fetch
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-run-process-'))
events = []
savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]))
savedArgv = process.argv
savedFetch = globalThis.fetch
process.env.ORCA_PANE_KEY = 'tab-1:leaf-1'
process.env.ORCA_AGENT_HOOK_PORT = '45678'
process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token'
globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body: unknown = JSON.parse(String(init?.body))
const payload =
typeof body === 'object' && body !== null && 'payload' in body ? body.payload : null
if (typeof payload === 'object' && payload !== null && 'hook_event_name' in payload) {
events.push(String(payload.hook_event_name))
}
return new Response(null, { status: 204 })
})
})
afterEach(() => {
process.argv = savedArgv
globalThis.fetch = savedFetch
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = savedEnv[key]
}
}
rmSync(tempDir, { recursive: true, force: true })
})
async function sessionStartEvents(argv: string[]): Promise<string[]> {
events = []
process.argv = argv
const pluginPath = join(tempDir, `plugin-${Math.random().toString(36).slice(2)}.mjs`)
writeFileSync(pluginPath, _internals.getOpenCodePluginSource())
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the generated plugin module exports this factory; the test calls it once.
const module = (await import(pathToFileURL(pluginPath).href)) as PluginModule
const hooks = await module.OrcaOpenCodeStatusPlugin?.({})
await hooks?.event({
event: { type: 'session.created', properties: { info: { id: 'root' } } }
})
return events
}
it('posts no session start from an `opencode run` process', async () => {
await expect(
sessionStartEvents(['bun', '/$bunfs/root/opencode', 'run', 'fix the bug'])
).resolves.toEqual([])
await expect(
sessionStartEvents(['/usr/local/bin/opencode', '--log-level', 'INFO', 'run', 'hi'])
).resolves.toEqual([])
})
it('still posts it from the TUI and from `serve`', async () => {
await expect(sessionStartEvents(['bun', '/$bunfs/root/opencode'])).resolves.toEqual([
'SessionStart'
])
await expect(
sessionStartEvents(['bun', '/$bunfs/root/opencode', 'serve', '--port', '4096'])
).resolves.toEqual(['SessionStart'])
})
})
@@ -0,0 +1,134 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setAppEnvironment } from '../../shared/app-environment'
import { OpenCodeHookService, openCode2HookService, getOpenCode2PluginSource } from './hook-service'
import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory'
const PLUGIN = 'orca-opencode2-status.js'
const TUI_ENTRY = join('orca-opencode2-status-tui', 'tui.js')
describe('OpenCodeHookService.refreshInstalledPlugins (app start)', () => {
let userDataDir: string
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME
beforeAll(() => {
userDataDir = mkdtempSync(join(tmpdir(), 'orca-opencode-startup-refresh-'))
process.env.XDG_CONFIG_HOME = join(userDataDir, 'xdg')
})
beforeEach(() => {
setAppEnvironment({
getPath: (name: string) => {
if (name === 'userData') {
return userDataDir
}
throw new Error(`unexpected getPath(${name})`)
},
getAppPath: () => process.cwd(),
getVersion: () => '0.0.0-test',
isPackaged: () => false,
onWillQuit: () => {},
exit: () => {},
getAppMetrics: () => []
})
})
afterEach(() => {
rmSync(join(userDataDir, 'xdg'), { recursive: true, force: true })
rmSync(join(userDataDir, 'opencode2-config-overlays'), { recursive: true, force: true })
})
afterAll(() => {
if (originalXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME
} else {
process.env.XDG_CONFIG_HOME = originalXdgConfigHome
}
rmSync(userDataDir, { recursive: true, force: true })
})
// Why: QA saw an upgraded app keep a running service on the old plugin until any pane opened.
it('upgrades an existing install, TUI copy included, so a running service reloads it', () => {
const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins')
mkdirSync(pluginsDir, { recursive: true })
writeFileSync(join(pluginsDir, PLUGIN), '// plugin from the previous Orca release')
openCode2HookService.refreshInstalledPlugins()
expect(readFileSync(join(pluginsDir, PLUGIN), 'utf8')).toBe(getOpenCode2PluginSource())
expect(readFileSync(join(pluginsDir, TUI_ENTRY), 'utf8')).toBe(getOpenCode2PluginSource())
})
it('never creates an install the user did not already have', () => {
openCode2HookService.refreshInstalledPlugins()
expect(existsSync(resolveOpenCodeConfigDirectory())).toBe(false)
expect(existsSync(join(userDataDir, 'opencode2-config-overlays'))).toBe(false)
})
it('leaves a current install untouched so nothing reloads', () => {
const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins')
mkdirSync(join(pluginsDir, 'orca-opencode2-status-tui'), { recursive: true })
writeFileSync(join(pluginsDir, PLUGIN), getOpenCode2PluginSource())
writeFileSync(join(pluginsDir, TUI_ENTRY), getOpenCode2PluginSource())
const past = new Date('2020-01-01T00:00:00Z')
utimesSync(join(pluginsDir, PLUGIN), past, past)
utimesSync(join(pluginsDir, TUI_ENTRY), past, past)
openCode2HookService.refreshInstalledPlugins()
expect(statSync(join(pluginsDir, PLUGIN)).mtimeMs).toBe(past.getTime())
expect(statSync(join(pluginsDir, TUI_ENTRY)).mtimeMs).toBe(past.getTime())
})
it('upgrades existing source overlays and skips overlays without Orca plugin', () => {
const overlays = join(userDataDir, 'opencode2-config-overlays')
const stale = join(overlays, 'stale', 'plugins')
const foreign = join(overlays, 'foreign', 'plugins')
mkdirSync(stale, { recursive: true })
mkdirSync(foreign, { recursive: true })
writeFileSync(join(stale, PLUGIN), '// plugin from the previous Orca release')
openCode2HookService.refreshInstalledPlugins()
expect(readFileSync(join(stale, PLUGIN), 'utf8')).toBe(getOpenCode2PluginSource())
expect(readFileSync(join(stale, TUI_ENTRY), 'utf8')).toBe(getOpenCode2PluginSource())
expect(existsSync(join(foreign, PLUGIN))).toBe(false)
})
it('keeps refreshing other installs when one write fails', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const service = new OpenCodeHookService({
pluginFileName: PLUGIN,
legacyHooksDir: 'opencode2-hooks',
overlayDir: 'opencode2-config-overlays',
pluginSource: () => '// next release',
installsTuiPlugin: true
})
const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins')
mkdirSync(pluginsDir, { recursive: true })
writeFileSync(join(pluginsDir, PLUGIN), '// old')
// A file where the TUI copy's directory belongs makes the config-dir write fail.
writeFileSync(join(pluginsDir, 'orca-opencode2-status-tui'), 'obstruction')
const overlayPlugins = join(userDataDir, 'opencode2-config-overlays', 'a', 'plugins')
mkdirSync(overlayPlugins, { recursive: true })
writeFileSync(join(overlayPlugins, PLUGIN), '// old')
service.refreshInstalledPlugins()
expect(readFileSync(join(overlayPlugins, PLUGIN), 'utf8')).toBe('// next release')
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
})
+53 -1
View File
@@ -129,6 +129,18 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
const pluginSource = readFileSync(pluginPath, 'utf8')
expect(pluginSource).toContain('OrcaOpenCodeStatusPlugin')
expect(pluginSource).toContain('messageID: part.messageID')
// Why: OpenCode 2 reports pane lifecycle from each TUI, which loads only plugin directories.
const tuiEntry = join(
resolveOpenCodeConfigDirectory(),
'plugins',
'orca-opencode-status-tui',
'tui.js'
)
expect(readFileSync(tuiEntry, 'utf8')).toBe(pluginSource)
const past = new Date('2020-01-01T00:00:00Z')
utimesSync(tuiEntry, past, past)
service.buildPtyEnv(daemonSessionId)
expect(statSync(tuiEntry).mtimeMs).toBe(past.getTime())
})
// Why: OpenCode 2 reloads a plugin whose file mtime changed, which restarted status mid-turn.
@@ -176,6 +188,24 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
}
)
// Why: a service that reloads between the two writes must already find the TUI copy and stand down.
it('writes the TUI copy before the server plugin file', () => {
const pluginsDir = join(resolveOpenCodeConfigDirectory(), 'plugins')
const serverPath = join(pluginsDir, 'orca-opencode2-status.js')
const tuiDir = join(pluginsDir, 'orca-opencode2-status-tui')
rmSync(serverPath, { recursive: true, force: true })
rmSync(tuiDir, { recursive: true, force: true })
// A directory in the server file's place makes that write fail.
mkdirSync(serverPath, { recursive: true })
try {
openCode2HookService.buildPtyEnv(daemonSessionId)
expect(existsSync(join(tuiDir, 'tui.js'))).toBe(true)
} finally {
rmSync(serverPath, { recursive: true, force: true })
rmSync(tuiDir, { recursive: true, force: true })
}
})
// Why: #22234 — OpenCode 2 installs under the plain `opencode` name, and its loader
// rejects a default export that only has server(). Asserting the emitted *source* is
// not enough; the installed file is what the v2 server validates, so load it.
@@ -227,6 +257,10 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
expect(module.default?.id).toBe('orca-opencode-status')
expect(module.default?.server).toBeTypeOf('function')
expect(module.default?.setup).toBeTypeOf('function')
// A service loading this dir stands down only when the TUI copy sits beside it.
expect(
readFileSync(join(legacyPluginPath, '..', 'orca-opencode-status-tui', 'tui.js'), 'utf8')
).toBe(getOpenCodePluginSource())
})
it('repairs late and overwritten legacy plugins atomically on the same service', () => {
@@ -241,7 +275,10 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => {
service.refreshLegacySharedPlugin()
expect(readFileSync(path, 'utf8')).toBe(getOpenCodePluginSource())
expect(readFileSync(reader, 'utf8')).toBe(stale)
expect(readdirSync(join(path, '..'))).toEqual(['orca-opencode-status.js'])
expect(readdirSync(join(path, '..')).sort()).toEqual([
'orca-opencode-status-tui',
'orca-opencode-status.js'
])
} finally {
closeSync(reader)
}
@@ -471,6 +508,21 @@ describe('OpenCodeHookService overlay mode (user OPENCODE_CONFIG_DIR set)', () =
expectUserConfigIntact()
})
it('installs the TUI copy in the overlay without mirroring a user dir of the same name', () => {
const userTuiDir = join(userConfigDir, 'plugins', 'orca-opencode-status-tui')
mkdirSync(userTuiDir)
writeFileSync(join(userTuiDir, 'tui.js'), 'USER OWNED')
const env = new OpenCodeHookService().buildPtyEnv(ptyId, userConfigDir)
const overlayTui = join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status-tui')
expect(lstatSync(overlayTui).isSymbolicLink()).toBe(false)
expect(readFileSync(join(overlayTui, 'tui.js'), 'utf8')).toBe(
readFileSync(join(env.OPENCODE_CONFIG_DIR!, 'plugins', 'orca-opencode-status.js'), 'utf8')
)
expect(readFileSync(join(userTuiDir, 'tui.js'), 'utf8')).toBe('USER OWNED')
})
it.skipIf(process.platform === 'win32')(
'does not write through a symlinked plugins/ directory into the user filesystem',
() => {
+63 -52
View File
@@ -13,15 +13,11 @@ import {
} from 'node:fs'
import { createHash } from 'node:crypto'
import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror'
import { getStatusPluginEndpointSource } from './status-plugin-endpoint-source'
import { getStatusPluginRuntimeStateSource } from './status-plugin-runtime-state-source'
import { getStatusPluginMessagePreviewSource } from './status-plugin-message-preview-source'
import { getStatusPluginSessionLineageSource } from './status-plugin-session-lineage-source'
import { getStatusPluginPostSource } from './status-plugin-post-source'
import { getStatusPluginDeliverySource } from './status-plugin-delivery-source'
import { getStatusPluginOwnershipSource } from './status-plugin-ownership-source'
import { getStatusPluginLifecycleSource } from './status-plugin-lifecycle-source'
import { getStatusPluginFactorySource } from './status-plugin-factory-source'
import {
getOpenCode2PluginSource,
getOpenCodeFamilyPluginSource,
getOpenCodePluginSource
} from './status-plugin-module-source'
import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory'
import {
getOpenCodeLegacySharedConfigDir,
@@ -32,6 +28,12 @@ import {
isInstalledOpenCodePluginCurrent,
isOverlayOpenCodePluginCurrent
} from '../../shared/opencode-installed-plugin'
import {
openCodeTuiPluginDirName,
writeOpenCodeTuiPlugin
} from '../../shared/opencode-tui-plugin-install'
export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource }
const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js'
const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays'
@@ -47,6 +49,8 @@ type OpenCodeHookVariant = {
legacyHooksDir: string
overlayDir: string
pluginSource: () => string
/** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */
installsTuiPlugin?: boolean
}
// Why: session IDs may contain path separators and are hashed downstream; cap pathological input.
@@ -59,50 +63,13 @@ function toSafeDirName(id: string): string {
return createHash('sha256').update(id).digest('hex').slice(0, 32)
}
// Both major versions install as `opencode`; let the loader choose server() or setup().
export function getOpenCodePluginSource(): string {
return getOpenCodeFamilyPluginSource('/hook/opencode', {
emitSessionStart: true,
emitNextEvents: true,
expectedAgent: 'opencode'
})
}
export function getOpenCode2PluginSource(): string {
return getOpenCodeFamilyPluginSource('/hook/opencode2', {
emitSessionStart: true,
emitNextEvents: true
})
}
export function getOpenCodeFamilyPluginSource(
hookPathname: string,
options: {
emitSessionStart: boolean
emitNextEvents?: boolean
expectedAgent?: 'opencode' | 'opencode2'
}
): string {
// Why: the plugin posts PTY environment data from OpenCode to the shared hooks server.
return [
...getStatusPluginEndpointSource(),
...getStatusPluginRuntimeStateSource(),
...getStatusPluginMessagePreviewSource(),
...getStatusPluginSessionLineageSource(),
...getStatusPluginPostSource(hookPathname),
...getStatusPluginDeliverySource(),
...getStatusPluginOwnershipSource(),
...getStatusPluginLifecycleSource(),
...getStatusPluginFactorySource(options)
].join('\n')
}
// Why: installs the plugin into OpenCode's config discovery path so it POSTs to the shared agent-hooks server, unifying OpenCode status with Claude/Codex/Gemini.
export class OpenCodeHookService {
private readonly pluginSource: () => string
private readonly pluginFileName: string
private readonly legacyHooksDir: string
private readonly overlayDir: string
private readonly installsTuiPlugin: boolean
constructor(variant?: OpenCodeHookVariant | (() => string)) {
const config: OpenCodeHookVariant =
@@ -117,9 +84,11 @@ export class OpenCodeHookService {
pluginFileName: ORCA_OPENCODE_PLUGIN_FILE,
legacyHooksDir: OPENCODE_LEGACY_HOOKS_DIR,
overlayDir: OPENCODE_OVERLAY_DIR,
pluginSource: getOpenCodePluginSource
pluginSource: getOpenCodePluginSource,
installsTuiPlugin: true
})
this.pluginSource = config.pluginSource
this.installsTuiPlugin = config.installsTuiPlugin === true
this.pluginFileName = config.pluginFileName
this.legacyHooksDir = config.legacyHooksDir
this.overlayDir = config.overlayDir
@@ -162,10 +131,14 @@ export class OpenCodeHookService {
// Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps
// processes that load it later; a running OpenCode 2 service keeps its cached module until restarted.
refreshLegacySharedPlugin(): void {
const pluginPath = join(this.getSharedConfigDir(), 'plugins', this.pluginFileName)
const pluginsDir = join(this.getSharedConfigDir(), 'plugins')
const pluginPath = join(pluginsDir, this.pluginFileName)
try {
const source = this.pluginSource()
if (readFileSync(pluginPath, 'utf8') !== source) {
const installed = readFileSync(pluginPath, 'utf8')
// Why: a TUI or service still loading this dir needs the TUI copy too, or the service keeps reporting under its starter pane.
this.writeTuiPlugin(pluginsDir, source)
if (installed !== source) {
writeFileAtomically(pluginPath, source)
}
} catch (error) {
@@ -176,6 +149,31 @@ export class OpenCodeHookService {
}
}
// Why: a running OpenCode 2 service reloads a changed plugin file, so refreshing Orca's existing
// installs at app start upgrades it without waiting for the next pane. Never creates an install.
refreshInstalledPlugins(): void {
this.refreshLegacySharedPlugin()
const overlayRoot = this.getOverlayRoot()
const overlays = existsSync(overlayRoot)
? readdirSync(overlayRoot).map((name) => join(overlayRoot, name))
: []
const configDir = resolveOpenCodeConfigDirectory()
for (const dir of [configDir, ...overlays]) {
if (!existsSync(join(dir, 'plugins', this.pluginFileName))) {
continue
}
try {
if (dir === configDir) {
this.writePluginToConfigDir(dir)
} else {
this.writePluginIntoOverlay(dir)
}
} catch (error) {
console.warn('[OpenCode] Failed to refresh status plugin:', dir, error)
}
}
}
private getOverlayRoot(): string {
return join(getAppEnvironment().getPath('userData'), this.overlayDir)
}
@@ -257,7 +255,11 @@ export class OpenCodeHookService {
mkdirSync(overlayPluginsDir, { recursive: true })
for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) {
// Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file.
if (pluginEntry.name === this.pluginFileName) {
if (
pluginEntry.name === this.pluginFileName ||
(this.installsTuiPlugin &&
pluginEntry.name === openCodeTuiPluginDirName(this.pluginFileName))
) {
continue
}
mirrorEntry(
@@ -283,6 +285,7 @@ export class OpenCodeHookService {
mkdirSync(pluginsDir, { recursive: true })
const pluginPath = join(pluginsDir, this.pluginFileName)
const source = this.pluginSource()
this.writeTuiPlugin(pluginsDir, source)
if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) {
try {
unlinkSync(pluginPath)
@@ -298,10 +301,17 @@ export class OpenCodeHookService {
mkdirSync(pluginsDir, { recursive: true })
const pluginPath = join(pluginsDir, this.pluginFileName)
const source = this.pluginSource()
this.writeTuiPlugin(pluginsDir, source)
if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) {
writeFileSync(pluginPath, source)
}
}
private writeTuiPlugin(pluginsDir: string, source: string): void {
if (this.installsTuiPlugin) {
writeOpenCodeTuiPlugin(pluginsDir, this.pluginFileName, source)
}
}
}
export const openCodeHookService = new OpenCodeHookService()
@@ -309,7 +319,8 @@ export const openCode2HookService = new OpenCodeHookService({
pluginFileName: 'orca-opencode2-status.js',
legacyHooksDir: OPENCODE2_LEGACY_HOOKS_DIR,
overlayDir: 'opencode2-config-overlays',
pluginSource: getOpenCode2PluginSource
pluginSource: getOpenCode2PluginSource,
installsTuiPlugin: true
})
export const _internals = {
getOpenCodePluginSource,
+5 -12
View File
@@ -14,12 +14,12 @@ import { readOpenCodeDatabase } from '../ai-vault/session-scanner-opencode-sqlit
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import { listRegisteredPtys } from '../memory/pty-registry'
import type SyncDatabase from '../sqlite/sync-database'
import { isOpenCodeClientProcess, type ProcessIdentityRow } from './opencode-client-sweep'
import type { HookListenerState } from '../../shared/agent-hook-listener/listener-state'
/**
* Main-process binder feeding the session→pane registry (#21359).
* Main-process binder feeding the session→pane registry (#21359), for OpenCode 1
* `serve` + `attach` only (see isOpenCodeSharedServerPost).
*
* Each round: read new sessions from the shared server's SQLite store,
* snapshot panes, sweep for live clients, correlate, bind. Everything the
@@ -212,15 +212,6 @@ export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult
return { ownerships }
}
/** True when the v2 session table has every column the binder reads. */
function canReadSessionV2(db: SyncDatabase): boolean {
return (
tableExists(db, 'session_v2') &&
columnExists(db, 'session_v2', 'directory') &&
columnExists(db, 'session_v2', 'time_created')
)
}
/**
* Sessions newer than `cursor`, oldest first. The composite
* `(time_created, id)` position means rows sharing a millisecond with the
@@ -237,7 +228,9 @@ export function listOpenCodeDbSessions(
return readOpenCodeDatabase({
dbPath,
read: (db) => {
const table = canReadSessionV2(db) ? 'session_v2' : 'session'
// Why `session` only: OpenCode 1 writes it; OpenCode 2 writes `session_v2`, and its
// posts name their own pane, so its sessions must never bind.
const table = 'session'
if (
!tableExists(db, table) ||
!columnExists(db, table, 'directory') ||
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { refreshV1, refreshV2 } = vi.hoisted(() => ({
refreshV1: vi.fn<() => void>(),
refreshV2: vi.fn<() => void>()
}))
vi.mock('./hook-service', () => ({
openCodeHookService: { refreshInstalledPlugins: refreshV1 },
openCode2HookService: { refreshInstalledPlugins: refreshV2 }
}))
import { refreshInstalledOpenCodeStatusPlugins } from './opencode-status-plugin-startup-refresh'
describe('refreshInstalledOpenCodeStatusPlugins', () => {
beforeEach(() => {
refreshV1.mockClear()
refreshV2.mockClear()
})
it('refreshes both variants by default', () => {
refreshInstalledOpenCodeStatusPlugins(null)
expect(refreshV1).toHaveBeenCalledTimes(1)
expect(refreshV2).toHaveBeenCalledTimes(1)
})
it('touches nothing while agent status hooks are off', () => {
refreshInstalledOpenCodeStatusPlugins({ agentStatusHooksEnabled: false })
expect(refreshV1).not.toHaveBeenCalled()
expect(refreshV2).not.toHaveBeenCalled()
})
it('skips a disabled variant only', () => {
refreshInstalledOpenCodeStatusPlugins({ disabledTuiAgents: ['opencode2'] })
expect(refreshV1).toHaveBeenCalledTimes(1)
expect(refreshV2).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,16 @@
import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting'
import type { ManagedHookDetectionSettings } from '../agent-hooks/managed-hook-detection-commands'
import { openCode2HookService, openCodeHookService } from './hook-service'
// Why: an Orca upgrade must reach an OpenCode 2 service that is already running, which reloads a
// changed plugin file, instead of waiting for the next pane spawn to rewrite it.
export function refreshInstalledOpenCodeStatusPlugins(
settings: ManagedHookDetectionSettings
): void {
if (isAgentStatusHooksEnabledForAgent(settings, 'opencode')) {
openCodeHookService.refreshInstalledPlugins()
}
if (isAgentStatusHooksEnabledForAgent(settings, 'opencode2')) {
openCode2HookService.refreshInstalledPlugins()
}
}
@@ -1,5 +1,5 @@
import {
getOpenCode2SetupSource,
getOpenCode2ModuleSource,
getOpenCode2EventNormalizationSource
} from '../opencode2/status-plugin-setup-source'
@@ -103,6 +103,7 @@ export function getStatusPluginFactorySource(options: {
' const info = event.properties?.info;',
' if (!info?.id || info.parentID) return;',
' rememberSessionRoot(info.id, info.id);',
' if (isOpenCodeRunProcess()) return; // a `run` goes Busy at once; its start row only blinks idle',
' await enqueueLifecycle(() =>',
' disposed ? undefined : post("SessionStart", { sessionID: info.id })',
' );',
@@ -286,7 +287,7 @@ export function getStatusPluginFactorySource(options: {
' },',
' };',
'};',
...(options.emitNextEvents ? getOpenCode2SetupSource() : []),
...(options.emitNextEvents ? getOpenCode2ModuleSource(pluginID, expectedAgent) : []),
'',
'// Why: OpenCode also resolves plugins through the module default export, and that',
'// loader rejects the module unless the default exposes `server()` ("must default',
@@ -0,0 +1,47 @@
import { getStatusPluginEndpointSource } from './status-plugin-endpoint-source'
import { getStatusPluginRuntimeStateSource } from './status-plugin-runtime-state-source'
import { getStatusPluginMessagePreviewSource } from './status-plugin-message-preview-source'
import { getStatusPluginSessionLineageSource } from './status-plugin-session-lineage-source'
import { getStatusPluginPostSource } from './status-plugin-post-source'
import { getStatusPluginDeliverySource } from './status-plugin-delivery-source'
import { getStatusPluginOwnershipSource } from './status-plugin-ownership-source'
import { getStatusPluginLifecycleSource } from './status-plugin-lifecycle-source'
import { getStatusPluginFactorySource } from './status-plugin-factory-source'
// Both major versions install as `opencode`; let the loader choose server() or setup().
export function getOpenCodePluginSource(): string {
return getOpenCodeFamilyPluginSource('/hook/opencode', {
emitSessionStart: true,
emitNextEvents: true,
expectedAgent: 'opencode'
})
}
export function getOpenCode2PluginSource(): string {
return getOpenCodeFamilyPluginSource('/hook/opencode2', {
emitSessionStart: true,
emitNextEvents: true
})
}
export function getOpenCodeFamilyPluginSource(
hookPathname: string,
options: {
emitSessionStart: boolean
emitNextEvents?: boolean
expectedAgent?: 'opencode' | 'opencode2'
}
): string {
// Why: the plugin posts PTY environment data from OpenCode to the shared hooks server.
return [
...getStatusPluginEndpointSource(),
...getStatusPluginRuntimeStateSource(),
...getStatusPluginMessagePreviewSource(),
...getStatusPluginSessionLineageSource(),
...getStatusPluginPostSource(hookPathname),
...getStatusPluginDeliverySource(),
...getStatusPluginOwnershipSource(),
...getStatusPluginLifecycleSource(),
...getStatusPluginFactorySource(options)
].join('\n')
}
@@ -16,6 +16,8 @@ export function getStatusPluginPostSource(hookPathname: string): string[] {
' worktreeId: process.env.ORCA_WORKTREE_ID || "",',
' env: coords.env,',
' version: coords.version,',
// opencodeMajor is set only by the OpenCode 2 setup() path, never read from OpenCode's version.
' ...(reportingOpenCodeMajor ? { opencodeMajor: reportingOpenCodeMajor } : {}),',
' payload: { hook_event_name: hookEventName, ...(extraProperties || {}) },',
' });',
' const controller = new AbortController();',
@@ -21,6 +21,8 @@ export function getStatusPluginRuntimeStateSource(): string[] {
'let busyRecoveryUsed = false;',
'let busyRecoveryEndpointKey = "";',
'let stateArrivalRevision = 0;',
'// Why: only OpenCode 2 calls setup(); its posts tell the host to keep its OpenCode 1 binder off them.',
'let reportingOpenCodeMajor = 0;',
'// Why: OpenCode can create directory-scoped factories and concurrent root',
'// sessions in one pane; module ownership lets waiting/busy aggregate safely.',
'let nextFactoryID = 0;',
@@ -52,6 +54,22 @@ export function getStatusPluginRuntimeStateSource(): string[] {
'let assistantPartFlushTimer = null;',
'let messagePartPostInFlight = null;',
'let deliveredMessagePartFactoryID = null;',
'let lastAssistantPartPostAt = 0;'
'let lastAssistantPartPostAt = 0;',
...getRunProcessSource()
]
}
// Mirrors isOpenCodeRunCommand (src/shared/opencode-headless-command.ts) over this process's argv.
function getRunProcessSource(): string[] {
return String.raw`
function isOpenCodeRunProcess() {
// Why drop a leading path: a compiled binary reports its embedded entry script as argv[1].
const args = process.argv.slice(1);
if (args.length > 0 && /[\\/]/.test(args[0])) args.shift();
for (let index = 0; index < args.length; index += 1) {
if (!args[index].startsWith("-")) return args[index] === "run";
if (args[index] === "--log-level") index += 1;
}
return false;
}`.split('\n')
}