mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Merge branch 'main' into fix/18031-pi-lifecycle
This commit is contained in:
@@ -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';
|
||||
Generated
+6
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
])
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user