Merge branch 'main' into fix/18031-pi-lifecycle

This commit is contained in:
Neil
2026-09-20 21:27:46 -07:00
committed by GitHub
45 changed files with 3221 additions and 389 deletions
+10
View File
@@ -0,0 +1,10 @@
diff --git a/index.js b/index.js
index 0480a96d718c997b0a5a54b775e635e2e048e796..85f71b13d3f0f31c3c2140581d1e4051afb21dc3 100644
--- a/index.js
+++ b/index.js
@@ -1,3 +1,5 @@
import * as queryString from './base.js';
export default queryString;
+
+export * from './base.js';
+6 -3
View File
@@ -12,6 +12,9 @@ patchedDependencies:
expo-notifications@55.0.27:
hash: ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0
path: patches/expo-notifications@55.0.27.patch
query-string@9.5.1:
hash: 8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b
path: patches/query-string@9.5.1.patch
react-native-webview@13.16.2:
hash: de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27
path: patches/react-native-webview@13.16.2.patch
@@ -9966,7 +9969,7 @@ snapshots:
escape-string-regexp: 4.0.0
fast-deep-equal: 3.1.3
nanoid: 3.3.18
query-string: 9.5.1
query-string: 9.5.1(patch_hash=8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b)
react: 19.2.8
react-is: 19.2.6
use-latest-callback: 0.2.6(react@19.2.8)
@@ -12230,7 +12233,7 @@ snapshots:
fast-deep-equal: 3.1.3
invariant: 2.2.4
nanoid: 3.3.18
query-string: 9.5.1
query-string: 9.5.1(patch_hash=8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b)
react: 19.2.8
react-fast-compare: 3.2.2
react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)
@@ -14283,7 +14286,7 @@ snapshots:
pure-rand@6.1.0: {}
query-string@9.5.1:
query-string@9.5.1(patch_hash=8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b):
dependencies:
decode-uri-component: 0.5.0
filter-obj: 5.1.0
+1
View File
@@ -9,5 +9,6 @@ overrides:
patchedDependencies:
expo-notifications@55.0.27: patches/expo-notifications@55.0.27.patch
query-string@9.5.1: patches/query-string@9.5.1.patch
react-native-webview@13.16.2: patches/react-native-webview@13.16.2.patch
react-native@0.83.10: patches/react-native@0.83.10.patch
@@ -0,0 +1,28 @@
import { execFileSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { pathToFileURL } from 'node:url'
import { describe, expect, it } from 'vitest'
/**
* expo-router and React Navigation serialise route params through `import * as queryString from
* 'query-string'`. The lockfile overrides `query-string` to 9.x for a `decode-uri-component`
* advisory, and 9.x's entry has a default export only, so without `patches/query-string@9.5.1.patch`
* every push carrying a param outside the path pattern throws `queryString.stringify is not a
* function` and every href with a query throws on `parse`. Resolved from expo-router's own location,
* the way Metro and the web bundler resolve it for that consumer, and imported by a plain Node
* child: vitest's default-export interop would paper over the missing names in-process.
*/
describe('query-string, as expo-router resolves it', () => {
it('exposes the named API the namespace import needs', () => {
const requireFromHere = createRequire(import.meta.url)
const requireFromExpoRouter = createRequire(requireFromHere.resolve('expo-router/package.json'))
const entry = pathToFileURL(requireFromExpoRouter.resolve('query-string')).href
const script = `const ns = await import(${JSON.stringify(entry)}); process.stdout.write(JSON.stringify({ names: Object.keys(ns).sort(), stringified: typeof ns.stringify === 'function' ? ns.stringify({ from: 'worktrees' }) : null }))`
const output = execFileSync(process.execPath, ['--input-type=module', '-e', script], {
encoding: 'utf8'
})
const { names, stringified } = JSON.parse(output)
expect(names).toEqual(expect.arrayContaining(['parse', 'stringify']))
expect(stringified).toBe('from=worktrees')
})
})
@@ -0,0 +1,245 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
import { lookupOpenCodeSessionPane } from '../../shared/agent-hook-listener/opencode-session-registry'
import { makePaneKey } from '../../shared/stable-pane-id'
import SyncDatabase from '../sqlite/sync-database'
import { AgentHookServer } from './server'
import type { OpenCodeBinderLoopDeps } from './server/server-opencode-binder'
import { defaultOpenCodeDbPath, listOpenCodeDbSessions } from '../opencode/opencode-session-binder'
const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const PANE_A = makePaneKey('binder-a', LEAF_A)
const PANE_B = makePaneKey('binder-b', LEAF_B)
const DIR = '/tmp/binder-worktree-a'
class BinderTestServer extends AgentHookServer {
public bindDeps(deps: Partial<OpenCodeBinderLoopDeps>): void {
this._setOpenCodeBinderDepsForTests(deps)
}
public runBinderRound(): Promise<number> {
return this.runOpenCodeBinderRoundOnce()
}
public startBinderLoop(): void {
this.startOpenCodeBinderLoop()
}
public ingest(source: AgentHookSource, body: unknown): void {
this.normalizeLocalHookPayload(source, body)
}
public readRegistry(sessionId: string): string | undefined {
return lookupOpenCodeSessionPane(this._getStateForTests(), sessionId)?.paneKey
}
}
function writeDb(dbPath: string, table: 'session_v2' | 'session'): 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)`
)
const insert = db.prepare(
`INSERT INTO ${table} (id, directory, time_created, parent_id) VALUES (?, ?, ?, ?)`
)
insert.run('ses_live', DIR, Date.now() - 60_000, null)
} finally {
db.close()
}
}
describe('opencode binder loop', () => {
let dir = ''
let dbPath = ''
let server: BinderTestServer
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'binder-db-'))
dbPath = join(dir, 'opencode.db')
server = new BinderTestServer()
server.bindDeps({
now: () => Date.now(),
dbPath: () => dbPath,
listPanes: () => [
{ paneKey: PANE_A, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111 }
],
sweep: async () => [
{ pid: 112, ppid: 111, startedAtMs: Date.now() - 120_000, executable: 'opencode', argv: ['opencode'] }
]
})
})
afterEach(() => {
server.stop()
rmSync(dir, { recursive: true, force: true })
})
it('binds a fresh session to its pane', async () => {
writeDb(dbPath, 'session_v2')
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('an opencode SessionStart kicks a round that binds before the poll', async () => {
writeDb(dbPath, 'session_v2')
vi.useFakeTimers()
try {
// Birth arrives stamped with the wrong (server-starter) pane.
server.ingest('opencode', {
paneKey: PANE_B,
launchToken: '',
payload: { hook_event_name: 'SessionStart', sessionID: 'ses_live' }
})
expect(server.readRegistry('ses_live')).toBeUndefined()
await vi.advanceTimersByTimeAsync(10_000)
expect(server.readRegistry('ses_live')).toBe(PANE_A)
} finally {
vi.useRealTimers()
}
})
it('pane teardown unbinds its sessions', async () => {
writeDb(dbPath, 'session_v2')
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')
expect(() => server.stop()).not.toThrow()
})
it('runs a round immediately on loop start', async () => {
writeDb(dbPath, 'session_v2')
server.startBinderLoop()
try {
await vi.waitFor(() => expect(server.readRegistry('ses_live')).toBe(PANE_A))
} finally {
server.stop()
}
})
it('discards a round that was in flight across stop', async () => {
writeDb(dbPath, 'session_v2')
let releaseSweep!: () => void
const sweepGate = new Promise<void>((resolve) => {
releaseSweep = resolve
})
server.bindDeps({
sweep: async () => {
await sweepGate
return [
{
pid: 112,
ppid: 111,
startedAtMs: Date.now() - 120_000,
executable: 'opencode',
argv: ['opencode']
}
]
}
})
const round = server.runBinderRound()
server.stop()
releaseSweep()
expect(await round).toBe(0)
expect(server.readRegistry('ses_live')).toBeUndefined()
})
it('an obsolete round does not clear the new round running flag', async () => {
writeDb(dbPath, 'session_v2')
let releaseFirst!: () => void
let releaseLater!: () => void
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const laterGate = new Promise<void>((resolve) => {
releaseLater = resolve
})
const clientRow = {
pid: 112,
ppid: 111,
startedAtMs: Date.now() - 120_000,
executable: 'opencode',
argv: ['opencode']
}
let sweepCalls = 0
server.bindDeps({
sweep: async () => {
sweepCalls += 1
await (sweepCalls === 1 ? firstGate : laterGate)
return [clientRow]
}
})
server.startBinderLoop()
await vi.waitFor(() => expect(sweepCalls).toBe(1))
server.stop()
server.startBinderLoop()
await vi.waitFor(() => expect(sweepCalls).toBe(2))
// The obsolete round finishes while the new round is still parked: its
// finally must not clear the flag the new round holds.
releaseFirst()
await new Promise((resolve) => setTimeout(resolve, 20))
// A third round attempted now must be refused at the flag check, calling
// no sweep. With the unguarded finally it would be admitted instead.
const extraRound = server.runBinderRound()
expect(sweepCalls).toBe(2)
releaseLater()
await vi.waitFor(() => expect(server.readRegistry('ses_live')).toBe(PANE_A))
await extraRound
server.stop()
})
})
describe('listOpenCodeDbSessions', () => {
let dir = ''
let dbPath = ''
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'binder-reader-'))
dbPath = join(dir, 'opencode.db')
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
it('reads session_v2 rows newer than the watermark', () => {
writeDb(dbPath, 'session_v2')
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('returns [] for a missing database instead of throwing', () => {
expect(listOpenCodeDbSessions(join(dir, 'absent.db'), { ms: 0, id: '' })).toEqual([])
})
it('the default path points at the local opencode store', () => {
expect(defaultOpenCodeDbPath()).toMatch(/opencode\.db$/)
})
})
describe('binder registry isolation', () => {
it('a fresh listener state starts unbound', () => {
const state = createHookListenerState()
expect(lookupOpenCodeSessionPane(state, 'ses_live')).toBeUndefined()
})
})
@@ -2,9 +2,9 @@ import { buildSpoolHookBody, type SpoolRecord } from '../../../shared/agent-hook
import { normalizeHookPayload } from '../../../shared/agent-hook-listener'
import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay'
import type { NormalizedLocalHook } from './server-types'
import { AgentHookServerPersistence } from './server-persistence'
import { AgentHookServerOpenCodeBinder } from './server-opencode-binder'
export abstract class AgentHookServerIngestNormalization extends AgentHookServerPersistence {
export abstract class AgentHookServerIngestNormalization extends AgentHookServerOpenCodeBinder {
protected setClaudeBackgroundEvidence(
paneKey: string,
hasRunningTask: boolean,
@@ -24,7 +24,16 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer
protected normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook {
if (source !== 'claude' || typeof body !== 'object' || body === null) {
return { event: normalizeHookPayload(this.state, source, body, this.env) }
const event = normalizeHookPayload(this.state, source, body, this.env)
if (
event &&
(source === 'opencode' || source === 'mimo-code') &&
event.hookEventName === 'SessionStart'
) {
// Why: a birth just arrived; bind it now instead of waiting out the poll interval.
this.kickOpenCodeBinder()
}
return { event }
}
const rawPaneKey = (body as Record<string, unknown>).paneKey
const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : ''
@@ -178,6 +178,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
this.rollbackTransportStart()
throw error
}
this.startOpenCodeBinderLoop()
}
private rollbackTransportStart(): void {
@@ -191,6 +192,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
stop(): void {
// Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
this.flushStatusPersistSync()
this.stopOpenCodeBinderLoop()
this.rollbackTransportStart()
this.env = 'production'
this.onAgentStatus = null
@@ -0,0 +1,213 @@
import {
advanceBinderCursor,
applyBinderOwnerships,
defaultOpenCodeDbPath,
listBinderPaneSnapshots,
listOpenCodeDbSessions,
OPENCODE_SESSION_CURSOR_START,
runOpenCodeBinderRound,
type BinderPaneSnapshot,
type BinderSessionRow,
type OpenCodeSessionCursor
} from '../../opencode/opencode-session-binder'
import {
sweepProcessIdentities,
type ProcessIdentityRow
} from '../../opencode/opencode-client-sweep'
import { lookupOpenCodeSessionPane } from '../../../shared/agent-hook-listener/opencode-session-registry'
import { AgentHookServerPersistence } from './server-persistence'
/** Poll cadence; hook-triggered kicks cover births between polls. */
const OPENCODE_BINDER_INTERVAL_MS = 60_000
const OPENCODE_BINDER_KICK_DEBOUNCE_MS = 10_000
/** Unbound sessions get re-correlated this long (pane inventory may lag births). */
const OPENCODE_BINDER_UNBOUND_RETRY_MS = 10 * 60_000
const OPENCODE_BINDER_PARENTS_MAX = 2_000
const OPENCODE_BINDER_UNBOUND_MAX = 500
/** Injectable I/O for the binder loop; real singletons by default, fakes in tests. */
export type OpenCodeBinderLoopDeps = {
now: () => number
dbPath: () => string
listSessions: (dbPath: string, cursor: OpenCodeSessionCursor) => BinderSessionRow[]
listPanes: () => BinderPaneSnapshot[]
sweep: () => Promise<ProcessIdentityRow[]>
}
/**
* Session→pane binder loop for the shared OpenCode server (#21359).
*
* Sits just above persistence in the chain so ingest layers can kick a round
* when a birth arrives early, and lifecycle can start/stop the timer. All
* I/O rides injectable deps (real singletons by default) so tests drive the
* whole loop without touching the user's opencode.db or process table.
*/
export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersistence {
private openCodeBinderTimer: ReturnType<typeof setInterval> | null = null
private openCodeBinderKickTimer: ReturnType<typeof setTimeout> | null = null
private openCodeBinderRunning = false
private openCodeBinderGeneration = 0
private openCodeBinderWatermark: OpenCodeSessionCursor = { ...OPENCODE_SESSION_CURSOR_START }
private openCodeBinderParents = new Map<string, string | null>()
private openCodeBinderUnbound = new Map<string, { row: BinderSessionRow; firstSeenMs: number }>()
private openCodeBinderDeps: OpenCodeBinderLoopDeps = {
now: () => Date.now(),
dbPath: () => defaultOpenCodeDbPath(),
listSessions: (dbPath, sinceMs) => listOpenCodeDbSessions(dbPath, sinceMs),
listPanes: () => listBinderPaneSnapshots(),
sweep: () => sweepProcessIdentities()
}
/** Test seam: drive the loop without the user's database or process table. */
protected _setOpenCodeBinderDepsForTests(deps: Partial<OpenCodeBinderLoopDeps>): void {
this.openCodeBinderDeps = { ...this.openCodeBinderDeps, ...deps }
}
/** Start the 60s poll loop plus one immediate round, idempotently. */
protected startOpenCodeBinderLoop(): void {
if (this.openCodeBinderTimer) {
return
}
this.openCodeBinderTimer = setInterval(() => {
void this.runOpenCodeBinderRoundOnce()
}, OPENCODE_BINDER_INTERVAL_MS)
if (this.openCodeBinderTimer.unref) {
this.openCodeBinderTimer.unref()
}
// Why immediately: existing sessions would otherwise keep the frozen
// stamp for up to a full interval after launch or restart.
void this.runOpenCodeBinderRoundOnce()
}
/** Stop timers and drop ephemeral binder state; in-flight rounds are discarded by generation. */
protected stopOpenCodeBinderLoop(): void {
// Why the generation bump: a round awaiting the process sweep must not
// apply ownerships — or resurrect the watermark — after the loop stopped.
this.openCodeBinderGeneration += 1
if (this.openCodeBinderTimer) {
clearInterval(this.openCodeBinderTimer)
this.openCodeBinderTimer = null
}
if (this.openCodeBinderKickTimer) {
clearTimeout(this.openCodeBinderKickTimer)
this.openCodeBinderKickTimer = null
}
this.openCodeBinderRunning = false
this.openCodeBinderWatermark = { ...OPENCODE_SESSION_CURSOR_START }
this.openCodeBinderParents.clear()
this.openCodeBinderUnbound.clear()
}
/**
* A birth may have arrived (opencode SessionStart): run one round soon so
* the session binds before its first busy stretch, instead of waiting out
* the poll interval. Trailing-edge debounced; concurrent rounds collapse.
*/
protected kickOpenCodeBinder(): void {
if (this.openCodeBinderKickTimer) {
return
}
this.openCodeBinderKickTimer = setTimeout(() => {
this.openCodeBinderKickTimer = null
void this.runOpenCodeBinderRoundOnce()
}, OPENCODE_BINDER_KICK_DEBOUNCE_MS)
if (this.openCodeBinderKickTimer.unref) {
this.openCodeBinderKickTimer.unref()
}
}
/** Run one correlate-and-bind round; returns applied binding count. */
protected async runOpenCodeBinderRoundOnce(): Promise<number> {
if (this.openCodeBinderRunning) {
return 0
}
this.openCodeBinderRunning = true
// Why capture before the try: if stop() lands while the sweep is in
// flight and a restart begins a new round, the obsolete round must not
// clear the new round's running flag (or two rounds overlap and apply
// ownership snapshots out of order).
const generation = this.openCodeBinderGeneration
try {
const deps = this.openCodeBinderDeps
const nowMs = deps.now()
const fresh = deps.listSessions(deps.dbPath(), this.openCodeBinderWatermark)
const sessions = [...fresh]
for (const [id, entry] of this.openCodeBinderUnbound) {
if (nowMs - entry.firstSeenMs > OPENCODE_BINDER_UNBOUND_RETRY_MS) {
this.openCodeBinderUnbound.delete(id)
continue
}
if (!fresh.some((row) => row.id === id)) {
sessions.push(entry.row)
}
}
if (sessions.length === 0) {
return 0
}
const panes = deps.listPanes()
const processes = await deps.sweep()
if (generation !== this.openCodeBinderGeneration) {
return 0
}
const knownOwners = new Map<string, string>()
for (const session of sessions) {
const bound = lookupOpenCodeSessionPane(this.state, session.id)
if (bound) {
knownOwners.set(session.id, bound.paneKey)
}
this.openCodeBinderParents.delete(session.id)
this.openCodeBinderParents.set(session.id, session.parentId)
}
while (this.openCodeBinderParents.size > OPENCODE_BINDER_PARENTS_MAX) {
const oldest = this.openCodeBinderParents.keys().next().value
if (oldest === undefined) {
break
}
this.openCodeBinderParents.delete(oldest)
}
const { ownerships } = runOpenCodeBinderRound({
nowMs,
sessions,
panes,
processes,
knownOwners,
parentBySessionId: this.openCodeBinderParents
})
const boundIds = new Set(ownerships.map((ownership) => ownership.sessionId))
const applied = applyBinderOwnerships(this.state, panes, ownerships, nowMs)
for (const session of sessions) {
if (knownOwners.has(session.id) || boundIds.has(session.id)) {
this.openCodeBinderUnbound.delete(session.id)
continue
}
if (!this.openCodeBinderUnbound.has(session.id)) {
if (this.openCodeBinderUnbound.size >= OPENCODE_BINDER_UNBOUND_MAX) {
break
}
this.openCodeBinderUnbound.set(session.id, { row: session, firstSeenMs: nowMs })
}
}
// Why from handled rows only: a session the full map could not track
// must stay re-listable next round instead of being silently passed by
// the watermark.
this.openCodeBinderWatermark = advanceBinderCursor({
fresh,
isHandled: (sessionId) =>
knownOwners.has(sessionId) ||
boundIds.has(sessionId) ||
this.openCodeBinderUnbound.has(sessionId),
current: this.openCodeBinderWatermark
})
return applied
} catch (err) {
// Why swallow: a binder failure must never break hook serving; the next
// round retries, and unbound sessions keep today's stamped behavior.
console.warn('[opencode-binder] round failed; keeping stamped attribution', err)
return 0
} finally {
if (generation === this.openCodeBinderGeneration) {
this.openCodeBinderRunning = false
}
}
}
}
+1
View File
@@ -91,6 +91,7 @@ export function buildPtyHostEnv(
// Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md.
const openCodeStatusService =
openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService
baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent
Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir))
if (baseEnv.OPENCODE_CONFIG_DIR) {
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value.
@@ -27,6 +27,7 @@ type OpenCodeSessionUsageRow = {
tokens_output: number
tokens_reasoning: number
tokens_cache_read: number
tokens_cache_write: number
}
function getProjectJoin(db: Database.Database): string {
@@ -46,6 +47,7 @@ function getAssistantSessionMessageCount(db: Database.Database): number {
const assistantPredicate = columnExists(db, 'session_message', 'type')
? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL"
: "json_extract(data, '$.tokens.input') IS NOT NULL"
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SQLite aggregate rows are validated by the typed count field below.
const row = db
.prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`)
.get() as { count?: number } | undefined
@@ -61,16 +63,29 @@ function canReadSessionUsageRows(db: Database.Database): boolean {
)
}
function getSessionCacheWriteSelect(db: Database.Database): string {
return columnExists(db, 'session', 'tokens_cache_write') ? 's.tokens_cache_write' : '0'
}
function getSessionTokenTotalExpression(db: Database.Database): string {
const cacheWrite = columnExists(db, 'session', 'tokens_cache_write')
? ' + tokens_cache_write'
: ''
return `tokens_input + tokens_output + tokens_reasoning + tokens_cache_read${cacheWrite}`
}
function getSessionUsageRowCount(db: Database.Database): number {
if (!canReadSessionUsageRows(db)) {
return 0
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SQLite aggregate rows are validated by the typed count field below.
const row = db
.prepare(
`SELECT COUNT(*) AS count
FROM session
WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0`
WHERE ${getSessionTokenTotalExpression(db)} > 0`
)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SQLite aggregate rows are validated by the typed count field below.
.get() as { count?: number } | undefined
return row?.count ?? 0
}
@@ -78,14 +93,18 @@ function getSessionUsageRowCount(db: Database.Database): number {
function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] {
const projectJoin = getProjectJoin(db)
const sessionModelSelect = getSessionModelSelect(db)
const cacheWriteSelect = getSessionCacheWriteSelect(db)
const tokenTotalExpression = getSessionTokenTotalExpression(db)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT aliases match OpenCodeSessionUsageRow across supported schemas.
const rows = db
.prepare(
`SELECT s.id, s.id AS session_id, s.time_created, s.time_updated,
s.directory, s.title, p.worktree, ${sessionModelSelect},
s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read
s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read,
${cacheWriteSelect} AS tokens_cache_write
FROM session s
${projectJoin}
WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0
WHERE ${tokenTotalExpression.replaceAll('tokens_', 's.tokens_')} > 0
ORDER BY s.time_created, s.id`
)
.all() as OpenCodeSessionUsageRow[]
@@ -105,10 +124,15 @@ function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] {
input: row.tokens_input,
output: row.tokens_output,
reasoning: row.tokens_reasoning,
total: row.tokens_input + row.tokens_output + row.tokens_reasoning,
total:
row.tokens_input +
row.tokens_output +
row.tokens_reasoning +
row.tokens_cache_read +
row.tokens_cache_write,
cache: {
read: row.tokens_cache_read,
write: 0
write: row.tokens_cache_write
}
}
})
+10 -6
View File
@@ -43,6 +43,7 @@ function createSessionTotalsSchema(db: Database.Database): void {
tokens_output INTEGER,
tokens_reasoning INTEGER,
tokens_cache_read INTEGER,
tokens_cache_write INTEGER,
time_created INTEGER,
time_updated INTEGER
);
@@ -57,9 +58,9 @@ function insertSessionTotalsRow(
db.prepare(
`INSERT INTO session (
id, directory, title, model, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
time_created, time_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
sessionId,
`${WORKTREE}/packages/app`,
@@ -70,6 +71,7 @@ function insertSessionTotalsRow(
100,
0,
0,
0,
1_777_777_700_000,
1_777_777_800_000
)
@@ -235,6 +237,7 @@ describe('parseOpenCodeUsageDatabase', () => {
tokens_output INTEGER,
tokens_reasoning INTEGER,
tokens_cache_read INTEGER,
tokens_cache_write INTEGER,
time_created INTEGER,
time_updated INTEGER
);
@@ -243,9 +246,9 @@ describe('parseOpenCodeUsageDatabase', () => {
db.prepare(
`INSERT INTO session (
id, project_id, directory, title, model, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
time_created, time_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
'session-1',
'project-1',
@@ -257,6 +260,7 @@ describe('parseOpenCodeUsageDatabase', () => {
500,
100,
250,
75,
1_777_777_700_000,
1_777_777_800_000
)
@@ -274,7 +278,7 @@ describe('parseOpenCodeUsageDatabase', () => {
totalCachedInputTokens: 250,
totalOutputTokens: 500,
totalReasoningOutputTokens: 100,
totalTokens: 1850,
totalTokens: 1925,
estimatedCostUsd: 0.06
})
expect(parsed.dailyAggregates).toEqual([
@@ -284,7 +288,7 @@ describe('parseOpenCodeUsageDatabase', () => {
cachedInputTokens: 250,
outputTokens: 500,
reasoningOutputTokens: 100,
totalTokens: 1850,
totalTokens: 1925,
estimatedCostUsd: 0.06
})
])
+2 -2
View File
@@ -91,11 +91,11 @@ describe('OpenCode hook plugin source', () => {
const digest = (source: string): string => createHash('sha256').update(source).digest('hex')
expect(digest(getOpenCodePluginSource())).toBe(
'd14859a36c88aefe3a45cd232789503296e0a23438b151c773414bad64ab8eaa'
'938867eae97b7ae4a7193a07755ee74a77c7a7352edcc28b59543b1b00713612'
)
expect(
digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false }))
).toBe('4de14bee0c27ce55f29f70b19aa6ce9967e09b098bba139fb88f0511af7d4fca')
).toBe('4c9c27af603a9e85e3e33a30c439d9dfb6785936dea0be76fdd64cf7dc2f7174')
})
it('filters child sessions via parentID lookup before forwarding events', () => {
@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest'
import type { ProcessResult, ProcessSpec } from '../../shared/child-process/process-spec'
import {
isOpenCodeClientArgv,
isOpenCodeClientProcess,
nativeWindowsRowToIdentity,
parsePsArgsLine,
parsePsCommLine,
parsePsElapsedToMs,
splitCommandLineArgv,
sweepProcessIdentities
} from './opencode-client-sweep'
const NOW = 1_700_000_000_000
describe('parsePsElapsedToMs', () => {
it('reads mm:ss, hh:mm:ss and dd-hh:mm:ss', () => {
expect(parsePsElapsedToMs('02:11', NOW)).toBe(NOW - 131_000)
expect(parsePsElapsedToMs('1:02:11', NOW)).toBe(NOW - 3_731_000)
expect(parsePsElapsedToMs('2-01:02:11', NOW)).toBe(NOW - 176_531_000)
})
it('rejects unknown shapes', () => {
expect(parsePsElapsedToMs('', NOW)).toBeNull()
expect(parsePsElapsedToMs('yesterday', NOW)).toBeNull()
})
})
describe('parsePsArgsLine', () => {
it('parses a client row', () => {
const row = parsePsArgsLine('23487 22618 2:11:51 opencode', NOW)
expect(row).toMatchObject({ pid: 23487, ppid: 22618, argv: ['opencode'] })
expect(row?.startedAtMs).toBe(NOW - (2 * 3_600 + 11 * 60 + 51) * 1000)
})
it('keeps session flags in argv', () => {
const row = parsePsArgsLine('999 100 00:05 opencode --session ses_abc', NOW)
expect(row?.argv).toEqual(['opencode', '--session', 'ses_abc'])
})
it('keeps a quoted executable path as argv[0]', () => {
const row = parsePsArgsLine('999 100 00:05 "/opt/my tools/opencode" --session ses_abc', NOW)
expect(row?.argv).toEqual(['/opt/my tools/opencode', '--session', 'ses_abc'])
expect(row?.executable).toBe('')
})
it('drops header-shaped and truncated rows', () => {
expect(parsePsArgsLine('PID PPID ELAPSED COMMAND', NOW)).toBeNull()
expect(parsePsArgsLine('1 0', NOW)).toBeNull()
expect(parsePsArgsLine('', NOW)).toBeNull()
})
})
describe('parsePsCommLine', () => {
it('reads the executable name past the pid', () => {
expect(parsePsCommLine('23487 opencode')).toEqual({ pid: 23487, executable: 'opencode' })
})
it('keeps executable names containing spaces whole', () => {
expect(parsePsCommLine(' 999 My App Helper ')).toEqual({
pid: 999,
executable: 'My App Helper'
})
})
it('drops header-shaped and truncated rows', () => {
expect(parsePsCommLine('PID COMMAND')).toBeNull()
expect(parsePsCommLine('1')).toBeNull()
expect(parsePsCommLine('')).toBeNull()
})
})
describe('splitCommandLineArgv', () => {
it('groups double-quoted spans', () => {
expect(
splitCommandLineArgv('"C:\\Program Files\\OpenCode\\opencode.exe" --session ses_1')
).toEqual(['C:\\Program Files\\OpenCode\\opencode.exe', '--session', 'ses_1'])
})
it('splits plain argv on whitespace', () => {
expect(splitCommandLineArgv('opencode --session ses_1')).toEqual([
'opencode',
'--session',
'ses_1'
])
})
it('drops empties', () => {
expect(splitCommandLineArgv('')).toEqual([])
})
})
describe('nativeWindowsRowToIdentity', () => {
it('maps pid, creation time and quoted command line', () => {
const row = nativeWindowsRowToIdentity({
pid: 23487,
ppid: 22618,
name: 'opencode.exe',
creationTimeMs: NOW - 60_000,
command: '"C:\\Program Files\\OpenCode\\opencode.exe" --session ses_1'
})
expect(row).toMatchObject({
pid: 23487,
ppid: 22618,
startedAtMs: NOW - 60_000,
executable: 'opencode.exe',
argv: ['C:\\Program Files\\OpenCode\\opencode.exe', '--session', 'ses_1']
})
})
it('skips rows without a creation time or command line', () => {
expect(nativeWindowsRowToIdentity({ pid: 4, ppid: 0, name: 'System', command: '' })).toBeNull()
expect(
nativeWindowsRowToIdentity({ pid: 4, ppid: 0, name: 'System', command: 'opencode' })
).toBeNull()
})
})
describe('isOpenCodeClientArgv', () => {
it('matches clients and rejects the serve daemon', () => {
expect(isOpenCodeClientArgv(['opencode'])).toBe(true)
expect(isOpenCodeClientArgv(['/opt/homebrew/bin/opencode', '--session', 'ses_1'])).toBe(true)
expect(isOpenCodeClientArgv(['C:\\tools\\opencode.exe'])).toBe(true)
expect(
isOpenCodeClientArgv(['C:\\Program Files\\OpenCode\\opencode.exe', '--session', 'ses_1'])
).toBe(true)
expect(isOpenCodeClientArgv(['opencode.exe', 'serve', '--service'])).toBe(false)
expect(isOpenCodeClientArgv(['node', 'server.js'])).toBe(false)
expect(isOpenCodeClientArgv([])).toBe(false)
})
})
describe('isOpenCodeClientProcess', () => {
it('trusts the executable when argv[0] is truncated by spaces', () => {
expect(
isOpenCodeClientProcess({ executable: 'opencode', argv: ['/opt/Open', 'Code/opencode'] })
).toBe(true)
})
it('still rejects the serve daemon', () => {
expect(
isOpenCodeClientProcess({ executable: 'opencode', argv: ['opencode', 'serve', '--service'] })
).toBe(false)
})
it('falls back to argv[0] without an executable', () => {
expect(isOpenCodeClientProcess({ executable: '', argv: ['opencode'] })).toBe(true)
expect(isOpenCodeClientProcess({ executable: '', argv: ['node', 'server.js'] })).toBe(false)
})
})
describe('sweepProcessIdentities', () => {
function psRunner(outputs: Record<'args' | 'comm', string | Error>): (
spec: ProcessSpec
) => Promise<ProcessResult> {
return async (spec: ProcessSpec): Promise<ProcessResult> => {
const kind = spec.args?.some((arg) => arg.includes('comm=')) ? 'comm' : 'args'
const output = outputs[kind]
if (output instanceof Error) {
throw output
}
return {
code: 0,
signal: null,
stdout: output,
stderr: '',
timedOut: false
}
}
}
it('joins the comm executable onto args rows on POSIX', async () => {
const rows = await sweepProcessIdentities({
platform: 'darwin',
nowMs: NOW,
run: psRunner({
args: '23487 22618 00:05 /opt/Open Code/opencode --session ses_1\n',
comm: '23487 opencode\n999 My App Helper\n'
})
})
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ pid: 23487, executable: 'opencode' })
expect(rows[0]?.argv).toEqual(['/opt/Open', 'Code/opencode', '--session', 'ses_1'])
})
it('degrades to argv[0] matching when the comm sweep fails', async () => {
const rows = await sweepProcessIdentities({
platform: 'darwin',
nowMs: NOW,
run: psRunner({
args: '23487 22618 00:05 opencode --session ses_1\n',
comm: new Error('comm unavailable')
})
})
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ pid: 23487, executable: '' })
})
it('reads the Windows table through the injected reader', async () => {
const rows = await sweepProcessIdentities({
platform: 'win32',
readWindowsTable: async () => [
{
pid: 23487,
ppid: 22618,
name: 'opencode.exe',
creationTimeMs: NOW - 60_000,
command: '"C:\\Program Files\\OpenCode\\opencode.exe"'
}
]
})
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ pid: 23487, ppid: 22618, startedAtMs: NOW - 60_000 })
})
})
+263
View File
@@ -0,0 +1,263 @@
import { runProcess } from '../../shared/child-process/run-process'
import {
readWindowsProcessTable,
type WindowsProcessRow as NativeWindowsProcessRow
} from '../windows/windows-process-table'
/**
* Host-wide sweep locating live OpenCode client processes for the
* session→pane binder (#21359).
*
* Why a dedicated sweep instead of reusing the memory collector's: that
* index carries pid/ppid/cpu/rss but no argv or start times, and importing
* the memory subsystem here would drag its Electron app-metrics dependency
* into the hook path. On Windows the table is read only through the native
* reader (`windows-process-table.ts`); on macOS/Linux through one `ps` call.
* The invocation pattern (5 s timeout, 10 MB cap, fail-open []) mirrors
* `windows-process-resource-collector.ts`.
*/
/** One process identity row from a host sweep. */
export type ProcessIdentityRow = {
pid: number
ppid: number
/** ms epoch the process started. */
startedAtMs: number
/**
* Kernel-reported executable name (`comm=` on POSIX, `name` on Windows).
* Unlike `args=`, this is not a reconstructed string, so an install path
* containing spaces cannot split it. Empty when the sweep could not read it;
* classification then falls back to argv[0].
*/
executable: string
/** argv approximation; see parsePsArgsLine. */
argv: string[]
}
const SWEEP_TIMEOUT_MS = 5_000
const SWEEP_MAX_BYTES = 10 * 1024 * 1024
/** `[[dd-]hh:]mm:ss` → elapsed ms, or null when the shape is unknown. */
export function parsePsElapsedToMs(etime: string, nowMs: number): number | null {
const match = etime.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/)
if (!match) {
return null
}
const days = Number.parseInt(match[1] ?? '0', 10)
const hours = Number.parseInt(match[2] ?? '0', 10)
const minutes = Number.parseInt(match[3] ?? '0', 10)
const seconds = Number.parseInt(match[4] ?? '0', 10)
if ([days, hours, minutes, seconds].some((n) => !Number.isFinite(n) || n < 0)) {
return null
}
return nowMs - ((days * 24 + hours) * 3_600 + minutes * 60 + seconds) * 1000
}
/**
* Split a command line into argv, grouping `"..."` so a quoted executable
* path survives as argv[0]. Covers the shapes that matter here (a quoted
* install path plus plain flags); it is not a full shell parser — an escaped
* quote inside a quoted span still splits. Downstream only flag-adjacent
* values (`--session <id>`) are read from this argv; classification uses the
* kernel executable name, because `ps` `args=` cannot preserve argv
* boundaries for unquoted paths.
*/
export function splitCommandLineArgv(commandLine: string): string[] {
const argv: string[] = []
const pattern = /"([^"]*)"|(\S+)/g
let match: RegExpExecArray | null
while ((match = pattern.exec(commandLine)) !== null) {
argv.push(match[1] ?? match[2] ?? '')
}
return argv.filter((part) => part.length > 0)
}
/**
* One `ps -eo pid=,ppid=,etime=,args=` line. `args` is a reconstructed
* command-and-arguments string: argv boundaries are lost, so a path with an
* unquoted space (e.g. `/opt/Open Code/opencode`) splits argv[0] in two.
* The executable name therefore comes from a separate `comm=` sweep;
* this parser records the flags it can still read reliably (`--session`
* values never contain spaces) and leaves `executable` empty for the join.
*/
export function parsePsArgsLine(line: string, nowMs: number): ProcessIdentityRow | null {
// Why a regex instead of split-with-limit: split discards everything past
// the limit, which would truncate argv to its first token.
const match = line.trim().match(/^(\S+)\s+(\S+)\s+(\S+)\s+([\s\S]*\S)\s*$/)
if (!match) {
return null
}
const [, pidText, ppidText, etimeText, argsText] = match
if (!pidText || !ppidText || !etimeText || !argsText) {
return null
}
const pid = Number.parseInt(pidText, 10)
const ppid = Number.parseInt(ppidText, 10)
const startedAtMs = parsePsElapsedToMs(etimeText, nowMs)
const argv = splitCommandLineArgv(argsText)
if (
!Number.isFinite(pid) ||
!Number.isFinite(ppid) ||
startedAtMs === null ||
argv.length === 0
) {
return null
}
return { pid, ppid, startedAtMs, executable: '', argv }
}
/**
* One `ps -eo pid=,comm=` line. `comm` is the kernel's executable name as a
* trailing field, so it may itself contain spaces — everything past the pid
* is the name. Empty names are dropped; the join then falls back to argv[0].
*/
export function parsePsCommLine(line: string): { pid: number; executable: string } | null {
const match = line.trim().match(/^(\S+)\s+([\s\S]*\S)\s*$/)
if (!match) {
return null
}
const [, pidText, executable] = match
const pid = Number.parseInt(pidText ?? '', 10)
if (!Number.isFinite(pid) || !executable) {
return null
}
return { pid, executable }
}
/**
* One native Windows process-table row. Rows without a kernel creation time
* cannot bracket a session creation, so they are skipped rather than guessed.
*/
export function nativeWindowsRowToIdentity(
row: NativeWindowsProcessRow
): ProcessIdentityRow | null {
if (!Number.isFinite(row.pid) || !Number.isFinite(row.ppid)) {
return null
}
if (typeof row.creationTimeMs !== 'number' || !Number.isFinite(row.creationTimeMs)) {
return null
}
const argv = splitCommandLineArgv(row.command)
if (argv.length === 0) {
return null
}
return {
pid: row.pid,
ppid: row.ppid,
startedAtMs: row.creationTimeMs,
executable: row.name,
argv
}
}
function executableBaseName(value: string): string {
const bare = value.split(/[\\/]/).at(-1) ?? ''
return bare.toLowerCase().replace(/\.exe$/, '')
}
function argvZeroBase(argv: readonly string[]): string {
return executableBaseName(argv[0] ?? '')
}
/** True for an OpenCode TUI/CLI client process (not the `serve` daemon). */
export function isOpenCodeClientArgv(argv: readonly string[]): boolean {
return isOpenCodeClientProcess({ executable: '', argv })
}
/**
* True for an OpenCode TUI/CLI client process (not the `serve` daemon).
* The kernel-reported executable wins when present: `ps` `args=` cannot
* preserve argv boundaries, so a truncated argv[0] must not veto a matching
* executable. With no executable recorded this degrades to argv[0] matching.
*/
export function isOpenCodeClientProcess(row: {
executable: string
argv: readonly string[]
}): boolean {
const classified =
row.executable && row.executable.length > 0
? executableBaseName(row.executable)
: argvZeroBase(row.argv)
if (classified !== 'opencode') {
return false
}
// Why exclude: the shared server's posts are the ones being reattributed;
// mistaking the daemon for a pane client would bind sessions to its pane.
return !row.argv.some((part) => part === 'serve' || part === '--service')
}
/** Every process identity row on this host; fail-open [] like the memory sweeps. */
export async function sweepProcessIdentities(
deps: {
platform?: NodeJS.Platform
run?: typeof runProcess
nowMs?: number
readWindowsTable?: () => Promise<NativeWindowsProcessRow[]>
} = {}
): Promise<ProcessIdentityRow[]> {
const platform = deps.platform ?? process.platform
const run = deps.run ?? runProcess
const nowMs = deps.nowMs ?? Date.now()
try {
if (platform === 'win32') {
// Why the native table and nothing else: it is the only sanctioned
// Windows process-table reader (see windows-process-enumeration.md);
// forking powershell.exe for a whole-table CIM scan is exactly the
// pattern it retired.
const readTable = deps.readWindowsTable ?? readWindowsProcessTable
const rows = await readTable()
return rows
.map((row) => nativeWindowsRowToIdentity(row))
.filter((row): row is ProcessIdentityRow => row !== null)
}
const stdout = await execFileText(run, 'ps', ['-eo', 'pid=,ppid=,etime=,args='])
const rows = stdout
.split('\n')
.map((line) => parsePsArgsLine(line, nowMs))
.filter((row): row is ProcessIdentityRow => row !== null)
// Why a second sweep: `args=` is one reconstructed string, so the
// executable name for classification comes from `comm=` instead. A
// failed comm sweep degrades to argv[0] matching rather than dropping
// the whole round.
try {
const commOut = await execFileText(run, 'ps', ['-eo', 'pid=,comm='])
const executables = new Map<number, string>()
for (const line of commOut.split('\n')) {
const parsed = parsePsCommLine(line)
if (parsed && !executables.has(parsed.pid)) {
executables.set(parsed.pid, parsed.executable)
}
}
for (const row of rows) {
const executable = executables.get(row.pid)
if (executable) {
row.executable = executable
}
}
} catch (err) {
console.warn('[opencode-binder] comm sweep failed; classifying from argv', err)
}
return rows
} catch (err) {
console.warn('[opencode-binder] process sweep failed; skipping round', err)
return []
}
}
/** Run one child process to text, throwing on timeout or nonzero exit. */
async function execFileText(
run: typeof runProcess,
program: string,
args: string[]
): Promise<string> {
const result = await run({
program,
args,
timeoutMs: SWEEP_TIMEOUT_MS,
maxOutputBytes: SWEEP_MAX_BYTES
})
if (result.timedOut || result.code !== 0) {
throw new Error(`${program} exited ${result.code ?? 'on timeout'}`)
}
return result.stdout
}
@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest'
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
import { lookupOpenCodeSessionPane } from '../../shared/agent-hook-listener/opencode-session-registry'
import { makePaneKey } from '../../shared/stable-pane-id'
import type { ProcessIdentityRow } from './opencode-client-sweep'
import {
advanceBinderCursor,
applyBinderOwnerships,
OPENCODE_SESSION_CURSOR_START,
runOpenCodeBinderRound,
type BinderPaneSnapshot
} from './opencode-session-binder'
const NOW = 1_700_000_100_000
const DIR = '/Users/jin/work/mocitec'
const PANE_A = makePaneKey('tab-a', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
const PANE_B = makePaneKey('tab-b', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')
function proc(
pid: number,
ppid: number,
argv: string[],
startedAtMs = NOW - 120_000
): ProcessIdentityRow {
return { pid, ppid, startedAtMs, executable: argv[0] ?? '', argv }
}
function pane(paneKey: string, shellPid: number | null): BinderPaneSnapshot {
return { paneKey, directory: DIR, worktreeId: 'repo::/Users/jin/work/mocitec', shellPid }
}
describe('runOpenCodeBinderRound', () => {
it('attributes a client to its pane subtree and binds the session', () => {
const { ownerships } = runOpenCodeBinderRound({
nowMs: NOW,
sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }],
panes: [pane(PANE_A, 100), pane(PANE_B, 200)],
processes: [
proc(100, 1, ['zsh']),
proc(200, 1, ['zsh']),
proc(210, 200, ['opencode'], NOW - 90_000)
],
knownOwners: new Map(),
parentBySessionId: new Map()
})
expect(ownerships).toEqual([
{ sessionId: 'ses_1', paneKey: PANE_B, basis: 'creation-correlation' }
])
})
it('ignores clients outside every pane subtree', () => {
const { ownerships } = runOpenCodeBinderRound({
nowMs: NOW,
sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }],
panes: [pane(PANE_A, 100)],
processes: [proc(100, 1, ['zsh']), proc(999, 1, ['opencode'], NOW - 90_000)],
knownOwners: new Map(),
parentBySessionId: new Map()
})
expect(ownerships).toEqual([])
})
it('inherits a root owner across the watermark via the parent map', () => {
const { ownerships } = runOpenCodeBinderRound({
nowMs: NOW,
sessions: [
{ id: 'ses_child', directory: DIR, createdAtMs: NOW - 30_000, parentId: 'ses_root' }
],
panes: [pane(PANE_A, 100)],
processes: [proc(100, 1, ['zsh']), proc(101, 100, ['opencode'], NOW - 3_600_000)],
knownOwners: new Map([['ses_root', PANE_A]]),
parentBySessionId: new Map([['ses_child', 'ses_root']])
})
expect(ownerships).toEqual([
{ sessionId: 'ses_child', paneKey: PANE_A, basis: 'creation-correlation' }
])
})
it('dedupes same-key snapshots newest-wins', () => {
const { ownerships } = runOpenCodeBinderRound({
nowMs: NOW,
sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }],
panes: [
{ ...pane(PANE_A, 100), directory: '/elsewhere' },
{ ...pane(PANE_A, 101), directory: DIR }
],
processes: [
proc(100, 1, ['zsh']),
proc(101, 1, ['zsh']),
proc(102, 101, ['opencode'], NOW - 90_000)
],
knownOwners: new Map(),
parentBySessionId: new Map()
})
expect(ownerships).toEqual([
{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'single-pane-directory' }
])
})
it('advances the cursor past handled rows only', () => {
const fresh = [
{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null },
{ id: 'ses_2', directory: DIR, createdAtMs: NOW - 10_000, parentId: null }
]
expect(
advanceBinderCursor({
fresh,
isHandled: () => true,
current: OPENCODE_SESSION_CURSOR_START
})
).toEqual({ ms: NOW - 10_000, id: 'ses_2' })
})
it('freezes the cursor before the first unhandled row so it is re-listed', () => {
const fresh = [
{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null },
{ id: 'ses_2', directory: DIR, createdAtMs: NOW - 10_000, parentId: null }
]
expect(
advanceBinderCursor({
fresh,
isHandled: (id) => id === 'ses_1',
current: OPENCODE_SESSION_CURSOR_START
})
).toEqual({ ms: NOW - 60_000, id: 'ses_1' })
})
it('keeps the cursor when nothing was handled', () => {
const current = { ms: NOW - 120_000, id: 'ses_0' }
expect(
advanceBinderCursor({
fresh: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }],
isHandled: () => false,
current
})
).toBe(current)
})
})
describe('applyBinderOwnerships', () => {
it('writes bindings with the pane worktree into the registry', () => {
const state = createHookListenerState()
const applied = applyBinderOwnerships(
state,
[pane(PANE_A, 100)],
[{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'argv' }],
NOW
)
expect(applied).toBe(1)
expect(lookupOpenCodeSessionPane(state, 'ses_1')).toMatchObject({
paneKey: PANE_A,
worktreeId: 'repo::/Users/jin/work/mocitec'
})
})
it('takes the newest row worktree when a pane remints', () => {
const state = createHookListenerState()
// Registry insertion order puts the stale row first; the live remint row
// carries a different worktree and must win, matching the round's
// newest-wins pane dedupe.
const applied = applyBinderOwnerships(
state,
[
{ paneKey: PANE_A, directory: '/elsewhere', worktreeId: 'repo::/elsewhere', shellPid: 100 },
{ ...pane(PANE_A, 101) }
],
[{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'argv' }],
NOW
)
expect(applied).toBe(1)
expect(lookupOpenCodeSessionPane(state, 'ses_1')).toMatchObject({
paneKey: PANE_A,
worktreeId: 'repo::/Users/jin/work/mocitec'
})
})
})
@@ -0,0 +1,341 @@
import { resolveOpenCodeDataDirectory } from './opencode-data-directory'
import {
bindOpenCodeSession,
type OpenCodeSessionBinding
} from '../../shared/agent-hook-listener/opencode-session-registry'
import {
correlateOpenCodeSessionOwners,
type CorrelatedClient,
type CorrelatedPane,
type CorrelatedSession,
type SessionOwnership
} from '../../shared/agent-hook-listener/opencode-session-correlation'
import { readOpenCodeDatabase } from '../ai-vault/session-scanner-opencode-sqlite-open'
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).
*
* Each round: read new sessions from the shared server's SQLite store,
* snapshot panes, sweep for live clients, correlate, bind. Everything the
* round needs is injected so the decision core stays unit-testable; only
* the SQLite reader below touches disk, reusing ai-vault's guarded open
* (read-only + query_only + busy timeout).
*/
/** One pane snapshot feeding a binder round. */
export type BinderPaneSnapshot = {
paneKey: string
/** Worktree root backing the pane (null when unknown); sessions beneath it are candidates. */
directory: string | null
worktreeId: string | null
shellPid: number | null
}
/** One session store row feeding a binder round. */
export type BinderSessionRow = {
id: string
directory: string
createdAtMs: number
parentId: string | null
}
/** Everything one binder round needs, injected for tests. */
export type BinderRoundDeps = {
nowMs: number
sessions: readonly BinderSessionRow[]
panes: readonly BinderPaneSnapshot[]
processes: readonly ProcessIdentityRow[]
knownOwners: ReadonlyMap<string, string>
parentBySessionId: ReadonlyMap<string, string | null>
}
/** Ownership decisions from one binder round. */
export type BinderRoundResult = {
ownerships: SessionOwnership[]
}
/** Position in the session store; composite so same-millisecond rows are never skipped. */
export type OpenCodeSessionCursor = {
ms: number
id: string
}
/** Cursor before anything was ever read. */
export const OPENCODE_SESSION_CURSOR_START: OpenCodeSessionCursor = { ms: 0, id: '' }
/** Order cursors the way the store lists rows: oldest first, id as tiebreak. */
function compareSessionRows(left: OpenCodeSessionCursor, right: OpenCodeSessionCursor): number {
return left.ms - right.ms || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
}
/**
* Advance the store cursor past handled rows only. `fresh` arrives in store
* order; handling is prefix-closed (the unbound-cap break only ever skips the
* tail), so the first unhandled row freezes the cursor and every row at or
* past it is re-listed next round instead of silently dropped.
*/
export function advanceBinderCursor(args: {
fresh: readonly BinderSessionRow[]
isHandled: (sessionId: string) => boolean
current: OpenCodeSessionCursor
}): OpenCodeSessionCursor {
let cursor = args.current
for (const session of args.fresh) {
if (!args.isHandled(session.id)) {
break
}
const candidate: OpenCodeSessionCursor = { ms: session.createdAtMs, id: session.id }
if (compareSessionRows(candidate, cursor) > 0) {
cursor = candidate
}
}
return cursor
}
/** pid→ppid index for one sweep; first row wins on duplicate pids. */
function childrenIndex(processes: readonly ProcessIdentityRow[]): Map<number, number> {
const ppidByPid = new Map<number, number>()
for (const row of processes) {
if (!ppidByPid.has(row.pid)) {
ppidByPid.set(row.pid, row.ppid)
}
}
return ppidByPid
}
/** Nearest pane shell at or above this pid; external terminals stay unattributed. */
function owningPane(
ppidByPid: Map<number, number>,
shellPidByPid: Map<number, string>,
pid: number
): string | null {
const seen = new Set<number>()
let current: number | undefined = pid
while (current !== undefined && !seen.has(current)) {
seen.add(current)
const owner = shellPidByPid.get(current)
if (owner) {
return owner
}
current = ppidByPid.get(current)
}
return null
}
/** Attribute opencode client rows to panes via shell-subtree walks. */
function toCorrelatedClients(
processes: readonly ProcessIdentityRow[],
panes: readonly BinderPaneSnapshot[],
nowMs: number
): CorrelatedClient[] {
const ppidByPid = childrenIndex(processes)
const shellPidByPid = new Map<number, string>()
for (const pane of panes) {
if (pane.shellPid !== null && !shellPidByPid.has(pane.shellPid)) {
shellPidByPid.set(pane.shellPid, pane.paneKey)
}
}
const clients: CorrelatedClient[] = []
for (const row of processes) {
if (!isOpenCodeClientProcess(row)) {
continue
}
const paneKey = owningPane(ppidByPid, shellPidByPid, row.pid)
if (!paneKey) {
continue
}
// Why lastSeenAlive = now: the sweep just observed it. Bracketing uses
// startedAt for the lower bound and this observation for the upper.
clients.push({ paneKey, startedAtMs: row.startedAtMs, lastSeenAliveMs: nowMs, argv: row.argv })
}
return clients
}
/** Pure round core: correlate unbound sessions against panes and clients. */
export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult {
// Why dedupe by key, newest wins: remints and reattachments can leave a
// stale registry row beside the live one; counting rows instead of panes
// would turn every same-pane tie into a false ambiguous and nothing would
// ever bind, while the oldest row would point the candidate set at a dead
// worktree.
const paneByKey = new Map<string, CorrelatedPane>()
for (const pane of deps.panes) {
paneByKey.set(pane.paneKey, { paneKey: pane.paneKey, directory: pane.directory })
}
const panes = [...paneByKey.values()]
const clients = toCorrelatedClients(deps.processes, deps.panes, deps.nowMs)
const sessions: CorrelatedSession[] = deps.sessions.map((row) => ({
id: row.id,
directory: row.directory,
createdAtMs: row.createdAtMs,
parentId: row.parentId
}))
// Why resolve inheritance here: the SQLite round only carries new rows, so
// an old root is invisible to the correlator; the binder's parent map walks
// the chain and the registry supplies the known root owner. Resolved heirs
// are emitted as binds (the registry lacks them) and fed back as known so
// the correlator skips what is already decided.
const knownOwners = new Map(deps.knownOwners)
const inherited: SessionOwnership[] = []
for (const session of sessions) {
if (knownOwners.has(session.id) || !session.parentId) {
continue
}
let parent: string | null | undefined = session.parentId
const seen = new Set<string>([session.id])
while (parent && !seen.has(parent)) {
seen.add(parent)
const owner = knownOwners.get(parent)
if (owner) {
knownOwners.set(session.id, owner)
inherited.push({ sessionId: session.id, paneKey: owner, basis: 'creation-correlation' })
break
}
parent = deps.parentBySessionId.get(parent)
}
}
const ownerships = [
...inherited,
...correlateOpenCodeSessionOwners({
sessions,
panes,
clients,
knownOwners
})
]
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
* cursor — including rows the LIMIT cut off last round — are re-listed
* instead of permanently skipped. Unknown shapes read as empty so an opencode
* schema move degrades to unbound sessions, never a crash. Fail-open [] on
* any read error for the same reason.
*/
export function listOpenCodeDbSessions(
dbPath: string,
cursor: OpenCodeSessionCursor
): BinderSessionRow[] {
try {
return readOpenCodeDatabase({
dbPath,
read: (db) => {
const table = canReadSessionV2(db) ? 'session_v2' : 'session'
if (
!tableExists(db, table) ||
!columnExists(db, table, 'directory') ||
!columnExists(db, table, 'time_created')
) {
return []
}
const parent = columnExists(db, table, 'parent_id') ? 'parent_id' : 'NULL'
const rows: unknown[] = db
.prepare(
`SELECT id, directory, time_created, ${parent} AS parent_id FROM ${table} WHERE time_created > ? OR (time_created = ? AND id > ?) ORDER BY time_created ASC, id ASC LIMIT 500`
)
.all(cursor.ms, cursor.ms, cursor.id)
const sessions: BinderSessionRow[] = []
for (const row of rows) {
if (typeof row !== 'object' || row === null) {
continue
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node:sqlite returns plain row objects; the object check above plus the per-field validation below reject anything else.
const record = row as Record<string, unknown>
if (
typeof record.id !== 'string' ||
typeof record.directory !== 'string' ||
typeof record.time_created !== 'number'
) {
continue
}
sessions.push({
id: record.id,
directory: record.directory,
createdAtMs: record.time_created,
parentId: typeof record.parent_id === 'string' ? record.parent_id : null
})
}
return sessions
}
})
} catch (err) {
console.warn('[opencode-binder] session store read failed; skipping round', err)
return []
}
}
/** Default database path for the local shared server. */
export function defaultOpenCodeDbPath(): string {
return `${resolveOpenCodeDataDirectory()}/opencode.db`
}
/**
* Live local panes from the PTY registry: pane key, worktree root and shell
* pid. Panes without a key or pid (hydrated gaps, remote panes) cannot own a
* client subtree, so they are skipped — their sessions stay unbound rather
* than guessed.
*/
export function listBinderPaneSnapshots(): BinderPaneSnapshot[] {
const snapshots: BinderPaneSnapshot[] = []
for (const pty of listRegisteredPtys()) {
if (!pty.paneKey || pty.pid === null) {
continue
}
const parsed = pty.worktreeId ? splitWorktreeIdForFilesystem(pty.worktreeId) : null
snapshots.push({
paneKey: pty.paneKey,
directory: parsed?.worktreePath ?? null,
worktreeId: pty.worktreeId,
shellPid: pty.pid
})
}
return snapshots
}
/** Apply one round's decisions to the listener registry. */
export function applyBinderOwnerships(
state: HookListenerState,
panes: readonly BinderPaneSnapshot[],
ownerships: readonly SessionOwnership[],
nowMs: number
): number {
const worktreeByPane = new Map<string, string | null>()
for (const pane of panes) {
// Why overwrite: matching the round's newest-wins pane dedupe, so a
// remint's live row wins over a stale row with a different worktree.
worktreeByPane.set(pane.paneKey, pane.worktreeId)
}
let applied = 0
for (const ownership of ownerships) {
const binding: OpenCodeSessionBinding = {
paneKey: ownership.paneKey,
boundAt: nowMs,
basis: ownership.basis
}
const worktreeId = worktreeByPane.get(ownership.paneKey)
if (worktreeId) {
binding.worktreeId = worktreeId
}
if (bindOpenCodeSession(state, ownership.sessionId, binding)) {
applied += 1
}
}
return applied
}
@@ -7,6 +7,7 @@ export function getStatusPluginFactorySource(options: {
emitSessionStart: boolean
emitNextEvents?: boolean
}): string[] {
const expectedAgent = options.emitNextEvents ? 'opencode2' : 'opencode'
return [
...(options.emitNextEvents ? getOpenCode2EventNormalizationSource() : []),
'// Why: accept the factory argument as an optional opaque parameter instead',
@@ -15,6 +16,7 @@ export function getStatusPluginFactorySource(options: {
'// destructuring form throw synchronously and crash OpenCode with an opaque',
'// UnknownError before any event is ever dispatched.',
'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {',
` if (process.env.ORCA_OPENCODE_AGENT && process.env.ORCA_OPENCODE_AGENT !== '${expectedAgent}') return {};`,
' const client = _ctx?.client;',
' const factoryID = ++nextFactoryID;',
' activeFactoryIDs.add(factoryID);',
@@ -1,172 +0,0 @@
// Why: the opencode.ai page is rendered with React Server Components. The
// embedded JS uses a wire format where object references look like:
// key:$R[28]={field:value,...}
// rather than plain `key:{field:value,...}`. A single key (e.g. monthlyUsage)
// can appear multiple times — once with real data and once as `null` inside a
// different component's props. We must find the occurrence that is an object
// with both usagePercent and resetInSec, not the null one.
/**
* Finds the brace-balanced object block assigned to `key` anywhere in `text`.
* Skips React Flight assignment tokens (e.g. `$R[N]=`) between the colon and
* the opening brace. Returns the first block that contains `usagePercent` AND
* `resetInSec` as direct numeric properties (not nested), so that placeholder
* `null` occurrences and billing-context duplicates are ignored.
*/
function extractUsageBlock(text: string, key: string): string | null {
// Match every occurrence of `key:` (with optional $R[N]= assignment)
// Why: React Flight wire format embeds object references between the colon
// and the literal brace, so we skip over any `$R[N]=` tokens to reach `{`.
const keyRegex = new RegExp(`\\b${key}\\b\\s*:`, 'g')
let keyMatch: RegExpExecArray | null
while ((keyMatch = keyRegex.exec(text)) !== null) {
// Scan forward from after the colon to find the opening `{`,
// allowing for the `$R[N]=` token or plain whitespace in between.
// We only scan a short window so we don't accidentally land on the
// next occurrence of the key.
const searchStart = keyMatch.index + keyMatch[0].length
const searchWindow = text.slice(searchStart, searchStart + 30)
const braceOffset = searchWindow.indexOf('{')
if (braceOffset === -1) {
// This occurrence has no object (e.g. `monthlyUsage:null`) — skip.
continue
}
const openBrace = searchStart + braceOffset
// Extract the balanced block
// Why: this brace-depth parser does not skip string literals. React Flight's
// current format does not emit raw { } inside strings, but this is a scraper
// against HTML we don't control — treat as fragile.
let depth = 0
let block: string | null = null
for (let i = openBrace; i < text.length; i++) {
if (text[i] === '{') {
depth++
} else if (text[i] === '}') {
depth--
if (depth === 0) {
block = text.slice(openBrace, i + 1)
break
}
}
}
if (!block) {
continue
}
// Verify this block has both required numeric fields as direct properties
// (depth 1 within the block). This rejects billing/plan objects that share
// the key name but lack usage data.
if (
hasDirectNumericField(block, 'usagePercent') &&
hasDirectNumericField(block, 'resetInSec')
) {
return block
}
}
return null
}
/**
* Returns true if `fieldName` exists as a direct (depth-1) numeric property
* of the object string `objText`.
*/
function hasDirectNumericField(objText: string, fieldName: string): boolean {
return extractTopLevelNumber(objText, fieldName) !== null
}
/**
* Extracts a numeric field at depth 1 of `objText` — ignores the same field
* inside nested sub-objects.
* Why: without depth tracking, a regex matches the first occurrence regardless
* of nesting, returning wrong values when a sub-object contains the same name.
*/
function extractTopLevelNumber(objText: string, fieldName: string): number | null {
const fieldRegex = new RegExp(`\\b${fieldName}\\b\\s*:\\s*(-?[0-9]+(?:\\.[0-9]+)?)`)
// Why: this brace-depth parser does not skip string literals. React Flight's
// current format does not emit raw { } inside strings, but this is a scraper
// against HTML we don't control — treat as fragile.
let depth = 0
for (let i = 0; i < objText.length; i++) {
const ch = objText[i]
if (ch === '{') {
depth++
continue
}
if (ch === '}') {
depth--
continue
}
// Only match at depth 1 (direct property of the root object).
if (depth === 1) {
const slice = objText.slice(i, i + fieldName.length + 30)
const m = fieldRegex.exec(slice)
if (m && m.index === 0) {
const n = Number.parseFloat(m[1])
return Number.isFinite(n) ? n : null
}
}
}
return null
}
type ParsedSubscription = {
rollingUsagePercent: number
weeklyUsagePercent: number
monthlyUsagePercent: number | null
rollingResetInSec: number
weeklyResetInSec: number
monthlyResetInSec: number | null
}
export function parseSubscriptionFromPageText(text: string): ParsedSubscription | null {
// Why: OpenCode usage is scraped from HTML-embedded JS (React Flight wire
// format). Defensive size check prevents runaway parsing on unexpected payloads.
if (!text || text.length > 10_000_000) {
return null
}
// Find the first occurrence of each usage key that has both usagePercent and
// resetInSec as direct numeric fields. This skips null occurrences and
// billing-context duplicates that use the same key name without usage data.
const rollingBlock = extractUsageBlock(text, 'rollingUsage')
const weeklyBlock = extractUsageBlock(text, 'weeklyUsage')
const monthlyBlock = extractUsageBlock(text, 'monthlyUsage')
const rollingPercent =
rollingBlock !== null ? extractTopLevelNumber(rollingBlock, 'usagePercent') : null
const rollingReset =
rollingBlock !== null ? extractTopLevelNumber(rollingBlock, 'resetInSec') : null
const weeklyPercent =
weeklyBlock !== null ? extractTopLevelNumber(weeklyBlock, 'usagePercent') : null
const weeklyReset = weeklyBlock !== null ? extractTopLevelNumber(weeklyBlock, 'resetInSec') : null
if (
rollingPercent === null ||
rollingReset === null ||
weeklyPercent === null ||
weeklyReset === null
) {
return null
}
const monthlyPercent =
monthlyBlock !== null ? extractTopLevelNumber(monthlyBlock, 'usagePercent') : null
const monthlyReset =
monthlyBlock !== null ? extractTopLevelNumber(monthlyBlock, 'resetInSec') : null
return {
rollingUsagePercent: Math.min(100, Math.max(0, rollingPercent)),
weeklyUsagePercent: Math.min(100, Math.max(0, weeklyPercent)),
monthlyUsagePercent:
monthlyPercent !== null ? Math.min(100, Math.max(0, monthlyPercent)) : null,
rollingResetInSec: rollingReset,
weeklyResetInSec: weeklyReset,
monthlyResetInSec: monthlyReset
}
}
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing'
const ISSUE_PAYLOAD = {
access: {
meters: {
fiveHour: {
resetsAt: '2026-09-18T12:42:04.962Z',
limitMicroCents: '1200000000',
usedMicroCents: '121745383'
},
week: {
resetsAt: '2026-09-21T00:00:00.000Z',
limitMicroCents: '3000000000',
usedMicroCents: '121745383'
},
month: {
limitMicroCents: '6000000000',
usedMicroCents: '121745383'
}
}
}
}
describe('parseOpenCodeGoStatusPayload', () => {
it('maps fiveHour/week/month meters into session/weekly/monthly windows', () => {
const parsed = parseOpenCodeGoStatusPayload(JSON.stringify(ISSUE_PAYLOAD))
expect(parsed).not.toBeNull()
expect(parsed?.session).toEqual({
usedPercent: (121745383 / 1_200_000_000) * 100,
windowMinutes: 300,
resetsAt: Date.parse('2026-09-18T12:42:04.962Z'),
resetDescription: null
})
expect(parsed?.weekly).toEqual({
usedPercent: (121745383 / 3_000_000_000) * 100,
windowMinutes: 10_080,
resetsAt: Date.parse('2026-09-21T00:00:00.000Z'),
resetDescription: null
})
expect(parsed?.monthly).toEqual({
usedPercent: (121745383 / 6_000_000_000) * 100,
windowMinutes: 43_200,
resetsAt: null,
resetDescription: null
})
})
it('accepts numeric microCents', () => {
const parsed = parseOpenCodeGoStatusPayload(
JSON.stringify({
access: {
meters: {
fiveHour: {
resetsAt: '2026-09-18T12:42:04.962Z',
limitMicroCents: 100,
usedMicroCents: 25
},
week: {
resetsAt: '2026-09-21T00:00:00.000Z',
limitMicroCents: 200,
usedMicroCents: 50
}
}
}
})
)
expect(parsed?.session?.usedPercent).toBe(25)
expect(parsed?.weekly?.usedPercent).toBe(25)
expect(parsed?.monthly).toBeNull()
})
it('caps usedPercent at 100 and floors at 0', () => {
const parsed = parseOpenCodeGoStatusPayload(
JSON.stringify({
access: {
meters: {
fiveHour: {
resetsAt: '2026-09-18T12:42:04.962Z',
limitMicroCents: '100',
usedMicroCents: '150'
},
week: {
resetsAt: '2026-09-21T00:00:00.000Z',
limitMicroCents: '100',
usedMicroCents: '-5'
}
}
}
})
)
expect(parsed?.session?.usedPercent).toBe(100)
expect(parsed?.weekly?.usedPercent).toBe(0)
})
it('returns null for HTML and other non-JSON bodies', () => {
expect(parseOpenCodeGoStatusPayload('<html>rollingUsage:{usagePercent:30}</html>')).toBeNull()
expect(parseOpenCodeGoStatusPayload('')).toBeNull()
expect(parseOpenCodeGoStatusPayload('{not json')).toBeNull()
})
it('returns null when fiveHour or week meters are missing', () => {
expect(
parseOpenCodeGoStatusPayload(
JSON.stringify({
access: {
meters: {
week: { limitMicroCents: '100', usedMicroCents: '10' }
}
}
})
)
).toBeNull()
})
})
@@ -0,0 +1,86 @@
import type { RateLimitWindow } from '../../shared/rate-limit-types'
const SESSION_WINDOW_MINUTES = 300
const WEEKLY_WINDOW_MINUTES = 10_080
const MONTHLY_WINDOW_MINUTES = 43_200
const MAX_STATUS_PAYLOAD_CHARS = 1_000_000
export type OpenCodeGoUsageWindows = {
session: RateLimitWindow
weekly: RateLimitWindow
monthly: RateLimitWindow | null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseMicroCents(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
if (!trimmed) {
return null
}
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : null
}
function parseResetsAt(value: unknown): number | null {
if (typeof value !== 'string' || value.trim() === '') {
return null
}
const resetsAt = Date.parse(value)
return Number.isFinite(resetsAt) ? resetsAt : null
}
function meterToWindow(meter: unknown, windowMinutes: number): RateLimitWindow | null {
if (!isRecord(meter)) {
return null
}
const used = parseMicroCents(meter.usedMicroCents)
const limit = parseMicroCents(meter.limitMicroCents)
if (used === null || limit === null || limit <= 0) {
return null
}
return {
usedPercent: Math.min(100, Math.max(0, (used / limit) * 100)),
windowMinutes,
resetsAt: parseResetsAt(meter.resetsAt),
resetDescription: null
}
}
export function parseOpenCodeGoStatusPayload(text: string): OpenCodeGoUsageWindows | null {
if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) {
return null
}
let payload: unknown
try {
payload = JSON.parse(text)
} catch {
return null
}
if (!isRecord(payload) || !isRecord(payload.access) || !isRecord(payload.access.meters)) {
return null
}
const meters = payload.access.meters
const session = meterToWindow(meters.fiveHour, SESSION_WINDOW_MINUTES)
const weekly = meterToWindow(meters.week, WEEKLY_WINDOW_MINUTES)
if (!session || !weekly) {
return null
}
return {
session,
weekly,
monthly: meterToWindow(meters.month, MONTHLY_WINDOW_MINUTES)
}
}
@@ -12,7 +12,10 @@ vi.mock('electron', () => ({
}))
import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-usage-fetcher'
const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f'
const CONSOLE_STATUS_URL = 'https://opencode.ai/console/api/go/status'
const LEGACY_WORKSPACE_GO_URL = /https:\/\/opencode\.ai\/workspace\/[^/]+\/go/
function makeResponse(body: string, status = 200): Response {
return {
@@ -22,25 +25,61 @@ function makeResponse(body: string, status = 200): Response {
} as Response
}
// Real React Flight wire format from opencode.ai — keys like `monthlyUsage`
// appear multiple times: once with actual data (as `$R[N]={...}`) and once as
// `null` inside a billing-context object. The parser must pick the data one.
const USAGE_PAGE_WITH_MONTHLY = `
<html><body><script>
$RC=function(a,b){/*...*/};
$R[20]={rollingUsage:$R[21]={status:"ok",resetInSec:7200,usagePercent:30},weeklyUsage:$R[22]={status:"ok",resetInSec:259200,usagePercent:51},monthlyUsage:$R[23]={status:"ok",resetInSec:1296000,usagePercent:89}};
$R[14]={customerID:"cus_ABC",reloadTrigger:5,monthlyLimit:null,monthlyUsage:null,timeMonthlyUsageUpdated:null};
</script></body></html>
`
function makeJsonResponse(body: unknown, status = 200): Response {
return makeResponse(JSON.stringify(body), status)
}
const USAGE_PAGE_NO_MONTHLY = `
const STATUS_WITH_MONTHLY = {
access: {
meters: {
fiveHour: {
resetsAt: '2026-04-24T14:00:00.000Z',
limitMicroCents: '1000',
usedMicroCents: '300'
},
week: {
resetsAt: '2026-05-01T12:00:00.000Z',
limitMicroCents: '1000',
usedMicroCents: '510'
},
month: {
resetsAt: '2026-05-24T12:00:00.000Z',
limitMicroCents: '1000',
usedMicroCents: '890'
}
}
}
}
const STATUS_NO_MONTHLY = {
access: {
meters: {
fiveHour: {
resetsAt: '2026-04-24T13:00:00.000Z',
limitMicroCents: '100',
usedMicroCents: '10'
},
week: {
resetsAt: '2026-04-25T12:00:00.000Z',
limitMicroCents: '100',
usedMicroCents: '20'
}
}
}
}
const LEGACY_USAGE_PAGE = `
<html><body><script>
$R[20]={rollingUsage:$R[21]={status:"ok",resetInSec:3600,usagePercent:10},weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:20}};
$R[20]={rollingUsage:$R[21]={status:"ok",resetInSec:7200,usagePercent:30},weeklyUsage:$R[22]={status:"ok",resetInSec:259200,usagePercent:51},monthlyUsage:$R[23]={status:"ok",resetInSec:1296000,usagePercent:89}};
</script></body></html>
`
const WORKSPACES_RESPONSE = 'id: "wrk_TESTWORKSPACEID123"'
function requestedUrls(): string[] {
return netFetchMock.mock.calls.map(([url]) => String(url))
}
describe('fetchOpenCodeGoRateLimits', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -79,7 +118,7 @@ describe('fetchOpenCodeGoRateLimits', () => {
expect(netFetchMock).not.toHaveBeenCalled()
})
it('returns error when cookie has no auth or __Host-auth name', async () => {
it('returns error when cookie has no known auth name', async () => {
const result = await fetchOpenCodeGoRateLimits('session=abc123; other=xyz')
expect(result.status).toBe('error')
@@ -105,8 +144,17 @@ describe('fetchOpenCodeGoRateLimits', () => {
expect(normalizeCookieInput('__Host-auth=token')).toBe('__Host-auth=token')
})
it('leaves __Host-console_session=... unchanged', () => {
expect(normalizeCookieInput('__Host-console_session=consoleTok')).toBe(
'__Host-console_session=consoleTok'
)
})
it('leaves multi-pair cookie headers unchanged', () => {
expect(normalizeCookieInput('auth=tok; other=val')).toBe('auth=tok; other=val')
expect(normalizeCookieInput('auth=tok; __Host-console_session=consoleTok')).toBe(
'auth=tok; __Host-console_session=consoleTok'
)
})
it('trims surrounding whitespace before wrapping', () => {
@@ -123,7 +171,7 @@ describe('fetchOpenCodeGoRateLimits', () => {
it('accepts a bare token (auto-wraps to auth=<token>)', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
const result = await fetchOpenCodeGoRateLimits('Fe26.2**baretoken')
@@ -136,7 +184,7 @@ describe('fetchOpenCodeGoRateLimits', () => {
it('uses GET /_server?id=<hash> with correct headers for workspaces', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
await fetchOpenCodeGoRateLimits('auth=mytoken')
@@ -156,7 +204,7 @@ describe('fetchOpenCodeGoRateLimits', () => {
it('uses an isolated session cookie jar and clears it after fetching', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
await fetchOpenCodeGoRateLimits('auth=mytoken')
@@ -210,9 +258,9 @@ describe('fetchOpenCodeGoRateLimits', () => {
it('applies configured proxy settings once to the isolated session', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
const proxySettings = {
httpProxyUrl: 'http://proxy.example:8080',
@@ -244,55 +292,61 @@ describe('fetchOpenCodeGoRateLimits', () => {
expect(netFetchMock).not.toHaveBeenCalled()
})
it('fetches usage from /workspace/<id>/go after resolving workspace ID', async () => {
it('fetches usage from /console/api/go/status with x-org-id and never scrapes /workspace/<id>/go', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
await fetchOpenCodeGoRateLimits('auth=mytoken')
expect(requestedUrls().some((url) => LEGACY_WORKSPACE_GO_URL.test(url))).toBe(false)
expect(netFetchMock).toHaveBeenNthCalledWith(
2,
'https://opencode.ai/workspace/wrk_TESTWORKSPACEID123/go',
expect.objectContaining({ method: 'GET' })
CONSOLE_STATUS_URL,
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
'x-org-id': 'wrk_TESTWORKSPACEID123',
Accept: 'application/json'
})
})
)
expect(netFetchMock.mock.calls[1][1].headers).not.toHaveProperty('Cookie')
})
it('returns ok with session, weekly, and monthly windows', async () => {
it('returns ok with session, weekly, and monthly windows from JSON meters', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
const now = Date.now()
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
expect(result.status).toBe('ok')
expect(result.error).toBeNull()
expect(result.session).toEqual({
usedPercent: 30,
windowMinutes: 300,
resetsAt: now + 7200 * 1000,
resetsAt: Date.parse('2026-04-24T14:00:00.000Z'),
resetDescription: null
})
expect(result.weekly).toEqual({
usedPercent: 51,
windowMinutes: 10080,
resetsAt: now + 259200 * 1000,
windowMinutes: 10_080,
resetsAt: Date.parse('2026-05-01T12:00:00.000Z'),
resetDescription: null
})
expect(result.monthly).toEqual({
usedPercent: 89,
windowMinutes: 43200,
resetsAt: now + 1296000 * 1000,
windowMinutes: 43_200,
resetsAt: Date.parse('2026-05-24T12:00:00.000Z'),
resetDescription: null
})
})
it('returns ok with null monthly when monthlyUsage is absent', async () => {
it('returns ok with null monthly when the month meter is absent', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_NO_MONTHLY))
.mockResolvedValueOnce(makeJsonResponse(STATUS_NO_MONTHLY))
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
@@ -303,13 +357,24 @@ describe('fetchOpenCodeGoRateLimits', () => {
})
it('caps usedPercent at 100 and floors at 0', async () => {
const page = `
rollingUsage: { usagePercent: 150, resetInSec: 3600 }
weeklyUsage: { usagePercent: -5, resetInSec: 86400 }
`
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(page))
netFetchMock.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)).mockResolvedValueOnce(
makeJsonResponse({
access: {
meters: {
fiveHour: {
resetsAt: '2026-04-24T13:00:00.000Z',
limitMicroCents: '100',
usedMicroCents: '150'
},
week: {
resetsAt: '2026-04-25T12:00:00.000Z',
limitMicroCents: '100',
usedMicroCents: '-5'
}
}
}
})
)
const result = await fetchOpenCodeGoRateLimits('auth=token')
@@ -318,77 +383,73 @@ describe('fetchOpenCodeGoRateLimits', () => {
expect(result.weekly?.usedPercent).toBe(0)
})
it('parses React Flight wire format with $R[N]= assignment tokens', async () => {
// Real format from opencode.ai — keys have $R[N]= between the colon and brace.
const page = `
rollingUsage:$R[21]={status:"ok",resetInSec:1337,usagePercent:42},
weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:68}
`
it('does not treat the old HTML usage page as success', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(page))
.mockResolvedValueOnce(makeResponse(LEGACY_USAGE_PAGE))
const result = await fetchOpenCodeGoRateLimits('auth=token')
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
expect(result.status).toBe('ok')
expect(result.session?.usedPercent).toBe(42)
expect(result.weekly?.usedPercent).toBe(68)
})
it('skips null occurrences and finds the real data block for monthlyUsage', async () => {
// Regression: on refresh, monthlyUsage:null appeared BEFORE the real
// monthlyUsage:$R[N]={usagePercent:89,...} in a different component's props.
// Parser must skip the null and find the data block.
const page = `
rollingUsage:$R[21]={status:"ok",resetInSec:18000,usagePercent:0},
weeklyUsage:$R[22]={status:"ok",resetInSec:57781,usagePercent:51},
monthlyUsage:null,timeMonthlyUsageUpdated:null,
monthlyUsage:$R[28]={status:"ok",resetInSec:1214779,usagePercent:89}
`
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(page))
const result = await fetchOpenCodeGoRateLimits('auth=token')
expect(result.status).toBe('ok')
expect(result.monthly?.usedPercent).toBe(89)
expect(result.monthly?.resetsAt).toBe(Date.now() + 1214779 * 1000)
})
it('returns null monthly when all monthlyUsage occurrences are null', async () => {
const page = `
rollingUsage:$R[21]={status:"ok",resetInSec:3600,usagePercent:10},
weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:20},
monthlyUsage:null,timeMonthlyUsageUpdated:null
`
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(page))
const result = await fetchOpenCodeGoRateLimits('auth=token')
expect(result.status).toBe('ok')
expect(result.monthly).toBeNull()
expect(result.status).toBe('error')
expect(result.error).toBe('Could not parse usage data')
expect(result.session).toBeNull()
})
it('skips workspace lookup when workspaceIdOverride is provided', async () => {
netFetchMock.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123')
expect(netFetchMock).toHaveBeenCalledTimes(1)
expect(requestedUrls().some((url) => LEGACY_WORKSPACE_GO_URL.test(url))).toBe(false)
expect(netFetchMock).toHaveBeenCalledWith(
'https://opencode.ai/workspace/wrk_OVERRIDE123/go',
expect.anything()
CONSOLE_STATUS_URL,
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({ 'x-org-id': 'wrk_OVERRIDE123' })
})
)
expect(result.status).toBe('ok')
})
it('keeps __Host-console_session and drops unrelated cookie names', async () => {
netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
await fetchOpenCodeGoRateLimits(
'session=secret; __Host-console_session=consoleTok; tracking=xyz; auth=realtoken',
'wrk_OVERRIDE123'
)
expect(cookiesSetMock).toHaveBeenCalledTimes(2)
expect(cookiesSetMock).toHaveBeenCalledWith(
expect.objectContaining({ name: '__Host-console_session', value: 'consoleTok' })
)
expect(cookiesSetMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'auth', value: 'realtoken' })
)
expect(cookiesSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'session' }))
expect(cookiesSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'tracking' }))
})
it('accepts a console session cookie without wrapping it as auth=', async () => {
netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
const result = await fetchOpenCodeGoRateLimits(
'__Host-console_session=consoleTok',
'wrk_OVERRIDE123'
)
expect(result.status).toBe('ok')
expect(cookiesSetMock).toHaveBeenCalledTimes(1)
expect(cookiesSetMock).toHaveBeenCalledWith(
expect.objectContaining({ name: '__Host-console_session', value: 'consoleTok' })
)
})
it('filters cookie to auth name only', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY))
await fetchOpenCodeGoRateLimits('session=secret; auth=realtoken; tracking=xyz')
@@ -426,7 +487,7 @@ describe('fetchOpenCodeGoRateLimits', () => {
expect(result.error).toMatch(/No workspace ID found/)
})
it('returns error on non-ok usage page response', async () => {
it('returns error on non-ok usage response', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse('Not Found', 404))
@@ -434,18 +495,31 @@ describe('fetchOpenCodeGoRateLimits', () => {
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
expect(result.status).toBe('error')
expect(result.error).toBe('Usage page fetch failed (404)')
expect(result.error).toBe('Usage fetch failed (404)')
})
it('returns error when usage data cannot be parsed from page', async () => {
it('tells the user to include __Host-console_session when usage fetch returns 401', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse('<html>no usage data here</html>'))
.mockResolvedValueOnce(makeResponse('Unauthorized', 401))
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
expect(result.status).toBe('error')
expect(result.error).toBe('Could not parse usage data from page')
expect(result.error).toBe(
'Usage fetch failed (401) — paste the full Cookie header including __Host-console_session (auth alone is not enough)'
)
})
it('returns error when usage data cannot be parsed', async () => {
netFetchMock
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
.mockResolvedValueOnce(makeResponse('{"access":{}}'))
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
expect(result.status).toBe('error')
expect(result.error).toBe('Could not parse usage data')
})
it('never logs the cookie in error messages', async () => {
@@ -1,24 +1,25 @@
import type { Session } from 'electron'
import { randomUUID } from 'node:crypto'
import type { NetworkProxySettings } from '../../shared/network-proxy'
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
import {
clearOpenCodeSessionCookies,
createOpenCodeRequestSession,
OPENCODE_BASE_URL
} from './opencode-go-request-session'
import { parseSubscriptionFromPageText } from './opencode-go-page-scraper'
import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing'
const OPENCODE_SERVER_URL = 'https://opencode.ai/_server'
const OPENCODE_GO_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/go/status`
const API_TIMEOUT_MS = 15_000
// Server-function hash for the workspaces endpoint — stable identifier used by
// the opencode.ai SST/TanStack router server-fn protocol.
const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f'
// Only these cookie names carry session auth on opencode.ai. Sending unrelated
// cookies pollutes the header and can expose sensitive data from other sites.
const AUTH_COOKIE_NAMES = new Set(['auth', '__Host-auth'])
// Closed allowlist: only known opencode.ai auth cookies. Console Go usage is
// authed by __Host-console_session; /_server workspace discovery still uses auth.
const AUTH_COOKIE_NAMES = new Set(['auth', '__Host-auth', '__Host-console_session'])
// Why: users may paste just the token value (e.g. "Fe26.2**...") instead of
// the full cookie header ("auth=Fe26.2**..."). Auto-wrapping avoids a confusing
@@ -29,7 +30,7 @@ export function normalizeCookieInput(raw: string): string {
return trimmed
}
// Already a valid cookie header: has multiple pairs or starts with known name.
if (trimmed.includes(';') || /^(?:auth|__Host-auth)=/i.test(trimmed)) {
if (trimmed.includes(';') || /^(?:auth|__Host-auth|__Host-console_session)=/i.test(trimmed)) {
return trimmed
}
// Only wrap if it looks like an Iron Session seal (starts with Fe26.2**)
@@ -73,19 +74,6 @@ function parseWorkspaceIds(text: string): string[] {
return ids
}
function makeWindow(
usedPercent: number,
resetInSec: number,
windowMinutes: number
): RateLimitWindow {
return {
usedPercent,
windowMinutes,
resetsAt: Date.now() + resetInSec * 1000,
resetDescription: null
}
}
export async function fetchOpenCodeGoRateLimits(
cookie: string,
workspaceIdOverride?: string,
@@ -229,47 +217,43 @@ async function fetchOpenCodeGoRateLimitsWithSession(
}
}
// Step 2: Robust workspace resolution. Try each candidate ID until one returns 200 OK
// and valid usage data. Each candidate gets its own timeout so a slow or
// hung candidate cannot starve the rest.
// Why: /workspace/<id>/go now 302s to console login. Usage is JSON at
// /console/api/go/status, scoped by x-org-id and authed by the console session.
let lastError = ''
for (const candidateId of ids) {
try {
const usagePageUrl = `${OPENCODE_BASE_URL}/workspace/${candidateId}/go`
const pageRes = await openCodeSession.fetch(usagePageUrl, {
const statusRes = await openCodeSession.fetch(OPENCODE_GO_STATUS_URL, {
method: 'GET',
headers: {
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
Accept: 'application/json',
Origin: OPENCODE_BASE_URL,
Referer: OPENCODE_BASE_URL
Referer: `${OPENCODE_BASE_URL}/console/${candidateId}/go`,
'x-org-id': candidateId
},
signal: AbortSignal.timeout(API_TIMEOUT_MS)
})
if (!pageRes.ok) {
lastError = `Usage page fetch failed (${pageRes.status})`
if (!statusRes.ok) {
lastError =
statusRes.status === 401
? 'Usage fetch failed (401) — paste the full Cookie header including __Host-console_session (auth alone is not enough)'
: `Usage fetch failed (${statusRes.status})`
continue
}
const pageText = await pageRes.text()
const parsed = parseSubscriptionFromPageText(pageText)
const parsed = parseOpenCodeGoStatusPayload(await statusRes.text())
if (parsed) {
const monthly =
parsed.monthlyUsagePercent !== null && parsed.monthlyResetInSec !== null
? makeWindow(parsed.monthlyUsagePercent, parsed.monthlyResetInSec, 43200) // 30d
: null
return {
provider: 'opencode-go',
session: makeWindow(parsed.rollingUsagePercent, parsed.rollingResetInSec, 300),
weekly: makeWindow(parsed.weeklyUsagePercent, parsed.weeklyResetInSec, 10080),
monthly,
session: parsed.session,
weekly: parsed.weekly,
monthly: parsed.monthly,
updatedAt: Date.now(),
error: null,
status: 'ok'
}
}
lastError = 'Could not parse usage data from page'
lastError = 'Could not parse usage data'
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
lastError = message
@@ -3,7 +3,7 @@ import {
AGENT_PROMPT_BRACKETED_PASTE_END,
AGENT_PROMPT_BRACKETED_PASTE_START,
buildAgentPromptPasteBytes,
getAgentPromptSubmitDelayMs
resolveAgentPromptSubmitDelayForAgent
} from '../../../shared/agent-prompt-injection'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import type { TuiAgent } from '../../../shared/tui-agent'
@@ -610,9 +610,13 @@ describe('OrcaRuntimeService', () => {
launchAgent: agent
})
const submitDelayMs = getAgentPromptSubmitDelayMs(
// The agent's own policy, not the byte-only delay: antigravity adds a per-line settle
// (#21665), and advancing fake timers by less than the policy waits leaves the submit
// pending until the real 30 s timeout.
const submitDelayMs = resolveAgentPromptSubmitDelayForAgent(
process.platform,
Buffer.byteLength(buildAgentPromptPasteBytes('review this change'), 'utf8')
'review this change',
agent
)
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change')
if (agent === 'omp') {
+1
View File
@@ -89,6 +89,7 @@ export class RelayAgentHookRuntime {
context.launchAgent === 'opencode2' || isOpenCode2LaunchCommand(launchCommandHint)
? 'opencode2'
: 'opencode'
env.ORCA_OPENCODE_AGENT = opencodeAgent
if (this.pluginOverlay.hasOpenCodeSource(opencodeAgent)) {
const sourceDir = resolveOpenCodeSourceConfigDir(context.env, context.shell)
const inheritedRelayOverlay = sourceDir
@@ -187,11 +187,10 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues
})
})
it('does not mirror the XDG default config root', () => {
it('mirrors the XDG default config root when using an overlay', () => {
withHome((home) => {
// Why: OpenCode APPENDS OPENCODE_CONFIG_DIR to its config-dir list rather than
// replacing it, so ~/.config/opencode is read anyway — mirroring it here would
// load the user's config and plugins twice.
// Why: OPENCODE_CONFIG_DIR replaces the default root, so the overlay must
// carry the user's default config and Orca's plugin together.
const defaultConfig = join(home, '.config', 'opencode')
mkdirSync(defaultConfig, { recursive: true })
writeFileSync(join(defaultConfig, 'opencode.json'), '{"model":"default"}')
@@ -202,7 +201,7 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape.
const dir = install({ opencodePluginSource: '// v1\n' }).overlayDirs.opencode as string
expect(existsSync(join(dir, 'opencode.json'))).toBe(false)
expect(existsSync(join(dir, 'opencode.json'))).toBe(true)
expect(existsSync(join(dir, 'plugins', 'orca-opencode-status.js'))).toBe(true)
})
})
+17 -13
View File
@@ -7,6 +7,7 @@ import { existsSync } from 'node:fs'
import { getRelayOpenCodePluginPath, type PluginOverlayManager } from './plugin-overlay'
import { resolveOpenCodeSourceConfigDir } from './plugin-overlay-env'
import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory'
import { assertPluginSourceUnderByteCap } from './plugin-source-limit'
import {
sanitizeWslHookInstanceKey,
@@ -26,11 +27,8 @@ export type InstallPluginsResult = {
export type InstallPluginsHandler = (params: Record<string, unknown>) => InstallPluginsResult
// Why NOT to fall back to ~/.config/opencode here: OpenCode APPENDS
// OPENCODE_CONFIG_DIR to its config-dir list, it does not replace it — the
// XDG default is always read too. Mirroring the default into the overlay would
// load the user's config (and plugins) twice. Only an explicitly-set dir is
// mirrored, because that one leaves the list when we override the var.
// OpenCode replaces its default config root when OPENCODE_CONFIG_DIR is set,
// so mirror the default root into the guest overlay as well as explicit paths.
export function createInstallPluginsHandler(
pluginOverlay: PluginOverlayManager,
env: NodeJS.ProcessEnv
@@ -73,12 +71,15 @@ export function createInstallPluginsHandler(
const incoming = typeof opencode === 'string' ? opencode : null
// Explicit-only (see header). Constant in practice for a relay's lifetime, so
// keying the cache on it is defensive; the rc scan behind it is memoized.
const sourceDir = resolveOpenCodeSourceConfigDir(env as Record<string, string>, env.SHELL)
const sourceDir =
resolveOpenCodeSourceConfigDir(env as Record<string, string>, env.SHELL) ??
resolveOpenCodeConfigDirectory(env as Record<string, string>, env.HOME)
const existingSourceDir = sourceDir && existsSync(sourceDir) ? sourceDir : undefined
const cached = materialized
if (
cached &&
(incoming === null || incoming === cached.source) &&
sourceDir === cached.sourceDir &&
existingSourceDir === cached.sourceDir &&
// Why: the dir surviving a failed rebuild proves nothing — the plugin does.
existsSync(getRelayOpenCodePluginPath(cached.dir))
) {
@@ -87,32 +88,35 @@ export function createInstallPluginsHandler(
const overlayId =
sanitizeWslHookInstanceKey(env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? 'wsl-opencode'
// Why: null on write failure — caller falls back to the guest's own config (no status), never crossing a Windows overlay into WSL.
opencodeDir = pluginOverlay.materializeOpenCode(overlayId, sourceDir) ?? undefined
opencodeDir = pluginOverlay.materializeOpenCode(overlayId, existingSourceDir) ?? undefined
materialized =
opencodeDir && incoming !== null
? { source: incoming, sourceDir, dir: opencodeDir }
? { source: incoming, sourceDir: existingSourceDir, dir: opencodeDir }
: null
}
}
let opencode2Dir: string | undefined
if (pluginOverlay.hasOpenCode2Source()) {
const incoming = typeof opencode2 === 'string' ? opencode2 : null
const sourceDir = resolveOpenCodeSourceConfigDir(env as Record<string, string>, env.SHELL)
const sourceDir =
resolveOpenCodeSourceConfigDir(env as Record<string, string>, env.SHELL) ??
resolveOpenCodeConfigDirectory(env as Record<string, string>, env.HOME)
const existingSourceDir = sourceDir && existsSync(sourceDir) ? sourceDir : undefined
const cached = materialized2
if (
cached &&
(incoming === null || incoming === cached.source) &&
sourceDir === cached.sourceDir &&
existingSourceDir === cached.sourceDir &&
existsSync(getRelayOpenCodePluginPath(cached.dir, 'opencode2'))
) {
opencode2Dir = cached.dir
} else {
const overlayId =
sanitizeWslHookInstanceKey(env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? 'wsl-opencode2'
opencode2Dir = pluginOverlay.materializeOpenCode2(overlayId, sourceDir) ?? undefined
opencode2Dir = pluginOverlay.materializeOpenCode2(overlayId, existingSourceDir) ?? undefined
materialized2 =
opencode2Dir && incoming !== null
? { source: incoming, sourceDir, dir: opencode2Dir }
? { source: incoming, sourceDir: existingSourceDir, dir: opencode2Dir }
: null
}
}
@@ -9,6 +9,7 @@ import { EditorDiffFileSurface } from './EditorDiffFileSurface'
import { EditorEditFileSurface } from './EditorEditFileSurface'
import { EditorFileLoadErrorView } from './EditorFileLoadErrorView'
import type { FileContent } from './editor-panel-content-types'
import { buildPdfScalePreferenceKey } from './pdf-scale-preference-storage'
import { translate } from '@/i18n/i18n'
import { useEditorConflictNavigation } from './useEditorConflictNavigation'
import { useMarkdownDocuments } from './useMarkdownDocuments'
@@ -106,6 +107,9 @@ export function EditorContent({
viewStateScopeId === activeFile.id
? `${activeFile.filePath}:pdf`
: `${activeFile.filePath}::${viewStateScopeId}:pdf`
// Why: the same absolute path can exist in different worktrees, paired
// runtimes, or SSH targets; durable PDF zoom must not cross those owners.
const pdfPreferenceKey = buildPdfScalePreferenceKey(activeFile)
const monacoLanguage = resolvedLanguage === 'notebook' ? 'json' : resolvedLanguage
const reloadOpenCheckRunDetailsTab = useAppStore((state) => state.reloadOpenCheckRunDetailsTab)
const markdownDocuments = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode, handleSave)
@@ -232,6 +236,7 @@ export function EditorContent({
editorViewStateKey={editorViewStateKey}
diffViewStateKey={diffViewStateKey}
pdfViewStateKey={pdfViewStateKey}
pdfPreferenceKey={pdfPreferenceKey}
fileContent={fileContents[activeFile.id]}
diffContent={diffContents[activeFile.id]}
editBuffer={editBuffers[activeFile.id]}
@@ -30,6 +30,7 @@ export function EditorEditFileSurface({
editorViewStateKey,
diffViewStateKey,
pdfViewStateKey,
pdfPreferenceKey,
fileContent,
diffContent,
editBuffer,
@@ -61,6 +62,7 @@ export function EditorEditFileSurface({
editorViewStateKey: string
diffViewStateKey: string
pdfViewStateKey: string
pdfPreferenceKey: string
fileContent: FileContent | undefined
diffContent: GitDiffResult | undefined
editBuffer: string | undefined
@@ -113,6 +115,7 @@ export function EditorEditFileSurface({
content={fileContent.content}
filePath={activeFile.filePath}
mimeType={fileContent.mimeType}
preferenceKey={pdfPreferenceKey}
scrollCacheKey={pdfViewStateKey}
/>
)
@@ -29,6 +29,9 @@ type ImageViewerProps = {
filePath: string
mimeType?: string
layout?: 'fill' | 'intrinsic'
// Why: callers without an owner identity (for example diff and conflict
// panes) must not persist a preference under a path-only key.
preferenceKey?: string | null
// Why: absent means "no PDF scroll memory" — diff and conflict-review callers
// mount several viewers on one path, so they deliberately pass nothing.
scrollCacheKey?: string | null
@@ -39,6 +42,7 @@ export default function ImageViewer({
filePath,
mimeType = FALLBACK_IMAGE_MIME_TYPE,
layout = 'fill',
preferenceKey,
scrollCacheKey = null
}: ImageViewerProps): JSX.Element {
const [isPopupOpen, setIsPopupOpen] = useState(false)
@@ -215,7 +219,12 @@ export default function ImageViewer({
if (isPdf) {
return (
<PdfViewer content={cleanedContent} filePath={filePath} scrollCacheKey={scrollCacheKey} />
<PdfViewer
content={cleanedContent}
filePath={filePath}
preferenceKey={preferenceKey}
scrollCacheKey={scrollCacheKey}
/>
)
}
@@ -23,6 +23,7 @@ import {
stepPdfScalePreference,
type PdfScalePreference
} from './pdf-scale-preference'
import { readPdfScalePreference, writePdfScalePreference } from './pdf-scale-preference-storage'
import { pdfViewPositionCache, setWithLRU } from '@/lib/scroll-cache'
import {
buildPdfScrollDestination,
@@ -44,6 +45,9 @@ const USER_SCROLL_INPUT_EVENTS = ['wheel', 'touchstart', 'keydown', 'pointerdown
type PdfViewerProps = {
content: string
filePath: string
// Why: callers that do not have an owner identity (for example diff and
// conflict panes) must not persist a preference under a path-only key.
preferenceKey?: string | null
// Why: absent means "no scroll memory" — the diff and conflict-review callers
// mount several viewers on one path, so a shared key would cross-write.
scrollCacheKey?: string | null
@@ -52,6 +56,7 @@ type PdfViewerProps = {
export default function PdfViewer({
content,
filePath,
preferenceKey = null,
scrollCacheKey = null
}: PdfViewerProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
@@ -65,22 +70,23 @@ export default function PdfViewer({
const findControllerRef = useRef<InstanceType<typeof PDFFindController> | null>(null)
const pdfViewerRef = useRef<InstanceType<typeof PdfJsViewer> | null>(null)
// Why: content reloads rebuild the pdf.js viewer; keep zoom across updates of
// the same file, and only reset when the open path changes.
// the same file and restore the durable preference after a remount or restart.
const scalePreferenceRef = useRef<PdfScalePreference>('page-width')
const filename = useMemo(() => filePath.split(/[/\\]/).pop() || filePath, [filePath])
const cleanedContent = useMemo(() => content.replace(/\s/g, ''), [content])
// Why: reset zoom to fit-width when the open path changes. An effect keeps the
// reset out of render (refs mutated in render can leak from discarded renders)
// and covers same-content/different-path opens the load effect skips.
// Why: restore the owner's preference outside render (refs mutated in render
// can leak from discarded renders) and cover same-content/different-path opens.
useEffect(() => {
scalePreferenceRef.current = 'page-width'
scalePreferenceRef.current = preferenceKey
? (readPdfScalePreference(preferenceKey) ?? 'page-width')
: 'page-width'
const viewer = pdfViewerRef.current
if (viewer) {
applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS)
applyPdfScalePreference(viewer, scalePreferenceRef.current, SCALE_BOUNDS)
}
}, [filePath])
}, [filePath, preferenceKey])
useEffect(() => {
const container = containerRef.current
@@ -306,15 +312,21 @@ export default function PdfViewer({
// Why: every zoom entry point (toolbar + keyboard) must record the scale
// preference so the next content reload restores it (see scalePreferenceRef).
const stepZoom = useCallback((direction: 'in' | 'out') => {
const viewer = pdfViewerRef.current
if (!viewer) {
return
}
const next = stepPdfScalePreference(viewer.currentScale, direction, SCALE_BOUNDS)
viewer.currentScale = next.scale
scalePreferenceRef.current = next.preference
}, [])
const stepZoom = useCallback(
(direction: 'in' | 'out') => {
const viewer = pdfViewerRef.current
if (!viewer) {
return
}
const next = stepPdfScalePreference(viewer.currentScale, direction, SCALE_BOUNDS)
viewer.currentScale = next.scale
scalePreferenceRef.current = next.preference
if (preferenceKey) {
writePdfScalePreference(preferenceKey, next.preference)
}
},
[preferenceKey]
)
const zoomIn = useCallback(() => stepZoom('in'), [stepZoom])
const zoomOut = useCallback(() => stepZoom('out'), [stepZoom])
@@ -326,7 +338,10 @@ export default function PdfViewer({
}
scalePreferenceRef.current = 'page-width'
applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS)
}, [])
if (preferenceKey) {
writePdfScalePreference(preferenceKey, 'page-width')
}
}, [preferenceKey])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildPdfScalePreferenceKey,
PDF_SCALE_PREFERENCES_STORAGE_KEY,
readPdfScalePreference,
writePdfScalePreference
} from './pdf-scale-preference-storage'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('PDF scale preference storage', () => {
it('keeps identical paths isolated by worktree and remote owner', () => {
const localKey = buildPdfScalePreferenceKey({ worktreeId: 'worktree-a', filePath: '/doc.pdf' })
const runtimeKey = buildPdfScalePreferenceKey({
worktreeId: 'worktree-a',
runtimeEnvironmentId: 'runtime-b',
filePath: '/doc.pdf'
})
const sshKey = buildPdfScalePreferenceKey({
worktreeId: 'worktree-a',
externalSshTargetId: 'ssh-c',
filePath: '/doc.pdf'
})
expect(new Set([localKey, runtimeKey, sshKey]).size).toBe(3)
})
it('round-trips a preference by file path', () => {
const storage = createMemoryStorage()
vi.stubGlobal('localStorage', storage)
writePdfScalePreference('/repo/report.pdf', 1.75)
expect(readPdfScalePreference('/repo/report.pdf')).toBe(1.75)
expect(readPdfScalePreference('/repo/other.pdf')).toBeNull()
})
it('persists fit-to-width resets and keeps files isolated', () => {
const storage = createMemoryStorage()
vi.stubGlobal('localStorage', storage)
writePdfScalePreference('/repo/report.pdf', 2)
writePdfScalePreference('/repo/other.pdf', 'page-width')
expect(readPdfScalePreference('/repo/report.pdf')).toBe(2)
expect(readPdfScalePreference('/repo/other.pdf')).toBe('page-width')
})
it('ignores malformed stored values', () => {
const storage = createMemoryStorage()
vi.stubGlobal('localStorage', storage)
storage.setItem(
PDF_SCALE_PREFERENCES_STORAGE_KEY,
JSON.stringify({ '/repo/report.pdf': { scale: 2 } })
)
expect(readPdfScalePreference('/repo/report.pdf')).toBeNull()
})
it('evicts the oldest entries after reaching the storage bound', () => {
const storage = createMemoryStorage()
vi.stubGlobal('localStorage', storage)
for (let index = 0; index < 101; index += 1) {
writePdfScalePreference(`/repo/report-${index}.pdf`, index)
}
expect(readPdfScalePreference('/repo/report-0.pdf')).toBeNull()
expect(readPdfScalePreference('/repo/report-100.pdf')).toBe(100)
})
it('ignores storage write failures', () => {
const storage = createMemoryStorage()
storage.setItem = () => {
throw new Error('storage unavailable')
}
vi.stubGlobal('localStorage', storage)
expect(() => writePdfScalePreference('/repo/report.pdf', 1.5)).not.toThrow()
})
})
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() {
return values.size
},
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => {
values.set(key, value)
}
}
}
@@ -0,0 +1,87 @@
import type { PdfScalePreference } from './pdf-scale-preference'
export const PDF_SCALE_PREFERENCES_STORAGE_KEY = 'orca.pdf.scale-preferences.v1'
const MAX_STORED_PREFERENCES = 100
export function buildPdfScalePreferenceKey(input: {
worktreeId: string
runtimeEnvironmentId?: string | null
externalSshTargetId?: string | null
filePath: string
}): string {
return JSON.stringify([
input.worktreeId,
input.runtimeEnvironmentId?.trim() || 'local',
input.externalSshTargetId?.trim() || null,
input.filePath
])
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isPdfScalePreference(value: unknown): value is PdfScalePreference {
return value === 'page-width' || (typeof value === 'number' && Number.isFinite(value))
}
function readStoredPreferences(storage: Storage): Record<string, unknown> {
try {
const raw = storage.getItem(PDF_SCALE_PREFERENCES_STORAGE_KEY)
if (!raw) {
return {}
}
const parsed: unknown = JSON.parse(raw)
return isRecord(parsed) ? parsed : {}
} catch {
return {}
}
}
function getStorage(): Storage | null {
try {
return globalThis.localStorage === undefined ? null : globalThis.localStorage
} catch {
return null
}
}
/** Read the last zoom choice for a PDF, if one was persisted. */
export function readPdfScalePreference(preferenceKey: string): PdfScalePreference | null {
const storage = getStorage()
if (!storage) {
return null
}
const preference = readStoredPreferences(storage)[preferenceKey]
return isPdfScalePreference(preference) ? preference : null
}
/** Persist a PDF zoom choice across viewer remounts and app restarts. */
export function writePdfScalePreference(
preferenceKey: string,
preference: PdfScalePreference
): void {
const storage = getStorage()
if (!storage) {
return
}
const stored = readStoredPreferences(storage)
// Reinsert to keep recently used files at the end of the bounded map.
delete stored[preferenceKey]
stored[preferenceKey] = preference
const keys = Object.keys(stored)
while (keys.length > MAX_STORED_PREFERENCES) {
const oldestKey = keys.shift()
if (oldestKey !== undefined) {
delete stored[oldestKey]
}
}
try {
storage.setItem(PDF_SCALE_PREFERENCES_STORAGE_KEY, JSON.stringify(stored))
} catch {
// The viewer remains usable when browser storage is unavailable or full.
}
}
@@ -167,4 +167,13 @@ describe('AccountsPane', () => {
markup.slice(markup.lastIndexOf('<button', addAccountIndex), addAccountIndex)
).not.toContain('disabled=""')
})
it('tells users to paste the OpenCode console session cookie, not auth alone', () => {
const markup = renderPane(getDefaultSettings('/tmp'))
expect(markup).toContain('__Host-console_session')
expect(markup).toContain('auth=…; __Host-console_session=…')
expect(markup).toContain('auth cookie still covers workspace discovery')
expect(markup).not.toContain('Fe26.2**… token or auth=Fe26.2**… header')
})
})
@@ -101,10 +101,10 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):
'OpenCode Go Session Cookie'
)}
description={translate(
'auto.components.settings.AccountsPane.b2b1aa936d',
'Paste your opencode.ai session cookie for rate limit fetching.'
'auto.components.settings.AccountsPane.0335bd31d5',
'Paste the full opencode.ai Cookie header, including __Host-console_session, for rate limit fetching.'
)}
keywords={['opencode', 'cookie', 'session', 'rate limit', 'status bar']}
keywords={['opencode', 'cookie', 'session', 'console', 'rate limit', 'status bar']}
className="space-y-2"
>
<Label>
@@ -120,8 +120,8 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):
onEdit={() => recordOpenCodeSettingEdit('cookie')}
commit={(opencodeSessionCookie) => updateSettings({ opencodeSessionCookie })}
placeholder={translate(
'auto.components.settings.AccountsPane.a7e38affcd',
'Fe26.2**… token or auth=Fe26.2**… header'
'auto.components.settings.AccountsPane.37b4b4a3f7',
'auth=…; __Host-console_session=…'
)}
spellCheck={false}
className="flex-1 text-xs"
@@ -142,22 +142,18 @@ export function renderOpenCodeAccountsSection(model: AccountsPaneSectionModel):
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.AccountsPane.0023cc336e',
'Paste either the raw token value (e.g.'
'auto.components.settings.AccountsPane.62ab430f94',
"Paste the full Cookie header from your browser's DevTools → Network → any opencode.ai request, including __Host-console_session (e.g."
)}{' '}
<code className="text-xs">
{translate('auto.components.settings.AccountsPane.922b51e02d', 'Fe26.2**…')}
{translate(
'auto.components.settings.AccountsPane.37b4b4a3f7',
'auth=…; __Host-console_session=…'
)}
</code>
{translate(
'auto.components.settings.AccountsPane.338820326a',
') or the full cookie header (e.g.'
)}{' '}
<code className="text-xs">
{translate('auto.components.settings.AccountsPane.8951c5309f', 'auth=Fe26.2**…')}
</code>
{translate(
'auto.components.settings.AccountsPane.7ce0e1907c',
"). Find it in your browser's DevTools → Network → any opencode.ai request → Cookie header. OpenCode Go auth is web-based and shared across Windows and WSL terminals."
'auto.components.settings.AccountsPane.d5267cce63',
'). The auth cookie still covers workspace discovery; auth alone is not enough for usage. OpenCode Go auth is web-based and shared across Windows and WSL terminals.'
)}
</p>
</SearchableSetting>
@@ -15,7 +15,11 @@ vi.mock('./settings-search-keywords', () => ({
translateSearchKeyword: (_key: string, fallback: string) => [fallback]
}))
import { getAccountsMiniMaxSearchEntries, getAccountsPaneSearchEntries } from './accounts-search'
import {
getAccountsMiniMaxSearchEntries,
getAccountsOpencodeSearchEntries,
getAccountsPaneSearchEntries
} from './accounts-search'
describe('getAccountsMiniMaxSearchEntries', () => {
it('returns a single entry that targets the MiniMax session cookie flow', () => {
@@ -42,3 +46,19 @@ describe('getAccountsMiniMaxSearchEntries', () => {
expect(titles).toContain('MiniMax Usage')
})
})
describe('getAccountsOpencodeSearchEntries', () => {
it('tells search to paste the full Cookie header including the console session', () => {
const cookieEntry = getAccountsOpencodeSearchEntries().find(
(entry) => entry.title === 'OpenCode Go Session Cookie'
)
expect(cookieEntry).toBeDefined()
expect(cookieEntry?.description).toContain('__Host-console_session')
expect(cookieEntry?.description).toContain('Cookie header')
expect(cookieEntry?.description).not.toMatch(/Fe26\.2\*\*/)
expect(cookieEntry?.keywords).toEqual(
expect.arrayContaining(['opencode', 'cookie', 'session', 'console', 'rate limit'])
)
})
})
@@ -134,13 +134,14 @@ export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [
'OpenCode Go Session Cookie'
),
description: translate(
'auto.components.settings.accounts.search.d1d2ae383c',
'Paste your opencode.ai session cookie for rate limit fetching.'
'auto.components.settings.accounts.search.25591bf95b',
'Paste the full opencode.ai Cookie header, including __Host-console_session, for rate limit fetching.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.accounts.search.8dcbef1856', 'opencode'),
...translateSearchKeyword('auto.components.settings.accounts.search.61f7d1fcbe', 'cookie'),
...translateSearchKeyword('auto.components.settings.accounts.search.9c4e40cf6b', 'session'),
...translateSearchKeyword('auto.components.settings.accounts.search.37020a02c2', 'console'),
...translateSearchKeyword(
'auto.components.settings.accounts.search.e949b08ffb',
'rate limit'
+12
View File
@@ -1074,17 +1074,24 @@
},
"settings": {
"AccountsPane": {
"0023cc336e": "Paste either the raw token value (e.g.",
"15e831350e": "Configure MiniMax usage tracking from platform.minimax.io.",
"1fd1b1b6b4": "Cookie not set",
"338820326a": ") or the full cookie header (e.g.",
"3455cf43fa": "Claude login.",
"350b2a1aa7": "Use your current",
"4e32e030b2": "Stored locally. Orca sends it only to platform.minimax.io for usage refreshes.",
"566d9a99ab": "_token=…; minimax_group_id_v2=…",
"5e08b0fe57": "Stored locally and sent only to platform.minimax.io for usage refreshes.",
"79418c782a": "Open platform.minimax.io/console/usage in your browser, sign in, then copy the Cookie request header from DevTools (Network → any remains request → Cookie).",
"7ce0e1907c": "). Find it in your browser's DevTools → Network → any opencode.ai request → Cookie header. OpenCode Go auth is web-based and shared across Windows and WSL terminals.",
"8951c5309f": "auth=Fe26.2**…",
"9107406589": "Could not load Claude accounts.",
"922b51e02d": "Fe26.2**…",
"a7e38affcd": "Fe26.2**… token or auth=Fe26.2**… header",
"b10cb4f696": "adding",
"b11078a9c2": "wsl",
"b2b1aa936d": "Paste your opencode.ai session cookie for rate limit fetching.",
"b43e761fe5": "MiniMax cookie update failed.",
"b8c2905c2b": "Could not load Codex accounts.",
"f5d8d2a6a1": "Open platform.minimax.io/console/usage in your browser and sign in."
@@ -1557,6 +1564,11 @@
"7c3bb36706": "remove",
"e2b0ee267f": "stale"
},
"accounts": {
"search": {
"d1d2ae383c": "Paste your opencode.ai session cookie for rate limit fetching."
}
},
"agent-awake-copy": {
"95d3031db2": "Keeps this computer and display awake while agents are working. Lid-close behavior follows this device's power settings.",
"a42f6fbdd8": "Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.",
+6
View File
@@ -6452,7 +6452,11 @@
"922b51e02d": "Fe26.2**…",
"0023cc336e": "Paste either the raw token value (e.g.",
"a7e38affcd": "Fe26.2**… token or auth=Fe26.2**… header",
"d5267cce63": "). The auth cookie still covers workspace discovery; auth alone is not enough for usage. OpenCode Go auth is web-based and shared across Windows and WSL terminals.",
"62ab430f94": "Paste the full Cookie header from your browser's DevTools → Network → any opencode.ai request, including __Host-console_session (e.g.",
"37b4b4a3f7": "auth=…; __Host-console_session=…",
"67e3c33670": "OpenCode Go session cookie",
"0335bd31d5": "Paste the full opencode.ai Cookie header, including __Host-console_session, for rate limit fetching.",
"b2b1aa936d": "Paste your opencode.ai session cookie for rate limit fetching.",
"36223200ac": "OpenCode Go Session Cookie",
"ea631977b5": "Configure OpenCode Go provider settings.",
@@ -8927,6 +8931,8 @@
"4ee2029e9c": "OpenCode Go Workspace ID",
"9c4e40cf6b": "session",
"61f7d1fcbe": "cookie",
"37020a02c2": "console",
"25591bf95b": "Paste the full opencode.ai Cookie header, including __Host-console_session, for rate limit fetching.",
"d1d2ae383c": "Paste your opencode.ai session cookie for rate limit fetching.",
"6ed1401020": "OpenCode Go Session Cookie",
"b7c2cee442": "experimental",
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import { normalizeHookPayload } from './agent-hook-listener'
import { createHookListenerState } from './agent-hook-listener/listener-state'
import { bindOpenCodeSession } from './agent-hook-listener/opencode-session-registry'
import { makePaneKey } from './stable-pane-id'
import type { HookListenerState } from './agent-hook-listener/listener-state'
const LEAF_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const LEAF_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const PANE_A = makePaneKey('tab-a', LEAF_A)
const PANE_B = makePaneKey('tab-b', LEAF_B)
function opencodeBusy(
state: HookListenerState,
paneKey: string,
sessionId: string,
launchToken = ''
): ReturnType<typeof normalizeHookPayload> {
return normalizeHookPayload(
state,
'opencode',
{ paneKey, launchToken, payload: { hook_event_name: 'SessionBusy', sessionID: sessionId } },
'production'
)
}
describe('opencode shared-server reattribution (#21359)', () => {
it('reattributes a bound session to its real pane', () => {
const state = createHookListenerState()
bindOpenCodeSession(state, 'ses_1', {
paneKey: PANE_B,
boundAt: 1,
basis: 'creation-correlation'
})
const result = opencodeBusy(state, PANE_A, 'ses_1')
expect(result?.paneKey).toBe(PANE_B)
expect(result?.tabId).toBe('tab-b')
expect(result?.payload.state).toBe('working')
})
it('keeps the stamped pane for unbound sessions', () => {
const state = createHookListenerState()
const result = opencodeBusy(state, PANE_A, 'ses_unknown')
expect(result?.paneKey).toBe(PANE_A)
})
it('substitutes the bound pane live token so its fence passes', () => {
const state = createHookListenerState()
// A tokened post teaches the listener pane B's live token.
normalizeHookPayload(
state,
'claude',
{ paneKey: PANE_B, launchToken: 'token-b-live', payload: { hook_event_name: 'Stop' } },
'production'
)
bindOpenCodeSession(state, 'ses_1', {
paneKey: PANE_B,
boundAt: 1,
basis: 'argv'
})
const result = opencodeBusy(state, PANE_A, 'ses_1')
expect(result?.paneKey).toBe(PANE_B)
expect(result?.launchToken).toBe('token-b-live')
})
it('leaves other sources untouched', () => {
const state = createHookListenerState()
bindOpenCodeSession(state, 'ses_1', {
paneKey: PANE_B,
boundAt: 1,
basis: 'argv'
})
const result = normalizeHookPayload(
state,
'claude',
{ paneKey: PANE_A, payload: { hook_event_name: 'Stop', session_id: 'ses_1' } },
'production'
)
expect(result?.paneKey).toBe(PANE_A)
})
it('never lets a stale same-pane stamp overwrite the live token', () => {
const state = createHookListenerState()
// A tokened post teaches the listener pane B's live token.
normalizeHookPayload(
state,
'claude',
{ paneKey: PANE_B, launchToken: 'token-b-live', payload: { hook_event_name: 'Stop' } },
'production'
)
bindOpenCodeSession(state, 'ses_1', {
paneKey: PANE_B,
boundAt: 1,
basis: 'argv'
})
// The shared server's frozen stamp carries a stale token for the same pane.
const result = opencodeBusy(state, PANE_B, 'ses_1', 'token-b-stale')
expect(result?.paneKey).toBe(PANE_B)
expect(result?.launchToken).toBe('token-b-live')
// And the stale stamp must not have poisoned the cache: a later lookup
// still returns the live token.
const again = opencodeBusy(state, PANE_B, 'ses_1', 'token-b-stale')
expect(again?.launchToken).toBe('token-b-live')
})
it('drops the stamped worktree when the binding has none', () => {
const state = createHookListenerState()
bindOpenCodeSession(state, 'ses_1', {
paneKey: PANE_B,
boundAt: 1,
basis: 'argv'
})
const result = normalizeHookPayload(
state,
'opencode',
{
paneKey: PANE_A,
worktreeId: 'repo::/stamped-worktree',
payload: { hook_event_name: 'SessionBusy', sessionID: 'ses_1' }
},
'production'
)
expect(result?.paneKey).toBe(PANE_B)
expect(result?.worktreeId).toBeUndefined()
})
})
@@ -0,0 +1,228 @@
import { describe, expect, it } from 'vitest'
import {
correlateOpenCodeSessionOwners,
sessionIdFromArgv
} from './agent-hook-listener/opencode-session-correlation'
const NOW = 1_000_000_000
const DIR = '/Users/jin/work/mocitec'
function session(id: string, createdAtMs: number, parentId: string | null = null) {
return { id, directory: DIR, createdAtMs, parentId }
}
function client(
paneKey: string,
startedAtMs: number,
lastSeenAliveMs = NOW,
argv: readonly string[] = ['opencode']
) {
return { paneKey, startedAtMs, lastSeenAliveMs, argv }
}
describe('sessionIdFromArgv', () => {
it('reads --session forms', () => {
expect(sessionIdFromArgv(['opencode', '--session', 'ses_1'])).toBe('ses_1')
expect(sessionIdFromArgv(['opencode', '-s', 'ses_2'])).toBe('ses_2')
expect(sessionIdFromArgv(['opencode', '--session=ses_3'])).toBe('ses_3')
})
it('ignores bare launches and flag-like values', () => {
expect(sessionIdFromArgv(['opencode'])).toBeNull()
expect(sessionIdFromArgv(['opencode', '--session'])).toBeNull()
expect(sessionIdFromArgv(['opencode', '--session', '--port'])).toBeNull()
})
})
describe('correlateOpenCodeSessionOwners', () => {
it('binds a lone pane in the directory', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [session('ses_1', NOW - 60_000)],
panes: [{ paneKey: 'pane-a', directory: DIR }],
clients: [client('pane-a', NOW - 120_000)],
knownOwners: new Map()
})
expect(results).toEqual([
{ sessionId: 'ses_1', paneKey: 'pane-a', basis: 'single-pane-directory' }
])
})
it('contains sessions beneath the worktree root', () => {
const sub = correlateOpenCodeSessionOwners({
sessions: [
{
id: 'ses_sub',
directory: `${DIR}/packages/app`,
createdAtMs: NOW - 60_000,
parentId: null
}
],
panes: [{ paneKey: 'pane-a', directory: DIR }],
clients: [client('pane-a', NOW - 120_000)],
knownOwners: new Map()
})
expect(sub).toEqual([
{ sessionId: 'ses_sub', paneKey: 'pane-a', basis: 'single-pane-directory' }
])
})
it('strips the macOS /tmp alias so both spellings meet', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [
{ id: 'ses_1', directory: '/tmp/binder-e2e', createdAtMs: NOW - 60_000, parentId: null }
],
panes: [{ paneKey: 'pane-a', directory: '/private/tmp/binder-e2e' }],
clients: [client('pane-a', NOW - 120_000)],
knownOwners: new Map()
})
expect(results).toEqual([
{ sessionId: 'ses_1', paneKey: 'pane-a', basis: 'single-pane-directory' }
])
})
it('keeps genuinely distinct /private roots apart', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [
{ id: 'ses_1', directory: '/private/repo', createdAtMs: NOW - 60_000, parentId: null }
],
panes: [{ paneKey: 'pane-a', directory: '/repo' }],
clients: [client('pane-a', NOW - 120_000)],
knownOwners: new Map()
})
expect(results).toEqual([])
})
it('folds Windows case differences', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [
{ id: 'ses_1', directory: 'c:\\users\\repo', createdAtMs: NOW - 60_000, parentId: null }
],
panes: [{ paneKey: 'pane-a', directory: 'C:\\Users\\Repo' }],
clients: [client('pane-a', NOW - 120_000)],
knownOwners: new Map()
})
expect(results).toEqual([
{ sessionId: 'ses_1', paneKey: 'pane-a', basis: 'single-pane-directory' }
])
})
it('keeps POSIX backslashes literal', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [
{ id: 'ses_1', directory: '/repo/a\\b', createdAtMs: NOW - 60_000, parentId: null }
],
panes: [{ paneKey: 'pane-a', directory: '/repo/a/b' }],
clients: [client('pane-a', NOW - 120_000)],
knownOwners: new Map()
})
expect(results).toEqual([])
})
it('resolves dot segments before containment', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [
{ id: 'ses_1', directory: '/repo/../other', createdAtMs: NOW - 60_000, parentId: null }
],
panes: [{ paneKey: 'pane-a', directory: '/repo' }],
clients: [client('pane-a', NOW - 120_000)],
knownOwners: new Map()
})
expect(results).toEqual([])
})
it('leaves a session unbound when no client brackets it', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [session('ses_1', NOW - 60_000)],
panes: [{ paneKey: 'pane-a', directory: DIR }],
clients: [],
knownOwners: new Map()
})
expect(results).toEqual([])
})
it('breaks a same-directory tie by client evidence', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [session('ses_1', NOW - 60_000)],
panes: [
{ paneKey: 'pane-a', directory: DIR },
{ paneKey: 'pane-b', directory: DIR }
],
clients: [client('pane-b', NOW - 120_000)],
knownOwners: new Map()
})
expect(results).toEqual([
{ sessionId: 'ses_1', paneKey: 'pane-b', basis: 'creation-correlation' }
])
})
it('stays unbound when both same-directory panes evidence a client', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [session('ses_1', NOW - 60_000)],
panes: [
{ paneKey: 'pane-a', directory: DIR },
{ paneKey: 'pane-b', directory: DIR }
],
clients: [client('pane-a', NOW - 120_000), client('pane-b', NOW - 90_000)],
knownOwners: new Map()
})
expect(results).toEqual([])
})
it('rejects a client that started after creation beyond skew', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [session('ses_1', NOW - 3_600_000)],
panes: [{ paneKey: 'pane-a', directory: DIR }],
clients: [client('pane-a', NOW - 60_000, NOW)],
knownOwners: new Map()
})
expect(results).toEqual([])
})
it('accepts a long-lived client that brackets creation', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [session('ses_1', NOW - 60_000)],
panes: [{ paneKey: 'pane-a', directory: DIR }],
clients: [client('pane-a', NOW - 86_400_000)],
knownOwners: new Map()
})
expect(results).toHaveLength(1)
})
it('binds by argv even in a crowded directory', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [session('ses_9', NOW - 60_000)],
panes: [
{ paneKey: 'pane-a', directory: DIR },
{ paneKey: 'pane-b', directory: DIR }
],
clients: [
client('pane-a', NOW - 120_000),
client('pane-b', NOW - 110_000, NOW, ['opencode', '--session', 'ses_9'])
],
knownOwners: new Map()
})
expect(results).toEqual([{ sessionId: 'ses_9', paneKey: 'pane-b', basis: 'argv' }])
})
it('a child session inherits its bound root', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [session('ses_child', NOW - 30_000, 'ses_root')],
panes: [{ paneKey: 'pane-a', directory: DIR }],
clients: [client('pane-a', NOW - 120_000)],
knownOwners: new Map([['ses_root', 'pane-a']])
})
expect(results).toEqual([
{ sessionId: 'ses_child', paneKey: 'pane-a', basis: 'creation-correlation' }
])
})
it('skips already-known sessions', () => {
const results = correlateOpenCodeSessionOwners({
sessions: [session('ses_1', NOW - 60_000)],
panes: [{ paneKey: 'pane-a', directory: DIR }],
clients: [client('pane-a', NOW - 120_000)],
knownOwners: new Map([['ses_1', 'pane-a']])
})
expect(results).toEqual([])
})
})
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { createHookListenerState } from './agent-hook-listener/listener-state'
import {
bindOpenCodeSession,
lookupOpenCodePaneLaunchToken,
lookupOpenCodeSessionPane,
moveOpenCodeSessionBindings,
OPENCODE_SESSION_BINDINGS_MAX,
trackOpenCodePaneLaunchToken,
unbindOpenCodeSessionsOfPane
} from './agent-hook-listener/opencode-session-registry'
import { makePaneKey } from './stable-pane-id'
const LEAF_A = '11111111-1111-4111-8111-111111111111'
const LEAF_B = '22222222-2222-4222-8222-222222222222'
const PANE_A = makePaneKey('tab-a', LEAF_A)
const PANE_B = makePaneKey('tab-b', LEAF_B)
describe('opencode session registry', () => {
it('binds and looks up a session owner', () => {
const state = createHookListenerState()
expect(
bindOpenCodeSession(state, 'ses_1', { paneKey: PANE_A, boundAt: 1, basis: 'argv' })
).toBe(true)
expect(lookupOpenCodeSessionPane(state, 'ses_1')?.paneKey).toBe(PANE_A)
expect(lookupOpenCodeSessionPane(state, 'ses_unknown')).toBeUndefined()
})
it('refuses blank ids and malformed pane keys', () => {
const state = createHookListenerState()
expect(bindOpenCodeSession(state, ' ', { paneKey: PANE_A, boundAt: 1, basis: 'argv' })).toBe(
false
)
expect(
bindOpenCodeSession(state, 'ses_1', { paneKey: 'not-a-pane', boundAt: 1, basis: 'argv' })
).toBe(false)
expect(state.opencodeSessionPaneBySessionId.size).toBe(0)
})
it('rebinds a session to a new owner and refreshes eviction order', () => {
const state = createHookListenerState()
bindOpenCodeSession(state, 'ses_1', { paneKey: PANE_A, boundAt: 1, basis: 'argv' })
bindOpenCodeSession(state, 'ses_1', {
paneKey: PANE_B,
boundAt: 2,
basis: 'creation-correlation'
})
expect(lookupOpenCodeSessionPane(state, 'ses_1')?.paneKey).toBe(PANE_B)
expect(lookupOpenCodeSessionPane(state, 'ses_1')?.basis).toBe('creation-correlation')
})
it('evicts oldest-bound first once capped', () => {
const state = createHookListenerState()
for (let i = 0; i < OPENCODE_SESSION_BINDINGS_MAX + 5; i += 1) {
bindOpenCodeSession(state, `ses_${i}`, { paneKey: PANE_A, boundAt: i, basis: 'argv' })
}
expect(state.opencodeSessionPaneBySessionId.size).toBe(OPENCODE_SESSION_BINDINGS_MAX)
expect(lookupOpenCodeSessionPane(state, 'ses_0')).toBeUndefined()
expect(
lookupOpenCodeSessionPane(state, `ses_${OPENCODE_SESSION_BINDINGS_MAX + 4}`)
).toBeDefined()
})
it('unbinds every session of a closed pane', () => {
const state = createHookListenerState()
bindOpenCodeSession(state, 'ses_1', { paneKey: PANE_A, boundAt: 1, basis: 'argv' })
bindOpenCodeSession(state, 'ses_2', { paneKey: PANE_A, boundAt: 2, basis: 'argv' })
bindOpenCodeSession(state, 'ses_3', { paneKey: PANE_B, boundAt: 3, basis: 'argv' })
expect(unbindOpenCodeSessionsOfPane(state, PANE_A)).toBe(2)
expect(lookupOpenCodeSessionPane(state, 'ses_1')).toBeUndefined()
expect(lookupOpenCodeSessionPane(state, 'ses_3')?.paneKey).toBe(PANE_B)
})
it('moves bindings with a pane', () => {
const state = createHookListenerState()
bindOpenCodeSession(state, 'ses_1', { paneKey: PANE_A, boundAt: 1, basis: 'argv' })
moveOpenCodeSessionBindings(state, PANE_A, PANE_B)
expect(lookupOpenCodeSessionPane(state, 'ses_1')?.paneKey).toBe(PANE_B)
})
it('tracks last-seen launch tokens per pane', () => {
const state = createHookListenerState()
trackOpenCodePaneLaunchToken(state, PANE_A, ' ')
expect(lookupOpenCodePaneLaunchToken(state, PANE_A)).toBeUndefined()
trackOpenCodePaneLaunchToken(state, PANE_A, 'token-1')
trackOpenCodePaneLaunchToken(state, PANE_A, 'token-2')
expect(lookupOpenCodePaneLaunchToken(state, PANE_A)).toBe('token-2')
})
})
+32 -2
View File
@@ -18,6 +18,10 @@ import { extractPromptText } from './agent-hook-listener/prompt-fields'
import { normalizeProviderEvent } from './agent-hook-listener/provider-dispatch'
import { hasExplicitUserPrompt } from './agent-hook-listener/provider-event-routing'
import { hasExplicitAmpPrompt } from './agent-hook-listener/providers/amp-events'
import {
resolveOpenCodeSharedServerEnvelope,
trackOpenCodePaneLaunchToken
} from './agent-hook-listener/opencode-session-registry'
import { readString } from './agent-hook-listener/tool-input-preview'
/** Canonical transport-agnostic normalization entry shared by main and relay listeners. */
export function normalizeHookPayload(
@@ -31,9 +35,16 @@ export function normalizeHookPayload(
if (!envelope) {
return null
}
const { record, paneKey, hookPayloadRecord, tabId, worktreeId, launchToken } = envelope
const {
record,
paneKey: stampedPaneKey,
hookPayloadRecord,
tabId: stampedTabId,
worktreeId: stampedWorktreeId,
launchToken: stampedLaunchToken
} = envelope
if (source === 'claude') {
state.claudeUnconfirmedRestoredStatusPaneKeys.delete(paneKey)
state.claudeUnconfirmedRestoredStatusPaneKeys.delete(stampedPaneKey)
}
const eventName =
readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ??
@@ -44,6 +55,25 @@ export function normalizeHookPayload(
source === 'codex' && readString(hookPayloadRecord, 'agent_id')
? null
: extractAgentProviderSession(source, hookPayloadRecord)
// Why (#21359): the shared OpenCode server stamps every post with its own
// frozen pane. When the binder has mapped this session to its real pane,
// the stamp is replaced before anything downstream (status lookup, dispatch,
// fences) can act on the wrong owner. Unbound sessions keep the stamp.
const { paneKey, tabId, worktreeId, launchToken } = resolveOpenCodeSharedServerEnvelope({
state,
source,
stamped: {
paneKey: stampedPaneKey,
tabId: stampedTabId,
worktreeId: stampedWorktreeId,
launchToken: stampedLaunchToken
},
sessionId: providerSession?.id
})
// Why after the resolve: tracking the stamped token first would let a stale
// shared-server stamp overwrite the pane's live token; the resolved envelope
// carries the stored token (or nothing) for bound sessions instead.
trackOpenCodePaneLaunchToken(state, paneKey, launchToken)
const providerPromptId =
source === 'claude'
? normalizeClaudePromptId(hookPayloadRecord.prompt_id)
@@ -11,6 +11,11 @@ import type { ClaudeSubagentRoster } from '../claude-subagent-roster'
import type { CodexSubagentRoster } from '../codex-subagent-roster'
import type { CodexSubagentTranscriptState } from '../codex-subagent-transcript'
import type { AgentHookEventPayload, ToolSnapshot } from './listener-event'
import {
moveOpenCodeSessionBindings,
unbindOpenCodeSessionsOfPane,
type OpenCodeSessionBinding
} from './opencode-session-registry'
/** Per-listener-instance caches needing per-PTY teardown; Orca's main process and the relay each get their own, never shared. */
export type HookListenerState = {
@@ -46,6 +51,15 @@ export type HookListenerState = {
codexLeadStateByPaneKey: Map<string, CodexLeadTurnState>
/** Newest Grok turn per pane, used to reject end reports that arrive after a replacement prompt. */
grokActiveTurnByPaneKey: Map<string, GrokActiveTurn>
/**
* OpenCode session id -> owning pane, observed from the client side. The
* shared v2 server stamps every post with its own frozen pane, so ingest
* reattributes bound sessions before disposition. Not a state claim itself —
* it names no row — so paneHasStateClaims ignores it.
*/
opencodeSessionPaneBySessionId: Map<string, OpenCodeSessionBinding>
/** Last launch token seen per pane; a rewritten shared-server post needs the bound pane's live token to pass its fence. */
lastLaunchTokenByPaneKey: Map<string, string>
}
export type GrokActiveTurn = {
@@ -103,7 +117,9 @@ export function createHookListenerState(
codexSubagentRosterByPaneKey: new Map(),
codexSubagentTranscriptByPaneKey: new Map(),
codexLeadStateByPaneKey: new Map(),
grokActiveTurnByPaneKey: new Map()
grokActiveTurnByPaneKey: new Map(),
opencodeSessionPaneBySessionId: new Map(),
lastLaunchTokenByPaneKey: new Map()
}
legacyStatusAdapterByState.set(state, adapter)
return state
@@ -190,6 +206,8 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string):
state.codexSubagentTranscriptByPaneKey.delete(paneKey)
state.codexLeadStateByPaneKey.delete(paneKey)
state.grokActiveTurnByPaneKey.delete(paneKey)
unbindOpenCodeSessionsOfPane(state, paneKey)
deletePaneScopedCacheEntry(state.lastLaunchTokenByPaneKey, paneKey)
}
/** Does this pane still hold anything that can ASSERT a state — a stored row, or a Claude latch that
@@ -264,6 +282,8 @@ export function movePaneCacheState(
movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.grokActiveTurnByPaneKey, fromPaneKey, toPaneKey)
moveOpenCodeSessionBindings(state, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.lastLaunchTokenByPaneKey, fromPaneKey, toPaneKey)
}
export function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void {
@@ -313,4 +333,6 @@ export function clearAllListenerCaches(state: HookListenerState): void {
state.codexSubagentTranscriptByPaneKey.clear()
state.codexLeadStateByPaneKey.clear()
state.grokActiveTurnByPaneKey.clear()
state.opencodeSessionPaneBySessionId.clear()
state.lastLaunchTokenByPaneKey.clear()
}
@@ -0,0 +1,252 @@
/**
* Pure session→pane correlation for the shared OpenCode server (#21359).
*
* The server stamps every post with its own frozen pane, so the binder must
* decide ownership from client-side evidence: which panes sit in the
* session's directory, and which of those panes runs an OpenCode client that
* could have created it. No platform APIs here — the main-process binder
* supplies sessions (SQLite), panes (PTY registry + CWD) and clients
* (argv-aware process sweep); this module only decides.
*/
/** One session row as the binder sees it. */
export type CorrelatedSession = {
id: string
directory: string
/** ms epoch from the session store. */
createdAtMs: number
parentId: string | null
}
/** One pane as the binder sees it. */
export type CorrelatedPane = {
paneKey: string
/**
* Worktree root backing the pane (from the PTY registry), or null when
* unknown. A session belongs to a pane's candidate set when its directory
* is the root or beneath it; same-worktree panes tie and the client sweep
* breaks the tie.
*/
directory: string | null
}
/** One live client process as the binder sees it. */
export type CorrelatedClient = {
/** Pane whose subtree holds this client. */
paneKey: string
/** ms epoch the client process started. */
startedAtMs: number
/** ms epoch the client was last observed alive. */
lastSeenAliveMs: number
/** Full argv; a `--session <id>` hit binds deterministically. */
argv: readonly string[]
}
/** One decided session owner for this round. */
export type SessionOwnership = {
sessionId: string
paneKey: string
basis: 'argv' | 'creation-correlation' | 'single-pane-directory'
}
/** Allow for stamp skew between the process table and the session store. */
export const OPENCODE_CREATE_SKEW_MS = 2 * 60 * 1000
import { normalizeRuntimePathForComparison } from '../cross-platform-path'
/**
* macOS symlinks /tmp, /var and /etc into /private; opencode records
* whichever spelling the process saw, so fold the alias to the real root
* before comparing. Narrow on purpose: a blanket `/private` strip would merge
* genuinely distinct POSIX roots (`/private/repo` vs `/repo`).
*/
function foldMacOsPrivateAlias(directory: string): string {
for (const name of ['tmp', 'var', 'etc']) {
if (directory === `/${name}`) {
return `/private/${name}`
}
if (directory.startsWith(`/${name}/`)) {
return `/private/${directory.slice(1)}`
}
}
return directory
}
/** Lexically resolve `.` and `..` so prefix containment cannot be fooled by dot segments. */
function resolveDotSegments(normalized: string): string {
const isAbsolute = normalized.startsWith('/')
const parts: string[] = []
for (const part of normalized.split('/')) {
if (part === '' || part === '.') {
continue
}
if (part === '..') {
if (parts.length > 0 && parts.at(-1) !== '..') {
parts.pop()
continue
}
if (!isAbsolute) {
parts.push(part)
}
continue
}
parts.push(part)
}
const joined = parts.join('/')
if (isAbsolute) {
return `/${joined}`
}
return joined === '' ? '.' : joined
}
/**
* Comparison key for session/pane directories. NFC + Windows-only backslash
* folding and case folding come from the shared helper (a backslash stays a
* literal filename character on POSIX); dot segments resolve lexically.
*/
function normalizeDir(directory: string): string {
return resolveDotSegments(
normalizeRuntimePathForComparison(foldMacOsPrivateAlias(directory.trim()))
)
}
/** `--session <id>`, `-s <id>`, `--session=<id>` or a trailing attach target. */
export function sessionIdFromArgv(argv: readonly string[]): string | null {
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (arg === '--session' || arg === '-s') {
const next = argv[i + 1]
if (next && !next.startsWith('-')) {
return next
}
} else if (arg.startsWith('--session=')) {
const value = arg.slice('--session='.length)
if (value) {
return value
}
}
}
return null
}
function clientCouldCreate(client: CorrelatedClient, createdAtMs: number): boolean {
// Why lifetime-overlap instead of a start window: a TUI opened days ago
// creates today's session from the same process. Started-before plus
// seen-alive-after brackets the creation; a start window alone would miss
// every long-lived client.
return (
client.startedAtMs <= createdAtMs + OPENCODE_CREATE_SKEW_MS &&
client.lastSeenAliveMs >= createdAtMs
)
}
/**
* Decide owners for sessions the registry has not bound yet. Child sessions
* inherit their root's owner (they roll up to the same pane); argv hits bind
* immediately; otherwise exactly one pane must have both the directory and a
* client that brackets the creation. Anything else stays unbound — today's
* behavior — rather than guessing.
*/
export function correlateOpenCodeSessionOwners(args: {
sessions: readonly CorrelatedSession[]
panes: readonly CorrelatedPane[]
clients: readonly CorrelatedClient[]
/** Owners already known (registry + earlier binds this round). */
knownOwners: ReadonlyMap<string, string>
}): SessionOwnership[] {
const { sessions, panes, clients, knownOwners } = args
const owners = new Map(knownOwners)
const results: SessionOwnership[] = []
/** Record one ownership decision, visible to later sessions in this round. */
const claim = (sessionId: string, paneKey: string, basis: SessionOwnership['basis']): void => {
owners.set(sessionId, paneKey)
results.push({ sessionId, paneKey, basis })
}
// Why argv first: a command line names its session outright, so it outranks
// every heuristic even when the directory is crowded.
for (const client of clients) {
const named = sessionIdFromArgv(client.argv)
if (named && !owners.has(named)) {
claim(named, client.paneKey, 'argv')
}
}
const panesByDirectory = new Map<string, CorrelatedPane[]>()
for (const pane of panes) {
if (!pane.directory) {
continue
}
const key = normalizeDir(pane.directory)
const list = panesByDirectory.get(key)
if (list) {
list.push(pane)
} else {
panesByDirectory.set(key, [pane])
}
}
/** Panes whose worktree root contains this directory (exact or beneath). */
function containingPanes(directory: string): CorrelatedPane[] {
const target = normalizeDir(directory)
const found: CorrelatedPane[] = []
for (const [root, list] of panesByDirectory) {
if (target === root || target.startsWith(`${root}/`)) {
found.push(...list)
}
}
return found
}
/** Walk the parent chain for an already-known root owner. */
const rootOwner = (session: CorrelatedSession): string | undefined => {
let current: CorrelatedSession | undefined = session
const seen = new Set<string>()
while (current?.parentId && !seen.has(current.id)) {
seen.add(current.id)
const owner = owners.get(current.parentId)
if (owner) {
return owner
}
current = sessions.find((s) => s.id === current?.parentId)
}
return current && current !== session ? owners.get(current.id) : undefined
}
for (const session of sessions) {
if (owners.has(session.id)) {
continue
}
const inherited = session.parentId
? (rootOwner(session) ?? owners.get(session.parentId))
: undefined
if (inherited) {
claim(session.id, inherited, 'creation-correlation')
continue
}
const candidates = containingPanes(session.directory)
if (candidates.length === 0) {
continue
}
const evidencing = candidates.filter((pane) =>
clients.some(
(client) =>
client.paneKey === pane.paneKey && clientCouldCreate(client, session.createdAtMs)
)
)
const [only] = evidencing
if (evidencing.length !== 1 || !only) {
continue
}
// Why the basis split: a lone containing pane needs no client evidence
// to be unambiguous; a shared worktree always resolves through it.
claim(
session.id,
only.paneKey,
candidates.length === 1 ? 'single-pane-directory' : 'creation-correlation'
)
}
return results
}
@@ -0,0 +1,187 @@
import { parsePaneKey } from '../stable-pane-id'
import type { AgentHookSource } from '../agent-hook-relay'
import type { HookListenerState } from './listener-state'
/**
* Which pane owns one OpenCode session, as observed from the client side.
*
* Why this exists: OpenCode v2 serves every pane from a single shared server
* process, so the status plugin's per-post stamp (`process.env.ORCA_PANE_KEY`)
* is frozen to whichever pane started the server. The session id is the only
* per-event truth that survives — every post carries it — but nothing maps it
* back to a pane. This registry is that map, filled by the main-process binder
* (client argv, then creation-correlation against the session store) and read
* at ingest to reattribute posts before disposition.
*/
export type OpenCodeSessionBinding = {
paneKey: string
worktreeId?: string
/** ms epoch of the bind; oldest-bound evicts first once capped. */
boundAt: number
/** How the binder learned this owner. */
basis: 'argv' | 'creation-correlation' | 'single-pane-directory'
}
/** Upper bound; sessions are cheap rows but the map must not grow forever. */
export const OPENCODE_SESSION_BINDINGS_MAX = 1000
/** Per-pane launch-token cache; keyed differently from bindings but shares the same bound. */
export const OPENCODE_PANE_LAUNCH_TOKENS_MAX = 1000
/** Per-listener session→pane map; the binder writes, ingest reads. */
function bindings(state: HookListenerState): Map<string, OpenCodeSessionBinding> {
return state.opencodeSessionPaneBySessionId
}
/**
* Record a session owner. Ignores blank ids and malformed pane keys so a
* corrupt binder observation can never poison ingest.
*/
export function bindOpenCodeSession(
state: HookListenerState,
sessionId: string,
binding: OpenCodeSessionBinding
): boolean {
const id = sessionId.trim()
if (!id || parsePaneKey(binding.paneKey) === null) {
return false
}
const map = bindings(state)
// Why delete-then-set: Map evicts in insertion order, so a refreshed bind
// must move to the back or a hot session would evict as if it were the
// oldest one.
map.delete(id)
map.set(id, binding)
while (map.size > OPENCODE_SESSION_BINDINGS_MAX) {
const oldest = map.keys().next().value
if (oldest === undefined) {
break
}
map.delete(oldest)
}
return true
}
/** Pane that owns this session, if the binder has seen it. */
export function lookupOpenCodeSessionPane(
state: HookListenerState,
sessionId: string
): OpenCodeSessionBinding | undefined {
return bindings(state).get(sessionId.trim())
}
/**
* Drop every binding owned by a pane: teardown, reuse and close must not let
* a dead pane keep claiming a live session's dots.
* @returns Number of bindings removed.
*/
export function unbindOpenCodeSessionsOfPane(state: HookListenerState, paneKey: string): number {
let removed = 0
for (const [sessionId, binding] of bindings(state)) {
// Why exact match only: bindings store validated `tabId:uuid` pane keys,
// which cannot carry the `\0` subscopes the hierarchical pane caches use.
if (binding.paneKey === paneKey) {
bindings(state).delete(sessionId)
removed += 1
}
}
return removed
}
/** Rewrite bindings when a pane moves (e.g. detached into another tab). */
export function moveOpenCodeSessionBindings(
state: HookListenerState,
fromPaneKey: string,
toPaneKey: string
): void {
if (fromPaneKey === toPaneKey || parsePaneKey(toPaneKey) === null) {
return
}
for (const binding of bindings(state).values()) {
if (binding.paneKey === fromPaneKey) {
binding.paneKey = toPaneKey
}
}
}
/**
* Last launch token seen per pane, from any source's envelope. The shared
* server's posts carry a frozen (usually empty) token, so a rewritten post
* for a fenced pane would be suppressed without the bound pane's live token.
* Updated on every tokened post; the fence comparison hashes it the same way.
*/
export function trackOpenCodePaneLaunchToken(
state: HookListenerState,
paneKey: string,
launchToken: string | undefined
): void {
const token = launchToken?.trim()
if (!token) {
return
}
const map = state.lastLaunchTokenByPaneKey
map.delete(paneKey)
map.set(paneKey, token)
while (map.size > OPENCODE_PANE_LAUNCH_TOKENS_MAX) {
const oldest = map.keys().next().value
if (oldest === undefined) {
break
}
map.delete(oldest)
}
}
/** Live token for a pane, if any tokened post has arrived since startup. */
export function lookupOpenCodePaneLaunchToken(
state: HookListenerState,
paneKey: string
): string | undefined {
return state.lastLaunchTokenByPaneKey.get(paneKey)
}
/** Envelope fields the rewrite may substitute, as stamped by the poster. */
export type OpenCodeStampedEnvelope = {
paneKey: string
tabId?: string
worktreeId?: string
launchToken?: string
}
/**
* Reattribute one shared-server post to the bound pane (#21359). Reads
* nothing but the registry: unbound sessions and other sources pass through
* untouched, so this is a no-op everywhere the binder has said nothing. A
* bound session always takes the stored pane token — which may be absent,
* in which case the post carries no token rather than the frozen stamp.
*/
export function resolveOpenCodeSharedServerEnvelope(args: {
state: HookListenerState
source: AgentHookSource
stamped: OpenCodeStampedEnvelope
sessionId: string | undefined
}): OpenCodeStampedEnvelope {
const { state, source, stamped, sessionId } = args
if ((source !== 'opencode' && source !== 'mimo-code') || !sessionId) {
return stamped
}
const binding = lookupOpenCodeSessionPane(state, sessionId)
if (!binding) {
return stamped
}
return {
paneKey: binding.paneKey,
// Why derive: the envelope rejects a tabId that disagrees with the pane
// key, so the substituted tab must come from the substituted pane.
tabId: parsePaneKey(binding.paneKey)?.tabId ?? stamped.tabId,
// Why no stamped fallback: a binding without a worktree (its pane row
// carried none) combined with the stamped pane's worktree would file the
// row under the wrong worktree. Absent is honest; wrong is not.
worktreeId: binding.worktreeId,
// Why the stored token or nothing: the frozen stamp carries the
// server-starter's (usually empty) token, which a fenced pane would
// suppress — and recording that stale token as live would poison the
// cache for the pane's real posts. The session is the authority here,
// not the posting process.
launchToken: lookupOpenCodePaneLaunchToken(state, binding.paneKey)
}
}