From 646fa3645fc407a2067d72a6e16057e825346360 Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Mon, 21 Sep 2026 00:06:55 -0300 Subject: [PATCH 1/8] fix(opencode): attribute shared-server sessions to their panes (#21577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: allow-list opencode tool-readout follow-up note * fix(opencode): attribute shared-server sessions to their panes The v2 shared server stamps every hook post with its own frozen pane, so all panes' status lands on the starter pane (#21359). - shared: session->pane registry plus ingest-time envelope rewrite; bound sessions resolve to their real pane, tab and live launch token before disposition, unbound sessions keep the stamped identity. - main: binder poll (SQLite session store, PTY-registry pane snapshots, argv-aware client sweep) with directory-containment plus client-lifetime correlation; 60s loop plus debounced SessionStart kick, wired into the hook server lifecycle. * fix(opencode): newest-wins pane dedupe, macOS private/tmp normalization Live verification against the dev instance found two binder gaps: remint rows for one pane counted as an ambiguous tie, and /tmp vs /private/tmp spellings never met on macOS. * fix(opencode): review fixes — newest-wins worktree, drop dead constant - applyBinderOwnerships now overwrites per-pane worktree, matching the round's newest-wins pane dedupe; a remint's live row wins over a stale row (pinned by test). - remove the unused OPENCODE_CLIENT_PRE_CREATE_WINDOW_MS export and the nowMs residue from clientCouldCreate. - give the per-pane launch-token cache its own named cap constant. * fix(opencode): address thread review — cursor, native table, tokens, lifecycle - composite (time_created, id) store cursor advanced past handled rows only, so same-millisecond pagination and full unbound maps no longer drop sessions silently. - Windows sweep reads the native process table instead of forking powershell.exe; quote-aware argv parsing on both platforms. - directory keys via normalizeRuntimePathForComparison (Windows case-fold, POSIX backslash literals) plus narrow macOS /tmp|/var|/etc aliases and lexical dot-segment resolution. - bound sessions always take the stored pane token (never the frozen stamp); token tracking runs after resolution. - binder generation guard discards post-stop rounds; first round runs immediately at loop start. - unbind/move use exact pane-key match; pane launch-token cache gets its own cap constant. - move the tool-readout note out of this PR for its own branch. * fix(opencode): second review round — executable field, worktree scope, round lifecycle - POSIX sweep reads comm= alongside args= and classifies on the kernel executable name, so unquoted install paths with spaces no longer split argv[0] and reject the client; Windows rows carry the native table name. Degrades to argv[0] when comm is unavailable. - bound sessions take only the binding's worktree (never the stamped pane's), so a worktree-less binding cannot file a row under the wrong worktree. - the binder generation is captured before the round body and the running flag clears only for the current generation, so an obsolete post-stop round cannot admit an overlapping round. --------- Co-authored-by: orca-agent --- .../server-opencode-binder.test.ts | 245 +++++++++++++ .../server/server-ingest-normalization.ts | 15 +- .../agent-hooks/server/server-lifecycle.ts | 2 + .../server/server-opencode-binder.ts | 213 +++++++++++ .../opencode/opencode-client-sweep.test.ts | 215 +++++++++++ src/main/opencode/opencode-client-sweep.ts | 263 ++++++++++++++ .../opencode/opencode-session-binder.test.ts | 176 +++++++++ src/main/opencode/opencode-session-binder.ts | 341 ++++++++++++++++++ ...ok-listener-opencode-reattribution.test.ts | 127 +++++++ ...tener-opencode-session-correlation.test.ts | 228 ++++++++++++ ...listener-opencode-session-registry.test.ts | 89 +++++ src/shared/agent-hook-listener.ts | 34 +- .../agent-hook-listener/listener-state.ts | 24 +- .../opencode-session-correlation.ts | 252 +++++++++++++ .../opencode-session-registry.ts | 187 ++++++++++ 15 files changed, 2405 insertions(+), 6 deletions(-) create mode 100644 src/main/agent-hooks/server-opencode-binder.test.ts create mode 100644 src/main/agent-hooks/server/server-opencode-binder.ts create mode 100644 src/main/opencode/opencode-client-sweep.test.ts create mode 100644 src/main/opencode/opencode-client-sweep.ts create mode 100644 src/main/opencode/opencode-session-binder.test.ts create mode 100644 src/main/opencode/opencode-session-binder.ts create mode 100644 src/shared/agent-hook-listener-opencode-reattribution.test.ts create mode 100644 src/shared/agent-hook-listener-opencode-session-correlation.test.ts create mode 100644 src/shared/agent-hook-listener-opencode-session-registry.test.ts create mode 100644 src/shared/agent-hook-listener/opencode-session-correlation.ts create mode 100644 src/shared/agent-hook-listener/opencode-session-registry.ts diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts new file mode 100644 index 00000000000..59d1c9142fe --- /dev/null +++ b/src/main/agent-hooks/server-opencode-binder.test.ts @@ -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): void { + this._setOpenCodeBinderDepsForTests(deps) + } + + public runBinderRound(): Promise { + 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((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((resolve) => { + releaseFirst = resolve + }) + const laterGate = new Promise((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() + }) +}) diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index 0a1e8d761d6..171a15ac5cc 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -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).paneKey const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : '' diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 5b8bf908d55..f3f7b625c83 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -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 diff --git a/src/main/agent-hooks/server/server-opencode-binder.ts b/src/main/agent-hooks/server/server-opencode-binder.ts new file mode 100644 index 00000000000..32f8e677539 --- /dev/null +++ b/src/main/agent-hooks/server/server-opencode-binder.ts @@ -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 +} + +/** + * 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 | null = null + private openCodeBinderKickTimer: ReturnType | null = null + private openCodeBinderRunning = false + private openCodeBinderGeneration = 0 + private openCodeBinderWatermark: OpenCodeSessionCursor = { ...OPENCODE_SESSION_CURSOR_START } + private openCodeBinderParents = new Map() + private openCodeBinderUnbound = new Map() + 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): 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 { + 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() + 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 + } + } + } +} diff --git a/src/main/opencode/opencode-client-sweep.test.ts b/src/main/opencode/opencode-client-sweep.test.ts new file mode 100644 index 00000000000..d5444eac128 --- /dev/null +++ b/src/main/opencode/opencode-client-sweep.test.ts @@ -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 { + return async (spec: ProcessSpec): Promise => { + 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 }) + }) +}) diff --git a/src/main/opencode/opencode-client-sweep.ts b/src/main/opencode/opencode-client-sweep.ts new file mode 100644 index 00000000000..bb37e504caa --- /dev/null +++ b/src/main/opencode/opencode-client-sweep.ts @@ -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 `) 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 + } = {} +): Promise { + 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() + 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 { + 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 +} diff --git a/src/main/opencode/opencode-session-binder.test.ts b/src/main/opencode/opencode-session-binder.test.ts new file mode 100644 index 00000000000..547ca9d01ba --- /dev/null +++ b/src/main/opencode/opencode-session-binder.test.ts @@ -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' + }) + }) +}) diff --git a/src/main/opencode/opencode-session-binder.ts b/src/main/opencode/opencode-session-binder.ts new file mode 100644 index 00000000000..9d40ccb278c --- /dev/null +++ b/src/main/opencode/opencode-session-binder.ts @@ -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 + parentBySessionId: ReadonlyMap +} + +/** 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 { + const ppidByPid = new Map() + 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, + shellPidByPid: Map, + pid: number +): string | null { + const seen = new Set() + 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() + 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() + 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([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 + 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() + 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 +} diff --git a/src/shared/agent-hook-listener-opencode-reattribution.test.ts b/src/shared/agent-hook-listener-opencode-reattribution.test.ts new file mode 100644 index 00000000000..676b569f495 --- /dev/null +++ b/src/shared/agent-hook-listener-opencode-reattribution.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts new file mode 100644 index 00000000000..c0bc1d0ef37 --- /dev/null +++ b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts @@ -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([]) + }) +}) diff --git a/src/shared/agent-hook-listener-opencode-session-registry.test.ts b/src/shared/agent-hook-listener-opencode-session-registry.test.ts new file mode 100644 index 00000000000..c5d4874a423 --- /dev/null +++ b/src/shared/agent-hook-listener-opencode-session-registry.test.ts @@ -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') + }) +}) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 7df46e74cbc..73f05c1d763 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -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) diff --git a/src/shared/agent-hook-listener/listener-state.ts b/src/shared/agent-hook-listener/listener-state.ts index d4daeaa04b2..5f442b1ff77 100644 --- a/src/shared/agent-hook-listener/listener-state.ts +++ b/src/shared/agent-hook-listener/listener-state.ts @@ -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 /** Newest Grok turn per pane, used to reject end reports that arrive after a replacement prompt. */ grokActiveTurnByPaneKey: Map + /** + * 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 + /** Last launch token seen per pane; a rewritten shared-server post needs the bound pane's live token to pass its fence. */ + lastLaunchTokenByPaneKey: Map } 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() } diff --git a/src/shared/agent-hook-listener/opencode-session-correlation.ts b/src/shared/agent-hook-listener/opencode-session-correlation.ts new file mode 100644 index 00000000000..069769ab07b --- /dev/null +++ b/src/shared/agent-hook-listener/opencode-session-correlation.ts @@ -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 ` 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 `, `-s `, `--session=` 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 +}): 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() + 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() + 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 +} diff --git a/src/shared/agent-hook-listener/opencode-session-registry.ts b/src/shared/agent-hook-listener/opencode-session-registry.ts new file mode 100644 index 00000000000..0d6183197b5 --- /dev/null +++ b/src/shared/agent-hook-listener/opencode-session-registry.ts @@ -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 { + 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) + } +} From 7a6d10064e25285da1cbe8f9a39d7fa2401c265c Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:12:08 +0900 Subject: [PATCH 2/8] fix: read OpenCode Go usage from the console API (#21462) * fix: read OpenCode Go usage from the console API The workspace HTML page now 302s to console login. Fetch /console/api/go/status with x-org-id, map JSON meters into the existing windows, and keep __Host-console_session on the closed cookie allowlist. Fixes #21420 * fix: tell users to paste the OpenCode console session cookie The Go status API is authed by __Host-console_session. Settings still told people to paste auth only, which 401s. Ask for the full Cookie header; auth remains enough for workspace discovery. --- .../rate-limits/opencode-go-page-scraper.ts | 172 ----------- .../opencode-go-status-parsing.test.ts | 118 ++++++++ .../rate-limits/opencode-go-status-parsing.ts | 86 ++++++ .../opencode-go-usage-fetcher.test.ts | 268 +++++++++++------- .../rate-limits/opencode-go-usage-fetcher.ts | 62 ++-- .../components/settings/AccountsPane.test.tsx | 9 + ...ccounts-pane-provider-setting-sections.tsx | 29 +- .../settings/accounts-search.test.ts | 22 +- .../components/settings/accounts-search.ts | 5 +- src/renderer/src/i18n/locales/en.json | 6 + 10 files changed, 447 insertions(+), 330 deletions(-) delete mode 100644 src/main/rate-limits/opencode-go-page-scraper.ts create mode 100644 src/main/rate-limits/opencode-go-status-parsing.test.ts create mode 100644 src/main/rate-limits/opencode-go-status-parsing.ts diff --git a/src/main/rate-limits/opencode-go-page-scraper.ts b/src/main/rate-limits/opencode-go-page-scraper.ts deleted file mode 100644 index 0f092da6db3..00000000000 --- a/src/main/rate-limits/opencode-go-page-scraper.ts +++ /dev/null @@ -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 - } -} diff --git a/src/main/rate-limits/opencode-go-status-parsing.test.ts b/src/main/rate-limits/opencode-go-status-parsing.test.ts new file mode 100644 index 00000000000..f8e0eeb29e7 --- /dev/null +++ b/src/main/rate-limits/opencode-go-status-parsing.test.ts @@ -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('rollingUsage:{usagePercent:30}')).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() + }) +}) diff --git a/src/main/rate-limits/opencode-go-status-parsing.ts b/src/main/rate-limits/opencode-go-status-parsing.ts new file mode 100644 index 00000000000..9137dd911a0 --- /dev/null +++ b/src/main/rate-limits/opencode-go-status-parsing.ts @@ -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 { + 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) + } +} diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts index 24db88111a4..704a15b9b73 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts @@ -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 = ` - -` +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 = ` ` 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=)', 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= 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//go after resolving workspace ID', async () => { + it('fetches usage from /console/api/go/status with x-org-id and never scrapes /workspace//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('no usage data here')) + .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 () => { diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index f2ea68b7588..1d9699bec88 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -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//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 diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index bf7656856d5..b1be5e1a305 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -167,4 +167,13 @@ describe('AccountsPane', () => { markup.slice(markup.lastIndexOf(' { + 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') + }) }) diff --git a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx index 6bf03df51f4..5d3a8953eca 100644 --- a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx +++ b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx @@ -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" >