From 7caa28ea12c5b92980fab573eeaccb0404ae27e2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:57:43 -0700 Subject: [PATCH] feat(daemon): retain pane key as optional session metadata Daemon sessions already receive ORCA_PANE_KEY in the spawn env but drop it. Retain it on the Session and expose it additively in the session listing so upcoming daemon-hosted agent hook status can route bindings by pane. The env value is caller-supplied, so it is only retained when it parses as a real pane key (shape + length); anything else stores undefined rather than an arbitrary string. No protocol version bump: the field is optional and older adapters ignore unknown fields. Session-inventory types move to daemon-session-info.ts (re-exported from types.ts) so the listing shape can grow within the max-lines budget. --- src/main/daemon/daemon-session-info.ts | 39 ++++++++++ src/main/daemon/session.ts | 4 + .../daemon/terminal-host-session-create.ts | 12 +++ .../daemon/terminal-host-session-listing.ts | 1 + .../terminal-host-session-pane-key.test.ts | 74 +++++++++++++++++++ src/main/daemon/types.ts | 36 +-------- src/main/ipc/pty-management.test.ts | 19 +++++ src/shared/agent-hook-listener.ts | 6 +- src/shared/stable-pane-id.ts | 3 + 9 files changed, 158 insertions(+), 36 deletions(-) create mode 100644 src/main/daemon/daemon-session-info.ts create mode 100644 src/main/daemon/terminal-host-session-pane-key.test.ts diff --git a/src/main/daemon/daemon-session-info.ts b/src/main/daemon/daemon-session-info.ts new file mode 100644 index 00000000000..c7fad9756b3 --- /dev/null +++ b/src/main/daemon/daemon-session-info.ts @@ -0,0 +1,39 @@ +// Session-inventory shapes, split out of types.ts so the listing payload can +// grow without pushing that entry point over its line budget. Re-exported from +// `./types` so existing importers keep one entry point. +import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' + +// ─── Session State Machine ────────────────────────────────────────── +export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' + +export type ShellReadyState = 'pending' | 'ready' | 'timed_out' | 'unsupported' + +export type SessionInfo = { + sessionId: string + incarnationId?: string + state: SessionState + shellState: ShellReadyState + isAlive: boolean + terminalHandle?: string + /** Routing metadata for status bindings only, never an identity key — pane keys are reusable. */ + paneKey?: string + wslDistro?: string | null + pid: number | null + cwd: string | null + cols: number + rows: number + createdAt: number + agentSessionOwners?: AgentSessionOwnerBinding[] +} + +export type ListSessionsResult = { + sessions: SessionInfo[] +} + +// Why: SessionInfo + source protocol version, so the Manage Sessions UI can +// label legacy-backed sessions. Populated by the router/adapter at RPC time; +// never transmitted over the daemon wire (daemon only speaks its own +// protocol version and doesn't know about other versions). +export type DaemonSessionInfo = SessionInfo & { + protocolVersion: number +} diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index d9c5553d3e8..43c821476c3 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -80,6 +80,7 @@ export type SessionOptions = { cols: number rows: number terminalHandle?: string + paneKey?: string launchAgent?: TuiAgent subprocess: SubprocessHandle shellReadySupported: boolean @@ -104,6 +105,8 @@ export class Session { readonly sessionId: string readonly incarnationId = randomUUID() readonly terminalHandle: string | null + /** Routing metadata for status bindings only — never an identity key, since pane keys are reusable across sessions. */ + readonly paneKey: string | null readonly launchAgent: TuiAgent | null readonly wslDistro: string | null private _state: SessionState = 'running' @@ -136,6 +139,7 @@ export class Session { constructor(opts: SessionOptions) { this.sessionId = opts.sessionId this.terminalHandle = opts.terminalHandle ?? null + this.paneKey = opts.paneKey ?? null this.launchAgent = opts.launchAgent ?? null this.wslDistro = opts.wslDistro ?? null this.subprocess = opts.subprocess diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index dcc88fada05..f2c9f6fcf65 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -1,5 +1,6 @@ import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend' +import { MAX_PANE_KEY_LEN, parsePaneKey } from '../../shared/stable-pane-id' import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' import { normalizePtySize } from './daemon-pty-size' import { Session } from './session' @@ -23,6 +24,16 @@ type TerminalHostSessionCreateDependencies = { onSessionExit: (sessionId: string, generation: string | undefined) => void } +// Why: the spawn env is caller-supplied, so retain the pane key only when it +// parses as one — an arbitrary string under `paneKey` would look authoritative +// to every downstream reader. +function resolvePaneKeyMetadata(value: string | undefined): string | undefined { + if (!value || value.length > MAX_PANE_KEY_LEN || parsePaneKey(value) === null) { + return undefined + } + return value +} + export async function createOrAttachTerminalSession( opts: InternalCreateOrAttachOptions, deps: TerminalHostSessionCreateDependencies @@ -99,6 +110,7 @@ export async function createOrAttachTerminalSession( cols: size.cols, rows: size.rows, terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, + paneKey: resolvePaneKeyMetadata(opts.env?.ORCA_PANE_KEY), launchAgent: opts.launchAgent, subprocess, ownerBackend: resolvePtyOwnerBackend({ diff --git a/src/main/daemon/terminal-host-session-listing.ts b/src/main/daemon/terminal-host-session-listing.ts index 3ae2d5e845e..55fa85230ad 100644 --- a/src/main/daemon/terminal-host-session-listing.ts +++ b/src/main/daemon/terminal-host-session-listing.ts @@ -19,6 +19,7 @@ export function listLiveTerminalHostSessions( shellState: session.shellState, isAlive: true, ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), + ...(session.paneKey ? { paneKey: session.paneKey } : {}), wslDistro: session.wslDistro, pid: session.pid, cwd: session.getCwd(), diff --git a/src/main/daemon/terminal-host-session-pane-key.test.ts b/src/main/daemon/terminal-host-session-pane-key.test.ts new file mode 100644 index 00000000000..666d837c3a3 --- /dev/null +++ b/src/main/daemon/terminal-host-session-pane-key.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from './session' +import { TerminalHost } from './terminal-host' + +vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() })) + +const PANE_KEY = 'tab-1:1b8c2f10-4a3e-4b1c-9d2e-7f6a5b4c3d2e' + +function createSubprocess(): SubprocessHandle { + let onExit: ((code: number) => void) | null = null + return { + pid: 99_999, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => onExit?.(0)), + forceKill: vi.fn(() => onExit?.(137)), + signal: vi.fn(), + onData: () => {}, + onExit: (callback) => { + onExit = callback + }, + dispose: vi.fn() + } +} + +describe('TerminalHost session pane identity', () => { + let host: TerminalHost + + afterEach(async () => { + await host?.dispose() + }) + + async function listWithPaneEnv(paneKey?: string) { + host = new TerminalHost({ spawnSubprocess: () => createSubprocess() }) + await host.createOrAttach({ + sessionId: 'pane-test', + cols: 80, + rows: 24, + env: { + ORCA_TERMINAL_HANDLE: 'term_abc', + ...(paneKey === undefined ? {} : { ORCA_PANE_KEY: paneKey }) + }, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + return host.listSessions()[0] + } + + it('retains and lists a valid ORCA_PANE_KEY', async () => { + expect(await listWithPaneEnv(PANE_KEY)).toMatchObject({ + terminalHandle: 'term_abc', + paneKey: PANE_KEY + }) + }) + + // Why: an absent key must omit the field, not emit null — older adapters read + // the listing structurally and a null would read as "known to have no pane". + it('omits paneKey entirely when ORCA_PANE_KEY is absent', async () => { + const listed = await listWithPaneEnv(undefined) + expect(listed).not.toHaveProperty('paneKey') + expect(listed.terminalHandle).toBe('term_abc') + }) + + it.each([ + ['no delimiter', 'tab-1'], + ['non-UUID leaf', 'tab-1:pane-3'], + ['extra delimiter', 'tab-1:2:1b8c2f10-4a3e-4b1c-9d2e-7f6a5b4c3d2e'], + ['empty tabId', ':1b8c2f10-4a3e-4b1c-9d2e-7f6a5b4c3d2e'], + ['empty value', ''], + ['overlong', `${'t'.repeat(300)}:1b8c2f10-4a3e-4b1c-9d2e-7f6a5b4c3d2e`] + ])('drops a malformed ORCA_PANE_KEY (%s)', async (_label, value) => { + expect(await listWithPaneEnv(value)).not.toHaveProperty('paneKey') + }) +}) diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 865572ea0b1..4c9d78fb473 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -16,7 +16,6 @@ import type { TuiAgent } from '../../shared/types' import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress' import type { AgentSessionExecutionClaim, - AgentSessionOwnerBinding, AgentSessionSurfaceBinding } from '../../shared/agent-session-host-authority' import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol' @@ -38,10 +37,9 @@ export { supportsPtyStartupIngress } from './daemon-protocol-version' -// ─── Session State Machine ────────────────────────────────────────── -export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' - -export type ShellReadyState = 'pending' | 'ready' | 'timed_out' | 'unsupported' +// Session state machine + session-inventory shapes live in daemon-session-info.ts; +// re-exported so existing importers keep one types entry point. +export * from './daemon-session-info' // The on-disk checkpoint.json shape lives in daemon-checkpoint-file.ts (it // depends only on TerminalModes here) — re-exported so existing importers of @@ -341,10 +339,6 @@ export type GetSnapshotResult = { snapshot: TerminalSnapshot | null } -export type ListSessionsResult = { - sessions: SessionInfo[] -} - export type ShutdownIfIdleResult = { retiring: boolean } @@ -355,30 +349,6 @@ export type SystemResolverHealthResult = { health: SystemResolverHealth } -export type SessionInfo = { - sessionId: string - incarnationId?: string - state: SessionState - shellState: ShellReadyState - isAlive: boolean - terminalHandle?: string - wslDistro?: string | null - pid: number | null - cwd: string | null - cols: number - rows: number - createdAt: number - agentSessionOwners?: AgentSessionOwnerBinding[] -} - -// Why: SessionInfo + source protocol version, so the Manage Sessions UI can -// label legacy-backed sessions. Populated by the router/adapter at RPC time; -// never transmitted over the daemon wire (daemon only speaks its own -// protocol version and doesn't know about other versions). -export type DaemonSessionInfo = SessionInfo & { - protocolVersion: number -} - // Stream-socket event shapes live in daemon-stream-events.ts; re-exported so // existing importers keep one types entry point. export * from './daemon-stream-events' diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index 1586c566ffb..204aabafcb2 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -184,6 +184,25 @@ describe('pty:management IPC handlers', () => { expect(result.sessions.map((s) => s.sessionId)).toEqual(['preserved-1']) }) + // Why: paneKey ships as an additive optional field with no protocol bump, so + // the client parsing path must pass it through rather than shape-validate. + it('passes an unknown-to-older-clients paneKey through the client parsing path', async () => { + const paneKey = 'tab-1:1b8c2f10-4a3e-4b1c-9d2e-7f6a5b4c3d2e' + const current = makeAdapter(5, [makeSession('with-pane', { paneKey })]) + const { registerDaemonManagementHandlers } = await importFresh() + getDaemonProviderMock.mockReturnValue(await makeRouter(current)) + registerDaemonManagementHandlers() + + const handlers = buildHandlerMap() + const result = (await handlers['pty:management:listSessions']({})) as { + sessions: DaemonSessionInfo[] + } + + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ sessionId: 'with-pane', paneKey }) + expect(result.sessions[0]?.protocolVersion).toBe(5) + }) + it('clears degraded mode after durable fresh-spawn routing recovers', async () => { const current = makeAdapter(5, [makeSession('preserved-1')]) const provider = await makeDegradedProvider(current) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 985f95b1876..5e2e63e9668 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -59,7 +59,7 @@ import { extractAgentProviderSession, type AgentProviderSessionMetadata } from './agent-session-resume' -import { parsePaneKey } from './stable-pane-id' +import { MAX_PANE_KEY_LEN, parsePaneKey } from './stable-pane-id' import { isCompactContinuationUserTurnText, isKnownHarnessInjectedUserTurnText @@ -104,8 +104,8 @@ function capOpenCodeHookText(text: string): string { : text } -/** Bound paneKey size (real keys are well under 200); caps per-pane caches against pathological input. Exported so non-HTTP ingest (`ingestRemote`) applies the same cap as defense-in-depth. */ -export const MAX_PANE_KEY_LEN = 200 +// Re-exported so existing importers (`ingestRemote` and the hook server) keep one entry point. +export { MAX_PANE_KEY_LEN } const CLAUDE_PROMPT_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i export function normalizeClaudePromptId(value: unknown): string | undefined { diff --git a/src/shared/stable-pane-id.ts b/src/shared/stable-pane-id.ts index a68117f3d74..eae48f4c62a 100644 --- a/src/shared/stable-pane-id.ts +++ b/src/shared/stable-pane-id.ts @@ -11,6 +11,9 @@ export type StablePaneId = string & { readonly [stablePaneIdBrand]: true } export type TerminalLeafId = StablePaneId & { readonly [terminalLeafIdBrand]: true } export type PaneKey = string & { readonly [paneKeyBrand]: true } +/** Bound paneKey size (real keys are well under 200); caps per-pane caches and untrusted ingest against pathological input. Lives here so validators can cap before `parsePaneKey` without pulling in the hook-listener graph. */ +export const MAX_PANE_KEY_LEN = 200 + export function isStablePaneId(value: string): value is StablePaneId { return UUID_RE.test(value) }