Files
orca/src/shared/agent-session-resume.ts
T
Neilandczzczz af4c3e2962 fix(jcode): decompose the four files jcode pushed over max-lines
Adding an agent tipped four modules past their line budget. AGENTS.md
forbids a `max-lines` disable or a per-file bump, so each is split on a
real seam rather than silenced:

- agent-catalog.tsx keeps `AgentIcon`, which 70+ files import, and the
  rows move out. The rows alone exceed the 300-line budget a `.ts` file
  gets, so they follow the primary/secondary split this repo already uses
  for commit-message agent specs.
- getAgentResumeArgv -> agent-resume-argv.ts, re-exported so the 18 call
  sites keep one import path.
- isDiscoverableSessionFile/pathSegments -> session-file-discovery.ts.
- remoteCodexSources -> remote-session-scanner-codex-sources.ts; Codex is
  the one remote agent with two CODEX_HOME roots.

Also:
- Records the readiness-census baseline jcode now needs. main added that
  gate while this branch was out; the fixture is the recorded 74-case
  matrix, not a hand-written one.
- Restores two entries a rebase resolution silently dropped from
  config/tsconfig.cli.json (gitlab/project-ref-parser,
  startup/shell-path-probe). Nothing to do with jcode; losing them was a
  conflict-resolution mistake on this branch.

Co-authored-by: czzczz <chanzrz_zbf@foxmail.com>
2026-10-03 01:49:40 -07:00

290 lines
9.8 KiB
TypeScript

import type { AgentHookSource } from './agent-hook-relay'
import type { AgentStatusState } from './agent-status-types'
import type { AgentMainAgentStatus } from './main-agent-status'
import type { TuiAgent } from './tui-agent'
export const RESUMABLE_TUI_AGENTS = [
'claude',
'codebuddy',
'codex',
'qoder',
'qoder-cn',
'qwen-code',
'gemini',
'antigravity',
'opencode',
'opencode2',
'pi',
'mimo-code',
'droid',
'grok',
'devin',
'omp',
'prime-agent',
'copilot',
'cursor',
'kimi',
'muse',
'zcode',
'dsh',
'jcode'
] as const satisfies readonly TuiAgent[]
export type ResumableTuiAgent = (typeof RESUMABLE_TUI_AGENTS)[number]
export type AgentProviderSessionKey = 'session_id' | 'conversation_id'
export type AgentProviderSessionMetadata = {
key: AgentProviderSessionKey
id: string
/** Authoritative on-disk transcript/rollout path reported by the agent's hook
* (Claude/Codex `transcript_path`), when available. Native chat reads this
* directly because recent Claude Code versions name the transcript file with a
* UUID that differs from the hook `session_id`, so reconstructing the path from
* `id` alone fails. Claude/Codex still resume by id; Pi uses its reported
* `session_file` as the authoritative `--session` resume locator. */
transcriptPath?: string
}
export type SleepingAgentLaunchConfig = {
agentCommand?: string
agentArgs: string
agentEnv: Record<string, string>
ompResumeFilePath?: string
}
export type SleepingAgentSessionRecord = {
paneKey: string
tabId?: string
worktreeId: string
agent: ResumableTuiAgent
providerSession: AgentProviderSessionMetadata
prompt: string
state: AgentStatusState
capturedAt: number
updatedAt: number
terminalTitle?: string
lastAssistantMessage?: string
interrupted?: boolean
/** The main agent's own status when captured, copied with `interrupted` by `agentVerdictFields`;
* read the verdict through `agentMainAgentVerdict`. */
mainAgent?: AgentMainAgentStatus
connectionId?: string | null
launchConfig?: SleepingAgentLaunchConfig
/** How the record was captured. Worktree-sleep records (legacy records have
* no origin) are consumed by worktree activation, which opens a fresh tab.
* Quit/live records describe panes that still exist in the restored session,
* so only the pane's own cold-restore path may consume them — activation
* launching a tab too would duplicate a warm-reattached session (#5232). */
origin?: 'worktree-sleep' | 'quit' | 'live'
/** Set on a finished pane captured by an explicit workspace sleep. Its
* `--resume` is issued by the pane's own cold restore when its tab is
* opened, so a mobile wake must not background-mount every such tab and
* respawn the whole workspace the user just slept (#11598). */
restoreOnTabOpenOnly?: boolean
}
const RESUMABLE_TUI_AGENT_SET: ReadonlySet<string> = new Set(RESUMABLE_TUI_AGENTS)
const PROVIDER_SESSION_ID_MAX_LENGTH = 512
export function hasUnsafeProviderSessionIdChars(value: string): boolean {
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i)
if (code <= 0x1f || code === 0x7f) {
return true
}
}
return false
}
function normalizeSessionId(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
if (
trimmed.length === 0 ||
trimmed.length > PROVIDER_SESSION_ID_MAX_LENGTH ||
trimmed.startsWith('-') ||
hasUnsafeProviderSessionIdChars(trimmed)
) {
return null
}
return trimmed
}
function readSessionId(record: Record<string, unknown>, keys: readonly string[]): string | null {
for (const key of keys) {
const normalized = normalizeSessionId(record[key])
if (normalized) {
return normalized
}
}
return null
}
/** The agent hook's authoritative transcript/rollout path, when present. Used by
* native chat to read the exact file rather than reconstructing it from the
* session id (which recent Claude Code no longer matches to the file name). */
function readTranscriptPathFromKeys(
record: Record<string, unknown>,
keys: readonly string[]
): string | undefined {
for (const key of keys) {
const raw = record[key]
if (typeof raw !== 'string') {
continue
}
const trimmed = raw.trim()
if (trimmed && !hasUnsafeProviderSessionIdChars(trimmed)) {
return trimmed
}
}
return undefined
}
function withTranscriptPath(
metadata: AgentProviderSessionMetadata,
payload: Record<string, unknown>,
keys: readonly string[] = ['transcript_path', 'transcriptPath']
): AgentProviderSessionMetadata {
const transcriptPath = readTranscriptPathFromKeys(payload, keys)
return transcriptPath ? { ...metadata, transcriptPath } : metadata
}
export function isResumableTuiAgent(value: unknown): value is ResumableTuiAgent {
return typeof value === 'string' && RESUMABLE_TUI_AGENT_SET.has(value)
}
export function normalizeAgentProviderSession(raw: unknown): AgentProviderSessionMetadata | null {
if (typeof raw !== 'object' || raw === null) {
return null
}
const record = raw as Record<string, unknown>
const key = record.key
if (key !== 'session_id' && key !== 'conversation_id') {
return null
}
const id = normalizeSessionId(record.id)
if (!id) {
return null
}
// Why: persisted/relay metadata crosses a trust boundary too; apply the same
// control-character rejection used for hook-reported transcript paths.
const transcriptPath = readTranscriptPathFromKeys(record, ['transcriptPath'])
return transcriptPath ? { key, id, transcriptPath } : { key, id }
}
/** Compare the provider-owned values that identify the CLI resume target.
* Pi-family transcript resumes use file identity; other agents use provider ids. */
export function agentProviderSessionsEqual(
agent: string | undefined,
left: AgentProviderSessionMetadata | undefined,
right: AgentProviderSessionMetadata | undefined
): boolean {
if (left === undefined || right === undefined) {
return left === right
}
return (
left.key === right.key &&
left.id === right.id &&
((agent !== 'pi' && agent !== 'prime-agent') || left.transcriptPath === right.transcriptPath)
)
}
export function extractAgentProviderSession(
source: AgentHookSource,
payload: Record<string, unknown>
): AgentProviderSessionMetadata | null {
switch (source) {
// Native-chat agents: also capture the hook's authoritative transcript_path,
// since recent Claude Code names the transcript file with a UUID that differs
// from the hook session_id (so the id-based glob no longer finds it).
case 'qoder-cn':
case 'qwen-code':
case 'qoder':
case 'codebuddy':
case 'claude':
case 'codex': {
const id = readSessionId(payload, ['session_id'])
return id ? withTranscriptPath({ key: 'session_id', id }, payload) : null
}
case 'gemini':
case 'droid':
// Why: Kimi Code posts a Claude-shaped `session_id` (e.g. session_<uuid>).
// falls through
case 'kimi': {
const id = readSessionId(payload, ['session_id'])
return id ? { key: 'session_id', id } : null
}
case 'muse': {
const id = readSessionId(payload, ['session_id'])
return id ? withTranscriptPath({ key: 'session_id', id }, payload) : null
}
// Why: ZCode's `transcript_path` is a per-invocation temp file it deletes when the hook
// returns (`createCompatibleHookStdin` mkdtemp + cleanup), so only the id is durable.
case 'zcode': {
const id = readSessionId(payload, ['session_id'])
return id ? { key: 'session_id', id } : null
}
// Why: DSH's hook bridge always sends an empty `transcript_path` (its persistence seam
// exposes no artifact path), so the session id alone carries the resume target.
case 'dsh': {
const id = readSessionId(payload, ['session_id'])
return id ? { key: 'session_id', id } : null
}
case 'antigravity': {
const id = readSessionId(payload, ['conversationId'])
return id ? { key: 'conversation_id', id } : null
}
case 'opencode':
case 'opencode2':
case 'mimo-code': {
const id = readSessionId(payload, ['sessionID'])
return id ? { key: 'session_id', id } : null
}
case 'pi':
case 'prime-agent': {
const id = readSessionId(payload, ['session_id'])
const providerSession = id
? withTranscriptPath({ key: 'session_id', id }, payload, ['session_file'])
: null
return providerSession?.transcriptPath ? providerSession : null
}
case 'grok': {
const id = readSessionId(payload, ['sessionId', 'session_id'])
return id ? { key: 'session_id', id } : null
}
case 'devin': {
const id = readSessionId(payload, ['session_id', 'sessionId'])
return id ? { key: 'session_id', id } : null
}
case 'jcode': {
const id = readSessionId(payload, ['session_id', 'sessionId'])
return id ? { key: 'session_id', id } : null
}
// OMP keeps id-based resume while optionally locating its native-chat transcript.
case 'omp': {
const id = readSessionId(payload, ['session_id'])
return id ? withTranscriptPath({ key: 'session_id', id }, payload, ['session_file']) : null
}
// Why: Copilot's hook `session_id` is also its `~/.copilot/session-state/<id>/`
// directory name, so the same id is the CLI's resume locator.
case 'copilot': {
const id = readSessionId(payload, ['session_id', 'sessionId'])
return id ? { key: 'session_id', id } : null
}
case 'cursor': {
const id = readSessionId(payload, ['conversation_id'])
return id ? { key: 'conversation_id', id } : null
}
case 'amp':
case 'command-code':
case 'hermes':
return null
}
}
// Re-exported so the 18 existing call sites keep one import path.
export { getAgentResumeArgv } from './agent-resume-argv'