diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts index eaa5dff073e2..eec624856130 100644 --- a/src/main/agent-hooks/server-opencode-binder.test.ts +++ b/src/main/agent-hooks/server-opencode-binder.test.ts @@ -66,7 +66,14 @@ 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, + previousInputAtMs: null + } ], sweep: async () => [ { @@ -246,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-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index f49978843d56..8c38c5e00b0f 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() @@ -167,7 +168,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..2f976943b0fb --- /dev/null +++ b/src/main/ipc/pty/ipc/write-input.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { listRegisteredPtys, registerPty, unregisterPty } from '../../../memory/pty-registry' +import { ptyOwnership } from '../provider/ownership-state' +import { createPtyWriteInput } from './write-input' + +const PTY_ID = 'pty-write-input-stamp' +/** Registered like any other pane, but the mocked provider below serves only PTY_ID. */ +const UNBACKED_PTY_ID = 'pty-write-input-unbacked' + +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, 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, inputKind: 'driving' }) +} + +/** 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 +} + +function register(ptyId: string): void { + registerPty({ ptyId, worktreeId: null, sessionId: null, paneKey: 'tab:leaf', pid: 1 }) +} + +beforeEach(() => { + ptyOwnership.set(PTY_ID, null) + ptyOwnership.set(UNBACKED_PTY_ID, null) + provider.write.mockReset() + register(PTY_ID) + register(UNBACKED_PTY_ID) +}) + +afterEach(() => { + ptyOwnership.delete(PTY_ID) + ptyOwnership.delete(UNBACKED_PTY_ID) + unregisterPty(PTY_ID) + unregisterPty(UNBACKED_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 = promptActivityAt(PTY_ID) + expect(stamped).toBeTypeOf('number') + 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(promptActivityAt(PTY_ID)).toBeUndefined() + expect(provider.write).toHaveBeenCalled() + }) + + it('leaves the pty unstamped when no provider can serve the write', () => { + // Reaches the provider lookup and gets nothing back, so no bytes are + // dispatched — the pane must not be credited with activity it never had. + expect(writePtyInput('hello', UNBACKED_PTY_ID)).toBe(false) + + expect(promptActivityAt(UNBACKED_PTY_ID)).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 2f8ea72cd9b0..abcd8dcfd4ff 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -7,6 +7,8 @@ import { isTerminalInputTooLargeWithDeferredMeasurement, iterateTerminalInputChunks } from '../../../../shared/terminal-input' +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 type { TerminalInputKind } from '../../../../shared/terminal-input-kind' @@ -29,6 +31,20 @@ export function isMainWindowPtyIpcEvent( export type PtyWritePayload = { id: string; data: string; inputKind: TerminalInputKind } 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?: PtyRendererDelivery runtime?: OrcaRuntimeService @@ -59,6 +75,9 @@ export function createPtyWriteInput(deps: { id: string, data: string ): 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) { diff --git a/src/main/memory/pty-registry.test.ts b/src/main/memory/pty-registry.test.ts new file mode 100644 index 000000000000..4adec7dfbd85 --- /dev/null +++ b/src/main/memory/pty-registry.test.ts @@ -0,0 +1,51 @@ +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('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)) + 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..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 @@ -26,6 +24,22 @@ 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 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 + /** + * 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() @@ -38,6 +52,20 @@ export function unregisterPty(ptyId: string): void { registry.delete(ptyId) } +/** + * 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 + } +} + /** 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..bfb52160a551 100644 --- a/src/main/opencode/opencode-session-binder.test.ts +++ b/src/main/opencode/opencode-session-binder.test.ts @@ -25,8 +25,20 @@ 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, + previousInputAtMs: number | null = null +): BinderPaneSnapshot { + return { + paneKey, + directory: DIR, + worktreeId: 'repo::/Users/jin/work/mocitec', + shellPid, + lastInputAtMs, + previousInputAtMs + } } describe('runOpenCodeBinderRound', () => { @@ -97,6 +109,130 @@ 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('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('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('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 }, @@ -161,7 +297,14 @@ 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, + previousInputAtMs: 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 0a693e6e928d..d3679c3f706c 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, @@ -35,6 +36,14 @@ export type BinderPaneSnapshot = { directory: string | null worktreeId: string | null shellPid: number | null + /** + * 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 + /** The stamp `lastInputAtMs` replaced; see `PtyRegistration`. */ + previousInputAtMs: number | null } /** One session store row feeding a binder round. */ @@ -157,6 +166,22 @@ function toCorrelatedClients( return clients } +/** + * 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. */ export function runOpenCodeBinderRound(deps: BinderRoundDeps): BinderRoundResult { // Why dedupe by key, newest wins: remints and reattachments can leave a @@ -166,7 +191,26 @@ 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) + // 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 && openCodeDirectoryMatches(previous.directory, pane.directory) + ? 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, + lastInputAtMs: carriedInput.last, + previousInputAtMs: carriedInput.previous + }) } const panes = [...paneByKey.values()] const clients = toCorrelatedClients(deps.processes, deps.panes, deps.nowMs) @@ -296,7 +340,9 @@ export function listBinderPaneSnapshots(): BinderPaneSnapshot[] { paneKey: pty.paneKey, directory: parsed?.worktreePath ?? null, worktreeId: pty.worktreeId, - shellPid: pty.pid + shellPid: pty.pid, + lastInputAtMs: pty.lastInputAtMs ?? null, + previousInputAtMs: pty.previousInputAtMs ?? null }) } return snapshots diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index 700445483a9b..86d7c63afe1b 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([ { @@ -52,6 +58,76 @@ 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', { + inputKind: 'driving' + }) + 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('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', { + inputKind: 'driving' + }) + 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 1597084eee15..be6ccf7539ae 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -20,6 +20,7 @@ import { resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' +import { notePtyInput } from '../memory/pty-registry' export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission { protected async writeTerminalAgentPrompt( @@ -65,6 +66,16 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso if (!this.ptyController?.write(ptyId, initialWrite, options.inputKind)) { 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. + // 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 @@ -106,6 +117,9 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind)) { 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) { 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..bf364ecf5e0a 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,256 @@ 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 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: [ + { 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('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, lastInputAtMs: NOW - 90_000 }, + { + paneKey: 'pane-b', + directory: DIR, + lastInputAtMs: NOW - 55_000, + previousInputAtMs: NOW - 61_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('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, lastInputAtMs: NOW - 61_000 } + ], + clients: [client('pane-a', NOW - 65_000), client('pane-b', NOW - 86_400_000)], + knownOwners: new Map() + }) + 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)], + panes: [ + { paneKey: 'pane-a', directory: DIR }, + { paneKey: 'pane-b', directory: DIR } + ], + 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('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 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: [ + { 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([]) + }) + + 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)], + 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..73875cefbc81 100644 --- a/src/shared/agent-hook-listener/opencode-session-correlation.ts +++ b/src/shared/agent-hook-listener/opencode-session-correlation.ts @@ -24,10 +24,24 @@ 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 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 + /** + * 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. */ @@ -52,6 +66,34 @@ 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 + +/** + * 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' /** @@ -110,6 +152,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) { @@ -140,12 +196,133 @@ 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 + * 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 +): TieBreakOutcome { + 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 + } + // 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 + } + } + if (newest === Number.NEGATIVE_INFINITY) { + 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 ? { kind: 'owner', paneKey: best.paneKey } : NO_OWNER +} + +/** + * 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 — 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 +): TieBreakOutcome { + let best: CorrelatedPane | null = null + let bestAt = Number.NEGATIVE_INFINITY + let tied = false + for (const pane of evidencing) { + const stamps = [pane.lastInputAtMs, pane.previousInputAtMs].filter( + (value): value is number => typeof value === 'number' && !Number.isNaN(value) + ) + if (stamps.length === 0) { + continue + } + 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 { 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. + continue + } + const activity = Math.max(...preCreation) + if (createdAtMs - activity > OPENCODE_INPUT_TIEBREAK_WINDOW_MS) { + continue + } + if (activity > bestAt) { + best = pane + bestAt = activity + tied = false + } else if (activity === bestAt) { + tied = true + } + } + return best && !tied ? { kind: 'owner', paneKey: best.paneKey } : NO_OWNER +} + /** * 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 — + * 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[] @@ -237,6 +414,29 @@ export function correlateOpenCodeSessionOwners(args: { ) const [only] = evidencing if (evidencing.length !== 1 || !only) { + // 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 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 } // Why the basis split: a lone containing pane needs no client evidence diff --git a/src/shared/agent-hook-listener/opencode-session-registry.ts b/src/shared/agent-hook-listener/opencode-session-registry.ts index 8fade351510d..0edcd32c0880 100644 --- a/src/shared/agent-hook-listener/opencode-session-registry.ts +++ b/src/shared/agent-hook-listener/opencode-session-registry.ts @@ -176,6 +176,8 @@ export function resolveOpenCodeSharedServerEnvelope(args: { body: unknown }): OpenCodeStampedEnvelope { const { state, source, stamped, sessionId } = args + // Why opencode2 passes through: Orca launches it with --standalone, so its + // server is private to the pane and the stamp already names the right pane. if (!isOpenCodeSharedServerPost(source, args.body) || !sessionId) { return stamped }