// ─── Explicit agent status (reported via native agent hooks → IPC) ────────── // Why: status comes from hooks (Claude, Codex, etc.) — never inferred from terminal titles; // a narrow interrupt fallback synthesizes a final `done` when an agent misses its cancellation hook. import type { ProgramStatusFacet } from './program-status/facet' import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { AgentMainAgentStatus } from './main-agent-status' import type { AgentStateHistoryEntry } from './agent-state-history' import { isAgentTurnOutcome } from './agent-turn-outcome' import type { OrchestrationFleetAttention } from './orchestration-fleet-attention' import type { AgentStatusRowFacets } from './agent-status-observation' import type { AgentChildWorkView } from './agent-status-child-work-view' import type { TerminalAgent } from './terminal-agent' import { AGENT_MODEL_MAX_LENGTH, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH, normalizeInteractivePromptField, normalizeOptionalField, normalizeOptionalMultilineField, normalizePromptField, normalizeTurnCompletedAtField } from './agent-status-field-normalization' import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' import { normalizeAgentSubagentsField, type AgentSubagentSnapshot } from './agent-status-subagent-snapshot' export { AGENT_MODEL_MAX_LENGTH, AGENT_STATUS_MAX_FIELD_LENGTH, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH } from './agent-status-field-normalization' export { agentSubagentsEqual, type AgentSubagentSnapshot, type AgentSubagentState } from './agent-status-subagent-snapshot' export type { AgentStatusCacheIdentity, AgentStatusClearIpcPayload, AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from './agent-status-ipc-payload' export { mainAgentStatusEqual, type AgentMainAgentStatus } from './main-agent-status' export { AGENT_STATE_HISTORY_MAX, type AgentStateHistoryEntry } from './agent-state-history' export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number] export type AgentWorkingMode = 'monitoring' // Why: agent types aren't a fixed set (custom agents exist); any non-empty string is // accepted — the well-known names are the recognized TerminalAgent ids plus the 'unknown' // sentinel (no agent identified yet), a convenience union for pattern-matching. export type WellKnownAgentType = TerminalAgent | 'unknown' export type AgentType = WellKnownAgentType | (string & {}) export type AgentStatusOrchestrationContext = { taskId: string dispatchId: string /** Runtime-authoritative lifecycle state. Hook-only contexts may omit it. */ dispatchStatus?: 'pending' | 'dispatched' | 'completed' | 'failed' | 'circuit_broken' taskTitle?: string displayName?: string parentTerminalHandle?: string parentPaneKey?: string coordinatorHandle?: string orchestrationRunId?: string /** Durable orchestration categories combined with the current push-fed status observation. */ attention?: OrchestrationFleetAttention } export type AgentStatusEntry = { /** Renderer-local status-feed confirmation for children; absent on hook rows. */ subagentObservation?: 'live' | 'unverifiable' state: AgentStatusState /** Ongoing work that does not require foreground agent execution. Only valid while working. */ workingMode?: AgentWorkingMode /** The user's most recent prompt. Cached across the turn — later tool-use events * omit it, so the last value persists until a new prompt or pane reset. Empty when unknown. */ prompt: string /** Timestamp (ms) of the last status update. */ updatedAt: number /** Timestamp (ms) the reported evidence was first observed. Separate from `updatedAt`, * which is the delivery/ordering clock a relay reconnect must restamp to stay monotonic. * Absent for locally derived rows and old hosts; freshness falls back to `updatedAt`. */ evidenceObservedAt?: number /** True only while a host-held structured session is represented by its live status feed. */ structuredHostOwned?: true /** Timestamp (ms) when the current `state` was first reported. * Why: separate from updatedAt so tool/prompt pings (which reset updatedAt) don't move it. */ stateStartedAt: number /** `updatedAt` of the write that switched into `state`; see AgentStateHistoryEntry.observedAt. */ stateObservedAt?: number /** When the main agent's current turn began, as the hook server stamped it. Absent from old hosts * and writers without a turn clock; readers fall back to `stateStartedAt`. */ turnStartedAt?: number agentType?: AgentType /** Provider model currently used by this session. */ model?: string /** Command installed by the running OMP extension; absent on older hosts. */ modelSwitchCommand?: 'orca-model' /** Composite key: `${tabId}:${leafId}` where leafId is a stable UUID layout leaf. */ paneKey: string /** Runtime terminal handle for matching retained parent rows when the parent * pane key cannot be re-derived after terminal teardown. */ terminalHandle?: string /** Worktree attribution stamped by main when a hook resolves there. * Why: orchestration workers can report before their tab exists in a renderer, so retaining this keeps them attributed instead of dropped. */ worktreeId?: string /** Accepted transport authority for this live row; null means local. */ connectionId?: string | null /** Tab attribution from the hook IPC payload, when available. */ tabId?: string terminalTitle?: string /** Rolling log of previous states, capped at AGENT_STATE_HISTORY_MAX. */ stateHistory: AgentStateHistoryEntry[] /** Name of the tool the agent is currently using (e.g. "Edit", "Bash"). */ toolName?: string /** Short preview of the tool input (e.g. file path, command). */ toolInput?: string /** JSON of the AskUserQuestion tool input, captured live; unlike toolInput it's not * truncated (clients render the full card). Cleared once the agent moves on so a stale prompt can't linger. */ interactivePrompt?: string /** Most recent assistant message preview, when the hook carried one. */ lastAssistantMessage?: string /** True when `lastAssistantMessage` came from a tool result/error, not assistant prose. * Status/dashboard surfaces still render it; native chat's streaming bubble must not, * or a tool's stdout is shown as the agent's reply. */ lastAssistantMessageIsToolOutput?: boolean /** Output of the newest completed (non-boundary) turn, kept across the next `working`. * Why: batched publications can fold a whole done→working turn into one notification, * so `lastAssistantMessage` is already cleared by the time a subscriber observes it. */ lastCompletedAssistantMessage?: string /** True when this `done` was reached via interrupt, not normal completion * (agent-reported or Orca's guarded fallback). Undefined otherwise. */ interrupted?: boolean /** True when this `done` is a session boundary, not a completed turn. See AgentStatusPayload. */ sessionBoundary?: boolean /** Orchestration dispatch context for panes spawned by another agent. * Why: parent/child hierarchy is pane-level state, not worktree lineage — workers often share the coordinator's worktree. */ orchestration?: AgentStatusOrchestrationContext /** Live in-process subagents/teammates of this pane's session. Absent when * none are tracked; the sidebar derives indented child rows from it. */ subagents?: AgentSubagentSnapshot[] /** The main agent's own state; absent from old hosts and from writers that carry no main agent fact * (OSC, launch seeds), where readers fall back to `state`. */ mainAgent?: AgentMainAgentStatus /** The host's child-work views for this session, when it publishes them; child rows then read * these instead of `subagents`. Absent from older hosts. */ children?: AgentChildWorkView[] /** Provider-owned conversation/session id captured from hook payloads. * Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */ providerSession?: AgentProviderSessionMetadata /** False when the status belongs to a non-terminal owner that restores itself. */ terminalResumeEligible?: false /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string /** The program's own OSC 7501 report (progress, message, blocks like sign-in), beside whichever * producer owns `state`. Never decides `state`; readers resolve it through * resolveProgramStatusPresentation. Live-only. */ programStatus?: ProgramStatusFacet /** True for a nonterminal state hydrated from last-status.json with no live hook since: * the transition may have been missed while no receiver was up, so freshness gates * treat the row as stale immediately. Cleared by any accepted live event. */ restoredUnconfirmed?: boolean } & AgentStatusRowFacets // ─── Agent status payload shape (what hook receivers send via IPC) ────────── // Hook integrations provide only normalized state fields; the renderer fills the rest (updatedAt, paneKey, …) on IPC receipt. export type AgentStatusPayload = { state: AgentStatusState /** Ongoing work that does not require foreground agent execution. Only valid while working. */ workingMode?: AgentWorkingMode prompt?: string agentType?: AgentType model?: string modelSwitchCommand?: 'orca-model' toolName?: string toolInput?: string /** JSON string of the AskUserQuestion tool input, captured live. See the * AgentStatusEntry field for semantics. Not truncated like toolInput. */ interactivePrompt?: string lastAssistantMessage?: string /** See the AgentStatusEntry field for semantics. */ lastAssistantMessageIsToolOutput?: boolean interrupted?: boolean /** True when this `done` marks a session boundary (connect/resume/clear landing idle, * e.g. Claude SessionStart — STA-3386), not a completed turn. Consumers that react to * completions (notifications, automation runs, unread badges, finished timestamps) * must ignore it. Only meaningful on `done`. */ sessionBoundary?: boolean /** Wall-clock ms when the lead turn ended while Claude background inventory kept the pane `working`. * `stateStartedAt` stays pinned for that whole working run, so this is the per-turn identity. * Present on the gated `working` row and that turn's later all-clear `done`. Event-only — not stored on AgentStatusEntry. */ turnCompletedAt?: number /** Live in-process children of the reporting session. See AgentStatusEntry. */ subagents?: AgentSubagentSnapshot[] /** The main agent's own state and last-turn verdict. See AgentMainAgentStatus. Producers publish it * beside the combined `state`; a reader that predates it keeps reading `state`. */ mainAgent?: AgentMainAgentStatus /** The execution host awaits a launched Claude task’s wake-up or its finishing turn. */ claudeTaskWakeupPending?: 'notification' | 'finishing-turn' } /** * Result of `parseAgentStatusPayload`: prompt is always a string (empty when omitted) so * consumers needn't nullish-coalesce; tool/assistant fields stay optional to distinguish * absence ("no new info") from an explicit empty string. */ export type ParsedAgentStatusPayload = Omit & { prompt: string } /** * Narrow an `AgentStatusIpcPayload` (or any superset) down to the status fields alone. * Why: the IPC shape is flattened, so a spread cannot be narrowed structurally — copying * a hook row into a client-visible projection would otherwise ship `launchToken`, * `connectionId`, `promptInteractionKey` and `providerSessionOnly` to every paired client. */ export function pickParsedAgentStatusPayload( row: ParsedAgentStatusPayload ): ParsedAgentStatusPayload { return { state: row.state, ...(row.workingMode !== undefined ? { workingMode: row.workingMode } : {}), prompt: row.prompt, ...(row.agentType !== undefined ? { agentType: row.agentType } : {}), ...(row.model !== undefined ? { model: row.model } : {}), ...(row.modelSwitchCommand ? { modelSwitchCommand: row.modelSwitchCommand } : {}), ...(row.toolName !== undefined ? { toolName: row.toolName } : {}), ...(row.toolInput !== undefined ? { toolInput: row.toolInput } : {}), ...(row.interactivePrompt !== undefined ? { interactivePrompt: row.interactivePrompt } : {}), ...(row.lastAssistantMessage !== undefined ? { lastAssistantMessage: row.lastAssistantMessage } : {}), ...(row.lastAssistantMessageIsToolOutput !== undefined ? { lastAssistantMessageIsToolOutput: row.lastAssistantMessageIsToolOutput } : {}), ...(row.interrupted !== undefined ? { interrupted: row.interrupted } : {}), ...(row.sessionBoundary !== undefined ? { sessionBoundary: row.sessionBoundary } : {}), ...(row.turnCompletedAt !== undefined ? { turnCompletedAt: row.turnCompletedAt } : {}), ...(row.subagents !== undefined ? { subagents: row.subagents } : {}), ...(row.mainAgent !== undefined ? { mainAgent: row.mainAgent } : {}), ...(row.claudeTaskWakeupPending !== undefined ? { claudeTaskWakeupPending: row.claudeTaskWakeupPending } : {}) } } /** * Wire shape for agent-status IPC. Both `agentStatus:set` and `agentStatus:getSnapshot` * produce this shape so renderer call sites share a single `setAgentStatus` path. */ /** Maximum character length for the toolName field. */ export const AGENT_STATUS_TOOL_NAME_MAX_LENGTH = 60 /** Maximum character length for the lastAssistantMessage preview. * Why: 8 KB fits a multi-paragraph summary while bounding per-pane cache against a buggy/malicious agent spamming huge strings. */ export const AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH = 8000 /** Maximum character length for the interactivePrompt field. * Why: holds full AskUserQuestion JSON — truncating to a preview like toolInput would corrupt it and drop options; capped to still bound cache growth. */ export const AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH = 16000 // Re-exported here because every consumer reaches for the entry type and its freshness gate // together; the clock rules themselves live in agent-status-freshness.ts. export { AGENT_STATUS_HYDRATE_MAX_AGE_MS, AGENT_STATUS_STALE_AFTER_MS, agentStatusAuthorityObservedAt, agentStatusEvidenceObservedAt, isFreshNonDoneAgentStatus } from './agent-status-freshness' // Why: ReadonlySet so .has() accepts any string without a cast here; the narrowing cast stays on the return line where it's proven safe. const VALID_STATES: ReadonlySet = new Set(AGENT_STATUS_STATES) export function isAgentStatusState(value: unknown): value is AgentStatusState { return typeof value === 'string' && VALID_STATES.has(value) } export const AGENT_STATUS_JSON_STRUCTURE_LIMITS = { structuralTokens: 4096, nestingDepth: 16 } as const /** A malformed `mainAgent` drops the FIELD, never the row: the combined `state` is still valid * evidence, and readers fall back to it exactly as they do for a host that predates the field. */ export function normalizeMainAgentStatusField(value: unknown): AgentMainAgentStatus | undefined { if (typeof value !== 'object' || value === null) { return undefined } const obj = value as Record const state = obj.state if (!isAgentStatusState(state)) { return undefined } if (typeof obj.stateStartedAt !== 'number' || !Number.isFinite(obj.stateStartedAt)) { return undefined } return { state, // Why: a verdict belongs to a finished turn; anything riding on a live state is stale. ...(state === 'done' && isAgentTurnOutcome(obj.outcome) ? { outcome: obj.outcome } : {}), ...(state === 'working' && obj.stopping === true ? { stopping: true as const } : {}), stateStartedAt: obj.stateStartedAt } } /** * Normalize and validate an already-parsed agent status object. Shared by the * JSON string entry point (`parseAgentStatusPayload`) and the object entry * point (`normalizeAgentStatusPayload`) so both paths enforce identical field * rules. Returns null when the payload is malformed or the state is invalid. */ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | null { if (typeof parsed !== 'object' || parsed === null) { return null } const obj = parsed as Record // Why: explicit typeof guard rejects non-string values instead of leaning on Set.has to return false for mismatched types. if (typeof obj.state !== 'string') { return null } const state = obj.state if (!VALID_STATES.has(state)) { return null } return { state: state as AgentStatusState, workingMode: state === 'working' && obj.workingMode === 'monitoring' ? 'monitoring' : undefined, prompt: normalizePromptField(obj.prompt), // Why: normalize like the other single-line fields so embedded newlines (e.g. `agentType: "claude\nrogue"`) can't break single-line UI and equality checks. agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH), model: normalizeOptionalField(obj.model, AGENT_MODEL_MAX_LENGTH), ...(obj.modelSwitchCommand === 'orca-model' ? { modelSwitchCommand: 'orca-model' as const } : {}), toolName: normalizeOptionalField(obj.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH), toolInput: normalizeOptionalField(obj.toolInput, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH), interactivePrompt: normalizeInteractivePromptField( obj.interactivePrompt, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH ), lastAssistantMessage: normalizeOptionalMultilineField( obj.lastAssistantMessage, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH ), // Why: absent/false collapse to undefined so the flag only ever means "known tool output"; // an old host that never sends it keeps today's behavior instead of silently suppressing. lastAssistantMessageIsToolOutput: obj.lastAssistantMessageIsToolOutput === true ? true : undefined, // Why: only meaningful on `done`; coerce to undefined elsewhere so it can't leak stale truth across transitions. interrupted: obj.interrupted === true && state === 'done' ? true : undefined, sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined, turnCompletedAt: normalizeTurnCompletedAtField(obj.turnCompletedAt, state), subagents: normalizeAgentSubagentsField(obj.subagents), mainAgent: normalizeMainAgentStatusField(obj.mainAgent), ...(obj.agentType === 'claude' && state !== 'done' && (obj.claudeTaskWakeupPending === 'notification' || obj.claudeTaskWakeupPending === 'finishing-turn') ? { claudeTaskWakeupPending: obj.claudeTaskWakeupPending } : {}) } } /** * Normalize an already-structured agent status object (e.g. from IPC, already * deserialized by Electron). Skips the JSON round-trip parseAgentStatusPayload * needs — hook events can fire many times per second during a tool-use run. */ export function normalizeAgentStatusPayload(payload: unknown): ParsedAgentStatusPayload | null { return normalizeAgentStatusObject(payload) } /** * Parse and validate an agent status JSON payload received from explicit * hook integrations or OSC 9999. Returns null if the payload is malformed or * has an invalid state. */ export function parseAgentStatusPayload(json: string): ParsedAgentStatusPayload | null { try { assertJsonTextStructureWithinLimits(json, AGENT_STATUS_JSON_STRUCTURE_LIMITS) return normalizeAgentStatusObject(JSON.parse(json)) } catch { return null } }