diff --git a/mobile/patches/query-string@9.5.1.patch b/mobile/patches/query-string@9.5.1.patch new file mode 100644 index 00000000000..eb8c575d5ec --- /dev/null +++ b/mobile/patches/query-string@9.5.1.patch @@ -0,0 +1,10 @@ +diff --git a/index.js b/index.js +index 0480a96d718c997b0a5a54b775e635e2e048e796..85f71b13d3f0f31c3c2140581d1e4051afb21dc3 100644 +--- a/index.js ++++ b/index.js +@@ -1,3 +1,5 @@ + import * as queryString from './base.js'; + + export default queryString; ++ ++export * from './base.js'; diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 201d564805c..63a1f5dec01 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -12,6 +12,9 @@ patchedDependencies: expo-notifications@55.0.27: hash: ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0 path: patches/expo-notifications@55.0.27.patch + query-string@9.5.1: + hash: 8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b + path: patches/query-string@9.5.1.patch react-native-webview@13.16.2: hash: de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27 path: patches/react-native-webview@13.16.2.patch @@ -9966,7 +9969,7 @@ snapshots: escape-string-regexp: 4.0.0 fast-deep-equal: 3.1.3 nanoid: 3.3.18 - query-string: 9.5.1 + query-string: 9.5.1(patch_hash=8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b) react: 19.2.8 react-is: 19.2.6 use-latest-callback: 0.2.6(react@19.2.8) @@ -12230,7 +12233,7 @@ snapshots: fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.18 - query-string: 9.5.1 + query-string: 9.5.1(patch_hash=8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b) react: 19.2.8 react-fast-compare: 3.2.2 react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8) @@ -14283,7 +14286,7 @@ snapshots: pure-rand@6.1.0: {} - query-string@9.5.1: + query-string@9.5.1(patch_hash=8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b): dependencies: decode-uri-component: 0.5.0 filter-obj: 5.1.0 diff --git a/mobile/pnpm-workspace.yaml b/mobile/pnpm-workspace.yaml index 9f0a43c71fe..4e4bb54fc3d 100644 --- a/mobile/pnpm-workspace.yaml +++ b/mobile/pnpm-workspace.yaml @@ -9,5 +9,6 @@ overrides: patchedDependencies: expo-notifications@55.0.27: patches/expo-notifications@55.0.27.patch + query-string@9.5.1: patches/query-string@9.5.1.patch react-native-webview@13.16.2: patches/react-native-webview@13.16.2.patch react-native@0.83.10: patches/react-native@0.83.10.patch diff --git a/mobile/src/navigation/query-string-named-exports.test.ts b/mobile/src/navigation/query-string-named-exports.test.ts new file mode 100644 index 00000000000..9cd34aedaea --- /dev/null +++ b/mobile/src/navigation/query-string-named-exports.test.ts @@ -0,0 +1,28 @@ +import { execFileSync } from 'node:child_process' +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' + +/** + * expo-router and React Navigation serialise route params through `import * as queryString from + * 'query-string'`. The lockfile overrides `query-string` to 9.x for a `decode-uri-component` + * advisory, and 9.x's entry has a default export only, so without `patches/query-string@9.5.1.patch` + * every push carrying a param outside the path pattern throws `queryString.stringify is not a + * function` and every href with a query throws on `parse`. Resolved from expo-router's own location, + * the way Metro and the web bundler resolve it for that consumer, and imported by a plain Node + * child: vitest's default-export interop would paper over the missing names in-process. + */ +describe('query-string, as expo-router resolves it', () => { + it('exposes the named API the namespace import needs', () => { + const requireFromHere = createRequire(import.meta.url) + const requireFromExpoRouter = createRequire(requireFromHere.resolve('expo-router/package.json')) + const entry = pathToFileURL(requireFromExpoRouter.resolve('query-string')).href + const script = `const ns = await import(${JSON.stringify(entry)}); process.stdout.write(JSON.stringify({ names: Object.keys(ns).sort(), stringified: typeof ns.stringify === 'function' ? ns.stringify({ from: 'worktrees' }) : null }))` + const output = execFileSync(process.execPath, ['--input-type=module', '-e', script], { + encoding: 'utf8' + }) + const { names, stringified } = JSON.parse(output) + expect(names).toEqual(expect.arrayContaining(['parse', 'stringify'])) + expect(stringified).toBe('from=worktrees') + }) +}) 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/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 6186976d127..dedd44e70e3 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -91,6 +91,7 @@ export function buildPtyHostEnv( // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. const openCodeStatusService = openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService + baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) if (baseEnv.OPENCODE_CONFIG_DIR) { // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. diff --git a/src/main/opencode-usage/opencode-usage-row-queries.ts b/src/main/opencode-usage/opencode-usage-row-queries.ts index 2e633d7cda2..db8e84010b2 100644 --- a/src/main/opencode-usage/opencode-usage-row-queries.ts +++ b/src/main/opencode-usage/opencode-usage-row-queries.ts @@ -27,6 +27,7 @@ type OpenCodeSessionUsageRow = { tokens_output: number tokens_reasoning: number tokens_cache_read: number + tokens_cache_write: number } function getProjectJoin(db: Database.Database): string { @@ -46,6 +47,7 @@ function getAssistantSessionMessageCount(db: Database.Database): number { const assistantPredicate = columnExists(db, 'session_message', 'type') ? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL" : "json_extract(data, '$.tokens.input') IS NOT NULL" + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SQLite aggregate rows are validated by the typed count field below. const row = db .prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`) .get() as { count?: number } | undefined @@ -61,16 +63,29 @@ function canReadSessionUsageRows(db: Database.Database): boolean { ) } +function getSessionCacheWriteSelect(db: Database.Database): string { + return columnExists(db, 'session', 'tokens_cache_write') ? 's.tokens_cache_write' : '0' +} + +function getSessionTokenTotalExpression(db: Database.Database): string { + const cacheWrite = columnExists(db, 'session', 'tokens_cache_write') + ? ' + tokens_cache_write' + : '' + return `tokens_input + tokens_output + tokens_reasoning + tokens_cache_read${cacheWrite}` +} + function getSessionUsageRowCount(db: Database.Database): number { if (!canReadSessionUsageRows(db)) { return 0 } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SQLite aggregate rows are validated by the typed count field below. const row = db .prepare( `SELECT COUNT(*) AS count FROM session - WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0` + WHERE ${getSessionTokenTotalExpression(db)} > 0` ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SQLite aggregate rows are validated by the typed count field below. .get() as { count?: number } | undefined return row?.count ?? 0 } @@ -78,14 +93,18 @@ function getSessionUsageRowCount(db: Database.Database): number { function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] { const projectJoin = getProjectJoin(db) const sessionModelSelect = getSessionModelSelect(db) + const cacheWriteSelect = getSessionCacheWriteSelect(db) + const tokenTotalExpression = getSessionTokenTotalExpression(db) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT aliases match OpenCodeSessionUsageRow across supported schemas. const rows = db .prepare( `SELECT s.id, s.id AS session_id, s.time_created, s.time_updated, s.directory, s.title, p.worktree, ${sessionModelSelect}, - s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read + s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read, + ${cacheWriteSelect} AS tokens_cache_write FROM session s ${projectJoin} - WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0 + WHERE ${tokenTotalExpression.replaceAll('tokens_', 's.tokens_')} > 0 ORDER BY s.time_created, s.id` ) .all() as OpenCodeSessionUsageRow[] @@ -105,10 +124,15 @@ function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] { input: row.tokens_input, output: row.tokens_output, reasoning: row.tokens_reasoning, - total: row.tokens_input + row.tokens_output + row.tokens_reasoning, + total: + row.tokens_input + + row.tokens_output + + row.tokens_reasoning + + row.tokens_cache_read + + row.tokens_cache_write, cache: { read: row.tokens_cache_read, - write: 0 + write: row.tokens_cache_write } } }) diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts index 4188a0f93a9..88559b0ec7d 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -43,6 +43,7 @@ function createSessionTotalsSchema(db: Database.Database): void { tokens_output INTEGER, tokens_reasoning INTEGER, tokens_cache_read INTEGER, + tokens_cache_write INTEGER, time_created INTEGER, time_updated INTEGER ); @@ -57,9 +58,9 @@ function insertSessionTotalsRow( db.prepare( `INSERT INTO session ( id, directory, title, model, cost, - tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, time_created, time_updated - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( sessionId, `${WORKTREE}/packages/app`, @@ -70,6 +71,7 @@ function insertSessionTotalsRow( 100, 0, 0, + 0, 1_777_777_700_000, 1_777_777_800_000 ) @@ -235,6 +237,7 @@ describe('parseOpenCodeUsageDatabase', () => { tokens_output INTEGER, tokens_reasoning INTEGER, tokens_cache_read INTEGER, + tokens_cache_write INTEGER, time_created INTEGER, time_updated INTEGER ); @@ -243,9 +246,9 @@ describe('parseOpenCodeUsageDatabase', () => { db.prepare( `INSERT INTO session ( id, project_id, directory, title, model, cost, - tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, time_created, time_updated - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( 'session-1', 'project-1', @@ -257,6 +260,7 @@ describe('parseOpenCodeUsageDatabase', () => { 500, 100, 250, + 75, 1_777_777_700_000, 1_777_777_800_000 ) @@ -274,7 +278,7 @@ describe('parseOpenCodeUsageDatabase', () => { totalCachedInputTokens: 250, totalOutputTokens: 500, totalReasoningOutputTokens: 100, - totalTokens: 1850, + totalTokens: 1925, estimatedCostUsd: 0.06 }) expect(parsed.dailyAggregates).toEqual([ @@ -284,7 +288,7 @@ describe('parseOpenCodeUsageDatabase', () => { cachedInputTokens: 250, outputTokens: 500, reasoningOutputTokens: 100, - totalTokens: 1850, + totalTokens: 1925, estimatedCostUsd: 0.06 }) ]) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index df5bfc022be..c33208c2b89 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -91,11 +91,11 @@ describe('OpenCode hook plugin source', () => { const digest = (source: string): string => createHash('sha256').update(source).digest('hex') expect(digest(getOpenCodePluginSource())).toBe( - 'd14859a36c88aefe3a45cd232789503296e0a23438b151c773414bad64ab8eaa' + '938867eae97b7ae4a7193a07755ee74a77c7a7352edcc28b59543b1b00713612' ) expect( digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false })) - ).toBe('4de14bee0c27ce55f29f70b19aa6ce9967e09b098bba139fb88f0511af7d4fca') + ).toBe('4c9c27af603a9e85e3e33a30c439d9dfb6785936dea0be76fdd64cf7dc2f7174') }) it('filters child sessions via parentID lookup before forwarding events', () => { 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/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 48074c5669c..871692639fe 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -7,6 +7,7 @@ export function getStatusPluginFactorySource(options: { emitSessionStart: boolean emitNextEvents?: boolean }): string[] { + const expectedAgent = options.emitNextEvents ? 'opencode2' : 'opencode' return [ ...(options.emitNextEvents ? getOpenCode2EventNormalizationSource() : []), '// Why: accept the factory argument as an optional opaque parameter instead', @@ -15,6 +16,7 @@ export function getStatusPluginFactorySource(options: { '// destructuring form throw synchronously and crash OpenCode with an opaque', '// UnknownError before any event is ever dispatched.', 'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {', + ` if (process.env.ORCA_OPENCODE_AGENT && process.env.ORCA_OPENCODE_AGENT !== '${expectedAgent}') return {};`, ' const client = _ctx?.client;', ' const factoryID = ++nextFactoryID;', ' activeFactoryIDs.add(factoryID);', 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/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts index 3ae648ab0ef..5e1de184c9c 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-07.spec.ts @@ -3,7 +3,7 @@ import { AGENT_PROMPT_BRACKETED_PASTE_END, AGENT_PROMPT_BRACKETED_PASTE_START, buildAgentPromptPasteBytes, - getAgentPromptSubmitDelayMs + resolveAgentPromptSubmitDelayForAgent } from '../../../shared/agent-prompt-injection' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import type { TuiAgent } from '../../../shared/tui-agent' @@ -610,9 +610,13 @@ describe('OrcaRuntimeService', () => { launchAgent: agent }) - const submitDelayMs = getAgentPromptSubmitDelayMs( + // The agent's own policy, not the byte-only delay: antigravity adds a per-line settle + // (#21665), and advancing fake timers by less than the policy waits leaves the submit + // pending until the real 30 s timeout. + const submitDelayMs = resolveAgentPromptSubmitDelayForAgent( process.platform, - Buffer.byteLength(buildAgentPromptPasteBytes('review this change'), 'utf8') + 'review this change', + agent ) const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change') if (agent === 'omp') { diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 27d41707ea5..7ab4bf01162 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -89,6 +89,7 @@ export class RelayAgentHookRuntime { context.launchAgent === 'opencode2' || isOpenCode2LaunchCommand(launchCommandHint) ? 'opencode2' : 'opencode' + env.ORCA_OPENCODE_AGENT = opencodeAgent if (this.pluginOverlay.hasOpenCodeSource(opencodeAgent)) { const sourceDir = resolveOpenCodeSourceConfigDir(context.env, context.shell) const inheritedRelayOverlay = sourceDir diff --git a/src/relay/wsl-install-plugins-handler.test.ts b/src/relay/wsl-install-plugins-handler.test.ts index 7e12e4c6435..3b0116260a0 100644 --- a/src/relay/wsl-install-plugins-handler.test.ts +++ b/src/relay/wsl-install-plugins-handler.test.ts @@ -187,11 +187,10 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues }) }) - it('does not mirror the XDG default config root', () => { + it('mirrors the XDG default config root when using an overlay', () => { withHome((home) => { - // Why: OpenCode APPENDS OPENCODE_CONFIG_DIR to its config-dir list rather than - // replacing it, so ~/.config/opencode is read anyway — mirroring it here would - // load the user's config and plugins twice. + // Why: OPENCODE_CONFIG_DIR replaces the default root, so the overlay must + // carry the user's default config and Orca's plugin together. const defaultConfig = join(home, '.config', 'opencode') mkdirSync(defaultConfig, { recursive: true }) writeFileSync(join(defaultConfig, 'opencode.json'), '{"model":"default"}') @@ -202,7 +201,7 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const dir = install({ opencodePluginSource: '// v1\n' }).overlayDirs.opencode as string - expect(existsSync(join(dir, 'opencode.json'))).toBe(false) + expect(existsSync(join(dir, 'opencode.json'))).toBe(true) expect(existsSync(join(dir, 'plugins', 'orca-opencode-status.js'))).toBe(true) }) }) diff --git a/src/relay/wsl-install-plugins-handler.ts b/src/relay/wsl-install-plugins-handler.ts index ca5c515a9e8..b602d353ea8 100644 --- a/src/relay/wsl-install-plugins-handler.ts +++ b/src/relay/wsl-install-plugins-handler.ts @@ -7,6 +7,7 @@ import { existsSync } from 'node:fs' import { getRelayOpenCodePluginPath, type PluginOverlayManager } from './plugin-overlay' import { resolveOpenCodeSourceConfigDir } from './plugin-overlay-env' +import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { assertPluginSourceUnderByteCap } from './plugin-source-limit' import { sanitizeWslHookInstanceKey, @@ -26,11 +27,8 @@ export type InstallPluginsResult = { export type InstallPluginsHandler = (params: Record) => InstallPluginsResult -// Why NOT to fall back to ~/.config/opencode here: OpenCode APPENDS -// OPENCODE_CONFIG_DIR to its config-dir list, it does not replace it — the -// XDG default is always read too. Mirroring the default into the overlay would -// load the user's config (and plugins) twice. Only an explicitly-set dir is -// mirrored, because that one leaves the list when we override the var. +// OpenCode replaces its default config root when OPENCODE_CONFIG_DIR is set, +// so mirror the default root into the guest overlay as well as explicit paths. export function createInstallPluginsHandler( pluginOverlay: PluginOverlayManager, env: NodeJS.ProcessEnv @@ -73,12 +71,15 @@ export function createInstallPluginsHandler( const incoming = typeof opencode === 'string' ? opencode : null // Explicit-only (see header). Constant in practice for a relay's lifetime, so // keying the cache on it is defensive; the rc scan behind it is memoized. - const sourceDir = resolveOpenCodeSourceConfigDir(env as Record, env.SHELL) + const sourceDir = + resolveOpenCodeSourceConfigDir(env as Record, env.SHELL) ?? + resolveOpenCodeConfigDirectory(env as Record, env.HOME) + const existingSourceDir = sourceDir && existsSync(sourceDir) ? sourceDir : undefined const cached = materialized if ( cached && (incoming === null || incoming === cached.source) && - sourceDir === cached.sourceDir && + existingSourceDir === cached.sourceDir && // Why: the dir surviving a failed rebuild proves nothing — the plugin does. existsSync(getRelayOpenCodePluginPath(cached.dir)) ) { @@ -87,32 +88,35 @@ export function createInstallPluginsHandler( const overlayId = sanitizeWslHookInstanceKey(env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? 'wsl-opencode' // Why: null on write failure — caller falls back to the guest's own config (no status), never crossing a Windows overlay into WSL. - opencodeDir = pluginOverlay.materializeOpenCode(overlayId, sourceDir) ?? undefined + opencodeDir = pluginOverlay.materializeOpenCode(overlayId, existingSourceDir) ?? undefined materialized = opencodeDir && incoming !== null - ? { source: incoming, sourceDir, dir: opencodeDir } + ? { source: incoming, sourceDir: existingSourceDir, dir: opencodeDir } : null } } let opencode2Dir: string | undefined if (pluginOverlay.hasOpenCode2Source()) { const incoming = typeof opencode2 === 'string' ? opencode2 : null - const sourceDir = resolveOpenCodeSourceConfigDir(env as Record, env.SHELL) + const sourceDir = + resolveOpenCodeSourceConfigDir(env as Record, env.SHELL) ?? + resolveOpenCodeConfigDirectory(env as Record, env.HOME) + const existingSourceDir = sourceDir && existsSync(sourceDir) ? sourceDir : undefined const cached = materialized2 if ( cached && (incoming === null || incoming === cached.source) && - sourceDir === cached.sourceDir && + existingSourceDir === cached.sourceDir && existsSync(getRelayOpenCodePluginPath(cached.dir, 'opencode2')) ) { opencode2Dir = cached.dir } else { const overlayId = sanitizeWslHookInstanceKey(env[WSL_HOOK_RELAY_INSTANCE_ENV]) ?? 'wsl-opencode2' - opencode2Dir = pluginOverlay.materializeOpenCode2(overlayId, sourceDir) ?? undefined + opencode2Dir = pluginOverlay.materializeOpenCode2(overlayId, existingSourceDir) ?? undefined materialized2 = opencode2Dir && incoming !== null - ? { source: incoming, sourceDir, dir: opencode2Dir } + ? { source: incoming, sourceDir: existingSourceDir, dir: opencode2Dir } : null } } diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 86527b25f29..c488bc1d25d 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -9,6 +9,7 @@ import { EditorDiffFileSurface } from './EditorDiffFileSurface' import { EditorEditFileSurface } from './EditorEditFileSurface' import { EditorFileLoadErrorView } from './EditorFileLoadErrorView' import type { FileContent } from './editor-panel-content-types' +import { buildPdfScalePreferenceKey } from './pdf-scale-preference-storage' import { translate } from '@/i18n/i18n' import { useEditorConflictNavigation } from './useEditorConflictNavigation' import { useMarkdownDocuments } from './useMarkdownDocuments' @@ -106,6 +107,9 @@ export function EditorContent({ viewStateScopeId === activeFile.id ? `${activeFile.filePath}:pdf` : `${activeFile.filePath}::${viewStateScopeId}:pdf` + // Why: the same absolute path can exist in different worktrees, paired + // runtimes, or SSH targets; durable PDF zoom must not cross those owners. + const pdfPreferenceKey = buildPdfScalePreferenceKey(activeFile) const monacoLanguage = resolvedLanguage === 'notebook' ? 'json' : resolvedLanguage const reloadOpenCheckRunDetailsTab = useAppStore((state) => state.reloadOpenCheckRunDetailsTab) const markdownDocuments = useMarkdownDocuments(activeFile, isMarkdown, mdViewMode, handleSave) @@ -232,6 +236,7 @@ export function EditorContent({ editorViewStateKey={editorViewStateKey} diffViewStateKey={diffViewStateKey} pdfViewStateKey={pdfViewStateKey} + pdfPreferenceKey={pdfPreferenceKey} fileContent={fileContents[activeFile.id]} diffContent={diffContents[activeFile.id]} editBuffer={editBuffers[activeFile.id]} diff --git a/src/renderer/src/components/editor/EditorEditFileSurface.tsx b/src/renderer/src/components/editor/EditorEditFileSurface.tsx index a9e3f8ff856..552689c3314 100644 --- a/src/renderer/src/components/editor/EditorEditFileSurface.tsx +++ b/src/renderer/src/components/editor/EditorEditFileSurface.tsx @@ -30,6 +30,7 @@ export function EditorEditFileSurface({ editorViewStateKey, diffViewStateKey, pdfViewStateKey, + pdfPreferenceKey, fileContent, diffContent, editBuffer, @@ -61,6 +62,7 @@ export function EditorEditFileSurface({ editorViewStateKey: string diffViewStateKey: string pdfViewStateKey: string + pdfPreferenceKey: string fileContent: FileContent | undefined diffContent: GitDiffResult | undefined editBuffer: string | undefined @@ -113,6 +115,7 @@ export function EditorEditFileSurface({ content={fileContent.content} filePath={activeFile.filePath} mimeType={fileContent.mimeType} + preferenceKey={pdfPreferenceKey} scrollCacheKey={pdfViewStateKey} /> ) diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx index b3e86bdfc81..73aaa09adc8 100644 --- a/src/renderer/src/components/editor/ImageViewer.tsx +++ b/src/renderer/src/components/editor/ImageViewer.tsx @@ -29,6 +29,9 @@ type ImageViewerProps = { filePath: string mimeType?: string layout?: 'fill' | 'intrinsic' + // Why: callers without an owner identity (for example diff and conflict + // panes) must not persist a preference under a path-only key. + preferenceKey?: string | null // Why: absent means "no PDF scroll memory" — diff and conflict-review callers // mount several viewers on one path, so they deliberately pass nothing. scrollCacheKey?: string | null @@ -39,6 +42,7 @@ export default function ImageViewer({ filePath, mimeType = FALLBACK_IMAGE_MIME_TYPE, layout = 'fill', + preferenceKey, scrollCacheKey = null }: ImageViewerProps): JSX.Element { const [isPopupOpen, setIsPopupOpen] = useState(false) @@ -215,7 +219,12 @@ export default function ImageViewer({ if (isPdf) { return ( - + ) } diff --git a/src/renderer/src/components/editor/PdfViewer.tsx b/src/renderer/src/components/editor/PdfViewer.tsx index 8fdd45d2c72..71fb9656391 100644 --- a/src/renderer/src/components/editor/PdfViewer.tsx +++ b/src/renderer/src/components/editor/PdfViewer.tsx @@ -23,6 +23,7 @@ import { stepPdfScalePreference, type PdfScalePreference } from './pdf-scale-preference' +import { readPdfScalePreference, writePdfScalePreference } from './pdf-scale-preference-storage' import { pdfViewPositionCache, setWithLRU } from '@/lib/scroll-cache' import { buildPdfScrollDestination, @@ -44,6 +45,9 @@ const USER_SCROLL_INPUT_EVENTS = ['wheel', 'touchstart', 'keydown', 'pointerdown type PdfViewerProps = { content: string filePath: string + // Why: callers that do not have an owner identity (for example diff and + // conflict panes) must not persist a preference under a path-only key. + preferenceKey?: string | null // Why: absent means "no scroll memory" — the diff and conflict-review callers // mount several viewers on one path, so a shared key would cross-write. scrollCacheKey?: string | null @@ -52,6 +56,7 @@ type PdfViewerProps = { export default function PdfViewer({ content, filePath, + preferenceKey = null, scrollCacheKey = null }: PdfViewerProps): JSX.Element { const containerRef = useRef(null) @@ -65,22 +70,23 @@ export default function PdfViewer({ const findControllerRef = useRef | null>(null) const pdfViewerRef = useRef | null>(null) // Why: content reloads rebuild the pdf.js viewer; keep zoom across updates of - // the same file, and only reset when the open path changes. + // the same file and restore the durable preference after a remount or restart. const scalePreferenceRef = useRef('page-width') const filename = useMemo(() => filePath.split(/[/\\]/).pop() || filePath, [filePath]) const cleanedContent = useMemo(() => content.replace(/\s/g, ''), [content]) - // Why: reset zoom to fit-width when the open path changes. An effect keeps the - // reset out of render (refs mutated in render can leak from discarded renders) - // and covers same-content/different-path opens the load effect skips. + // Why: restore the owner's preference outside render (refs mutated in render + // can leak from discarded renders) and cover same-content/different-path opens. useEffect(() => { - scalePreferenceRef.current = 'page-width' + scalePreferenceRef.current = preferenceKey + ? (readPdfScalePreference(preferenceKey) ?? 'page-width') + : 'page-width' const viewer = pdfViewerRef.current if (viewer) { - applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS) + applyPdfScalePreference(viewer, scalePreferenceRef.current, SCALE_BOUNDS) } - }, [filePath]) + }, [filePath, preferenceKey]) useEffect(() => { const container = containerRef.current @@ -306,15 +312,21 @@ export default function PdfViewer({ // Why: every zoom entry point (toolbar + keyboard) must record the scale // preference so the next content reload restores it (see scalePreferenceRef). - const stepZoom = useCallback((direction: 'in' | 'out') => { - const viewer = pdfViewerRef.current - if (!viewer) { - return - } - const next = stepPdfScalePreference(viewer.currentScale, direction, SCALE_BOUNDS) - viewer.currentScale = next.scale - scalePreferenceRef.current = next.preference - }, []) + const stepZoom = useCallback( + (direction: 'in' | 'out') => { + const viewer = pdfViewerRef.current + if (!viewer) { + return + } + const next = stepPdfScalePreference(viewer.currentScale, direction, SCALE_BOUNDS) + viewer.currentScale = next.scale + scalePreferenceRef.current = next.preference + if (preferenceKey) { + writePdfScalePreference(preferenceKey, next.preference) + } + }, + [preferenceKey] + ) const zoomIn = useCallback(() => stepZoom('in'), [stepZoom]) const zoomOut = useCallback(() => stepZoom('out'), [stepZoom]) @@ -326,7 +338,10 @@ export default function PdfViewer({ } scalePreferenceRef.current = 'page-width' applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS) - }, []) + if (preferenceKey) { + writePdfScalePreference(preferenceKey, 'page-width') + } + }, [preferenceKey]) useEffect(() => { const handleKeyDown = (e: KeyboardEvent): void => { diff --git a/src/renderer/src/components/editor/pdf-scale-preference-storage.test.ts b/src/renderer/src/components/editor/pdf-scale-preference-storage.test.ts new file mode 100644 index 00000000000..0c0ce1b0f51 --- /dev/null +++ b/src/renderer/src/components/editor/pdf-scale-preference-storage.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + buildPdfScalePreferenceKey, + PDF_SCALE_PREFERENCES_STORAGE_KEY, + readPdfScalePreference, + writePdfScalePreference +} from './pdf-scale-preference-storage' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('PDF scale preference storage', () => { + it('keeps identical paths isolated by worktree and remote owner', () => { + const localKey = buildPdfScalePreferenceKey({ worktreeId: 'worktree-a', filePath: '/doc.pdf' }) + const runtimeKey = buildPdfScalePreferenceKey({ + worktreeId: 'worktree-a', + runtimeEnvironmentId: 'runtime-b', + filePath: '/doc.pdf' + }) + const sshKey = buildPdfScalePreferenceKey({ + worktreeId: 'worktree-a', + externalSshTargetId: 'ssh-c', + filePath: '/doc.pdf' + }) + + expect(new Set([localKey, runtimeKey, sshKey]).size).toBe(3) + }) + + it('round-trips a preference by file path', () => { + const storage = createMemoryStorage() + vi.stubGlobal('localStorage', storage) + + writePdfScalePreference('/repo/report.pdf', 1.75) + + expect(readPdfScalePreference('/repo/report.pdf')).toBe(1.75) + expect(readPdfScalePreference('/repo/other.pdf')).toBeNull() + }) + + it('persists fit-to-width resets and keeps files isolated', () => { + const storage = createMemoryStorage() + vi.stubGlobal('localStorage', storage) + + writePdfScalePreference('/repo/report.pdf', 2) + writePdfScalePreference('/repo/other.pdf', 'page-width') + + expect(readPdfScalePreference('/repo/report.pdf')).toBe(2) + expect(readPdfScalePreference('/repo/other.pdf')).toBe('page-width') + }) + + it('ignores malformed stored values', () => { + const storage = createMemoryStorage() + vi.stubGlobal('localStorage', storage) + storage.setItem( + PDF_SCALE_PREFERENCES_STORAGE_KEY, + JSON.stringify({ '/repo/report.pdf': { scale: 2 } }) + ) + + expect(readPdfScalePreference('/repo/report.pdf')).toBeNull() + }) + + it('evicts the oldest entries after reaching the storage bound', () => { + const storage = createMemoryStorage() + vi.stubGlobal('localStorage', storage) + + for (let index = 0; index < 101; index += 1) { + writePdfScalePreference(`/repo/report-${index}.pdf`, index) + } + + expect(readPdfScalePreference('/repo/report-0.pdf')).toBeNull() + expect(readPdfScalePreference('/repo/report-100.pdf')).toBe(100) + }) + + it('ignores storage write failures', () => { + const storage = createMemoryStorage() + storage.setItem = () => { + throw new Error('storage unavailable') + } + vi.stubGlobal('localStorage', storage) + + expect(() => writePdfScalePreference('/repo/report.pdf', 1.5)).not.toThrow() + }) +}) + +function createMemoryStorage(): Storage { + const values = new Map() + return { + get length() { + return values.size + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => { + values.set(key, value) + } + } +} diff --git a/src/renderer/src/components/editor/pdf-scale-preference-storage.ts b/src/renderer/src/components/editor/pdf-scale-preference-storage.ts new file mode 100644 index 00000000000..6906570f036 --- /dev/null +++ b/src/renderer/src/components/editor/pdf-scale-preference-storage.ts @@ -0,0 +1,87 @@ +import type { PdfScalePreference } from './pdf-scale-preference' + +export const PDF_SCALE_PREFERENCES_STORAGE_KEY = 'orca.pdf.scale-preferences.v1' + +const MAX_STORED_PREFERENCES = 100 + +export function buildPdfScalePreferenceKey(input: { + worktreeId: string + runtimeEnvironmentId?: string | null + externalSshTargetId?: string | null + filePath: string +}): string { + return JSON.stringify([ + input.worktreeId, + input.runtimeEnvironmentId?.trim() || 'local', + input.externalSshTargetId?.trim() || null, + input.filePath + ]) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isPdfScalePreference(value: unknown): value is PdfScalePreference { + return value === 'page-width' || (typeof value === 'number' && Number.isFinite(value)) +} + +function readStoredPreferences(storage: Storage): Record { + try { + const raw = storage.getItem(PDF_SCALE_PREFERENCES_STORAGE_KEY) + if (!raw) { + return {} + } + const parsed: unknown = JSON.parse(raw) + return isRecord(parsed) ? parsed : {} + } catch { + return {} + } +} + +function getStorage(): Storage | null { + try { + return globalThis.localStorage === undefined ? null : globalThis.localStorage + } catch { + return null + } +} + +/** Read the last zoom choice for a PDF, if one was persisted. */ +export function readPdfScalePreference(preferenceKey: string): PdfScalePreference | null { + const storage = getStorage() + if (!storage) { + return null + } + const preference = readStoredPreferences(storage)[preferenceKey] + return isPdfScalePreference(preference) ? preference : null +} + +/** Persist a PDF zoom choice across viewer remounts and app restarts. */ +export function writePdfScalePreference( + preferenceKey: string, + preference: PdfScalePreference +): void { + const storage = getStorage() + if (!storage) { + return + } + + const stored = readStoredPreferences(storage) + // Reinsert to keep recently used files at the end of the bounded map. + delete stored[preferenceKey] + stored[preferenceKey] = preference + const keys = Object.keys(stored) + while (keys.length > MAX_STORED_PREFERENCES) { + const oldestKey = keys.shift() + if (oldestKey !== undefined) { + delete stored[oldestKey] + } + } + + try { + storage.setItem(PDF_SCALE_PREFERENCES_STORAGE_KEY, JSON.stringify(stored)) + } catch { + // The viewer remains usable when browser storage is unavailable or full. + } +} 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..a04690c52c0 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" >