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.
This commit is contained in:
Brennan Benson
2026-08-04 15:57:43 -07:00
parent b3a4a4f929
commit 7caa28ea12
9 changed files with 158 additions and 36 deletions
+39
View File
@@ -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
}
+4
View File
@@ -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
@@ -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({
@@ -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(),
@@ -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')
})
})
+3 -33
View File
@@ -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'
+19
View File
@@ -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)
+3 -3
View File
@@ -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 {
+3
View File
@@ -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)
}