From 930548d9690251c1edc1164fe00ecc9c59ae46d1 Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Fri, 25 Sep 2026 08:59:52 -0300 Subject: [PATCH 1/9] fix(opencode): bind same-directory sessions to the pane that submitted the prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared OpenCode server freezes ORCA_PANE_KEY to whichever pane started it, so #21577 rebinds each post to the session's real pane. The binder only produced a binding when exactly one pane could have created the session, and two panes sharing a directory — split panes in one worktree, or sibling folder-workspace panes — always both qualify, so those sessions stayed unbound forever. Every post for them kept the frozen stamp: the starter pane spun for another pane's task while the pane that owned the task read grey. Record the last interactive write against each pane's PTY and, when several panes qualify, bind to the one whose PTY received input immediately before the session row appeared. The window is 60s, the input must predate the row, and the leader must be unambiguous; anything weaker still stays unbound, because a wrong owner reproduces the reported bug rather than softening it. --- .../server-opencode-binder.test.ts | 16 ++- src/main/ipc/pty-ipc-mock-registry.ts | 4 +- src/main/ipc/pty/ipc/write-input.test.ts | 73 +++++++++++++ src/main/ipc/pty/ipc/write-input.ts | 3 + src/main/memory/pty-registry.test.ts | 38 +++++++ src/main/memory/pty-registry.ts | 19 ++++ .../opencode/opencode-session-binder.test.ts | 65 ++++++++++- src/main/opencode/opencode-session-binder.ts | 32 +++++- ...tener-opencode-session-correlation.test.ts | 101 ++++++++++++++++++ .../opencode-session-correlation.ts | 74 ++++++++++++- 10 files changed, 413 insertions(+), 12 deletions(-) create mode 100644 src/main/ipc/pty/ipc/write-input.test.ts create mode 100644 src/main/memory/pty-registry.test.ts diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts index 59d1c9142feb..a594a3671592 100644 --- a/src/main/agent-hooks/server-opencode-binder.test.ts +++ b/src/main/agent-hooks/server-opencode-binder.test.ts @@ -67,10 +67,22 @@ describe('opencode binder loop', () => { now: () => Date.now(), dbPath: () => dbPath, listPanes: () => [ - { paneKey: PANE_A, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111 } + { + paneKey: PANE_A, + directory: DIR, + worktreeId: `repo::${DIR}`, + shellPid: 111, + lastInputAtMs: null + } ], sweep: async () => [ - { pid: 112, ppid: 111, startedAtMs: Date.now() - 120_000, executable: 'opencode', argv: ['opencode'] } + { + pid: 112, + ppid: 111, + startedAtMs: Date.now() - 120_000, + executable: 'opencode', + argv: ['opencode'] + } ] }) }) diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index 71b77621b755..4f033254a9e6 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -37,6 +37,7 @@ export const trackMock: Mock = vi.fn() export const classifyErrorMock: Mock = vi.fn() export const registerPtyMock: Mock = vi.fn() export const unregisterPtyMock: Mock = vi.fn() +export const notePtyInputMock: Mock = vi.fn() export const setMigrationUnsupportedPtyMock: Mock = vi.fn() export const clearMigrationUnsupportedPtyMock: Mock = vi.fn() export const clearMigrationUnsupportedPtysForPaneKeyMock: Mock = vi.fn() @@ -165,7 +166,8 @@ export const linuxCliShimModuleMock = () => ({ export const ptyRegistryModuleMock = () => ({ registerPty: registerPtyMock, - unregisterPty: unregisterPtyMock + unregisterPty: unregisterPtyMock, + notePtyInput: notePtyInputMock }) export const migrationUnsupportedPtyModuleMock = () => ({ diff --git a/src/main/ipc/pty/ipc/write-input.test.ts b/src/main/ipc/pty/ipc/write-input.test.ts new file mode 100644 index 000000000000..a55605ff0490 --- /dev/null +++ b/src/main/ipc/pty/ipc/write-input.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { listRegisteredPtys, registerPty, unregisterPty } from '../../../memory/pty-registry' +import { agentSessionPtyWriteGate } from '../../../runtime/agent-session-pty-write-gate' +import { ptyOwnership } from '../provider/ownership-state' +import { createPtyWriteInput } from './write-input' + +const PTY_ID = 'pty-write-input-stamp' + +const { provider } = vi.hoisted(() => ({ provider: { write: vi.fn() } })) + +vi.mock('../provider/registry', () => ({ + tryGetProviderForPty: (id: string) => (id === PTY_ID ? provider : undefined) +})) + +const mainWindow = { + isDestroyed: () => false, + webContents: { isDestroyed: () => false, send: vi.fn() } +} + +function writePtyInput(data: string) { + return createPtyWriteInput({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture is only read through isDestroyed(), webContents.isDestroyed() and webContents.send, all three of which it provides; Electron's BrowserWindow cannot be constructed outside a running app. + mainWindow: mainWindow as never, + clearHiddenRendererResizeOutput: vi.fn() + }).writePtyInput({ id: PTY_ID, data }) +} + +function stamp() { + return listRegisteredPtys().find((pty) => pty.ptyId === PTY_ID)?.lastInputAtMs +} + +beforeEach(() => { + ptyOwnership.set(PTY_ID, null) + provider.write.mockReset() + registerPty({ ptyId: PTY_ID, worktreeId: null, sessionId: null, paneKey: 'tab:leaf', pid: 1 }) +}) + +afterEach(() => { + ptyOwnership.delete(PTY_ID) + unregisterPty(PTY_ID) + vi.restoreAllMocks() +}) + +describe('writePtyInput registry stamp', () => { + it('records the keystroke so the session binder can break a same-directory tie', () => { + const before = Date.now() + + expect(writePtyInput('hello')).toBe(true) + + const stamped = stamp() + expect(stamped).toBeTypeOf('number') + expect(stamped).toBeGreaterThanOrEqual(before) + }) + + it('leaves the pty unstamped when the write is refused', () => { + vi.spyOn(agentSessionPtyWriteGate, 'admit').mockReturnValue({ + admitted: false, + refusal: { + code: 'agent_session_checkpoint_stale', + sessionId: 'session-1', + ownerRuntimeKind: null, + handoffStage: null, + ownerPid: null, + runtimeFence: null + } + }) + + expect(writePtyInput('hello')).toBe(false) + expect(stamp()).toBeUndefined() + expect(provider.write).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index 6713e49000bb..d40f39b1cd28 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -11,6 +11,7 @@ import { iterateTerminalInputChunks } from '../../../../shared/terminal-input' import { reportAgentSessionWriteRefusal } from '../agent-session-write-refusal-report' +import { notePtyInput } from '../../../memory/pty-registry' import { ptyOwnership } from '../provider/ownership-state' import { tryGetProviderForPty } from '../provider/registry' import { @@ -206,6 +207,7 @@ export function createPtyWriteInput(deps: { try { const now = performance.now() lastInputAtByPty.set(args.id, now) + notePtyInput(args.id, Date.now()) interactiveOutputCharsByPty.set(args.id, 0) if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) @@ -235,6 +237,7 @@ export function createPtyWriteInput(deps: { try { const now = performance.now() lastInputAtByPty.set(args.id, now) + notePtyInput(args.id, Date.now()) interactiveOutputCharsByPty.set(args.id, 0) if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) diff --git a/src/main/memory/pty-registry.test.ts b/src/main/memory/pty-registry.test.ts new file mode 100644 index 000000000000..c8e4503aa488 --- /dev/null +++ b/src/main/memory/pty-registry.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { listRegisteredPtys, notePtyInput, registerPty, unregisterPty } from './pty-registry' + +function registration(ptyId: string) { + return { ptyId, worktreeId: null, sessionId: null, paneKey: 'tab:leaf', pid: 1 } +} + +describe('notePtyInput', () => { + it('stamps the registration it was asked about', () => { + const ptyId = 'pty-note-input-stamp' + registerPty(registration(ptyId)) + try { + notePtyInput(ptyId, 1_700_000_000_000) + expect(find(ptyId)?.lastInputAtMs).toBe(1_700_000_000_000) + } finally { + unregisterPty(ptyId) + } + }) + + it('ignores a pty the registry never learned', () => { + notePtyInput('pty-never-registered', 1_700_000_000_000) + expect(find('pty-never-registered')).toBeUndefined() + expect(listRegisteredPtys().some((pty) => pty.ptyId === 'pty-never-registered')).toBe(false) + }) + + it('drops the stamp with the registration', () => { + const ptyId = 'pty-note-input-teardown' + registerPty(registration(ptyId)) + notePtyInput(ptyId, 1_700_000_000_000) + unregisterPty(ptyId) + expect(find(ptyId)).toBeUndefined() + }) +}) + +function find(ptyId: string) { + return listRegisteredPtys().find((pty) => pty.ptyId === ptyId) +} diff --git a/src/main/memory/pty-registry.ts b/src/main/memory/pty-registry.ts index bb92081258f6..c507995ad9bd 100644 --- a/src/main/memory/pty-registry.ts +++ b/src/main/memory/pty-registry.ts @@ -26,6 +26,13 @@ export type PtyRegistration = { // storing null lets the collector render a zero-attribution row for that // PTY instead of throwing and dropping the whole snapshot. pid: number | null + /** + * ms epoch of the last interactive write to this PTY. Absent until a + * keystroke arrives, and dropped when the PTY is re-registered under a new + * incarnation. The session binder reads it to decide which of two + * same-directory panes submitted the prompt that created a session (#22838). + */ + lastInputAtMs?: number } const registry = new Map() @@ -38,6 +45,18 @@ export function unregisterPty(ptyId: string): void { registry.delete(ptyId) } +/** + * Record an interactive write against a PTY. Ids the registry never learned + * (remote PTYs, already-torn-down ones) are ignored so the row cannot be + * created by input alone. + */ +export function notePtyInput(ptyId: string, nowMs: number): void { + const entry = registry.get(ptyId) + if (entry) { + entry.lastInputAtMs = nowMs + } +} + /** Snapshot of currently-registered local PTYs for the collector to walk. */ export function listRegisteredPtys(): PtyRegistration[] { return [...registry.values()] diff --git a/src/main/opencode/opencode-session-binder.test.ts b/src/main/opencode/opencode-session-binder.test.ts index 547ca9d01ba3..0e80bc709ef2 100644 --- a/src/main/opencode/opencode-session-binder.test.ts +++ b/src/main/opencode/opencode-session-binder.test.ts @@ -25,8 +25,18 @@ function proc( 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 } +function pane( + paneKey: string, + shellPid: number | null, + lastInputAtMs: number | null = null +): BinderPaneSnapshot { + return { + paneKey, + directory: DIR, + worktreeId: 'repo::/Users/jin/work/mocitec', + shellPid, + lastInputAtMs + } } describe('runOpenCodeBinderRound', () => { @@ -97,6 +107,49 @@ describe('runOpenCodeBinderRound', () => { ]) }) + it('breaks a same-directory tie from the pane snapshots input', () => { + const { ownerships } = runOpenCodeBinderRound({ + nowMs: NOW, + sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }], + panes: [pane(PANE_A, 100, NOW - 3_600_000), pane(PANE_B, 200, NOW - 65_000)], + processes: [ + proc(100, 1, ['zsh']), + proc(200, 1, ['zsh']), + proc(101, 100, ['opencode'], NOW - 86_400_000), + proc(201, 200, ['opencode'], NOW - 86_400_000) + ], + knownOwners: new Map(), + parentBySessionId: new Map() + }) + expect(ownerships).toEqual([ + { sessionId: 'ses_1', paneKey: PANE_B, basis: 'creation-correlation' } + ]) + }) + + it('keeps a reminted pane input history when its live row has none', () => { + const { ownerships } = runOpenCodeBinderRound({ + nowMs: NOW, + sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }], + panes: [ + pane(PANE_A, 100, NOW - 65_000), + pane(PANE_A, 101, null), + pane(PANE_B, 200, NOW - 3_600_000) + ], + processes: [ + proc(100, 1, ['zsh']), + proc(101, 1, ['zsh']), + proc(200, 1, ['zsh']), + proc(102, 100, ['opencode'], NOW - 86_400_000), + proc(201, 200, ['opencode'], NOW - 86_400_000) + ], + knownOwners: new Map(), + parentBySessionId: new Map() + }) + expect(ownerships).toEqual([ + { sessionId: 'ses_1', paneKey: PANE_A, basis: 'creation-correlation' } + ]) + }) + it('advances the cursor past handled rows only', () => { const fresh = [ { id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }, @@ -161,7 +214,13 @@ describe('applyBinderOwnerships', () => { const applied = applyBinderOwnerships( state, [ - { paneKey: PANE_A, directory: '/elsewhere', worktreeId: 'repo::/elsewhere', shellPid: 100 }, + { + paneKey: PANE_A, + directory: '/elsewhere', + worktreeId: 'repo::/elsewhere', + shellPid: 100, + lastInputAtMs: null + }, { ...pane(PANE_A, 101) } ], [{ sessionId: 'ses_1', paneKey: PANE_A, basis: 'argv' }], diff --git a/src/main/opencode/opencode-session-binder.ts b/src/main/opencode/opencode-session-binder.ts index 9d40ccb278c9..f305dfa3239f 100644 --- a/src/main/opencode/opencode-session-binder.ts +++ b/src/main/opencode/opencode-session-binder.ts @@ -35,6 +35,11 @@ export type BinderPaneSnapshot = { directory: string | null worktreeId: string | null shellPid: number | null + /** + * ms epoch of the pane's last interactive PTY write, null when the pane has + * not been typed into since it registered. Breaks same-directory ties. + */ + lastInputAtMs: number | null } /** One session store row feeding a binder round. */ @@ -157,6 +162,20 @@ function toCorrelatedClients( return clients } +/** Most recent of two observations; null when neither side has one. */ +function mostRecentInput( + left: number | null | undefined, + right: number | null | undefined +): number | null { + if (typeof left !== 'number') { + return typeof right === 'number' ? right : null + } + if (typeof right !== 'number') { + return left + } + return Math.max(left, right) +} + /** 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 @@ -166,7 +185,15 @@ export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult // worktree. const paneByKey = new Map() for (const pane of deps.panes) { - paneByKey.set(pane.paneKey, { paneKey: pane.paneKey, directory: pane.directory }) + const previous = paneByKey.get(pane.paneKey) + paneByKey.set(pane.paneKey, { + paneKey: pane.paneKey, + directory: pane.directory, + // Why most-recent instead of the current row's own value: a reminted + // PTY re-registers with no input history, so the replaced row's + // observation is the only record of what the human typed there. + lastInputAtMs: mostRecentInput(previous?.lastInputAtMs, pane.lastInputAtMs) + }) } const panes = [...paneByKey.values()] const clients = toCorrelatedClients(deps.processes, deps.panes, deps.nowMs) @@ -303,7 +330,8 @@ export function listBinderPaneSnapshots(): BinderPaneSnapshot[] { paneKey: pty.paneKey, directory: parsed?.worktreePath ?? null, worktreeId: pty.worktreeId, - shellPid: pty.pid + shellPid: pty.pid, + lastInputAtMs: pty.lastInputAtMs ?? null }) } return snapshots diff --git a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts index c0bc1d0ef37a..8d85eed240a6 100644 --- a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts +++ b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts @@ -168,6 +168,107 @@ describe('correlateOpenCodeSessionOwners', () => { expect(results).toEqual([]) }) + it('breaks a same-directory tie with the pane typed into just before creation', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR, lastInputAtMs: NOW - 3_600_000 }, + { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 65_000 } + ], + clients: [client('pane-a', NOW - 86_400_000), client('pane-b', NOW - 86_400_000)], + knownOwners: new Map() + }) + expect(results).toEqual([ + { sessionId: 'ses_1', paneKey: 'pane-b', basis: 'creation-correlation' } + ]) + }) + + it('prefers whichever of two in-window panes was typed into last', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR, lastInputAtMs: NOW - 90_000 }, + { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 65_000 } + ], + clients: [client('pane-a', NOW - 86_400_000), client('pane-b', NOW - 86_400_000)], + knownOwners: new Map() + }) + expect(results).toEqual([ + { sessionId: 'ses_1', paneKey: 'pane-b', basis: 'creation-correlation' } + ]) + }) + + it('binds the only typed pane even when the other has no input record', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR }, + { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 65_000 } + ], + clients: [client('pane-a', NOW - 86_400_000), client('pane-b', NOW - 86_400_000)], + knownOwners: new Map() + }) + expect(results).toEqual([ + { sessionId: 'ses_1', paneKey: 'pane-b', basis: 'creation-correlation' } + ]) + }) + + it('stays unbound when both panes were typed into before creation', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR, lastInputAtMs: NOW - 65_000 }, + { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 65_000 } + ], + clients: [client('pane-a', NOW - 86_400_000), client('pane-b', NOW - 86_400_000)], + knownOwners: new Map() + }) + expect(results).toEqual([]) + }) + + it('ignores input older than the tie-break window', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR, lastInputAtMs: NOW - 600_000 }, + { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 660_000 } + ], + clients: [client('pane-a', NOW - 86_400_000), client('pane-b', NOW - 86_400_000)], + knownOwners: new Map() + }) + expect(results).toEqual([]) + }) + + it('ignores input that arrived after the session was created', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR, lastInputAtMs: NOW - 65_000 }, + { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 10_000 } + ], + clients: [client('pane-a', NOW - 86_400_000), client('pane-b', NOW - 86_400_000)], + knownOwners: new Map() + }) + expect(results).toEqual([ + { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'creation-correlation' } + ]) + }) + + it('ignores input when only one pane has client evidence', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR, lastInputAtMs: NOW - 65_000 }, + { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 61_000 } + ], + clients: [client('pane-a', NOW - 86_400_000)], + knownOwners: new Map() + }) + expect(results).toEqual([ + { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'creation-correlation' } + ]) + }) + it('rejects a client that started after creation beyond skew', () => { const results = correlateOpenCodeSessionOwners({ sessions: [session('ses_1', NOW - 3_600_000)], diff --git a/src/shared/agent-hook-listener/opencode-session-correlation.ts b/src/shared/agent-hook-listener/opencode-session-correlation.ts index 069769ab07bc..d90b268445b0 100644 --- a/src/shared/agent-hook-listener/opencode-session-correlation.ts +++ b/src/shared/agent-hook-listener/opencode-session-correlation.ts @@ -24,10 +24,17 @@ export type CorrelatedPane = { /** * Worktree root backing the pane (from the PTY registry), or null when * unknown. A session belongs to a pane's candidate set when its directory - * is the root or beneath it; same-worktree panes tie and the client sweep - * breaks the tie. + * is the root or beneath it; same-worktree panes tie and the evidence below + * decides between them. */ directory: string | null + /** + * ms epoch of the pane's last interactive PTY write, or null/absent when the + * pane has never been typed into since Orca registered it. Supplied by the + * binder so same-directory ties can be broken by who actually submitted the + * prompt, not by who merely had a client alive. + */ + lastInputAtMs?: number | null } /** One live client process as the binder sees it. */ @@ -52,6 +59,17 @@ export type SessionOwnership = { /** Allow for stamp skew between the process table and the session store. */ export const OPENCODE_CREATE_SKEW_MS = 2 * 60 * 1000 +/** + * How long before a session's creation a pane's last keystroke still counts + * as evidence that pane submitted the prompt which created it. OpenCode + * writes the session row when the prompt is submitted, and the Enter that + * submits it is itself PTY input, so a minute is generous — and keeping it + * narrow matters, because a pane the human happened to touch just before an + * Orca-launched agent minted its session (a launch whose prompt arrives as a + * flag, leaving that pane no input of its own) must not be credited with it. + */ +export const OPENCODE_INPUT_TIEBREAK_WINDOW_MS = 60 * 1000 + import { normalizeRuntimePathForComparison } from '../cross-platform-path' /** @@ -140,12 +158,56 @@ function clientCouldCreate(client: CorrelatedClient, createdAtMs: number): boole ) } +/** + * Break a same-directory tie by who was actually typing. Submitting the prompt + * that creates a session writes to that pane's PTY immediately beforehand, so + * the candidate with the most recent pre-creation input is the creator. A + * candidate with no recorded input, or one whose last keystroke fell outside + * the window, contributes nothing; candidates recorded at the identical + * instant tie and are both rejected rather than guessed between. + */ +function tieBreakByRecentInput( + evidencing: readonly CorrelatedPane[], + createdAtMs: number +): string | null { + let best: CorrelatedPane | null = null + let bestAt = Number.NEGATIVE_INFINITY + let tied = false + for (const pane of evidencing) { + const at = pane.lastInputAtMs + if (typeof at !== 'number' || Number.isNaN(at)) { + continue + } + // The record is written before the keystroke reaches the PTY, so input + // postdating the row cannot have submitted it; rejecting it outright is + // what keeps a pane that was merely typed into afterwards from winning. + if (at > createdAtMs) { + continue + } + if (createdAtMs - at > OPENCODE_INPUT_TIEBREAK_WINDOW_MS) { + continue + } + if (at > bestAt) { + best = pane + bestAt = at + tied = false + } else if (at === bestAt) { + tied = true + } + } + return best && !tied ? best.paneKey : null +} + /** * Decide owners for sessions the registry has not bound yet. Child sessions * inherit their root's owner (they roll up to the same pane); argv hits bind * immediately; otherwise exactly one pane must have both the directory and a - * client that brackets the creation. Anything else stays unbound — today's - * behavior — rather than guessing. + * client that brackets the creation. When several panes qualify — the normal + * case for split panes or sibling folder workspaces, which share a directory — + * the pane whose PTY received input just before the session appeared wins, but + * only when that pane is an unambiguous in-window leader. Anything still + * undecided stays unbound rather than guessed: a wrong owner shows the wrong + * pane spinning, which is the bug this resolves, not a milder version of it. */ export function correlateOpenCodeSessionOwners(args: { sessions: readonly CorrelatedSession[] @@ -237,6 +299,10 @@ export function correlateOpenCodeSessionOwners(args: { ) const [only] = evidencing if (evidencing.length !== 1 || !only) { + const recent = tieBreakByRecentInput(evidencing, session.createdAtMs) + if (recent) { + claim(session.id, recent, 'creation-correlation') + } continue } // Why the basis split: a lone containing pane needs no client evidence From 6b56faaea71f4bdf3aa5bca064c026e93c5b3805 Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Fri, 25 Sep 2026 10:24:05 -0300 Subject: [PATCH 2/9] docs(opencode): record that the shared-server reattribution gate rests on opencode2 running standalone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveOpenCodeSharedServerEnvelope rewrites only opencode and mimo-code. opencode2 is correctly absent because Orca launches it with --standalone — a private per-pane server whose env already names the right pane. Nothing said that anywhere, so a reader could either "fix" the omission and rewrite stamps that were never wrong, or drop the flag and leave opencode2 frozen onto the server's starter pane with no rebinding. --- src/shared/agent-hook-listener/opencode-session-registry.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/shared/agent-hook-listener/opencode-session-registry.ts b/src/shared/agent-hook-listener/opencode-session-registry.ts index 0d6183197b57..e9b5be3b2831 100644 --- a/src/shared/agent-hook-listener/opencode-session-registry.ts +++ b/src/shared/agent-hook-listener/opencode-session-registry.ts @@ -161,6 +161,10 @@ export function resolveOpenCodeSharedServerEnvelope(args: { sessionId: string | undefined }): OpenCodeStampedEnvelope { const { state, source, stamped, sessionId } = args + // Why opencode2 is absent: Orca launches it with --standalone, so its server + // is private to the pane and the stamp already names the right pane — there + // is nothing to rewrite. Drop that flag and opencode2 must join this list, or + // its posts freeze onto whichever pane started the shared server. if ((source !== 'opencode' && source !== 'mimo-code') || !sessionId) { return stamped } From e9beb99c234f134e9033e215d5f27900c1040f40 Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Fri, 25 Sep 2026 10:33:54 -0300 Subject: [PATCH 3/9] fix(opencode): keep the session tie-break from crediting a bystander pane Review of #22847 found four ways the new evidence could bind a session to the wrong pane or claim more than it knew: - Orca launches OpenCode with `--prompt`, so the creating pane writes no keystroke at all and input alone handed the session to whichever sibling pane had recently been typed into. Launch recency is now consulted first: a client that booted within 20s of the row appearing explains the session, a bystander's keystroke does not. - lastInputAtMs holds only the newest write, so typing again after creation erased the pre-creation evidence and let a bystander become the unique in-window leader. A post-creation stamp now abstains rather than skips. - A pane that moved directory inherited input recorded under its old root, so a session in the new one could win a tie on keystrokes from elsewhere. - The stamp was taken before dispatch, so an oversized or never-dispatched payload aged a pane, and xterm's own CPR/DA1 replies counted as input. The stamp now sits at the dispatch point and skips terminal query replies. --- src/main/ipc/pty/ipc/write-input.test.ts | 9 ++ src/main/ipc/pty/ipc/write-input.ts | 20 +++- .../opencode/opencode-session-binder.test.ts | 25 +++++ src/main/opencode/opencode-session-binder.ts | 10 +- ...tener-opencode-session-correlation.test.ts | 49 +++++++++- .../opencode-session-correlation.ts | 96 ++++++++++++++++--- 6 files changed, 194 insertions(+), 15 deletions(-) diff --git a/src/main/ipc/pty/ipc/write-input.test.ts b/src/main/ipc/pty/ipc/write-input.test.ts index a55605ff0490..0aecc9bdbf0d 100644 --- a/src/main/ipc/pty/ipc/write-input.test.ts +++ b/src/main/ipc/pty/ipc/write-input.test.ts @@ -53,6 +53,15 @@ describe('writePtyInput registry stamp', () => { expect(stamped).toBeGreaterThanOrEqual(before) }) + it('does not stamp for an automatic terminal query reply', () => { + // xterm answers CPR/DA1 queries on its own; a reply must never age a pane + // into winning a same-directory session tie. + expect(writePtyInput('\x1b[3;1R')).toBe(true) + + expect(stamp()).toBeUndefined() + expect(provider.write).toHaveBeenCalled() + }) + it('leaves the pty unstamped when the write is refused', () => { vi.spyOn(agentSessionPtyWriteGate, 'admit').mockReturnValue({ admitted: false, diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index d40f39b1cd28..b3e6450f407d 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -12,6 +12,7 @@ import { } from '../../../../shared/terminal-input' import { reportAgentSessionWriteRefusal } from '../agent-session-write-refusal-report' import { notePtyInput } from '../../../memory/pty-registry' +import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' import { ptyOwnership } from '../provider/ownership-state' import { tryGetProviderForPty } from '../provider/registry' import { @@ -35,6 +36,20 @@ export function isMainWindowPtyIpcEvent( export type PtyWritePayload = { id: string; data: string } export type PtyViewportClaimPayload = { id: string; cols: number; rows: number } +/** + * Record input against a PTY for the OpenCode session binder. Only called once + * a provider write is actually about to be issued, so an oversized or + * undispatched payload cannot age a pane into winning a same-directory tie. + * Terminal query replies (DA1/DECRQM/CPR …) are written by xterm with nobody + * typing, so they must not count as input either. + */ +function stampPtyInput(id: string, data: string): void { + if (isTerminalQueryReply(data)) { + return + } + notePtyInput(id, Date.now()) +} + export function createPtyWriteInput(deps: { mainWindow: BrowserWindow runtime?: OrcaRuntimeService @@ -92,6 +107,9 @@ export function createPtyWriteInput(deps: { data: string, admitted: AgentSessionPtyWriteAdmittance ): boolean | Promise => { + // Reached only after the oversized-input check, so this is the first point + // where bytes are genuinely on their way to the PTY. + stampPtyInput(id, data) const chunks = iterateTerminalInputChunks(data) const first = chunks.next() if (first.done) { @@ -207,7 +225,6 @@ export function createPtyWriteInput(deps: { try { const now = performance.now() lastInputAtByPty.set(args.id, now) - notePtyInput(args.id, Date.now()) interactiveOutputCharsByPty.set(args.id, 0) if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) @@ -237,7 +254,6 @@ export function createPtyWriteInput(deps: { try { const now = performance.now() lastInputAtByPty.set(args.id, now) - notePtyInput(args.id, Date.now()) interactiveOutputCharsByPty.set(args.id, 0) if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) diff --git a/src/main/opencode/opencode-session-binder.test.ts b/src/main/opencode/opencode-session-binder.test.ts index 0e80bc709ef2..ed7ead9177b4 100644 --- a/src/main/opencode/opencode-session-binder.test.ts +++ b/src/main/opencode/opencode-session-binder.test.ts @@ -150,6 +150,31 @@ describe('runOpenCodeBinderRound', () => { ]) }) + it('does not carry input across a directory change', () => { + // The remint row now points at DIR, but the input recorded under it + // happened while the pane sat in /elsewhere. Carrying it forward would let + // a session in DIR win a tie on keystrokes that never happened there. + const { ownerships } = runOpenCodeBinderRound({ + nowMs: NOW, + sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }], + panes: [ + { ...pane(PANE_A, 100, NOW - 65_000), directory: '/elsewhere' }, + { ...pane(PANE_A, 101, null), directory: DIR }, + pane(PANE_B, 200, NOW - 3_600_000) + ], + processes: [ + proc(100, 1, ['zsh']), + proc(101, 1, ['zsh']), + proc(200, 1, ['zsh']), + proc(102, 101, ['opencode'], NOW - 86_400_000), + proc(201, 200, ['opencode'], NOW - 86_400_000) + ], + knownOwners: new Map(), + parentBySessionId: new Map() + }) + expect(ownerships).toEqual([]) + }) + it('advances the cursor past handled rows only', () => { const fresh = [ { id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }, diff --git a/src/main/opencode/opencode-session-binder.ts b/src/main/opencode/opencode-session-binder.ts index f305dfa3239f..b4c994c137c6 100644 --- a/src/main/opencode/opencode-session-binder.ts +++ b/src/main/opencode/opencode-session-binder.ts @@ -186,13 +186,21 @@ export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult const paneByKey = new Map() for (const pane of deps.panes) { const previous = paneByKey.get(pane.paneKey) + // Why the directory check: a pane that moved (detached into another tab, + // reminted against a different root) must not inherit input recorded under + // its old directory, or a session in the new one could win a tie on + // keystrokes that never happened there. + const carriedInput = + previous && previous.directory === pane.directory + ? mostRecentInput(previous.lastInputAtMs, pane.lastInputAtMs) + : pane.lastInputAtMs paneByKey.set(pane.paneKey, { paneKey: pane.paneKey, directory: pane.directory, // Why most-recent instead of the current row's own value: a reminted // PTY re-registers with no input history, so the replaced row's // observation is the only record of what the human typed there. - lastInputAtMs: mostRecentInput(previous?.lastInputAtMs, pane.lastInputAtMs) + lastInputAtMs: carriedInput }) } const panes = [...paneByKey.values()] diff --git a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts index 8d85eed240a6..97bd094e9454 100644 --- a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts +++ b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts @@ -239,7 +239,10 @@ describe('correlateOpenCodeSessionOwners', () => { expect(results).toEqual([]) }) - it('ignores input that arrived after the session was created', () => { + it('abstains when a pane was typed into after the session was created', () => { + // lastInputAtMs holds only the newest write, so pane-b's pre-creation + // evidence has been overwritten by later typing. Skipping it would let + // pane-a win as the unique in-window leader and reproduce #22838. const results = correlateOpenCodeSessionOwners({ sessions: [session('ses_1', NOW - 60_000)], panes: [ @@ -249,11 +252,55 @@ describe('correlateOpenCodeSessionOwners', () => { clients: [client('pane-a', NOW - 86_400_000), client('pane-b', NOW - 86_400_000)], knownOwners: new Map() }) + expect(results).toEqual([]) + }) + + it('binds an Orca-launched session to the pane whose client just booted', () => { + // Orca passes the first prompt with --prompt, so the creating pane writes + // nothing and input alone would hand the session to the bystander in + // pane-b, which was typed into a second before creation. + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR }, + { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 61_000 } + ], + clients: [client('pane-a', NOW - 65_000), client('pane-b', NOW - 86_400_000)], + knownOwners: new Map() + }) expect(results).toEqual([ { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'creation-correlation' } ]) }) + it('stays unbound when two panes booted together', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR }, + { paneKey: 'pane-b', directory: DIR } + ], + clients: [client('pane-a', NOW - 65_000), client('pane-b', NOW - 65_000)], + knownOwners: new Map() + }) + expect(results).toEqual([]) + }) + + it('ignores a client that started after the session was created', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR }, + { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 65_000 } + ], + clients: [client('pane-a', NOW - 40_000), client('pane-b', NOW - 86_400_000)], + knownOwners: new Map() + }) + expect(results).toEqual([ + { sessionId: 'ses_1', paneKey: 'pane-b', basis: 'creation-correlation' } + ]) + }) + it('ignores input when only one pane has client evidence', () => { const results = correlateOpenCodeSessionOwners({ sessions: [session('ses_1', NOW - 60_000)], diff --git a/src/shared/agent-hook-listener/opencode-session-correlation.ts b/src/shared/agent-hook-listener/opencode-session-correlation.ts index d90b268445b0..c95082f8794f 100644 --- a/src/shared/agent-hook-listener/opencode-session-correlation.ts +++ b/src/shared/agent-hook-listener/opencode-session-correlation.ts @@ -70,6 +70,23 @@ export const OPENCODE_CREATE_SKEW_MS = 2 * 60 * 1000 */ export const OPENCODE_INPUT_TIEBREAK_WINDOW_MS = 60 * 1000 +/** + * How long before a session's creation a pane's OpenCode client must have + * started for that start to identify the pane as the creator. Orca-launched + * agents pass their first prompt on the command line (`--prompt`), so the pane + * that created the session never writes a keystroke of its own — which is why + * launch recency is consulted before input recency: a client that booted + * seconds before the row appeared explains the session's existence, whereas a + * bystander's keystroke does not. Opening an OpenCode pane is a deliberate, + * rare act, so this is far higher precision than typing, which is constant. + * + * 20s is ~2x the startup Orca already measures for this binary (paste-ready + * ~4.8s, composer ~10s — see `draftPasteReadyTimeoutMs`), and stays tight + * enough that a pane merely *opened* this minute while another pane prompted + * does not read as the launcher. + */ +export const OPENCODE_LAUNCH_TIEBREAK_WINDOW_MS = 20 * 1000 + import { normalizeRuntimePathForComparison } from '../cross-platform-path' /** @@ -158,6 +175,53 @@ function clientCouldCreate(client: CorrelatedClient, createdAtMs: number): boole ) } +/** + * Break a same-directory tie on the pane whose OpenCode client had just + * started. This is the only signal available when the creator was launched by + * Orca with `--prompt`, since that prompt rides on the spawn command and the + * creating pane never writes to its PTY. The freshest qualifying client wins, + * and two panes booted together tie and are both rejected. + */ +function tieBreakByFreshLaunch( + evidencing: readonly CorrelatedPane[], + clients: readonly CorrelatedClient[], + createdAtMs: number +): string | null { + let best: CorrelatedPane | null = null + let bestStart = Number.NEGATIVE_INFINITY + let tied = false + for (const pane of evidencing) { + let newest = Number.NEGATIVE_INFINITY + for (const client of clients) { + if (client.paneKey !== pane.paneKey || !clientCouldCreate(client, createdAtMs)) { + continue + } + if (client.startedAtMs > newest) { + newest = client.startedAtMs + } + } + if (newest === Number.NEGATIVE_INFINITY) { + continue + } + // A client that started after the row appeared cannot have created it; the + // 2s allowance absorbs `ps etime`'s whole-second granularity. + if (newest > createdAtMs + 2_000) { + continue + } + if (createdAtMs - newest > OPENCODE_LAUNCH_TIEBREAK_WINDOW_MS) { + continue + } + if (newest > bestStart) { + best = pane + bestStart = newest + tied = false + } else if (newest === bestStart) { + tied = true + } + } + return best && !tied ? best.paneKey : null +} + /** * Break a same-directory tie by who was actually typing. Submitting the prompt * that creates a session writes to that pane's PTY immediately beforehand, so @@ -178,11 +242,13 @@ function tieBreakByRecentInput( if (typeof at !== 'number' || Number.isNaN(at)) { continue } - // The record is written before the keystroke reaches the PTY, so input - // postdating the row cannot have submitted it; rejecting it outright is - // what keeps a pane that was merely typed into afterwards from winning. + // Why abstain rather than skip: lastInputAtMs holds only the newest write, + // so a pane that typed the prompt and was then typed into again has had its + // pre-creation evidence overwritten. Skipping it would let a bystander + // become the unique in-window leader — the exact wrong-pane result this + // tie-break exists to prevent. if (at > createdAtMs) { - continue + return null } if (createdAtMs - at > OPENCODE_INPUT_TIEBREAK_WINDOW_MS) { continue @@ -204,10 +270,13 @@ function tieBreakByRecentInput( * immediately; otherwise exactly one pane must have both the directory and a * client that brackets the creation. When several panes qualify — the normal * case for split panes or sibling folder workspaces, which share a directory — - * the pane whose PTY received input just before the session appeared wins, but - * only when that pane is an unambiguous in-window leader. Anything still - * undecided stays unbound rather than guessed: a wrong owner shows the wrong - * pane spinning, which is the bug this resolves, not a milder version of it. + * two signals decide, in order: the pane whose client had just booted (which + * covers Orca-launched agents, whose prompt rides on the spawn command and + * leaves their pane no keystroke), then the pane whose PTY received input just + * before the session appeared. Each must yield an unambiguous in-window + * leader. Anything still undecided stays unbound rather than guessed: a wrong + * owner shows the wrong pane spinning, which is the bug this resolves, not a + * milder version of it. */ export function correlateOpenCodeSessionOwners(args: { sessions: readonly CorrelatedSession[] @@ -299,9 +368,14 @@ export function correlateOpenCodeSessionOwners(args: { ) const [only] = evidencing if (evidencing.length !== 1 || !only) { - const recent = tieBreakByRecentInput(evidencing, session.createdAtMs) - if (recent) { - claim(session.id, recent, 'creation-correlation') + // Launch recency is consulted first: an Orca-launched agent carries its + // first prompt on the spawn command, so the creating pane never writes a + // keystroke and input alone would hand the session to a bystander. + const owner = + tieBreakByFreshLaunch(evidencing, clients, session.createdAtMs) ?? + tieBreakByRecentInput(evidencing, session.createdAtMs) + if (owner) { + claim(session.id, owner, 'creation-correlation') } continue } From f8d25af37c84f0f74aec308be85aaa0bd7378f86 Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Fri, 25 Sep 2026 11:52:01 -0300 Subject: [PATCH 4/9] fix(opencode): record Orca-delivered prompts as pane prompt activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prompt Orca dispatches to a running agent is written host-side through writeTerminalAgentPrompt, never as a keystroke, so the pane that submitted it recorded no prompt activity at all. In a shared directory that reproduces the wrong-pane outcome review found for flag launches: the creator silent while a sibling pane's typing wins the tie as unique in-window leader. Stamp on the successful write, and broaden lastInputAtMs from "interactive write" to "prompt activity" across the registry, binder and correlator docs — the field now covers both a renderer keystroke and a prompt Orca delivered. --- src/main/memory/pty-registry.ts | 11 ++--- src/main/opencode/opencode-session-binder.ts | 5 ++- .../agent-prompt-submission-runtime.test.ts | 40 +++++++++++++++++++ ...rca-runtime-write-terminal-agent-prompt.ts | 12 +++--- .../opencode-session-correlation.ts | 18 +++++---- 5 files changed, 66 insertions(+), 20 deletions(-) diff --git a/src/main/memory/pty-registry.ts b/src/main/memory/pty-registry.ts index c507995ad9bd..c55e2e94f7fb 100644 --- a/src/main/memory/pty-registry.ts +++ b/src/main/memory/pty-registry.ts @@ -27,10 +27,11 @@ export type PtyRegistration = { // PTY instead of throwing and dropping the whole snapshot. pid: number | null /** - * ms epoch of the last interactive write to this PTY. Absent until a - * keystroke arrives, and dropped when the PTY is re-registered under a new - * incarnation. The session binder reads it to decide which of two - * same-directory panes submitted the prompt that created a session (#22838). + * ms epoch of the last prompt activity on this PTY — a keystroke from the + * renderer, or a prompt Orca delivered host-side. Absent until one arrives, + * and dropped when the PTY is re-registered under a new incarnation. The + * session binder reads it to decide which of two same-directory panes + * submitted the prompt that created a session (#22838). */ lastInputAtMs?: number } @@ -46,7 +47,7 @@ export function unregisterPty(ptyId: string): void { } /** - * Record an interactive write against a PTY. Ids the registry never learned + * Record prompt activity against a PTY. Ids the registry never learned * (remote PTYs, already-torn-down ones) are ignored so the row cannot be * created by input alone. */ diff --git a/src/main/opencode/opencode-session-binder.ts b/src/main/opencode/opencode-session-binder.ts index b4c994c137c6..b5587c96747a 100644 --- a/src/main/opencode/opencode-session-binder.ts +++ b/src/main/opencode/opencode-session-binder.ts @@ -36,8 +36,9 @@ export type BinderPaneSnapshot = { worktreeId: string | null shellPid: number | null /** - * ms epoch of the pane's last interactive PTY write, null when the pane has - * not been typed into since it registered. Breaks same-directory ties. + * ms epoch of the pane's last prompt activity (renderer keystroke, or a + * prompt Orca delivered host-side), null when neither has happened since it + * registered. Breaks same-directory ties. */ lastInputAtMs: number | null } diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 873863ada75c..765cd7ca2e39 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AGENT_PROMPT_BRACKETED_PASTE_END } from '../../shared/agent-prompt-injection' +import { listRegisteredPtys, registerPty, unregisterPty } from '../memory/pty-registry' import { AGENT_PROMPT_TEST_WORKTREE_PATH, createAgentPromptSubmissionRuntime @@ -9,6 +10,11 @@ import { makeStore } from './runtime-rpc-worktree-store-fixtures' const createPromptRuntime = createAgentPromptSubmissionRuntime +/** Prompt activity the OpenCode session binder reads; absent until one arrives. */ +function promptActivityAt(ptyId: string): number | undefined { + return listRegisteredPtys().find((pty) => pty.ptyId === ptyId)?.lastInputAtMs +} + vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue([ { @@ -50,6 +56,40 @@ describe('agent prompt submission runtime', () => { expect(writes.filter((data) => data === '\r')).toHaveLength(1) }) + it('records an Orca-delivered prompt as prompt activity on the pane', async () => { + vi.useFakeTimers() + // The fixture's fake pty controller bypasses the spawn path that normally + // registers a pane, so register it here — the stamp only lands on a pane + // the registry knows, exactly as in production. + registerPty({ + ptyId: 'pty-prompt', + worktreeId: null, + sessionId: null, + paneKey: 'tab:leaf', + pid: 1 + }) + try { + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + } + }) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + // A prompt Orca delivers is not a keystroke, so nothing in the renderer + // path records it. Without this the pane that submitted the prompt shows + // no prompt activity and OpenCode's same-directory tie could hand its + // session to a sibling pane that merely happened to be typed into. + expect(promptActivityAt('pty-prompt')).toBeTypeOf('number') + } finally { + unregisterPty('pty-prompt') + } + }) + it('accepts a working-to-idle cycle completed before the first poll', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index b5aff5f755c3..6ea87d4064d1 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -21,6 +21,7 @@ import { resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' +import { notePtyInput } from '../memory/pty-registry' export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission { protected async writeTerminalAgentPrompt( @@ -68,6 +69,11 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso if (!this.ptyController?.write(ptyId, initialWrite)) { throw new Error('terminal_not_writable') } + // Why: a prompt Orca delivers is not a keystroke, so the renderer path + // never records it. Without this the pane that submitted the prompt shows + // no prompt activity, and OpenCode's same-directory tie could hand its + // session to a sibling pane that merely happened to be typed into. + notePtyInput(ptyId, Date.now()) } catch (error) { renderGate?.dispose() throw error @@ -85,11 +91,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso } else { const agent = this.getPtyAgent(ptyId) const submitDelayMs = options.promptForSchedule - ? resolveAgentPromptSubmitDelayForAgent( - writeHostPlatform, - options.promptForSchedule, - agent - ) + ? resolveAgentPromptSubmitDelayForAgent(writeHostPlatform, options.promptForSchedule, agent) : getAgentPromptSubmitDelayMs(writeHostPlatform, pasteByteLength) await waitForAgentPromptDelay(submitDelayMs, options.signal) } diff --git a/src/shared/agent-hook-listener/opencode-session-correlation.ts b/src/shared/agent-hook-listener/opencode-session-correlation.ts index c95082f8794f..2fdd0a588077 100644 --- a/src/shared/agent-hook-listener/opencode-session-correlation.ts +++ b/src/shared/agent-hook-listener/opencode-session-correlation.ts @@ -29,10 +29,11 @@ export type CorrelatedPane = { */ directory: string | null /** - * ms epoch of the pane's last interactive PTY write, or null/absent when the - * pane has never been typed into since Orca registered it. Supplied by the - * binder so same-directory ties can be broken by who actually submitted the - * prompt, not by who merely had a client alive. + * ms epoch of the pane's last prompt activity (renderer keystroke, or a + * prompt Orca delivered host-side), or null/absent when the pane has had + * none since Orca registered it. Supplied by the binder so same-directory + * ties can be broken by who actually submitted the prompt, not by who merely + * had a client alive. */ lastInputAtMs?: number | null } @@ -223,10 +224,11 @@ function tieBreakByFreshLaunch( } /** - * Break a same-directory tie by who was actually typing. Submitting the prompt - * that creates a session writes to that pane's PTY immediately beforehand, so - * the candidate with the most recent pre-creation input is the creator. A - * candidate with no recorded input, or one whose last keystroke fell outside + * Break a same-directory tie by who was actually submitting the prompt. + * Creating a session writes to that pane's PTY immediately beforehand — + * whether a human keystroke or a prompt Orca delivered host-side — so the + * candidate with the most recent pre-creation activity is the creator. A + * candidate with no recorded activity, or one whose last write fell outside * the window, contributes nothing; candidates recorded at the identical * instant tie and are both rejected rather than guessed between. */ From 465e297b9bda6617dafb56a8ffe1f758a7ff53ea Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Fri, 25 Sep 2026 13:18:32 -0300 Subject: [PATCH 5/9] fix(opencode): stop masking a launch client and dropping input across a respelled directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the tie-break fixes found two ways it could still misbind: - tieBreakByFreshLaunch took the newest of a pane's qualifying clients, but clientCouldCreate admits starts up to CREATE_SKEW after the row. A client spawned inside the pane (an `opencode run`) therefore became the newest, tripped the post-creation rejection and dropped the whole pane — masking an earlier client that really was the launch, and letting a bystander win on input. The rejection now applies per client, so only post-creation clients are excluded from the max. - The input-history merge compared directories as raw strings while the correlator normalizes them, so a remint that only respelled the root (/tmp/x vs /private/tmp/x) discarded valid input and handed the session to a pane with older activity — the opposite of the guard's intent. The binder now compares normalized directories via openCodeDirectoryMatches. --- .../opencode/opencode-session-binder.test.ts | 29 +++++++++++++++++++ src/main/opencode/opencode-session-binder.ts | 3 +- ...tener-opencode-session-correlation.test.ts | 23 +++++++++++++++ .../opencode-session-correlation.ts | 27 +++++++++++++---- 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/main/opencode/opencode-session-binder.test.ts b/src/main/opencode/opencode-session-binder.test.ts index ed7ead9177b4..913c15c74f71 100644 --- a/src/main/opencode/opencode-session-binder.test.ts +++ b/src/main/opencode/opencode-session-binder.test.ts @@ -175,6 +175,35 @@ describe('runOpenCodeBinderRound', () => { expect(ownerships).toEqual([]) }) + it('keeps input across a remint that only changed the directory spelling', () => { + // macOS folds /tmp into /private/tmp; the correlator normalizes before + // comparing containment, so the binder must too — otherwise a remint that + // merely respelled the root discards valid input and hands the session to + // a pane with older activity. + const ALIAS_DIR = '/tmp/binder-alias' + const { ownerships } = runOpenCodeBinderRound({ + nowMs: NOW, + sessions: [{ id: 'ses_1', directory: ALIAS_DIR, createdAtMs: NOW - 60_000, parentId: null }], + panes: [ + { ...pane(PANE_A, 100, NOW - 65_000), directory: '/tmp/binder-alias' }, + { ...pane(PANE_A, 101, null), directory: '/private/tmp/binder-alias' }, + { ...pane(PANE_B, 200, NOW - 3_600_000), directory: '/private/tmp/binder-alias' } + ], + processes: [ + proc(100, 1, ['zsh']), + proc(101, 1, ['zsh']), + proc(200, 1, ['zsh']), + proc(102, 101, ['opencode'], NOW - 86_400_000), + proc(201, 200, ['opencode'], NOW - 86_400_000) + ], + knownOwners: new Map(), + parentBySessionId: new Map() + }) + expect(ownerships).toEqual([ + { sessionId: 'ses_1', paneKey: PANE_A, basis: 'creation-correlation' } + ]) + }) + it('advances the cursor past handled rows only', () => { const fresh = [ { id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }, diff --git a/src/main/opencode/opencode-session-binder.ts b/src/main/opencode/opencode-session-binder.ts index b5587c96747a..6e6bea0cfe77 100644 --- a/src/main/opencode/opencode-session-binder.ts +++ b/src/main/opencode/opencode-session-binder.ts @@ -5,6 +5,7 @@ import { } from '../../shared/agent-hook-listener/opencode-session-registry' import { correlateOpenCodeSessionOwners, + openCodeDirectoryMatches, type CorrelatedClient, type CorrelatedPane, type CorrelatedSession, @@ -192,7 +193,7 @@ export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult // its old directory, or a session in the new one could win a tie on // keystrokes that never happened there. const carriedInput = - previous && previous.directory === pane.directory + previous && openCodeDirectoryMatches(previous.directory, pane.directory) ? mostRecentInput(previous.lastInputAtMs, pane.lastInputAtMs) : pane.lastInputAtMs paneByKey.set(pane.paneKey, { diff --git a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts index 97bd094e9454..21d7c146bc71 100644 --- a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts +++ b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts @@ -301,6 +301,29 @@ describe('correlateOpenCodeSessionOwners', () => { ]) }) + it('keeps a pane whose later client postdates the session', () => { + // clientCouldCreate admits starts up to CREATE_SKEW after the row, so a + // client spawned inside the pane (e.g. `opencode run`) must not mask the + // earlier client that actually launched this session — and must not let + // the bystander in pane-b inherit it on input alone. + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR }, + { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 61_000 } + ], + clients: [ + client('pane-a', NOW - 65_000), + client('pane-a', NOW - 40_000), + client('pane-b', NOW - 86_400_000) + ], + knownOwners: new Map() + }) + expect(results).toEqual([ + { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'creation-correlation' } + ]) + }) + it('ignores input when only one pane has client evidence', () => { const results = correlateOpenCodeSessionOwners({ sessions: [session('ses_1', NOW - 60_000)], diff --git a/src/shared/agent-hook-listener/opencode-session-correlation.ts b/src/shared/agent-hook-listener/opencode-session-correlation.ts index 2fdd0a588077..e213c0750eb1 100644 --- a/src/shared/agent-hook-listener/opencode-session-correlation.ts +++ b/src/shared/agent-hook-listener/opencode-session-correlation.ts @@ -146,6 +146,20 @@ function normalizeDir(directory: string): string { ) } +/** + * True when two pane directories name the same place after the normalization + * session containment uses (macOS /private alias, Windows case and backslash + * folding, dot segments). Raw equality here would discard a reminted pane's + * input history whenever only the path's spelling changed, handing the + * session to a pane with older activity — the opposite of the guard's intent. + */ +export function openCodeDirectoryMatches(left: string | null, right: string | null): boolean { + if (!left || !right) { + return false + } + return normalizeDir(left) === normalizeDir(right) +} + /** `--session `, `-s `, `--session=` or a trailing attach target. */ export function sessionIdFromArgv(argv: readonly string[]): string | null { for (let i = 0; i < argv.length; i += 1) { @@ -197,6 +211,14 @@ function tieBreakByFreshLaunch( if (client.paneKey !== pane.paneKey || !clientCouldCreate(client, createdAtMs)) { continue } + // Reject here rather than on the pane's newest: clientCouldCreate allows + // starts up to CREATE_SKEW after the row, so testing only the max would + // let a later client (an `opencode run` spawned inside the pane) mask an + // earlier one that really was the launch. A pane holding only + // post-creation clients still ends at -Infinity and is skipped below. + if (client.startedAtMs > createdAtMs + 2_000) { + continue + } if (client.startedAtMs > newest) { newest = client.startedAtMs } @@ -204,11 +226,6 @@ function tieBreakByFreshLaunch( if (newest === Number.NEGATIVE_INFINITY) { continue } - // A client that started after the row appeared cannot have created it; the - // 2s allowance absorbs `ps etime`'s whole-second granularity. - if (newest > createdAtMs + 2_000) { - continue - } if (createdAtMs - newest > OPENCODE_LAUNCH_TIEBREAK_WINDOW_MS) { continue } From f7ae08d0c2ad2aa7c687d2c906d7954c10148795 Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Fri, 25 Sep 2026 17:44:19 -0300 Subject: [PATCH 6/9] fix(opencode): only credit a pane once its prompt is actually submitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host-side stamp fired after the paste write, but on the separate-submit path nothing is submitted until the Enter that follows — and beforeWrite failures, cancellation, and a permission state appearing all land between the two. A pane whose prompt was pasted and then abandoned still claimed prompt activity, so a session created elsewhere in the window could bind to a pane that never submitted anything. Stamp after the joined paste+submit frame when the prompt rides on one write, and after the Enter write succeeds on the two-write path. Covered by a test mirroring the existing "does not send Enter after a permission state appears" scenario: the paste reaches the PTY, the Enter does not, and the pane must stay unstamped. --- .../agent-prompt-submission-runtime.test.ts | 32 +++++++++++++++++++ ...rca-runtime-write-terminal-agent-prompt.ts | 10 +++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 765cd7ca2e39..1d59d298213e 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -90,6 +90,38 @@ describe('agent prompt submission runtime', () => { } }) + it('does not record prompt activity when the prompt is pasted but never submitted', async () => { + vi.useFakeTimers() + registerPty({ + ptyId: 'pty-prompt', + worktreeId: null, + sessionId: null, + paneKey: 'tab:leaf', + pid: 1 + }) + try { + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) { + runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) + } + }) + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') + + await vi.runAllTimersAsync() + + await rejected + // The paste reached the PTY but the Enter never did, so nothing was + // submitted. Crediting this pane would let it win a same-directory tie + // for a session it never created — the wrong-pane outcome this exists + // to prevent. + expect(writes).not.toContain('\r') + expect(promptActivityAt('pty-prompt')).toBeUndefined() + } finally { + unregisterPty('pty-prompt') + } + }) + it('accepts a working-to-idle cycle completed before the first poll', async () => { vi.useFakeTimers() const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index 5862599e8d2b..e6b17717621f 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -70,7 +70,12 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso // never records it. Without this the pane that submitted the prompt shows // no prompt activity, and OpenCode's same-directory tie could hand its // session to a sibling pane that merely happened to be typed into. - notePtyInput(ptyId, Date.now()) + // Joined frame only: on the separate-submit path nothing is submitted + // until the Enter below, and a failure in between must not credit a pane + // that never submitted anything. + if (submitWithPaste) { + notePtyInput(ptyId, Date.now()) + } } catch (error) { renderGate?.dispose() throw error @@ -112,6 +117,9 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } + // The prompt is submitted by this write, so this is the first moment the + // pane can honestly claim prompt activity. + notePtyInput(ptyId, Date.now()) } const effectTimeoutMs = resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)) if (!options.acceptQueued || !options.requestId) { From 2831db153f15933e4b44b2f34c760f007dea0a13 Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Fri, 25 Sep 2026 21:25:01 -0300 Subject: [PATCH 7/9] fix(opencode): abstain when the two pane signals disagree, and recover submitted prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the tie-break: - Launch recency was absolute: tieBreakByFreshLaunch never read lastInputAtMs, so a pane whose client merely booted inside the window beat a pane that had just been written to. Reachable whenever someone opens a same-directory pane and then submits a prompt in an established one — a wrong-pane bind, which is the symptom this work exists to remove. The two signals are symmetric and no evidence separates a launched creator from an innocent pane opened nearby, so disagreement now abstains rather than picking a side. - The global veto added for a post-creation stamp fired whenever any pane was touched after the row, which is ordinary traffic, and left the session permanently unbound — the binder drops unbound sessions after ten minutes. The registry now keeps the stamp it replaces, so a pane that submitted and then kept typing still shows its submission. Abstention narrows to the case where both slots postdate the row and an earlier submission may genuinely have been evicted. Also corrects the pty-registry module doc, which still claimed the write side was two calls and that the PTY subsystem had no other reason to know about it. --- .../server-opencode-binder.test.ts | 3 +- src/main/memory/pty-registry.test.ts | 13 +++ src/main/memory/pty-registry.ts | 30 +++-- .../opencode/opencode-session-binder.test.ts | 36 +++++- src/main/opencode/opencode-session-binder.ts | 46 ++++---- ...tener-opencode-session-correlation.test.ts | 104 ++++++++++++++---- .../opencode-session-correlation.ts | 85 ++++++++------ 7 files changed, 229 insertions(+), 88 deletions(-) diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts index a594a3671592..eddfa9f46d48 100644 --- a/src/main/agent-hooks/server-opencode-binder.test.ts +++ b/src/main/agent-hooks/server-opencode-binder.test.ts @@ -72,7 +72,8 @@ describe('opencode binder loop', () => { directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111, - lastInputAtMs: null + lastInputAtMs: null, + previousInputAtMs: null } ], sweep: async () => [ diff --git a/src/main/memory/pty-registry.test.ts b/src/main/memory/pty-registry.test.ts index c8e4503aa488..4adec7dfbd85 100644 --- a/src/main/memory/pty-registry.test.ts +++ b/src/main/memory/pty-registry.test.ts @@ -24,6 +24,19 @@ describe('notePtyInput', () => { expect(listRegisteredPtys().some((pty) => pty.ptyId === 'pty-never-registered')).toBe(false) }) + it('keeps the stamp it replaces so a submission survives later typing', () => { + const ptyId = 'pty-note-input-history' + registerPty(registration(ptyId)) + try { + notePtyInput(ptyId, 1_000) + notePtyInput(ptyId, 2_000) + expect(find(ptyId)?.lastInputAtMs).toBe(2_000) + expect(find(ptyId)?.previousInputAtMs).toBe(1_000) + } finally { + unregisterPty(ptyId) + } + }) + it('drops the stamp with the registration', () => { const ptyId = 'pty-note-input-teardown' registerPty(registration(ptyId)) diff --git a/src/main/memory/pty-registry.ts b/src/main/memory/pty-registry.ts index c55e2e94f7fb..aa59d2609471 100644 --- a/src/main/memory/pty-registry.ts +++ b/src/main/memory/pty-registry.ts @@ -1,13 +1,11 @@ /** - * Lightweight side-table that lets the memory collector attribute PTY - * processes back to the worktree that spawned them. + * Lightweight side-table of live local PTYs, keyed by id. * - * Why a separate module rather than folding it into pty.ts: the PTY - * subsystem has no other reason to know about worktree memory. Keeping - * the registry here means the collector can evolve (e.g. start tracking - * extra per-session metadata) without touching the critical-path spawn - * handler. The write-side is just two calls — `register` on spawn, - * `unregister` on teardown. + * Two consumers share it: the memory collector, which attributes PTY processes + * back to the worktree that spawned them, and (via the session binder) the + * OpenCode session→pane correlation, which reads the prompt-activity stamps the + * PTY write paths leave here. The write side is `register` on spawn, + * `unregister` on teardown, and `notePtyInput` on each prompt-activity write. * * Scope: local PTYs only. SSH-backed PTYs execute on a remote host, so * their memory does not contribute to Orca's process footprint and @@ -34,6 +32,14 @@ export type PtyRegistration = { * submitted the prompt that created a session (#22838). */ lastInputAtMs?: number + /** + * The stamp `lastInputAtMs` replaced. A session row appears *after* the + * prompt that created it, so the creating pane's useful evidence is its + * pre-creation stamp — and if it typed again after submitting, that evidence + * is only visible here. Two slots recover the ordinary "submitted, then kept + * typing" case; a third post-creation write evicts it. + */ + previousInputAtMs?: number } const registry = new Map() @@ -47,13 +53,15 @@ export function unregisterPty(ptyId: string): void { } /** - * Record prompt activity against a PTY. Ids the registry never learned - * (remote PTYs, already-torn-down ones) are ignored so the row cannot be - * created by input alone. + * Record prompt activity against a PTY, keeping the stamp it replaces so a + * pane that submitted a prompt and then kept typing can still show the + * submission. Ids the registry never learned (remote PTYs, already-torn-down + * ones) are ignored so the row cannot be created by input alone. */ export function notePtyInput(ptyId: string, nowMs: number): void { const entry = registry.get(ptyId) if (entry) { + entry.previousInputAtMs = entry.lastInputAtMs entry.lastInputAtMs = nowMs } } diff --git a/src/main/opencode/opencode-session-binder.test.ts b/src/main/opencode/opencode-session-binder.test.ts index 913c15c74f71..bfb52160a551 100644 --- a/src/main/opencode/opencode-session-binder.test.ts +++ b/src/main/opencode/opencode-session-binder.test.ts @@ -28,14 +28,16 @@ function proc( function pane( paneKey: string, shellPid: number | null, - lastInputAtMs: number | null = null + lastInputAtMs: number | null = null, + previousInputAtMs: number | null = null ): BinderPaneSnapshot { return { paneKey, directory: DIR, worktreeId: 'repo::/Users/jin/work/mocitec', shellPid, - lastInputAtMs + lastInputAtMs, + previousInputAtMs } } @@ -204,6 +206,33 @@ describe('runOpenCodeBinderRound', () => { ]) }) + it('carries the replaced stamp across a remint so a submission survives', () => { + // The stale row holds pane A's submission at NOW-61s, already displaced into + // the older slot by a later write; the live row has no history. pane B was + // written to at NOW-65s, so only the carried older slot lets A win. + const { ownerships } = runOpenCodeBinderRound({ + nowMs: NOW, + sessions: [{ id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }], + panes: [ + pane(PANE_A, 100, NOW - 55_000, NOW - 61_000), + pane(PANE_A, 101, null, null), + pane(PANE_B, 200, NOW - 65_000) + ], + processes: [ + proc(100, 1, ['zsh']), + proc(101, 1, ['zsh']), + proc(200, 1, ['zsh']), + proc(102, 100, ['opencode'], NOW - 86_400_000), + proc(201, 200, ['opencode'], NOW - 86_400_000) + ], + knownOwners: new Map(), + parentBySessionId: new Map() + }) + expect(ownerships).toEqual([ + { sessionId: 'ses_1', paneKey: PANE_A, basis: 'creation-correlation' } + ]) + }) + it('advances the cursor past handled rows only', () => { const fresh = [ { id: 'ses_1', directory: DIR, createdAtMs: NOW - 60_000, parentId: null }, @@ -273,7 +302,8 @@ describe('applyBinderOwnerships', () => { directory: '/elsewhere', worktreeId: 'repo::/elsewhere', shellPid: 100, - lastInputAtMs: null + lastInputAtMs: null, + previousInputAtMs: null }, { ...pane(PANE_A, 101) } ], diff --git a/src/main/opencode/opencode-session-binder.ts b/src/main/opencode/opencode-session-binder.ts index 6e6bea0cfe77..621b6c0acc6c 100644 --- a/src/main/opencode/opencode-session-binder.ts +++ b/src/main/opencode/opencode-session-binder.ts @@ -42,6 +42,8 @@ export type BinderPaneSnapshot = { * registered. Breaks same-directory ties. */ lastInputAtMs: number | null + /** The stamp `lastInputAtMs` replaced; see `PtyRegistration`. */ + previousInputAtMs: number | null } /** One session store row feeding a binder round. */ @@ -164,18 +166,20 @@ function toCorrelatedClients( return clients } -/** Most recent of two observations; null when neither side has one. */ -function mostRecentInput( - left: number | null | undefined, - right: number | null | undefined -): number | null { - if (typeof left !== 'number') { - return typeof right === 'number' ? right : null - } - if (typeof right !== 'number') { - return left - } - return Math.max(left, right) +/** + * The two newest stamps among the rows describing one pane, newest first. A + * remint can leave two rows for the same pane and the live one starts with no + * history, so the replaced row's stamps are the only record of what the human + * typed there. + */ +function newestTwoStamps(...values: readonly (number | null | undefined)[]): { + last: number | null + previous: number | null +} { + const sorted = values + .filter((value): value is number => typeof value === 'number') + .sort((left, right) => right - left) + return { last: sorted[0] ?? null, previous: sorted[1] ?? null } } /** Pure round core: correlate unbound sessions against panes and clients. */ @@ -194,15 +198,18 @@ export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult // keystrokes that never happened there. const carriedInput = previous && openCodeDirectoryMatches(previous.directory, pane.directory) - ? mostRecentInput(previous.lastInputAtMs, pane.lastInputAtMs) - : pane.lastInputAtMs + ? newestTwoStamps( + previous.lastInputAtMs, + previous.previousInputAtMs, + pane.lastInputAtMs, + pane.previousInputAtMs + ) + : { last: pane.lastInputAtMs, previous: pane.previousInputAtMs } paneByKey.set(pane.paneKey, { paneKey: pane.paneKey, directory: pane.directory, - // Why most-recent instead of the current row's own value: a reminted - // PTY re-registers with no input history, so the replaced row's - // observation is the only record of what the human typed there. - lastInputAtMs: carriedInput + lastInputAtMs: carriedInput.last, + previousInputAtMs: carriedInput.previous }) } const panes = [...paneByKey.values()] @@ -341,7 +348,8 @@ export function listBinderPaneSnapshots(): BinderPaneSnapshot[] { directory: parsed?.worktreePath ?? null, worktreeId: pty.worktreeId, shellPid: pty.pid, - lastInputAtMs: pty.lastInputAtMs ?? null + lastInputAtMs: pty.lastInputAtMs ?? null, + previousInputAtMs: pty.previousInputAtMs ?? null }) } return snapshots diff --git a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts index 21d7c146bc71..7c755d34e1a6 100644 --- a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts +++ b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts @@ -239,10 +239,9 @@ describe('correlateOpenCodeSessionOwners', () => { expect(results).toEqual([]) }) - it('abstains when a pane was typed into after the session was created', () => { - // lastInputAtMs holds only the newest write, so pane-b's pre-creation - // evidence has been overwritten by later typing. Skipping it would let - // pane-a win as the unique in-window leader and reproduce #22838. + it('ignores a pane whose only write postdates the session', () => { + // Submitting a prompt is itself a write, so a pane holding a single stamp + // that postdates the row cannot have created it — pane-a keeps the session. const results = correlateOpenCodeSessionOwners({ sessions: [session('ses_1', NOW - 60_000)], panes: [ @@ -252,60 +251,93 @@ describe('correlateOpenCodeSessionOwners', () => { clients: [client('pane-a', NOW - 86_400_000), client('pane-b', NOW - 86_400_000)], knownOwners: new Map() }) - expect(results).toEqual([]) + expect(results).toEqual([ + { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'creation-correlation' } + ]) }) - it('binds an Orca-launched session to the pane whose client just booted', () => { - // Orca passes the first prompt with --prompt, so the creating pane writes - // nothing and input alone would hand the session to the bystander in - // pane-b, which was typed into a second before creation. + it('recovers the submitting stamp from a pane that kept typing', () => { + // pane-b submitted at T-1s and typed again at T+5s. The newest stamp alone + // would look post-creation and let pane-a inherit the session; the older + // slot holds the submission. const results = correlateOpenCodeSessionOwners({ sessions: [session('ses_1', NOW - 60_000)], panes: [ - { paneKey: 'pane-a', directory: DIR }, - { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 61_000 } + { paneKey: 'pane-a', directory: DIR, lastInputAtMs: NOW - 90_000 }, + { + paneKey: 'pane-b', + directory: DIR, + lastInputAtMs: NOW - 55_000, + previousInputAtMs: NOW - 61_000 + } ], - clients: [client('pane-a', NOW - 65_000), client('pane-b', NOW - 86_400_000)], + clients: [client('pane-a', NOW - 86_400_000), client('pane-b', NOW - 86_400_000)], knownOwners: new Map() }) expect(results).toEqual([ - { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'creation-correlation' } + { sessionId: 'ses_1', paneKey: 'pane-b', basis: 'creation-correlation' } ]) }) - it('stays unbound when two panes booted together', () => { + it('abstains when a pane typed twice after the session and lost its earlier stamps', () => { + // Both slots postdate the row, so an earlier submission may have been + // evicted. That pane cannot be ruled out as the creator, and pane-a must not + // inherit the session on evidence that is merely incomplete. + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR, lastInputAtMs: NOW - 65_000 }, + { + paneKey: 'pane-b', + directory: DIR, + lastInputAtMs: NOW - 10_000, + previousInputAtMs: NOW - 20_000 + } + ], + clients: [client('pane-a', NOW - 86_400_000), client('pane-b', NOW - 86_400_000)], + knownOwners: new Map() + }) + expect(results).toEqual([]) + }) + + it('stays unbound when a fresh launch and a recent write disagree', () => { + // pane-a's client booted seconds ago (an Orca launch, which writes no + // keystroke) while pane-b was written to a second before the row. Both + // explanations fit, and nothing separates a launched creator from an + // innocent pane opened nearby — so neither is credited. const results = correlateOpenCodeSessionOwners({ sessions: [session('ses_1', NOW - 60_000)], panes: [ { paneKey: 'pane-a', directory: DIR }, - { paneKey: 'pane-b', directory: DIR } + { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 61_000 } ], - clients: [client('pane-a', NOW - 65_000), client('pane-b', NOW - 65_000)], + clients: [client('pane-a', NOW - 65_000), client('pane-b', NOW - 86_400_000)], knownOwners: new Map() }) expect(results).toEqual([]) }) - it('ignores a client that started after the session was created', () => { + it('binds a fresh launch when no write disagrees', () => { const results = correlateOpenCodeSessionOwners({ sessions: [session('ses_1', NOW - 60_000)], panes: [ { paneKey: 'pane-a', directory: DIR }, - { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 65_000 } + { paneKey: 'pane-b', directory: DIR } ], - clients: [client('pane-a', NOW - 40_000), client('pane-b', NOW - 86_400_000)], + clients: [client('pane-a', NOW - 65_000), client('pane-b', NOW - 86_400_000)], knownOwners: new Map() }) expect(results).toEqual([ - { sessionId: 'ses_1', paneKey: 'pane-b', basis: 'creation-correlation' } + { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'creation-correlation' } ]) }) it('keeps a pane whose later client postdates the session', () => { // clientCouldCreate admits starts up to CREATE_SKEW after the row, so a // client spawned inside the pane (e.g. `opencode run`) must not mask the - // earlier client that actually launched this session — and must not let - // the bystander in pane-b inherit it on input alone. + // earlier client that launched this session. Masking would leave no launch + // leader at all and let pane-b's write hand it the session; unmasked, the + // two signals disagree and the round abstains instead. const results = correlateOpenCodeSessionOwners({ sessions: [session('ses_1', NOW - 60_000)], panes: [ @@ -319,8 +351,34 @@ describe('correlateOpenCodeSessionOwners', () => { ], knownOwners: new Map() }) + expect(results).toEqual([]) + }) + + it('stays unbound when two panes booted together', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR }, + { paneKey: 'pane-b', directory: DIR } + ], + clients: [client('pane-a', NOW - 65_000), client('pane-b', NOW - 65_000)], + knownOwners: new Map() + }) + expect(results).toEqual([]) + }) + + it('ignores a client that started after the session was created', () => { + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR }, + { paneKey: 'pane-b', directory: DIR, lastInputAtMs: NOW - 65_000 } + ], + clients: [client('pane-a', NOW - 40_000), client('pane-b', NOW - 86_400_000)], + knownOwners: new Map() + }) expect(results).toEqual([ - { sessionId: 'ses_1', paneKey: 'pane-a', basis: 'creation-correlation' } + { sessionId: 'ses_1', paneKey: 'pane-b', basis: 'creation-correlation' } ]) }) diff --git a/src/shared/agent-hook-listener/opencode-session-correlation.ts b/src/shared/agent-hook-listener/opencode-session-correlation.ts index e213c0750eb1..967bf43aadfb 100644 --- a/src/shared/agent-hook-listener/opencode-session-correlation.ts +++ b/src/shared/agent-hook-listener/opencode-session-correlation.ts @@ -36,6 +36,12 @@ export type CorrelatedPane = { * had a client alive. */ lastInputAtMs?: number | null + /** + * The stamp `lastInputAtMs` replaced. The row appears once the prompt is + * submitted and the creator commonly keeps typing, so the submission is often + * only visible in this older slot. + */ + previousInputAtMs?: number | null } /** One live client process as the binder sees it. */ @@ -241,13 +247,17 @@ function tieBreakByFreshLaunch( } /** - * Break a same-directory tie by who was actually submitting the prompt. - * Creating a session writes to that pane's PTY immediately beforehand — - * whether a human keystroke or a prompt Orca delivered host-side — so the - * candidate with the most recent pre-creation activity is the creator. A - * candidate with no recorded activity, or one whose last write fell outside - * the window, contributes nothing; candidates recorded at the identical - * instant tie and are both rejected rather than guessed between. + * Break a same-directory tie by which pane submitted the prompt. Creating a + * session writes to that pane's PTY immediately beforehand — a human keystroke + * or a prompt Orca delivered — so the candidate with the newest pre-creation + * activity is the creator. Both of a pane's stamps are consulted, because the + * creator will usually have typed again after submitting. + * + * A pane whose stamps all postdate the row has had its earlier history evicted, + * so it cannot be ruled out as the creator; crediting anyone else would be the + * wrong-pane result this exists to prevent, and the round abstains instead. A + * pane with no stamps at all is a launch candidate rather than an input one and + * is simply not considered here. */ function tieBreakByRecentInput( evidencing: readonly CorrelatedPane[], @@ -257,26 +267,33 @@ function tieBreakByRecentInput( let bestAt = Number.NEGATIVE_INFINITY let tied = false for (const pane of evidencing) { - const at = pane.lastInputAtMs - if (typeof at !== 'number' || Number.isNaN(at)) { + const stamps = [pane.lastInputAtMs, pane.previousInputAtMs].filter( + (value): value is number => typeof value === 'number' && !Number.isNaN(value) + ) + if (stamps.length === 0) { continue } - // Why abstain rather than skip: lastInputAtMs holds only the newest write, - // so a pane that typed the prompt and was then typed into again has had its - // pre-creation evidence overwritten. Skipping it would let a bystander - // become the unique in-window leader — the exact wrong-pane result this - // tie-break exists to prevent. - if (at > createdAtMs) { - return null + const preCreation = stamps.filter((value) => value <= createdAtMs) + if (preCreation.length === 0) { + if (stamps.length >= 2) { + // Every stamp we hold postdates the row, and two slots means at least + // two writes since. An earlier submission may have been evicted — + // unknowable, and not another pane's to claim. + return null + } + // A single post-creation write cannot have created the row: submitting is + // itself a write, and it would be the stamp we hold. + continue } - if (createdAtMs - at > OPENCODE_INPUT_TIEBREAK_WINDOW_MS) { + const activity = Math.max(...preCreation) + if (createdAtMs - activity > OPENCODE_INPUT_TIEBREAK_WINDOW_MS) { continue } - if (at > bestAt) { + if (activity > bestAt) { best = pane - bestAt = at + bestAt = activity tied = false - } else if (at === bestAt) { + } else if (activity === bestAt) { tied = true } } @@ -289,13 +306,13 @@ function tieBreakByRecentInput( * immediately; otherwise exactly one pane must have both the directory and a * client that brackets the creation. When several panes qualify — the normal * case for split panes or sibling folder workspaces, which share a directory — - * two signals decide, in order: the pane whose client had just booted (which - * covers Orca-launched agents, whose prompt rides on the spawn command and - * leaves their pane no keystroke), then the pane whose PTY received input just - * before the session appeared. Each must yield an unambiguous in-window - * leader. Anything still undecided stays unbound rather than guessed: a wrong - * owner shows the wrong pane spinning, which is the bug this resolves, not a - * milder version of it. + * two signals are computed: the pane whose client had just booted (which covers + * Orca-launched agents, whose prompt rides on the spawn command and leaves their + * pane no keystroke) and the pane whose PTY received input just before the + * session appeared. Each must yield an unambiguous in-window leader, and they + * must agree, before anything is bound. Anything still undecided stays unbound + * rather than guessed: a wrong owner shows the wrong pane spinning, which is the + * bug this resolves, not a milder version of it. */ export function correlateOpenCodeSessionOwners(args: { sessions: readonly CorrelatedSession[] @@ -390,10 +407,16 @@ export function correlateOpenCodeSessionOwners(args: { // Launch recency is consulted first: an Orca-launched agent carries its // first prompt on the spawn command, so the creating pane never writes a // keystroke and input alone would hand the session to a bystander. - const owner = - tieBreakByFreshLaunch(evidencing, clients, session.createdAtMs) ?? - tieBreakByRecentInput(evidencing, session.createdAtMs) - if (owner) { + const launchOwner = tieBreakByFreshLaunch(evidencing, clients, session.createdAtMs) + const inputOwner = tieBreakByRecentInput(evidencing, session.createdAtMs) + // Why disagreement abstains: a pane that just booted and a pane that was + // just written to explain this row equally well, and no evidence tells a + // launched creator apart from an innocent pane someone happened to open + // nearby. Picking a side would risk the wrong-pane result this file + // exists to prevent, so only agreement is acted on. + const agreed = launchOwner === null || inputOwner === null || launchOwner === inputOwner + const owner = launchOwner ?? inputOwner + if (owner && agreed) { claim(session.id, owner, 'creation-correlation') } continue From d970337dade11c62a98f80f38036f344d696a334 Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Fri, 25 Sep 2026 21:56:53 -0300 Subject: [PATCH 8/9] fix(opencode): let an eviction abstain outrank the launch signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tieBreakByRecentInput returned a bare null for two different meanings — "no in-window input leader" and "a candidate's history was evicted, so nobody may claim this session" — and the caller read both as agreement before letting the launch signal name a pane. A pane whose stamps were all evicted could therefore be bypassed by another pane's fresh client, taking a session the abstain says must stay unclaimed. Both probes now return an explicit outcome (owner | none | abstain), and the round stops on abstain before either signal is consulted. --- ...tener-opencode-session-correlation.test.ts | 21 ++++++++ .../opencode-session-correlation.ts | 50 +++++++++++++------ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts index 7c755d34e1a6..bf364ecf5e0a 100644 --- a/src/shared/agent-hook-listener-opencode-session-correlation.test.ts +++ b/src/shared/agent-hook-listener-opencode-session-correlation.test.ts @@ -317,6 +317,27 @@ describe('correlateOpenCodeSessionOwners', () => { expect(results).toEqual([]) }) + it('does not credit a launch when another pane history was evicted', () => { + // pane-b wrote twice after the row, so its earlier history is gone and it + // cannot be ruled out as the creator. pane-a's fresh client alone must not + // take the session — the eviction abstain outranks a launch signal. + const results = correlateOpenCodeSessionOwners({ + sessions: [session('ses_1', NOW - 60_000)], + panes: [ + { paneKey: 'pane-a', directory: DIR }, + { + paneKey: 'pane-b', + directory: DIR, + lastInputAtMs: NOW - 10_000, + previousInputAtMs: NOW - 20_000 + } + ], + clients: [client('pane-a', NOW - 65_000), client('pane-b', NOW - 86_400_000)], + knownOwners: new Map() + }) + expect(results).toEqual([]) + }) + it('binds a fresh launch when no write disagrees', () => { const results = correlateOpenCodeSessionOwners({ sessions: [session('ses_1', NOW - 60_000)], diff --git a/src/shared/agent-hook-listener/opencode-session-correlation.ts b/src/shared/agent-hook-listener/opencode-session-correlation.ts index 967bf43aadfb..73875cefbc81 100644 --- a/src/shared/agent-hook-listener/opencode-session-correlation.ts +++ b/src/shared/agent-hook-listener/opencode-session-correlation.ts @@ -196,6 +196,15 @@ function clientCouldCreate(client: CorrelatedClient, createdAtMs: number): boole ) } +/** + * Outcome of one evidence probe. `abstain` is not the same as `none`: it means + * a candidate's history was evicted so it cannot be ruled out as the creator, + * and therefore no other pane may be credited from the other signal either. + */ +type TieBreakOutcome = { kind: 'owner'; paneKey: string } | { kind: 'none' } | { kind: 'abstain' } + +const NO_OWNER: TieBreakOutcome = { kind: 'none' } + /** * Break a same-directory tie on the pane whose OpenCode client had just * started. This is the only signal available when the creator was launched by @@ -207,7 +216,7 @@ function tieBreakByFreshLaunch( evidencing: readonly CorrelatedPane[], clients: readonly CorrelatedClient[], createdAtMs: number -): string | null { +): TieBreakOutcome { let best: CorrelatedPane | null = null let bestStart = Number.NEGATIVE_INFINITY let tied = false @@ -243,7 +252,7 @@ function tieBreakByFreshLaunch( tied = true } } - return best && !tied ? best.paneKey : null + return best && !tied ? { kind: 'owner', paneKey: best.paneKey } : NO_OWNER } /** @@ -255,14 +264,15 @@ function tieBreakByFreshLaunch( * * A pane whose stamps all postdate the row has had its earlier history evicted, * so it cannot be ruled out as the creator; crediting anyone else would be the - * wrong-pane result this exists to prevent, and the round abstains instead. A - * pane with no stamps at all is a launch candidate rather than an input one and - * is simply not considered here. + * wrong-pane result this exists to prevent, and the round abstains instead — for + * the launch signal too, which is why `abstain` is distinct from `none`. A pane + * with no stamps at all is a launch candidate rather than an input one and is + * simply not considered here. */ function tieBreakByRecentInput( evidencing: readonly CorrelatedPane[], createdAtMs: number -): string | null { +): TieBreakOutcome { let best: CorrelatedPane | null = null let bestAt = Number.NEGATIVE_INFINITY let tied = false @@ -279,7 +289,7 @@ function tieBreakByRecentInput( // Every stamp we hold postdates the row, and two slots means at least // two writes since. An earlier submission may have been evicted — // unknowable, and not another pane's to claim. - return null + return { kind: 'abstain' } } // A single post-creation write cannot have created the row: submitting is // itself a write, and it would be the stamp we hold. @@ -297,7 +307,7 @@ function tieBreakByRecentInput( tied = true } } - return best && !tied ? best.paneKey : null + return best && !tied ? { kind: 'owner', paneKey: best.paneKey } : NO_OWNER } /** @@ -404,19 +414,27 @@ export function correlateOpenCodeSessionOwners(args: { ) const [only] = evidencing if (evidencing.length !== 1 || !only) { - // Launch recency is consulted first: an Orca-launched agent carries its - // first prompt on the spawn command, so the creating pane never writes a - // keystroke and input alone would hand the session to a bystander. - const launchOwner = tieBreakByFreshLaunch(evidencing, clients, session.createdAtMs) - const inputOwner = tieBreakByRecentInput(evidencing, session.createdAtMs) + // Launch recency covers an Orca-launched agent, which carries its first + // prompt on the spawn command and so never writes a keystroke; input + // recency covers a prompt typed or dispatched in a shared directory. + const launch = tieBreakByFreshLaunch(evidencing, clients, session.createdAtMs) + const input = tieBreakByRecentInput(evidencing, session.createdAtMs) + // An evicted candidate cannot be ruled out as the creator, so no other + // pane may be credited — not even by a launch that would otherwise stand + // uncontradicted. + if (input.kind === 'abstain') { + continue + } // Why disagreement abstains: a pane that just booted and a pane that was // just written to explain this row equally well, and no evidence tells a // launched creator apart from an innocent pane someone happened to open // nearby. Picking a side would risk the wrong-pane result this file // exists to prevent, so only agreement is acted on. - const agreed = launchOwner === null || inputOwner === null || launchOwner === inputOwner - const owner = launchOwner ?? inputOwner - if (owner && agreed) { + const disagrees = + launch.kind === 'owner' && input.kind === 'owner' && launch.paneKey !== input.paneKey + const owner = + launch.kind === 'owner' ? launch.paneKey : input.kind === 'owner' ? input.paneKey : null + if (owner && !disagrees) { claim(session.id, owner, 'creation-correlation') } continue From edbbfd8fff15317d320b58cd2ad9dd61833d28cc Mon Sep 17 00:00:00 2001 From: Pablo Werlang Date: Thu, 1 Oct 2026 00:04:56 -0300 Subject: [PATCH 9/9] fix(opencode): follow main's inputKind and pane-snapshot fields after the main merge --- .../agent-hooks/server-opencode-binder.test.ts | 18 ++++++++++++++++-- src/main/ipc/pty/ipc/write-input.test.ts | 2 +- .../agent-prompt-submission-runtime.test.ts | 8 ++++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts index 1525b519bd20..eec624856130 100644 --- a/src/main/agent-hooks/server-opencode-binder.test.ts +++ b/src/main/agent-hooks/server-opencode-binder.test.ts @@ -253,8 +253,22 @@ describe('OpenCode 1 serve + attach', () => { now: () => Date.now(), dbPath: () => dbPath, listPanes: () => [ - { paneKey: PANE_A, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 111 }, - { paneKey: PANE_B, directory: DIR, worktreeId: `repo::${DIR}`, shellPid: 211 } + { + paneKey: PANE_A, + directory: DIR, + worktreeId: `repo::${DIR}`, + shellPid: 111, + lastInputAtMs: null, + previousInputAtMs: null + }, + { + paneKey: PANE_B, + directory: DIR, + worktreeId: `repo::${DIR}`, + shellPid: 211, + lastInputAtMs: null, + previousInputAtMs: null + } ], sweep: async () => [ { pid: 112, ppid: 111, startedAtMs, executable: 'opencode', argv: ['opencode', 'serve'] }, diff --git a/src/main/ipc/pty/ipc/write-input.test.ts b/src/main/ipc/pty/ipc/write-input.test.ts index ff6196677da6..2f976943b0fb 100644 --- a/src/main/ipc/pty/ipc/write-input.test.ts +++ b/src/main/ipc/pty/ipc/write-input.test.ts @@ -23,7 +23,7 @@ function writePtyInput(data: string, ptyId: string = PTY_ID) { return createPtyWriteInput({ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture is only read through isDestroyed(), webContents.isDestroyed() and webContents.send, all three of which it provides; Electron's BrowserWindow cannot be constructed outside a running app. mainWindow: mainWindow as never - }).writePtyInput({ id: ptyId, data }) + }).writePtyInput({ id: ptyId, data, inputKind: 'driving' }) } /** Prompt activity the OpenCode session binder reads; absent until one arrives. */ diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index cc290fd9ffe1..86d7c63afe1b 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -77,7 +77,9 @@ describe('agent prompt submission runtime', () => { } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) await vi.runAllTimersAsync() await expect(submission).resolves.toMatchObject({ accepted: true }) @@ -107,7 +109,9 @@ describe('agent prompt submission runtime', () => { runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) } }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') await vi.runAllTimersAsync()