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 type { BinderSessionRow, OpenCodeSessionCursor } from '../foreign-sqlite-readers/opencode-binder-sessions-result' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { listRegisteredPtys } from '../memory/pty-registry' 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), for OpenCode 1 * `serve` + `attach` only (see isOpenCodeSharedServerPost). * * Each round: read new sessions from the shared server's SQLite store, * snapshot panes, sweep for live clients, correlate, bind. Everything the * round needs is injected so the decision core stays unit-testable; the * store read runs on the foreign SQLite reader worker * (foreign-sqlite-readers/readers/opencode-binder-sessions.ts). */ /** 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 } /** 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[] } /** 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 } } /** 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 }