Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/main/agent-hooks/local-tmux-managed-pty.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createLocalTmuxManagedPtyResolver } from './local-tmux-managed-pty'
import type { PtyProcessInfo } from '../providers/types'

const row: PtyProcessInfo = {
id: 'pty',
incarnationId: 'generation',
rootProcessId: 123,
cwd: '/workspace',
title: 'shell',
worktreeId: 'folder:workspace'
}
afterEach(() => {
vi.useRealTimers()
})

describe('authenticated local tmux root resolution', () => {
it('shares inventory captures and accepts only the current daemon binding', async () => {
const listProcesses = vi.fn(async () => [row])
const resolve = createLocalTmuxManagedPtyResolver({ getPtyId: () => 'pty', listProcesses })
const [first, second] = await Promise.all([resolve('pane'), resolve('pane')])
expect(first).toEqual({
pid: 123,
incarnation: 'generation',
scope: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'folder:workspace',
workspaceKind: 'folder'
}
})
expect(second).toEqual(first)
expect(listProcesses).toHaveBeenCalledTimes(1)
})
it('refuses a binding changed during capture and excludes WSL roots from a native probe', async () => {
let binding = 'pty'
const resolve = createLocalTmuxManagedPtyResolver({
getPtyId: () => binding,
listProcesses: async () => {
binding = 'replacement'
return [row]
}
})
expect(await resolve('pane')).toBeNull()
const wsl = createLocalTmuxManagedPtyResolver({
getPtyId: () => 'pty',
listProcesses: async () => [{ ...row, wslDistro: 'Ubuntu-24.04' }]
})
expect(await wsl('pane')).toBeNull()
})
it('refreshes expired inventory instead of assigning a reused PID to the old incarnation', async () => {
vi.useFakeTimers()
vi.setSystemTime(1000)
const listProcesses = vi
.fn()
.mockResolvedValueOnce([row])
.mockResolvedValueOnce([{ ...row, incarnationId: 'replacement' }])
const resolve = createLocalTmuxManagedPtyResolver({ getPtyId: () => 'pty', listProcesses })
expect((await resolve('pane'))?.incarnation).toBe('generation')
vi.setSystemTime(2001)
expect((await resolve('pane'))?.incarnation).toBe('replacement')
})
})
56 changes: 56 additions & 0 deletions src/main/agent-hooks/local-tmux-managed-pty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { PtyProcessInfo } from '../providers/types'
import type { TmuxManagedPty } from '../../shared/tmux-agent-hook-owner'

/** Reuse the daemon's authenticated inventory; never resolve an SSH pane from the client. */
export function createLocalTmuxManagedPtyResolver(options: {
getPtyId: (paneKey: string) => string | undefined | null
listProcesses: () => Promise<PtyProcessInfo[]>
}): (paneKey: string) => Promise<TmuxManagedPty | null> {
let pending: Promise<PtyProcessInfo[]> | undefined
let inventory: PtyProcessInfo[] = []
let capturedAt = -Infinity
const read = async (): Promise<PtyProcessInfo[]> => {
if (Date.now() - capturedAt < 1000) {
return inventory
}
if (!pending) {
pending = options
.listProcesses()
.then((rows) => {
inventory = rows
capturedAt = Date.now()
return rows
})
.finally(() => {
pending = undefined
})
}
return pending
}
return async (paneKey) => {
if (process.platform === 'win32') {
return null
}
const id = options.getPtyId(paneKey)
if (!id) {
return null
}
const row = (await read()).find((entry) => entry.id === id)
if (!row?.rootProcessId || !row.incarnationId || !row.worktreeId || row.wslDistro) {
return null
}
if (options.getPtyId(paneKey) !== id) {
return null
}
return {
pid: row.rootProcessId,
incarnation: row.incarnationId,
scope: {
executionHostId: 'local',
wslDistro: null,
workspaceId: row.worktreeId,
workspaceKind: row.worktreeId.startsWith('folder:') ? 'folder' : 'git-worktree'
}
}
}
}
70 changes: 70 additions & 0 deletions src/main/agent-hooks/server-tmux-canonical.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { createHash } from 'node:crypto'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer, _internals } from './server'
import { postHookEvent } from './server.test-fixtures'
import {
TMUX_TEST_PANE,
TMUX_TEST_ROOT,
TMUX_TEST_ROWS,
tmuxTestBody
} from '../../shared/tmux-status.test-fixture'

vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) }))
const probe = vi.hoisted(() => vi.fn())
vi.mock('../../shared/tmux-host-attachment-probe', () => ({ probeTmuxHostAttachments: probe }))
class CanonicalTestServer extends AgentHookServer {
hasLegacyRow(): boolean {
return this.state.lastStatusByPaneKey.has(TMUX_TEST_PANE)
}
}
let server: CanonicalTestServer
beforeEach(async () => {
_internals.resetCachesForTests()
probe.mockResolvedValue({ clients: [{ pid: 101, pane: '%0' }], rows: TMUX_TEST_ROWS })
server = new CanonicalTestServer()
server.setTmuxManagedPtyResolver(async () => TMUX_TEST_ROOT)
await server.start({ env: 'production' })
})
afterEach(() => server.stop())
describe('main canonical tmux projection', () => {
it('publishes one canonical outer row, blocks legacy overwrites, and cleans the exact outer and inner subjects', async () => {
expect((await postHookEvent(server, tmuxTestBody(), '/hook/opencode')).status).toBe(204)
expect(server.hasLegacyRow()).toBe(false)
expect(
server.attestCompatibilityAuthority({
paneKey: TMUX_TEST_PANE,
launchTokenHash: createHash('sha256').update('generation').digest('hex'),
connectionId: null,
terminalProvenance: 'current_runtime'
})
).toEqual({ paneKey: TMUX_TEST_PANE, source: 'current_hook' })
expect(server.getCanonicalStatusSnapshot().parents).toHaveLength(2)
expect(server.getStatusSnapshot()).toMatchObject([{ paneKey: TMUX_TEST_PANE, state: 'done' }])
server.ingestTerminalStatus({
paneKey: TMUX_TEST_PANE,
worktreeId: 'workspace',
payload: { state: 'working', prompt: 'untrusted repaint', agentType: 'opencode' }
})
expect(server.hasLegacyRow()).toBe(false)
expect(server.getStatusSnapshot()[0]?.state).toBe('done')
server.clearPaneState(TMUX_TEST_PANE)
expect(server.getCanonicalStatusSnapshot().parents).toHaveLength(0)
expect(server.getStatusSnapshot()).toHaveLength(0)
})
it('removes canonical tmux status on certified retirement', async () => {
await postHookEvent(server, tmuxTestBody(), '/hook/opencode')
server.retirePaneAuthority(TMUX_TEST_PANE)
expect(server.getStatusSnapshot()).toHaveLength(0)
expect(server.getCanonicalStatusSnapshot().parents).toHaveLength(0)
})
it('dismisses a canonical projection without creating a legacy status copy', async () => {
await postHookEvent(server, tmuxTestBody(), '/hook/opencode')
server.dropStatusEntry(TMUX_TEST_PANE, { preserveResumeIdentity: false })
expect(server.hasLegacyRow()).toBe(false)
expect(server.getStatusSnapshot()).toHaveLength(0)
expect(server.getCanonicalStatusSnapshot().parents).toHaveLength(2)
server.dropStatusEntriesByTabPrefix('tab-tmux')
expect(server.getCanonicalStatusSnapshot().parents).toHaveLength(0)
})
})
79 changes: 79 additions & 0 deletions src/main/agent-hooks/server-tmux-wire.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer, _internals } from './server'
import { PANE } from './server.test-fixtures'
import { buildRelayHookEnvelope } from '../../relay/agent-hook-envelope-build'

vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) }))
const time = 1_800_000_000_000
const envelope = {
source: 'opencode',
paneKey: PANE,
worktreeId: 'workspace',
connectionId: null,
launchToken: 'generation',
payload: { agentType: 'opencode', state: 'waiting', prompt: 'approval' }
} as const
const unavailable = { ...envelope, statusUnavailable: true, payload: null } as const

describe('optional tmux projection wire metadata', () => {
let server: AgentHookServer
beforeEach(() => {
_internals.resetCachesForTests()
vi.useFakeTimers()
vi.setSystemTime(time)
server = new AgentHookServer()
})
afterEach(() => {
server.stop()
vi.useRealTimers()
})
it('preserves host evidence age while keeping local delivery order current', () => {
server.ingestRemote({ ...envelope, evidenceAgeMs: 60_000 }, 'connection')
const row = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE)
expect(row?.evidenceObservedAt).toBe(time - 60_000)
expect(row?.receivedAt).toBe(time)
vi.setSystemTime(time + 10_000)
server.ingestRemote({ ...envelope, evidenceAgeMs: 70_000, isReplay: true }, 'connection')
expect(
server.getStatusSnapshot().find((entry) => entry.paneKey === PANE)?.evidenceObservedAt
).toBe(time - 60_000)
})
it('clears a selected projection only for the matching host, workspace and generation', () => {
server.ingestRemote(envelope, 'connection')
server.ingestRemote(unavailable, 'old-connection')
server.ingestRemote({ ...unavailable, launchToken: 'old-generation' }, 'connection')
server.ingestRemote({ ...unavailable, worktreeId: 'foreign' }, 'connection')
expect(server.getStatusSnapshot()).toHaveLength(1)
server.ingestRemote(unavailable, 'connection')
expect(server.getStatusSnapshot()).toHaveLength(0)
})
it('accepts old relay envelopes and ignores unknown null payloads without fabricating Done', () => {
server.ingestRemote(envelope, 'connection')
expect(server.getStatusSnapshot()[0]?.evidenceObservedAt).toBe(time)
server.ingestRemote({ ...unavailable, statusUnavailable: undefined }, 'connection')
expect(server.getStatusSnapshot()[0]?.state).toBe('waiting')
})
it('rejects invalid ages and never forwards absolute host clocks', () => {
for (const age of [-1, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, 1.5]) {
server.ingestRemote({ ...envelope, evidenceAgeMs: age }, 'connection')
}
expect(server.getStatusSnapshot()).toHaveLength(0)
const built = buildRelayHookEnvelope(
{ ...envelope, hostEvidenceObservedAt: time - 1000 },
'opencode'
)
expect(built.evidenceAgeMs).toBe(1000)
expect(built).not.toHaveProperty('hostEvidenceObservedAt')
vi.setSystemTime(time + 1000)
expect(
buildRelayHookEnvelope(
{ ...envelope, hostEvidenceObservedAt: time - 1000 },
'opencode',
undefined,
undefined,
{ isReplay: true }
).evidenceAgeMs
).toBe(2000)
})
})
1 change: 1 addition & 0 deletions src/main/agent-hooks/server/server-authority-fences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth
})
const hadStatus = retiredRows.length > 0
for (const key of paneKeys) {
this.clearTmuxInnerSubjects(key)
this.markPaneClosedForAgentStatus(key)
this.restartedStatusLaunchTokenHashByPaneKey.delete(key)
this.clearAssistantMessageRetry(key)
Expand Down
11 changes: 6 additions & 5 deletions src/main/agent-hooks/server/server-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,14 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
| EnrichedAgentHookEventPayload
| undefined
const canonical = existing ?? this.getTmuxSelectedStatus(resolvedPaneKey)
// Why: stateStartedAt pins the turn; the renderer's updatedAt is stamped at or after this
// receivedAt (runtime-sync and recovery paths use Date.now()/capturedAt), so a strictly
// newer cached event is the only replacement worth protecting.
if (
!existing ||
existing.stateStartedAt !== identity.stateStartedAt ||
existing.receivedAt > identity.receivedAt
!canonical ||
canonical.stateStartedAt !== identity.stateStartedAt ||
canonical.receivedAt > identity.receivedAt
) {
continue
}
Expand Down Expand Up @@ -167,7 +168,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen
* itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a
* different file. */
protected hasLiveClaimsForPaneKey(paneKey: string): boolean {
return paneHasStateClaims(this.state, paneKey)
return Boolean(this.getTmuxSelectedStatus(paneKey)) || paneHasStateClaims(this.state, paneKey)
}

/** Clear statuses proven to belong to one lost SSH transport. */
Expand Down Expand Up @@ -232,7 +233,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen
| EnrichedAgentHookEventPayload
| undefined
if (!existing) {
return null
return this.deleteTmuxSelectedStatus(resolvedPaneKey) ?? null
}
deleteLegacyAgentStatus(this.state, resolvedPaneKey)
this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey)
Expand Down
4 changes: 2 additions & 2 deletions src/main/agent-hooks/server/server-ingest-normalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { normalizeHookPayload } from '../../../shared/agent-hook-listener'
import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay'
import { isOpenCodeSharedServerPost } from '../../../shared/agent-hook-listener/opencode-session-registry'
import type { NormalizedLocalHook } from './server-types'
import { AgentHookServerOpenCodeBinder } from './server-opencode-binder'
import { AgentHookServerTmuxStatus } from './server-tmux-status'

export abstract class AgentHookServerIngestNormalization extends AgentHookServerOpenCodeBinder {
export abstract class AgentHookServerIngestNormalization extends AgentHookServerTmuxStatus {
protected setClaudeBackgroundEvidence(
paneKey: string,
hasRunningTask: boolean,
Expand Down
30 changes: 25 additions & 5 deletions src/main/agent-hooks/server/server-ingest-remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
claudeRunningNonAgentTask?: unknown
/** The producing peer's advertised run-capability set — a property of the peer/connection that built this envelope, not an orthogonal call parameter. Absent (older relay/HTTP paths) defaults to the unadvertised-legacy-peer set. */
advertisedAgentStatusCapabilities?: readonly string[]
statusUnavailable?: unknown
evidenceAgeMs?: unknown
payload: unknown
},
connectionId: string | null
Expand Down Expand Up @@ -138,6 +140,25 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
toolAgentType,
providerSession
} = normalizeRemoteEnvelopeFields(envelope)
if (envelope.statusUnavailable === true && envelope.payload === null) {
const previous = this.state.lastStatusByPaneKey.get(paneKey)
if (
(source === 'opencode' || source === 'opencode2') &&
previous?.connectionId === trimmedConnectionId &&
trimmedConnectionId !== null &&
previous.worktreeId === worktreeId &&
previous.source === source &&
previous.launchToken === envelope.launchToken &&
this.getAgentStatusDisposition(paneKey) !== 'suppress'
) {
this.clearPaneState(paneKey, { statusUnavailable: true })
}
return
}
const age = envelope.evidenceAgeMs
if (age !== undefined && (typeof age !== 'number' || !Number.isSafeInteger(age) || age < 0)) {
return
}
// Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed).
const validatedPayload = normalizeAgentStatusPayload(envelope.payload)
if (!validatedPayload) {
Expand Down Expand Up @@ -200,10 +221,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
if (hookEventName === 'PreCompact' || source !== 'claude') {
return
}
// Why: a relay predating this change strips `compactTrigger` from its cached PostCompact
// before replaying it, so the replay has no manual/auto discriminator. That relay's mapping is
// fixed and known — manual produced `done`, auto produced `working` — so the payload state
// stands in for the missing trigger. Trigger substitution only; ownership is still checked.
// Older relays omit the trigger; their state supplies it without bypassing ownership.
const effectiveTrigger = resolveLegacyCompactTrigger(compactTrigger, normalizedPayload.state)
// Why: an auto compact happens inside a turn that resumes and emits its own Stop. An older
// relay maps it to `working`, and this ingest applies the relay's payload verbatim — so
Expand Down Expand Up @@ -298,7 +316,9 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS
this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey)
}
}
: undefined
: undefined,
'hook',
typeof age === 'number' ? Math.max(0, Date.now() - age) : undefined
)
}
}
Loading
Loading