diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index 6b7a63ba196..235a1cd579c 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -3,8 +3,7 @@ _orca_shell_features=",${ORCA_SHELL_FEATURES:-}," builtin unset ORCA_SHELL_FEATURES __orca_has_feature() { [[ "$_orca_shell_features" == *",$1,"* ]]; } __orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" -# Why a plain variable: the channel is consumed and destroyed in these first -# lines, so nothing this shell later spawns can see or inherit the selection. +# Consume the feature channel before profiles so child processes cannot inherit the selection. __orca_ready_marker="" __orca_has_feature ready && __orca_ready_marker=1 unset _orca_shell_features @@ -17,10 +16,7 @@ elif [[ -f "$HOME/.bash_login" ]]; then elif [[ -f "$HOME/.profile" ]]; then source "$HOME/.profile" fi -# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as -# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds -# treat each embedded newline as Enter and mangle the prompt into PS2 -# continuation. Modern readline defaults this on; force it for the rest. +# Force bracketed paste on older readline so multiline startup prompts remain one literal paste. [[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null __orca_restore_agent_teams_path() { [[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0 @@ -36,8 +32,7 @@ if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; esac fi -# Why: user startup files may set the default OpenCode config after Orca's -# spawn env; restore the Orca-managed config dir before the first prompt. +# Restore the managed OpenCode config after user profiles may have replaced it. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" @@ -110,7 +105,7 @@ if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then function omp { __orca_omp "$@"; } fi -# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. +# Preserve the managed CODEX_HOME after user profiles. [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" # Why: a typed alias expands inside the shell, after pane launch prep. # Why unalias inside the substitution: an alias named codex makes command -v @@ -174,10 +169,7 @@ if [[ -n "${ORCA_CLAUDE_PROFILE_POINTER:-}" && -n "${__orca_claude_binary:-}" && fi unset __orca_claude_binary -# Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent -# status when the foreground command exits — mirrors the zsh daemon wrapper. -# Without this, bash users (default on most Linux distros) keep a stuck -# 'working' spinner after the CLI exits without a Stop/SessionEnd hook. +# OSC 133 C/D clears stale agent status when a CLI exits without a stop hook. __orca_initializing_wrapper=1 __orca_osc133_precmd() { local exit_code=$? @@ -224,22 +216,17 @@ __orca_osc133_preexec() { __bp_*) [[ -n "${__orca_in_prompt_command:-}" ]] && return 0 ;; esac __orca_run_user_debug_trap - # Why: a framework (bash-preexec/starship) may replace our DEBUG trap at the - # first prompt; __orca_osc133_epilogue re-takes it each prompt and stores the - # framework's trap here, so the framework's own preexec still runs while our - # command-start C survives its re-arm. + # Chain the framework trap captured before each prompt's lifecycle re-arm. if [[ -n "${__orca_chained_debug_trap:-}" ]]; then eval "$__orca_chained_debug_trap" || true fi [[ -z "${__orca_in_prompt_command:-}" ]] || return 0 - # Why: a chained trap can invoke us more than once for a single command, so - # emit C only on the first fire (the __orca_in_command gate), and never for a - # prompt-time hook — ours or bash-preexec's __bp_* helpers. + # Emit C once per foreground command, excluding prompt hooks and repeated chained-trap calls. [[ -z "${__orca_in_command:-}" ]] || return 0 printf "\033]133;C\007" __orca_in_command=1 } -# Why: adopt the latest user trap before Orca retakes lifecycle ownership. +# Adopt the latest user trap before reclaiming lifecycle ownership. __orca_osc133_epilogue() { unset __orca_in_prompt_command __orca_adopt_outer_debug_trap @@ -445,8 +432,7 @@ fi unset __orca_debug_trap_spec __orca_debug_trap_command __orca_had_functrace unset -f __orca_normalize_prompt_command_part __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command unset __orca_prompt_command_normalized -# Why: arm DEBUG after wrapper setup; otherwise bash treats our own rcfile -# commands as a foreground command and emits a fake C/D before the first prompt. +# Arm DEBUG after setup so rcfile commands cannot emit a fake lifecycle before the first prompt. __orca_initial_prompt=1 trap '__orca_osc133_preexec' DEBUG unset __orca_initializing_wrapper diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 7b890315f6a..a3fc8a9caeb 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -1,12 +1,9 @@ # Orca daemon zsh shell-ready wrapper __orca_usable_zdotdir() { [[ -n "${1:-}" ]] || return 1 - # Orca's own dir, by marker file or by the shape older builds wrote. [[ "$1" != */shell-ready/zsh ]] || return 1 [[ ! -f "$1/.orca-shell-wrapper" ]] || return 1 - # A directory holding no zsh startup file at all is not a config root, - # whoever wrote it — and a stale value pointing at one would stop zsh from - # ever reading the user's real .zshenv. + # Reject empty config roots so stale wrapper state cannot hide the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do [[ -r "$1/$_orca_startup_file" ]] && return 0 @@ -23,10 +20,7 @@ builtin unfunction __orca_usable_zdotdir builtin typeset -ga _orca_shell_features _orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) builtin unset ORCA_SHELL_FEATURES -# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config -# that replaces precmd_functions wholesale drops the hook, and an exported value -# nothing will ever consume is then inherited by every child of this pane, -# including a nested Orca (#11146). Captured non-exported, it cannot escape. +# Consume now: user config can drop the deferred hook, leaving exported history state to leak into children. builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" builtin unset ORCA_HISTFILE __orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } @@ -41,8 +35,7 @@ __orca_osc133_precmd() { } __orca_osc133_preexec() { builtin printf "\033]133;C\007" - # Why typeset -g: a plain assignment here creates a global inside a function, - # which prints a warning above every command under warn_create_global. + # Declare explicitly to avoid warn_create_global output above every command. builtin typeset -g __orca_in_command=1 } __orca_deferred_line_init() { @@ -58,7 +51,7 @@ __orca_deferred_line_init() { __orca_prompt_mark "$@" fi } -# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +# Run after user prompt hooks without copying their function metadata. __orca_deferred_sched_init() { local __orca_prompt_status=$? builtin emulate -L zsh @@ -80,10 +73,7 @@ __orca_arm_deferred_line_init() { fi } __orca_deferred_init() { - # Why first: this body runs after the user's own config, so it would otherwise - # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup - # drop whichever feature is listed first. + # Reset user options: NO_UNSET can abort hooks and KSH_ARRAYS breaks the feature lookup. builtin emulate -L zsh (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 @@ -191,7 +181,7 @@ __orca_deferred_init() { function omp { __orca_omp "$@"; } fi - # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. + # Restore the runtime CODEX_HOME after user rc files. [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" fi # Why: a typed alias expands inside the shell, after pane launch prep. @@ -259,12 +249,7 @@ __orca_deferred_init() { HISTFILE="$_orca_histfile" fi if __orca_has_feature ready; then - # Why: capture the prior zle-line-init so the marker chains to it. On a - # re-source we are already the bound widget, so keep the function captured - # the first time instead of clobbering it to empty (which would silently - # drop the user's widget on every prompt after the second source). Only - # user-defined widgets are chainable as plain functions; builtin/completion - # forms (rare for zle-line-init) are left unchained. + # Preserve the original user widget across re-sourcing; builtin/completion widgets cannot chain as plain functions. if [[ "${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then : elif (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" == user:* ]]; then @@ -274,16 +259,14 @@ __orca_deferred_init() { fi __orca_prompt_mark() { printf "\033]777;orca-shell-ready\007" - # Why: call the prior hook as a plain function, not an aliased widget, so - # $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers. + # Call as a function so $WIDGET remains zle-line-init for add-zle-hook-widget dispatchers. if [[ -n "${__orca_prev_line_init_fn:-}" ]]; then "${__orca_prev_line_init_fn}" "$@" fi } zle -N zle-line-init __orca_prompt_mark fi - # Why called here: we were appended during this prompt's own precmd sweep, so - # the permanent hook has not run yet and the first prompt would lose its mark. + # The permanent hook was added during this sweep; run it now so the first prompt gets its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index b2e5e7eb986..4267902cad6 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -1,12 +1,9 @@ # Orca zsh shell-ready wrapper __orca_usable_zdotdir() { [[ -n "${1:-}" ]] || return 1 - # Orca's own dir, by marker file or by the shape older builds wrote. [[ "$1" != */shell-ready/zsh ]] || return 1 [[ ! -f "$1/.orca-shell-wrapper" ]] || return 1 - # A directory holding no zsh startup file at all is not a config root, - # whoever wrote it — and a stale value pointing at one would stop zsh from - # ever reading the user's real .zshenv. + # Reject empty config roots so stale wrapper state cannot hide the user's real .zshenv. local _orca_startup_file for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do [[ -r "$1/$_orca_startup_file" ]] && return 0 @@ -23,10 +20,7 @@ builtin unfunction __orca_usable_zdotdir builtin typeset -ga _orca_shell_features _orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}}) builtin unset ORCA_SHELL_FEATURES -# Why ORCA_HISTFILE is consumed HERE and not in the deferred hook: a user config -# that replaces precmd_functions wholesale drops the hook, and an exported value -# nothing will ever consume is then inherited by every child of this pane, -# including a nested Orca (#11146). Captured non-exported, it cannot escape. +# Consume now: user config can drop the deferred hook, leaving exported history state to leak into children. builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" builtin unset ORCA_HISTFILE __orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } @@ -41,8 +35,7 @@ __orca_osc133_precmd() { } __orca_osc133_preexec() { builtin printf "\033]133;C\007" - # Why typeset -g: a plain assignment here creates a global inside a function, - # which prints a warning above every command under warn_create_global. + # Declare explicitly to avoid warn_create_global output above every command. builtin typeset -g __orca_in_command=1 } __orca_deferred_line_init() { @@ -58,7 +51,7 @@ __orca_deferred_line_init() { __orca_prompt_mark "$@" fi } -# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +# Run after user prompt hooks without copying their function metadata. __orca_deferred_sched_init() { local __orca_prompt_status=$? builtin emulate -L zsh @@ -80,10 +73,7 @@ __orca_arm_deferred_line_init() { fi } __orca_deferred_init() { - # Why first: this body runs after the user's own config, so it would otherwise - # inherit whatever options that config left set. Under NO_UNSET an unset - # precmd_functions is fatal, and KSH_ARRAYS makes the 1-based feature lookup - # drop whichever feature is listed first. + # Reset user options: NO_UNSET can abort hooks and KSH_ARRAYS breaks the feature lookup. builtin emulate -L zsh (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 @@ -198,7 +188,7 @@ __orca_deferred_init() { function omp { __orca_omp "$@"; } fi - # Why: Codex must keep using Orca's runtime CODEX_HOME after rc files. + # Restore the runtime CODEX_HOME after user rc files. [[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}" fi # Why: a typed alias expands inside the shell, after pane launch prep. @@ -268,12 +258,7 @@ __orca_deferred_init() { if __orca_has_feature ready || __orca_has_feature startup; then __orca_emit_ready_marker="" __orca_has_feature ready && __orca_emit_ready_marker=1 - # Why: capture the prior zle-line-init so the marker chains to it. On a - # re-source we are already the bound widget, so keep the function captured - # the first time instead of clobbering it to empty (which would silently - # drop the user's widget on every prompt after the second source). Only - # user-defined widgets are chainable as plain functions; builtin/completion - # forms (rare for zle-line-init) are left unchained. + # Preserve the original user widget across re-sourcing; builtin/completion widgets cannot chain as plain functions. if [[ "${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then : elif (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" == user:* ]]; then @@ -285,8 +270,7 @@ __orca_deferred_init() { if [[ "${__orca_emit_ready_marker-1}" == 1 ]]; then printf "\033]777;orca-shell-ready\007" fi - # Why: call the prior hook as a plain function, not an aliased widget, so - # $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers. + # Call as a function so $WIDGET remains zle-line-init for add-zle-hook-widget dispatchers. if [[ -n "${__orca_prev_line_init_fn:-}" ]]; then "${__orca_prev_line_init_fn}" "$@" fi @@ -299,8 +283,7 @@ __orca_deferred_init() { } zle -N zle-line-init __orca_prompt_mark fi - # Why called here: we were appended during this prompt's own precmd sweep, so - # the permanent hook has not run yet and the first prompt would lose its mark. + # The permanent hook was added during this sweep; run it now so the first prompt gets its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init diff --git a/src/main/acp/acp-account-binding.ts b/src/main/acp/acp-account-binding.ts index 756b579a41e..a858f0621fc 100644 --- a/src/main/acp/acp-account-binding.ts +++ b/src/main/acp/acp-account-binding.ts @@ -1,6 +1,3 @@ -// How an ACP agent's chats pin their account: what a new chat records, and how a launch points the -// agent at the account its record pinned. Both run on the machine that runs the agent. - import { chmod, mkdir } from 'node:fs/promises' import { join } from 'node:path' import { @@ -12,21 +9,18 @@ import { resolveStructuredEnvAccountHomePath } from '../runtime/structured-agent type AccountPin = { accountHomeVariable: string } | { accountLocatorKind: 'opencode' } export type AcpAccountBinding = { - /** What the agent's definition says its records pin. */ pin: AccountPin - /** The account a new chat pins: the current selection, read without side effects. */ + /** Resolves the current selection without side effects. */ resolve(input: { launchEnv: NodeJS.ProcessEnv stateDirectory: string }): Promise - /** `env` pointed at the pinned account; throws for an account of another kind. */ + /** Throws if the pinned account belongs to another kind. */ environment(home: AgentSessionAccountHome, env: Record): Record - /** Readies the account a start's `env` points at, before every spawn; reads never call it. */ + /** Runs before every spawn, never on reads. */ prepare?(env: Readonly>): Promise } -/** An agent whose account is one config directory named by `variable`, with a default under the - * user's home. */ export function directoryAccountBinding( variable: string, defaultHome: (homePath: string) => string @@ -46,9 +40,7 @@ export function directoryAccountBinding( } } -/** An agent whose account is a folder Orca keeps for it on each host, which the agent signs in to - * itself. It keeps that sign-in there in plain text, so every start makes the folder and - * `privateSubfolders` owner-only, whatever made them first. */ +/** Restricts plaintext sign-in folders to their owner before every spawn. */ export function hostFolderAccountBinding( variable: string, folderName: string, diff --git a/src/main/acp/acp-agent-connection.ts b/src/main/acp/acp-agent-connection.ts index 0c7ede25b1c..e5504561f46 100644 --- a/src/main/acp/acp-agent-connection.ts +++ b/src/main/acp/acp-agent-connection.ts @@ -10,30 +10,26 @@ import { AcpConnectionClosedError, AcpSignInPromptError } from './acp-errors' import { resolveAcpPeerOptions, type AcpPeerOptions } from './acp-peer-limits' import { AcpSessionRuntime, type AcpSessionRuntimeOptions } from './acp-session-runtime' -/** How an agent that signs in through a browser says so on its output. */ export type AcpSignInWatch = { - /** A sign-in line's valid link (the connection ends signed out), null for a malformed one, - * undefined for any other line. */ + /** Returns a valid link, null for a malformed sign-in line, or undefined for other output. */ read(line: string, source: 'stdout' | 'stderr'): string | null | undefined - /** The agent's answer on its sign-in: a session started, or it asked to sign in. */ answered?(signedIn: boolean): void - /** Receives each valid link instead, and the connection stays open for the sign-in to finish. */ + /** Keeps the connection open for sign-in instead of closing it signed out. */ link?(link: string): void } -// A full sign-in link with its framing; the stderr tail kept for diagnostics stays smaller. +// Allow full sign-in links while keeping the diagnostic tail smaller. const SIGN_IN_LINE_MAX_CHARS = 32_768 export type AcpAgentConnectionOptions = Omit & { peer?: Omit /** Process exit evidence, including expected closes and processless spawn failures. */ onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void - /** Any stdout or stderr chunk from the child. */ onOutput?: () => void signIn?: AcpSignInWatch } -/** The execution host owns the child and protocol lifetime; the adapter owns turns and Stop. */ +/** The host owns process lifetime; the adapter owns turns and Stop. */ export function createAcpAgentConnection( launch: ProviderProcessLaunch, options: AcpAgentConnectionOptions = {}, @@ -53,12 +49,12 @@ export class AcpAgentConnection extends AcpSessionRuntime { options: AcpAgentConnectionOptions = {}, spawnImpl: PipedProcessSpawner = spawnProcess ) { - // Validate before spawning so invalid limits cannot leave an unowned child. + // Invalid limits must fail before spawning an unowned child. const peer = resolveAcpPeerOptions({ ...options.peer, closeOnInputEnd: false }) const { signIn } = options let signedOut = (): void => {} let unreadable = (): void => {} - // A sign-in line is never kept or logged; only a whole, valid one is an answer. + // Never retain sign-in links in diagnostics. const signInLine = (line: string, source: 'stdout' | 'stderr', cut = false): boolean => { const link = signIn?.read(line, source) if (link === undefined) { @@ -137,7 +133,7 @@ export class AcpAgentConnection extends AcpSessionRuntime { signIn?.answered?.(false) super.close(new AcpSignInPromptError('The agent asked to sign in in a browser')) } - // A broken sign-in line on the protocol stream is a broken agent, not a sign-in answer. + // A malformed protocol-stream link closes the connection without reporting signed-out status. unreadable = () => super.close(new AcpConnectionClosedError('The agent printed a sign-in link Orca cannot read')) child.stderr.on('error', (error) => super.close(error)) @@ -166,7 +162,7 @@ export class AcpAgentConnection extends AcpSessionRuntime { return this.managed.rootVerdict } - /** Retained evidence from the last close attempt; a null tree means no observation. */ + /** Null tree means no observation. */ get lastCloseResult(): Readonly { return this.managed.lastCloseResult } diff --git a/src/main/acp/acp-background-task-child-work.ts b/src/main/acp/acp-background-task-child-work.ts index ec44fc57c13..7b012f05248 100644 --- a/src/main/acp/acp-background-task-child-work.ts +++ b/src/main/acp/acp-background-task-child-work.ts @@ -7,14 +7,13 @@ import { isBackgroundTaskBlock } from '../../shared/native-chat-types' import { AGENT_CHILD_WORK_ID_MAX_LENGTH } from '../../shared/agent-status-child-work-value-guards' import type { AcpTimelineEvent } from './acp-timeline-event' -/** Keeps a task's handle apart from a subagent's, so a Stop knows which route it takes. */ +// Keep task Stop handles distinct from subagent handles. const TASK_ID_PREFIX = 'acp-task:' function acpBackgroundTaskHandle(taskId: string): AgentChildWorkEvidenceHandle { return { idKind: 'task_id', id: `${TASK_ID_PREFIX}${taskId}` } } -/** The agent's own task id behind a child handle, when the handle names a background task. */ export function acpBackgroundTaskStopId(childId: string): string | undefined { return childId.startsWith(TASK_ID_PREFIX) ? childId.slice(TASK_ID_PREFIX.length) : undefined } @@ -29,7 +28,6 @@ export function isAcpBackgroundTaskChildWorkEvent(event: AcpTimelineEvent): bool return backgroundTaskBlocks(event).length > 0 } -/** A running task is a live record until it ends, and then its record goes, like a command's. */ export function acpBackgroundTaskChildWork( event: AcpTimelineEvent, observedAt: number, @@ -37,7 +35,6 @@ export function acpBackgroundTaskChildWork( ): AgentChildWorkEvidence[] { return backgroundTaskBlocks(event).flatMap((block): AgentChildWorkEvidence[] => { const handle = acpBackgroundTaskHandle(block.taskId) - // A handle too long to record could not address the agent's own task either. if (handle.id.length > AGENT_CHILD_WORK_ID_MAX_LENGTH) { return [] } diff --git a/src/main/acp/acp-background-task-timeline.ts b/src/main/acp/acp-background-task-timeline.ts index f489ff8a1a9..d339792b401 100644 --- a/src/main/acp/acp-background-task-timeline.ts +++ b/src/main/acp/acp-background-task-timeline.ts @@ -10,9 +10,7 @@ import type { } from '../native-chat/agent-session-timeline/provider-timeline-event' import type { AcpBackgroundTaskUpdate } from './acp-dialects/acp-dialect' -/** Partial provider snapshots; the assembler owns settlement and durable placement. A running - * task's snapshot lives until it settles. Settled ones are history, bounded, so a later ending - * (after a Stop found it gone) keeps its label; one evicted keeps only its row's turn. */ +/** Retain running snapshots and bounded settled labels; the assembler owns durable placement. */ export class AcpBackgroundTaskTimeline { private readonly running = new Map() private readonly settled = new BoundedMap({ diff --git a/src/main/acp/acp-compaction-turn.ts b/src/main/acp/acp-compaction-turn.ts index 00a9db6ec41..0b4f9780655 100644 --- a/src/main/acp/acp-compaction-turn.ts +++ b/src/main/acp/acp-compaction-turn.ts @@ -1,6 +1,4 @@ -// A `/compact` Orca sends as a prompt, run as the command turn the host opened. What the agent -// writes meanwhile is the command's output, read for how it went and never drawn; its answer ends -// the turn with one result row, as Claude's and Codex's compactions end. +// Consume /compact output without rendering it; end the command turn with one result row. import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' @@ -10,19 +8,16 @@ import { structuredCompactionOutcome } from '../native-chat/agent-session-wire/s import type { AcpDialect } from './acp-dialects/acp-dialect' import type { ContentBlock, SessionUpdate } from './generated/acp-protocol.generated' -/** The prompt every compacting ACP agent takes as its own compaction command. */ export const ACP_COMPACT_PROMPT: readonly ContentBlock[] = [{ type: 'text', text: '/compact' }] -/** What a running compaction has said so far. */ export type AcpCompaction = { reply: string; failureDetail?: string } -/** The parts of a dialect's frame a running compaction reads. */ export type AcpCompactionFrame = { failureDetail?: string end?: { durationMs?: number; failureDetail?: string } } -/** Reads the running compaction's reply text; true for an update that is its output. */ +/** Returns true when the update belongs to the compaction. */ export function readAcpCompactionUpdate(compaction: AcpCompaction, update: SessionUpdate): boolean { if (update.sessionUpdate === 'agent_message_chunk') { if (update.content.type === 'text') { @@ -33,8 +28,7 @@ export function readAcpCompactionUpdate(compaction: AcpCompaction, update: Sessi return update.sessionUpdate === 'agent_thought_chunk' } -/** Folds a frame of the running compaction into it: its words and end are read for the result its - * answer writes. True for a frame that is the compaction's own, which draws nothing. */ +/** Absorbs compaction output; true suppresses rendering the frame. */ export function absorbAcpCompactionFrame( prompt: { compaction?: AcpCompaction; durationMs?: number }, extension: AcpCompactionFrame | undefined, @@ -58,7 +52,6 @@ export function absorbAcpCompactionFrame( return update !== undefined && readAcpCompactionUpdate(compaction, update) } -/** The compaction's result row and its turn's end, from the agent's answer. */ export function acpCompactionEnd(input: { compaction: AcpCompaction turn: string @@ -66,15 +59,13 @@ export function acpCompactionEnd(input: { stopReason: string at: number durationMs?: number - /** Words the answer itself carried, for one that failed. */ failureDetail?: string - /** The answer said the agent is not signed in. */ notSignedIn?: boolean dialect: AcpDialect agentName?: string }): ProviderTimelineEvent[] { const { compaction, turn, stopReason } = input - // The host's command turn is the assembler's open turn, which no provider key names. + // The host's command turn has no provider key. const join = { thread: input.thread } const end = (outcome: 'success' | 'failure' | 'cancellation'): ProviderTimelineEvent => ({ type: 'turn.end', @@ -86,7 +77,6 @@ export function acpCompactionEnd(input: { const reply = stopReason === 'end_turn' ? input.dialect.compactionReply?.(compaction.reply) : undefined if (reply?.outcome === 'skipped') { - // Nothing to compact is no failure: the turn succeeds and says why nothing changed. const text = providerDiagnostic(reply.detail, 'person')?.text ?? 'Nothing to compact' return [ { @@ -106,7 +96,7 @@ export function acpCompactionEnd(input: { const words = reply?.detail ?? input.failureDetail ?? compaction.failureDetail const detail = words === undefined ? undefined : providerDiagnostic(words, 'person') const verdict = structuredCompactionOutcome({ - // An agent that ends `/compact` normally compacted, unless its reply says otherwise. + // Normal completion implies compaction unless the reply says otherwise. compacted: stopReason === 'end_turn' && reply === undefined, interruptRequested: stopReason === 'cancelled', failed: detail ? { detail } : {} @@ -114,7 +104,7 @@ export function acpCompactionEnd(input: { if (verdict.outcome === 'cancellation') { return [end('cancellation')] } - // The same fact a signed-out send records, so its guidance shows and the child is replaced. + // Use signed-out guidance and replace the child, as for a signed-out send. const failure = verdict.failure && input.notSignedIn ? agentSessionFailureFact('notSignedIn', detail ? { detail } : {}) diff --git a/src/main/acp/acp-context-usage.ts b/src/main/acp/acp-context-usage.ts index 007e56413ab..df41f2d6ac2 100644 --- a/src/main/acp/acp-context-usage.ts +++ b/src/main/acp/acp-context-usage.ts @@ -31,7 +31,7 @@ export class AcpContextTimeline { ] } - /** Replayed history is dropped except what it says about the context window. */ + /** Retain only context-window information from replayed history. */ history( update: SessionUpdate | undefined, usage: AgentSessionContextUsage | undefined, diff --git a/src/main/acp/acp-conversation-copies.ts b/src/main/acp/acp-conversation-copies.ts index ff354e917c3..088d6b5851b 100644 --- a/src/main/acp/acp-conversation-copies.ts +++ b/src/main/acp/acp-conversation-copies.ts @@ -5,7 +5,7 @@ import { copyAcpConversation } from './acp-conversation-copy' import type { AcpStructuredConnection } from './acp-structured-connection' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' -/** Copy children have their own cleanup lifecycle; they never gate the original chat's start. */ +/** Copy children never gate the original chat's start. */ export class AcpConversationCopies { private readonly children = new ProviderAcquisitionStarts() private readonly shutdown = new AbortController() @@ -14,7 +14,6 @@ export class AcpConversationCopies { constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} copy: NonNullable = async (input) => { - // A copy runs the agent too, so it is fenced like a chat start. const gate = this.deps.spec.launchGate?.enter(this.shutdown.signal) const copy = copyAcpConversation(this.deps, input, { signal: gate?.signal ?? this.shutdown.signal, diff --git a/src/main/acp/acp-conversation-copy.test.ts b/src/main/acp/acp-conversation-copy.test.ts index 6c191bd709b..f1b4382c1cd 100644 --- a/src/main/acp/acp-conversation-copy.test.ts +++ b/src/main/acp/acp-conversation-copy.test.ts @@ -154,7 +154,6 @@ describe('copying a chat conversation for the CLI', () => { } }) - // A copy lands in this host's OpenCode store; a CLI sent to a server would not find it. it.each(['opencode', 'opencode2'] as const)( 'refuses to copy a %s chat whose arguments route to a server, before starting OpenCode', async (agent) => { diff --git a/src/main/acp/acp-conversation-copy.ts b/src/main/acp/acp-conversation-copy.ts index 1edae4addc1..6b0dc627521 100644 --- a/src/main/acp/acp-conversation-copy.ts +++ b/src/main/acp/acp-conversation-copy.ts @@ -1,6 +1,5 @@ import { normalizeAgentProviderSession } from '../../shared/agent-session-resume' import { OpenCodeCopyServerRouteError } from '../../shared/opencode-launch-command' -// The chat protocol saves an independent conversation; its ordinary CLI opens that copy. import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' import { agentSessionAccountHomesEqual, @@ -132,7 +131,7 @@ async function forkAdvertised( return connection.forkSession(sessionId, cwd) } -/** Rederive selection at the fork boundaries; ordinary terminals still own their normal routing. */ +/** Recheck the selected account at fork boundaries. */ async function assertCopyAccountSelected( { spec, dataDirectory }: Pick, home: AgentSessionAccountHome, diff --git a/src/main/acp/acp-dialects/acp-dialect.ts b/src/main/acp/acp-dialects/acp-dialect.ts index feaf28ea9a4..5e43fead5a2 100644 --- a/src/main/acp/acp-dialects/acp-dialect.ts +++ b/src/main/acp/acp-dialects/acp-dialect.ts @@ -15,31 +15,24 @@ export type AcpRequestPresentation = { reply(response: AgentSessionPromptResponse | null): unknown } -/** A request the client answers at once, asking no one: the reply, and a plan it carried for the - * chat's existing plan row. */ export type AcpRequestSettlement = { reply: unknown; plan?: string } export type AcpBackgroundTaskUpdate = Pick & Partial> & { - /** Used only until a provider description is known. */ fallbackLabel?: string - /** Used only until a frame names the task's kind. */ fallbackKind?: NativeChatBackgroundTaskBlock['kind'] } -/** What the provider said about one subagent; absent fields are unchanged. */ +/** Omitted fields leave existing subagent state unchanged. */ export type AcpSubagentUpdate = { - /** The provider's subagent id: the roster key, and where its own rows are filed. */ id: string state?: NativeChatSubagentState label?: string - /** Latest total tokens the provider reported for this subagent. */ tokens?: number - /** The provider turn that spawned it. */ + /** Spawning provider turn. */ turn?: string - /** The subagent's final reply, once it completed. */ result?: string - /** For a report that cannot tell a subagent from a background task: applies only to a known one. */ + /** Ambiguous task reports apply only to known subagents. */ knownOnly?: true } @@ -52,17 +45,15 @@ export type AcpDialectNotification = at?: number started?: boolean end?: Omit - /** The provider's own words for why `turn` failed, sent apart from its end. */ failureDetail?: string usage?: AgentSessionContextUsage backgroundTasks?: AcpBackgroundTaskUpdate[] subagents?: AcpSubagentUpdate[] } -/** A prompt hook refused the message: the turn ended, but the user did not stop it. */ +/** Hook denial completes the turn rather than recording a user Stop. */ export type AcpHookBlock = { reason?: string } -/** How a turn ended, as the agent reported it. */ export type AcpTurnEnding = { stopReason: string at: number @@ -72,29 +63,29 @@ export type AcpTurnEnding = { hookBlock?: AcpHookBlock } -/** How a `/compact` the agent ended normally went, read from its reply; absent compacted. */ +/** Undefined means a normally ended /compact succeeded. */ export type AcpCompactionReply = { outcome: 'skipped' | 'failed'; detail: string } /** Hooks interpret extensions; lifecycle and row identity stay shared. */ export type AcpDialect = { subagentStop?: { - /** Missing required target: proves the route exists without addressing any child. */ + /** Probe without a target to establish support without cancelling a child. */ probe: { method: string; params: unknown } recognizesProbeError(error: AcpAgentError): boolean request(sessionId: string, id: string): { method: string; params: unknown } response(value: unknown, id: string): { cancelled: boolean; state?: NativeChatSubagentState } } backgroundTaskStop?: { - /** Missing required target: proves the route exists without addressing any task. */ + /** Probe without a target to establish support without killing a task. */ probe: { method: string; params: unknown } - /** The agent refusing the route by name: the only answer that hides Stop. */ + /** Only explicit route rejection hides Stop. */ lacksRoute(error: AcpAgentError): boolean request(sessionId: string, taskId: string): { method: string; params: unknown } - /** `gone`: the agent no longer runs a task by that id; its own ending may still follow. */ + /** A missing task may still report its own ending later. */ response(value: unknown, taskId: string): 'killed' | 'gone' | 'refused' } injectedPromptIdentity?: true - /** A tool update in the shared shape (`rawOutput.stdout`, `rawOutput.exitCode`), read first. */ + /** Normalize before reading tools: rawOutput.stdout and rawOutput.exitCode. */ normalizeToolUpdate?(update: ToolCallUpdate): ToolCallUpdate toolName?(update: ToolCallUpdate): string | undefined toolBackgroundTasks?( @@ -106,27 +97,19 @@ export type AcpDialect = { subagentSessionEnd?(method: string, params: unknown): 'completed' | 'stopped' | undefined notification?(method: string, params: unknown, at: number): AcpDialectNotification | undefined contextWindow?(models: unknown): number | undefined - /** A model's own effort menu and default, as the agent advertises them per model. */ modelEfforts?(model: ModelInfo): { efforts: AgentSessionOptionChoice[]; defaultEffort?: string } - /** A request shown to the person; a permission request it claims is never auto-answered. */ + /** Claimed permission requests are never auto-approved. */ request?(method: string, params: unknown): AcpRequestPresentation | undefined - /** Requests answered at once instead of shown to the person. */ settleRequest?(method: string, params: unknown): AcpRequestSettlement | undefined - /** The provider's words in a `session/prompt` error answer, when its message is generic. */ promptErrorDetail?(error: AcpAgentError): string | undefined authenticationRequired?(error: AcpAgentError): boolean - /** A hook's refusal, read from the `_meta` of the prompt's answer or of the turn's end. */ hookBlock?(meta: unknown): AcpHookBlock | undefined - /** The row for a failed turn the provider gave no words for. */ failedTurnText?(stopReason: string): string - /** An agent that ends a `/compact` it did not do as a normal turn says so in its reply. */ compactionReply?(text: string): AcpCompactionReply | undefined - /** The agent's own `/` commands a chat does not offer: hidden from its list, and a message - * that is only one of them is refused before it reaches the agent. */ + /** Hide these commands and reject command-only prompts before dispatch. */ withheldCommands?: readonly string[] } -/** Which of the dialect's stop routes this agent process proved it answers. */ export type AcpChildStops = { subagents: boolean; backgroundTasks: boolean } export const GENERIC_ACP_DIALECT: AcpDialect = {} diff --git a/src/main/acp/acp-dialects/antigravity-dialect.ts b/src/main/acp/acp-dialects/antigravity-dialect.ts index f1de78dc939..f796d56de35 100644 --- a/src/main/acp/acp-dialects/antigravity-dialect.ts +++ b/src/main/acp/acp-dialects/antigravity-dialect.ts @@ -6,8 +6,7 @@ import { acpPermissionPresentation, pendingAcpResolution } from '../acp-timeline import type { ToolCallUpdate } from '../generated/acp-protocol.generated' import type { AcpDialect, AcpRequestPresentation } from './acp-dialect' -// Antigravity asks the person a question as a permission request on an `interaction_` tool call, -// one choice per option, sent even in its yolo mode: a question, never an approval to auto-allow. +// interaction_ permissions are questions, even in yolo mode; never auto-approve them. function questionRequest(method: string, params: unknown): AcpRequestPresentation | undefined { const request = method === 'session/request_permission' ? readAcpPermissionRequest(params, () => {}) : null @@ -32,7 +31,7 @@ function questionRequest(method: string, params: unknown): AcpRequestPresentatio : legacyAgentSessionQuestionAnswers(body, response.optionId) const [answer] = answers ?? [] const optionId = answer?.optionIds[0] - // The server takes back one of its own choices; it has no free-text answer. + // The server accepts one offered choice and no free text. if ( answers?.length !== 1 || answer?.optionIds.length !== 1 || @@ -54,7 +53,7 @@ const securityWarningSchema = z.looseObject({ }) }) -// The server marks a risky "Allow Always" with a prompt-injection warning in that option's `_meta`. +// Risky Allow Always options carry warnings in option metadata. function warnedApproval(method: string, params: unknown): AcpRequestPresentation | undefined { const request = method === 'session/request_permission' ? readAcpPermissionRequest(params, () => {}) : null @@ -67,9 +66,7 @@ function warnedApproval(method: string, params: unknown): AcpRequestPresentation : undefined } -// A command arrives as `rawInput {command_line, working_dir}`, and once done as `rawOutput -// {commandLine, workingDir, exitCode, combinedOutput, formatted_output}` with no content blocks; -// the shared shape is `rawInput.command` and `rawOutput {stdout, exitCode}`. +// Normalize the server's command-field variants to command/cwd and stdout/exitCode. const optionalText = z.string().optional() const commandFieldsSchema = z.looseObject({ CommandLine: optionalText, @@ -113,8 +110,8 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { export const ANTIGRAVITY_ACP_DIALECT: AcpDialect = { request: (method, params) => questionRequest(method, params) ?? warnedApproval(method, params), normalizeToolUpdate, - // Its command's title is the command line itself; the kind is what names a command row. + // Avoid using the full command line as the tool name. toolName: (update) => (update.kind === 'execute' ? 'execute' : undefined), - // The server's own sign-out clears the sign-in every Antigravity chat on the host shares. + // Logout would clear the account shared by every Antigravity chat on this host. withheldCommands: ['logout'] } diff --git a/src/main/acp/acp-dialects/grok-background-task-stop.ts b/src/main/acp/acp-dialects/grok-background-task-stop.ts index fe2bcf523e9..70aab6e32a0 100644 --- a/src/main/acp/acp-dialects/grok-background-task-stop.ts +++ b/src/main/acp/acp-dialects/grok-background-task-stop.ts @@ -9,14 +9,13 @@ const response = z.union([ outcome: z.enum(['killed', 'already_exited', 'not_found']) }) }), - // Grok's envelope for a kill it could not attempt, such as an unknown session. z.object({ result: z.null(), error: z.string() }) ]) export const grokBackgroundTaskStop: NonNullable = { probe: { method: '_x.ai/task/kill', params: {} }, lacksRoute: (error) => error.code === -32601, - // `source` is left to Grok's default: its own per-task UI kill. + // Omit source to use Grok's per-task UI kill behavior. request: (sessionId, taskId) => ({ method: '_x.ai/task/kill', params: { sessionId, taskId } }), response: (value, taskId) => { const parsed = response.safeParse(value) diff --git a/src/main/acp/acp-dialects/grok-background-tasks.ts b/src/main/acp/acp-dialects/grok-background-tasks.ts index 89e988a12c9..8ccedf523b7 100644 --- a/src/main/acp/acp-dialects/grok-background-tasks.ts +++ b/src/main/acp/acp-dialects/grok-background-tasks.ts @@ -44,16 +44,13 @@ const resultOutputSchema = z.object({ }) type GrokTask = z.infer export type GrokTaskResult = z.infer -/** Grok's output reads name a monitor only by this command prefix. */ const MONITOR_COMMAND = /^\[monitor[:\]]/ -/** ...and a subagent by this one; subagents are roster rows, not background tasks. */ const SUBAGENT_COMMAND = /^\[subagent[:\]]/ export function isGrokSubagentTask(task: Pick): boolean { return SUBAGENT_COMMAND.test(task.command ?? '') } -/** The per-task results of a finished output read or kill call. */ export function grokTaskResults( rawOutput: unknown ): { type: 'TaskOutput' | 'KillTask'; tasks: GrokTaskResult[] } | undefined { @@ -122,7 +119,7 @@ function snapshot( : {}), ...(label ? { label } : fallbackLabel ? { fallbackLabel } : {}), ...(task.output_file === undefined ? {} : { outputFile: task.output_file }), - // A running task's summary is Grok's "Background task started", not the task's result. + // Running summaries announce startup; only settled summaries contain task results. ...(settled ? { summary: task.summary ?? '' } : {}), ...(task.error === undefined && !settled ? {} : { error: task.error ?? '' }) } @@ -156,7 +153,7 @@ export function grokToolBackgroundTasks( } const input = taskInputSchema.safeParse(tool.input) if (tool.name === 'kill_command_or_subagent' && update.status === 'completed' && input.success) { - // Inference: a successful kill call settles its named tasks without a completion notice. + // Infer settlement from successful kill calls that emit no separate completion notice. return [ ...new Set([ ...(input.data.task_ids ?? []), diff --git a/src/main/acp/acp-dialects/grok-dialect.ts b/src/main/acp/acp-dialects/grok-dialect.ts index f62890df84a..1ebaa4823bf 100644 --- a/src/main/acp/acp-dialects/grok-dialect.ts +++ b/src/main/acp/acp-dialects/grok-dialect.ts @@ -37,7 +37,6 @@ const hookDeniedSchema = z.looseObject({ cancellationContext: z.looseObject({ reason: z.string().optional() }).nullish() }) const promptErrorDataSchema = z.looseObject({ message: z.string() }) -/** Grok writes its result text as the failure's reason only for these ends. */ const FAILED_STOP_REASONS = ['error', 'rate_limit'] const SESSION_NOTIFICATIONS = ['x.ai/session_notification', 'x.ai/session/update'] @@ -78,7 +77,7 @@ function contextWindow(models: unknown): number | undefined { ) } -/** Grok ends a hook-denied prompt as `cancelled`, naming the hook's verdict in `_meta`. */ +// Grok reports hook denial as cancellation with a verdict in _meta. function hookBlock(meta: unknown): AcpHookBlock | undefined { const parsed = hookDeniedSchema.safeParse(meta) const reason = parsed.data?.cancellationContext?.reason?.trim() @@ -133,7 +132,7 @@ function notification( : { disposition: 'map', usage: { window: { tokens, capturedAt: at } } } } if (canonical === 'x.ai/session/prompt_complete') { - // A copy of the turn's end; only its failure reason is read, in case the end carried none. + // Duplicate turn endings may supply a missing failure reason. const parsed = promptCompleteSchema.safeParse(params) const detail = parsed.success ? failureDetail(parsed.data.stopReason, parsed.data.agentResult) @@ -167,7 +166,7 @@ function notification( const meta = turnMetaSchema.safeParse(envelope._meta) const completion = completionSchema.safeParse(envelope.update) const response = responseSchema.safeParse(envelope.update) - // Only a retry Grok gave up on is read; it names no turn, so it joins the one running. + // Exhausted retries have no turn ID; attach their failure to the running turn. const retry = retrySchema.safeParse(envelope.update) const retryFailure = retry.success && retry.data.type === 'failed' && retry.data.message?.trim() diff --git a/src/main/acp/acp-dialects/grok-model-catalog.ts b/src/main/acp/acp-dialects/grok-model-catalog.ts index 7f1fbce3a14..6843375eb97 100644 --- a/src/main/acp/acp-dialects/grok-model-catalog.ts +++ b/src/main/acp/acp-dialects/grok-model-catalog.ts @@ -1,6 +1,3 @@ -// Grok's own model facts: the per-model effort menu it writes in each model's `_meta`, and the -// models it lists during `initialize` (or serves from `x.ai/models/list`) with no session. - import { z } from 'zod' import type { AgentSessionModelOption, @@ -22,7 +19,7 @@ const EFFORT_LABELS: Readonly> = { xhigh: 'X-High', max: 'Max' } -// The menu Grok offers a model that supports effort but names none of its own. +// Fallback only for effort-capable models without an advertised menu. const BUILT_IN_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const const effortEntrySchema = z.union([ @@ -48,8 +45,7 @@ function capitalized(id: string): string { return id.charAt(0).toUpperCase() + id.slice(1) } -/** One model's effort menu as Grok advertises it. Choice values are Grok's option ids — what its - * effort config option takes — not the canonical sampling value behind them. */ +/** Effort choices use Grok's config option IDs, not underlying sampling values. */ export function grokModelEfforts(model: Pick): { efforts: AgentSessionOptionChoice[] defaultEffort?: string @@ -90,8 +86,7 @@ export function grokModelEfforts(model: Pick): { return { efforts, ...(defaultEffort ? { defaultEffort } : {}) } } -/** Names no default: a session's own model can differ from the `currentModelId` computed without - * one (Grok resolves its default from remote config too), so a no-pick chat reports it instead. */ +/** Session-free currentModelId may differ from the model a chat runs; leave defaults to sessions. */ export function grokModelCatalogFromState(state: SessionModelState): AgentSessionModelOption[] { return state.availableModels.map((model) => ({ id: model.modelId, @@ -102,7 +97,6 @@ export function grokModelCatalogFromState(state: SessionModelState): AgentSessio })) } -/** The models `initialize` computed, else `x.ai/models/list`; neither creates a session. */ export async function readGrokModelCatalog( initialized: InitializeResponse, connection: { requestSessionFreeExtension(method: string, params: unknown): Promise } diff --git a/src/main/acp/acp-dialects/grok-requests.ts b/src/main/acp/acp-dialects/grok-requests.ts index b8678812061..899b314df89 100644 --- a/src/main/acp/acp-dialects/grok-requests.ts +++ b/src/main/acp/acp-dialects/grok-requests.ts @@ -98,12 +98,10 @@ function questionRequest(params: unknown): AcpRequestPresentation { } } -/** What Grok hears once Orca has shown its plan: no approval is given for the person. */ const PLAN_SHOWN_FEEDBACK = 'Orca showed your plan to the user. Stop here and wait for their feedback or a request to implement it in a later turn.' -/** Grok's plan-mode exit: its plan goes to the chat's plan row and the gate is answered at once - * (`abandoned`), so the turn ends instead of waiting on an approval card. */ +// Show the plan and abandon its approval gate so Grok waits for later user instructions. function settlePlanRequest(params: unknown): AcpRequestSettlement { const parsed = planRequestSchema.safeParse(params) if (!parsed.success) { diff --git a/src/main/acp/acp-dialects/grok-subagents.ts b/src/main/acp/acp-dialects/grok-subagents.ts index fec9b36f924..40b3ee86f2f 100644 --- a/src/main/acp/acp-dialects/grok-subagents.ts +++ b/src/main/acp/acp-dialects/grok-subagents.ts @@ -1,6 +1,4 @@ -// Grok's subagents, read as roster updates. Recorded from Grok 1.0.46 (s7 fixtures): a spawn is -// `spawn_subagent`, and Grok reports the child on the parent session as `subagent_spawned`, -// `subagent_progress` and `subagent_finished`. The subagent id is also the child's own session id. +// Grok's subagent IDs also name their child sessions (1.0.46 recordings). import { z } from 'zod' import type { AgentJournalToolCallItem } from '../../../shared/agent-session-journal-types' @@ -44,13 +42,11 @@ const killInputSchema = z.object({ task_id: idSchema.optional(), task_ids: z.array(idSchema).optional() }) -/** The id line of the reply a background spawn returns at once. */ const SPAWN_ACK_ID = /^subagent_id:\s*(\S+)\s*$/m -/** Machine blocks Grok appends to a subagent's reply for the parent model. */ const MACHINE_BLOCKS = /[\s\S]*?<\/subagent_\1>/g const MAX_PROMPT_LABEL_CHARS = 80 -/** Grok's statuses in the roster's words; a Map so `__proto__` resolves to nothing. */ +// Map lookups avoid treating __proto__ as a recognized state. const SUBAGENT_STATES: ReadonlyMap = new Map([ ['completed', 'completed'], ['success', 'completed'], @@ -78,7 +74,6 @@ function promptLabel(prompt: string | undefined): string | undefined { : line || undefined } -/** A `subagent_*` session update; undefined for any other update. */ export function grokSubagentNotification(update: unknown): AcpSubagentUpdate | undefined { const spawned = spawnedSchema.safeParse(update) if (spawned.success) { @@ -102,7 +97,7 @@ export function grokSubagentNotification(update: unknown): AcpSubagentUpdate | u } const state = subagentState(finished.data.status) const result = state === 'completed' ? replyText(finished.data.output) : undefined - // A cancelled subagent reports 0 tokens; the count it reported running stands. + // Preserve running token totals when cancelled subagents report zero. const tokens = finished.data.tokens_used return { id: finished.data.subagent_id, @@ -133,8 +128,7 @@ function spawnUpdates(update: ToolCallUpdate, tool: AgentJournalToolCallItem): A return id ? [{ id, state: 'working', ...named }] : [] } -/** What a finished tool call says about subagents: its spawn's end or start, an output read, or a - * kill. Reads and kills name tasks of any kind, so they settle only subagents already known. */ +/** Output reads and kill calls can name other task types; apply only to known subagents. */ export function grokToolSubagents( update: ToolCallUpdate, tool: AgentJournalToolCallItem @@ -167,7 +161,7 @@ export function grokToolSubagents( if (tool.name !== 'kill_command_or_subagent' || !kill.success) { return [] } - // Inference, as for background tasks: a successful kill ends the tasks it named. + // Infer stopped state from a successful kill call. return [ ...new Set([...(kill.data.task_ids ?? []), ...(kill.data.task_id ? [kill.data.task_id] : [])]) ].map((id) => ({ id, state: 'stopped', knownOnly: true })) diff --git a/src/main/acp/acp-dialects/omp-dialect.ts b/src/main/acp/acp-dialects/omp-dialect.ts index d696ee98f73..c45dc702fc0 100644 --- a/src/main/acp/acp-dialects/omp-dialect.ts +++ b/src/main/acp/acp-dialects/omp-dialect.ts @@ -2,14 +2,10 @@ import { z } from 'zod' import type { ToolCallContent, ToolCallUpdate } from '../generated/acp-protocol.generated' import type { AcpCompactionReply, AcpDialect } from './acp-dialect' -// OMP sends a tool's result as `rawOutput: {content: [{type: 'text', text}], details}`, with a -// command's non-zero exit at `details.exitCode`, and repeats the text as content behind a -// `$ ` echo. The shared reader takes content text first, so that echo became the output. -// A zero exit is left out; completed foreground results carry wall time, while service and -// background launch results do not establish a process exit. +// Read raw result text instead of the repeated command echo in content. +// Only timed foreground completion implies exit zero; service/background launches prove no exit. const textBlockSchema = z.looseObject({ type: z.literal('text'), text: z.string() }) const promptErrorDataSchema = z.looseObject({ details: z.string() }) -// OMP's prompt check: no usable model (nothing signed in), or the selected model's provider has no key. const SIGNED_OUT_DETAIL_PREFIXES = ['No model selected.\n\nUse /login', 'No API key found for '] const toolResultSchema = z.looseObject({ content: z.array(z.unknown()), @@ -30,7 +26,7 @@ function contentText(block: ToolCallContent | undefined): string | undefined { return block?.type === 'content' && block.content.type === 'text' ? block.content.text : undefined } -/** OMP appends these notice lines to a command's output; the row shows exit and timing itself. */ +// Exit and timing notices already have dedicated row presentation. function commandOutput(text: string, exitCode: number | undefined, timed: boolean): string { let output = text const exitNotice = `\n\nCommand exited with code ${exitCode}` @@ -62,7 +58,7 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { if (texts.includes(echo)) { return update } - // The result's own text moves to `stdout`; content keeps anything else it carried. + // Retain non-output content while moving the raw result text to stdout. const content = (update.content ?? []).slice(1).filter((block) => { const text = contentText(block) return text === undefined || !texts.includes(text) @@ -94,8 +90,7 @@ function normalizeToolUpdate(update: ToolCallUpdate): ToolCallUpdate { } } -// OMP ends a `/compact` it could not do as a normal turn whose reply says `Compaction failed`; -// "Nothing to compact" and "Already compacted" are its no-ops (its RPC errors say the same). +// OMP reports compaction failure and no-ops in a normally completed turn's reply. const COMPACTION_FAILED = /\bcompaction failed\b/i const COMPACTION_NOOP = /\b(?:nothing to compact|already compacted)\b/i diff --git a/src/main/acp/acp-dialects/opencode-dialect.ts b/src/main/acp/acp-dialects/opencode-dialect.ts index 98566568f16..75fc7956fb0 100644 --- a/src/main/acp/acp-dialects/opencode-dialect.ts +++ b/src/main/acp/acp-dialects/opencode-dialect.ts @@ -2,8 +2,7 @@ import { z } from 'zod' import type { ToolCallUpdate } from '../generated/acp-protocol.generated' import type { AcpDialect } from './acp-dialect' -// OpenCode reports a command's output and exit code under `rawOutput.metadata`; the shared shape -// is `stdout` and `exitCode`. +// Normalize rawOutput.metadata to the shared stdout/exitCode fields. const commandRawOutputSchema = z.looseObject({ output: z.unknown().optional(), metadata: z diff --git a/src/main/acp/acp-errors.ts b/src/main/acp/acp-errors.ts index ef9ccb97cbc..f8f6a480c3f 100644 --- a/src/main/acp/acp-errors.ts +++ b/src/main/acp/acp-errors.ts @@ -12,8 +12,7 @@ export class AcpRpcError extends Error { } } -/** The agent's own error answer to a request: it read the request and refused it. Errors Orca - * raises about a request (a timeout, an unreadable answer, a closed connection) are not this. */ +/** A rejection received from the agent, distinct from local transport failures. */ export class AcpAgentError extends AcpRpcError { constructor(code: number, message: string, data?: unknown) { super(code, message, data) @@ -32,11 +31,10 @@ export class AcpAuthRequiredError extends AcpAgentError { } } -/** The agent printed its sign-in prompt instead of answering, so the message is Orca's, not its. */ +/** Orca's sign-in prompt message, distinct from an agent-authored error. */ export class AcpSignInPromptError extends AcpAuthRequiredError {} -/** Types an agent's sign-in answer. A sign-in prompt close stays itself: a copy would pass Orca's - * log words off as the agent's. */ +/** Preserve prompt errors so Orca's diagnostic text is never attributed to the agent. */ export function acpAuthRequiredError( error: AcpAgentError, authMethods?: AuthMethod[] @@ -46,7 +44,7 @@ export function acpAuthRequiredError( : new AcpAuthRequiredError(error.message, error.data, authMethods) } -/** Orca could not read the agent's answer; `data` keeps the raw answer, `issues` why it failed. */ +/** Retains the raw answer in `data` and validation failures in `issues`. */ export class AcpInvalidResponseError extends AcpRpcError { constructor( message: string, @@ -58,7 +56,6 @@ export class AcpInvalidResponseError extends AcpRpcError { } } -/** A line from the agent exceeded the framing limit, so the message it carried was never read. */ export class AcpFrameTooLargeError extends Error { constructor( readonly method: string | null, diff --git a/src/main/acp/acp-incoming-requests.ts b/src/main/acp/acp-incoming-requests.ts index 25c473f6bb5..b542724d418 100644 --- a/src/main/acp/acp-incoming-requests.ts +++ b/src/main/acp/acp-incoming-requests.ts @@ -28,8 +28,7 @@ export class AcpIncomingRequests { this.open.clear() } - // Each handler answers its own request (a permission answers `cancelled`); -32800 only if it - // throws. The runtime never answers for a live handler: an answer still being saved must win. + // Handlers own cancellation replies; do not preempt an answer still being saved. cancel(): void { for (const request of this.open.values()) { if (!request.cancelled) { @@ -63,7 +62,7 @@ export class AcpIncomingRequests { void Promise.race([ abandoned, Promise.resolve().then(() => { - // A cancelled request still reaches its handler, so a permission can answer `cancelled`. + // Cancellation still reaches the handler so permissions can answer `cancelled`. if (request.closed) { throw controller.signal.reason } diff --git a/src/main/acp/acp-json-rpc-peer.ts b/src/main/acp/acp-json-rpc-peer.ts index 60c5250e1f4..4e82096b508 100644 --- a/src/main/acp/acp-json-rpc-peer.ts +++ b/src/main/acp/acp-json-rpc-peer.ts @@ -22,13 +22,12 @@ import { export type { AcpJsonRpcMessage } from './acp-rpc-envelope' export type AcpRequestContext = { id: string | number | null; signal: AbortSignal } export type AcpPeerHandlers = { - // Void means handled; unsupported methods must throw AcpRpcError(-32601). The handler owns its - // request: once the signal aborts it still answers, or throws (-32800); unanswered ends at close(). + // Void is handled; unsupported methods throw -32601. Aborted handlers still reply or throw -32800. onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown onNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void onClose?: (error: Error) => void - /** A line on the input that is not a protocol message, including an unterminated last one. */ + /** Includes non-protocol output and unterminated final lines. */ onOtherLine?: (line: string, cut?: boolean) => void } export class AcpJsonRpcPeer { @@ -163,7 +162,7 @@ export class AcpJsonRpcPeer { return this.send({ jsonrpc: '2.0', method, params }) } - /** Aborts every open agent request's signal; each handler still sends its own answer. */ + /** Aborts signals; handlers still own replies. */ cancelIncomingRequests(): void { this.incoming.cancel() } diff --git a/src/main/acp/acp-launch-gate.ts b/src/main/acp/acp-launch-gate.ts index c07c6b7ae91..04dbdc7df2a 100644 --- a/src/main/acp/acp-launch-gate.ts +++ b/src/main/acp/acp-launch-gate.ts @@ -1,6 +1,4 @@ -// Fences an agent's chat starts on this host off while its shared sign-in or installed runtime is -// replaced or removed: a chat running meanwhile could save the old account back or run deleted files. -// In memory only; a restart ends it. +// Fence chat starts during host account/runtime changes to prevent stale writes or deleted-file use. import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { ProviderAcquisitionConnection } from '../provider-process/provider-acquisition-starts' @@ -15,7 +13,7 @@ export class AcpLaunchGate { this.refusal = `${label}'s setup is changing on this host. Try again after it finishes.` } - /** A start's way in: refused while an operation runs; an operation that begins aborts it. */ + /** Refuses starts during an operation and aborts existing starts when one begins. */ enter(signal: AbortSignal | undefined): { signal: AbortSignal; leave: () => void } { if (this.operating) { throw new AgentSessionPreSpawnError(new Error(this.refusal), { @@ -34,15 +32,13 @@ export class AcpLaunchGate { } } - /** Held from spawn until the child exits: a start that left the gate still runs until its chat is - * loaded, so an operation proves every such child gone, not only the loaded chats. */ + /** Retain spawned children even after acquisition finishes, before their chats are loaded. */ retain(connection: ProviderAcquisitionConnection): void { this.retained.add(connection) connection.onExit(() => this.retained.delete(connection)) } - /** `work`, once the starts under way have ended, `stopLive` has stopped the running chats and - * every retained child is proven gone; otherwise it rejects without running `work`. */ + /** Stop starts, live chats and retained root processes before running setup changes. */ async run(stopLive: () => Promise, work: () => Promise): Promise { if (this.operating) { throw new Error(this.refusal) diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 72ea1eb2595..ff6ab0546de 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -1,6 +1,3 @@ -// How Orca starts each ACP agent, as data: adding an agent is one more row (plus a dialect when it -// speaks protocol extensions). Nothing outside `acp-dialects/` branches on an agent's name. - import { join } from 'node:path' import type { AcpDialect } from './acp-dialects/acp-dialect' import { GROK_ACP_DIALECT } from './acp-dialects/grok-dialect' @@ -58,9 +55,7 @@ import { readAntigravitySignInLine } from '../antigravity/antigravity-sign-in-ur import type { AcpSignInWatch } from './acp-agent-connection' import type { AcpLaunchGate } from './acp-launch-gate' -/** How an agent lists its models before a chat: from its `initialize` answer (plus extension - * requests), from a listing command, from a session it opens and deletes, or not at all. Never - * `authenticate`. */ +/** Model discovery never authenticates interactively. */ export type AcpModelDiscovery = | { kind: 'initialize' @@ -70,7 +65,6 @@ export type AcpModelDiscovery = requestSessionFreeExtension(method: string, params: unknown): Promise } ): Promise - /** The listing marks the model the account is configured to run as its default. */ listingNamesConfiguredModel: boolean } | { @@ -78,28 +72,20 @@ export type AcpModelDiscovery = args: readonly string[] parse(stdout: string): AgentSessionModelOption[] listingNamesConfiguredModel: boolean - /** The releases whose listing matches what their chats offer; any other lists in a session - * when `otherReleasesListInSession`, else not at all and its chats' own lists fill the - * catalog. Absent lists on every release. */ + /** Unsupported releases use session discovery when enabled, otherwise live-chat catalogs. */ supportsVersion?(version: string): boolean otherReleasesListInSession?: true } | AcpSessionModelDiscovery | { kind: 'unavailable'; reason: string } -/** What a new chat offers, read from a session opened in the asking chat's folder and then - * deleted. An agent that cannot delete one is not asked unless it can keep that session in a - * folder Orca removes, and with no chat folder (a prewarm) none is opened, so no conversation or - * new project is left in its history. */ +/** Requires a chat folder and session deletion or disposable storage to avoid persistent history. */ export type AcpSessionModelDiscovery = { kind: 'session' listingNamesConfiguredModel: boolean - /** Arguments that keep the listing's session in `folder` instead of the agent's own store. */ sessionFolderArgs?(folder: string): string[] } -/** Whether some release lists in a session in the asking chat's folder, so the listing needs one - * and reads that folder's project config. */ export function acpListingNeedsChatFolder(discovery: AcpModelDiscovery): boolean { return ( discovery.kind === 'session' || @@ -108,72 +94,55 @@ export function acpListingNeedsChatFolder(discovery: AcpModelDiscovery): boolean } export type AcpLaunchSpec = { - /** The Orca agent id, which names the agent's records, its catalog label and its settings. */ agent: TuiAgent command: string /** The terminal agent's Command setting may name a different program than this chat server. */ ignoreAgentCommandSetting?: true - /** Built per launch: `fullAccess` is the chat's own Full access permission mode; - * `pluginDir` is a plugin folder `visualsSkill` asked the agent to load. */ args(input: { fullAccess: boolean; pluginDir: string | null }): string[] - /** Laid over the child's environment last, after the account and the user's own variables. */ + /** Applied after the account and user environment. */ env: Readonly> - /** Rewrites what the child would inherit from Orca's own plumbing; returns the keys it must not - * inherit at all. `inherited` is the environment the child process starts from. */ + /** Scrubs inherited Orca plumbing and returns keys to omit entirely. */ scrubEnvironment?(env: Record, inherited: NodeJS.ProcessEnv): string[] dialect: AcpDialect - /** The agent's own sign-in command, for a person to run when it reports auth required. */ loginCommand: readonly string[] - /** Which advertised sign-in method to use when the agent reports auth required, read from the - * environment it was launched with (on its own machine); none leaves it not signed in. */ + /** Selects an advertised method using the execution host's environment; undefined leaves it signed out. */ authMethod?(input: { advertised: readonly string[] env: Readonly> }): string | undefined - /** The agent's browser sign-in on its output: a valid sign-in line ends the chat signed out. */ + /** A valid sign-in output line closes the chat signed out. */ signIn?: Omit - /** The account each chat pins, and how a launch points the agent at it. */ account: AcpAccountBinding - /** The ascending `--version` minimums a structured chat needs (`agentCliMinimumVersion`), asked - * before a create and again at every launch; absent runs whatever is installed. */ + /** Ascending release-line minimums, checked before creation and at every launch. */ minimumVersions?: readonly string[] - /** Where the agent installs its own binary, searched after PATH. */ + /** Searched after PATH. */ installDirectories(input: { env: Readonly>; homePath: string }): string[] - /** A pinned copy Orca installs under its data folder when the person asks; a start runs an - * installed release before PATH and never installs one. A spec with one names no - * `minimumVersions`. */ + /** Installed only on request; takes precedence over PATH and excludes `minimumVersions`. */ managedRuntime?: ManagedRuntime - /** The agent's `mode` config option value per chat permission mode, through which it enforces - * the mode itself; Orca then answers none of its permission requests for the person. */ + /** Agent-enforced modes; Orca does not answer its permission requests. */ permissionModeConfig?: { values: { ask: string } & Partial> } - /** The agent compacts its conversation when sent `/compact` as a prompt; off hides `/compact`. */ + /** Enables /compact as a prompt command. */ compaction?: true - /** Throws when the chat's own arguments would send a copied conversation's CLI elsewhere than - * the store the copy is written to. */ + /** Rejects copy arguments that route the CLI away from the copy's store. */ assertCopyLaunchArgs?: (args: readonly string[]) => void - /** The agent's own store of a session's user messages, read for restart recovery only. */ + /** Used only for restart recovery. */ readStoredUserMessages?: AcpStoredUserMessagesReader modelDiscovery: AcpModelDiscovery - /** How a launch loads the inline-visuals skill; absent, the agent's chats have no visuals. */ visualsSkill?: AcpVisualsSkillLoader - /** How a chat adopts a conversation the agent's CLI recorded; null when it cannot. */ transcriptImport: StructuredAgentTranscriptImport | null - /** How the agent's chats own the Session History rows their conversations list as. */ sessionHistory: StructuredAgentSessionHistory - /** Each process gets its own temp folder: the agent's temp files collide across processes. */ + /** Isolate processes whose agent temp files collide. */ privateTempDirectory?: true - /** Reopens a chat's session with `session/resume` even when the agent can also load it. */ + /** Prefer session/resume even when loading is supported. */ resumePreference?: 'resume' - /** Holds this host's chat starts off while its sign-in or runtime is replaced or removed. */ launchGate?: AcpLaunchGate } const GROK_LAUNCH_SPEC: AcpLaunchSpec = { agent: 'grok', command: 'grok', - // `--always-approve` only for full access, as the chat's permission mode chooses. args: ({ fullAccess, pluginDir }) => [ 'agent', ...(fullAccess ? ['--always-approve'] : []), @@ -183,7 +152,7 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { env: {}, dialect: GROK_ACP_DIALECT, loginCommand: agentSessionSignInFor('grok')?.loginCommand ?? [], - // An API key in Grok's own environment, else the sign-in Grok already cached; never interactive. + // Prefer the launch environment's API key, then cached sign-in; never prompt interactively. authMethod: ({ advertised, env }) => env.XAI_API_KEY?.trim() && advertised.includes('xai.api_key') ? 'xai.api_key' @@ -192,8 +161,7 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { : undefined, account: directoryAccountBinding('GROK_HOME', (homePath) => join(homePath, '.grok')), installDirectories: ({ env }) => (env.GROK_HOME ? [join(env.GROK_HOME, 'bin')] : []), - // Grok lists its models in `initialize`, before and without any session. Its `currentModelId` - // there can differ from what a session runs, so a chat started with no pick names the default. + // initialize's currentModelId can differ from a session's; leave the default to the chat. modelDiscovery: { kind: 'initialize', read: readGrokModelCatalog, @@ -205,21 +173,18 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { sessionHistory: GROK_SESSION_HISTORY } -// 1.18.31 is the release the recorded sessions capture; 2.0.14 the 2.x release verified to start the -// private child below. +// Verified release lines: 1.18.31 recordings and 2.0.14 private-child startup. const OPENCODE_MINIMUM_VERSIONS = ['1.18.31', '2.0.14'] as const -// `opencode acp` on 1.x serves in-process; on 2.x it starts a private `opencode serve --stdio` child -// with this environment and ends it with stdin, so both lines run the chat's own account. +// 1.x serves in-process; 2.x passes this environment to a private serve --stdio child. function openCodeLaunchSpec( row: Pick ): AcpLaunchSpec { return { ...row, - // OpenCode has no bypass flag: full access answers each permission request yes. + // No bypass flag; full access approves permission requests individually. args: () => ['acp'], - // Applied last: the client name ACP sessions report, and no question tool, since Orca shows no - // question form yet (1.x would wait on one forever; 2.x drops it). + // Disable unsupported question forms: 1.x waits forever; 2.x drops them. env: { OPENCODE_CLIENT: 'acp', OPENCODE_ENABLE_QUESTION_TOOL: 'false' }, scrubEnvironment: scrubOpenCodeAcpEnvironment, dialect: OPENCODE_ACP_DIALECT, @@ -230,7 +195,6 @@ function openCodeLaunchSpec( visualsSkill: loadOpenCodeVisualsSkill, compaction: true, assertCopyLaunchArgs: assertOpenCodeCopyUsesLocalStore, - // Finds a conversation of either line in the one database both lines write. transcriptImport: OPENCODE_TRANSCRIPT_IMPORT, sessionHistory: OPENCODE_SESSION_HISTORY } @@ -240,12 +204,11 @@ const OPENCODE_LAUNCH_SPEC = openCodeLaunchSpec({ agent: 'opencode', command: 'opencode', minimumVersions: OPENCODE_MINIMUM_VERSIONS, - // Its `initialize` names no models; 1.x lists them with a command, 2.x in a session it deletes. + // 1.x lists via CLI; 2.x requires a disposable session. modelDiscovery: { kind: 'command', args: OPENCODE_MODEL_LISTING_ARGS, parse: parseOpenCodeModelListing, - // The listing marks no default; a chat started with no pick names it. listingNamesConfiguredModel: false, // 2.x and later reject `--verbose`, and their listings omit providers that report after startup. supportsVersion: (version) => compareAppVersions(version, '2.0.0') < 0, @@ -253,8 +216,7 @@ const OPENCODE_LAUNCH_SPEC = openCodeLaunchSpec({ } }) -// Since 2.0.0 `opencode2` is an alias OpenCode's installer writes beside `opencode`, and the only -// name for 2.x where `opencode` is still 1.x. Its chats share OpenCode's account and history rows. +// opencode2 is the installer's 2.x alias; both names share accounts and history. const OPENCODE2_LAUNCH_SPEC = openCodeLaunchSpec({ agent: 'opencode2', command: 'opencode2', @@ -262,26 +224,21 @@ const OPENCODE2_LAUNCH_SPEC = openCodeLaunchSpec({ modelDiscovery: { kind: 'session', listingNamesConfiguredModel: false } }) -// OMP serves ACP through `omp acp`; its environment reaches it as the user set it. const OMP_LAUNCH_SPEC: AcpLaunchSpec = { agent: 'omp', command: 'omp', - // Full access answers each permission request yes. + // Full access approves permission requests individually. args: ({ pluginDir }) => ['acp', ...(pluginDir ? ['--plugin-dir', pluginDir] : [])], env: {}, dialect: OMP_ACP_DIALECT, loginCommand: agentSessionSignInFor('omp')?.loginCommand ?? [], - // The directory OMP's terminal chats read too; its default is OMP's own. account: directoryAccountBinding('PI_CODING_AGENT_DIR', (homePath) => join(homePath, '.omp', 'agent') ), - // OMP's installers use directories the shared resolver already searches after PATH. installDirectories: () => [], - // 17.0.5 is the release verified to serve `omp acp`. + // First release verified to serve ACP. minimumVersions: ['17.0.5'], - // `omp models --json` lists every model without the `enabledModels` filter a chat applies, so it - // could offer models a chat refuses. A session lists exactly what a chat in that folder offers; - // `omp acp` cannot delete one, so it is kept out of OMP's store (17.0.5 and 18.12.2 verified). + // CLI listings include disabled models; use a disposable session (OMP cannot delete sessions). modelDiscovery: { kind: 'session', listingNamesConfiguredModel: false, @@ -293,37 +250,34 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = { sessionHistory: OMP_SESSION_HISTORY } -// `agy` keeps its own conversations apart from the ACP server's, so its arguments resume none. +// agy conversations are separate from the ACP server's and cannot resume these sessions. const ANTIGRAVITY_SESSION_HISTORY: StructuredAgentSessionHistory = { rowAgents: ['antigravity'], rowSessionId: (link) => link.handle.nativeId, parseResumeArgs: () => null } -// Google's Antigravity ACP server, which Orca installs (the `agy` CLI has no ACP mode). Its chats -// sign in to a folder of their own on each host; an `agy` login is not theirs. +// The managed ACP server has its own sign-in; agy provides neither ACP nor its account. export const ANTIGRAVITY_LAUNCH_SPEC = { agent: 'antigravity', command: ANTIGRAVITY_ACP_COMMAND, ignoreAgentCommandSetting: true, - // The registry's Linux launch: without `--uid=` the server, started as root, drops to nobody. + // Without --uid=, the Linux server drops root launches to nobody. args: () => (process.platform === 'linux' ? ['--uid='] : []), env: ANTIGRAVITY_ACP_ENV, scrubEnvironment: scrubAntigravityAcpEnvironment, dialect: ANTIGRAVITY_ACP_DIALECT, loginCommand: agentSessionSignInFor('antigravity')?.loginCommand ?? [], - // Signs in with the Google account the folder saved; without one, the server prints a sign-in link. authMethod: ({ advertised }) => advertised.includes('oauth-personal') ? 'oauth-personal' : undefined, signIn: { read: readAntigravitySignInLine, - // The host's sign-in status is the server's latest answer, as chats meet it. answered: answerAntigravityChatAuth }, account: antigravityChatAccount, installDirectories: () => [], managedRuntime: ANTIGRAVITY_ACP_RUNTIME, - // Its yolo mode still asks the person questions and administrative reviews. + // Yolo still requests questions and administrative reviews. permissionModeConfig: { values: { ask: 'default', 'accept-edits': 'auto_edit', 'full-access': 'yolo' } }, diff --git a/src/main/acp/acp-model-catalog-probe.test.ts b/src/main/acp/acp-model-catalog-probe.test.ts index ca9775875eb..505e2973f74 100644 --- a/src/main/acp/acp-model-catalog-probe.test.ts +++ b/src/main/acp/acp-model-catalog-probe.test.ts @@ -14,7 +14,7 @@ import type { ProviderProcessLaunch } from '../provider-process/provider-process import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-account-home' const GROK = acpLaunchSpecFor('grok')! -// The user's own OpenCode environment, read without the app's managed-account service. +// Bypass the app's managed-account service for this fixture. const OPENCODE: AcpLaunchSpec = { ...acpLaunchSpecFor('opencode')!, account: openCodeAcpAccountBinding(() => ({ @@ -33,7 +33,7 @@ const MODEL_STATE = { name: 'Grok Build', _meta: { supportsReasoningEffort: true, - // The session's own pick, which must not become a catalog default. + // A session pick must not become a catalog default. reasoningEffort: 'low', reasoningEfforts: [ { value: 'low', id: 'fast', label: 'Fast' }, @@ -182,9 +182,7 @@ describe('ACP model catalog probes', () => { { id: 'grok-build', label: 'Grok Build', - // `initialize`'s currentModelId is not what a session runs, so it names no default. isDefault: false, - // Grok's option ids, which its effort config option takes, and its own default only. efforts: [ { value: 'fast', label: 'Fast' }, { value: 'deep', label: 'Deep' } @@ -244,7 +242,6 @@ describe('ACP model catalog probes', () => { expect.objectContaining({ command: '/opt/opencode/bin/opencode', args: ['models', '--verbose'], - // Where the project's opencode.json is read, so it lists what a chat there offers. cwd: '/work/project', env: expect.objectContaining({ OPENCODE_CLIENT: 'acp' }) }), @@ -255,7 +252,6 @@ describe('ACP model catalog probes', () => { ]) }) - // Every 2.x release rejects `--verbose`, and its own listings omit providers that report late. it('lists OpenCode 2.x from a session it opens and deletes, with no listing command', async () => { const { agent, deps, launches } = scriptedOpenCode2(true) const success = await createAcpModelCatalogProbe(OPENCODE, deps)( @@ -289,7 +285,6 @@ describe('ACP model catalog probes', () => { expect(agent.frames.map((frame) => frame.method)).toEqual(['initialize']) }) - // `omp acp` advertises no delete, so its listing session goes to a folder the probe removes. it('lists OMP from a session kept in a folder it removes, in the chat folder', async () => { const { agent, deps, launches } = scriptedOpenCode2(false) const dataDirectory = await mkdtemp(join(tmpdir(), 'orca-omp-listing-')) @@ -352,7 +347,7 @@ describe('ACP model catalog probes', () => { warn.mockRestore() }) - // OpenCode may already have saved the session it never named; nothing is left to delete it by. + // An interrupted create can save a session without returning its ID. it('stops a listing cut off while OpenCode 2.x creates its session, without deleting', async () => { const { agent, deps, closed } = scriptedOpenCode2(true) agent.on('session/new', () => {}) diff --git a/src/main/acp/acp-model-catalog-probe.ts b/src/main/acp/acp-model-catalog-probe.ts index dd96eef0b2e..4113f4c0e73 100644 --- a/src/main/acp/acp-model-catalog-probe.ts +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -29,14 +29,13 @@ import { import { AcpStructuredOptions } from './acp-structured-options' import type { InitializeResponse } from './generated/acp-protocol.generated' -/** The part of an ACP connection a session-free listing may use: no session method is reachable. */ +/** Session-free capability boundary: no session methods are exposed. */ export type AcpModelCatalogConnection = { initialize(): Promise requestSessionFreeExtension(method: string, params: unknown): Promise close(): Promise } -/** A connection that may open one session, to read what a new chat offers, and delete it. */ export type AcpModelCatalogSessionConnection = Omit< AcpModelCatalogConnection, 'requestSessionFreeExtension' @@ -46,34 +45,28 @@ export type AcpModelCatalogSessionConnection = Omit< } export type AcpModelCatalogProbeDeps = AcpLaunchInvocationDeps & { - /** Test seams; production spawns through the shared supervised runner and ACP connection. */ probeVersion?: AgentCliVersionReader connect?: (launch: ProviderProcessLaunch) => AcpModelCatalogConnection connectSession?: (launch: ProviderProcessLaunch) => AcpModelCatalogSessionConnection runListing?: typeof runAgentModelCatalogListing } -/** - * Lists an ACP agent's models before a chat, under the binary, account and scrubbed environment its - * chats launch with. A release that runs no structured chat lists nothing, nor does one its - * discovery names no listing for. - */ +/** Uses the same binary, account and scrubbed environment as chats. */ export function createAcpModelCatalogProbe( spec: AcpLaunchSpec, deps: AcpModelCatalogProbeDeps ): AgentModelCatalogProbe { return async (accountHome, options): Promise => { const discovery = spec.modelDiscovery - // Registered as unavailable instead (`acpModelCatalogDiscovery`); never built for one. if (discovery.kind === 'unavailable') { throw new Error(discovery.reason) } const workspace = options?.workspacePath - // Known without spawning anything: a listing that may open a session needs a chat's folder. + // Reject folderless session discovery before spawning even a version probe. if (!workspace && acpListingNeedsChatFolder(discovery)) { throw folderlessListing(spec) } - // Spawned from home; a listing reads a project's config only in the chat folder it is asked for. + // Start in home; only explicit chat-folder discovery reads project config. const cwd = deps.homePath ?? homedir() const { command, env, envToDelete } = await resolveAcpLaunchInvocation(spec, accountHome, { ...deps, @@ -111,7 +104,7 @@ export function createAcpModelCatalogProbe( if (!workspace) { throw folderlessListing(spec) } - // Kept out of the agent's own store, and removed once `close` has ended the process. + // Remove disposable session storage after the process closes. const folderArgs = listing.sessionFolderArgs const folder = folderArgs ? await acpRunFolder(join(deps.dataDirectory, 'acp-listing-sessions', spec.agent)) @@ -146,7 +139,6 @@ const folderlessListing = (spec: AcpLaunchSpec) => type AcpModelListing = Exclude -/** How `version` lists: its discovery, unless that is a command this release has no listing for. */ function listingFor(discovery: AcpModelListing, version: string | null): AcpModelListing | null { if (discovery.kind !== 'command' || !discovery.supportsVersion) { return discovery @@ -159,8 +151,6 @@ function listingFor(discovery: AcpModelListing, version: string | null): AcpMode : null } -/** What a new chat in `workspace` offers, read from a session opened only to be deleted, or kept - * in a folder the probe removes. */ async function readSessionModels( spec: AcpLaunchSpec, listing: AcpSessionModelDiscovery, @@ -173,7 +163,7 @@ async function readSessionModels( throw new Error(`${spec.agent} cannot delete a session, so it lists its models only in chats`) } const started = await connection.start({ cwd: workspace, mcpServers: [] }) - // Best effort: a failed delete, or a listing stopped mid-create, leaves one empty conversation. + // A failed delete or interrupted create may leave an empty conversation. if (deletes) { await connection.deleteSession(started.sessionId).catch((error: unknown) => { console.warn(`[acp-model-catalog] ${spec.agent} kept its listing session:`, error) diff --git a/src/main/acp/acp-oversized-lines.ts b/src/main/acp/acp-oversized-lines.ts index a4329b84782..7c1add349b9 100644 --- a/src/main/acp/acp-oversized-lines.ts +++ b/src/main/acp/acp-oversized-lines.ts @@ -9,7 +9,7 @@ export type AcpOversizedLineTarget = { diagnose: (message: string) => void } -/** The Codex reader's prefix classification: settle whoever was owed the message that was lost. */ +/** Settle requests whose replies were lost to the framing limit. */ export function settleOversizedAcpLine( rejected: NdjsonRejectedRecord & { kind: 'line-too-long' }, target: AcpOversizedLineTarget @@ -31,8 +31,8 @@ export function settleOversizedAcpLine( (method) => new AcpFrameTooLargeError(method, observedBytes, maxLineBytes) ) } else if (record.kind === 'response-unknown') { - // A numeric id with no readable result: any pending call could be the one that never settles. + // An unreadable response ID leaves every pending call suspect. target.close(new AcpFrameTooLargeError(null, observedBytes, maxLineBytes)) } - // A notification or non-JSON-RPC output (a stray log line) settles nothing; closing would end the session. + // Ignore oversized notifications and stray output without ending the session. } diff --git a/src/main/acp/acp-peer-limits.ts b/src/main/acp/acp-peer-limits.ts index 6a8f29a33dd..742a38af55c 100644 --- a/src/main/acp/acp-peer-limits.ts +++ b/src/main/acp/acp-peer-limits.ts @@ -6,7 +6,7 @@ export type AcpPeerOptions = { maxPendingRequests?: number maxIncomingRequests?: number requestTimeoutMs?: number | null - /** A managed connection waits for process exit rather than treating stdout EOF as exit. */ + /** Managed connections use process exit, not stdout EOF. */ closeOnInputEnd?: boolean } @@ -41,7 +41,7 @@ export function requestTimeout(value: number | null | undefined): number | null return value } -/** Each start step's own bound; turns are unbounded and option writes carry their caller's. */ +// Bound startup steps separately; prompts remain unbounded. const ACP_START_STEP_TIMEOUT_MS: Readonly>> = { initialize: 60_000, 'session/new': 300_000, diff --git a/src/main/acp/acp-permission-requests.test.ts b/src/main/acp/acp-permission-requests.test.ts index d04649beb36..bbd4a133541 100644 --- a/src/main/acp/acp-permission-requests.test.ts +++ b/src/main/acp/acp-permission-requests.test.ts @@ -79,7 +79,6 @@ describe('ACP permission requests', () => { }, 'handler failed: Error: renderer gone' ], - // JSON.parse yields an untyped value, so a deliberately malformed reply needs no assertion. [ 'the handler answers nonsense', () => JSON.parse('{"outcome":"yes"}'), diff --git a/src/main/acp/acp-permission-requests.ts b/src/main/acp/acp-permission-requests.ts index 4c4aa0b8f02..0c9583b59e6 100644 --- a/src/main/acp/acp-permission-requests.ts +++ b/src/main/acp/acp-permission-requests.ts @@ -22,7 +22,7 @@ const routingSchema = z.looseObject({ }) const optionRoutingSchema = z.looseObject({ optionId: z.string() }) -// Keeps every field the schema does not know or can read; drops (and names) the unreadable ones. +// Preserve unknown fields for forward compatibility; report malformed known fields. function readableFields( knownFields: Record, value: Record, @@ -40,7 +40,7 @@ function readableFields( return kept } -/** Validates only what answering needs (session, tool call id, options with ids); null if unusable. */ +/** Require routing IDs and usable options; tolerate malformed optional fields. */ export function readAcpPermissionRequest( params: unknown, diagnose: (message: string) => void @@ -92,7 +92,7 @@ export function readAcpPermissionRequest( return request.data } -/** Asks the caller; any answer Orca cannot send (a throw, a bad reply, an unoffered option) is `cancelled`. */ +/** Cancel on handler failure, malformed replies or unoffered options. */ export function answerAcpPermission( request: RequestPermissionRequest, context: AcpRequestContext, diff --git a/src/main/acp/acp-private-temp.ts b/src/main/acp/acp-private-temp.ts index c271d09261a..b041be6f91f 100644 --- a/src/main/acp/acp-private-temp.ts +++ b/src/main/acp/acp-private-temp.ts @@ -1,6 +1,4 @@ -// A temp folder of its own for each process of an agent whose temp files collide across processes, -// under Orca's locked data root. ACP children talk over stdio, so one left by an earlier host run -// can never be reattached: the host's first start of the agent clears the whole root. +// Stdio children cannot reattach after a host restart, so stale temp folders can be cleared. import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { join } from 'node:path' @@ -11,8 +9,7 @@ import type { StructuredAgentSessionLogger } from '../native-chat/agent-session- const wipes = new Map>() -/** A new folder under `root` for one agent process. The host's first use of `root` clears it: - * a crash, or a handle Windows holds past the kill, can leave an earlier run's folder behind. */ +/** Clear crash leftovers on first use of a root, then isolate each process in a new folder. */ export async function acpRunFolder( root: string, logger?: StructuredAgentSessionLogger @@ -32,7 +29,6 @@ export async function acpRunFolder( return { path, remove: () => remove(path) } } -/** `deps.connect`, giving the child a new folder as its temp and removing it once the child exits. */ export async function acpPrivateTempConnect< Connection extends Pick = AcpStructuredConnection >( diff --git a/src/main/acp/acp-prompt-content.test.ts b/src/main/acp/acp-prompt-content.test.ts index 90750905664..80a85796945 100644 --- a/src/main/acp/acp-prompt-content.test.ts +++ b/src/main/acp/acp-prompt-content.test.ts @@ -135,7 +135,7 @@ describe('ACP prompt content', () => { }) it('keeps a message with images within one protocol line', async () => { - // The images fit their raw limit, but with this much text the prompt would pass the line. + // Raw images fit, but their encoded payload plus text exceeds the protocol line limit. const text = 'x'.repeat(8 * 1024 * 1024) const image = { type: 'image-ref' as const, diff --git a/src/main/acp/acp-prompt-content.ts b/src/main/acp/acp-prompt-content.ts index 5aa49cbb754..281b1225d10 100644 --- a/src/main/acp/acp-prompt-content.ts +++ b/src/main/acp/acp-prompt-content.ts @@ -1,6 +1,3 @@ -// Native images require advertised support and bounded materialization; host paths retain -// their ordinary file-reference representation whenever native delivery cannot fit. - import { extname, isAbsolute } from 'node:path' import { fileURLToPath } from 'node:url' import { @@ -28,14 +25,14 @@ import { acpSessionAcceptsImages } from './acp-structured-options' import type { ContentBlock } from './generated/acp-protocol.generated' const ACP_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']) -/** A prompt is one JSON-RPC line; this leaves room for the request around its blocks. */ +// Reserve space for the JSON-RPC envelope around the prompt blocks. const ACP_PROMPT_LINE_BUDGET = resolveAcpPeerOptions().maxLineBytes - 64 * 1024 -/** One message's images together, raw: base64 makes each 4 bytes of them 3 more. */ +// Budget raw images for base64's 4:3 expansion and leave room for text. export const ACP_PROMPT_IMAGE_MAX_BYTES = Math.floor((ACP_PROMPT_LINE_BUDGET * 3) / 4) - 1024 * 1024 const MAX_IMAGE_COUNT = 20 const READ_TIMEOUT_MS = 15_000 -/** Why Orca did not send a message; never the agent's refusal. */ +/** A local dispatch refusal, distinct from an agent rejection. */ export class AcpPromptContentError extends Error { constructor(readonly failure: SubmissionRejectionFact) { super(`ACP prompt refused: ${failure.kind}`) @@ -47,7 +44,6 @@ function attachmentProblem(attachment: AgentSessionAttachmentProblem): AcpPrompt return new AcpPromptContentError(agentSessionFailureFact('attachmentInvalid', { attachment })) } -/** The image's absolute path on this host, from its path or a `file:` URL. */ function hostImagePath(block: NativeChatImageRefBlock): string | null { let path = block.path if (!path && block.url?.startsWith('file:')) { @@ -121,12 +117,11 @@ async function imageBlock( return { block: { type: 'image', mimeType, data: data.toString('base64') }, bytes: data.length } } -/** The prompt for `body`; throws `AcpPromptContentError` for what Orca will not send. */ export async function acpPromptBlocks( body: AgentJournalMessageItem, acceptsImages: boolean ): Promise { - // Reserve text and path slots, including those after each optional native image. + // Reserve later text and file references before allocating image capacity. const blocks: ContentBlock[] = body.blocks.map((block) => { if (block.type === 'text') { return { type: 'text', text: block.text } @@ -148,7 +143,6 @@ export async function acpPromptBlocks( throw attachmentProblem({ reason: 'unsupportedType' }) } if (!acceptsImages) { - // As the composer would have written it, had it known before the agent's start answered. const path = hostImagePath(block) if (!path) { throw attachmentProblem({ reason: 'unsupportedType' }) @@ -180,22 +174,18 @@ export async function acpPromptBlocks( blocks[index] = image.block encodedBytes += blockBytes(image.block) - previousBytes } - // Only images can carry a message past one line here; their words are the refusal's. if (imageCount > 0 && Buffer.byteLength(JSON.stringify(blocks)) > ACP_PROMPT_LINE_BUDGET) { throw attachmentProblem({ reason: 'totalTooLarge', limit: ACP_PROMPT_IMAGE_MAX_BYTES }) } return blocks } -/** A message that is only one of the dialect's withheld commands, with no attachment. */ function isWithheldCommand(body: AgentJournalMessageItem, withheld: readonly string[]): boolean { const [block, ...rest] = body.blocks const text = block?.type === 'text' && rest.length === 0 ? block.text.trim() : '' return text.startsWith('/') && withheld.includes(text.slice(1)) } -/** The prompt for `body` to `session`'s agent, or Orca's refusal of it in words that name the - * agent. Images go only where `acpSessionAcceptsImages` allows them. */ export async function acpDispatchPrompt( body: AgentJournalMessageItem, session: { diff --git a/src/main/acp/acp-prompt-turns.ts b/src/main/acp/acp-prompt-turns.ts index e525e56a665..9d71f51f7c9 100644 --- a/src/main/acp/acp-prompt-turns.ts +++ b/src/main/acp/acp-prompt-turns.ts @@ -15,14 +15,12 @@ export type AcpPromptTurn = { requestedAt: number opened: boolean durationMs?: number - /** A `/compact` running as the turn the host opened for it. */ compaction?: AcpCompaction } -/** An injected identity is known before any provider output arrives. */ export class AcpPromptTurns { current?: AcpPromptTurn - /** The last prompt that ended, so its answer can still add the failure reason the end lacked. */ + /** Retain the ended prompt so its reply can supply a late failure reason. */ last?: AcpPromptTurn constructor( @@ -31,8 +29,7 @@ export class AcpPromptTurns { private readonly agentName?: string ) {} - /** `compactionTurn` runs a `/compact` as the command turn the host already opened, which its - * frames join and its answer ends. */ + /** Reuse the host's already-open command turn for compaction. */ open( clientMessageId: string, at: number, @@ -57,14 +54,13 @@ export class AcpPromptTurns { return { promptId: turn, events: injected ? [] : this.start(turn, at) } } - /** The running `/compact`, when `turn` is its turn. */ compacting(turn: string | undefined): AcpPromptTurn | undefined { return turn !== undefined && this.current?.turn === turn && this.current.compaction ? this.current : undefined } - /** True for a frame the running compaction `turn` read as its own. */ + /** True suppresses rendering the compaction's frame. */ absorbCompaction( turn: string | undefined, extension: AcpCompactionFrame | undefined, @@ -74,7 +70,6 @@ export class AcpPromptTurns { return prompt !== undefined && absorbAcpCompactionFrame(prompt, extension, update) } - /** The running compaction's result row and end, when `turn` is its turn. */ endCompaction(turn: string, ending: AcpTurnEnding): ProviderTimelineEvent[] | undefined { const prompt = this.compacting(turn) if (!prompt?.compaction) { @@ -95,7 +90,7 @@ export class AcpPromptTurns { this.current = undefined } - /** Forgets a prompt the agent refused before its turn opened; false for any other. */ + /** Only discard refusals received before the turn opens. */ refuse(clientMessageId: string): boolean { if (this.current?.clientMessageId !== clientMessageId || this.current.opened) { return false @@ -122,8 +117,7 @@ export class AcpPromptTurns { } } -/** A hook-denied turn completed rather than stopped: its row, under one identity however often - * the agent reports the end, then the end. */ +/** Hook denial completes the turn; a stable row key deduplicates repeated endings. */ export function acpTurnEndEvents( thread: string, turn: string, diff --git a/src/main/acp/acp-recovery-history.ts b/src/main/acp/acp-recovery-history.ts index 07ee22aa410..d23a8f49651 100644 --- a/src/main/acp/acp-recovery-history.ts +++ b/src/main/acp/acp-recovery-history.ts @@ -1,7 +1,4 @@ -// Restart recovery for an ACP agent that keeps its own store: whether a message a crash left -// unconfirmed reached the agent. Presence only: a match settles it as sent, while absence proves -// nothing, so nothing is ever called undelivered. Bounded, and null on any failure; the user can -// always resend or discard, so this never stands between them and the chat. +// Recovery proves delivery only by presence; missing history never proves non-delivery. import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' import { agentSessionSendBodyFingerprint } from '../../shared/structured-agent-session-send-mutation' @@ -21,24 +18,21 @@ import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-a const RECOVERY_READ_MS = 5_000 -/** A person's message as the agent's own store holds it. */ export type AcpStoredUserMessage = { - /** The store's id for it, claimed at most once. */ + /** Provider ID, claimed at most once. */ id: string blocks: NativeChatBlock[] - /** Epoch ms the agent recorded it, on this machine's clock. */ + /** Epoch milliseconds on the execution host's clock. */ createdAt: number } -/** Reads the user messages of one provider session from the agent's own store; null when it cannot. */ export type AcpStoredUserMessagesReader = (input: { env: Record providerSessionId: string signal: AbortSignal }) => Promise -/** Sends a crash may have left unconfirmed; a queued card was never handed over, so the agent - * cannot hold it. */ +// Queued submissions never reached the agent. function sendsInDoubt(load: JournalLoad): AgentJournalSubmission[] { return [...load.state.submissions.values()].filter( (submission) => @@ -55,13 +49,7 @@ function fingerprintOf(sessionId: string, message: AcpStoredUserMessage): string }) } -/** - * The agent's store keeps no id of Orca's, so a stored message can only match a send by content. - * Each send the journal already accepted first claims the earliest stored copy of its text written - * no earlier than it was sent, so an identical message that did reach the agent is never offered as - * the one in doubt; what is left, from the first send in doubt on, is the window. Claiming more than - * a send's own copy only leaves a send in doubt unconfirmed, never wrongly confirmed. - */ +/** Claim accepted sends' matching copies first to avoid falsely confirming identical sends in doubt. */ function recoveryWindow( load: JournalLoad, inDoubt: readonly AgentJournalSubmission[], @@ -94,7 +82,6 @@ function recoveryWindow( ) } -/** The window for one chat; null for an agent whose store Orca does not read. */ export async function readAcpRecoveryHistory( input: Pick & { spec: Pick @@ -129,7 +116,7 @@ export async function readAcpRecoveryHistory( return null } return { - // Absence proves nothing: neither the start of the read nor the end of the agent's work is known. + // Neither the read boundary nor completion of the agent's work is known. boundaryConsistent: false, turnInFlight: true, items: recoveryWindow(load, inDoubt, messages) diff --git a/src/main/acp/acp-scripted-agent.test-support.ts b/src/main/acp/acp-scripted-agent.test-support.ts index 97907824c77..80f0135bc47 100644 --- a/src/main/acp/acp-scripted-agent.test-support.ts +++ b/src/main/acp/acp-scripted-agent.test-support.ts @@ -44,7 +44,6 @@ export class AcpScriptedAgent { on(method: string, handler: Handler): void { this.methods.set(method, handler) } - /** Confirms every `session/set_config_option`, answering `base` plus the option it set. */ confirmConfigOptions(base: readonly unknown[]): void { this.on('session/set_config_option', (frame) => { const { configId: id, value } = setConfigOptionSchema.parse(frame.params) diff --git a/src/main/acp/acp-scripted-child.test-fixture.ts b/src/main/acp/acp-scripted-child.test-fixture.ts index f8f2ccd882b..6c324b61094 100644 --- a/src/main/acp/acp-scripted-child.test-fixture.ts +++ b/src/main/acp/acp-scripted-child.test-fixture.ts @@ -77,7 +77,7 @@ export function acpScriptedChild(agent: AcpLaunchSpec['agent']): ScriptedAgentCh script.reply(frame, { configOptions }) }) script.on('session/set_model', (frame) => script.reply(frame, {})) - // Grok's subagent-stop probe: the handler refuses the missing id, so stops are offered. + // Refusing a missing ID proves the subagent-stop route exists. script.on('_x.ai/subagent/cancel', (frame) => script.fail( frame, @@ -86,7 +86,7 @@ export function acpScriptedChild(agent: AcpLaunchSpec['agent']): ScriptedAgentCh 'invalid params: missing field `subagentId`' ) ) - // Its background-task stop probe, answered the same way. + // Prove the background-task Stop route the same way. script.on('_x.ai/task/kill', (frame) => script.fail( frame, diff --git a/src/main/acp/acp-scripted-connection.test-fixture.ts b/src/main/acp/acp-scripted-connection.test-fixture.ts index a5d2dc29953..402fd8411ec 100644 --- a/src/main/acp/acp-scripted-connection.test-fixture.ts +++ b/src/main/acp/acp-scripted-connection.test-fixture.ts @@ -7,9 +7,7 @@ import type { AcpStructuredConnection } from './acp-structured-connection' import { AcpScriptedAgent } from './acp-scripted-agent.test-support' export const PID = 4242 -/** A scripted agent behind the connection surface: the real protocol runtime over in-memory stdio, - * with the process lifecycle `AcpAgentConnection` gives it (stdout EOF is not exit; the exit closes - * the protocol with the agent's last words; a protocol failure while it runs is `onClose`). */ +/** Real in-memory protocol with separate stream, process-exit and connection-loss lifecycles. */ export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConnection { readonly agent: AcpScriptedAgent readonly pid: number | undefined = PID @@ -56,7 +54,7 @@ export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConn resumeReading(): void { this.agent.stdout.resume() } - /** What a close proves once the protocol is closed; replace it to leave the exit unproven. */ + /** Override to simulate a close without proven exit. */ proveClose = async (): Promise => { this.exit() return true @@ -67,7 +65,6 @@ export class FakeAcpChild extends AcpSessionRuntime implements AcpStructuredConn this.drainNotifications(error) return this.proveClose().finally(() => super.close(error)) } - /** The agent process ends on its own (or Orca's close landed). */ exit(): void { if (this.gone) { return diff --git a/src/main/acp/acp-session-events.ts b/src/main/acp/acp-session-events.ts index 208b8abb994..574cefb26eb 100644 --- a/src/main/acp/acp-session-events.ts +++ b/src/main/acp/acp-session-events.ts @@ -14,7 +14,7 @@ export type AcpSessionEvent = | { kind: 'known'; notification: SessionNotification } | { kind: 'unrecognized'; sessionId: string; raw: z.infer } -/** A `session/update`, typed when this build knows its kind; null when it is not one at all. */ +/** Preserve unknown updates; reject malformed envelopes. */ export function readAcpSessionEvent(params: unknown): AcpSessionEvent | null { const envelope = updateEnvelopeSchema.safeParse(params) if (!envelope.success) { diff --git a/src/main/acp/acp-session-lifecycle.test.ts b/src/main/acp/acp-session-lifecycle.test.ts index 276d81d5c56..702e439dc50 100644 --- a/src/main/acp/acp-session-lifecycle.test.ts +++ b/src/main/acp/acp-session-lifecycle.test.ts @@ -243,7 +243,6 @@ describe('ACP caller-owned waits', () => { const { agent, runtime } = fixture({ onRequest: (method) => new Promise((resolve, reject) => { - // '_vendor/silent' ignores the abort: the runtime never answers for it. if (method !== '_vendor/silent') { withdrawal.signal.addEventListener('abort', () => method === '_vendor/plan' diff --git a/src/main/acp/acp-session-notifications.test.ts b/src/main/acp/acp-session-notifications.test.ts index e71c3084f9a..f2b52921df2 100644 --- a/src/main/acp/acp-session-notifications.test.ts +++ b/src/main/acp/acp-session-notifications.test.ts @@ -24,7 +24,7 @@ afterEach(() => describe('ACP session update compatibility', () => { it.each([ ['known', { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }], - // Unfamiliar enum values stay typed: the generated enums are open. + // Generated enums accept future values. ['known', { sessionUpdate: 'tool_call', toolCallId: 't', title: 'Search', kind: 'web_search' }], ['known', { sessionUpdate: 'tool_call_update', toolCallId: 't', status: 'cancelled' }], [ diff --git a/src/main/acp/acp-session-reopen-failure.ts b/src/main/acp/acp-session-reopen-failure.ts index 90c7442d24b..8a863cb6bf9 100644 --- a/src/main/acp/acp-session-reopen-failure.ts +++ b/src/main/acp/acp-session-reopen-failure.ts @@ -1,9 +1,3 @@ -// What takes over when an ACP agent cannot reopen the session a chat proved. A session this chat -// created and never exchanged a turn on, which the agent says it does not hold, is superseded: -// there was nothing to remember. Any other is replaced by a new session that names it, and the -// chat says once that the agent forgot: the row names the lost conversation, so a later start -// can tell whether it was ever written. - import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' @@ -11,17 +5,15 @@ import type { ProviderTimelineEvent } from '../native-chat/agent-session-timelin import { AcpAuthRequiredError, AcpRpcError } from './acp-errors' import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' -/** ACP's "resource not found": the agent holds no session under the id this chat proved. */ const ACP_RESOURCE_NOT_FOUND = -32002 export const ACP_REOPEN_FAILED = 'ACP agent could not reopen its saved session; starting a new one' -/** The fields the new session's link carries; rethrows `error` when the start must fail instead. */ export function acpReopenTakeover( error: unknown, resume: NonNullable, start: { over: boolean; now: number; warn: (fields: { scope: string; error: unknown }) => void } ): Pick { - // Signed out, or the start is over (Close, Stop, a lost agent): a new session would not help. + // A replacement session cannot resolve sign-in failure or an abandoned start. if (error instanceof AcpAuthRequiredError || start.over) { throw error } @@ -36,12 +28,10 @@ export function acpReopenTakeover( return { replaces: { key: resume.key, reason: 'restore-failed', replacedAt: start.now } } } -/** The provider item key of the row saying the conversation under chain key `lostKey` was lost. */ export function acpSessionNotRestoredItem(lostKey: string): string { return `session-not-restored:${lostKey}` } -/** The one row a lost conversation gets: the agent no longer remembers what came before. */ export function acpSessionNotRestoredRow( lostKey: string, providerSessionId: string, diff --git a/src/main/acp/acp-session-runtime.test.ts b/src/main/acp/acp-session-runtime.test.ts index 6c5b2745dab..2246206c792 100644 --- a/src/main/acp/acp-session-runtime.test.ts +++ b/src/main/acp/acp-session-runtime.test.ts @@ -315,7 +315,7 @@ describe('ACP session runtime', () => { expect(diagnostics).toContain( 'Answered ACP permission request cancelled: handler selected an unavailable option' ) - // The session stays usable: a local permission failure is not a protocol failure. + // A local permission failure must leave the session usable. await expect(runtime.prompt([...textPrompt])).rejects.toThrow('already in progress') }) diff --git a/src/main/acp/acp-session-runtime.ts b/src/main/acp/acp-session-runtime.ts index 266b9995a97..dc2b87e9bb5 100644 --- a/src/main/acp/acp-session-runtime.ts +++ b/src/main/acp/acp-session-runtime.ts @@ -45,16 +45,12 @@ export type AcpSessionRuntimeOptions = { clientInfo?: InitializeRequest['clientInfo'] peer?: AcpPeerOptions onPermission?: AcpPermissionHandler - /** Agent requests other than permissions. The handler owns its request: once `context.signal` - * aborts, send the agent's own cancelled reply, finish an answer already in progress, or throw - * (-32800). The runtime never answers for it; a request left unanswered ends at `close()`. */ + /** After abort, handlers still reply or throw (-32800); close ends unanswered requests. */ onRequest?: (method: string, params: unknown, context: AcpRequestContext) => unknown - /** Agent notifications other than `session/update` (protocol extensions), delivered - * synchronously in arrival order with the `subscribe` events. */ + /** Delivered synchronously in arrival order with subscription events. */ onExtensionNotification?: (method: string, params: unknown) => void onDiagnostic?: (message: string) => void onClose?: (error: Error) => void - /** A stdout line that is not a protocol message. */ onOtherLine?: (line: string, cut?: boolean) => void /** Authentication answers, published synchronously as responses are accepted. */ onAuthAnswered?: (signedIn: boolean) => void @@ -133,8 +129,7 @@ export class AcpSessionRuntime { return this.call('authenticate', { methodId }, AuthenticateResponseSchema) } - /** A vendor extension method, answered as the agent sent it. Core methods have typed calls here, - * so a session-free caller can never create or touch a session through this. */ + /** Reject core session methods for session-free discovery. */ async requestSessionFreeExtension(method: string, params: unknown): Promise { if (!method.includes('/') || method.startsWith('session/')) { throw new Error(`Not an ACP extension method: ${method}`) @@ -143,14 +138,13 @@ export class AcpSessionRuntime { return this.peer.request(method, params, { timeoutMs: null }) } - /** Copies saved session `sessionId` into a new one the agent saves (`session/fork`) and answers - * its id; this connection starts no session of its own. Ask only an agent that advertises it. */ + /** Fork without starting a session here; requires advertised fork support. */ async forkSession(sessionId: string, cwd: string): Promise { const params = { sessionId, cwd, mcpServers: [] } return (await this.call('session/fork', params, ForkSessionResponseSchema)).sessionId } - /** Deletes saved session `sessionId` (`session/delete`). Ask only an agent that advertises it. */ + /** Requires advertised session deletion support. */ deleteSession = (sessionId: string) => this.call('session/delete', { sessionId }, z.unknown()) start(options: AcpSessionStartOptions): Promise { @@ -207,7 +201,6 @@ export class AcpSessionRuntime { return this.call('session/set_mode', params, SetSessionModeResponseSchema) } - /** Dialect-owned control requests share this session's transport and bounded lifetime. */ requestExtension(method: string, params: unknown, timeoutMs = 30_000): Promise { this.sessionId() if (!method.startsWith('_')) { @@ -279,7 +272,7 @@ export class AcpSessionRuntime { if (!request) { throw new AcpRpcError(-32602, 'Invalid ACP permission request') } - // Whether a turn the agent began itself may ask is the caller's call; the runtime cannot see it. + // The caller decides whether agent-initiated turns may request permission. if (request.sessionId !== this.started?.sessionId) { return { outcome: { outcome: 'cancelled' } } } diff --git a/src/main/acp/acp-session-update.ts b/src/main/acp/acp-session-update.ts index c36d286e5ef..e67146ad517 100644 --- a/src/main/acp/acp-session-update.ts +++ b/src/main/acp/acp-session-update.ts @@ -52,7 +52,7 @@ export function acpSessionUpdate( : [{ type: 'provider.frame', frameKind: update.sessionUpdate, payload: update, ...join }] } case 'user_message_chunk': - // A live echo of the send is the send's own row. + // The original send already owns a journal row. return [] case 'tool_call': case 'tool_call_update': { @@ -61,7 +61,7 @@ export function acpSessionUpdate( const tool = events[0] const body = tool && 'body' in tool && tool.body?.kind === 'tool-call' ? tool.body : null const subagentUpdates = body ? (dialect.toolSubagents?.(update, body) ?? []) : [] - // Runs first, so a subagent this call reveals is never also taken for a background task. + // Register subagents before excluding them from background tasks. const subagentEvents = subagents.translate(subagentUpdates, join.join, at) const tasks = (body ? (dialect.toolBackgroundTasks?.(update, body) ?? []) : []).filter( (task) => !subagents.has(task.taskId) @@ -92,7 +92,7 @@ export function acpSessionUpdate( case 'current_mode_update': case 'config_option_update': case 'session_info_update': - // These feed commands, options and the session title, outside the timeline. + // Options, commands and titles update outside the timeline. return [] case 'plan_update': case 'plan_removed': diff --git a/src/main/acp/acp-structured-acquire.ts b/src/main/acp/acp-structured-acquire.ts index bb4ca0bc616..7bcf4109276 100644 --- a/src/main/acp/acp-structured-acquire.ts +++ b/src/main/acp/acp-structured-acquire.ts @@ -1,9 +1,4 @@ -// Making a reservation real for an ACP agent: open its connection (which spawns and owns the -// process) and record it before any handshake, so the host publishes the child at spawn. The -// handshake (`acp-structured-handshake`) runs after: initialize, then reattach the session this chat -// proved with `session/load` (`session/resume` for an agent that cannot load) or start a new one, -// and the host receives protocol facts once that session answers. The handshake has no time bound of -// its own: the host's startup limit, or a close, Stop or quit, stops it at any point. +// Publish and track the child before handshaking so startup cancellation can reach it. import { initializeAcpStructuredSession } from './acp-structured-handshake' import { @@ -33,21 +28,17 @@ import type { AcpStructuredTurnsDeps } from './acp-structured-turns' import { RequestPermissionResponseSchema } from './generated/acp-protocol.generated' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -/** Frames an agent may send before its session exists; past this they are dropped. */ const MAX_EARLY_FRAMES = 2_048 export async function acquireAcpStructuredSession(input: { acquire: StructuredAgentSessionAcquireInput deps: AcpStructuredSessionAdapterDeps generation: string - /** The spawn was aborted; checked until the spawn, after which `track` hands it the connection. */ + /** Checked until spawn; afterward `track` gives cancellation the connection. */ abandoned: () => boolean - /** Registers the connection so a close during the acquire can stop it. */ track: (connection: AcpStructuredConnection) => void - /** The process's exit, observed while or after the session exists. */ onExit: (session: AcpStructuredChild | null) => void - /** The protocol broke with the process perhaps still running. Null while starting: then the - * start itself fails. */ + /** Protocol loss may precede process exit; null means startup has not produced a session. */ onConnectionLost: (session: AcpStructuredChild | null, error: Error) => void onSettled: AcpStructuredTurnsDeps['settle'] forceClose: (sessionId: string) => void @@ -73,14 +64,13 @@ export async function acquireAcpStructuredSession(input: { closedBeforeSpawn() const launch = await prepareAcpStructuredLaunch(deps, acquire, closedBeforeSpawn) let session: AcpStructuredChild | null = null - // A slot rather than a `let`: closures read it, and control-flow narrowing cannot see them write. + // Use a slot so closure writes do not conflict with control-flow narrowing. const slot: { lane: AcpStructuredLane | null; reattaching: boolean } = { lane: null, reattaching: false } const options = new AcpStructuredOptions(launch.spec.dialect) - // Only Orca's own prompt may ask the person, as with permissions: a turn the agent began itself - // has nobody waiting on it. What a settled request carries still shows in that turn. + // Agent-initiated turns cannot open interactive requests; settled request content may still show. const prompts = new AcpStructuredPrompts( () => slot.lane, () => isAcpReadySession(session) && session.turns?.acceptsRequests === true, @@ -97,7 +87,7 @@ export async function acquireAcpStructuredSession(input: { early.push(deliver) } } - // No await for an agent without the flag: nothing may come between the last abort check and spawn. + // Keep the last abort check and spawn synchronous when no temp directory is needed. const connect = spec.privateTempDirectory ? await acpPrivateTempConnect(deps, closedBeforeSpawn) : deps.connect @@ -114,14 +104,12 @@ export async function acquireAcpStructuredSession(input: { ...(spec.signIn ? { signIn: spec.signIn } : {}), onPermission: (request, context) => { if (!isAcpReadySession(session) || !session.turns?.acceptsRequests) { - // No prompt of Orca's runs (a turn the agent began itself included), or a Stop or steer - // is cutting it short: nobody is there to ask. + // No interactive prompt remains during Stop, steering or an agent-initiated turn. return { outcome: { outcome: 'cancelled' } } } - // Full access never answers a dialect's question, nor an agent that enforces the mode itself. + // Do not auto-approve questions or permissions enforced by the agent's own mode. const claimed = spec.dialect.request?.('session/request_permission', request) if (launch.fullAccess && !spec.permissionModeConfig && !claimed) { - // Full access: Orca answers yes for the person, as the agent's own bypass flag would. const allow = request.options.find((option) => option.kind === 'allow_once') if (allow) { return { outcome: { outcome: 'selected', optionId: allow.optionId } } @@ -148,16 +136,13 @@ export async function acquireAcpStructuredSession(input: { sessionId, message }), - // The protocol broke while the process may still run; the connection is already ending it. - // Its stdout ending alone is not this: the agent may still answer Stop's cancel or exit. + // Protocol loss may leave the process alive; stdout EOF alone does not trigger this. onClose: (error) => input.onConnectionLost(session, error), - // A crash usually ends stdout first; the session's end still reads the agent's last words at - // this proven exit. + // Read final diagnostics at process exit, which may follow stdout EOF. onExit: () => input.onExit(session) } ) - // From here an abort closes this connection (the start's one canceller): whatever the agent left - // unanswered fails at once and the process ends, whether or not its exit is proven yet. + // From here, cancellation closes this connection even before exit is proven. input.track(connection) connection.subscribe((event: AcpSessionEvent) => whenLane(() => { @@ -185,7 +170,7 @@ export async function acquireAcpStructuredSession(input: { `${spec.agent} ACP agent`, deps.readProcessStartTime ) - /** `attaching`: the lane opens inside the attach window, before any frame queued so far. */ + // Begin the attach window before replaying queued frames. const makeLane = (providerSessionId: string, attaching = false): AcpStructuredLane => { const lane = new AcpStructuredLane({ sink, diff --git a/src/main/acp/acp-structured-adapter.test-support.ts b/src/main/acp/acp-structured-adapter.test-support.ts index 7516c9b0d39..4b7b9dd9f1d 100644 --- a/src/main/acp/acp-structured-adapter.test-support.ts +++ b/src/main/acp/acp-structured-adapter.test-support.ts @@ -1,7 +1,5 @@ import { FakeAcpChild } from './acp-scripted-connection.test-fixture' export { FakeAcpChild, PID } from './acp-scripted-connection.test-fixture' -// A scripted ACP agent behind the real adapter: a fake connection over in-memory stdio, the real -// protocol runtime, translator and assembler, and a real on-disk journal to read back. import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -69,16 +67,13 @@ export type AcpAdapterRig = { options?: Readonly> permissionMode?: AgentChatPermissionMode }): ReturnType - /** Frames Orca wrote to the agent with this method. */ sent(method: string): FakeFrame[] - /** Waits for the `index`th frame Orca writes with this method. */ frame(method: string, index?: number): Promise settle(): Promise } export async function openAcpAdapterRig( options: { - /** The agent's row; Grok's unless a test drives another agent. */ spec?: AcpLaunchSpec launch?: Partial initialize?: Record @@ -95,7 +90,7 @@ export async function openAcpAdapterRig( let opened: StructuredAgentSessionProviderOpenResult | null = null const startListeners = new Set<() => void>() const spec = options.spec ?? GROK - // A start readies the account folder its row pins; here a scratch one. + // Use a scratch account folder for agents that prepare one. const { prepare, resolve, environment } = spec.account const scratch = { launchEnv: {}, stateDirectory: tmpdir() } const account = prepare ? environment(await resolve(scratch), {}) : {} @@ -115,7 +110,6 @@ export async function openAcpAdapterRig( const adapter = new AcpStructuredSessionAdapter({ spec, resolveLaunch, - // A double starts nothing, so a recovery read sees the launch a start would. readLaunch: (identity) => resolveLaunch({ identity, spawnToken: 'recovery-read' }), connect: (launch, connectionOptions) => { spawned.push('spawn') @@ -253,12 +247,10 @@ const HELLO: AgentJournalMessageItem = { blocks: [{ type: 'text', text: 'hello' }] } -/** A person's send of `hello` under `clientMessageId`. */ export function sendHello(rig: AcpAdapterRig, clientMessageId: string, fence = 1) { return rig.adapter.dispatch({ sessionId: SESSION, clientMessageId, body: HELLO, fence }) } -/** One streamed reply chunk of the turn Grok runs under `promptId`. */ export function replyChunk(promptId: string, text: string, meta: Record = {}) { return { sessionId: PROVIDER_SESSION, @@ -267,7 +259,6 @@ export function replyChunk(promptId: string, text: string, meta: Record = new Set(['model', 'effort']) export function isAcpStructuredOptionKey(key: string): boolean { return ACP_OPTION_KEYS.has(key) } -/** What the host knows about an ACP agent before any of its sessions runs. */ export function acpStructuredAgentDefinition(spec: AcpLaunchSpec): StructuredAgentDefinition { const { managedRuntime } = spec return { @@ -25,21 +22,20 @@ export function acpStructuredAgentDefinition(spec: AcpLaunchSpec): StructuredAge handleTransport: ACP_HANDLE_TRANSPORT, ...spec.account.pin, capabilities: { - // ACP has no stable rewind or goal method; a command arrives through `/` instead. + // ACP has no stable rewind or goal methods. rewind: false, compact: spec.compaction === true, threadGoal: false, contextUsage: true, - // Narrowed per chat by what the agent advertises at its start. + // Narrowed by each agent's advertised startup capabilities. imagePrompts: true, steering: 'queue', - // Orca answers every permission request the agent sends; the agent decides when it asks. approvalEnforcement: 'orca' }, ...(managedRuntime && { managedRuntime: () => managedRuntime.pin() !== null }), restingOptions: { acceptsKey: isAcpStructuredOptionKey, - // The agent lists its models over the protocol once it runs; Orca keeps no list of its own. + // Populate catalogs from live agents. fallbackModels: () => null, effortDefaultsToModel: false } diff --git a/src/main/acp/acp-structured-antigravity.test.ts b/src/main/acp/acp-structured-antigravity.test.ts index 4f27c12d359..b47f4776fcf 100644 --- a/src/main/acp/acp-structured-antigravity.test.ts +++ b/src/main/acp/acp-structured-antigravity.test.ts @@ -1,5 +1,3 @@ -// Antigravity's row against its recorded ACP server (1.3.0) and the environment it launches with. - import { mkdtemp, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -43,7 +41,7 @@ it('signs a first chat in, asks in default mode, and answers a question with one initialize: result(initialized), script: (agent) => { replay.attach(agent) - // A fresh folder names no sign-in method until a client picks one. + // The recorded server requires explicit authentication even with a fresh folder. agent.on('session/new', (frame) => agent.frames.some((sent) => sent.method === 'authenticate') ? agent.reply(frame, { ...result(opened), sessionId: PROVIDER_SESSION }) @@ -98,7 +96,7 @@ it('launches on its own private folder with file sign-in and no other Google cre }) expect(command).toBe(ANTIGRAVITY.command) expect(env).toMatchObject({ GEMINI_HOME: folder, AGY_ACP_FORCE_FILE_STORAGE: '1' }) - // A key deleted at spawn beats its overlay, so the file sign-in must not be among them. + // Spawn deletions override overlays; preserve the file sign-in keys. expect(envToDelete.sort()).toEqual([ 'ANTIGRAVITY_HARNESS_PATH', 'GCLOUD_PROJECT', @@ -152,7 +150,6 @@ it('runs the chat permission mode picked mid-chat as the server mode on the next } expect(await host.setOption(CALLER, { envelope, ...fields })).toMatchObject({ ok: true }) await send(host, value) - // The child launched with the earlier mode is replaced before the send reaches it. const previous = child child = await waitFor(() => { expect(rig.child()).not.toBe(previous) @@ -225,7 +222,7 @@ it('stops a chat that has left the gate but is not loaded yet before the operati agent.reply(frame, { ...result(opened), sessionId: PROVIDER_SESSION }) ) }) - // Its start returned, so the gate has no start to abort and no loaded chat lists it yet. + // Acquisition ended, but no loaded chat owns this child yet. await rig.acquire() const child = rig.child() let ranOverLiveChild: boolean | null = null diff --git a/src/main/acp/acp-structured-auth.test.ts b/src/main/acp/acp-structured-auth.test.ts index ecbcbed3054..b4bd0b4db1b 100644 --- a/src/main/acp/acp-structured-auth.test.ts +++ b/src/main/acp/acp-structured-auth.test.ts @@ -1,6 +1,3 @@ -// Grok signs in on the machine it runs on: on auth required, with the API key in its own launch -// environment, else the sign-in it already cached; with neither, the chat reports it signed out. - import { afterEach, describe, expect, it } from 'vitest' import { z } from 'zod' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' @@ -22,7 +19,6 @@ const BOTH = [ { id: 'cached_token', name: 'Cached sign-in' } ] -/** Grok refuses to open a session until it is authenticated. */ function requiresSignIn(agent: AcpScriptedAgent): void { let signedIn = false agent.on('authenticate', (frame) => { diff --git a/src/main/acp/acp-structured-background-task-stop.test.ts b/src/main/acp/acp-structured-background-task-stop.test.ts index 759f7076235..acfaa70149b 100644 --- a/src/main/acp/acp-structured-background-task-stop.test.ts +++ b/src/main/acp/acp-structured-background-task-stop.test.ts @@ -39,7 +39,7 @@ async function fixture() { const hosted = await openAttachedHostRig({}, childWork.sink) cleanup.push(async () => { const agent = hosted.rig.child().agent - // Only cleanup answers cancellation; task-stop assertions run before this. + // Only teardown cancellation ends the prompt; Stop assertions run first. agent.on('session/cancel', () => { const prompt = agent.frames.findLast((frame) => frame.method === 'session/prompt') if (prompt) { @@ -53,7 +53,6 @@ async function fixture() { const agent = hosted.rig.child().agent agent.notify('session/update', replyChunk(promptIdOf(prompt), 'Starting it')) await hosted.rows() - // As the recorded s6-background Grok run sends them. const background = async (taskId = TASK, extra: Record = {}) => { agent.notify('_x.ai/task_backgrounded', { sessionId: PROVIDER_SESSION, @@ -183,7 +182,7 @@ describe('Grok background commands in the background-tasks strip', () => { expect(f.kills().at(-1)?.params).toEqual({ sessionId: PROVIDER_SESSION, taskId: TASK }) expect(f.agent.frames.filter((frame) => frame.method === 'session/cancel')).toEqual([]) expect(f.rig.child().closed).toBe(false) - // Held until Grok's own ending arrives, so the strip keeps the row in its stopping state. + // Keep the row stopping until Grok reports its ending. expect((await f.strip())?.tasks?.map((task) => task.id)).toEqual([ CHILD, 'acp-task:sibling-task' @@ -225,7 +224,6 @@ describe('Grok background commands in the background-tasks strip', () => { expect(await f.strip()).toBeNull() }) - // As for Codex and Claude: running background work must be stopped before the chat is cleared. it('refuses /clear while a background command runs', async () => { const f = await fixture() await f.background() @@ -247,8 +245,7 @@ describe('Grok background commands in the background-tasks strip', () => { }) }) - // Grok's own refusal means it killed nothing; a reply about another task proves nothing. The Stop - // is accepted either way, and its note says which. + // Refusal proves no kill; a mismatched task reply leaves cancellation unknown. it.each([ ['rpc-error', "The background task wasn't stopped."], ['envelope-error', "The background task wasn't stopped."], @@ -303,7 +300,6 @@ describe('Grok kill route check', () => { expect(frame.params).toEqual({}) agent.fail(frame, -32601, 'Method not found') }) - // The session still offers Stop for its subagents, whose route answered. expect(rig.adapter.backgroundTaskStops(ADAPTER_SESSION)?.supportsTaskStop).toBe(true) expect(evidence).toMatchObject([ { type: 'live', child: { handle: { id: CHILD }, kind: 'command', stoppable: false } } diff --git a/src/main/acp/acp-structured-broken-stream.test.ts b/src/main/acp/acp-structured-broken-stream.test.ts index 7a1c117e686..5406d048bbb 100644 --- a/src/main/acp/acp-structured-broken-stream.test.ts +++ b/src/main/acp/acp-structured-broken-stream.test.ts @@ -23,7 +23,6 @@ afterEach(async () => { await closeProviderTimelineRigs() }) -// Answers the handshake and echoes one reply chunk for the prompt, then runs `onPrompt`. const agentScript = (onPrompt: string): string => String.raw` const fs = require('node:fs') fs.writeFileSync(process.env.ORCA_TEST_PID_FILE, String(process.pid)) @@ -66,7 +65,6 @@ const CLOSES_STDOUT_MID_TURN = ` process.stdin.once('end', () => process.exit(0)) ${agentScript('fs.closeSync(1)')} ` -// Starts a process that inherits its stdout and outlives it, records that process, then exits. const EXITS_HOLDING_STDOUT = agentScript(String.raw` const held = require('@orca/process-host').spawnProcess({ program: process.execPath, diff --git a/src/main/acp/acp-structured-child-lifecycle.ts b/src/main/acp/acp-structured-child-lifecycle.ts index 5c3017878fa..2ceb2ba7aaf 100644 --- a/src/main/acp/acp-structured-child-lifecycle.ts +++ b/src/main/acp/acp-structured-child-lifecycle.ts @@ -14,7 +14,7 @@ export class AcpStructuredChildren { readonly sessions = new Map() readonly starts = new ProviderAcquisitionStarts() constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} - /** A requested close: proven by the root's exit; a tree not proven gone is the caller's to report. */ + /** Requires root exit; reports unproven descendant cleanup separately. */ async close(sessionId: string): Promise { const connection = this.sessions.get(sessionId)?.connection const closed = await this.stop(sessionId, true) @@ -28,8 +28,7 @@ export class AcpStructuredChildren { return closed } - /** True only once every child is proven gone, or when this adapter runs none for the session: - * a failed start's child, and the session's own. */ + /** Requires exit of both the failed-start child and the session child, if present. */ async stop(sessionId: string, requested = true): Promise { const [failedStart, session] = await Promise.all([ this.starts.stopFailed(sessionId), @@ -60,14 +59,14 @@ export class AcpStructuredChildren { return session.closing } if (requested && session.journalClosed === null) { - // A close, dispose or quit ends a running turn as a Stop does before the child goes. + // Allow the running turn to settle before closing the process. session.closeRequested = true await windDownAcpTurn(session, this.stopGraceMs()) if (session.ended) { return true } } - // A connection loss already decided why the child ends; a later stop does not relabel it. + // Preserve a connection loss's cause across later Stop calls. if (session.journalClosed === null) { session.closeRequested ||= requested session.lane.flush() @@ -79,7 +78,6 @@ export class AcpStructuredChildren { return proven } - /** The child's exit is proven: the host hears it, and nothing of the child stays here. */ finish(session: AcpStructuredChild, observedAt: number): void { if (isAcpReadySession(session)) { endAcpStructuredSession(session, observedAt, this.deps.onEvent) @@ -109,8 +107,7 @@ export class AcpStructuredChildren { } } - /** The connection broke while the child may still run: nothing more it says can be journaled, so - * the journal closes now and the child is stopped; the host hears `ended` once that is proven. */ + /** Close the journal on protocol loss; notify the host after process exit is proven. */ connectionLost(session: AcpStructuredChild | null, error: Error): void { if ( !session || diff --git a/src/main/acp/acp-structured-child-session-end.test.ts b/src/main/acp/acp-structured-child-session-end.test.ts index 7acf01695bb..9c7d4a8e19f 100644 --- a/src/main/acp/acp-structured-child-session-end.test.ts +++ b/src/main/acp/acp-structured-child-session-end.test.ts @@ -244,7 +244,6 @@ describe('recorded child-session outcome during a full parent Stop', () => { await hosted.rows() vi.setSystemTime(1791496678726) agent.notify(selectedFinished.method, selectedFinished.params) - // One saved row per subagent, which the host writes from its records. const subagents = async () => (await hosted.rows()).flatMap((row) => row.body.kind === 'message' diff --git a/src/main/acp/acp-structured-child-stop.ts b/src/main/acp/acp-structured-child-stop.ts index e1c28fdf825..7245ec8c6b2 100644 --- a/src/main/acp/acp-structured-child-stop.ts +++ b/src/main/acp/acp-structured-child-stop.ts @@ -50,8 +50,7 @@ async function probeAcpSubagentStop( } } -/** Only the agent naming the route unknown hides Stop; any other answer leaves the Stop itself to - * report what happened. */ +/** Only an explicit unknown-route response hides Stop. */ async function probeAcpBackgroundTaskStop( connection: AcpStructuredConnection, dialect: AcpDialect @@ -89,7 +88,7 @@ async function stopAcpSubagent( try { value = await session.connection.requestExtension(request.method, request.params) } catch (error) { - // Grok answers an ended or unknown subagent normally, so its refusal means it did nothing; anything else leaves the effect unknown. + // Grok reports missing subagents normally; a refusal proves no cancellation occurred. if (error instanceof AcpAgentError) { return { cancelled: false, stillRunning: true } } @@ -99,7 +98,7 @@ async function stopAcpSubagent( if (result.state === 'working') { return { cancelled: result.cancelled } } - // Not found is no acceptance; an answer that names how the subagent finished is its own ending. + // Missing is not accepted; a terminal state supplies its own outcome. const outcome = result.state === undefined ? undefined : acpSubagentOutcome(result.state) return result.cancelled || outcome !== undefined ? { @@ -119,7 +118,7 @@ async function stopAcpBackgroundTask( try { value = await session.connection.requestExtension(request.method, request.params) } catch (error) { - // Grok answers a task it no longer holds normally, so a refusal means it killed nothing. + // Grok reports missing tasks normally; a refusal proves nothing was killed. if (error instanceof AcpAgentError) { return { cancelled: false, stillRunning: true } } @@ -129,15 +128,14 @@ async function stopAcpBackgroundTask( if (outcome === 'refused') { return { cancelled: false, stillRunning: true } } - // A killed task's own completion ends its row. A gone one settles here, and any completion - // Grok still sends for it (exited but not yet swept) overwrites this with the real result. + // Settle missing tasks now; later provider completion replaces this with its actual result. if (outcome === 'gone') { session.lane.apply(session.lane.translator.reconcileBackgroundTask(taskId)) } return { cancelled: true } } -/** The host has already resolved these handles from this parent's stoppable child records. */ +/** Handles come from the host's authoritative stoppable-child records. */ export async function stopAcpChildren( session: AcpStructuredSession, fence: number, diff --git a/src/main/acp/acp-structured-child-work-admission.test.ts b/src/main/acp/acp-structured-child-work-admission.test.ts index 4abddb5896f..fe568925481 100644 --- a/src/main/acp/acp-structured-child-work-admission.test.ts +++ b/src/main/acp/acp-structured-child-work-admission.test.ts @@ -21,7 +21,6 @@ afterEach(async () => { await closeProviderTimelineRigs() }) -/** What Grok says about one subagent, translated as the adapter would. */ function subagent(lane: AcpStructuredLane, state: 'working' | 'completed'): AcpTimelineEvent[] { const update = state === 'working' diff --git a/src/main/acp/acp-structured-child-work.test-support.ts b/src/main/acp/acp-structured-child-work.test-support.ts index b3a58279fa3..21247e7608f 100644 --- a/src/main/acp/acp-structured-child-work.test-support.ts +++ b/src/main/acp/acp-structured-child-work.test-support.ts @@ -24,8 +24,6 @@ export function acpChildWorkStatusSink() { return { sink, views: () => (parent ? server.getStructuredChildWorkViews(parent) : []) } } -/** A host holding one working Grok parent: what a translator reported becomes its child records, - * the one authority on each subagent. */ export function acpSubagentRecords() { const { sink, views } = acpChildWorkStatusSink() const subject = makeStructuredAgentStatusSubject( diff --git a/src/main/acp/acp-structured-child-work.test.ts b/src/main/acp/acp-structured-child-work.test.ts index f6c6723f310..d8860410647 100644 --- a/src/main/acp/acp-structured-child-work.test.ts +++ b/src/main/acp/acp-structured-child-work.test.ts @@ -37,7 +37,6 @@ function cleanupScriptedHost(hosted: Awaited>) { const scriptedPrompt = prompt if (scriptedPrompt) { const { agent } = hosted.rig.child() - // Only teardown's actual cancel ends this fixture's scripted prompt. agent.on('session/cancel', () => agent.reply(scriptedPrompt, { stopReason: 'cancelled' })) } await hosted.host.close(SESSION, 'user-close') @@ -190,7 +189,7 @@ describe('Grok roster evidence through the existing host child store', () => { const f = await fixture() await f.spawn('first') await f.spawn('sibling') - // Each saved row keeps the raw label; the shared presentation derives the ordinal, as the strip does. + // Ordinals belong to presentation; persisted labels stay unchanged. expect(await f.roster()).toMatchObject([{ label: 'Exact label' }, { label: 'Exact label' }]) expect((await f.strip())?.tasks).toMatchObject([ { id: 'first', kind: 'agent', description: 'Exact label' }, @@ -222,7 +221,7 @@ describe('Grok roster evidence through the existing host child store', () => { }) }) expect(await f.targetedStop('first')).toMatchObject({ ok: true, value: { cancelled: true } }) - // The accepted Stop settles that run until Grok's own ending says how it ended. + // Grok's eventual ending can revise the provisional Stop outcome. expect(f.childWork.views()).toMatchObject([ { providerId: 'first', membership: 'settled', outcome: 'cancelled' }, { providerId: 'sibling', membership: 'live' } @@ -416,7 +415,7 @@ describe('Grok roster evidence through the existing host child store', () => { } }) - // Grok's own refusal means it did nothing; a reply about another subagent proves nothing, so it is + // Refusal proves no cancellation; a mismatched reply leaves the outcome unknown. // unknown. The Stop is accepted either way, and its note says which. it.each([ ['rpc-error', "The background task wasn't stopped."], diff --git a/src/main/acp/acp-structured-child.ts b/src/main/acp/acp-structured-child.ts index 665b5326510..412bb82a57f 100644 --- a/src/main/acp/acp-structured-child.ts +++ b/src/main/acp/acp-structured-child.ts @@ -39,7 +39,7 @@ export function acpStartupFailure( error: unknown ): SubmissionRejectionFact | undefined { if (error instanceof AcpAgentError && acpAuthenticationRequired(session.spec.dialect, error)) { - // The agent's sign-in words, never log lines it wrote before refusing. + // Use the agent's sign-in answer, excluding preceding diagnostic output. return acpSignInRequiredFailure(session.spec.dialect, error) } return undefined diff --git a/src/main/acp/acp-structured-compaction.test.ts b/src/main/acp/acp-structured-compaction.test.ts index 288d4f2616d..10329ff5db1 100644 --- a/src/main/acp/acp-structured-compaction.test.ts +++ b/src/main/acp/acp-structured-compaction.test.ts @@ -27,7 +27,6 @@ afterEach(async () => { const OPENCODE = acpLaunchSpecFor('opencode')! const OMP = acpLaunchSpecFor('omp')! -/** The command turn the host writes before it hands `/compact` over. */ async function commandRun(rig: AcpAdapterRig): Promise { const turn = structuredAgentSessionCommandTurn('compact-1') const running = agentJournalTurnBody({ turnId: turn.turnId, state: 'running', startedAt: 4_000 }) @@ -38,7 +37,6 @@ async function commandRun(rig: AcpAdapterRig): Promise { const rows = await rig.rig.rows() diff --git a/src/main/acp/acp-structured-connection.ts b/src/main/acp/acp-structured-connection.ts index d49fe7467a0..c943272615f 100644 --- a/src/main/acp/acp-structured-connection.ts +++ b/src/main/acp/acp-structured-connection.ts @@ -1,11 +1,6 @@ -// The ACP agent as the adapter holds it: one connection that owns both the agent's process and its -// protocol (`createAcpAgentConnection`). Supervision, the process-tree teardown, Windows-safe -// spawning and stdio are the connection's; turns, Stop and steering are the adapter's. - import type { AcpAgentConnection, AcpAgentConnectionOptions } from './acp-agent-connection' import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' -/** What the adapter uses of a connection; tests stand in a scripted agent behind the same surface. */ export type AcpStructuredConnection = Pick< AcpAgentConnection, | 'initialize' @@ -34,7 +29,7 @@ export type ConnectAcpAgent = ( options: AcpAgentConnectionOptions ) => Connection -/** Resolves once `connection`'s process exits, `signal` aborts, or `ms` pass, whichever is first. */ +/** Waits for process exit, abort or timeout. */ export function waitForAcpExit( connection: AcpStructuredConnection, ms: number, diff --git a/src/main/acp/acp-structured-fixture-replay.test-support.ts b/src/main/acp/acp-structured-fixture-replay.test-support.ts index e1e7d502660..496d07efaee 100644 --- a/src/main/acp/acp-structured-fixture-replay.test-support.ts +++ b/src/main/acp/acp-structured-fixture-replay.test-support.ts @@ -1,7 +1,4 @@ -// Plays a recorded Grok session back as the agent behind the real adapter. Orca's own frames -// advance the recording: the agent's lines after each one are sent until the recording waits on -// Orca again. The recording's prompt ids and session id are rewritten to the ones this run uses, -// as the real agent echoes the prompt id it was sent. +// Replay recorded traffic with this run's session and prompt IDs, advancing on Orca's frames. import { expect } from 'vitest' import { z } from 'zod' @@ -9,7 +6,6 @@ import type { AcpFixtureFrame } from './acp-timeline-fixture.test-support' import type { AcpScriptedAgent, FakeFrame } from './acp-scripted-agent.test-support' import { PROVIDER_SESSION } from './acp-structured-adapter.test-support' -/** Recordings name their sessions `session-N`; a replay plays them all as this run's one. */ const RECORDED_SESSION = /"session-\d+"/g const promptMetaSchema = z.object({ _meta: z.object({ promptId: z.string() }) }) const messageSchema = z.object({ @@ -41,7 +37,6 @@ export class GrokFixtureReplay { agent.on('session/cancel', (frame) => this.orcaSent('session/cancel', frame)) } - /** What the recording waits for Orca to send next; null once it has played to the end. */ get awaiting(): string | null { if (this.responseFailure !== undefined) { throw this.responseFailure @@ -76,7 +71,7 @@ export class GrokFixtureReplay { this.cursor += 1 const message = this.rewrite(recorded.message) if (message.method !== undefined && message.id !== undefined) { - // The agent asks Orca something; the recording resumes once Orca answers it. + // Resume only after Orca answers the agent's request. void agent .request(message.id, message.method, message.params) .then((response) => { diff --git a/src/main/acp/acp-structured-grok-subagent-recordings.test.ts b/src/main/acp/acp-structured-grok-subagent-recordings.test.ts index b02fd75f3e8..99392051702 100644 --- a/src/main/acp/acp-structured-grok-subagent-recordings.test.ts +++ b/src/main/acp/acp-structured-grok-subagent-recordings.test.ts @@ -1,7 +1,3 @@ -// The adapter against Grok's recorded subagents (Grok 1.0.46): each recording plays back as the -// agent, and the assertions read what it reports to the host about each subagent and the reply -// rows a client would see. - import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' @@ -46,7 +42,6 @@ function send(rig: AcpAdapterRig, clientMessageId: string) { type Reported = { id: string; label?: string; outcome?: string; tokens?: number } -/** Each subagent as the adapter last reported it to the host. */ function subagents(evidence: readonly AgentChildWorkEvidence[]): Reported[] { const reported = new Map() for (const edge of evidence) { diff --git a/src/main/acp/acp-structured-handshake.ts b/src/main/acp/acp-structured-handshake.ts index e22a480e02b..c6f982059b1 100644 --- a/src/main/acp/acp-structured-handshake.ts +++ b/src/main/acp/acp-structured-handshake.ts @@ -1,8 +1,4 @@ -// An ACP child's handshake, run after the host has published it: initialize, reopen the chat's -// session or start a new one, then return its protocol facts to the host. A saved session the -// agent cannot reopen is replaced by a new one, with a warning row that any later start writes if -// this one never did; while a reopened session replays history the journal already holds, nothing -// it sends is written except context usage. +// Suppress replayed history during reattach; preserve context usage. import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -57,7 +53,7 @@ export async function initializeAcpStructuredSession( assertStarting() const initialized = await connection.initialize() assertStarting() - // Chosen here, on the machine Grok runs on, from the environment it was launched with. + // Choose authentication using the execution host's launch environment. const authMethodId = spec.authMethod?.({ advertised: (initialized.authMethods ?? []).map((method) => method.id), env: launch.env @@ -87,8 +83,6 @@ export async function initializeAcpStructuredSession( now: now(), warn: (fields) => deps.logger?.warn(ACP_REOPEN_FAILED, { ...fields, sessionId }) }) - // A new session takes the old one's place, with a new lane, so nothing of the failed - // attach's window outlives it. } } if (!started || !liveLane) { @@ -104,7 +98,7 @@ export async function initializeAcpStructuredSession( liveLane.apply(liveLane.translator.contextModels(started.response.models, now())) const mode = spec.permissionModeConfig if (mode) { - // A refusal fails the start: the session would otherwise run another permission mode. + // Fail startup rather than silently use a different permission mode. const requested = mode.values[acquire.permissionMode ?? 'ask'] ?? mode.values.ask const configId = options.modeConfigId() const { configOptions } = await connection.setConfigOption(configId, requested) @@ -140,7 +134,7 @@ export async function initializeAcpStructuredSession( if (connection.exited || connection.closed) { throw new Error(connection.stderrTail() || `${spec.command} exited while starting`) } - // The chat says once per lost conversation that the agent forgot it, this start's loss included. + // Include warning rows missed by earlier failed starts. const lost = [ ...(launch.resume?.unannouncedLosses() ?? []), ...(takeover?.replaces ? [takeover.replaces.key] : []) diff --git a/src/main/acp/acp-structured-host-configured-default.test.ts b/src/main/acp/acp-structured-host-configured-default.test.ts index d05a7cc0ba9..49422d155b7 100644 --- a/src/main/acp/acp-structured-host-configured-default.test.ts +++ b/src/main/acp/acp-structured-host-configured-default.test.ts @@ -1,6 +1,4 @@ -// Grok's session-free listing can name a model (`initialize`'s currentModelId) other than the one a -// session runs. The user's case: the listing said Grok 4.7, every chat ran Grok 4.6. Through the -// real Grok probe, adapter and host, a new chat's first frame names only what a chat ran. +// Regression: initialize listed Grok 4.7 while new sessions actually ran Grok 4.6. import { afterEach, describe, expect, it } from 'vitest' import type { AgentSessionModelCatalogResult } from '../../shared/agent-session-wire' @@ -44,7 +42,6 @@ const EFFORT_META = { const OTHER_CHAT = 'grok-chat-2' -/** Another Grok chat in the same workspace, as the host's record of it. */ function otherChat(store: AgentSessionRecordStore | null) { const first = store?.getRecord(SESSION) return first ? { ...first, sessionId: OTHER_CHAT } : undefined @@ -58,7 +55,6 @@ afterEach(async () => { await closeProviderTimelineRigs() }) -/** Grok's `initialize` with no session: it computes 4.7 as its current model. */ function grokProbe() { return createAcpModelCatalogProbe(GROK, { resolveEnvironment: async () => ({ PATH: '/usr/bin' }), @@ -94,7 +90,6 @@ function grokProbe() { }) } -/** What a new Grok chat's composer paints first from a host answer. */ function firstFrame(answer: AgentSessionModelCatalogResult) { const seed = structuredAgentSessionSeedCatalog('grok') const state = applyStructuredAgentSessionModelCatalog( @@ -111,8 +106,7 @@ function firstFrame(answer: AgentSessionModelCatalogResult) { return { model: select('model'), effort: select('effort') } } -/** The host's listing, then a Grok chat opened with no model pick whose own session runs `runs`; - * `create` founds it with its first message, so only delivery starts the agent. */ +/** With `create`, only first-message delivery starts the agent. */ async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMessage) { const records: { store: AgentSessionRecordStore | null } = { store: null } const discovery = GROK.modelDiscovery @@ -127,7 +121,6 @@ async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMe agentReadsProjectModelConfig, workspaceMayOverrideDefaultModel, probes: { grok: grokProbe() }, - // As the launch spec says, not by hand. listingNamesConfiguredModel: new Set( discovery.kind !== 'unavailable' && discovery.listingNamesConfiguredModel ? ['grok'] : [] ) @@ -150,7 +143,6 @@ async function openGrokHost(runs: string, create?: StructuredAgentSessionFirstMe : rig.host.attach(CALLER, attachParams()) expect(await opening).toMatchObject({ ok: true }) if (!create) { - // The attach answers at spawn; what the chat runs is known once its start is proven. await rig.ready() } return { ...rig, catalog, listed } @@ -167,7 +159,6 @@ describe('Grok’s default comes from what a chat with no pick runs', () => { const { catalog, host } = await openGrokHost('grok-4.6') expect((await host.readOptions(SESSION)).current).toMatchObject({ model: 'grok-4.6' }) - // The next new chat, in any workspace: Grok reads no project config for its model. const answer = await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' }) expect(answer).toMatchObject({ listingNamesConfiguredModel: true, @@ -182,10 +173,9 @@ describe('Grok’s default comes from what a chat with no pick runs', () => { body: hostTestMessage('hello') }) await catalog.read({ agent: 'grok', waitForListing: true }) - // Delivery started the agent and handed it the opening message; nothing picked a model. const prompt = await rig.frame('session/prompt') rig.child().agent.reply(prompt, { stopReason: 'end_turn' }) - // Learned from the start the delivery made, before any options read. + // The start taught the catalog before any options read. expect(firstFrame(await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' }))).toEqual({ model: 'grok-4.6', effort: 'high' @@ -199,14 +189,13 @@ describe('Grok’s default comes from what a chat with no pick runs', () => { it('a chat’s later reads teach nothing: a newer chat’s default stands', async () => { const { catalog, host } = await openGrokHost('grok-4.6') - // Grok's default moved since: a newer chat with no pick starts on 4.7 and says so at its start. + // A newer unpicked session reports the account's changed default. const newer = new AcpStructuredOptions() newer.adoptSession( { configOptions: SessionConfigOptionSchema.array().parse(GROK_CONFIG_OPTIONS) }, 'new' ) catalog.recordLiveListing(OTHER_CHAT, newer.startListing()) - // The first chat keeps running and reads its options again, as every turn does. await host.readOptions(SESSION) const answer = await catalog.read({ agent: 'grok', workspacePath: '/elsewhere' }) expect(firstFrame(answer)).toEqual({ model: 'grok-4.7', effort: 'high' }) diff --git a/src/main/acp/acp-structured-host-crash.test.ts b/src/main/acp/acp-structured-host-crash.test.ts index 39032c7a86c..541b8757a92 100644 --- a/src/main/acp/acp-structured-host-crash.test.ts +++ b/src/main/acp/acp-structured-host-crash.test.ts @@ -1,8 +1,3 @@ -// A Grok crash mid-reply, through the real host: the host's settlement of the proven exit writes one -// `provider-exit:` batch whose cut-short row reports on the turn the crash ended, carrying the -// adapter's failure with Grok's last words. Whether Grok's pipes closed before its exit was seen -// changes only what the chat shows in between. - import { afterEach, describe, expect, it, vi } from 'vitest' import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' import { journalLifecycleMutationItemId } from '../native-chat/agent-session-journal/journal-row-builders' @@ -20,7 +15,6 @@ afterEach(async () => { const LAST_WORDS = 'qa-cell20-last-words' -/** Grok mid-reply ("Oak") with a command running, every lifecycle batch the journal takes recorded. */ async function midReply() { const rig = await openAttachedHostRig() const { host } = rig @@ -38,8 +32,7 @@ async function midReply() { batches.push(batch) return append(batch) }) - // A settlement plans its batch at its own turn in the write queue: recorded as it is planned, and - // only when it writes something (an empty plan writes no row). + // Capture nonempty settlements when planned in the write queue. const appendPlanned = journal.appendPlannedLifecycleBatch vi.spyOn(journal, 'appendPlannedLifecycleBatch').mockImplementation((input) => appendPlanned({ @@ -91,8 +84,7 @@ describe('a Grok crash with its exit seen first', () => { rig.child.exit() const settled = await settledCrash(rig) expectOneTurnScopedCutShortRow(settled) - // The child's own end landed first, interrupted at the exit and never `unverifiable`: the host - // has no turn or call of it left to revise. + // Provider exit already interrupted the turn; only its notice remains to write. expect( settled.batch.mutations.map((mutation) => mutation.kind === 'item' && mutation.body.kind) ).toEqual(['status']) @@ -103,7 +95,7 @@ describe('a Grok crash with its exit seen first', () => { describe('a Grok crash whose pipes close before its exit is seen', () => { it('is unverifiable only until the exit is proven; the provider-exit batch then revises the turn', async () => { const rig = await midReply() - // The child outlives its stream until Orca's close of it is proven. + // Keep the process alive after stream loss until close is proven. let exitNow: () => void = () => undefined rig.child.proveClose = () => new Promise((resolve) => { @@ -121,14 +113,12 @@ describe('a Grok crash whose pipes close before its exit is seen', () => { exitNow() const settled = await settledCrash(rig) expectOneTurnScopedCutShortRow(settled) - // The turn's revision, its call's and the row land together. const turnKey = settled.turn!.itemId expect( settled.batch.mutations.flatMap((mutation) => mutation.kind === 'item' ? [journalLifecycleMutationItemId(mutation)] : [] ) ).toEqual(expect.arrayContaining([settled.statuses[0]!.itemId, turnKey])) - // No later stale-session pass rewrites it: nothing is left for one. expect(rig.batches.filter((batch) => batch.settlementId.includes('stale-session'))).toEqual([]) await rig.host.close(SESSION, 'user-close') }) diff --git a/src/main/acp/acp-structured-host-lifecycle.test.ts b/src/main/acp/acp-structured-host-lifecycle.test.ts index 85b265bb819..acba95ffd32 100644 --- a/src/main/acp/acp-structured-host-lifecycle.test.ts +++ b/src/main/acp/acp-structured-host-lifecycle.test.ts @@ -1,7 +1,3 @@ -// The ACP adapter behind the real host: the host's own order (a fresh sink per start, bound only -// once the start proved its owner; Close and Stop reaching a start from outside the session's -// queue), with a scripted Grok, the real record store and an on-disk journal. - import { afterEach, describe, expect, it, vi } from 'vitest' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' @@ -35,8 +31,7 @@ afterEach(async () => { await closeProviderTimelineRigs() }) -/** On every reopen, Grok sends what its load may carry: the saved `reply` marked as replay, and a - * task the dead process left running, ended by the restart. */ +/** Replay history and end the task left behind by the prior process. */ function loads(reply: string, count: { loads: number }) { return (agent: AcpScriptedAgent) => agent.on('session/load', (frame) => { @@ -93,7 +88,6 @@ describe('reopening a Grok chat through the host', () => { await ready() resumed = true await exchange('complete', false) - // Grok dies mid-reply; the host ends its record, which moves the chat's fence. const cutAt = fence() rig.child().exit() await waitFor(() => expect(fence()).toBeGreaterThan(cutAt)) @@ -105,7 +99,6 @@ describe('reopening a Grok chat through the host', () => { expect(rows.flatMap((row) => readAgentJournalTurn(row.body)?.state ?? [])).not.toContain( 'completed' ) - // The transcript explains the cut the way a Claude or Codex chat's does. const transcript = withNativeChatCutTurnNotices(rows, { agentName: 'Grok' }) expect( transcript.some( @@ -135,7 +128,7 @@ describe('a Grok crash whose exit is not proven yet', () => { return proves } child.stderr = 'panic: out of memory' - // Both pipes close as the process dies, before its exit is seen. + // Model pipes closing before the process exit is observed. child.agent.close() await rig.settle() const next = await send(host, 'next') @@ -155,7 +148,6 @@ describe('a Grok crash whose exit is not proven yet', () => { await rig.settle() await host.flushStreamedEvents(SESSION) const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' }) - // The crash's own notice, as for any crash; not a Stop's. expect( transcript.flatMap((row) => row.body.kind === 'status' @@ -206,7 +198,6 @@ describe('closing or stopping a Grok chat while it starts', () => { const attaching = host.attach(CALLER, attachParams()) await rig.frame('initialize') const stopping = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) - // With no close behind it. await waitFor(() => expect(rig.child().exited).toBe(true)) expect((await attaching).ok).toBe(true) expect(await stopping).toMatchObject({ ok: true }) @@ -224,7 +215,6 @@ describe('closing or stopping a Grok chat while it starts', () => { } }) expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) - // The chat closes; the next send has to start Grok again, and that start never answers. await host.close(SESSION, 'user-close') await host.send(CALLER, { envelope: envelope('agentSession.send', { body: hello }), @@ -297,7 +287,7 @@ describe('a Grok send whose acceptance could not be written', () => { vi.spyOn(journal, 'acceptLate').mockRejectedValue(new Error('disk full')) const sent = await send(host, 'hello') const prompt = await rig.frame('session/prompt') - // Grok names the prompt only with its first frame: until then it holds the send. + // The first provider frame supplies prompt identity and proves acceptance. await waitFor(() => expect(host.currentWork(SESSION)?.working()).toBe(true)) rig.child().agent.notify('session/update', replyChunk(promptIdOf(prompt), 'hi')) @@ -305,7 +295,7 @@ describe('a Grok send whose acceptance could not be written', () => { await rig.settle() await waitFor(() => expect(host.currentWork(SESSION)?.working()).toBe(false)) - // The acceptance never landed: the row is still the handover's alone. + // Rejected journal admission leaves the send pending. expect( (await host.journalSnapshot(SESSION)).submissions.find((row) => row.clientMessageId === sent) ).toMatchObject({ dispatchState: 'pending' }) diff --git a/src/main/acp/acp-structured-host-live-listing.test.ts b/src/main/acp/acp-structured-host-live-listing.test.ts index 71616f706db..6eb1fa4bd64 100644 --- a/src/main/acp/acp-structured-host-live-listing.test.ts +++ b/src/main/acp/acp-structured-host-live-listing.test.ts @@ -7,8 +7,6 @@ import { import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { CALLER } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' import { GROK_CONFIG_OPTIONS, PROVIDER_SESSION } from './acp-structured-adapter.test-support' -// A running Grok chat's own model list, read through the real host, reaches the account's saved -// catalog: the next chat opens warm even when a session-free listing failed. import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' @@ -97,11 +95,9 @@ describe('a live Grok listing', () => { stop: vi.fn(), providerStarted: vi.fn() } - // The rig waits for the child to turn ready: bookkeeping never fails a proven start. const { host, rig, store, fence } = await openAttachedHostRig({}, undefined, modelCatalog) expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(1) expect(store.getRecord(SESSION)?.lease.ownerProcess).toEqual(expect.any(Object)) - // The rest of the start still lands, and live options remain readable. await expect(host.readOptions(SESSION)).resolves.toMatchObject({ current: expect.any(Object) }) expect(modelCatalog.recordLiveListing).toHaveBeenCalledTimes(2) expect( diff --git a/src/main/acp/acp-structured-host-option-hang.test.ts b/src/main/acp/acp-structured-host-option-hang.test.ts index 96eb8bb111f..6f6f9524c5c 100644 --- a/src/main/acp/acp-structured-host-option-hang.test.ts +++ b/src/main/acp/acp-structured-host-option-hang.test.ts @@ -1,6 +1,3 @@ -// A model or effort pick Grok never answers, through the real host: it must not hold the chat's -// Stop or Close behind it, and on its own it fails at a bound instead of holding the chat forever. - import { afterEach, describe, expect, it, vi } from 'vitest' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import { @@ -34,7 +31,7 @@ describe('a Grok model pick Grok never answers during send', () => { const invalid = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}, { payloadFingerprint: 'invalid' }) }) - // The write runs outside the session's serialize, so even a refused Stop is answered at once. + // Stop bypasses the session queue even while an option write stalls. await expect(invalid).resolves.toMatchObject({ ok: false }) const request = { envelope: envelope('agentSession.cancel', {}) } await expect(host.cancel(CALLER, request)).resolves.toMatchObject({ ok: true }) @@ -86,7 +83,6 @@ describe('a Grok model pick Grok never answers during send', () => { ) vi.useRealTimers() expect(framesOf(rig.child(), 'session/prompt')).toEqual([]) - // The chat's queue moved on: a later operation runs. await expect(host.readOptions(SESSION)).resolves.toMatchObject({ current: { model: savedModel } }) diff --git a/src/main/acp/acp-structured-host-restore-failed.test.ts b/src/main/acp/acp-structured-host-restore-failed.test.ts index 52c3356db19..77a60307d11 100644 --- a/src/main/acp/acp-structured-host-restore-failed.test.ts +++ b/src/main/acp/acp-structured-host-restore-failed.test.ts @@ -1,8 +1,3 @@ -// A Grok session Grok cannot reopen, through the real host, record store, journal and launch -// resolver: a fresh session continues the chat, the chain records what it replaced, and the chat -// says so once, unless nothing was ever exchanged on the lost session. A start that succeeds -// writes the row an earlier attach failure dropped. - import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { readAgentSessionFailureFact } from '../../shared/agent-session-failure' @@ -39,7 +34,6 @@ const keyOf = (nativeId: string) => type Lost = Map -/** Grok opens sessions in turn and cannot reopen any session in `lost`; it reopens every other. */ function sessionsLost(loads: string[], lost: Lost) { const opened = [PROVIDER_SESSION, FRESH, FRESH_2] return (agent: AcpScriptedAgent) => { @@ -102,7 +96,6 @@ async function openRestoreRig(code: number, message: string) { readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored' ) } - /** A send Grok answers in `providerSession`, ending its turn. */ const exchange = async (text: string, reply: string, providerSession: string) => { await send(host, text) const prompt = await rig.frame('session/prompt') @@ -148,7 +141,6 @@ describe('a saved Grok session Grok cannot reopen', () => { text: "Grok couldn't reopen its earlier session, so this chat continues in a new one. Grok doesn't remember the earlier messages." }) - // The chat keeps working in the fresh session. await exchange('still there?', 'yes', FRESH) const messages = (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items .filter((row) => row.body.kind === 'message') @@ -160,7 +152,6 @@ describe('a saved Grok session Grok cannot reopen', () => { ]) ) - // The next reopen loads the fresh session, and says nothing new. await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) await ready() @@ -195,10 +186,8 @@ describe('a created Grok session Grok reports missing on the first reopen', () = }) expect(head?.supersedesKey).toBeUndefined() expect(await warnings()).toHaveLength(1) - // The person still sees what Grok forgot. expect(await messages()).toEqual(expect.arrayContaining(['remember 42', 'noted'])) - // The next reopen loads the fresh session, and says nothing new. await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) await ready() @@ -240,7 +229,7 @@ describe('a replacement after its earlier attach failed to open the journal', () expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) await ready() await host.close(SESSION, 'user-close') - // The journal must open before a spawned child's background handshake proves a new link. + // Fail before the spawned child's background handshake can prove a replacement link. vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce( new Error('journal path unavailable') ) @@ -318,7 +307,6 @@ describe('a replacement Grok never saved, superseded after its row was written', await ready() expect(loads).toEqual([PROVIDER_SESSION, FRESH]) expect(await warnings()).toHaveLength(1) - // The newer session carries the same loss, so reopening it says nothing new. await host.close(SESSION, 'user-close') expect(await host.attach(CALLER, attachParams(fence()))).toMatchObject({ ok: true }) await ready() diff --git a/src/main/acp/acp-structured-host-start-aborts.test.ts b/src/main/acp/acp-structured-host-start-aborts.test.ts index 212a5b15ef4..17c717e1ac2 100644 --- a/src/main/acp/acp-structured-host-start-aborts.test.ts +++ b/src/main/acp/acp-structured-host-start-aborts.test.ts @@ -1,6 +1,3 @@ -// A Grok start the host aborts — at quit, by a close, or by an admitted Stop — through the one -// signal the host owns, with a scripted Grok, the real record store and an on-disk journal. - import { afterEach, describe, expect, it } from 'vitest' import { closeProviderTimelineRigs } from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' @@ -35,7 +32,7 @@ async function within(promise: Promise, ms: number): Promise void): void { const aborts = host.collaboratorsForTests().runtimeState.acquireAborts const begin = aborts.begin.bind(aborts) @@ -55,7 +52,7 @@ function beforeAttachEnds(host: StructuredAgentSessionHost, atEnd: () => void): } } -/** Runs `during` once, while the attach is still probing the previous owner, before any launch. */ +/** Runs during owner probing, before launch. */ function duringOwnerProbe(host: StructuredAgentSessionHost, during: () => void): void { const state = host.collaboratorsForTests().runtimeState const probe = state.probeOwner.bind(state) @@ -76,7 +73,6 @@ describe('a Grok start the host aborts', () => { }) const attaching = host.attach(CALLER, attachParams()) await rig.frame('initialize') - // The start has no bound of its own: the quit is what ends it. expect(await within(host.flushAllStreamedEvents({ trigger: 'quit' }), 2_000)).not.toBe(STALLED) expect((await attaching).ok).toBe(true) expect(rig.child().exited).toBe(true) @@ -122,7 +118,6 @@ describe('a Grok start the host aborts', () => { it('never launches Grok for a start a Stop reached while its attach was still reconciling', async () => { const { rig, host } = await openHostRig() expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: true }) - // The chat closes; the next send has to start Grok again. await host.close(SESSION, 'user-close') let stopping: ReturnType | undefined duringOwnerProbe(host, () => { @@ -145,7 +140,6 @@ describe('a Grok start the host aborts', () => { let second: Promise | undefined duringOwnerProbe(host, () => { stopping = stop(host) - // Accepted while the aborted start is still unwinding. second = send(host, 'second') }) await send(host, 'hello') @@ -158,7 +152,6 @@ describe('a Grok start the host aborts', () => { it('starts Grok afresh for a message sent right after a Stop ended a hung handshake', async () => { const { rig, host, children } = await openClosedResumableChat({ hangsHandshake: 2 }) const first = send(host, 'hello') - // The restart's own child, not the first one's. await waitFor(() => expect(children()).toBe(2)) await rig.frame('initialize') const stopping = stop(host) @@ -170,8 +163,7 @@ describe('a Grok start the host aborts', () => { }) }) -/** A Grok chat the user closed; each later start loads it. `hangsHandshake`: that child never - * answers `initialize`. */ +/** `hangsHandshake` selects a child that never answers initialize. */ async function openClosedResumableChat(options: { hangsHandshake?: number } = {}) { let children = 0 let resumed = false @@ -194,7 +186,6 @@ async function openClosedResumableChat(options: { hangsHandshake?: number } = {} return { ...rig, children: () => children } } -/** The stopped message reads cancelled; the one sent after it reached Grok, with no failure row. */ async function expectDeliveredAfterStop( { rig, host }: Pick>, 'rig' | 'host'>, secondId: string diff --git a/src/main/acp/acp-structured-host-stop.test.ts b/src/main/acp/acp-structured-host-stop.test.ts index eec7cb0322a..e82d5423d80 100644 --- a/src/main/acp/acp-structured-host-stop.test.ts +++ b/src/main/acp/acp-structured-host-stop.test.ts @@ -1,6 +1,4 @@ import { deferred } from './acp-scripted-agent.test-support' -// A Grok chat's Stop through the real host: it ends the process once Grok settles its turn, and the -// next send resumes the session. import { afterEach, describe, expect, it, vi } from 'vitest' import { withNativeChatCutTurnNotices } from '../../shared/native-chat-cut-turn-notice' @@ -31,7 +29,7 @@ describe('a Grok chat Stop', () => { await send(host, 'hello') const prompt = await rig.frame('session/prompt') first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) - // Grok takes a moment to wind the turn down; the host waits for it before ending the process. + // Delay cancellation completion to verify that process close waits for the turn. const answered: { cancel: boolean; atClose: boolean | null } = { cancel: false, atClose: null } const cancellation = deferred() first.agent.on('session/cancel', () => cancellation.resolve()) @@ -63,7 +61,6 @@ describe('a Grok chat Stop', () => { expect(count.loads).toBe(1) expect(framesOf(second, 'session/new')).toHaveLength(0) expect(await messages()).toEqual(['hello', 'partial', 'again']) - // Only the Stop's own note: no exit or cut-reply notice for a Stop the person asked for. const transcript = withNativeChatCutTurnNotices(await rows(), { agentName: 'Grok' }) expect( transcript.flatMap((row) => (row.body.kind === 'status' ? [row.body.text] : [])) @@ -82,7 +79,7 @@ describe('a Grok chat Stop', () => { first.agent.notify('session/update', replyChunk(promptIdOf(prompt), 'partial')) await rig.settle() await host.flushStreamedEvents(SESSION) - // A write that never completes, as a full pipe to a Grok that stopped reading. + // Model a full pipe to an agent that stopped reading. let cancels = 0 const deadlineStarted = deferred() let deadlineElapsed = false @@ -154,7 +151,7 @@ describe('a Grok chat Stop', () => { await host.flushStreamedEvents(SESSION) await stop(host) await waitFor(() => expect(first.exited).toBe(true)) - // The task ended with Grok's process; Orca cannot say how, so its row says no more than that. + // Process exit ends observation without proving the task's own outcome. const task = (await rows()).find((row) => row.itemId.includes('background-task')) expect(task?.body).toMatchObject({ blocks: [ @@ -196,8 +193,6 @@ describe('a Grok chat Stop naming a turn that has ended', () => { await host.close(SESSION, 'user-close') }) - // A late Stop is an accepted no-op, as a Claude Stop is: the follow-up handed over but not yet - // echoed is left alone. it('leaves Grok and the next prompt alone in the gap before Grok echoes it', async () => { const { rig, host, rows, turns } = await openAttachedHostRig() const { agent } = rig.child() diff --git a/src/main/acp/acp-structured-host-subagent-stop.test.ts b/src/main/acp/acp-structured-host-subagent-stop.test.ts index a0a5a4b8c39..6a4ca8fef16 100644 --- a/src/main/acp/acp-structured-host-subagent-stop.test.ts +++ b/src/main/acp/acp-structured-host-subagent-stop.test.ts @@ -47,7 +47,7 @@ async function stoppingChild(reportsOutcome: boolean) { stdio: [agent.stdin, agent.stdout, stderr, null, null] satisfies PipedChildProcess['stdio'] }) const spawn = vi.fn(() => child) - // Teardown is simulated: no signal or process-table operation can reach the machine. + // Stub teardown so fixture PIDs cannot reach real process operations. teardown.mockImplementation(async () => { child.emit('exit', 0, null) return 'exited' @@ -155,7 +155,6 @@ describe('Grok child outcome during a full host Stop', () => { const [unavailable] = await fixture.rosters() expect(fixture.childWork.views()).toMatchObject([{ membership: 'settled', outcome: 'unknown' }]) expect(unavailable.row.itemId).toBe(fixture.original.row.itemId) - // Contact was lost when the session ended, a moment the host observed. expect(unavailable.block).toEqual({ ...fixture.original.block, state: 'unverifiable', diff --git a/src/main/acp/acp-structured-host.test-support.ts b/src/main/acp/acp-structured-host.test-support.ts index 95e5bd1e51c..11463ab0e79 100644 --- a/src/main/acp/acp-structured-host.test-support.ts +++ b/src/main/acp/acp-structured-host.test-support.ts @@ -1,6 +1,3 @@ -// The ACP adapter behind the real host, with a scripted Grok, the real record store and an -// on-disk journal. - import { expect } from 'vitest' import { z } from 'zod' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' @@ -57,7 +54,6 @@ export const attachParams = (fence: number | null = null) => providerHandle: undefined }) -/** A launch that resumes the provider session once the chat has one. */ export function launch(resume: () => boolean, spec = GROK): () => Promise { return async () => ({ spec, @@ -95,7 +91,7 @@ export async function openHostRig( const state = hostTestState() const store = await openTestAgentSessionRecordStore(state.root) let generation = 0 - // As the runtime wires it: every exit and late send settlement the adapter observes reaches the host. + // Forward lifecycle and late-send settlements as the runtime does. const hosted: { host: StructuredAgentSessionHost | null } = { host: null } const journalDatabase = openTestJournalHostDatabase(state.root) const rig = await openAcpAdapterRig({ @@ -142,7 +138,6 @@ export async function openHostRig( (await host.history({ sessionId: SESSION, direction: 'tail' })).page.items .filter((row) => row.body.kind === 'message') .map((row) => messageText(row.body)) - /** Orca's send of `hello` as `m1`, and Grok's reply `text`, ended or left running. */ const exchange = async (text: string, end: boolean) => { await ready() const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal @@ -167,7 +162,6 @@ export async function openHostRig( return { rig, host, store, journalDatabase, fence, messages, exchange, ready } } -/** Grok's capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ export const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } @@ -198,7 +192,6 @@ export function stop(host: StructuredAgentSessionHost, turnId?: string) { export const framesOf = (child: FakeAcpChild, method: string) => child.agent.frames.filter((frame) => frame.method === method) -/** A new Grok chat the host attached; Grok loads its session on a later start, counting each. */ export async function openAttachedHostRig( deps: Partial & { script?: (agent: AcpScriptedAgent) => void diff --git a/src/main/acp/acp-structured-image-prompts.test.ts b/src/main/acp/acp-structured-image-prompts.test.ts index df608d46ee0..da033067e6f 100644 --- a/src/main/acp/acp-structured-image-prompts.test.ts +++ b/src/main/acp/acp-structured-image-prompts.test.ts @@ -10,7 +10,6 @@ import { openAcpAdapterRig, type AcpAdapterRig } from './acp-structured-adapter. afterEach(closeProviderTimelineRigs) const DATA = Buffer.from('89504e470d0a1a0a', 'hex').toString('base64') -// What the chat's options report says, by the same rule as the send. function acceptsImages(rig: AcpAdapterRig) { return rig.adapter .readOptions({ sessionId: SESSION, fence: 1 }) @@ -43,7 +42,6 @@ describe('ACP image prompts', () => { ] }) await expect(acceptsImages(rig)).resolves.toBe(true) - // The start's answer, not a second handshake. expect(rig.sent('initialize')).toHaveLength(1) }) diff --git a/src/main/acp/acp-structured-lane.ts b/src/main/acp/acp-structured-lane.ts index 97e8dd17435..5d013d97daa 100644 --- a/src/main/acp/acp-structured-lane.ts +++ b/src/main/acp/acp-structured-lane.ts @@ -1,6 +1,4 @@ -// One ACP child's path into the journal: the translator turns its traffic into grammar events, and -// the shared assembler writes them. Events apply strictly in order; one the sink refuses under -// backpressure holds every later one until the sink drains, so nothing is reordered or dropped. +// Preserve event order across journal backpressure; hold later events until the sink accepts. import type { StructuredAgentSessionChildWorkProducer } from '../native-chat/agent-session-wire/structured-agent-session-subagent-rows' import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' @@ -26,26 +24,23 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-logger' import type { AcpTextDrop } from './acp-turn-messages' -/** How long a held event waits for the sink to say it drained before trying again on its own. */ const BACKPRESSURE_RETRY_MS = 250 -/** Subagent evidence holds its place among the events: it reaches the host only after the journal - * wrote what came before it. */ +// Publish child-work evidence only after preceding journal events commit. type SubagentWork = { type: 'subagent.work'; evidence: AgentChildWorkEvidence[] } export type AcpStructuredLaneDeps = { sink: ProviderTimelineSink sessionId: string agent: string - /** The agent's name as a person reads it, for the rows that name it. */ agentName: string generation: string providerSessionId: string dialect: AcpDialect logger: StructuredAgentSessionLogger - /** A send of Orca's reached the agent: the turn it opened has its first provider event. */ + /** First provider event confirms receipt of an Orca send. */ onInputAccepted: (clientMessageId: string) => void - /** The sink refused for good; nothing more this child says can be journaled. */ + /** Permanent sink refusal prevents further journaling. */ onFailed: (reason: string) => void onChildWorkEvidence?: (evidence: AgentChildWorkEvidence[]) => void onChildWorkFailure?: (error: unknown) => void @@ -90,7 +85,6 @@ export class AcpStructuredLane { return this.assembler.openTurnId } - /** The host already opened this command's turn; the agent's frames and end join that turn. */ beginCommand(command: StructuredAgentSessionCommandRun): void { if (this.failed || this.disposed || this.backlog.length > 0) { throw new Error('ACP timeline has not drained for this command') @@ -114,7 +108,7 @@ export class AcpStructuredLane { this.drain() } - /** Resolves once `turnId` is no longer the open turn, or nothing more can be written. */ + /** Resolves when the turn leaves or further writes become impossible. */ whenTurnLeaves(turnId: string): Promise { return new Promise((resolve) => { const check = (): void => { @@ -128,13 +122,11 @@ export class AcpStructuredLane { }) } - /** The sink drained: whatever it held back goes now. */ retry(): void { this.drain() } - /** Whether the journal row `itemId` is the row of request `requestKey`, which the assembler - * writes under this acquisition's generation (any incarnation a reused key adds). */ + /** Matches this acquisition's request row, including reused-key incarnations. */ isRequestRow(itemId: string, requestKey: string): boolean { const identity = parseAgentJournalItemKey(itemId) if (identity?.provider !== 'legacy' || identity.sessionId !== this.deps.sessionId) { @@ -204,7 +196,7 @@ export class AcpStructuredLane { return } this.backlog.shift() - // A subagent the provider reports after its session ended is no live child of it. + // Ignore live-child evidence after session end. this.ended ||= event.type === 'session.ended' && dropped === undefined if (event.type === 'input.accepted') { this.deps.onInputAccepted(event.clientMessageId) @@ -233,7 +225,6 @@ export class AcpStructuredLane { this.deps.onFailed(reason) } - /** False when the sink held it back; it stays first in the backlog. */ private queueSubagentWork(evidence: AgentChildWorkEvidence[]): boolean { if (this.ended) { return true @@ -250,7 +241,6 @@ export class AcpStructuredLane { return admission.accepted } - /** A subagent's stop is offered only once the agent proved it honours one. */ private publishSubagentWork(evidence: AgentChildWorkEvidence[]): void { const stoppable = this.deps.childStops?.().subagents === true this.publishEvidence( diff --git a/src/main/acp/acp-structured-launch-preparation.ts b/src/main/acp/acp-structured-launch-preparation.ts index 9a508f698cc..f0b138e2fe8 100644 --- a/src/main/acp/acp-structured-launch-preparation.ts +++ b/src/main/acp/acp-structured-launch-preparation.ts @@ -1,5 +1,4 @@ -// What a start checks on this host before it spawns the agent. A runtime Orca manages is installed -// only when the person asks, so a start that finds no copy refuses rather than downloading one. +// Startup never installs a managed runtime; missing installations must fail before spawn. import { AgentSessionPreSpawnError, @@ -8,7 +7,6 @@ import { import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' import type { AcpStructuredSessionAdapterDeps } from './acp-structured-session-adapter-deps' -/** The launch a start spawns; `closedBeforeSpawn` throws once the start was abandoned. */ export function prepareAcpStructuredLaunch( deps: Pick, acquire: Pick, @@ -23,7 +21,6 @@ export function prepareAcpStructuredLaunch( return prepareAcpLaunch(deps, resolveLaunch, closedBeforeSpawn) } -/** Any spawn of the agent on this host, a chat's or not: its binary installed, its account ready. */ export async function prepareAcpLaunch< Launch extends { command: string; env: Record } >( @@ -34,7 +31,7 @@ export async function prepareAcpLaunch< const { spec } = deps const launch = await resolve().catch(preSpawnFailure) closedBeforeSpawn() - // A missed lookup returns the bare name; a selected Command setting resolves or refuses. + // Only a missed lookup returns the bare command; explicit Command settings resolve or fail. if (spec.managedRuntime && launch.command === spec.command) { throw new AgentSessionPreSpawnError( new Error(`${spec.managedRuntime.label} is not installed on this host`), diff --git a/src/main/acp/acp-structured-launch-resolution-omp.test.ts b/src/main/acp/acp-structured-launch-resolution-omp.test.ts index 0f34bd39f99..d0d901ebc66 100644 --- a/src/main/acp/acp-structured-launch-resolution-omp.test.ts +++ b/src/main/acp/acp-structured-launch-resolution-omp.test.ts @@ -48,7 +48,6 @@ function resolver( } describe('OMP ACP launch resolution', () => { - // OMP has no bypass flag: the chat's own mode alone decides whether Orca answers its requests. it("runs `omp acp` pointed at the agent directory the chat pinned, in the chat's own mode", async () => { for (const permissionMode of ['ask', 'full-access'] as const) { const launch = await resolver(ompRecord())({ identity, spawnToken: 'token', permissionMode }) @@ -66,7 +65,6 @@ describe('OMP ACP launch resolution', () => { ANTHROPIC_API_KEY: 'sk-test' } const launch = await resolver(ompRecord(), { launchEnv: userEnv })({ identity }) - // Only the shared pane and hook keys, and an inherited scratch folder: this chat has none. expect(launch.envToDelete).toEqual([ ...STRUCTURED_CHILD_ENV_TO_DELETE, NATIVE_CHAT_SCRATCH_DIR_ENV, @@ -83,7 +81,7 @@ describe('OMP ACP launch resolution', () => { 'darwin' ).env expect(child).toMatchObject(userEnv) - // A pane's identity would let OMP's Orca status extension report for this chat too. + // Pane identity would let the terminal extension report status for this chat. expect(child.ORCA_PANE_KEY).toBeUndefined() }) diff --git a/src/main/acp/acp-structured-launch-resolution-opencode.test.ts b/src/main/acp/acp-structured-launch-resolution-opencode.test.ts index bafcfd99753..6c46d57b2bc 100644 --- a/src/main/acp/acp-structured-launch-resolution-opencode.test.ts +++ b/src/main/acp/acp-structured-launch-resolution-opencode.test.ts @@ -253,7 +253,6 @@ describe('OpenCode ACP launch resolution', () => { }) }) -// Since OpenCode 2.0.0 `opencode2` is an alias the 2.x installer writes beside `opencode`. describe('OpenCode 2 ACP launch resolution', () => { const OPENCODE2 = { ...acpLaunchSpecFor('opencode2')!, @@ -277,7 +276,6 @@ describe('OpenCode 2 ACP launch resolution', () => { }) }) - // 1.x and 2.x below 2.0.14 never ran the private child this row needs. it.each(['1.18.35', '2.0.13', '2.0.14-rc.1'])('refuses %s as too old', async (version) => { await expect(launchOn(version)).rejects.toMatchObject({ installation: { agent: 'opencode2', installedVersion: version, minimumVersion: '2.0.14' } diff --git a/src/main/acp/acp-structured-launch-resolution.test.ts b/src/main/acp/acp-structured-launch-resolution.test.ts index babf62f582e..79baa2313bb 100644 --- a/src/main/acp/acp-structured-launch-resolution.test.ts +++ b/src/main/acp/acp-structured-launch-resolution.test.ts @@ -41,7 +41,6 @@ function grokRecord(overrides: Partial = {}): AgentSessionRe } } -/** A journal holding one turn of each named provider session. */ function journalWithTurns(...providerSessions: string[]): JournalLoad { const state = createJournalReducerState(identity.sessionId, 'epoch-1') providerSessions.forEach((providerSession, index) => { @@ -209,7 +208,6 @@ describe('ACP launch resolution', () => { nativeId: 'acp-1' }) const replaced = grokRecord({ providerHandleChain: [link('acp-1'), link('acp-2', 'acp-1')] }) - /** A journal holding the row for `lostKey`, written while `providerSession` ran. */ const withRow = (providerSession: string): JournalLoad => { const load = journalWithTurns() const itemId = agentJournalItemKey( @@ -236,7 +234,6 @@ describe('ACP launch resolution', () => { (await resolver(record, readJournal).resolve({ identity })).resume?.unannouncedLosses() const reads: string[] = [] - // A chain that lost nothing never reads the journal. expect( await losses(grokRecord({ providerHandleChain: [link('acp-1')] }), () => { reads.push('read') diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index 1403ad810d6..bdc0679077f 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -1,8 +1,4 @@ -// How a durable session record becomes an ACP agent launch, on the machine this runtime runs on. -// -// Every input is read back from the record the store made durable, never from the call that -// triggered the acquire: the working directory, the account home, and the provider session a -// resume names are the ones this session proved. +// Launch from durable record values for the working directory, pinned account and resume session. import { delimiter } from 'node:path' import { homedir } from 'node:os' @@ -53,59 +49,46 @@ export type AcpStructuredLaunch = { command: string args: string[] cwd: string - /** The child's sealed environment; a read that starts no child gets the agent's own. */ + /** Sealed for spawns; recovery reads use the unsealed agent environment. */ env: Record - /** Keys the child must not inherit from this process's own environment. */ envToDelete: string[] fullAccess: boolean - /** The provider session to load; null starts a new one. */ resume: { sessionId: string - /** The chain key of the session to load, which a fresh session names if it takes over. */ key: string - /** Only a session this chat created and never exchanged a turn on may be one the agent never - * saved, and so be superseded. Read only when the agent cannot reopen it. */ + /** A chat-created session with no exchanged turn may never have been saved. */ mayBeUnsaved: () => boolean - /** Keys of the conversations the chain says the agent lost whose warning row the chat does not - * hold, as when the attach that would have written it failed. Read once the start succeeds. */ + /** Lost conversations whose warning rows are missing; read after startup succeeds. */ unannouncedLosses: () => string[] } | null } export type AcpStructuredLaunchPartDeps = { - /** The chat's journal as it stands, read without opening it; null when it has none. */ + /** Reads without opening the journal. */ readJournal: (sessionId: string) => JournalLoad | null resolveCommand?: typeof resolveCliCommand homePath?: string - /** The environment the child process inherits at spawn; this process's own by default. */ inheritedEnv?: NodeJS.ProcessEnv probeVersion?: AgentCliVersionReader - /** Orca's data folder on this host, which holds the spec's managed runtime. */ dataDirectory: string } & Pick -/** What a launch's binary and environment are read from; a catalog probe reads the same. */ export type AcpLaunchInvocationDeps = { - /** The shared base env; re-read per acquisition. */ + /** Reread per acquisition. */ resolveEnvironment: () => Promise> - /** The user's per-agent environment overlay from settings. */ resolveLaunchEnv?: (agent: string) => Record - /** The user's per-agent Command setting, re-read per acquisition; none runs the stock binary. */ + /** Reread per acquisition; absent uses the stock command. */ resolveCommandSettings?: () => StructuredAgentCommandSettings - /** Rereads that env when the agent's program is not found in it; absent never retries. */ + /** Optional environment refresh after a missing-program lookup. */ refreshEnvironmentAfterMiss?: (askedAt: number) => Promise - /** Where the program starts, for a PATH entry relative to it; Orca's own when absent. */ + /** Resolves relative PATH entries against the launch directory. */ resolveWorkingDirectory?: () => Promise | string } & Pick< AcpStructuredLaunchPartDeps, 'resolveCommand' | 'homePath' | 'inheritedEnv' | 'dataDirectory' > -/** The binary a launch with `env` spawns: the applicable Command setting when set, else the stock - * binary from PATH, then the agent's own install directories. A spec with a managed runtime runs - * its installed release before those, and a copy from them only beside every pinned member; the - * bare name when none qualifies. A create's version check resolves through here too, so it asks - * about the same file. A null `dataDirectory` skips the installed release. */ +/** Prefer explicit commands, then managed releases, then complete pinned copies on PATH. */ export function resolveAcpLaunchCommand( spec: AcpLaunchSpec, env: Readonly>, @@ -145,8 +128,6 @@ export function resolveAcpLaunchCommand( return (found !== spec.command && copyBesidePinnedMembers(found, pin)) || spec.command } -/** What `command`'s `--version` says against the spec's minimums; asked only when the spec names - * some or `wantVersion`. */ export async function checkAcpLaunchVersion( spec: AcpLaunchSpec, launch: { command: string; cwd: string; env: Record }, @@ -163,9 +144,7 @@ export async function checkAcpLaunchVersion( ) } -/** The binary and environment any child of this agent runs under for `accountHome`: a session's - * launch and a catalog probe both resolve here, so neither can reach another account or install, - * and both reread the environment once when the program is missing from it. */ +/** Resolve chats and probes consistently; optionally refresh once after a missing-program lookup. */ export function resolveAcpLaunchInvocation( spec: AcpLaunchSpec, accountHome: AgentSessionAccountHome, @@ -197,8 +176,6 @@ async function readAcpLaunchInvocation( return { command, env, envToDelete } } -/** An ACP agent's part of a launch: its binary under the pinned account, the release check, the - * visuals skill, its arguments and the provider session to reattach. */ export function acpStructuredLaunchPart( spec: AcpLaunchSpec, deps: AcpStructuredLaunchPartDeps @@ -222,7 +199,7 @@ export function acpStructuredLaunchPart( ) const cwd = await basis.launchDirectory() const visuals = spec.visualsSkill ? await basis.visuals() : null - // Again at every launch: the binary on PATH may have changed since the chat was created. + // PATH may have changed since chat creation. const launchVersion = await checkAcpLaunchVersion( spec, { command, cwd, env }, @@ -242,7 +219,6 @@ export function acpStructuredLaunchPart( inherited: deps.inheritedEnv ?? process.env }) : null - // The chat's own mode, as the host decided it for this start; a start naming none asks. const fullAccess = basis.permissionMode === 'full-access' const head = agentSessionProviderHandleChainHead(record.providerHandleChain) return { @@ -267,7 +243,7 @@ export function acpStructuredLaunchPart( inheritedEnvToDelete: [ ...STRUCTURED_CHILD_ENV_TO_DELETE, ...envToDelete, - // Without visuals, a folder Orca itself inherited (started from a chat) names another chat's. + // An inherited scratch directory belongs to a different chat. ...(skill ? [] : [NATIVE_CHAT_SCRATCH_DIR_ENV]), LEGACY_NATIVE_CHAT_VISUALS_DIR_ENV ] @@ -275,8 +251,7 @@ export function acpStructuredLaunchPart( } } -/** Proof the chat holds no turn of provider session `nativeId`; a journal that does not read whole, - * or does not read at all, proves nothing. */ +/** Missing journals count as empty; read errors or damaged/newer journals cannot prove no exchange. */ function nothingExchangedOn( readJournal: AcpStructuredLaunchPartDeps['readJournal'], sessionId: string, @@ -303,8 +278,7 @@ function nothingExchangedOn( return true } -/** Derived on every start, never stored: a row an attach failure dropped is written by the next - * start that succeeds. A journal that does not read whole proves nothing, so none is written. */ +/** Retry missing loss-warning rows on successful starts; unreadable journals suppress new warnings. */ function unannouncedLosses( readJournal: AcpStructuredLaunchPartDeps['readJournal'], sessionId: string, @@ -337,6 +311,6 @@ function unannouncedLosses( written.add(spelled) } } - // Any session's row counts: one an unused replacement wrote stays when the agent supersedes it. + // Warnings survive even if the replacement session that wrote them is superseded. return lost.filter((key) => !written.has(providerTimelineKeyPart(acpSessionNotRestoredItem(key)))) } diff --git a/src/main/acp/acp-structured-launch-visuals.test.ts b/src/main/acp/acp-structured-launch-visuals.test.ts index 3db6e617eac..2030455d34c 100644 --- a/src/main/acp/acp-structured-launch-visuals.test.ts +++ b/src/main/acp/acp-structured-launch-visuals.test.ts @@ -111,8 +111,7 @@ describe('ACP launch visuals', () => { expect(launch.env[NATIVE_CHAT_SCRATCH_DIR_ENV]).toBe(FOLDER) }) - // OMP's settings layers (global, profile, project, overlays) replace lists, so any config of - // Orca's would hide the user's own skill folders; a plugin folder adds beside them. + // OMP config layers replace skill lists; a plugin directory preserves the user's configured folders. it("gives OMP the plugin folder, leaving the user's config files as they set them", async () => { const launch = await resolve('omp', { version: '17.0.5', diff --git a/src/main/acp/acp-structured-launch-visuals.ts b/src/main/acp/acp-structured-launch-visuals.ts index aa1ca0ae855..39f25f53e3b 100644 --- a/src/main/acp/acp-structured-launch-visuals.ts +++ b/src/main/acp/acp-structured-launch-visuals.ts @@ -1,6 +1,3 @@ -// What an ACP launch gets for inline visuals: the chat's folder, and whatever its agent needs to -// load the skill. Best effort: a chat whose agent can't load it starts without visuals. - import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import type { NativeChatVisualsLaunch, @@ -10,12 +7,10 @@ import type { AcpLaunchSpec } from './acp-launch-specs' import type { AcpVisualsSkillLaunch } from './acp-visuals-skill' export type AcpLaunchVisualsDeps = { - /** Prepares a chat's scratch folder for inline visuals; absent, no chat gets visuals. */ prepareVisuals?: PrepareNativeChatVisuals logger?: StructuredAgentSessionLogger } -/** The environment the child will see: what it inherits, the launch's laid over, then the strips. */ function childEnvironmentView( env: Readonly>, inherited: NodeJS.ProcessEnv, @@ -34,8 +29,7 @@ function childEnvironmentView( return view } -/** How this launch's agent loads the skill for the chat's prepared `visuals`; null leaves the chat - * without visuals. Never throws. */ +/** Skill loading failures leave the chat without visuals. */ export async function loadAcpVisualsSkill( spec: AcpLaunchSpec, deps: AcpLaunchVisualsDeps, diff --git a/src/main/acp/acp-structured-location-support.ts b/src/main/acp/acp-structured-location-support.ts index df5e8c18124..7cd9c60bae6 100644 --- a/src/main/acp/acp-structured-location-support.ts +++ b/src/main/acp/acp-structured-location-support.ts @@ -2,7 +2,6 @@ import type { AgentSessionExecutionLocation } from '../../shared/agent-session-r import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import type { AcpLaunchSpec } from './acp-launch-specs' -/** The execution host can supervise this ACP child and has its pinned build when one is required. */ export function supportsAcpStructuredLocation( spec: AcpLaunchSpec, location: AgentSessionExecutionLocation, diff --git a/src/main/acp/acp-structured-managed-runtime.test.ts b/src/main/acp/acp-structured-managed-runtime.test.ts index 682fcc42063..3a0c5fa7962 100644 --- a/src/main/acp/acp-structured-managed-runtime.test.ts +++ b/src/main/acp/acp-structured-managed-runtime.test.ts @@ -52,7 +52,7 @@ it('starts the installed release first, else a copy beside every pinned member', } await expect(spawn()).rejects.toMatchObject({ reason: 'runtimeNotInstalled' }) - // A server without its companion beside it is no copy. + // A server without its companion is an incomplete runtime. const localBin = join(homePath, '.local', 'bin') await mkdir(localBin, { recursive: true }) await writeFile(join(localBin, command), '', { mode: 0o755 }) diff --git a/src/main/acp/acp-structured-omp-recordings.test.ts b/src/main/acp/acp-structured-omp-recordings.test.ts index 62666cc9ba9..918808b5970 100644 --- a/src/main/acp/acp-structured-omp-recordings.test.ts +++ b/src/main/acp/acp-structured-omp-recordings.test.ts @@ -1,6 +1,3 @@ -// The adapter against OMP's recorded `omp acp` traffic (17.0.5): each recording plays back as the -// agent, and the assertions read the journal a client would see. - import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' @@ -151,7 +148,7 @@ describe('OMP recordings through the adapter', () => { await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'completed', outcome: 'success' }]) ) - // OMP reports an exit code only when it is not zero; a completed foreground command exited 0. + // OMP omits zero exit codes for completed foreground commands. expect(await toolRow(rig)).toMatchObject({ state: 'completed', exitCode: 0, @@ -170,7 +167,7 @@ describe('OMP recordings through the adapter', () => { await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'interrupted', outcome: 'cancellation' }]) ) - // OMP sends nothing more for the stopped command; Orca settles its row with the turn. + // OMP sends no final tool update after cancellation; settle it with the turn. const tool = await toolRow(rig) expect(tool).toMatchObject({ endedAs: 'interrupted', diff --git a/src/main/acp/acp-structured-opencode-recordings.test.ts b/src/main/acp/acp-structured-opencode-recordings.test.ts index be6b24ab0ce..fb7b5fcb04c 100644 --- a/src/main/acp/acp-structured-opencode-recordings.test.ts +++ b/src/main/acp/acp-structured-opencode-recordings.test.ts @@ -1,7 +1,3 @@ -// The adapter against OpenCode's recorded `opencode acp` traffic (1.18.31, and 2.0.29 for the -// `opencode-v2-*` ones): each recording plays back as the agent, and the assertions read the -// journal a client would see. - import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' @@ -114,7 +110,7 @@ describe('OpenCode recordings through the adapter', () => { await answer(rig, replay, 'reject', 1) await waitFor(() => expect(replay.awaiting).toBeNull()) await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'completed' }])) - // The write OpenCode repeats to a client that took no file system leaves no row. + // No filesystem capability was advertised, so the repeated write request creates no row. expect(JSON.stringify((await rig.rig.rows()).map((row) => row.body))).not.toContain( 'fs/write_text_file' ) @@ -152,7 +148,7 @@ describe('OpenCode 2 recordings through the adapter', () => { }) }) - // Unlike 1.x, 2.x ends the turn when a permission is rejected: it answers `cancelled`. + // OpenCode 2.x ends denied-permission turns as cancelled, unlike 1.x. it('a rejected permission fails its tool and OpenCode ends the turn as cancelled', async () => { const { rig, replay } = await replaying('opencode-v2-deny', 'opencode2') await answer(rig, replay, 'reject') diff --git a/src/main/acp/acp-structured-options.test.ts b/src/main/acp/acp-structured-options.test.ts index 917c9bf690c..2f54e1e7f50 100644 --- a/src/main/acp/acp-structured-options.test.ts +++ b/src/main/acp/acp-structured-options.test.ts @@ -33,7 +33,7 @@ describe('ACP session options', () => { const options = new AcpStructuredOptions() options.adoptSession({ configOptions }) const { models, current } = options.read() - // The effort menu belongs to the model running now; nothing is anyone's default. + // Current values supply an effort menu, never a catalog default. expect(models).toEqual([ { id: 'model-a', label: 'Model A', isDefault: false, efforts: [] }, { @@ -64,7 +64,7 @@ describe('ACP session options', () => { reasoningEfforts: [{ value: 'medium', default: true }] } }, - // The session's own effort written into the running model's meta is not its default. + // Session-selected effort metadata must not become a model default. { modelId: 'model-b', name: 'Model B', diff --git a/src/main/acp/acp-structured-options.ts b/src/main/acp/acp-structured-options.ts index 8c7821d7caf..c33219483a1 100644 --- a/src/main/acp/acp-structured-options.ts +++ b/src/main/acp/acp-structured-options.ts @@ -1,7 +1,3 @@ -// What a running ACP agent reports about its own choices — config options (model, reasoning -// effort), the older model list, and its `/` commands — as Orca's option and command surfaces. -// Kept as the agent last reported it; nothing here is guessed or listed by Orca itself. - import { abortSignalReason } from '../../shared/abort-signal-reason' import type { @@ -29,7 +25,6 @@ import { type SelectOption = Extract -/** How an Orca option key reaches the agent: a config option it declared, or the model method. */ export type AcpOptionWrite = | { method: 'config'; configId: string; value: string } | { method: 'model'; modelId: string } @@ -62,16 +57,15 @@ export class AcpStructuredOptions { private configOptions: SessionConfigOption[] = [] private models: SessionModelState | null = null private commands: AgentSessionSlashCommand[] | undefined - // A loaded session keeps the model it ran; only a new one resolves the agent's own config. + // Only new sessions resolve configured defaults; loaded sessions retain their saved choices. private resolvesConfig = false readonly picked = new Set() - /** Each key's value from Orca's last successful write; never the agent's report. */ + /** Last successful Orca writes, distinct from agent-reported values. */ readonly applied = new Map() private initialChoice: Readonly> = {} currentProof = new Set() writeRevision = 0 - /** The session state a new, load or resume response reported. */ adoptSession( response: { configOptions?: SessionConfigOption[] | null @@ -86,12 +80,11 @@ export class AcpStructuredOptions { this.currentProof = new Set(Object.keys(this.initialChoice)) } - /** The native selection before any message changed this child. */ + /** Native selection before any message changed this child. */ messageDefault(): Readonly> { return this.initialChoice } - /** Orca sent `key` to the session, or the chat's saved options hold a value for it. */ notePick(key: string): void { this.picked.add(key) } @@ -124,7 +117,6 @@ export class AcpStructuredOptions { return this.commands } - /** Where a pick of `key` goes; null when the agent offers nothing for it. */ write(key: string, value: string): AcpOptionWrite | null { if (key === 'model') { const option = this.select('model') @@ -140,11 +132,7 @@ export class AcpStructuredOptions { return null } - /** - * The agent's models and what this session runs. A model's effort menu and default are catalog - * facts: the dialect's per-model menu when the agent advertises one, else the session's effort - * option for the model it runs now only. The session's current values are never a default. - */ + /** Effort menus describe models; current session values never establish catalog defaults. */ read(): Pick { const modelOption = this.select('model') const effortOption = this.select('thought_level') @@ -174,7 +162,7 @@ export class AcpStructuredOptions { return { ...model, isDefault: false, - // An empty advertised menu is no menu: the running model keeps the one its session offers. + // Fall back to the running session's menu when the advertised menu is empty. ...(advertised && advertised.efforts.length > 0 ? advertised : sessionMenu) } }) @@ -189,9 +177,7 @@ export class AcpStructuredOptions { } } - /** With no model sent since a new session began, what it runs is the agent's own config - * resolution: that scope's configured default. Null when that names no listed model; undefined - * when this session can't say. */ + /** Unpicked new-session default: null if unlisted, undefined if the session cannot establish it. */ configuredDefault(): AgentModelCatalogConfiguredChoice | null | undefined { const { models, current } = this.read() return unpickedSessionConfiguredChoice({ @@ -202,12 +188,10 @@ export class AcpStructuredOptions { }) } - /** What this session's start hands the host once: its listing, with what it resolved. */ startListing(): AgentModelCatalogLiveListing { return withLiveCatalogListing(this.read(), this.configuredDefault()).catalogListing } - /** The values the agent reports now, as the record's options would name them. */ reported(): Readonly> { const { current } = this.read() return { @@ -216,7 +200,7 @@ export class AcpStructuredOptions { } } - /** The session's mode is its `mode`-category select, whatever the agent names it. */ + // Match the mode category rather than assuming an agent's config ID. modeConfigId(): string { return this.select('mode')?.id ?? 'mode' } @@ -229,7 +213,6 @@ export class AcpStructuredOptions { } } -/** Images go only to an agent whose start advertised them. */ export async function acpSessionAcceptsImages(session: { connection: Pick }): Promise { @@ -238,12 +221,11 @@ export async function acpSessionAcceptsImages(session: { ) } -/** A running child's options, and whether a message to it may carry images. */ export async function readAcpLiveOptions( session: Parameters[0] & { options: AcpStructuredOptions } ) { const acceptsImages = await acpSessionAcceptsImages(session) - // Only the start says what the config resolved: the session may have moved since. + // Only startup establishes config resolution; later reads may reflect changed picks. return { ...withLiveCatalogListing(session.options.read()), acceptsImages } } diff --git a/src/main/acp/acp-structured-prompt-failure.test.ts b/src/main/acp/acp-structured-prompt-failure.test.ts index 44c45250480..30a9ced66d2 100644 --- a/src/main/acp/acp-structured-prompt-failure.test.ts +++ b/src/main/acp/acp-structured-prompt-failure.test.ts @@ -19,7 +19,6 @@ function specFor(agent: string): AcpLaunchSpec { return spec } -/** Fails the first prompt with `code`, after the turn started unless `beforeTurn`. */ async function failFirstPrompt( spec: AcpLaunchSpec, error: { code: number; message: string; data?: unknown }, @@ -52,7 +51,7 @@ async function failFirstPrompt( } const WORDS = 'Model quota exceeded for this workspace; try again in 5 minutes.' -// OpenCode 1.18.31's recorded answer when its model provider fails: data is only metadata. +// OpenCode 1.18.31 provider-error data contains metadata, not user-facing failure text. const OPENCODE_PROVIDER_ERROR = { code: -32603, message: 'Internal error: CAPTURE_PROVIDER_400', diff --git a/src/main/acp/acp-structured-prompts.ts b/src/main/acp/acp-structured-prompts.ts index 7f9516e9827..4d354f574b8 100644 --- a/src/main/acp/acp-structured-prompts.ts +++ b/src/main/acp/acp-structured-prompts.ts @@ -1,11 +1,4 @@ -// The agent's open requests to the user (permission, question, plan approval), each held as the -// JSON-RPC call it is until a person answers it, a Stop cancels it, or the agent withdraws it. -// An answer claims the request, commits the journal compare-and-set while the claim is held, and -// only then replies, so a second client loses the commit and the agent hears exactly one answer. -// Each request is this module's to answer: once the agent cancels it, or a Stop or steer withdraws -// what is open (`withdrawAll`), an unclaimed one is answered with the agent's own cancelled reply -// and a claimed one still sends the committed answer — except a permission the agent itself -// cancelled, which the protocol answers `cancelled` the moment it is cancelled. +// Claim requests through journal commit before replying so competing clients cannot answer twice. import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' import { @@ -20,11 +13,11 @@ import type { AcpStructuredLane } from './acp-structured-lane' type OpenRequest = { key: string presentation: AcpRequestPresentation - /** A permission, whose cancellation the protocol answers itself. */ + /** Protocol cancellation can answer permissions immediately, even while a claim is saved. */ permission: boolean signal: AbortSignal claimed: boolean - /** A Stop or steer withdrew it while an answer was being saved: a failed save withdraws it. */ + /** Deferred withdrawal applies if the claimed answer fails to save. */ withdrawing: boolean settle: (reply: unknown) => void } @@ -34,9 +27,9 @@ export class AcpStructuredPrompts { constructor( private readonly lane: () => AcpStructuredLane | null, - /** Whether a request arriving now belongs to a turn that may still ask the person. */ + /** Allows interactive requests for the current turn. */ private readonly admits: () => boolean, - /** Whether its turn may still show what a request answered at once carried. */ + /** Allows content from requests that settled immediately. */ private readonly shows: () => boolean ) {} @@ -44,7 +37,6 @@ export class AcpStructuredPrompts { return this.open.size } - /** Answers one agent request: a row the user can act on, or the translator's verdict on it. */ handle(method: string, params: unknown, context: AcpRequestContext): Promise { const lane = this.lane() if (!lane || context.id === null) { @@ -52,15 +44,13 @@ export class AcpStructuredPrompts { } const translated = lane.translator.request(method, params, context.id) if (translated.settled) { - // Answered at once; what it carried shows only while its turn may still show anything. if (this.shows()) { lane.apply(translated.events) } return Promise.resolve(translated.settled.reply) } if (translated.presentation && !this.admits()) { - // No prompt of Orca's runs, or a Stop or steer is cutting it short: the agent hears its own - // cancelled reply and no card opens. + // Reply cancelled without opening a card when the turn can no longer ask. return Promise.resolve(translated.presentation.reply(null)) } lane.apply(translated.events) @@ -86,8 +76,7 @@ export class AcpStructuredPrompts { } } this.open.set(key, entry) - // The agent or a Stop gave up on it: its card can no longer be answered, and the agent hears - // its own cancelled reply. A claimed one finishes its answer. + // Unclaimed requests withdraw immediately; claimed answers finish their commit. context.signal.addEventListener( 'abort', () => { @@ -129,12 +118,12 @@ export class AcpStructuredPrompts { } entry.settle(reply) if (entry.permission && entry.signal.aborted) { - // Cancelled while the answer was saved: the agent already heard `cancelled`, not this answer. + // Protocol cancellation already replied while the permission answer was being saved. throw new Error(`the agent stopped waiting for the answer to ${input.itemId}`) } } - /** A Stop or steer: the agent hears its own cancelled reply to everything no answer has claimed. */ + /** Cancel unclaimed requests; let claimed answers finish saving. */ withdrawAll(): void { for (const entry of this.open.values()) { if (entry.claimed) { @@ -150,7 +139,6 @@ export class AcpStructuredPrompts { this.lane()?.apply([{ type: 'request.withdrawn', request: entry.key }]) } - /** The child is gone: nobody can answer for it any more. */ clear(): void { this.open.clear() } diff --git a/src/main/acp/acp-structured-request-admission.test.ts b/src/main/acp/acp-structured-request-admission.test.ts index cca2c684c7c..61045a63d7f 100644 --- a/src/main/acp/acp-structured-request-admission.test.ts +++ b/src/main/acp/acp-structured-request-admission.test.ts @@ -1,7 +1,3 @@ -// What Grok asks reaches the person only while Orca's prompt runs and may still ask: a Stop or a -// steer withdraws what is open and refuses what arrives later with Grok's own cancelled reply, with -// no card, as does a turn Grok began itself; an answer the person already gave is still sent. - import { afterEach, describe, expect, it } from 'vitest' import { closeProviderTimelineRigs, diff --git a/src/main/acp/acp-structured-session-adapter-deps.ts b/src/main/acp/acp-structured-session-adapter-deps.ts index 1ff7d93836c..db2c431d38d 100644 --- a/src/main/acp/acp-structured-session-adapter-deps.ts +++ b/src/main/acp/acp-structured-session-adapter-deps.ts @@ -13,40 +13,36 @@ import type { AcpStructuredLaunch } from './acp-structured-launch-resolution' import type { AgentChildWorkEvidence } from '../../shared/agent-status-child-work-evidence' import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' -/** How long a Stop, counted from its cancel, lets the agent end its turn before the child goes. */ +// Grace begins at cancellation, before process close. export const ACP_STOP_GRACE_MS = 4_000 -/** How long a model or effort pick waits for the agent's answer, as Claude's and Codex's do. */ export const ACP_OPTION_WRITE_TIMEOUT_MS = 30_000 export type AcpStructuredSessionAdapterDeps = { spec: AcpLaunchSpec - /** The child to spawn, sealed with the reservation's token. */ + /** Sealed with the reservation's spawn token. */ resolveLaunch: (input: { identity: AgentSessionJournalIdentity spawnToken: string - /** The mode the host decided this start runs with; absent asks. */ + /** Defaults to ask. */ permissionMode?: AgentChatPermissionMode }) => Promise - /** The same launch for a read that starts no child (restart recovery): the agent's env, unsealed. */ + /** Recovery reads use the same launch with an unsealed environment. */ readLaunch: (identity: AgentSessionJournalIdentity) => Promise /** Current host arguments before protocol-specific filtering; copies open through the CLI. */ resolveLaunchArgs?: () => Promise | string[] - /** The chat's journal as it stands, read without opening it; null when it has none. */ + /** Reads without opening the journal. */ readJournal?: (sessionId: string) => JournalLoad | null - /** Orca's data folder on this host, under its instance lock. */ + /** Protected by this host's instance lock. */ dataDirectory: string - /** Starts the agent's process and owns its protocol: `createAcpAgentConnection` in production. */ connect: ConnectAcpAgent readProcessStartTime?: (pid: number) => Promise - /** Every exit, expected or not: the host ends that child's record. */ onEvent?: (event: StructuredAgentSessionLifecycleEvent) => void - /** `producer`: the reporting child's own sink, which writes what the report changes. */ + /** `producer` is the reporting child's sink for durable changes. */ onChildWorkEvidence?: ( sessionId: string, evidence: AgentChildWorkEvidence[], producer?: StructuredAgentSessionChildWorkProducer ) => void - /** A send this adapter admitted, settled once the agent answered for it. */ onDispatchSettledLate?: ( settlement: { sessionId: string; clientMessageId: string } & ( | { providerIdentity: AgentJournalItemIdentity } @@ -58,9 +54,7 @@ export type AcpStructuredSessionAdapterDeps = { now?: () => number mintGeneration?: () => string mintLinkId?: () => string - /** Bounds a Stop's wait for the agent to end its turn; past it the child is closed. */ stopGraceMs?: number - /** Bounds a pick's wait for the agent's answer; past it the pick fails. */ optionWriteTimeoutMs?: number isWindowsProcessStartTimeAvailable?: () => boolean } diff --git a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts index 7de71caca1f..8489b83203d 100644 --- a/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts +++ b/src/main/acp/acp-structured-session-adapter-lifecycle.test.ts @@ -71,7 +71,7 @@ describe('ACP Stop names a turn', () => { await sendHello(rig, 'new') await rig.frame('session/prompt', 1) await rig.settle() - // The journal shows no turn running: Grok has not echoed the new prompt yet, as with Claude. + // Before the first echo, the new prompt has no running journal turn. await expect( rig.adapter.cancelTurn({ sessionId: SESSION, @@ -105,11 +105,10 @@ describe('ACP Stop names a turn', () => { resolveLiveTurnId: () => live }) ).resolves.toEqual({ cancelled: true }) - // The steer's cancel, then the Stop's own bounded one. expect(rig.sent('session/cancel')).toHaveLength(2) expect(rig.settled).toEqual( expect.arrayContaining([ - // Held by Orca, never prompted: proof it never reached the agent. + // A held steer never reached the agent. expect.objectContaining({ clientMessageId: 'follow-up', state: 'rejected', @@ -144,7 +143,7 @@ describe('ACP Stop of a turn Grok began itself', () => { await rig.adapter.awaitStoppedRequestEnd(SESSION, Date.now()) expect(rig.sent('session/cancel')).toHaveLength(1) expect((await journalTurns(rig)).at(-1)).toMatchObject({ state: 'interrupted' }) - // The host ends the child next; the adapter does not end it on its own. + // Process teardown belongs to the host. expect(rig.child().closes).toBe(0) }) @@ -193,7 +192,7 @@ describe('ACP connection loss', () => { ]) ) expect(rig.child().closes).toBe(1) - // Nothing was opened for a send the agent never echoed, so nothing claims it completed. + // An unacknowledged prompt never opened a turn to mark complete. expect(await journalTurns(rig)).toEqual([]) await expect(sendHello(rig, 'after')).rejects.toThrow(/no live grok child/) }) @@ -224,7 +223,6 @@ describe('ACP connection loss', () => { child.agent.close() await rig.settle() expect(rig.ended).toEqual([]) - // Never left Orca, so it is not recorded as unconfirmed. expect( await rig.adapter.dispatch({ sessionId: SESSION, @@ -237,7 +235,7 @@ describe('ACP connection loss', () => { reason: 'Grok stopped before this message was sent.', rejection: { kind: 'providerExited', neverReachedAgent: true } }) - // The next start's stop of the old child is one Orca asked for; it does not rename the crash. + // Cleanup retries must preserve the earlier crash's cause. await expect(rig.acquire()).rejects.toMatchObject({ name: 'AgentSessionAcquisitionExitUnprovenError' }) @@ -270,7 +268,6 @@ describe('ACP connection loss', () => { await sendHello(rig, 'garbled') const prompt = await rig.frame('session/prompt') rig.child().agent.notify('session/update', replyChunk('prompt:garbled', 'partial')) - // Usage without the totals the schema requires: the answer fails to parse. rig.child().agent.reply(prompt, { stopReason: 'end_turn', usage: { inputTokens: 10, outputTokens: 2 } @@ -322,7 +319,6 @@ describe('ACP refusals', () => { ]) ) expect(await journalTurns(rig)).toEqual([]) - // The session takes the next send. rig.child().agent.on('session/prompt', () => {}) await sendHello(rig, 'next') await rig.frame('session/prompt', 1) @@ -488,7 +484,6 @@ describe('ACP session release', () => { await rig.acquire() expect(rig.adapter.backgroundTaskStops(SESSION)).toBeDefined() await rig.adapter.closeSession(SESSION) - // Both answer from the entry the adapter holds for a chat; none is left. expect(rig.adapter.backgroundTaskStops(SESSION)).toBeUndefined() expect(rig.adapter.readCommands(SESSION)).toBeUndefined() }) diff --git a/src/main/acp/acp-structured-session-adapter-recordings.test.ts b/src/main/acp/acp-structured-session-adapter-recordings.test.ts index f0095f16c4f..c337acc8d1e 100644 --- a/src/main/acp/acp-structured-session-adapter-recordings.test.ts +++ b/src/main/acp/acp-structured-session-adapter-recordings.test.ts @@ -1,6 +1,3 @@ -// The adapter against Grok's recorded traffic: each recording plays back as the agent, and the -// assertions read the journal a client would see. - import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' @@ -110,7 +107,7 @@ describe('Grok recordings through the adapter', () => { await waitFor(async () => expect(await turns(rig)).toMatchObject([{ state: 'interrupted', outcome: 'cancellation' }]) ) - // The recording left the shell call `in_progress`; the turn's end cuts it short. + // The recording leaves this call running; turn end must interrupt it. const tool = (await rowsOf(rig, 'tool-call')).at(-1) expect(tool?.body).toMatchObject({ kind: 'tool-call', state: 'failed', endedAs: 'interrupted' }) await send(rig, 'send-2') diff --git a/src/main/acp/acp-structured-session-adapter-resume.test.ts b/src/main/acp/acp-structured-session-adapter-resume.test.ts index bfbf909aef0..b35d3b5d7f2 100644 --- a/src/main/acp/acp-structured-session-adapter-resume.test.ts +++ b/src/main/acp/acp-structured-session-adapter-resume.test.ts @@ -31,7 +31,6 @@ const hello: AgentJournalMessageItem = { blocks: [{ type: 'text', text: 'hello' }] } -/** Grok's own capabilities: it loads and resumes sessions; Orca reopens with `session/load`. */ const RESUMES = { agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } } } const resume = { launch: { @@ -44,8 +43,7 @@ const resume = { } } -/** Everything Grok may send while it reattaches: its saved exchange marked as replay, a task the - * dead process left running ended by the restart, and its context usage. */ +/** Replay history, end the prior process's stale task, and report context usage. */ function sendsWhileAttaching(agent: AcpScriptedAgent, reply: string): void { agent.notify('session/update', { sessionId: PROVIDER_SESSION, @@ -66,7 +64,6 @@ function sendsWhileAttaching(agent: AcpScriptedAgent, reply: string): void { }) } -/** Orca's send of `hello` as `m1` and Grok's reply, ended or cut off. */ async function exchange(rig: AcpAdapterRig, reply: string, end: boolean): Promise { await rig.rig.eventSink.appendItem({ provider: 'orca', clientMessageId: 'm1' }, hello, { turnScope: { kind: 'thread' } @@ -90,7 +87,6 @@ const notRestoredRows = async (rig: AcpAdapterRig) => readAgentSessionFailureFact(row.body.failure)?.kind === 'sessionNotRestored' ) -/** What Grok might send while it resumes with no replay mark: an old reply and its turn's end. */ function sendsUnmarkedWhileAttaching(agent: AcpScriptedAgent): void { agent.notify('session/update', replyChunk('prompt:old', 'stale text')) agent.notify('x.ai/session_notification', { @@ -122,7 +118,6 @@ describe('the reattach window', () => { ...resumesUnmarked, script: (agent: AcpScriptedAgent) => agent.on('session/load', (frame) => { - // An old reply with no end and no replay mark. agent.notify('session/update', replyChunk('prompt:old', 'stale text')) agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS }) }) @@ -149,7 +144,6 @@ describe('the context meter across a reopen', () => { ...resume, initialize: RESUMES, script: (agent) => - // A load that replays no usage; its answer carries only the models. agent.on('session/load', (frame) => agent.reply(frame, { configOptions: GROK_CONFIG_OPTIONS, models }) ) @@ -234,7 +228,6 @@ describe('reattaching a Grok chat the journal holds', () => { await rig.settle() expect(rig.sent('session/load')).toHaveLength(1) expect(rig.sent('session/resume')).toHaveLength(0) - // No second user bubble, no stale task row, and no turn opened by the load's own traffic. expect((await rig.rig.rows()).map((row) => row.itemId)).toEqual(before) expect(await texts(rig)).toEqual(['hello', 'hi']) await expect(rig.adapter.cancelTurn({ sessionId: SESSION, fence: 2 })).resolves.toEqual({ @@ -298,7 +291,7 @@ describe('reattaching a Grok chat the journal holds', () => { await exchange(rig, 'hi', true) await rig.adapter.closeSession(SESSION) const before = await rig.rig.rows() - // The host's order: a fresh sink per start, bound to the journal once the start succeeded. + // Bind a fresh acquisition sink only after startup succeeds. const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging(SESSION)) try { const acquired = await rig.adapter.acquire({ @@ -351,10 +344,8 @@ describe('reattaching a Grok chat the journal holds', () => { handle: { nativeId: PROVIDER_SESSION }, supersedesKey: 'acp:grok:never-saved' }) - // The failed load left the translator taking prompts. await exchange(rig, 'hi', true) expect(await texts(rig)).toEqual(['hello', 'hi']) - // Nothing was forgotten: the agent never saved that session. expect(await notRestoredRows(rig)).toEqual([]) }) @@ -437,7 +428,6 @@ describe('reattaching a Grok chat the journal holds', () => { expect(rig.ended).toMatchObject([{ cause: 'requested-close' }]) expect(rig.sent('session/new')).toEqual([]) expect(await notRestoredRows(rig)).toEqual([]) - // Nothing was replaced, so nothing says it was. expect(logger.warn).not.toHaveBeenCalledWith(ACP_REOPEN_FAILED, expect.anything()) }) diff --git a/src/main/acp/acp-structured-session-adapter-teardown.test.ts b/src/main/acp/acp-structured-session-adapter-teardown.test.ts index 2e11dc2775f..a453e5c2736 100644 --- a/src/main/acp/acp-structured-session-adapter-teardown.test.ts +++ b/src/main/acp/acp-structured-session-adapter-teardown.test.ts @@ -1,6 +1,3 @@ -// A close, dispose or quit while a turn runs: the agent is asked to cancel it and given the Stop's -// grace to end it before its process goes, as a Stop does; an idle close goes at once. - import { afterEach, describe, expect, it, vi } from 'vitest' import { closeProviderTimelineRigs, @@ -22,7 +19,6 @@ afterEach(async () => { await closeProviderTimelineRigs() }) -/** What Orca had written, and whether the turn had ended, when it first asked the child to close. */ function watchClose(child: FakeAcpChild, turn: { ended: boolean }) { const atClose: { cancels: number | null; turnEnded: boolean | null } = { cancels: null, diff --git a/src/main/acp/acp-structured-session-adapter.test.ts b/src/main/acp/acp-structured-session-adapter.test.ts index cb5c2ead6e6..4ea1e92dcea 100644 --- a/src/main/acp/acp-structured-session-adapter.test.ts +++ b/src/main/acp/acp-structured-session-adapter.test.ts @@ -66,7 +66,7 @@ describe('ACP structured session adapter: acquire', () => { provider: 'grok', accountHome: { variable: 'GROK_HOME', path: '/home/work/.grok' } }) - // The launch its registration composes: the shared seal names the child, not this adapter. + // Use the registration's shared launch seal rather than an adapter-specific one. const rig = await openAcpAdapterRig({ deps: { resolveLaunch: createAcpStructuredLaunchResolver(GROK, { @@ -168,7 +168,7 @@ it('reads those last words when the agent ends its stdout before its exit is see agent.on('session/new', () => { const child = rig.child() child.stderr = 'grok: config.toml is invalid' - // On POSIX a dying process's stdout ends first; its exit is observed a moment later. + // Simulate stdout EOF preceding observed process exit. child.agent.stdout.end() }) }) @@ -227,7 +227,7 @@ describe('ACP structured session adapter: turns', () => { rig.child().agent.reply(await rig.frame('session/prompt', index), { stopReason: 'end_turn' }) await waitFor(() => expect(rig.adapter.holdsDispatch(SESSION)).toBe(false)) } - // Back on the opening model, its opening effort is restored; elsewhere the agent keeps its own. + // Restore opening effort only when returning to the opening model. expect(rig.sent('session/set_config_option').map((frame) => frame.params)).toMatchObject([ { configId: 'model', value: 'grok-4.6' }, { configId: 'model', value: 'grok-4.7' }, @@ -445,10 +445,9 @@ describe('ACP structured session adapter: requests a Stop cancels', () => { kind: 'approval', response: { kind: 'option', optionId: 'allow-once' }, fence: 1, - // The Stop lands while the person's answer is being saved. commit: () => rig.adapter.cancelTurn({ sessionId: SESSION, fence: 1 }).then(() => {}) }) - // The Stop withdraws only what no answer has claimed. + // Stop must not preempt an answer already claimed for commit. await expect(answering).resolves.toBeUndefined() expect(await permission).toMatchObject({ result: { outcome: { outcome: 'selected', optionId: 'allow-once' } } diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index c135829df7a..072dd32dece 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -1,7 +1,3 @@ -// A structured chat over the Agent Client Protocol: one adapter per registered ACP agent, which -// the router drives like the Claude and Codex lanes. Rewind and goals are absent, so the chat hides -// them; compaction is the agent's own `/compact` prompt, for an agent whose launch spec offers it. - import { randomUUID } from 'node:crypto' import { withProviderDiagnostic } from '../../shared/agent-session-failure' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' @@ -49,7 +45,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte this.copies = new AcpConversationCopies(deps) } - /** Restart recovery's evidence; null for an agent whose own store Orca cannot read. */ providerHistoryWindow: NonNullable = ( input ) => readAcpRecoveryHistory(this.deps, input.identity) @@ -57,7 +52,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte copyConversation: NonNullable = (input) => this.copies.copy(input) - // The child runs on this runtime's own machine; Windows needs process start-time proof. supportsLocation = (location: AgentSessionExecutionLocation): boolean => { const { spec, isWindowsProcessStartTimeAvailable } = this.deps return supportsAcpStructuredLocation(spec, location, isWindowsProcessStartTimeAvailable) @@ -116,14 +110,13 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } catch (error) { const { connection } = attempt if (connection && error instanceof AcpConnectionClosedError) { - // A protocol that broke before the exit was seen: wait (bounded) for that exit, so the - // failure carries the agent's last words, as a running session's end does. + // Wait briefly for process exit so startup failures include final diagnostics. await waitForAcpExit(connection, this.stopGraceMs(), attempt.signal) } - // Checked before the close below, which would make any exit look like one Orca asked for. + // Capture spontaneous exit before close can classify it as requested. const exitedOnItsOwn = connection?.exited === true && !attempt.signal.aborted if (connection && !(await connection.close().catch(() => false))) { - // Kept, so the next start or quit closes this same process again; no second one spawns. + // Retain failed cleanup for retry before another child can spawn. this.children.starts.retainFailed(sessionId, connection) throw new AgentSessionAcquisitionExitUnprovenError(error) } @@ -135,7 +128,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte throw connection ? closed : new AgentSessionPreSpawnError(closed) } if (connection && exitedOnItsOwn && !isAgentSessionPreSpawnError(error)) { - // The agent's own last words are what a person can act on. throw new AgentSessionAcquisitionExitProvenError( withObservedProviderExit( new Error(connection.stderrTail() || String(error), { cause: error }) @@ -176,7 +168,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte ): Promise { const lost = this.children.sessions.get(input.sessionId) if (lost && lost.journalClosed !== null) { - // The connection broke and the exit is not yet proven: the message never left Orca. + // Protocol loss prevents dispatch even before exit is proven. return rejectedAcpDispatch(lost) } const session = this.live(input.sessionId) @@ -185,7 +177,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte return { state: 'rejected', ...prompt } } if (this.children.sessions.get(input.sessionId) !== session || session.journalClosed !== null) { - // The child ended while its attachments were read: nothing left Orca. + // Attachment reads may outlive the child; this send never reached it. return rejectedAcpDispatch(session) } const { sessionId, fence, signal } = input @@ -210,7 +202,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte prompt, requestedAt: input.requestedAt ?? this.now() }) - // The write is the admission; the agent's first event for the turn settles it. + // Admission precedes provider confirmation of the send. return { state: 'admitted' } } @@ -219,9 +211,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = async (input) => { const session = this.live(input.sessionId) - // The Stop ends the child unless it is declined here. The host forwards a named Stop only for - // the live turn or one with no journal row yet; for the latter, in the gap before a follow-up's - // turn opens, it stops what is in flight. A named turn other than the live one stops nothing. + // Refuse a stale named turn; a handover with no journal turn can stop the in-flight prompt. const liveTurnId = input.resolveLiveTurnId?.() ?? session.lane.openTurnId if (input.turnId !== undefined && liveTurnId !== null && input.turnId !== liveTurnId) { return { cancelled: false, refusal: { turnNotRunning: true } } @@ -229,14 +219,12 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte if (!session.turns.running && session.lane.openTurnId === null && !session.turns.holdsSteers) { return { cancelled: false, refusal: { turnNotRunning: true } } } - // The agent may end its turn its own way; the host ends the process once that lands or the - // grace runs out (`awaitStoppedRequestEnd`), never waiting on the cancel's write. + // The host waits for turn completion or grace expiry, never for the cancel write. interruptAcpTurn(session) return { cancelled: true } } - // Stop is a session boundary: a cancel ends only the running turn, and background work could - // begin another turn. The next send reloads the session. + // End the child so background work cannot reopen a turn after Stop; the next send reloads. stopEndsSession = (): boolean => true awaitStoppedRequestEnd = async (sessionId: string, stoppedAt: number): Promise => { @@ -260,7 +248,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte ) } session.options.notePick(input.key) - // Bounded, and abandoned by a close or Stop: the session's queue waits on it. + // Bound option writes so they cannot hold the session queue indefinitely. await writeAcpSessionOption(session.connection, session.options, write, { key: input.key, value: input.value, @@ -296,7 +284,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte } } - // Undefined until the session opens: the host holds what it handed a starting child. + // Until startup opens the session, the host retains responsibility for held sends. heldSends = (sessionId: string): ReadonlySet | undefined => this.children.sessions.get(sessionId)?.turns?.heldSends @@ -314,7 +302,6 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte hasOwnedAcquisition = (sessionId: string): boolean => this.children.sessions.has(sessionId) || this.children.starts.failedSessionIds().includes(sessionId) - /** After a sink failure: the exit is recovered as unexpected. */ forceCloseSession = (sessionId: string): Promise => this.children.stop(sessionId, false) async closeAll(): Promise { @@ -323,7 +310,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte ...this.children.sessions.keys(), ...this.children.starts.failedSessionIds() ]) - // A start still under way is the host's to abort: its teardown does, before this runs. + // The host aborts in-progress starts before this teardown. const proven = await Promise.all( [...ids].map((sessionId) => this.children.stop(sessionId, true)) ) diff --git a/src/main/acp/acp-structured-session.ts b/src/main/acp/acp-structured-session.ts index 98e326d8b3f..9bece67dfb1 100644 --- a/src/main/acp/acp-structured-session.ts +++ b/src/main/acp/acp-structured-session.ts @@ -1,6 +1,3 @@ -// One live ACP agent connection and what Orca keeps beside it, and how its traffic and its -// process's exit reach the journal and the host. - import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' import type { @@ -30,17 +27,15 @@ export type AcpStructuredSession = { acquisitionGeneration: string spec: AcpLaunchSpec childStops?: AcpChildStops - /** The agent's process and its protocol, one owner. */ connection: AcpStructuredConnection lane: AcpStructuredLane prompts: AcpStructuredPrompts options: AcpStructuredOptions turns: AcpStructuredTurns - /** Orca asked this child to stop; its exit is then a requested close. */ closeRequested: boolean - /** Why nothing this child says reaches the journal any more; null while it does. */ + /** Null while provider events can still reach the journal. */ journalClosed: string | null - /** Its exit is proven and the host was told. */ + /** Exit was proven and reported to the host. */ ended: boolean exitObservedAt: number | null unbindReadingControl?: () => void @@ -48,8 +43,7 @@ export type AcpStructuredSession = { open: (signal: AbortSignal) => Promise } -/** Every agent frame goes through here once the lane exists: options and commands first, so a - * read right after the frame sees them, then the journal. `reattaching`: see `asReattachHistory`. */ +/** Update options before journaling so synchronous reads see the latest frame. */ export function routeAcpSessionEvent( session: Pick, event: AcpSessionEvent, @@ -76,8 +70,7 @@ export function routeAcpSessionEvent( ) } -/** While a chat reattaches, everything the agent sends is replayed history, whatever the agent - * marked: marked as replay, the translator drops it and keeps only its usage. */ +/** Treat all reattach traffic as history, even without provider replay marks. */ export function asReattachHistory(params: unknown): unknown { if (typeof params !== 'object' || params === null || Array.isArray(params)) { return params @@ -87,12 +80,7 @@ export function asReattachHistory(params: unknown): unknown { return { ...params, _meta: { ...marked, isReplay: true } } } -/** - * Nothing more this child says reaches the journal, once: on its exit, or when its connection broke - * while it may still run. Open requests die, held sends never left Orca, the running send's fate is - * unknown, and the running turn ends as `verdict` says: `unverifiable` while the child may still - * run (the host's settlement of its proven exit revises it), interrupted at a proven exit. - */ +/** Close journaling once; unproven exits remain unverifiable until the host settles them. */ export function closeAcpSessionJournal( session: AcpStructuredSession, reason: string, @@ -113,8 +101,6 @@ export function closeAcpSessionJournal( session.unbindReadingControl?.() } -/** The child's proven exit, once: the journal closes if it has not, and the host hears `ended` so - * it releases the session. */ export function endAcpStructuredSession( session: AcpStructuredSession, observedAt: number, @@ -127,15 +113,13 @@ export function endAcpStructuredSession( session.events?.observeExit?.(undefined, observedAt) session.exitObservedAt = observedAt const stderr = session.connection.stderrTail() - // Read at the proven exit: a crash usually ends the agent's stdout before its exit is observed, - // so the connection's close comes first, but the agent's own last words are what explain it. + // Process exit can follow stream loss; final stderr explains the crash. const reason = session.closeRequested ? `${session.spec.agent} ACP agent closed by Orca` : session.journalClosed !== null && !stderr ? session.journalClosed : `${session.spec.agent} ACP agent exited${stderr ? `: ${stderr}` : ''}` - // A death of its own ended the running turn at the exit, as the host's exit settlement reads it - // (`exitedRootTurnScope`). A close Orca asked for leaves it `unverifiable`; that settlement revises it. + // Spontaneous exit interrupts now; requested close leaves the host to revise an unverifiable turn. closeAcpSessionJournal( session, reason, diff --git a/src/main/acp/acp-structured-sign-in.test.ts b/src/main/acp/acp-structured-sign-in.test.ts index e8425302577..e03b51e106c 100644 --- a/src/main/acp/acp-structured-sign-in.test.ts +++ b/src/main/acp/acp-structured-sign-in.test.ts @@ -81,7 +81,7 @@ it('ends a running turn signed out when the connection closes for sign-in', asyn await rig.acquire() await sendHello(rig, 'first') await rig.frame('session/prompt') - // The protocol close the real connection makes at a sign-in prompt line on stderr. + // Simulate the real connection closing on a stderr sign-in link. const internal = 'The agent asked to sign in in a browser' AcpSessionRuntime.prototype.close.call(rig.child(), new AcpSignInPromptError(internal)) await rig.settle() @@ -92,21 +92,20 @@ it('ends a running turn signed out when the connection closes for sign-in', asyn rejection: expect.objectContaining({ kind: 'notSignedIn' }) }) ) - // Orca's own words for the prompt are for logs; the chat says only how to sign in. + // Internal prompt diagnostics must not appear as agent-authored guidance. const rejected = rig.settled.find((item) => 'state' in item && item.state === 'rejected') expect(rejected && 'reason' in rejected ? rejected.reason : '').not.toContain(internal) }) it('starts signed out without Orca words when the connection closes for sign-in', async () => { const rig = await openAcpAdapterRig({ script: (agent) => agent.on('session/new', () => {}) }) - // Session setup runs in the start's open, which the rig awaits after the acquire. const starting = rig.acquire() await rig.frame('session/new') const internal = 'The agent asked to sign in in a browser' AcpSessionRuntime.prototype.close.call(rig.child(), new AcpSignInPromptError(internal)) await starting const error = rig.openFailure() - // Session setup re-types sign-in answers; this close must stay Orca's, words and all. + // Preserve Orca's prompt error when session setup retypes authentication failures. expect(error).toMatchObject({ reason: 'notSignedIn' }) expect(providerDiagnosticOf(error)).toBeUndefined() }) @@ -119,7 +118,6 @@ it.each(ACP_LAUNCH_SPECS)( spec, script: (agent) => agent.on('session/new', (frame) => agent.fail(frame, -32000, detail)) }) - // The host receives the refusal from open and then closes the child. await rig.acquire() expect(rig.openFailure()).toMatchObject({ reason: 'notSignedIn' }) expect(providerDiagnosticOf(rig.openFailure())).toEqual({ text: detail, audience: 'person' }) diff --git a/src/main/acp/acp-structured-steer.test.ts b/src/main/acp/acp-structured-steer.test.ts index d9908933a8c..46d875bb001 100644 --- a/src/main/acp/acp-structured-steer.test.ts +++ b/src/main/acp/acp-structured-steer.test.ts @@ -1,7 +1,3 @@ -// A message sent while a Grok prompt runs steers: the running prompt is cancelled (the session -// stays) and the message goes as the next prompt. The adapter holds a steer only while that cancel -// lands; the host's queue holds everything else. - import { afterEach, describe, expect, it, vi } from 'vitest' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' @@ -35,7 +31,6 @@ afterEach(async () => { await closeProviderTimelineRigs() }) -/** A turn Grok began itself, as a background task's completion wakes it. */ const GROK_TURN = 'task-completed-background-1' function endsGrokTurn(agent: AcpScriptedAgent): void { @@ -45,7 +40,6 @@ function endsGrokTurn(agent: AcpScriptedAgent): void { }) } -/** Grok answers each cancel by ending the prompt it is running, as `cancelled`. */ function answersCancels(agent: AcpScriptedAgent): void { agent.on('session/cancel', () => { const answered = new Set(agent.frames.flatMap((frame) => (frame.method ? [] : [frame.id]))) @@ -172,7 +166,6 @@ describe('a send while a Grok prompt runs', () => { expect(rig.ended).toEqual([]) expect(rig.child().closes).toBe(0) expect(rig.settled.map((settled) => settled.clientMessageId)).toEqual(['first']) - // A Stop sends its own cancel; the host's grace, not the cancel, ends Grok. await rig.adapter.cancelTurn({ sessionId: ADAPTER_SESSION, fence: 1 }) await rig.settle() expect(rig.sent('session/cancel')).toHaveLength(2) @@ -255,7 +248,6 @@ describe('a send while a turn Grok began itself runs', () => { }) describe('Steer on a Grok card, through the host', () => { - /** A card queued behind whatever turn runs. */ async function queueCard(rig: Awaited>) { const { host } = rig await host.flushStreamedEvents(SESSION) @@ -288,7 +280,6 @@ describe('Steer on a Grok card, through the host', () => { return { cardId, sendNow, page, sends } } - /** A Grok turn running, and a card queued behind it. */ async function cardBehindARunningTurn() { const rig = await openAttachedHostRig({ stopGraceMs: 20 }) await send(rig.host, 'hello') @@ -327,7 +318,6 @@ describe('Steer on a Grok card, through the host', () => { agent.notify('session/update', replyChunk(promptIdOf(prompt), 'working')) await rig.rig.settle() await rig.host.flushStreamedEvents(SESSION) - // No queued-message delivery: the host hands it over while the turn runs. await send(rig.host, 'steer') await waitFor(() => expect(framesOf(rig.rig.child(), 'session/cancel')).toHaveLength(1)) agent.reply(prompt, { stopReason: 'cancelled' }) diff --git a/src/main/acp/acp-structured-stop.ts b/src/main/acp/acp-structured-stop.ts index 85e48625b92..0984d40b676 100644 --- a/src/main/acp/acp-structured-stop.ts +++ b/src/main/acp/acp-structured-stop.ts @@ -1,17 +1,12 @@ -// How a running ACP turn ends before its child does, for a Stop and a requested close alike: the -// cancel goes out once, and the agent gets a bounded grace, counted from it, to end its turn. - import type { AcpStructuredSession } from './acp-structured-session' type AcpStoppableSession = Pick -/** A turn a cancel can end: Orca's prompt, or one the agent opened itself. */ function acpTurnInFlight(session: AcpStoppableSession): boolean { return session.turns.running || session.lane.openTurnId !== null } -/** As a Stop: held steers and the agent's open requests are withdrawn, then the cancel is written, - * never awaited; a failed write leaves the grace to run out. */ +/** Do not await the cancel write; grace expiry must still close an unresponsive child. */ export function interruptAcpTurn(session: AcpStoppableSession): void { session.turns.stop(Date.now()) if (!acpTurnInFlight(session)) { @@ -21,7 +16,7 @@ export function interruptAcpTurn(session: AcpStoppableSession): void { void session.connection.cancel().catch(() => undefined) } -/** Resolves once the running turn ended, or `graceMs` after `stoppedAt` (`Date.now()` time). */ +/** Wait for turn completion or grace expiry using the Date.now clock. */ export async function awaitAcpTurnEnd( session: AcpStoppableSession, stoppedAt: number, @@ -45,8 +40,7 @@ export async function awaitAcpTurnEnd( } } -/** Before a requested close: a running turn is cancelled, unless a Stop already did, and waited on - * within the grace that cancel started. */ +/** Reuse an earlier Stop's grace deadline rather than starting a new one. */ export async function windDownAcpTurn( session: AcpStoppableSession, graceMs: number diff --git a/src/main/acp/acp-structured-turns.ts b/src/main/acp/acp-structured-turns.ts index dc39195c326..6a5798c6bcb 100644 --- a/src/main/acp/acp-structured-turns.ts +++ b/src/main/acp/acp-structured-turns.ts @@ -1,12 +1,4 @@ -// The sends of one ACP session. ACP runs one prompt at a time, so a message sent while Orca's prompt -// runs steers: that prompt is cancelled (the session stays) and the message goes as the next prompt -// once the agent answers the cancel, however long that takes. Each running prompt gets one cancel, -// however many steers arrive; steers wait here, the last one runs, and a Stop withdraws what waits. -// A turn the agent began itself is not Orca's to cut short: a message sent during it goes to the -// agent at once. Each send is settled exactly once: -// accepted when the agent's first event for its turn (or its answer) arrives, rejected when the -// agent refused it or it never left Orca, unknown when the agent died with it or its connection -// broke before it answered. +// Steers wait for the active prompt to answer cancellation; agent-initiated turns accept sends directly. import { agentSessionFailureFact, @@ -38,7 +30,6 @@ type Send = { clientMessageId: string; prompt: ContentBlock[]; requestedAt: numb export type AcpStructuredTurnsDeps = { connection: Pick - /** Answers the agent's open requests cancelled, as a steer's cancel ends what they belong to. */ withdrawRequests: () => void lane: AcpStructuredLane agentName: string @@ -48,14 +39,12 @@ export type AcpStructuredTurnsDeps = { export class AcpStructuredTurns { private active: Send | null = null - /** The running send a steer already cancelled: one cancel per prompt, retried if its write failed. */ + // One cancel per prompt; failed writes allow a later steer to retry. private steerCancelled: Send | null = null - /** Steers waiting for the prompt ahead of them to answer its cancel. */ private readonly steers: Send[] = [] private readonly unsettled = new Set() private readonly idleWaiters = new Set<() => void>() private ended = false - /** When a Stop or a requested close reached this session; it ends with the process. */ private stopAt: number | null = null constructor(private readonly deps: AcpStructuredTurnsDeps) {} @@ -64,8 +53,7 @@ export class AcpStructuredTurns { return this.active !== null } - /** Whether an agent request may reach the person: Orca's prompt runs and nobody is cutting it - * short (no steer's cancel, no Stop). */ + /** Interactive requests require an active Orca prompt with no Stop or steer cancellation. */ get acceptsRequests(): boolean { return ( this.stopAt === null && @@ -75,33 +63,28 @@ export class AcpStructuredTurns { ) } - /** The sends this agent holds: each from its dispatch until its settlement. */ get heldSends(): ReadonlySet { return this.unsettled } - /** Steers wait behind the running prompt's cancel. */ get holdsSteers(): boolean { return this.steers.length > 0 } - /** Whether a Stop reached this session. */ get stopped(): boolean { return this.stopAt !== null } - /** When the first Stop reached this session (`Date.now()` time); null before one did. */ + /** First Stop time on the Date.now clock. */ get stoppedAt(): number | null { return this.stopAt } - /** A Stop: held steers never reach the agent, and nothing it asks from now on is shown. */ stop(at: number): boolean { this.stopAt ??= at return this.withdrawSteers() } - /** Resolves once no prompt of Orca's is running. */ whenIdle(): Promise { if (!this.active) { return Promise.resolve() @@ -119,9 +102,7 @@ export class AcpStructuredTurns { this.start(send) } - /** A `/compact` in the command turn the host opened. The host settles its message, so nothing - * here does; the answer ends the turn, or the child's end does. The prompt's write is the - * receipt. */ + /** The host owns compaction-message settlement; reuse its open command turn. */ async compact(command: StructuredAgentSessionCommandRun): Promise { if (this.active || this.ended) { throw new Error(`${this.deps.agentName} is still working`) @@ -148,7 +129,6 @@ export class AcpStructuredTurns { return { state: 'accepted', providerIdentity: null } } - /** The agent took the send: its turn's first event, or its answer, arrived. */ accept(clientMessageId: string): void { if (this.unsettled.delete(clientMessageId)) { this.deps.settle({ @@ -169,7 +149,7 @@ export class AcpStructuredTurns { return withdrawn.length > 0 } - /** The child is gone: held sends never left Orca, and the running one's fate is unknown. */ + /** Teardown rejects held steers; an unconfirmed active send remains unknown. */ end(reason: string, cause?: Error): void { this.ended = true for (const send of this.steers.splice(0)) { @@ -177,7 +157,6 @@ export class AcpStructuredTurns { } const active = this.active if (active && cause instanceof AcpAuthRequiredError) { - // The connection ended to ask for sign-in: the turn fails as the agent's sign-in answer does. this.agentFailed(active, cause) } else if (active && this.unsettled.delete(active.clientMessageId)) { this.deps.settle({ clientMessageId: active.clientMessageId, state: 'unknown', reason }) @@ -197,8 +176,7 @@ export class AcpStructuredTurns { private run(send: Send, promptId: string): void { const { lane } = this.deps - // An agent whose dialect echoes this id on every event of the turn gets it, so its rows join the - // turn Orca opened; no other agent is sent the extension. + // Inject identity only for dialects that echo it on turn events. const meta = lane.translator.injectsPromptIdentity ? { promptId, requestId: promptId } : undefined @@ -217,11 +195,11 @@ export class AcpStructuredTurns { }, (error: unknown) => { if (this.active !== send || error instanceof AcpConnectionClosedError) { - // A lost connection: the session's end settles the send, as unknown. + // Connection teardown owns unknown-send settlement. return } if (!(error instanceof AcpAgentError)) { - // Answered, but not in a way Orca can read: the turn failed and the next send may go. + // Unreadable replies fail the turn but allow the next send. lane.apply( lane.translator.promptFailed( send.clientMessageId, @@ -242,7 +220,6 @@ export class AcpStructuredTurns { const { lane } = this.deps const refusal = lane.translator.promptRefused(send.clientMessageId, error) if (refusal !== null) { - // The agent answered the prompt with an error before starting it: its own refusal. const detail = providerDiagnostic(refusal, 'person') this.reject( send.clientMessageId, @@ -272,8 +249,7 @@ export class AcpStructuredTurns { } } - /** Asks once per running prompt to end it; never bounded, so a slow answer only delays the steer, - * and a Stop still ends the session. */ + /** Steering waits indefinitely for cancellation; Stop can still end the child. */ private cancelForSteer(): void { const send = this.active if (!send) { @@ -285,7 +261,7 @@ export class AcpStructuredTurns { } this.steerCancelled = send this.deps.connection.cancel().catch(() => { - // Never written: the next steer may ask again. + // A failed write lets the next steer retry. if (this.steerCancelled === send) { this.steerCancelled = null } diff --git a/src/main/acp/acp-subagents.ts b/src/main/acp/acp-subagents.ts index 487fd38c7c9..8eae33370ea 100644 --- a/src/main/acp/acp-subagents.ts +++ b/src/main/acp/acp-subagents.ts @@ -1,9 +1,4 @@ -// An ACP agent's subagents, read as evidence for the host's child records, plus each completed -// subagent's reply as its own row. -// -// The host decides what every subagent is doing and writes its saved row; this only reads the -// dialect's updates. It lives as long as the provider child: a subagent an earlier run spawned is -// not known here. +// Track evidence for this provider child; the host owns durable subagent records. import { createHash } from 'node:crypto' import type { AgentChildWorkOutcome } from '../../shared/agent-status-child-work' @@ -20,11 +15,10 @@ import type { AcpSubagentUpdate } from './acp-dialects/acp-dialect' import type { AcpTimelineEvent } from './acp-timeline-event' type KnownSubagent = { - /** Where its reply row goes: the turn that spawned it. */ + /** Pin replies to the spawning turn. */ placement: ProviderTimelineJoin - /** The reply already written, so a repeated result writes nothing. */ reply?: string - /** How it first ended: a reply belongs only to a run that completed. */ + /** Preserve the first terminal state; only completed runs can publish replies. */ ended?: NativeChatSubagentState } @@ -38,7 +32,6 @@ const OUTCOMES: Record< unverifiable: 'unknown' } -/** How a subagent's run ended, by the state the dialect read; undefined while it runs. */ export function acpSubagentOutcome( state: NativeChatSubagentState ): AgentChildWorkOutcome | undefined { @@ -46,11 +39,10 @@ export function acpSubagentOutcome( } export class AcpSubagents { - /** Every subagent this provider child reported, so a later report is still known as one's. */ private readonly known = new Map() private readonly pending: AgentChildWorkEvidence[] = [] - /** Known children include settled ones, so they cannot become background tasks. */ + /** Include settled subagents so they cannot be reclassified as background tasks. */ has(id: string): boolean { return this.known.has(id) } @@ -60,7 +52,6 @@ export class AcpSubagents { this.pending.length = 0 } - /** What the updates translated since the last drain said, for the host's records. */ drainEvidence(): AgentChildWorkEvidence[] { return this.pending.splice(0) } @@ -85,8 +76,7 @@ export class AcpSubagents { } this.known.set(update.id, subagent) const ending = update.state !== undefined && acpSubagentOutcome(update.state) !== undefined - // A late report about an ended run is not a new run: the host drops the run's record at the - // next send, so a live edge then would bring it back as working. + // Late live reports must not resurrect an ended run after the host retires its record. if (ending || subagent.ended === undefined) { this.pending.push(subagentEvidence(update, at)) } @@ -117,7 +107,7 @@ export class AcpSubagents { } } -/** A subagent runs on past the prompt turn that spawned it, and the provider names no run. */ +// Subagents outlive their spawning prompt and have no provider run identity. function subagentEvidence(update: AcpSubagentUpdate, observedAt: number): AgentChildWorkEvidence { const handle = subagentHandle(update.id) const outcome = update.state === undefined ? undefined : acpSubagentOutcome(update.state) @@ -130,10 +120,9 @@ function subagentEvidence(update: AcpSubagentUpdate, observedAt: number): AgentC kind: 'agent', residency: 'background', state: update.state === 'idle' ? 'idle' : 'working', - // The strip and the saved row show the same label. ...(update.label !== undefined ? { description: boundSubagentLabel(update.label) } : {}), ...(update.tokens !== undefined ? { totalTokens: update.tokens } : {}), - // An id the handle had to digest cannot address the provider's cancellation. + // Hashed handles cannot address provider cancellation. stoppable: handle.id === update.id } } @@ -155,7 +144,7 @@ function subagentHandle(id: string): AgentChildWorkEvidenceHandle { return { idKind: 'task_id', id: bounded, - // Invocation fences are smaller than handles; keep the full stable handle. + // Hash the smaller invocation fence while retaining the full handle. ...(bounded.length > AGENT_CHILD_WORK_ID_MAX_LENGTH ? { runId: `acp-child:${createHash('sha256').update(bounded).digest('hex')}` } : {}) diff --git a/src/main/acp/acp-timeline-background-task-evidence.test.ts b/src/main/acp/acp-timeline-background-task-evidence.test.ts index d2e8bb4d587..5c77d4d2d94 100644 --- a/src/main/acp/acp-timeline-background-task-evidence.test.ts +++ b/src/main/acp/acp-timeline-background-task-evidence.test.ts @@ -145,7 +145,7 @@ describe('background task outcome evidence', () => { await fixture.rig.rows() if (boundary === 'eviction') { for (let index = 0; index < 200; index += 1) { - // Pressure only the translator's snapshots: settled ones go first, the running call stays. + // Pressure translator snapshots without evicting the running call. fixture.lane().notification( 'session/update', { @@ -217,7 +217,7 @@ describe('background task outcome evidence', () => { true ) } else { - // Unmarked: a task notice during a load is history too. + // Load-window notices are history even without a replay mark. taskNotice(fixture, taskId) } fixture.finishLoad() diff --git a/src/main/acp/acp-timeline-dialect.test.ts b/src/main/acp/acp-timeline-dialect.test.ts index 27ce8c83d8e..db7d632835d 100644 --- a/src/main/acp/acp-timeline-dialect.test.ts +++ b/src/main/acp/acp-timeline-dialect.test.ts @@ -134,7 +134,7 @@ describe('ACP dialect request and turn boundaries', () => { outcome: 'accepted', answers: { 'Choose a test?': ['Unit', 'Integration'], 'Another?': 'Yes' } }) - // A plan is shown, never put to the person as an approval, and never approved for them. + // Show the plan without opening approval or auto-approving it. const plan = translator.request( '_x.ai/exit_plan_mode', { sessionId: 'provider-1', toolCallId: 'plan-1', planContent: '# Plan' }, diff --git a/src/main/acp/acp-timeline-fixture.test-support.ts b/src/main/acp/acp-timeline-fixture.test-support.ts index 7b39131192b..9596781e247 100644 --- a/src/main/acp/acp-timeline-fixture.test-support.ts +++ b/src/main/acp/acp-timeline-fixture.test-support.ts @@ -69,14 +69,13 @@ function fixtureAnswer( } } -/** Captured frames go through translation, assembly and the real serialized journal. */ export async function openAcpFixtureRig(dialect: AcpDialect = GROK_ACP_DIALECT) { const rig = await openProviderTimelineRig() let providerSessionId = 'session-1' const translator = () => new AcpTimelineTranslator({ sessionId: providerSessionId, dialect }) let lane = translator() const records = acpSubagentRecords() - // What each subagent's saved row last said: its host record, kept past the record's retirement. + // Preserve the host's last saved view after record retirement. const latest = new Map() const report = (evidence: AgentChildWorkEvidence[]) => { for (const view of records(evidence)) { @@ -92,12 +91,12 @@ export async function openAcpFixtureRig(dialect: AcpDialect = GROK_ACP_DIALECT) for (const event of events) { expect(rig.assembler.apply(event).admission.accepted).toBe(true) if (event.type === 'turn.open') { - // As the host retires finished records when the user's next send is accepted. + // Retire finished host records on the next accepted turn. drain() report([{ type: 'turn-started', observedAt: event.at }]) } if (event.type === 'session.ended') { - // As the lane reports a session end once the journal admitted it. + // Publish session end only after journal admission. drain() report([{ type: 'session-ended', observedAt: 9_000 }]) } @@ -181,13 +180,11 @@ export async function openAcpFixtureRig(dialect: AcpDialect = GROK_ACP_DIALECT) apply, feed, lane: () => lane, - /** A new provider child: the sweep, a new generation's assembler and a new translator. */ restart: async () => { await rig.restart() drain() lane = translator() }, - /** Every subagent reported so far, as its host record last read. */ subagents: () => { drain() return [...latest.values()] diff --git a/src/main/acp/acp-timeline-generic.test.ts b/src/main/acp/acp-timeline-generic.test.ts index bcf8c5108dc..4cad369c1c5 100644 --- a/src/main/acp/acp-timeline-generic.test.ts +++ b/src/main/acp/acp-timeline-generic.test.ts @@ -95,7 +95,7 @@ describe('generic ACP translation', () => { { type: 'diff', path: 'file.ts', oldText, newText } ] }) - // A settled journal row refuses a late provider update that says it is running again. + // Settled journal rows reject late updates that try to reopen them. update({ sessionUpdate: 'tool_call_update', toolCallId: 'tool-1', status: 'in_progress' }) apply(translator.promptResult('send-1', { stopReason: 'end_turn' }, 1200)) const body = (await rig.rows()).find( @@ -104,7 +104,7 @@ describe('generic ACP translation', () => { expect(body).toMatchObject({ name: 'edit_file', state: 'completed', - // The input came only while the call ran; the settled row still keeps it whole. + // Preserve full input supplied only during the running state. input: { text: 'x'.repeat(40000) }, output: { head: 'y'.repeat(40000), truncated: false, byteLength: 40000 } }) diff --git a/src/main/acp/acp-timeline-grok-subagent-retention.test.ts b/src/main/acp/acp-timeline-grok-subagent-retention.test.ts index e6a9d442cac..68307320b17 100644 --- a/src/main/acp/acp-timeline-grok-subagent-retention.test.ts +++ b/src/main/acp/acp-timeline-grok-subagent-retention.test.ts @@ -70,7 +70,6 @@ function record(fixture: Fixture, id: string) { return fixture.subagents().filter((view) => view.providerId === id) } -/** The scope of the rows the latest turn writes. */ async function openTurnScope(fixture: Fixture) { const turn = (await fixture.rig.rows()).findLast((row) => readAgentJournalTurn(row.body)) return turn ? { kind: 'turn', turnItemId: turn.itemId } : undefined @@ -253,7 +252,7 @@ describe('a Grok subagent reported on long after the turn that spawned it', () = ) expect(events).toEqual([]) await fixture.rig.rows() - // Disposal says the session ended once more; nothing reports the child live again. + // Repeated session-end evidence must not revive the child. expect(evidence.map((edge) => edge.type).slice(0, 2)).toEqual(['live', 'session-ended']) expect(evidence.filter((edge) => edge.type === 'live')).toHaveLength(1) }) diff --git a/src/main/acp/acp-timeline-grok-subagents.test.ts b/src/main/acp/acp-timeline-grok-subagents.test.ts index 18129c702f9..d6c0e93007e 100644 --- a/src/main/acp/acp-timeline-grok-subagents.test.ts +++ b/src/main/acp/acp-timeline-grok-subagents.test.ts @@ -1,7 +1,3 @@ -// Grok's recorded subagents (Grok 1.0.46, s7 fixtures) through translation, assembly and the -// journal: each subagent is one host record, which the host saves as its row, and its reply is its -// own row. - import { afterEach, describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { isBackgroundTaskBlock } from '../../shared/native-chat-types' @@ -40,7 +36,6 @@ function notification(update: Record) { return { sessionId: 'session-1', update } } -/** The background recording up to Grok's spawn notice: the subagent is running. */ async function untilSpawned() { const frames = await readAcpFixture('s7-subagent-background') const spawned = frames.findIndex((frame) => @@ -65,7 +60,7 @@ describe('Grok subagents through the shared timeline', () => { expect(replies(rows)).toEqual([ { agentId: 'subagent-1', role: 'assistant', blocks: [{ type: 'text', text: '4' }] } ]) - // The spawn call keeps its own row; the child's own traffic is not the conversation's. + // Child traffic must not become parent-conversation messages. expect( rows.flatMap((row) => row.body.kind === 'tool-call' ? [[row.body.name, row.body.state]] : [] diff --git a/src/main/acp/acp-timeline-joins.test.ts b/src/main/acp/acp-timeline-joins.test.ts index 722ccfb1a8f..c02750c4a41 100644 --- a/src/main/acp/acp-timeline-joins.test.ts +++ b/src/main/acp/acp-timeline-joins.test.ts @@ -72,7 +72,6 @@ describe('ACP joins through the assembler', () => { providerItemId('request', request, { generation: GENERATION }), providerItemId('request', request, { generation: 'gen-2' }) ]) - // The sweep cancelled the dead child's pending request; the new child's waits. expect( approvals.map((row) => row.body.kind === 'approval' && row.body.resolution.state) ).toEqual(['cancelled', 'pending']) diff --git a/src/main/acp/acp-timeline-open-enums.test.ts b/src/main/acp/acp-timeline-open-enums.test.ts index 08907152afa..fe9dadabb02 100644 --- a/src/main/acp/acp-timeline-open-enums.test.ts +++ b/src/main/acp/acp-timeline-open-enums.test.ts @@ -21,7 +21,6 @@ async function genericRig() { return { rig, translator, apply, update } } -// The protocol's enums are open: a value newer than this build reads as known and falls back. describe('ACP values newer than this build', () => { it('keeps a tool with a newer kind or status as a tool row in its last known state', async () => { const { rig, translator, apply, update } = await genericRig() diff --git a/src/main/acp/acp-timeline-opencode-dialect.test.ts b/src/main/acp/acp-timeline-opencode-dialect.test.ts index 45f234c8c42..23e1603d8f1 100644 --- a/src/main/acp/acp-timeline-opencode-dialect.test.ts +++ b/src/main/acp/acp-timeline-opencode-dialect.test.ts @@ -26,8 +26,7 @@ async function openCodeRig() { return { rig, translator, apply, update } } -// The shapes OpenCode's ACP server writes for a shell tool: its output and exit code ride under -// `rawOutput.metadata`, beside the text content. +// OpenCode puts shell output and exit status in rawOutput.metadata. const bashStart = { sessionUpdate: 'tool_call', toolCallId: 'call-1', diff --git a/src/main/acp/acp-timeline-recordings.test.ts b/src/main/acp/acp-timeline-recordings.test.ts index 4297da6fc80..9531ab7a072 100644 --- a/src/main/acp/acp-timeline-recordings.test.ts +++ b/src/main/acp/acp-timeline-recordings.test.ts @@ -107,7 +107,6 @@ describe('recorded ACP traffic through the journal', () => { expect(await fixture.rig.turns()).toHaveLength(2) }) - // OpenCode 2.0.29: three turns, then a restart whose `session/load` replays them before a follow-up. it("keeps an OpenCode 2 chat's history once across a reopen, and adds the follow-up turn", async () => { const fixture = await openAcpFixtureRig(OPENCODE_ACP_DIALECT) const frames = await readAcpFixture('opencode-v2-reopen') diff --git a/src/main/acp/acp-timeline-requests.ts b/src/main/acp/acp-timeline-requests.ts index 19a66bc6ed3..4c5570485d9 100644 --- a/src/main/acp/acp-timeline-requests.ts +++ b/src/main/acp/acp-timeline-requests.ts @@ -12,12 +12,11 @@ export const pendingAcpResolution = { resolvedAt: null } as const -/** `description`: the agent's own words about what answering means, shown above the choices. */ export function acpPermissionPresentation( params: unknown, description?: string ): AcpRequestPresentation { - // The runtime already read this request and reported any field it dropped. + // Runtime parsing already reported dropped fields. const request = readAcpPermissionRequest(params, () => {}) if (!request) { throw new AcpRpcError(-32602, 'Invalid ACP permission request') @@ -69,7 +68,6 @@ export function translateAcpRequest( ): { events: ProviderTimelineEvent[] presentation?: AcpRequestPresentation - /** Answered at once: nobody is asked. */ settled?: { reply: unknown } } { const session = requestSessionSchema.safeParse(params) @@ -109,8 +107,7 @@ export function translateAcpRequest( options.dialect.request?.(method, params) ?? (method === 'session/request_permission' ? acpPermissionPresentation(params) : undefined) if (!presentation) { - // A file system or terminal call Orca never advertised is the agent's own misstep (OpenCode 1.x - // repeats each approved edit as a file write it already made): refused, with no row. + // OpenCode 1.x repeats completed writes through unadvertised client methods; omit their rows. if (UNADVERTISED_CLIENT_METHOD.test(method)) { return { events: [] } } diff --git a/src/main/acp/acp-timeline-translator.ts b/src/main/acp/acp-timeline-translator.ts index b9bca633890..7f8c65c2f69 100644 --- a/src/main/acp/acp-timeline-translator.ts +++ b/src/main/acp/acp-timeline-translator.ts @@ -31,19 +31,17 @@ const requestSessionSchema = z.object({ sessionId: z.string() }) export type AcpTimelineTranslatorOptions = { sessionId: string dialect?: AcpDialect - /** The agent's display name, for a failed turn the provider gave no words for. */ agentName?: string onTextDropped?: (drop: AcpTextDrop) => void } -/** Consumes each frame once; the host retries the returned grammar events. Lives exactly as long - * as its provider child, so it never reads the journal. */ +/** Consume frames once; callers retry returned events. State lasts only for this provider child. */ export class AcpTimelineTranslator { private readonly dialect: AcpDialect private readonly prompts: AcpPromptTurns private readonly tools = new AcpToolTimeline() private readonly backgroundTasks: AcpBackgroundTaskTimeline - /** Its evidence is drained by the lane, in the journal's order. */ + /** Drained by the lane in journal order. */ readonly subagents = new AcpSubagents() private readonly messages = new AcpTurnMessages() private readonly started = new BoundedMap({ maxEntries: 128 }) @@ -63,13 +61,11 @@ export class AcpTimelineTranslator { this.subagents.dispose() } - /** Whether the agent's dialect echoes an injected prompt identity on the turn's events. */ get injectsPromptIdentity(): boolean { return this.dialect.injectedPromptIdentity === true } - /** The host injects promptId as session/prompt._meta.promptId (and requestId). A `/compact` runs - * as `compactionTurn`, the command turn the host opened. */ + /** Use the host's command turn for compaction; inject prompt identity only when supported. */ openPrompt( clientMessageId: string, at: number, @@ -90,16 +86,14 @@ export class AcpTimelineTranslator { return this.finishPrompt(clientMessageId, { stopReason: result.stopReason, at, hookBlock }) } - /** The prompt failed: the agent's own error answer, or an answer Orca could not read (then no - * words are the agent's, and the row says only that the turn failed). A closed connection never - * reaches here. */ + /** Only agent-authored failures supply diagnostic text; connection loss is handled separately. */ promptFailed(clientMessageId: string, error: Error, at: number): ProviderTimelineEvent[] { const detail = error instanceof AcpAgentError ? acpPromptErrorDetail(this.dialect, error) : undefined const ended = this.prompts.last const notSignedIn = this.authenticationRequired(error) if (this.prompts.current?.clientMessageId !== clientMessageId) { - // The provider already ended this turn; its answer may carry the only copy of the reason. + // A late prompt answer may supply a failure reason missing from the provider's end. return ended?.clientMessageId === clientMessageId && this.failures.has(ended.turn) ? this.failures.row(ended.turn, detail, 'error', notSignedIn) : [] @@ -112,8 +106,7 @@ export class AcpTimelineTranslator { return acpAuthenticationRequired(this.dialect, error) } - /** The agent refused the prompt before its turn began: forgets it and answers the agent's reason. - * Null once the turn opened, when the refusal ends that turn instead (`promptFailed`). */ + /** Return the agent's refusal before turn open; afterward the error belongs to `promptFailed`. */ promptRefused(clientMessageId: string, error: AcpAgentError): string | null { return this.prompts.refuse(clientMessageId) ? acpPromptErrorDetail(this.dialect, error) : null } @@ -132,7 +125,7 @@ export class AcpTimelineTranslator { return this.context.models(models, at, this.dialect, { thread: this.options.sessionId }) } - /** The history `session/load` replays is dropped, except what it says about the context window. */ + /** Discard loaded history except context-window information. */ beginLoad(): void { if (this.prompts.current || this.loading) { throw new Error('ACP load overlaps a prompt or load') @@ -160,7 +153,7 @@ export class AcpTimelineTranslator { ) } - /** Settles a task the agent says it no longer runs; its own later ending may still overwrite this. */ + /** Provisionally settle missing tasks; later provider endings may revise the outcome. */ reconcileBackgroundTask(taskId: string): ProviderTimelineEvent[] { const update = { taskId, state: 'unverifiable' as const, summary: '', error: '' } return this.backgroundTasks.translate([update], { thread: this.options.sessionId }) @@ -209,8 +202,7 @@ export class AcpTimelineTranslator { const offeredTurn = providerTurn ?? (this.prompts.current?.opened ? this.prompts.current.turn : this.activeTurn) const owner = update ? this.messages.owner(offeredTurn, update) : { turn: offeredTurn } - // A running compaction's turn is the host's, open in the assembler under no provider key, so - // its frames name none; its words and end are read for the result its answer writes. + // Compaction frames join the host's open command turn without a provider turn key. const turn = this.prompts.compacting(owner.turn) ? undefined : owner.turn if ( owner.settled && @@ -251,7 +243,7 @@ export class AcpTimelineTranslator { } const end = extension?.end if (end && turn) { - // A hook's refusal is the whole turn: it ends here, and its answer adds nothing. + // Hook refusal ends immediately; normal completion waits for the prompt reply. if ( this.prompts.current?.turn === turn && ['end_turn', 'cancelled'].includes(end.stopReason) && @@ -297,7 +289,7 @@ export class AcpTimelineTranslator { }) } - /** A failed end writes the reason row before the end, so it joins the turn while open. */ + // Write failure reasons before ending the turn so their rows join it. private endTurn(turn: string, ending: AcpTurnEnding): ProviderTimelineEvent[] { const { stopReason, failureDetail, notSignedIn } = ending const events = this.prompts.endCompaction(turn, ending) ?? [ @@ -314,7 +306,7 @@ export class AcpTimelineTranslator { private start(turn: string, at: number): ProviderTimelineEvent[] { if (turn === this.prompts.current?.turn) { const events = this.prompts.start(turn, at) - // Marked so a late frame for this prompt after its end neither reopens nor retargets it. + // Late frames must not reopen or retarget this prompt. this.started.set(turn, true) this.activeTurn = turn return events diff --git a/src/main/acp/acp-timeline-turn-failures.test.ts b/src/main/acp/acp-timeline-turn-failures.test.ts index b0177884b57..a815288c10f 100644 --- a/src/main/acp/acp-timeline-turn-failures.test.ts +++ b/src/main/acp/acp-timeline-turn-failures.test.ts @@ -10,7 +10,7 @@ import { AcpAgentError } from './acp-errors' afterEach(closeProviderTimelineRigs) -// The shape Grok sends for a prompt its API refused, with the reason scrubbed to a placeholder. +// Captured Grok API failure with a placeholder reason. const REASON = 'API error (status 400 Bad Request): invalid_request_error: placeholder reason' const session = { sessionId: 'session-1' } const queued = (promptId: string) => ({ ...session, entries: [], runningPromptId: promptId }) @@ -82,7 +82,6 @@ describe('a failed ACP turn says why', () => { const rows = await f.rig.rows() const failures = statusRows(rows) expect(failures).toHaveLength(1) - // The turn ran, so the row reads as Codex's turn-ending error does: no refusal sentence or fact. expect(failures[0]?.body).toEqual({ kind: 'status', tone: 'error', text: REASON }) const turns = await f.rig.turns() expect(turns.map((turn) => [turn.state, turn.outcome])).toEqual([ diff --git a/src/main/acp/acp-tool-timeline.ts b/src/main/acp/acp-tool-timeline.ts index 19ea2b59438..1b52b5b820b 100644 --- a/src/main/acp/acp-tool-timeline.ts +++ b/src/main/acp/acp-tool-timeline.ts @@ -44,7 +44,7 @@ function outputText(update: ToolCallUpdate): string | undefined { return JSON.stringify(update.rawOutput) ?? '' } -/** A whole-file replacement is a linear-time unified patch, even for unrelated large files. */ +// Whole-file replacement avoids expensive diffs for large unrelated files. function replacementPatch(path: string, oldText: string, newText: string): string { const lines = (text: string, prefix: string): { count: number; text: string } => { if (!text) { @@ -76,21 +76,15 @@ function boundedDiffPatch(path: string, oldText: string, newText: string): strin type ToolSnapshot = { body: AgentJournalToolCallItem; turn?: string } -/** Holds the snapshots that fill ACP's partial tool updates, settled ones evicted first. Its bound - * is the assembler's open budget, which counts each running tool at no fewer bytes, so a running - * tool is evicted only after the assembler refused one past that budget, which ends the session. - * An update for an evicted settled tool is dropped by the assembler as settled. - * A tool's turn is held once: on its snapshot while that lives, then in `turns`. */ +// Evict settled snapshots first to preserve partial-update context for live tools. export class AcpToolTimeline { private readonly tools = new Map() private bytes = 0 - /** Turns of tools whose snapshot is gone, so a later task or request still lands beside the tool - * that started it. */ + // Preserve placement after snapshot eviction for later tasks and requests. private readonly turns = new BoundedMap({ maxEntries: MAX_PROVIDER_TIMELINE_OPEN_ENTRIES }) - // One latest snapshot per live call; the closing event takes ownership. Bytes are a ledger, - // not an RSS ceiling: provider frame limits and the live-entry count bound this finite carry. + // Retain full live payloads until close; byte accounting is a ledger, not an RSS ceiling. private readonly whole = new BoundedMap< string, { diff --git a/src/main/acp/acp-turn-failures.ts b/src/main/acp/acp-turn-failures.ts index a1123deb35f..547b7be70f0 100644 --- a/src/main/acp/acp-turn-failures.ts +++ b/src/main/acp/acp-turn-failures.ts @@ -9,24 +9,22 @@ import type { ProviderTimelineEvent } from '../native-chat/agent-session-timelin import type { AcpDialect } from './acp-dialects/acp-dialect' import { AcpAgentError, AcpAuthRequiredError, AcpSignInPromptError } from './acp-errors' -/** Ends the provider failed, rather than ones it chose (a refusal, a token limit). */ const FAILED_STOP_REASONS = ['error', 'rate_limit'] function acpStopReasonFailed(stopReason: string): boolean { return FAILED_STOP_REASONS.includes(stopReason) } -/** The provider's words in its error answer to `session/prompt`. Agents often answer a generic - * message ("Internal error") and keep their own words in `data`. */ +/** Prefer provider error details over generic messages such as Internal error. */ export function acpPromptErrorDetail(dialect: AcpDialect, error: AcpAgentError): string { - // Empty: the sign-in sentence alone says what to do. + // Orca's sign-in guidance already explains what to do. if (error instanceof AcpSignInPromptError) { return '' } return dialect.promptErrorDetail?.(error) ?? acpErrorDataWords(error.data) ?? error.message } -// Other structured data is metadata (service, error class names), not words for a person. +// Structured metadata is not user-facing diagnostic text. function acpErrorDataWords(data: unknown): string | undefined { const words = typeof data === 'string' @@ -44,7 +42,6 @@ export function acpAuthenticationRequired(dialect: AcpDialect, error: unknown): ) } -/** A sign-in refusal in the agent's own words. */ export function acpSignInRequiredFailure( dialect: AcpDialect, error: AcpAgentError @@ -53,12 +50,8 @@ export function acpSignInRequiredFailure( return agentSessionFailureFact('notSignedIn', detail ? { detail } : {}) } -/** One error row per failed turn, in the provider's own words, as a Codex turn-ending error reads: - * the message was accepted and the turn ran, so it is no refusal. Providers send that reason several - * times (beside the end, after it, in the prompt's error answer), so a later copy only adds a - * reason the row still lacks. */ +/** Deduplicate failure rows by turn, accepting later reasons when earlier reports lacked one. */ export class AcpTurnFailures { - /** The reason each failed turn's row holds; '' for none yet. */ private readonly rows = new BoundedMap< string, { text: string; notSignedIn: boolean; usageLimited: boolean } @@ -76,7 +69,6 @@ export class AcpTurnFailures { return this.rows.has(turn) } - /** The row an end writes, if it failed. */ ended( turn: string, stopReason: string, @@ -97,7 +89,7 @@ export class AcpTurnFailures { const diagnostic = (written?.text ? providerDiagnostic(written.text, 'person') : undefined) ?? detail const authenticationRequired = notSignedIn || written?.notSignedIn === true - // A later copy of the reason (the prompt's error answer) names no stop reason of its own. + // Preserve rate-limit classification when a later prompt error omits the stop reason. const usageLimited = !authenticationRequired && (stopReason === 'rate_limit' || written?.usageLimited === true) if ( diff --git a/src/main/acp/acp-turn-messages.ts b/src/main/acp/acp-turn-messages.ts index 3df459ebc90..ec101af2c5e 100644 --- a/src/main/acp/acp-turn-messages.ts +++ b/src/main/acp/acp-turn-messages.ts @@ -20,12 +20,11 @@ export type AcpTextDrop = { producerAgentId?: string } -/** Stable turn-relative message positions. */ export class AcpTurnMessages { private readonly turns = new BoundedMap({ maxEntries: 128 }) private readonly owners = new BoundedMap({ maxEntries: 128 }) - /** Channel items inherit the provider message's first turn, including trailing chunks. */ + /** Keep trailing chunks on the provider message's original turn. */ owner(turn: string | undefined, update: SessionUpdate): { turn?: string; settled?: boolean } { if ( (update.sessionUpdate !== 'agent_message_chunk' && diff --git a/src/main/acp/acp-visuals-skill.ts b/src/main/acp/acp-visuals-skill.ts index d9970278e0f..cc48c064023 100644 --- a/src/main/acp/acp-visuals-skill.ts +++ b/src/main/acp/acp-visuals-skill.ts @@ -1,40 +1,32 @@ -// How each ACP agent loads the chat-visuals skill for one launch, without touching the user's own -// config: ACP itself has no way to hand an agent a skill. +// ACP has no standard skill delivery; use each agent's per-process configuration. import { hasReachedAppVersion } from '../../shared/app-version' import type { NativeChatVisualsSkillLocation } from '../native-chat/native-chat-visuals-skill-location' export type AcpVisualsSkillInput = { skill: NativeChatVisualsSkillLocation - /** The `--version` the launch's binary printed; null when it printed none. */ version: string | null - /** What the child will see: its inherited environment with the launch's laid over it. */ env: Readonly> } -/** What a launch adds so the agent finds the skill: a plugin folder the agent's args name, and/or - * variables laid over its environment. */ export type AcpVisualsSkillLaunch = { pluginDir?: string env?: Record } -/** Null: this launch can't load the skill, so the chat starts without visuals. */ export type AcpVisualsSkillLoader = ( input: AcpVisualsSkillInput ) => Promise -// Why 1.0.44: the oldest release checked that has `agent --plugin-dir`; an unknown flag stops Grok. +// Unknown flags stop Grok; 1.0.44 is the oldest verified plugin-dir release. const GROK_PLUGIN_DIR_FIRST_VERSION = '1.0.44' -/** Grok takes Claude's plugin layout through `agent --plugin-dir`, for this process only. */ export const loadGrokVisualsSkill: AcpVisualsSkillLoader = async ({ skill, version }) => version !== null && hasReachedAppVersion(version, GROK_PLUGIN_DIR_FIRST_VERSION) ? { pluginDir: skill.pluginDir } : null -/** `content` (the user's own inline config, if any) with `skillsRoot` added to its skill paths; - * null when that config is not one this can extend safely. */ +/** Extend inline skill paths only when the existing configuration can be preserved. */ export function withOpenCodeSkillsPath( content: string | undefined, skillsRoot: string @@ -43,14 +35,14 @@ export function withOpenCodeSkillsPath( try { config = content?.trim() ? JSON.parse(content) : {} } catch { - // JSONC or a typo: rewriting it would drop the user's settings. + // Rewriting JSONC or malformed JSON would lose user settings. return null } if (typeof config !== 'object' || config === null || Array.isArray(config)) { return null } const skills: unknown = 'skills' in config ? config.skills : undefined - // 2.x also takes a bare list of paths and URLs. + // OpenCode 2.x also accepts a bare path/URL list. if (Array.isArray(skills)) { return JSON.stringify({ ...config, skills: [...skills, skillsRoot] }) } @@ -66,11 +58,9 @@ export function withOpenCodeSkillsPath( : null } -// Why 2.x: 2.x adds inline `skills.paths` to the config files' list; 1.x lets it replace the -// user's own, so their skills would vanish from Orca's chats. +// OpenCode 1.x replaces user skill paths; 2.x merges inline paths with configured lists. const OPENCODE_MERGED_SKILL_PATHS_FIRST_VERSION = '2.0.14' -/** OpenCode reads `skills.paths` from the inline config variable. */ export const loadOpenCodeVisualsSkill: AcpVisualsSkillLoader = async ({ skill, env, version }) => { if ( version === null || @@ -82,9 +72,7 @@ export const loadOpenCodeVisualsSkill: AcpVisualsSkillLoader = async ({ skill, e return content === null ? null : { env: { OPENCODE_CONFIG_CONTENT: content } } } -// No gate: 17.0.5+ has `--plugin-dir`, but 18.1.5-18.1.8 skip its skills by default (no visuals). -/** OMP takes Claude's plugin layout through `--plugin-dir` too, for this process only, beside the - * user's own plugins and skill folders. */ +// OMP 18.1.5–18.1.8 accept plugin-dir but skip its skills by default. export const loadOmpVisualsSkill: AcpVisualsSkillLoader = async ({ skill }) => ({ pluginDir: skill.pluginDir }) diff --git a/src/main/agent-hooks/agent-home-login-shell.test.ts b/src/main/agent-hooks/agent-home-login-shell.test.ts index 88185b699e7..15fc0cb6081 100644 --- a/src/main/agent-hooks/agent-home-login-shell.test.ts +++ b/src/main/agent-hooks/agent-home-login-shell.test.ts @@ -3,9 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' type ProbeResult = { code: number | null; stdout: string; timedOut: boolean } type RunProcessStub = (spec: { program: string; args: string[] }) => Promise -// Why: the probe spawns a real login shell and swallows every spawn failure into its fallback. -// Left unmocked this asserts the runner's scheduling latency, not the parser: on a loaded CI box -// the 8s timeout expires and the first case silently flips to the fallback path. +// Mock shell probes so CI scheduling and profile failures cannot silently exercise the fallback. const probe = vi.hoisted(() => ({ runProcess: vi.fn() })) vi.mock('@orca/process-host', async (importOriginal) => ({ ...(await importOriginal>()), @@ -72,8 +70,6 @@ describe.runIf(process.platform !== 'win32')('resolveLoginShellGrokHome', () => await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') }) - // Why: this is the branch that made the old test flaky — pin it so a probe failure is - // an asserted fallback rather than an invisible substitution for a real answer. it.each([ new Error('spawn failed'), Object.assign(new Error('spawn timed out'), { killed: true }) diff --git a/src/main/agent-hooks/agent-home-login-shell.ts b/src/main/agent-hooks/agent-home-login-shell.ts index 73a332d1ad8..e261416efba 100644 --- a/src/main/agent-hooks/agent-home-login-shell.ts +++ b/src/main/agent-hooks/agent-home-login-shell.ts @@ -40,7 +40,6 @@ function resolveLoginShell(): string { return candidate } -/** The login shell's value for `envName`, or null when unset, invalid, or unreadable. */ async function readLoginShellAgentHome( envName: AgentHomeEnvName, signal?: AbortSignal @@ -49,7 +48,7 @@ async function readLoginShellAgentHome( const shell = resolveLoginShell() const shellName = basename(shell) const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' - // Why: agent PTYs start login shells, so read the same profile-derived home override. + // Read profile overrides used by agent login-shell PTYs. const result = await runProcess({ program: shell, args: [mode, `printenv ${envName} | head -c ${AGENT_HOME_MAX_LENGTH + 1}`], @@ -75,7 +74,6 @@ export async function resolveLoginShellGrokHome( return (await readLoginShellAgentHome('GROK_HOME', signal)) ?? defaultAgentHome(home, '.grok') } -/** `$KIRO_HOME` replaces `~/.kiro` outright, for agent configs and sessions alike. */ export async function resolveLoginShellKiroHome( home: string, signal?: AbortSignal @@ -83,10 +81,7 @@ export async function resolveLoginShellKiroHome( return (await readLoginShellAgentHome('KIRO_HOME', signal)) ?? defaultAgentHome(home, '.kiro') } -/** - * A process not started from a login shell (orcad over SSH) misses profile-only agent homes, so - * its installers would write where the agents' login-shell panes never look. Never throws. - */ +/** SSH hosts may lack profile-only agent homes; adopt them before installers choose paths. */ export async function adoptLoginShellAgentHomes( env: NodeJS.ProcessEnv = process.env ): Promise { diff --git a/src/main/agent-hooks/agent-hook-runtime-deps.ts b/src/main/agent-hooks/agent-hook-runtime-deps.ts index fb675270454..884cbf3cfba 100644 --- a/src/main/agent-hooks/agent-hook-runtime-deps.ts +++ b/src/main/agent-hooks/agent-hook-runtime-deps.ts @@ -5,15 +5,13 @@ import { isAgentStatusHooksEnabled } from '../../shared/agent-status-hooks-setti type RuntimeDeps = NonNullable[2]> -/** The hook-store bindings every runtime host (desktop, `orca serve`, orcad) gives its runtime. */ export function agentHookRuntimeDeps( server: AgentHookServer, observedPaneIdentities: Pick, getSettings: () => Parameters[0] ) { return { - // Why: main's OSC parse is the only producer for a PTY agent, and this store is the only - // place `worktree.ps` and the mobile projection read from. + // OSC-derived PTY status feeds the store read by worktree.ps and mobile projections. onTerminalAgentStatus: (event) => server.ingestTerminalStatus(event), onTerminalProgramStatusFacet: (event) => server.ingestProgramStatusFacet(event), onClaudeTerminalEvidence: (paneKey, evidence) => @@ -21,16 +19,14 @@ export function agentHookRuntimeDeps( getAgentStatusSnapshot: () => server.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), getAgentStatusSnapshotForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey), - // Why unfiltered: resume-identity rows carry the provider session mobile native chat - // addresses transcripts by — Pi publishes identity that way and would otherwise be unreachable. + // Include resume identities so mobile can locate provider transcripts, including Pi sessions. getAgentProviderSessionSnapshot: () => server.getStatusSnapshot(), getAgentProviderSessionRowsForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey), subscribeAgentProviderSessionChanges: (listener) => server.subscribeProviderSessionChanges(listener), - // Why captured rather than resolved at read: the fleet snapshot remints cached rows on every - // read, so a row observed under one process otherwise acquires whatever owns the pane now. + // Use identity captured at observation; resolving now could attribute cached rows to a new process. readObservedAgentStatusPaneIdentity: (paneKey) => observedPaneIdentities.read(paneKey), - // Why: structured chats have no hooks, so the host writes their projections into the same store. + // Structured chats publish here directly because they have no hooks. structuredAgentStatusSink: { publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), forget: (subject) => server.dropStructuredStatus(subject), diff --git a/src/main/agent-hooks/agent-status-pane-index.ts b/src/main/agent-hooks/agent-status-pane-index.ts index 3844ef42127..8c23c1b2c13 100644 --- a/src/main/agent-hooks/agent-status-pane-index.ts +++ b/src/main/agent-hooks/agent-status-pane-index.ts @@ -1,8 +1,6 @@ import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' -/** Groups one agent-status snapshot by pane key so a projection that resolves many - * panes reads the snapshot once instead of rescanning (and re-materializing) it per - * pane. Rows without a pane key are unaddressable here and dropped. */ +/** Index once per snapshot to avoid rematerializing rows for every pane. */ export function indexAgentStatusRowsByPaneKey( rows: readonly AgentStatusIpcPayload[] ): Map { diff --git a/src/main/agent-hooks/agent-transition-detector.test.ts b/src/main/agent-hooks/agent-transition-detector.test.ts index bbca59c02b1..a1ca7fdcff8 100644 --- a/src/main/agent-hooks/agent-transition-detector.test.ts +++ b/src/main/agent-hooks/agent-transition-detector.test.ts @@ -102,7 +102,7 @@ describe('AgentTransitionDetector', () => { ]) h.changes.length = 0 ingest(h.server, { state: 'done' }, { worktreeId: FOLDER }) - // Moving workspace rests the pane where it was; a done with no turn there is no finish. + // Leaving rests the old workspace; done without a turn is not a completion. expect(h.changes).toEqual([ { kind: 'moved', @@ -136,7 +136,6 @@ describe('AgentTransitionDetector', () => { at: startedAt } ]) - // The seeded turn is known, so its finish is news. ingest(h.server, { state: 'done' }) expect(h.changes.at(-1)).toMatchObject({ kind: 'moved', transition: 'turn-finished' }) }) @@ -273,7 +272,7 @@ describe('AgentTransitionDetector: hookless titles', () => { transition: 'turn-finished' }) ]) - // The hook row owns the pane now: its titles are not read, and its terminal's exit is the row's. + // Hook authority replaces title tracking for this pane. h.changes.length = 0 noteTitle(h.detector, '⠋ Codex working') h.exits.get('pty-1')!() diff --git a/src/main/agent-hooks/agent-transition-detector.ts b/src/main/agent-hooks/agent-transition-detector.ts index 6d5ab15ca7b..3d514888045 100644 --- a/src/main/agent-hooks/agent-transition-detector.ts +++ b/src/main/agent-hooks/agent-transition-detector.ts @@ -22,22 +22,20 @@ export type AgentTransitionStore = Pick< 'getStatusSnapshot' | 'getStatusSnapshotForPane' | 'subscribeStatusRowMutations' > -/** The workspace a pane belongs to, qualified by the SSH host that runs it (null: this machine). */ export type AgentPaneWorkspace = { worktreeId: string; sshHostId: ExecutionHostId | null } export type AgentPaneChange = - /** A status that predates this observer (restored, replayed, present at attach, a hookless - * agent's first title): never news. */ + /** Existing status seeds observation without reporting a new transition. */ | { kind: 'seeded' paneKey: string workspace: AgentPaneWorkspace phase: AgentPhase at: number - /** A first title: an idle one may follow no turn at all. */ + /** An initial idle title may follow no turn at all. */ fromTitle?: true } - /** Every hook look not reported as seeded, changed or not; null: no row or no workspace. */ + /** Emitted for every non-seeded hook observation, even without a phase change. */ | { kind: 'observed' paneKey: string @@ -45,7 +43,6 @@ export type AgentPaneChange = phase: AgentPhase at: number } - /** The pane changed phase at `at`; leaving a workspace rests the pane there. */ | { kind: 'moved' paneKey: string @@ -74,15 +71,10 @@ export function sameAgentPaneWorkspace( return !!a && !!b && a.worktreeId === b.worktreeId && a.sshHostId === b.sshHostId } -/** - * Per-pane agent transitions from the hook server's status store, and from hookless agents' titles - * where no hook row reports the pane: prompt submitted, turn finished, errored, needs input. One - * phase per pane, so a title-tracked turn a hook row finishes is a finish. Runs on every host; panes - * at rest are not kept. - */ +/** Track one phase per pane, preferring hook rows over hookless-agent titles. */ export class AgentTransitionDetector { private readonly panes = new Map() - /** Hookless panes seen since their PTY started: the first title seeds, it is not a transition. */ + // The first title for each PTY seeds state rather than reporting a transition. private readonly titlePanes = new Map() private readonly listeners = new Set<(change: AgentPaneChange) => void>() private store: AgentTransitionStore | null = null @@ -95,7 +87,7 @@ export class AgentTransitionDetector { } ) {} - /** Subscribe before {@link attach}, or the statuses present at attach are missed. */ + /** Subscribe before attach to receive its initial statuses. */ subscribe(listener: (change: AgentPaneChange) => void): () => void { this.listeners.add(listener) return () => { @@ -121,7 +113,6 @@ export class AgentTransitionDetector { } } - /** Hydration fills the store without a row mutation; once it has, its rows seed here. */ seedRestoredRows(): void { for (const row of this.store?.getStatusSnapshot() ?? []) { if (!this.panes.has(row.paneKey)) { @@ -130,7 +121,6 @@ export class AgentTransitionDetector { } } - /** Hookless agents' titles. Hook authority is per pane, as in Smart's attention. */ noteTerminalSideEffects(batch: TerminalSideEffectBatch): void { const { worktreeId, paneKey } = batch if (!worktreeId || !paneKey) { @@ -138,7 +128,7 @@ export class AgentTransitionDetector { } for (const fact of batch.facts) { if (fact.kind === 'title' && !this.latestRow(paneKey)) { - // A cleared stale working title is a timer's guess, not a finish. + // Timer-cleared titles do not prove turn completion. const phase = fact.staleWorkingTitleClear ? 'rest' : titleAgentPhase(fact.rawTitle) this.observeTitle( batch.ptyId, @@ -161,7 +151,7 @@ export class AgentTransitionDetector { this.advance(paneKey, workspace, phase, this.deps.now(), agentPhaseTransition) return } - // A new PTY in the pane is a new agent; the last one left with its terminal. + // A new PTY replaces the pane's previous agent. if (known) { this.endTitlePane(paneKey) } @@ -175,7 +165,7 @@ export class AgentTransitionDetector { } this.titlePanes.set(paneKey, { ptyId, releaseExit }) const now = this.deps.now() - // The pane leaves its old workspace first, so a reader never takes the leave for the new one. + // Publish departure before associating the pane with its new workspace. this.leaveIfMoved(paneKey, workspace, now) this.emit({ kind: 'seeded', paneKey, workspace, phase, at: now, fromTitle: true }) this.track(paneKey, workspace, phase) @@ -184,7 +174,7 @@ export class AgentTransitionDetector { private endTitlePane(paneKey: string): void { this.titlePanes.get(paneKey)?.releaseExit() this.titlePanes.delete(paneKey) - // A hook row that took the pane over still reports it. + // Preserve hook-owned state after title tracking ends. if (!this.latestRow(paneKey)) { this.advance(paneKey, null, 'rest', this.deps.now(), () => null) } @@ -211,7 +201,6 @@ export class AgentTransitionDetector { ) } - /** The pane observed in `observed`; a pane that left or changed workspace rests where it was. */ private advance( paneKey: string, workspace: AgentPaneWorkspace | null, @@ -265,8 +254,7 @@ export class AgentTransitionDetector { } } - /** The row the pane's phase reads. The window applies a pane's rows in arrival order, so the - * newest is the one it holds. */ + /** Use the newest row to match the window's arrival-order projection. */ latestRow(paneKey: string): AgentStatusIpcPayload | null { let latest: AgentStatusIpcPayload | null = null for (const row of this.store?.getStatusSnapshotForPane(paneKey) ?? []) { @@ -279,7 +267,6 @@ export class AgentTransitionDetector { private emit(change: AgentPaneChange): void { for (const listener of this.listeners) { - // One reader's failure must not cost the others the change. try { listener(change) } catch (error) { diff --git a/src/main/agent-hooks/branch-rename-failure-output.ts b/src/main/agent-hooks/branch-rename-failure-output.ts index 8b8660484b7..1534c120b72 100644 --- a/src/main/agent-hooks/branch-rename-failure-output.ts +++ b/src/main/agent-hooks/branch-rename-failure-output.ts @@ -3,10 +3,7 @@ import { type AgentGenerationFailureOutput } from '../text-generation/agent-failure-output' -// Why: the full CLI output is a diagnostic for the local user only. Keeping it -// in memory (never in worktree metadata) means nothing environment-identifying -// is persisted or synced to paired clients; a restart just loses the on-demand -// view while the sanitized excerpt badge survives. +// Keep full CLI diagnostics local and ephemeral; persist only the sanitized failure badge. const MAX_ENTRIES = 32 const entriesByWorktreeId = new Map() @@ -14,8 +11,7 @@ export function rememberBranchRenameFailureOutput( worktreeId: string, output: AgentGenerationFailureOutput | null | undefined ): void { - // Delete-then-set keeps insertion order as recency so eviction drops the - // stalest worktree first. + // Refresh insertion order so eviction removes the least recently updated worktree. entriesByWorktreeId.delete(worktreeId) if (!output) { return diff --git a/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts b/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts index 341fd8fa033..c5c129b809b 100644 --- a/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts +++ b/src/main/agent-hooks/claude-cancel-capture.test-fixture.ts @@ -1,6 +1,4 @@ -// Loads the Claude Code 2.1.280 cancel captures (src/shared/__fixtures__/claude-cancel-*-hooks.jsonl, -// sidecars beside them): hook payloads recorded over a real PTY, merged in time order with the -// driver's cancel and kill markers. +// Merge Claude Code 2.1.280 PTY hook captures with their cancel/kill sidecars in time order. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { z } from 'zod' @@ -54,8 +52,7 @@ export function cancelLabelled(records: CapturedRecord[], label: string): Captur return cancel } -/** Whether a hook landed inside the settle window on the capture's own clock, which is when the - * renderer's baseline check drops the inference instead of sending it. */ +/** A hook inside the capture's settle window supersedes the renderer's inferred cancellation. */ export function hookSupersedesCancel(records: CapturedRecord[], cancel: CapturedCancel): boolean { return records.some( (record) => diff --git a/src/main/agent-hooks/ended-process-reconciliation.test.ts b/src/main/agent-hooks/ended-process-reconciliation.test.ts index 04c653a3c5e..9ca8a907181 100644 --- a/src/main/agent-hooks/ended-process-reconciliation.test.ts +++ b/src/main/agent-hooks/ended-process-reconciliation.test.ts @@ -41,7 +41,6 @@ function claudeRow(server: AgentHookServer, state: 'working' | 'waiting' | 'done }) } -/** A row carrying a resumable provider session — the only shape `dropStatusEntry` retains. */ function resumableClaudeRow(server: AgentHookServer): void { server.ingestRemote( { @@ -62,9 +61,7 @@ function paneState(server: AgentHookServer): string { return server.getStatusSnapshotForPane(PANE)[0]?.state ?? 'missing' } -// The teardown that normally clears a dead pane depends on the spawn-time ptyPaneKey mapping, which -// a restored PTY may never rebuild — leaving the row and its latches with nothing left to retire -// them. See PLAN-STA-4612 §4.2. +// Restored PTYs may lack spawn-time mappings, so ordinary teardown cannot retire their stale rows. describe('reconcileEndedProcessForPaneKeys', () => { it('retires a working row whose process is certifiably dead', async () => { const server = await startServer() @@ -81,9 +78,7 @@ describe('reconcileEndedProcessForPaneKeys', () => { }) it('clears Claude latches even when the stored row already reads done', async () => { - // A latch can outlive the row it gated (a restored row, or one written before the inventory - // arrived), so a row can read `done` with a latch that would re-gate `working` on the pane's - // very next event. + // Stale latches can re-gate a done row on the next event. const server = await startServer() try { claudeRow(server, 'done') @@ -112,10 +107,7 @@ describe('reconcileEndedProcessForPaneKeys', () => { }) it('keeps the resume identity a paired dismissal minted when the shell outlived the agent', async () => { - // The renderer's confirmed-shell route calls agentStatus:drop FIRST, which deliberately mints a - // providerSessionOnly remnant, then this. That remnant carries no state claim — it cannot gate a - // pane `working` — and the pane's PTY is still there to resume into, so retiring the pane's live - // claims must not take it. Without the option it goes with everything else. + // Paired dismissal preserves resume identity; retire live claims without discarding that remnant. const server = await startServer() try { resumableClaudeRow(server) @@ -132,10 +124,9 @@ describe('reconcileEndedProcessForPaneKeys', () => { expect(kept?.providerSessionOnly).toBe(true) expect(kept?.providerSession?.id).toBe('resume-me') expect(kept?.launchToken).toBeUndefined() - // The live claims still went: a latch left behind would re-gate the pane on its next event. expect(server._getStateForTests().claudeRunningNonAgentTaskPaneKeys.has(PANE)).toBe(false) - // The retained identity must not recreate dead launch authority after restart. + // Resume identity must not restore dead process authority. server.flushStatusPersistSync() server.stop() const restarted = await startServer() diff --git a/src/main/agent-hooks/first-work-branch-rename-wsl-move.test.ts b/src/main/agent-hooks/first-work-branch-rename-wsl-move.test.ts index 5b32a4e04e0..9c45b1f432f 100644 --- a/src/main/agent-hooks/first-work-branch-rename-wsl-move.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename-wsl-move.test.ts @@ -90,7 +90,6 @@ describe('first-work rename while a WSL move takes the project', () => { expect(setDisplayName).not.toHaveBeenCalled() expect(setRenameError).not.toHaveBeenCalled() - // A transient bail, not a settled verdict: once the move settles the next event renames. resetWslMoveFenceForTests() await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['branch', '-m', 'you/fix-auth'], { @@ -149,7 +148,6 @@ describe('first-work rename while a WSL move takes the project', () => { expect(setDisplayName).not.toHaveBeenCalled() expect(setRenameError).not.toHaveBeenCalled() - // Still pending: once the move settles the next event renames. resetWslMoveFenceForTests() await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['branch', '-m', 'you/fix-auth'], { @@ -181,7 +179,6 @@ describe('first-work rename while a WSL move takes the project', () => { }) expect(renameWorktreeFolder).not.toHaveBeenCalled() - // The branch already has its new name, so the rename is settled: a later event never renames again. await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) const branchRenames = gitExecFileAsyncMock.mock.calls.filter( ([args]) => args[0] === 'branch' && args[1] === '-m' diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 5df0802847e..5b51bbe9bbf 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -22,7 +22,6 @@ const { resolveTextGenerationParamsMock: vi.fn(), prepareLocalEnvMock: vi.fn(async () => ({ ok: true as const })), computeBranchNameMock: vi.fn((leaf: string) => `you/${leaf}`), - // Mirror computeBranchNameMock's `you/` strategy so prefix stripping is realistic. getConfiguredBranchPrefixMock: vi.fn((_settings: unknown, username: string | null) => username) })) @@ -99,8 +98,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { isPendingFirstAgentMessageRename: () => true }) const items: AgentJournalRenderItem[] = [] - // A real journal's sequence only ever advances, so the feed's projection - // cache must miss on every publish here: this test is about the rename. + // Advance journal sequence so projection caching cannot hide a rename event. let sequence = 0 // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks, snapshot(), the provenance reads and runningTurn() of a journal. const journal = { @@ -281,8 +279,6 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { }) it('skips the redundant branch-rename notify when the folder rename succeeded', async () => { - // The folder rename already pushed a worktrees:changed carrying the id mapping, - // so onRenamed would only trigger a second, redundant renderer re-list. const { deps, onRenamed } = makeDeps({ renameWorktreeFolder: vi.fn(async () => true) }) await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) expect(onRenamed).not.toHaveBeenCalled() @@ -304,9 +300,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { }) it('strips a prefix the model leaked into the slug from both branch and display name', async () => { - // Model echoed `you/worktree-spinner`, which the - // sanitizer folds to `you-worktree-spinner`; without stripping it would - // double-prefix the branch (`you/you-...`) and show "You worktree spinner". + // Sanitization folds an echoed you/ prefix into the slug; strip it to prevent double-prefixing. generateBranchNameMock.mockResolvedValue({ success: true, slug: 'you-worktree-spinner' }) const { deps, setDisplayName } = makeDeps() await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) @@ -318,8 +312,6 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { }) it('skips the rename when the model echoes only the configured prefix', async () => { - // The model emitted just `you` (the prefix); stripping leaves an empty slug, - // so renaming would re-add the prefix and double it to `you/you`. generateBranchNameMock.mockResolvedValue({ success: true, slug: 'you' }) const { deps, onRenamed, setRenameError } = makeDeps() await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) @@ -328,7 +320,6 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect.anything() ) expect(onRenamed).not.toHaveBeenCalled() - // Benign terminal state: clear any stale badge, never raise a new one. expect(setRenameError).not.toHaveBeenCalledWith(WORKTREE_ID, expect.any(String)) }) @@ -343,9 +334,6 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { }) it('runs Git against the backing folder for a folder-workspace instance id', async () => { - // Why: instance ids carry a synthetic `::workspace:` suffix that is not - // a real directory. The Git cwd must resolve to the folder or `rev-parse` - // spawns against a nonexistent path (ENOENT). const instanceId = `${WORKTREE_ID}::workspace:123e4567-e89b-12d3-a456-426614174000` const { deps } = makeDeps({ resolveWorktreeIdForTab: () => instanceId }) await maybeAutoRenameBranchOnFirstWork(workingEvent({ worktreeId: undefined }), deps) @@ -494,7 +482,6 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect.objectContaining({ cwd: '/repo/wt' }) ) expect(onRenamed).toHaveBeenCalledWith(REPO_ID) - // The eventual success must clear the error raised on the first attempt. expect(setRenameError).toHaveBeenLastCalledWith(WORKTREE_ID, null) }) @@ -534,20 +521,15 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) expect(generateBranchNameMock).not.toHaveBeenCalled() expect(onRenamed).not.toHaveBeenCalled() - // Benign skip (user-named branch) must never raise the failure badge; it may - // only clear a stale one (null), never set a non-null error message. expect(setRenameError).not.toHaveBeenCalledWith(WORKTREE_ID, expect.any(String)) }) it('clears a stale error when a retryable worktree later reaches a benign stop', async () => { - // First event: transient generation failure raises the badge. generateBranchNameMock.mockResolvedValueOnce({ success: false, error: 'agent not ready' }) const { deps, setRenameError } = makeDeps() await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) expect(setRenameError).toHaveBeenCalledWith(WORKTREE_ID, 'agent not ready', null) - // Second event: the user has since pushed the branch, so it settles benignly. - // The stale "rename failed" badge must be cleared rather than stick forever. gitExecFileAsyncMock.mockImplementation( gitResponder({ currentBranch: 'you/Nautilus', hasUpstream: true }) ) @@ -581,9 +563,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { }) it('surfaces a failed upstream probe and retries instead of settling silently (issue #7808)', async () => { - // Localized git (gettext + de_DE) translates even the `fatal:` prefix, so - // the no-upstream error is unrecognizable. This must not settle as "has - // upstream" — the badge goes up and a later event may still succeed. + // Localized probe failures must remain visible and retryable. const localizedError = new Error( 'Command failed: git rev-parse --abbrev-ref HEAD@{u}\n' + "Schwerwiegend: Kein Upstream-Branch für Branch 'you/Nautilus' konfiguriert." @@ -604,7 +584,6 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect.stringContaining('Schwerwiegend') ) - // Once git output is readable again, the same worktree retries and renames. gitExecFileAsyncMock.mockImplementation(plainResponder) await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( @@ -635,8 +614,6 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { ['branch', '-m', 'you/fix-auth-2'], expect.objectContaining({ cwd: '/repo/wt' }) ) - // Display name and folder must follow the resolved (suffixed) leaf, not the - // pre-suffix slug — otherwise they diverge from the branch. expect(setDisplayName).toHaveBeenCalledWith(WORKTREE_ID, 'Fix auth 2') expect(renameWorktreeFolder).toHaveBeenCalledWith(WORKTREE_ID, 'fix-auth-2') }) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index 9a35a2635da..e6dc4e827e4 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -1,4 +1,3 @@ -// On first agent work in a fresh workspace, replace the auto-generated creature branch (e.g. `you/Nautilus`) with a short work-derived name. import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' import { isFolderRepo } from '../../shared/repo-kind' @@ -34,7 +33,7 @@ import { isWslMoveRepoBlocked } from '../persistence/moving-wsl-projects/wsl-mov export type FirstWorkBranchRenameEvent = { paneKey: string - /** Agent-echoed worktree id; unreliable, used only as a fallback. */ + /** Untrusted agent echo; use only as a fallback. */ worktreeId: string | undefined tabId: string | undefined state: string @@ -49,45 +48,37 @@ export type FirstWorkBranchRenameDeps = { Partial> getRepo: (repoId: string) => Repo | undefined getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined - /** Current sidebar display name for the worktree, if one is stored. */ getCurrentDisplayName: (worktreeId: string) => string | undefined - /** Current workspace path for non-git folder workspaces. */ getFolderWorkspacePath?: (worktreeId: string) => string | undefined - /** True while a WSL move is taking the folder workspace, or the WSL host owns it. */ isFolderWorkspaceWslMoveBlocked?: (worktreeId: string) => boolean - /** True while a workspace title is waiting for the first agent message. */ isPendingFirstAgentMessageRename?: (worktreeId: string) => boolean - /** True only for Orca-created worktrees whose branch Orca is allowed to rename. */ + /** Require Orca creation provenance before renaming a branch. */ canRenameOrcaCreatedBranch: (worktreeId: string) => boolean - /** Persist a new sidebar display name for the worktree. */ setDisplayName: (worktreeId: string, displayName: string) => void - /** Align the on-disk folder with the new branch leaf (best-effort, local-only). */ + /** Best-effort local folder alignment after the branch rename. */ renameWorktreeFolder?: (worktreeId: string, newLeaf: string) => Promise - /** Record (or clear with null) an auto-rename failure for the sidebar "rename failed" badge; `failureOutput` carries bounded CLI output for on-demand display. */ + /** Null clears the badge; failureOutput supplies bounded local diagnostics. */ setRenameError: ( worktreeId: string, error: string | null, failureOutput?: AgentGenerationFailureOutput | null ) => void - /** Authoritative tab→worktree resolution from the session's tab map. */ resolveWorktreeIdForTab: (tabId: string) => string | undefined - /** Invalidate caches + notify the renderer so the new branch name surfaces. */ onRenamed: (repoId: string) => void } -// inFlight blocks concurrent generation; settled caches definitive verdicts (transient bails stay unsettled to retry later). +// Cache only definitive verdicts; transient failures must remain retryable. const inFlightWorktreeIds = new Set() const settledWorktreeIds = new Set() export const FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT = 500 -/** Test seam: clear the per-process dedupe sets. */ export function resetFirstWorkBranchRenameState(): void { inFlightWorktreeIds.clear() settledWorktreeIds.clear() } function rememberSettledWorktreeId(worktreeId: string): void { - // Why: worktree ids are unbounded over a long session; evict oldest to bound memory (costs a rare re-probe). + // Bound long-lived deduplication at the cost of occasional re-probes. settledWorktreeIds.delete(worktreeId) settledWorktreeIds.add(worktreeId) while (settledWorktreeIds.size > FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT) { @@ -103,20 +94,18 @@ export async function maybeAutoRenameBranchOnFirstWork( event: FirstWorkBranchRenameEvent, deps: FirstWorkBranchRenameDeps ): Promise { - // Cheap gates first — the signal must be a live "work has begun" event. if (event.isReplay || event.state !== 'working') { return } if (!deps.getSettings().autoRenameBranchFromWork) { return } - // Resolve from the tab (authoritative); the agent-echoed worktreeId isn't present on every event. + // Prefer authoritative tab mapping over an agent-echoed workspace ID. const tabId = parsePaneKey(event.paneKey)?.tabId ?? event.tabId const worktreeId = (tabId ? deps.resolveWorktreeIdForTab(tabId) : undefined) ?? event.worktreeId if (!worktreeId) { return } - // Short-circuit settled/in-flight worktrees before any logging or work. if (settledWorktreeIds.has(worktreeId) || inFlightWorktreeIds.has(worktreeId)) { return } @@ -126,27 +115,24 @@ export async function maybeAutoRenameBranchOnFirstWork( } inFlightWorktreeIds.add(worktreeId) try { - // settled = definitive verdict (renamed/ineligible); false = transient bail to retry later. const settled = await runAutoRename(worktreeId, prompt, event.assistantMessage, deps) if (settled) { rememberSettledWorktreeId(worktreeId) } } catch (error) { - // Why: best-effort convenience; a failure must never disrupt the user's agent, so swallow after logging. console.warn('[auto-branch-rename] rename attempt failed:', error) } finally { inFlightWorktreeIds.delete(worktreeId) } } -/** Returns true for a definitive verdict (renamed or permanently ineligible → stop re-probing), false for a transient bail worth retrying. */ +/** True settles permanently; false allows a later event to retry. */ async function runAutoRename( worktreeId: string, prompt: string, assistantMessage: string | undefined, deps: FirstWorkBranchRenameDeps ): Promise { - // `stop` = permanent skip (logs which gate bailed); `retry` = transient; `clearError` drops a stale "rename failed" badge on a benign final state. const stop = (reason: string, clearError = false): true => { if (clearError) { deps.setRenameError(worktreeId, null) @@ -172,7 +158,7 @@ async function runAutoRename( } const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId)) - // Why: folder-workspace ids carry a synthetic `::workspace:` suffix that's not a real dir; resolve to the backing folder for Git's cwd. + // Synthetic workspace suffixes must not become Git working-directory paths. const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!repo || !parsed) { return stop('unresolved repo or worktree id') @@ -181,12 +167,12 @@ async function runAutoRename( return stop('folder project has no branch to rename', true) } const worktreePath = parsed.worktreePath - // Before any git command; retry keeps the rename pending until the move settles. + // Defer Git work while a WSL move holds the project. if (isWslMoveRepoBlocked(repo)) { return retry('a WSL move is taking the project') } - // Why: a runtime row's connectionId names the server's own SSH target; that host owns the branch, never this client. + // Runtime-host branches belong to that host, regardless of the client's SSH connection. const route = resolveGitRouteForHost(getRepoExecutionHostId(repo)) if (route.kind === 'runtime') { return stop(`branch is owned by ${route.hostId}`) @@ -203,18 +189,17 @@ async function runAutoRename( if (!deps.canRenameOrcaCreatedBranch(worktreeId)) { return stop(`worktree is not eligible for auto-rename`, true) } - // Only rename a branch Orca auto-named — never one the user chose. const leaf = currentBranch.slice(currentBranch.lastIndexOf('/') + 1) if (!isAutoGeneratedCreatureBranchName(leaf)) { return stop(`branch "${currentBranch}" is not an auto-generated name`, true) } - // Never rewrite a branch that's already been published. + // Preserve published branches. const upstreamProbe = await probeBranchUpstream(exec) if (upstreamProbe.outcome === 'has-upstream') { return stop(`branch "${currentBranch}" already has an upstream`, true) } if (upstreamProbe.outcome === 'probe-failed') { - // Why: settling an unreadable probe as "has upstream" made this silent + permanent via the settled cache (issue #7808). + // Failed probes must remain retryable rather than entering the settled cache. const probeError = upstreamProbe.message.replace(/\s+/g, ' ').trim() deps.setRenameError( worktreeId, @@ -227,7 +212,6 @@ async function runAutoRename( const hostKey = getCommitMessageModelDiscoveryHostKey(null) const resolvedParams = resolveTextGenerationParams(settings, hostKey, 'branchName', repo) if (!resolvedParams.ok) { - // Why: a generation-step failure (vs a benign skip) is user-actionable, so surface it on the card. deps.setRenameError(worktreeId, resolvedParams.error) return stop(`no generation agent: ${resolvedParams.error}`) } @@ -244,19 +228,18 @@ async function runAutoRename( params, target ) - // Generation takes seconds; a move may have started meanwhile. + // Generation can outlive the start of a WSL move. if (isWslMoveRepoBlocked(repo)) { return retry('a WSL move started during generation') } if (!generated.success) { - // Transient failure: retry on a later event; skip the badge for a user-canceled generation. if (!generated.canceled) { deps.setRenameError(worktreeId, generated.error, generated.failureOutput ?? null) } return retry(`generation failed: ${generated.error}`) } - // Re-validate after generation (takes seconds): bail if the branch changed or was published meanwhile. + // Generation can outlive a branch switch or publication. const branchNow = (await exec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim() if (branchNow !== currentBranch) { return retry(`branch changed during generation (${currentBranch} -> ${branchNow})`) @@ -271,38 +254,34 @@ async function runAutoRename( } const username = (await resolveLocalGitUsername(repo.path)) || null - // The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip it to avoid double-prefixing. + // Models may echo the configured prefix; strip it before adding the prefix ourselves. const slug = stripConfiguredBranchPrefix( generated.slug, getConfiguredBranchPrefix(settings, username) ) - // Prefix-only output strips to empty; renaming would just re-add the prefix (`tmchow/tmchow`), so skip. if (!slug) { return stop('model produced only the configured prefix', true) } const newBranch = await resolveUniqueBranchName( exec, slug, - // Use the non-throwing builder here: the prefix was already validated at - // worktree-create time, and this best-effort background rename has its own - // retry/stop handling, so it must not throw on prefix issues. + // Use the nonthrowing builder so invalid prefixes follow this background task's stop/retry policy. (slugLeaf) => computeBranchName(slugLeaf, settings, username), currentBranch ) if (!newBranch || newBranch === currentBranch) { - // No distinct name is terminal and benign, so clear any stale failure badge from a prior transient attempt. return stop(`no distinct unique branch name for slug "${slug}"`, true) } - // The name lookups above await git; a move may have started since the post-generation check. + // Awaited name lookups can outlive the start of a WSL move. if (isWslMoveRepoBlocked(repo)) { return retry('a WSL move started before the branch rename') } await renameCurrentBranch(exec, newBranch) - // resolveUniqueBranchName may append a collision suffix (`-2`, …), so derive names from the resolved leaf, not the slug. + // Collision suffixes belong in display and folder names too. const newBranchLeaf = newBranch.slice(newBranch.lastIndexOf('/') + 1) - // Only sync the sidebar name when it's still the auto-generated creature name, so a user-typed name is left alone. + // Preserve user-edited display names. const currentDisplayName = deps.getCurrentDisplayName(worktreeId) const newDisplayName = humanizeBranchSlug(newBranchLeaf) const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName) @@ -310,12 +289,10 @@ async function runAutoRename( deps.setDisplayName(worktreeId, newDisplayName) } - // A successful rename clears any stale generation-failure surfaced earlier. deps.setRenameError(worktreeId, null) - // Best-effort local-only folder align, after setDisplayName so the new name rides into the migrated identity; a failure must never undo the landed rename. + // Update display before migrating folder identity; folder failures must preserve the landed branch rename. let folderRenamed = false - // The branch rename landed, but a move now taking the project must not see its folder moved. const folderBlocked = isWslMoveRepoBlocked(repo) if (deps.renameWorktreeFolder && !folderBlocked) { try { @@ -325,14 +302,13 @@ async function runAutoRename( } } - // A folder rename already notified the renderer, so a second onRenamed would just re-list redundantly. + // A folder move already notified the renderer. if (!folderRenamed) { deps.onRenamed(repo.id) } const displayLog = updateDisplay ? `display "${currentDisplayName ?? ''}" -> "${newDisplayName}"` : `display kept ("${currentDisplayName}")` - // The branch already has its new name, so a move that skipped the folder still settles the rename. const folderLog = folderRenamed ? '; folder renamed' : folderBlocked diff --git a/src/main/agent-hooks/first-work-folder-rename.test.ts b/src/main/agent-hooks/first-work-folder-rename.test.ts index 6a3572aa6c6..49daf88b998 100644 --- a/src/main/agent-hooks/first-work-folder-rename.test.ts +++ b/src/main/agent-hooks/first-work-folder-rename.test.ts @@ -51,8 +51,7 @@ describe('renameWorktreeFolderOnFirstWork', () => { OLD_ID, 'repo1::/ws/worktree-creation-spinner' ) - // Main re-keys its stored layout before the window hears of the rename, so the window's - // re-key and save only mirror it (worktree-identity-rename-main-agreement.test.ts). + // Migrate main-process state before the renderer re-keys and saves its layout. expect(vi.mocked(deps.migrateWorktreeIdentity).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(deps.notifyWorktreeRenamed).mock.invocationCallOrder[0]! ) diff --git a/src/main/agent-hooks/first-work-folder-rename.ts b/src/main/agent-hooks/first-work-folder-rename.ts index 01e03755ec7..c0266646b97 100644 --- a/src/main/agent-hooks/first-work-folder-rename.ts +++ b/src/main/agent-hooks/first-work-folder-rename.ts @@ -1,10 +1,3 @@ -// Why: after the first-work branch+display rename, the worktree's on-disk folder -// still carries its creature name (e.g. `cunner`), which is confusing once the -// branch reads `worktree-creation-spinner`. This module aligns the folder with -// the new branch leaf via `git worktree move`, then migrates Orca's path-derived -// worktree identity so meta, tabs, and the live PTY session carry over. It is -// best-effort and local-only — remote/Windows/locked/dest-taken all degrade to -// "folder kept" without disturbing the rename that already succeeded. import type { Repo } from '../../shared/repo-types' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, @@ -19,31 +12,21 @@ import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-id export type FirstWorkFolderRenameDeps = { getRepo: (repoId: string) => Repo | undefined getSettings: () => Parameters[0]['settings'] - /** Re-key all worktreeId-keyed state from the old (path-derived) id to the new. */ migrateWorktreeIdentity: (target: HostWorktreeTarget, newWorktreeId: string) => void - /** Invalidate caches + tell the renderer the worktree's id changed (old->new) so - * it re-keys its state instead of treating the rename as a deletion. */ + /** Re-key renderer state so a folder move is not treated as deletion. */ notifyWorktreeRenamed: (repoId: string, oldWorktreeId: string, newWorktreeId: string) => void - /** True when the path already exists — git worktree move refuses a taken dest. */ pathExists: (path: string) => Promise moveWorktree: (repoPath: string, oldPath: string, newPath: string) => Promise } -/** - * Rename a worktree's folder to match its work-derived branch leaf. Returns true - * only when the folder was actually moved; false (folder kept) for every skip or - * graceful-degrade case. Throws only on an unexpected git failure mid-move — the - * caller swallows it so the branch/display rename is never undone. - */ +/** False keeps the folder; callers retain the successful branch rename even if this move fails. */ export async function renameWorktreeFolderOnFirstWork( worktreeId: string, newLeaf: string, deps: FirstWorkFolderRenameDeps ): Promise { const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId)) - // Why: oldWorktreePath feeds an on-disk folder move. Resolve the synthetic - // `::workspace:` suffix to the backing folder; identity is migrated - // separately via the untouched worktreeId. + // Strip synthetic workspace suffixes from filesystem paths without losing the identity suffix. const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!repo || !parsed) { return false @@ -67,8 +50,7 @@ export async function renameWorktreeFolderOnFirstWork( return false } await deps.moveWorktree(repo.path, plan.oldPath, plan.newPath) - // Order: move first (point of no return), then re-key identity synchronously so - // nothing interleaves before the worktree's state is re-bound to the new id. + // After the move, migrate identity synchronously before notifying the renderer. deps.migrateWorktreeIdentity(hostWorktreeTargetForRepo(worktreeId, repo), plan.newWorktreeId) deps.notifyWorktreeRenamed(repo.id, worktreeId, plan.newWorktreeId) return true diff --git a/src/main/agent-hooks/first-work-generation-target.ts b/src/main/agent-hooks/first-work-generation-target.ts index 8b0f112331f..0409165fda2 100644 --- a/src/main/agent-hooks/first-work-generation-target.ts +++ b/src/main/agent-hooks/first-work-generation-target.ts @@ -4,7 +4,6 @@ import { type CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' -/** The local agent env the generation runs in; null when that env can't be prepared. */ export async function resolveGenerationTarget( worktreePath: string, agentId: string, diff --git a/src/main/agent-hooks/first-work-rename-runtime.ts b/src/main/agent-hooks/first-work-rename-runtime.ts index 5e3ebbe1d16..af63dd94757 100644 --- a/src/main/agent-hooks/first-work-rename-runtime.ts +++ b/src/main/agent-hooks/first-work-rename-runtime.ts @@ -64,7 +64,7 @@ export function firstWorkRenameDeps( }, canRenameOrcaCreatedBranch: (worktreeId) => { const meta = store.getWorktreeMeta(worktreeId) - // Why: a user branch could coincidentally match a creature name; only Orca-stamped worktrees are safe to auto-rename. + // A creature-like name alone cannot prove Orca owns the branch. return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true }, setDisplayName: (worktreeId, displayName) => { @@ -85,11 +85,9 @@ export function firstWorkRenameDeps( } store.setWorktreeMeta(target, { displayName, - // The first-agent title is an intentional user-facing label; keep it stable after the - // generated branch is renamed and across subsequent catalog refreshes. + // Pin the generated title across branch renames and catalog refreshes. displayNameIsPinned: true, pendingFirstAgentMessageRename: false, - // Success clears the failure badge (redundant with the explicit setRenameError(null)). firstAgentMessageRenameError: null }) }, @@ -107,9 +105,8 @@ export function firstWorkRenameDeps( }) : undefined, setRenameError: (worktreeId, error, failureOutput) => { - // Refresh the full-output capture before the dedupe below — a repeat error string is still a fresh run. + // Refresh diagnostics before deduping; repeated text can come from a new run. rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) - // Skip the write + push when unchanged — most settled worktrees never had an error to clear. const scope = parseWorkspaceKey(worktreeId) if (scope?.type === 'folder') { const current = store.getFolderWorkspace!( @@ -133,7 +130,6 @@ export function firstWorkRenameDeps( return } store.setWorktreeMeta(target, { firstAgentMessageRenameError: error }) - // Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects. runtime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) }, resolveWorktreeIdForTab: (tabId) => store.getWorktreeIdForTab!(tabId), diff --git a/src/main/agent-hooks/first-work-structured-session-rename.ts b/src/main/agent-hooks/first-work-structured-session-rename.ts index 637dc1e9c3c..7384ba27ca9 100644 --- a/src/main/agent-hooks/first-work-structured-session-rename.ts +++ b/src/main/agent-hooks/first-work-structured-session-rename.ts @@ -14,7 +14,7 @@ export function maybeAutoRenameWorkspaceOnFirstStructuredTurn( } return maybeAutoRenameBranchOnFirstWork( { - // No pane: a structured session is resolved by its workspace id, not by a terminal tab. + // Structured sessions use workspace identity rather than a terminal pane. paneKey: '', tabId: undefined, worktreeId: summary.workspaceId, diff --git a/src/main/agent-hooks/first-work-workspace-title-rename.ts b/src/main/agent-hooks/first-work-workspace-title-rename.ts index da9b4ab3eb5..877191fbe3a 100644 --- a/src/main/agent-hooks/first-work-workspace-title-rename.ts +++ b/src/main/agent-hooks/first-work-workspace-title-rename.ts @@ -6,11 +6,6 @@ import { import { resolveGenerationTarget } from './first-work-generation-target' import type { FirstWorkBranchRenameDeps } from './first-work-branch-rename' -/** - * Non-git folder workspaces have no branch to rename; the first-work hook - * instead generates a workspace title. Shares the branch-name generation - * machinery and the caller's stop/retry verdict semantics. - */ export async function runFolderWorkspaceTitleAutoRename( worktreeId: string, prompt: string, @@ -19,7 +14,6 @@ export async function runFolderWorkspaceTitleAutoRename( stop: (reason: string, clearError?: boolean) => true, retry: (reason: string) => false ): Promise { - // Retry keeps the title pending until the move settles. if (deps.isFolderWorkspaceWslMoveBlocked?.(worktreeId) === true) { return retry('a WSL move is taking the workspace') } diff --git a/src/main/agent-hooks/grok-replay-guard.ts b/src/main/agent-hooks/grok-replay-guard.ts index 6309ed6e584..f387e1b080e 100644 --- a/src/main/agent-hooks/grok-replay-guard.ts +++ b/src/main/agent-hooks/grok-replay-guard.ts @@ -2,7 +2,7 @@ import { WINDOWS_HOOK_STDIN_DRAIN_LABEL } from './hook-stdin-contract' export function buildPosixGrokReplayGuardLines(): string[] { return [ - // Why: Grok imports vendor hooks; only its native hook may report the event as Grok. + // Imported vendor hooks must not duplicate Grok's native hook event. 'if [ -n "$GROK_HOOK_EVENT" ]; then', ' exit 0', 'fi' diff --git a/src/main/agent-hooks/hook-config-write-path.ts b/src/main/agent-hooks/hook-config-write-path.ts index 9feb348c8b1..a60275b11c6 100644 --- a/src/main/agent-hooks/hook-config-write-path.ts +++ b/src/main/agent-hooks/hook-config-write-path.ts @@ -11,8 +11,7 @@ export function resolveHooksJsonWritePath(configPath: string): string { return configPath } if (isSymlink) { - // Why: atomic rename on the link path disconnects dotfiles-managed hook - // configs. A dangling link must fail closed rather than be replaced. + // Resolve symlinks before atomic replacement; dangling links must fail closed. return realpathSync.native(configPath) } return configPath diff --git a/src/main/agent-hooks/hook-process-capture.test.ts b/src/main/agent-hooks/hook-process-capture.test.ts index caf801059ca..f8b3dd93c62 100644 --- a/src/main/agent-hooks/hook-process-capture.test.ts +++ b/src/main/agent-hooks/hook-process-capture.test.ts @@ -30,7 +30,7 @@ describe('Claude outer hook process capture', () => { `HOME=${home}`, 'PATH=/usr/bin:/bin', 'ORCA_PANE_KEY=presence-test-pane', - // Why: the agent's shell zone may differ from Orca's; the identity must not. + // Agent timezone differences must not change process identity. ...(timeZone ? [`TZ=${timeZone}`] : []), '/bin/sh', '-c', diff --git a/src/main/agent-hooks/hook-process-capture.ts b/src/main/agent-hooks/hook-process-capture.ts index 0e68c51fed1..5091f23642d 100644 --- a/src/main/agent-hooks/hook-process-capture.ts +++ b/src/main/agent-hooks/hook-process-capture.ts @@ -5,7 +5,6 @@ export const CLAUDE_HOOK_PARENT_CAPTURE = export function buildHookProcessCapture(): string[] { return [ 'orca_agent_process=', - // Why: skip the process reads for Claude sessions outside an Orca pane. '[ -z "${ORCA_PANE_KEY:-}" ] || case "${ORCA_HOOK_AGENT_PID:-}" in ""|*[!0-9]*) ;; *)', ' if [ -r "/proc/$ORCA_HOOK_AGENT_PID/stat" ]; then', ' orca_agent_stat=$(cat "/proc/$ORCA_HOOK_AGENT_PID/stat" 2>/dev/null) || orca_agent_stat=', @@ -15,7 +14,7 @@ export function buildHookProcessCapture(): string[] { ' case "$orca_agent_start" in ""|*[!0-9]*) ;; *)', ' [ -z "$orca_agent_boot" ] || orca_agent_process=$(printf \'{"pid":%s,"platform":"linux","startTime":"%s:%s"}\' "$ORCA_HOOK_AGENT_PID" "$orca_agent_boot" "$orca_agent_start") ;; esac', ' elif [ "$(uname -s 2>/dev/null)" = Darwin ]; then', - // Why: lstart is local time; the host probe pins the same zone so the strings compare. + // Match the host probe's timezone when comparing local-time lstart strings. ' orca_agent_start=$(TZ=UTC0 LC_ALL=C /bin/ps -p "$ORCA_HOOK_AGENT_PID" -o lstart= 2>/dev/null | sed \'s/^ *//;s/ *$//\')', ' [ -z "$orca_agent_start" ] || orca_agent_process=$(printf \'{"pid":%s,"platform":"darwin","startTime":"%s"}\' "$ORCA_HOOK_AGENT_PID" "$orca_agent_start")', ' fi ;;', diff --git a/src/main/agent-hooks/hook-script-outside-orca.test.ts b/src/main/agent-hooks/hook-script-outside-orca.test.ts index 5e9aedda29b..b6b2fe9b85c 100644 --- a/src/main/agent-hooks/hook-script-outside-orca.test.ts +++ b/src/main/agent-hooks/hook-script-outside-orca.test.ts @@ -28,8 +28,7 @@ afterEach(() => { } }) -/** Managed hooks are installed into the user's agent config, so they also run when the - * agent is launched from a plain terminal. There they must be inert and silent. */ +/** User-global hooks also run outside Orca, where they must stay inert and silent. */ function runHook(dir: string, extraEnv: NodeJS.ProcessEnv = {}) { const script = join(dir, 'codex-hook.sh') writeFileSync(script, getManagedScript('posix')) diff --git a/src/main/agent-hooks/hook-status-session-tabs-republish.ts b/src/main/agent-hooks/hook-status-session-tabs-republish.ts index b53e4e90501..460aae88aa3 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-republish.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-republish.ts @@ -9,14 +9,7 @@ type SessionTabsRepublisher = { type StatusStore = Pick -/** - * Republish `session.tabs` whenever a pane's status row changes. - * - * Every producer — hook posts, the relay receivers, and main's own OSC parse — lands in the - * store, so this is the one signal that a pane's published projection is out of date. Nothing - * else republishes on a status-only transition, so a paired client would otherwise keep the - * pane's last projection until an unrelated PTY touch came along (#7970). - */ +/** Status-only changes need session.tabs republishing even without PTY activity. */ export function installHookStatusSessionTabsRepublish( statusStore: StatusStore, getRuntime: () => SessionTabsRepublisher | null | undefined diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index 4892fc088a5..1c881843d1f 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -1,31 +1,15 @@ export type PosixHookEmptyPayloadPolicy = 'exit' | 'empty-object' -// Why: a stripped PATH must not stop a hook from consuming stdin, or the agent -// sees exit 127 and a broken pipe mid-write (#8110). `command -p` resolves from -// the shell's built-in default PATH, so it also survives hosts without /bin/cat -// (NixOS) and ignores a worktree-local `cat` that could capture the payload. +// Use the default PATH to drain stdin on stripped-PATH/NixOS hosts and avoid repo-local cat. export const POSIX_HOOK_STDIN_READER = '{ command -p cat 2>/dev/null || cat; }' export const POSIX_HOOK_STDIN_DRAIN_COMMAND = `${POSIX_HOOK_STDIN_READER} >/dev/null 2>&1 || :` -/** Seconds the JSON reader waits for the writer's first byte before giving up. - * Comfortably inside Grok's 10s hook timeout, and far enough above process - * startup that a loaded or remote host cannot lose a payload that is merely late. */ +/** Leave startup headroom within Grok's 10-second hook budget. */ export const POSIX_HOOK_JSON_STDIN_FIRST_BYTE_TIMEOUT_SECONDS = 5 -/** Seconds of silence that end a payload which never parses as JSON (the `cat` shape). */ +/** Stop stalled input that never forms complete JSON. */ export const POSIX_HOOK_JSON_STDIN_IDLE_TIMEOUT_SECONDS = 1.5 -// Why: Grok SessionStart writes one JSON object and then waits for the hook to -// exit without closing stdin, so reading to EOF deadlocks until Grok's 10s -// timeout. Return as soon as the first complete JSON value has arrived. -// -// Three invariants this script must hold, because the shell chains a second -// reader behind it and a reader that consumed bytes cannot be retried: -// 1. A non-zero exit implies stdin was never read, so the `||` fallback still -// sees the whole stream. Everything after the imports is therefore guarded. -// 2. Decoding is incremental. A multi-byte character straddling two reads must -// not raise, or a CJK/emoji payload falls through to `cat` and hangs. -// 3. The payload is emitted unchanged. Re-serialising would rewrite non-ASCII -// as \uXXXX and reorder keys behind the agent's back. +// Grok keeps stdin open: return complete JSON unchanged, decode incrementally, and fail only before reading. const POSIX_HOOK_JSON_STDIN_PYTHON = [ 'import codecs, json, os, select', 'text = ""', @@ -42,8 +26,7 @@ const POSIX_HOOK_JSON_STDIN_PYTHON = [ ' break', ` timeout = ${POSIX_HOOK_JSON_STDIN_IDLE_TIMEOUT_SECONDS}`, ' text += decoder.decode(chunk)', - // raw_decode does not skip leading whitespace, so a padded payload would - // otherwise never complete and would wait out the idle timeout. + // raw_decode rejects leading whitespace. ' value = text.lstrip()', ' if not value:', ' continue', @@ -56,8 +39,7 @@ const POSIX_HOOK_JSON_STDIN_PYTHON = [ 'except Exception:', ' pass', 'try:', - // os.write skips the locale-dependent stdout encoder, which raises under - // LC_ALL=C for a non-ASCII payload. + // Write bytes to bypass the locale-dependent stdout encoder. ' data = text.encode("utf-8")', ' written = 0', ' while written < len(data):', @@ -66,9 +48,7 @@ const POSIX_HOOK_JSON_STDIN_PYTHON = [ ' pass' ].join('\n') -// Why a variable rather than two inline copies: the script is embedded twice in -// the reader chain, and `-c '<600 chars>'` twice is an EDR oversized-command-line -// signal as well as unreadable in the generated hook. +// Embed the script once to avoid oversized command lines flagged by EDR. const POSIX_HOOK_JSON_STDIN_PYTHON_VAR = 'orca_hook_json_stdin_py' export const POSIX_HOOK_JSON_STDIN_PRELUDE: readonly string[] = [ `${POSIX_HOOK_JSON_STDIN_PYTHON_VAR}='${POSIX_HOOK_JSON_STDIN_PYTHON}'` @@ -77,20 +57,13 @@ export const POSIX_HOOK_JSON_STDIN_PRELUDE: readonly string[] = [ const jsonStdinInterpreter = (name: string): string => `command -p ${name} -c "$${POSIX_HOOK_JSON_STDIN_PYTHON_VAR}" 2>/dev/null` -// Why: macOS ships /usr/bin/python3 as an Xcode stub that re-resolves the real -// interpreter on every run when it cannot reach its cache under $HOME. A HOME -// that does not exist costs ~6.6s per spawn there, which alone overruns Grok's -// 10s hook budget. Unsetting it brings that back to ~95ms and is what a -// home-less process sees anyway. Safe to mutate: the reader only ever runs -// inside the `payload=$(...)` subshell, so the hook's own HOME is untouched. +// An invalid HOME stalls macOS's Python stub; unset it only inside the payload-capture subshell. const POSIX_HOOK_JSON_STDIN_HOME_GUARD = '{ [ -d "${HOME:-}" ] || unset HOME; }' -// Why `python` too: the script avoids py3-only syntax (verified on 2.7) so a host -// that only ships `python` does not drop straight to the `cat` hang. +// Python 2.7 compatibility avoids falling back to a hanging cat on older hosts. export const POSIX_HOOK_JSON_STDIN_READER = `${POSIX_HOOK_JSON_STDIN_HOME_GUARD}; ${jsonStdinInterpreter('python3')} || ${jsonStdinInterpreter('python')} || ${POSIX_HOOK_STDIN_READER}` -/** Optional reader override for an agent whose caller keeps stdin open after the payload. - * `prelude` must be emitted before the capture line; keep them together. */ +/** Emit prelude before capture when overriding the reader for callers that keep stdin open. */ export type PosixHookStdinReader = { readonly reader: string readonly prelude: readonly string[] @@ -101,8 +74,7 @@ export const POSIX_HOOK_JSON_STDIN: PosixHookStdinReader = { prelude: POSIX_HOOK_JSON_STDIN_PRELUDE } -// Why: every POSIX hook must own stdin before any no-op exit; sharing this -// prelude prevents agent templates from inventing different drain semantics. +// POSIX hooks must drain stdin before any no-op exit to prevent agent-side broken pipes. export function buildPosixHookPayloadCapture( emptyPayloadPolicy: PosixHookEmptyPayloadPolicy = 'exit', stdinReader: PosixHookStdinReader = { reader: POSIX_HOOK_STDIN_READER, prelude: [] } @@ -118,12 +90,9 @@ export function buildPosixHookPayloadCapture( ] } -/** Shell-side durable fallback shared by every POSIX managed hook. - * `eventNameVar` is for providers that send the event name out-of-band rather than in the - * payload JSON; without it both the progress filter and replay would miss the event name. */ +/** Providers with out-of-band event names need eventNameVar for spool filtering and replay. */ export function buildPosixHookSpoolLines(source: string, eventNameVar?: string): string[] { - // Why: the event name must be a printf ARG, not inlined in the single-quoted format, - // where a command substitution would be emitted literally. + // Pass substitutions as printf arguments; single-quoted formats would emit them literally. const eventFormat = eventNameVar ? '"hookEventName":"%s",' : '' const eventArg = eventNameVar ? ` "$(spool_json_escape "\${${eventNameVar}:-}")"` : '' const spoolRecordLine = " { printf '\\n{".concat( @@ -140,10 +109,9 @@ export function buildPosixHookSpoolLines(source: string, eventNameVar?: string): ? ` case "\${${eventNameVar}:-}" in PreToolUse|PostToolUse|PostToolUseFailure) return 0 ;; esac` : ' case "$payload" in *\'"PreToolUse"\'*|*\'"PostToolUse"\'*|*\'"PostToolUseFailure"\'*) return 0 ;; esac', ' [ -n "${ORCA_AGENT_HOOK_ENDPOINT:-}" ] || return 0', - // Why: an endpoint can linger in a parent shell after leaving Orca; without a pane key - // the record is un-attributable and would accumulate as pane-unknown.jsonl. + // Stale endpoint variables outside Orca must not accumulate unattributed spool records. ' [ -n "${ORCA_PANE_KEY:-}" ] || return 0', - // Why: a stale env var must not create a spool tree for an Orca that is not installed here. + // A stale variable must not create spool state for an absent Orca installation. ' [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ] || return 0', ' spool_base=${ORCA_AGENT_HOOK_ENDPOINT%/*}', ' spool_dir="$spool_base/spool"', @@ -165,39 +133,27 @@ export function buildPosixHookSpoolLines(source: string, eventNameVar?: string): } export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin' -// Why: qualify the stdin reader because Windows searches the worktree for -// executables before PATH and hook payloads must not reach repo-local code. +// Windows searches the worktree before PATH; never give hook payloads to a repo-local executable. export const WINDOWS_HOOK_STDIN_READER = '"%SystemRoot%\\System32\\more.com"' export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = `${WINDOWS_HOOK_STDIN_READER} >nul 2>nul` -// The Orca context a hook needs before it may own stdin; see the rule below. const WINDOWS_HOOK_ENVIRONMENT_VARS = [ 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_PANE_KEY' ] as const -// Why (#11549): missing Orca context means the hook ran outside an Orca pane, where the caller -// may abandon stdin rather than close it — a read-to-EOF then blocks forever and strands a -// visible window per hook event. The Windows rule: a hook must check the Orca env before it -// owns stdin, and exit without reading when the env is missing — the payload is discarded on -// that path anyway. This applies to .cmd and Git Bash hooks alike, and to the -// PowerShell launchers that own stdin themselves when the managed script is missing. -// POSIX hooks keep capture-first: their callers close stdin, and exiting mid-write there -// surfaces as EPIPE the agent can see (#8110). +// Windows callers may leave stdin open outside Orca: guard first; POSIX must capture first to avoid EPIPE. export function buildWindowsHookEnvironmentGuardLines(): string[] { return WINDOWS_HOOK_ENVIRONMENT_VARS.map((name) => `if "%${name}%"=="" exit /b 0`) } -/** The same guard in sh, for the Git Bash hooks and launchers that run on Windows. - * Default-formed because a static hook precheck (Grok) rejects a bare reference it - * cannot resolve. POSIX hosts keep capture-first — this is the Windows rule only. */ +/** Git Bash on Windows needs default-form variables to pass Grok's static precheck. */ export const WINDOWS_GIT_BASH_HOOK_ENVIRONMENT_GUARD = `if ${WINDOWS_HOOK_ENVIRONMENT_VARS.map( (name) => `[ -z "\${${name}-}" ]` ).join(' || ')}; then exit 0; fi` -/** The same guard for a PowerShell hook or launcher. Anything that reaches - * `[Console]::In.ReadToEnd()` must run this first, or it inherits #11549. */ +/** Check Orca context before any PowerShell ReadToEnd to avoid hanging on abandoned stdin. */ export const WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD = `if (${WINDOWS_HOOK_ENVIRONMENT_VARS.map( (name) => `-not $env:${name}` ).join(' -or ')}) { exit 0 }` diff --git a/src/main/agent-hooks/hooks-json-read.ts b/src/main/agent-hooks/hooks-json-read.ts index 97ca220c354..066dbe53f3e 100644 --- a/src/main/agent-hooks/hooks-json-read.ts +++ b/src/main/agent-hooks/hooks-json-read.ts @@ -13,7 +13,7 @@ export type HooksJsonSnapshot = { } export function parseHooksJsonText(raw: string): HooksConfig | null { - // Why: JSON.parse rejects a decoded UTF-8 BOM; strip only the leading marker. + // JSON.parse rejects a decoded UTF-8 BOM. const content = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw try { const parsed = JSON.parse(content) @@ -23,15 +23,9 @@ export function parseHooksJsonText(raw: string): HooksConfig | null { } } -// Why: generation guards abort a mutation when the file no longer matches the -// bytes it was derived from; the raw snapshot and the parse must come from one -// read or a concurrent save can slip between them unnoticed. +// Generation guards need raw bytes and parsed config from the same read to detect concurrent saves. export function readHooksJsonWithRaw(configPath: string): HooksJsonSnapshot { - // Why: the read arm below already separates "no hooks configured" from "could - // not read", but the `existsSync` arm in front of it did not — it returned a - // VALID EMPTY config for a file that merely could not be opened, and the - // installer then wrote generated hooks over it. One read classifies both, and - // closes the TOCTOU window between the two calls. + // Only confirmed absence permits an empty config; unreadable files must not be overwritten. try { const raw = readFileSync(configPath, 'utf-8') return { raw, config: parseHooksJsonText(raw) } diff --git a/src/main/agent-hooks/installer-utils-remote.test.ts b/src/main/agent-hooks/installer-utils-remote.test.ts index 7d1c947ac8e..916848abcb3 100644 --- a/src/main/agent-hooks/installer-utils-remote.test.ts +++ b/src/main/agent-hooks/installer-utils-remote.test.ts @@ -213,7 +213,6 @@ describe('installer-utils-remote', () => { const contents = fs.files.get('/home/u/.claude/settings.json')! const parsed = JSON.parse(contents) expect(parsed.hooks.Stop[0].hooks[0].command).toBe('foo') - // Tmp must be cleaned up. const tmp = Array.from(fs.files.keys()).find((k) => k.includes('.tmp')) expect(tmp).toBeUndefined() expect(fs.modes.get('/home/u/.claude/settings.json')).toBe(0o600) diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts index a67c013b4b8..e438b2b79e3 100644 --- a/src/main/agent-hooks/installer-utils-remote.ts +++ b/src/main/agent-hooks/installer-utils-remote.ts @@ -1,13 +1,4 @@ -// Why: SFTP-backed equivalents of `installer-utils.ts` for the remote-install -// flow. Each function takes an `sftp` handle plus paths the agent CLI expects -// on the remote (e.g. `~/.claude/settings.json`). Lives in `agent-hooks/` -// because it shares the contract with the local installer (script body, -// hook-event shape, atomic-rename semantics) and any drift between them is -// exactly the bug we want to avoid. -// -// We deliberately keep the JSON merge logic in the existing -// `installer-utils.ts` and only swap fs primitives — the JSON shape and -// managed-command matching must stay identical to the local install. +// Share parsing and merge rules with local installation; replace only filesystem operations. import { randomUUID } from 'node:crypto' import type { SFTPWrapper, FileEntryWithStats } from 'ssh2' @@ -18,12 +9,7 @@ import { parseHooksJsonText } from './hooks-json-read' const DEFAULT_REMOTE_CONFIG_MODE = 0o600 const REMOTE_SFTP_OPERATION_TIMEOUT_MS = 10_000 -/** Read+JSON-parse a remote file. Returns `null` on parse failure (caller - * surfaces "could not parse" status to the UI), `{}` on missing file - * (matches local behavior — first-install case). Rethrows on other I/O - * failures (permission denied, EIO, channel closed) so the caller can - * distinguish transient SFTP errors from a malformed-JSON case rather - * than collapsing both into a misleading "could not parse" diagnostic. */ +/** Null means invalid JSON, {} means missing; propagate other I/O failures. */ export async function readHooksJsonRemote( sftp: SFTPWrapper, remotePath: string @@ -40,34 +26,27 @@ export async function readHooksJsonRemote( return parseHooksJsonText(body) } -/** Atomically write a JSON config to the remote — write to a tmp path then - * rename, mirroring the local writeHooksJson contract. The .bak rotation is - * intentionally NOT carried over: the remote file is the user's, and a - * per-target backup convention belongs alongside the remote installer UI - * (out of scope for this commit). */ +/** Atomically replace the remote config without rotating local-style backups. */ export async function writeHooksJsonRemote( sftp: SFTPWrapper, remotePath: string, - // Why: mirrors the local writer — the config is only the fallback serialization source. config: Record, - // Why: mirrors the local writer — a JSONC config supplies text edited in place. + // JSONC callers can supply text edited in place. options?: { serialized?: string } ): Promise { const dir = dirnamePosix(remotePath) await mkdirpRemote(sftp, dir) const serialized = options?.serialized ?? `${JSON.stringify(config, null, 2)}\n` - // Why: skip the write when on-disk content is identical so repeated - // install() calls do not bump the file's mtime / inode unnecessarily. + // Preserve mtime and inode on repeated installs. try { const existing = await readFile(sftp, remotePath) if (existing === serialized) { return } } catch { - // ENOENT or read error — fall through to the write below. + // Retry through the write path after a failed read. } - // Why: tmp + rename so a partial network drop mid-write does not leave a - // truncated settings.json that the agent CLI would refuse to load. + // Atomic replacement prevents interrupted writes from truncating live settings. const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp` try { const mode = await getRemoteFileModeOrDefault(sftp, remotePath, DEFAULT_REMOTE_CONFIG_MODE) @@ -75,18 +54,15 @@ export async function writeHooksJsonRemote( await chmod(sftp, tmp, mode) await rename(sftp, tmp, remotePath) } finally { - // Best-effort cleanup if rename failed. try { await unlink(sftp, tmp) } catch { - // already gone or never created + // Cleanup is best effort. } } } -/** Write the managed hook script to the remote and chmod 0o755. POSIX-only: - * the payload is a shell script and every path here is POSIX-joined, so a - * Windows SSH target would need a separate installer, not a tweak to this one. */ +/** POSIX SSH targets only: paths and script payloads follow POSIX conventions. */ export async function writeManagedScriptRemote( sftp: SFTPWrapper, remotePath: string, @@ -101,12 +77,10 @@ export async function writeManagedScriptRemote( return } } catch { - // ENOENT or read error — fall through to the atomic write below. + // Retry through the atomic write path after a failed read. } - // Why: existing configs may already invoke this script. Write/chmod a temp - // file first, then rename it into place so interrupted reinstalls do not - // leave the configured hook path truncated or non-executable. + // Publish only a complete executable script; existing configs may invoke it during reinstall. const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp` try { await writeFile(sftp, tmp, content, 0o755) @@ -116,7 +90,7 @@ export async function writeManagedScriptRemote( try { await unlink(sftp, tmp) } catch { - // already gone or never created + // Cleanup is best effort. } } } @@ -135,7 +109,7 @@ export async function readTextFileRemote( } } -/** Entry names in a remote directory, or null when the directory does not exist. */ +/** Null means the directory is missing; propagate other I/O failures. */ export async function listRemoteDirectory( sftp: SFTPWrapper, remotePath: string @@ -163,7 +137,7 @@ export async function writeTextFileRemoteAtomic( return } } catch { - // ENOENT or read error — fall through to the atomic write below. + // Retry through the atomic write path after a failed read. } const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp` @@ -176,13 +150,11 @@ export async function writeTextFileRemoteAtomic( try { await unlink(sftp, tmp) } catch { - // already gone or never created + // Cleanup is best effort. } } } -// ─── Private SFTP primitives ──────────────────────────────────────── - function sftpOperation( label: string, run: (callback: (err: unknown, value?: T) => void) => void @@ -194,8 +166,7 @@ function sftpOperation( return } settled = true - // Why: remote hook installation must fail open; a wedged SFTP callback - // should degrade hook status, not block SSH workspace startup forever. + // A wedged SFTP callback must not block workspace startup indefinitely. reject(new Error(`Timed out waiting for SFTP ${label}`)) }, REMOTE_SFTP_OPERATION_TIMEOUT_MS) if (typeof timer === 'object' && 'unref' in timer) { @@ -277,10 +248,7 @@ async function rename(sftp: SFTPWrapper, src: string, dst: string): Promise s.length > 0) let current = remotePath.startsWith('/') ? '' : '.' for (const seg of segments) { @@ -337,9 +303,7 @@ async function mkdirpRemote(sftp: SFTPWrapper, remotePath: string): Promise { // .bak should hold v2 (the version before v3) const bak = JSON.parse(readFileSync(`${configPath}.bak`, 'utf-8')) expect(bak).toEqual(v2) - // configPath should hold v3 const current = JSON.parse(readFileSync(configPath, 'utf-8')) expect(current).toEqual(v3) }) @@ -258,8 +257,7 @@ describe('createManagedCommandMatcher', () => { expect(match(undefined)).toBe(false) expect(match('')).toBe(false) expect(match('echo "user-authored hook"')).toBe(false) - // Same filename but not under an agent-hooks/ directory — treat as - // user-authored to avoid stomping on someone else's hook. + // A matching filename outside agent-hooks remains user-owned. expect(match('/bin/sh "/home/alice/scripts/claude-hook.sh"')).toBe(false) }) @@ -268,8 +266,6 @@ describe('createManagedCommandMatcher', () => { }) it('matches the guarded launcher form so wrapped commands sweep correctly', () => { - // Why: older guarded launchers used a single `-x` check. The sweep must - // still recognize them or reinstalling would retain a stale duplicate. expect( match( 'if [ -x "/Users/alice/Library/Application Support/Orca/agent-hooks/claude-hook.sh" ]; then /bin/sh "/Users/alice/Library/Application Support/Orca/agent-hooks/claude-hook.sh"; fi' @@ -283,8 +279,6 @@ describe('createManagedCommandMatcher', () => { }) it('matches the pre-default-form launcher so upgrades replace it instead of duplicating', () => { - // Why: installs before the ${VAR-} conversion emitted bare $HOME/$SYSTEMROOT. The sweep must - // still recognize them, or an upgrade would leave the stale entry beside the new one. expect( match( 'if [ -z "$HOME" ]; then :; else if [ -f "$HOME/.orca/agent-hooks/claude-hook.sh" ]; then /bin/sh "$HOME/.orca/agent-hooks/claude-hook.sh"; fi; fi' @@ -647,9 +641,7 @@ describe('wrapWindowsHookCommand', () => { ) }) - // Why the ordering matters: a gate event reads silence as deny (#2426), and outside an - // Orca pane the guard exits before the read — so an answer placed after the drain never - // reaches the agent at all when the caller abandons the pipe (#11549). + // Gate events read silence as denial, so answer before a missing-context guard can exit. it('answers before it guards, and guards before it owns stdin', () => { const decoded = decodeWindowsHookCommand( wrapWindowsHookCommand( @@ -712,8 +704,7 @@ describe('wrapRuntimeHomeHookCommand', () => { expect(command).not.toMatch(/[A-Z]:[\\/]|\/Users\/|\/home\//) }) - // Why: a static hook precheck (Grok) rejects the whole command on any bare reference it cannot - // resolve, including one in a branch that platform never takes. + // Grok rejects unresolved bare variables even in untaken branches. it.each([ ['default', undefined], ['neutral-json', { neutralJsonWhenMissing: true }] @@ -916,8 +907,6 @@ describe('buildWindowsAgentHookCurlPostCommand', () => { it('posts form fields via curl.exe and reads the payload from stdin', () => { const command = buildWindowsAgentHookCurlPostCommand('codex') - // Why: the fast path must not spawn a second PowerShell; that startup cost - // is the regression this replaces. expect(command).not.toMatch(/powershell/i) expect(command).toContain('%SystemRoot%\\System32\\curl.exe') expect(command).toContain('http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/codex') @@ -928,8 +917,6 @@ describe('buildWindowsAgentHookCurlPostCommand', () => { // Why: `payload@-` makes curl read raw bytes from stdin and urlencode them, // so UTF-8 prompts survive without a code-page conversion. expect(command).toContain('--data-urlencode "payload@-"') - // Why: same dead-listener bound as the POSIX hook so a stalled server can't - // hold up the agent. expect(command).toContain('--connect-timeout 0.5 --max-time 1.5') }) }) diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index 60d38f1c450..8ddaf20a004 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -45,11 +45,11 @@ export type HooksConfig = { [key: string]: unknown } -// Why: host-level backstop timeout for status hooks, independent of the curl --max-time and Copilot's timeoutSec (#4633). +// Host-level backstop independent of curl and provider-specific timeouts. export const MANAGED_HOOK_TIMEOUT_SECONDS = 10 export const MANAGED_HOOK_TIMEOUT_MILLISECONDS = MANAGED_HOOK_TIMEOUT_SECONDS * 1000 -// Nested command hook for the Claude-shaped `hooks: [...]` schema (Claude, Codex, Gemini, Droid, Grok, Command Code, Devin). +// Claude-shaped schemas nest command hooks; Cursor puts command on the definition itself. export function buildManagedCommandHook( command: string, timeout = MANAGED_HOOK_TIMEOUT_SECONDS @@ -57,7 +57,6 @@ export function buildManagedCommandHook( return { type: 'command', command, timeout } } -// Direct command definition for schemas that put `command` on the definition itself (Cursor's top-level shape). export function buildManagedCommandDefinition(command: string): HookDefinition { return { command, timeout: MANAGED_HOOK_TIMEOUT_SECONDS } } @@ -69,19 +68,19 @@ export { type HooksJsonSnapshot } from './hooks-json-read' -// Why: match by script file name, not exact command, so a fresh install sweeps stale entries from old/parallel installs. +// Match script names so reinstalls sweep entries from old and parallel installations. export function createManagedCommandMatcher( scriptFileName: string ): (command: string | undefined) => boolean { const scriptStem = scriptFileName.replace(/\.(?:cmd|ps1|sh)$/, '') - // Why: installs use .cmd/.ps1 (Windows) or .sh (SSH/POSIX); match all so a platform switch still sweeps stale hooks. + // Sweep every platform suffix after a platform switch. const needles = [ `agent-hooks/${scriptFileName}`, `agent-hooks/${scriptStem}.cmd`, `agent-hooks/${scriptStem}.ps1`, `agent-hooks/${scriptStem}.sh` ] - // The shared native hook names its agent in the first argument: `orca-agent-hook.exe claude`. + // The native executable selects its provider through argv[1]. const nativeHook = `agent-hooks/${WINDOWS_NATIVE_HOOK_EXECUTABLE_NAME} ${getWindowsNativeHookAgent(scriptFileName)} ` return (command) => { if (!command) { @@ -109,7 +108,7 @@ function decodePowerShellEncodedCommand(command: string): string | null { } } -// Why: prod/dev/parallel Orca instances must write the same managed entry, not race between per-userData script paths. +// Parallel Orca instances must share one user-wide script path. export function getSharedManagedScriptPath(scriptFileName: string): string { return join(homedir(), '.orca', 'agent-hooks', scriptFileName) } @@ -128,16 +127,12 @@ export { wrapWindowsHookCommand } from './windows-hook-command' -/** - * Extra form lines inserted before the final `payload@-` line (each should end with ` ^`). - * Used by Grok to attach `grokHome` without fragile string replace on the shared template. - */ +/** Extra form lines precede payload@- and must end with ` ^`. */ export function buildWindowsAgentHookPostCommand( source: AgentHookSource, extraFormLines: readonly string[] = [] ): string { - // Why: PowerShell startup makes inline per-turn Codex hooks visibly slow, so mirror the POSIX curl path. - // Why: fully-qualify curl so a repo-local curl.exe can't hijack hook payloads. + // Avoid PowerShell startup; qualify curl to prevent repo-local payload interception. return [ `"%SystemRoot%\\System32\\curl.exe" -sS -X POST "http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%/hook/${source}" ^`, ' --connect-timeout 0.5 --max-time 1.5 ^', @@ -154,7 +149,7 @@ export function buildWindowsAgentHookPostCommand( ].join('\r\n') } -// Why: PowerShell per-post costs ~300ms startup and mangles UTF-8 via code-page translation; curl.exe (Win10 1803+) avoids both. +// curl.exe avoids PowerShell startup and code-page corruption of UTF-8 payloads. export function buildWindowsAgentHookCurlPostCommand(source: AgentHookSource): string { return [ '"%SystemRoot%\\System32\\curl.exe" -sS -X POST', @@ -234,7 +229,7 @@ export function hookDefinitionHasManagedCommand( ) } -// Why: temp+rename so concurrent writers can't leave a torn script for an in-flight /bin/sh to source. +// Atomic replacement protects scripts being read by in-flight hooks. export function writeManagedScript( scriptPath: string, content: string | Buffer, @@ -259,7 +254,7 @@ export function writeManagedScript( const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) try { writeScriptWithAclRetry(tmpPath, content) - // Why: chmod before rename so the canonical path is never visible non-executable, else the POSIX guard skips the hook. + // Publish with execute permission so POSIX guards never skip a newly installed script. if (process.platform !== 'win32') { chmodSync(tmpPath, 0o755) } @@ -275,7 +270,7 @@ export function writeManagedScript( } } -// Why: a restrictive directory DACL makes writes fail with EPERM on Windows; grant an ACL and retry once. +// Retry Windows EPERM once after repairing a restrictive directory ACL. function writeScriptWithAclRetry(scriptPath: string, content: string | Buffer): void { try { writeFileSync(scriptPath, content, 'utf-8') @@ -295,59 +290,49 @@ function writeScriptWithAclRetry(scriptPath: string, content: string | Buffer): export function writeHooksJson( configPath: string, - // Why: only used for the fallback serialization, so any JSON-shaped config qualifies — - // ZCode nests its hook block under `hooks.events`, not Claude's `hooks.`. + // Fallback serialization accepts provider-specific JSON shapes, including nested hooks.events. config: Record, - // Why: `serialized` lets a JSONC config (Devin) supply text edited in place, so the - // atomic write + rolling backup below stay shared instead of being reimplemented. + // JSONC callers supply edited text while retaining atomic writes and backups. options?: { preserveMode?: boolean; defaultMode?: number; serialized?: string } ): void { const writePath = resolveHooksJsonWritePath(configPath) const dir = dirname(writePath) mkdirSync(dir, { recursive: true }) - // Why: temp+rename leaves the original untouched on a crash/disk-full mid-write. - // Why randomUUID: avoids tmp-path collisions when two install() calls fire in the same millisecond. + // Unique temp paths isolate concurrent writers and preserve the original on write failure. const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) const serialized = options?.serialized ?? `${JSON.stringify(config, null, 2)}\n` - // Why preserve by default: a rewrite must not widen an owner-only config (settings.json can - // hold API keys) to the process umask, which on SSH hosts is the login's (#31125). + // Preserve owner-only permissions: agent configs can contain API keys. const existingMode = options?.preserveMode !== false && existsSync(writePath) ? statSync(writePath).mode & 0o777 : options?.defaultMode - // Why: skip the write (and therefore the .bak rotation) when the on-disk - // content is already identical. Without this, every install() rewrites the - // file and rolls the backup forward, which can silently destroy the last - // recoverable copy if install() is called repeatedly (e.g. on app start). + // Identical reinstalls must not rotate away the last recoverable backup. if (existsSync(writePath)) { try { if (readFileSync(writePath, 'utf-8') === serialized) { return } } catch { - // Fall through to the normal write path; a read error isn't worth failing the install for. + // Retry through the write path after a failed read. } } try { - // Why not on Windows: a read-only config's mode would make the temp file undeletable on failure. + // Windows read-only mode could prevent deleting a failed temporary write. const tmpMode = process.platform === 'win32' ? undefined : existingMode writeFileSync(tmpPath, serialized, { encoding: 'utf-8', mode: tmpMode, flag: 'wx' }) if (existingMode !== undefined && process.platform !== 'win32') { // Preserve requested permissions even with a stricter process umask. chmodSync(tmpPath, existingMode) } - // Why: single rolling backup — one file, no accumulation in ~/.claude. - // Protects against a merge-logic bug producing bad JSON; the original is - // always recoverable from .bak until the next write. + // Keep one prior version for recovery from a bad merge. if (existsSync(writePath)) { writeRollingFileBackup(writePath, `${writePath}.bak`) } renameSync(tmpPath, writePath) } finally { - // Clean up temp file if rename failed. if (existsSync(tmpPath)) { try { unlinkSync(tmpPath) diff --git a/src/main/agent-hooks/local-agent-cli-presence.ts b/src/main/agent-hooks/local-agent-cli-presence.ts index b0fb6835487..ed75e9675aa 100644 --- a/src/main/agent-hooks/local-agent-cli-presence.ts +++ b/src/main/agent-hooks/local-agent-cli-presence.ts @@ -146,7 +146,6 @@ async function maybeHydrateShellPath(options: DetectOptions): Promise { } } -/** Merges the login shell's PATH into this process once (cached), as CLI detection does. */ export async function hydrateAgentCliShellPath( hydratePath: () => Promise = hydrateShellPath ): Promise { @@ -156,7 +155,6 @@ export async function hydrateAgentCliShellPath( mergePathSegments(result.segments) } } catch (error) { - // Detection failure must never permit config mutation. console.warn('[agent-hooks] Shell PATH hydration failed; using inherited PATH:', error) } } diff --git a/src/main/agent-hooks/locked-executable-rename.test.ts b/src/main/agent-hooks/locked-executable-rename.test.ts index 8f2d0d300ac..878da7b1853 100644 --- a/src/main/agent-hooks/locked-executable-rename.test.ts +++ b/src/main/agent-hooks/locked-executable-rename.test.ts @@ -5,7 +5,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'no import { tmpdir } from 'node:os' import { join } from 'node:path' -// How many more renames out of each path fail the way Windows refuses a running executable. +// Remaining simulated Windows rename refusals per path. const { refusals } = vi.hoisted(() => ({ refusals: new Map() })) function refuseIfListed(from: string): void { const left = refusals.get(from) ?? 0 diff --git a/src/main/agent-hooks/locked-executable-rename.ts b/src/main/agent-hooks/locked-executable-rename.ts index 291b185d336..e53ded60eed 100644 --- a/src/main/agent-hooks/locked-executable-rename.ts +++ b/src/main/agent-hooks/locked-executable-rename.ts @@ -2,11 +2,7 @@ import { existsSync, renameSync, rmSync } from 'node:fs' import { rename, rm, stat } from 'node:fs/promises' import { isPermissionError } from '../win32-utils' -/** - * Where a copy still running from the previous build is moved so the new file can take its name. - * Windows refuses to replace a running executable but lets it be renamed; the next install deletes - * the aside, and while the aside itself still runs, a further replacement throws. - */ +/** Windows permits renaming a running executable aside so a new build can take its path. */ export function getLockedExecutableAsidePath(targetPath: string): string { return `${targetPath}.old` } @@ -54,7 +50,7 @@ export async function renameOverLockedTargetAsync( try { await rename(tmpPath, targetPath) } catch (placeError) { - // Same rollback as the sync path. + // Restore the registered path if placement fails. const targetExists = await stat(targetPath).then( () => true, () => false diff --git a/src/main/agent-hooks/managed-agent-hook-controls.test.ts b/src/main/agent-hooks/managed-agent-hook-controls.test.ts index bd9dbc2c7e3..de644290891 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.test.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.test.ts @@ -106,8 +106,7 @@ describe('managed agent hook controls', () => { await installManagedAgentHooks({ agentCmdOverrides: {} }) - // Why (#11549 aftermath): the skipped agent's user-wide config still invokes the - // script, so a stale (leaking) script must not be frozen by the presence gate. + // Existing scripts remain referenced by user-wide configs after CLI removal. expect(mocks.refreshClaude).toHaveBeenCalledTimes(1) expect(mocks.refreshCodex).toHaveBeenCalledTimes(1) expect(mocks.installClaude).not.toHaveBeenCalled() @@ -136,9 +135,7 @@ describe('managed agent hook controls', () => { }) const install = installManagedAgentHooks({ agentCmdOverrides: {} }) - // Why waitFor and not a fixed microtask tick: the install path awaits session reconcilers - // before refreshing scripts, so the number of ticks before this point is an implementation - // detail. The assertion below is the real contract: no CLI probe until the refresh resolves. + // Earlier awaits vary; wait for refresh entry before asserting that CLI probing is blocked. await vi.waitFor(() => expect(mocks.refreshClaude).toHaveBeenCalled()) expect(mocks.detect).not.toHaveBeenCalled() @@ -300,8 +297,6 @@ describe('startup managed hook reconciliation (STA-5679)', () => { }) it('skips instead of removing when this profile has the off switch set', () => { - // Why this matters: the hook files are user-global. Startup removal here deleted the hooks that - // every other Orca instance depends on, and Cursor then reads as idle with no status at all. expect(resolveStartupManagedHookAction({ agentStatusHooksEnabled: false })).toBe('skip') }) diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 70a2b9bb24d..04f945bb86b 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -46,11 +46,7 @@ type RemoveOptions = { export type StartupManagedHookAction = 'install' | 'skip' -// Why never 'remove': this reads THIS instance's settings, but the managed hook files are -// user-global (~/.claude/settings.json, ~/.cursor/hooks.json). A second Orca profile with the off -// switch set would delete the hooks every other instance depends on, and Cursor — the one agent -// with no title-derived status fallback — then goes silently idle (STA-5679). Honoring the off -// switch only requires skipping the install; explicit removal stays on the Settings toggle. +// Settings are per profile but hooks are user-global; startup removal would break other instances. export function resolveStartupManagedHookAction( settings: ManagedHookSettings ): StartupManagedHookAction { @@ -117,10 +113,7 @@ async function runInstaller( } } -// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking -// Orca's script, but the presence gate below then skips install() forever, freezing the -// script at whatever Orca generated last. Existing scripts are Orca-owned, so bring them -// current before any gating; creating new ones remains install()'s presence-gated job. +// Refresh owned scripts before CLI presence gating; user-wide configs may still invoke them. async function refreshExistingManagedScripts(options: InstallOptions): Promise { const allowed = options.agents ? new Set(options.agents) : null for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) { diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index a2c4297b3b1..05ede676721 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -23,9 +23,6 @@ import { museHookService } from '../muse/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' import { zcodeHookService } from '../zcode/hook-service' -// Why (#16441): Codex's installer may await a codex app-server session for its -// hook hashes instead of blocking the main thread on spawnSync. Widening the tuple -// keeps the other agent services synchronous — the shared loop already awaits. export type ManagedAgentHookInstallOptions = { userInitiated?: boolean; cliVersion?: string } export type ManagedAgentHookInstaller = readonly [ HookInstallAgent, @@ -70,12 +67,7 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['kiro', () => kiroHookService.install()] ] -// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a -// user-wide agent config keeps invoking after the CLI falls off PATH. Amp and Hermes write -// provider-native plugin code into their own config dirs with their own install lifecycles, -// not shared launchers, so they are deliberately absent. Enforced by the coverage test in -// managed-hook-script-refresh.test.ts: a new installer that writes a launcher without adding -// a refresher here fails that test. +// Refresh shared launchers even after CLI removal; Amp/Hermes manage provider-native plugins separately. export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScriptRefresher[] = [ ['claude', () => claudeHookService.refreshManagedScripts()], ['openclaude', () => openClaudeHookService.refreshManagedScripts()], diff --git a/src/main/agent-hooks/managed-agent-hook-startup.ts b/src/main/agent-hooks/managed-agent-hook-startup.ts index c845bf4bd36..6183e5c0919 100644 --- a/src/main/agent-hooks/managed-agent-hook-startup.ts +++ b/src/main/agent-hooks/managed-agent-hook-startup.ts @@ -7,15 +7,11 @@ import { type StartupHookSettings = NonNullable[0]> -/** Installs or refreshes this host's managed agent hooks once at process start; never removes. */ export function reconcileStartupManagedAgentHooks(host: { getSettings: () => StartupHookSettings isQuitting: () => boolean shouldHydrateShellPath: boolean }): void { - // Why skip rather than remove when the off switch is set: the hook files are user-global but this - // decision reads only THIS profile's settings, so removing here deletes the hooks every other Orca - // instance depends on (STA-5679). Skipping already keeps removed hooks from reappearing on launch. if (resolveStartupManagedHookAction(host.getSettings()) !== 'install') { return } diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index 6fbe86a4489..dcff7494cf2 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -46,7 +46,7 @@ type CommandBuilders = { remote: (scriptPath: string) => string[] } -// Why: Gemini/Droid/Command Code keep their thin builders private; exercise the wrappers they call. +// Private Gemini/Droid/Command Code builders delegate to these wrappers. const standardCommands: CommandBuilders = { local: (path) => [ process.platform === 'win32' ? wrapWindowsHookCommand(path) : wrapPosixHookCommand(path) @@ -170,7 +170,6 @@ const buildersByAgent = new Map([ 'grok', { local: (path) => [getGrokManagedCommand(path)], - // Why: grok-hook-remote-install.ts calls this wrapper directly, with the pane guard. remote: (path) => [wrapPosixHookCommand(path, {}, { requiredEnvVar: 'ORCA_PANE_KEY' })] } ], @@ -220,8 +219,7 @@ const buildersByAgent = new Map([ } ], [ - // Why bare: jcode parses the hook command line shell-style but executes it - // directly, so a `sh -c`/`if [ -f … ]` wrapper would be run as the program name. + // jcode directly executes parsed argv; shell wrappers would become an invalid program name. 'jcode', { local: (path) => [getJcodeManagedCommand(path)], @@ -237,7 +235,6 @@ const buildersByAgent = new Map([ ] ]) -// Why: as in MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS, native plugin source has no shell command to scan. const exemptionsByAgent = new Map([ ['amp', 'Native TypeScript plugin source; no shell hook command'], ['hermes', 'Native Python plugin source; no shell hook command'] @@ -248,7 +245,6 @@ describe('managed hook command contract', () => { ['local', MANAGED_AGENT_HOOK_INSTALLERS.map(([agent]) => agent)], ['remote', REMOTE_MANAGED_HOOK_INSTALLER_AGENTS] ] as const)('covers the %s installer registry in both directions', (_target, agents) => { - // Why: mirror the remote installer coverage ratchet (#7253); a new provider cannot opt out silently. for (const agent of agents) { expect( Number(buildersByAgent.has(agent)) + Number(exemptionsByAgent.has(agent)), diff --git a/src/main/agent-hooks/managed-hook-command-env.test-fixture.ts b/src/main/agent-hooks/managed-hook-command-env.test-fixture.ts index 77c73534c3f..0fc2d12b703 100644 --- a/src/main/agent-hooks/managed-hook-command-env.test-fixture.ts +++ b/src/main/agent-hooks/managed-hook-command-env.test-fixture.ts @@ -9,8 +9,7 @@ export const GROK_PROVIDED_HOOK_VARIABLES = [ const providedVariables = new Set(GROK_PROVIDED_HOOK_VARIABLES) export function findBareHookCommandVariables(command: string): string[] { - // Why: Grok scans dollar bytes without shell quoting state, even inside single quotes. - // These are the two runtime-home assertions from installer-utils.test.ts, shared across builders. + // Grok scans dollar bytes even inside single quotes. const references = [ ...command.matchAll(/\$(?!\{)([A-Za-z_][A-Za-z0-9_]*)/g), ...command.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g) diff --git a/src/main/agent-hooks/managed-hook-install-lock.ts b/src/main/agent-hooks/managed-hook-install-lock.ts index 41dd21f9bda..611dcc70081 100644 --- a/src/main/agent-hooks/managed-hook-install-lock.ts +++ b/src/main/agent-hooks/managed-hook-install-lock.ts @@ -75,12 +75,11 @@ async function acquireInstallLock( const state = await inspectManagedHookLock(lockPath) if (state.kind === 'unknown') { - // Why: unverifiable legacy locks cannot be stolen safely; hooks are best-effort, - // so fail fast instead of adding a recurring connection timeout. + // Never steal unverifiable legacy locks; fail fast rather than delay best-effort installation. throw new Error('Managed-hook install lock has an unverifiable owner') } if (state.kind === 'owned' && state.owner.hostIdentity !== hostIdentity) { - // Why: homes can be shared across SSH hosts, whose PID namespaces are unrelated. + // Shared homes can span unrelated host PID namespaces. throw new Error('Managed-hook install lock belongs to another host') } diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.test-fixture.ts b/src/main/agent-hooks/managed-hook-local-filesystem.test-fixture.ts index b19972b54cd..6bc58c7c3db 100644 --- a/src/main/agent-hooks/managed-hook-local-filesystem.test-fixture.ts +++ b/src/main/agent-hooks/managed-hook-local-filesystem.test-fixture.ts @@ -18,7 +18,7 @@ function finish(callback: Callback, error: NodeJS.ErrnoException | null, v callback(null, value) } -/** Runs the managed installers' callback-style SFTP surface against the local disk in tests. */ +/** Adapt the installers' callback-style SFTP operations to local disk for tests. */ export function createManagedHookLocalFilesystem(): SFTPWrapper { const adapter = { readFile(path: string, _encoding: string, callback: Callback): void { @@ -36,8 +36,7 @@ export function createManagedHookLocalFilesystem(): SFTPWrapper { stat(path, (error, stats) => finish(callback, error, stats)) }, readdir(path: string, callback: Callback<{ filename: string }[]>): void { - // Why names only: Kiro lists its agent configs by name; attrs have no consumer, - // matching the WSL adapter's `{ filename }` entries. + // Kiro needs names only, matching the WSL adapter. readdir(path, (error, names) => finish( callback, diff --git a/src/main/agent-hooks/managed-hook-lock-claims.ts b/src/main/agent-hooks/managed-hook-lock-claims.ts index d3299c7bfd8..9115b5154b5 100644 --- a/src/main/agent-hooks/managed-hook-lock-claims.ts +++ b/src/main/agent-hooks/managed-hook-lock-claims.ts @@ -130,7 +130,7 @@ async function claimManagedHookLock( }) try { try { - // Why: renaming the witness elects one claimant without ever dropping the hard link. + // Rename elects one claimant while preserving the hard-link witness. await rename(source.sourcePath, claimedOwnerPath) } catch (error) { if (hasCode(error, 'ENOENT')) { @@ -207,8 +207,7 @@ export async function removeManagedHookLock( try { await cleanAcquiredClaim(claimed) } catch (error) { - // Why: the canonical lock is already gone; residue cleanup must not - // turn a successful release into a failed installation. + // Residue cleanup must not turn a completed release into installation failure. console.warn('[agent-hooks] Failed to clean managed-hook recovery claim', error) } } diff --git a/src/main/agent-hooks/managed-hook-lock-records.ts b/src/main/agent-hooks/managed-hook-lock-records.ts index 555c2a1a521..060e65a038a 100644 --- a/src/main/agent-hooks/managed-hook-lock-records.ts +++ b/src/main/agent-hooks/managed-hook-lock-records.ts @@ -135,7 +135,7 @@ export async function inspectManagedHookLock(lockPath: string): Promise { const draftPath = join(directory, `host-id-draft-${token}`) await writeFile(draftPath, token, { encoding: 'utf8', flag: 'wx', mode: 0o600 }) try { - // Why: the hard link publishes a complete token atomically across relay processes. + // Publish a complete token atomically across relay processes. await link(draftPath, tokenPath).catch((error) => { if (!hasCode(error, 'EEXIST')) { throw error @@ -111,8 +111,7 @@ async function readHostIdentity(): Promise { return `host-token:${durableToken}` } if (process.platform === 'linux') { - // Why: an unverifiable host scope may acquire and release its own clean lock, - // but a later process gets a different identity and cannot steal its residue. + // A process-local identity can release its own lock but prevents later processes stealing its residue. return runtimeHostIdentity } if (process.platform === 'win32') { @@ -255,8 +254,7 @@ async function readProcessIdentity(pid: number): Promise ({ homedirMock: vi.fn<() => string>() })) -// Why: stands in for asking a real Codex for its hook hashes, as the grant stub once did. vi.mock('../codex/codex-hook-hash-lookup', async (importOriginal) => ({ ...(await importOriginal()), resolveCodexHookAnswerForLaunch: async () => @@ -125,8 +121,7 @@ describe('managed hook script refresh', () => { const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-')) homedirMock.mockReturnValue(home) try { - // Why: the bug population has a script (from a past install) but no reachable - // CLI — and possibly no config dir Orca may create. Seed only the script. + // Simulate a prior install whose CLI and provider config directory are now absent. const hooksDir = join(home, '.orca', 'agent-hooks') mkdirSync(hooksDir, { recursive: true }) writeFileSync(join(hooksDir, 'claude-hook.cmd'), STALE_WINDOWS_HOOK) @@ -136,9 +131,7 @@ describe('managed hook script refresh', () => { const refreshed = readFileSync(join(hooksDir, 'claude-hook.cmd'), 'utf8') expect(refreshed).toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0') expect(refreshed).not.toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" goto') - // Why: refresh must not resurrect config for a CLI the user may have removed. expect(existsSync(join(home, '.claude'))).toBe(false) - // Why: the statusline script was never installed here, so it must not appear. expect(existsSync(join(hooksDir, 'claude-statusline.cmd'))).toBe(false) } finally { homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) @@ -156,7 +149,6 @@ describe('managed hook script refresh', () => { delete process.env.KIMI_CODE_HOME delete process.env.XDG_CONFIG_HOME try { - // Why awaited: the Codex installer waits for Codex's hook hashes before it writes. await withPlatform('win32', async () => { for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) { await install() @@ -166,16 +158,12 @@ describe('managed hook script refresh', () => { const files = readdirSync(hooksDir) expect(files.length).toBeGreaterThan(0) const refresherAgents = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS.map(([agent]) => agent) - // Why: an installer that writes a shared launcher but skips the refresher list would - // recreate the frozen-stale-script class this suite exists to prevent. for (const file of files) { expect( refresherAgents.some((agent) => file.startsWith(`${agent}-`)), `${file} is written to ~/.orca/agent-hooks but no refresher owns it` ).toBe(true) } - // Why: the reverse direction — a refresher naming an agent that writes nothing is a - // stale registry entry, likely a renamed script file. for (const agent of refresherAgents) { expect( files.some((file) => file.startsWith(`${agent}-`)), @@ -212,7 +200,6 @@ describe('managed hook script refresh', () => { await expect(refresh(), `${agent} refresh on empty home`).resolves.toBeUndefined() } }) - // Why: a refresh pass on a machine with no prior installs must be a strict no-op. expect(readdirSync(home)).toEqual([]) } finally { homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) diff --git a/src/main/agent-hooks/managed-hook-script-refresh.ts b/src/main/agent-hooks/managed-hook-script-refresh.ts index bc355ede7fb..e23b22ece40 100644 --- a/src/main/agent-hooks/managed-hook-script-refresh.ts +++ b/src/main/agent-hooks/managed-hook-script-refresh.ts @@ -61,7 +61,7 @@ async function writeScriptWithAclRetry( } } -// Why: refresh must not block Electron's main thread or create state for an absent CLI. +// Refresh asynchronously without creating state for an absent CLI. export async function refreshManagedScriptIfPresent( scriptPath: string, content: string | Buffer, diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 42a763456c7..786a8bd5314 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -1,7 +1,4 @@ -// Why: stdin ownership is a cross-agent process contract; one executable -// matrix catches an unread early exit without duplicating template assertions. -// Exception (#11549): Windows batch hooks give up stdin ownership on the -// missing-Orca-env path, so their writer may break there. +// POSIX hooks drain before exit; Windows missing-context hooks may exit without owning stdin. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as CodexHookHashLookup from '../codex/codex-hook-hash-lookup' import { spawn } from 'node:child_process' @@ -18,8 +15,7 @@ let previousUserDataPath: string | undefined beforeEach(() => { previousUserDataPath = process.env.ORCA_USER_DATA_PATH isolatedUserDataDir = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-user-data-')) - // Why: Orca-managed Codex hooks resolve through ORCA_USER_DATA_PATH before - // the mocked home; an inherited live path would let this test rewrite them. + // Isolate managed-script resolution so inherited user-data paths cannot target live hooks. process.env.ORCA_USER_DATA_PATH = isolatedUserDataDir }) @@ -37,13 +33,12 @@ const { homedirMock, bundledHook } = vi.hoisted(() => { return { homedirMock: vi.fn<() => string>(), bundledHook } }) -// Why: no packaged resources here; the exe leg points Claude's install at the real built binary. +// Test the real built executable without packaged resource lookup. vi.mock('./windows-native-hook-executable', async (importOriginal) => ({ ...(await importOriginal()), resolveBundledWindowsNativeHookExecutable: () => bundledHook.path })) -// Why: stands in for asking a real Codex for its hook hashes, as the grant stub once did. vi.mock('../codex/codex-hook-hash-lookup', async (importOriginal) => ({ ...(await importOriginal()), resolveCodexHookAnswerForLaunch: async () => @@ -93,7 +88,6 @@ import { WINDOWS_NATIVE_HOOK_BUILD_TIMEOUT_MS } from './windows-native-hook-exe.test-fixture' -/** The launchers ship their command base64'd; assert the shape they actually run. */ function decodeEncodedPowerShellCommand(command: string): string { const encoded = command.match(/-EncodedCommand\s+(\S+)/) expect(encoded, 'launcher carries an encoded command').not.toBeNull() @@ -101,17 +95,11 @@ function decodeEncodedPowerShellCommand(command: string): string { } const REMOTE_HOME = '/home/dev' -// Why all three: Windows reports a write to a pipe whose reader is gone as any of these, -// depending on whether the read handle, the pipe, or the process went first. Enumerating -// them keeps the guard-exit legs from failing on which race the host happened to run. +// Windows broken-pipe errors vary with pipe and process teardown ordering. const WRITER_BROKEN_BY_EARLY_EXIT = ['EPIPE', 'ECONNRESET', 'EOF'] const LARGE_PAYLOAD = Buffer.alloc(1_000_000, 'x') -// Why: a developer box may set HKCU\...\Command Processor\AutoRun, which cmd.exe runs before any -// .cmd — and MSYS spawns a .cmd without `/d`, so it fires on the Git Bash legs. Redirecting the -// profile makes the usual `%USERPROFILE%\.cmd_aliases.cmd` target vanish, putting cmd's "not -// recognized" on the hook's stderr. Seed an empty target so these suites measure the launcher -// rather than the host's shell configuration. +// Seed the user's usual AutoRun target so profile redirection cannot introduce cmd stderr noise. function seedCmdAutoRunTarget(profileDir: string): void { mkdirSync(profileDir, { recursive: true }) writeFileSync(join(profileDir, '.cmd_aliases.cmd'), '@echo off\r\n', 'utf8') @@ -193,8 +181,7 @@ function runHookProcess( executable: string, args: string[], env: NodeJS.ProcessEnv, - // Why: `abandon` leaves the pipe open and unwritten — the shape a caller outside an Orca - // pane produces, and the only one that can catch a read-to-EOF that never returns (#11549). + // An open, unwritten pipe exposes read-to-EOF hangs outside an Orca pane. stdin: 'close' | 'abandon' = 'close' ): Promise { return new Promise((resolve, reject) => { @@ -253,7 +240,6 @@ async function generatePosixScripts(): Promise> { const generated = [...memory.fs.files.entries()].filter( ([path]) => path.includes('/.orca/agent-hooks/') && path.endsWith('.sh') ) - // Why: Claude ships a second managed script (the statusline usage feed); the stdin lifecycle contract applies to every generated script. expect(generated.length, `${entry.agent} generated scripts`).toBeGreaterThan(0) for (const [path, script] of generated) { scripts.set(`${entry.agent} ${path.split('/').pop()}`, script) @@ -262,8 +248,7 @@ async function generatePosixScripts(): Promise> { return scripts } -// Why: the Codex installer is async, so the override has to stay pinned across -// the await instead of being restored by a synchronous `finally` while it runs. +// Keep the platform override pinned across asynchronous installers. async function withPlatform(platform: NodeJS.Platform, run: () => T | Promise): Promise { const original = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: platform }) @@ -300,7 +285,6 @@ describe('Windows managed hook stdin structure', () => { expect(mainBatchScripts).toHaveLength(12) for (const fileName of mainBatchScripts) { const script = readFileSync(join(hooksDir, fileName), 'utf8') - // Why: missing-env path must not touch more.com — hang class from #11549. expect(script, `${fileName} port guard`).toContain( 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0' ) @@ -308,10 +292,7 @@ describe('Windows managed hook stdin structure', () => { 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0' ) expect(script, `${fileName} pane guard`).toContain('if "%ORCA_PANE_KEY%"=="" exit /b 0') - // Why: pin the rule, not today's three guards — a fourth ORCA_* guard routed to the - // drain would reintroduce #11549 with this suite green. The pattern spans the guard so - // it catches both `if "%VAR%"==""` and `if not defined VAR`; the Devin skip names no - // ORCA_* var, so it stays exempt. + // Catch every ORCA_* guard routed to the drain, including future variables and alternate guard syntax. expect(script, `${fileName} no ORCA_* guard may route to the more.com drain`).not.toMatch( /ORCA_[A-Z_]+.*goto :?orca_agent_hook_drain_stdin/ ) @@ -320,8 +301,7 @@ describe('Windows managed hook stdin structure', () => { expect(script).toContain('antigravity-hook-post.cjs') continue } - // Why: the epilogue stays shared — claude-hook-impl.cmd still jumps to it from the - // Devin-imports-.claude skip, which now sits below these guards. + // Claude's Devin-import skip still uses the shared drain after the context guards. expect(script, `${fileName} drain epilogue`).toContain( [ ':orca_agent_hook_drain_stdin', @@ -331,9 +311,7 @@ describe('Windows managed hook stdin structure', () => { ) } - // Why (#11549): the Devin skip is the only remaining in-script jump to more.com, so it - // must sit below the env guards — otherwise a Devin session outside an Orca pane still - // parks there and strands the hook exactly like the pre-fix guards did. + // Guard Orca context before the Devin skip can reach a hanging drain. const claude = readFileSync(join(hooksDir, 'claude-hook-impl.cmd'), 'utf8') expect(claude, 'claude devin guard present').toContain( 'if not "%DEVIN_PROJECT_DIR%"=="" goto :orca_agent_hook_drain_stdin' @@ -342,10 +320,7 @@ describe('Windows managed hook stdin structure', () => { claude.indexOf('if not "%DEVIN_PROJECT_DIR%"=="" goto :orca_agent_hook_drain_stdin') ) - // Why: the two encoded-PowerShell launchers own stdin themselves when the managed - // script is missing, so the same guard has to precede their ReadToEnd — and the - // fallback answer has to precede the guard, or a gate event outside a pane is - // answered with silence, which reads as deny (#2426/#15462). + // Missing-script launchers must answer gates before guarding, then guard before ReadToEnd. for (const [name, command] of [ [ 'wrapWindowsHookCommand', @@ -422,23 +397,14 @@ describe('Windows managed hook stdin structure', () => { : [scriptPath] const result = await runHookProcess(executable, args, hookEnvironment()) expect(result.exitCode, `${fileName} exit code`).toBe(0) - // Why (#11549 class): every Windows-local hook exits before owning stdin when the - // Orca env is missing, so the writer may break. hookEnvironment() strips every - // ORCA_* var, so this relaxation only ever covers the missing-env path — a - // happy-path case added to this loop must not reuse it. + // Only missing-context Windows paths permit broken writers; in-pane hooks must drain. for (const error of result.stdinErrors) { expect(WRITER_BROKEN_BY_EARLY_EXIT, `${fileName} stdin error`).toContain(error.code) } } const missingScript = 'C:\\missing\\orca-hook.cmd' - // Why: the cmd fast path is intentionally a bare, directly-spawnable .cmd - // path (Codex/Antigravity/Devin launch it as argv[0], not via cmd.exe), so - // it cannot own stdin for a missing script — a cmd-builtin drain would make - // argv[0] unspawnable and fail every hook (#8430 regression). Only launchers - // that already require a real interpreter (encoded PowerShell, Git Bash) - // drain a missing script; the bare path's missing-script behavior is a - // normal launch failure, covered in installer-utils.test.ts. + // Bare .cmd launchers must remain directly spawnable; only interpreter launchers can drain a missing script. const launcherCases = [ { name: 'encoded PowerShell', @@ -452,10 +418,6 @@ describe('Windows managed hook stdin structure', () => { } ] for (const launcher of launcherCases) { - // Why (#11549 class): a launcher that reaches an interpreter owns stdin for a - // missing script exactly like a managed script does, so it obeys the same rule — - // drain inside a pane, exit before reading outside one. Its writer may therefore - // break on the missing-env leg, and must not on the in-pane leg. const outside = await runHookProcess( launcher.executable, launcher.args, @@ -478,9 +440,7 @@ describe('Windows managed hook stdin structure', () => { ) expect(insideAPane.exitCode, `${launcher.name} in-pane exit code`).toBe(0) expect(insideAPane.stdinErrors, `${launcher.name} in-pane stdin errors`).toHaveLength(0) - // Why this leg and not a shape assertion: an unguarded ReadToEnd exits fine when - // the writer closes the pipe. Only a caller that abandons it strands the launcher, - // which is what left a console per hook event on the reporting hosts. + // Closing stdin masks ReadToEnd hangs; an abandoned pipe exposes them. const abandoned = await runHookProcess( launcher.executable, launcher.args, @@ -496,7 +456,7 @@ describe('Windows managed hook stdin structure', () => { } ) - // Why: command-shape tests missed conhost discarding the JSON consumers observe (#14818). + // Command-shape assertions missed conhost discarding the consumer's JSON output. it.skipIf(process.platform !== 'win32').each([ { form: 'claude-hook.cmd', bundled: false }, { form: 'orca-agent-hook.exe claude', bundled: true } @@ -518,13 +478,12 @@ describe('Windows managed hook stdin structure', () => { expect(entry.args).toBeUndefined() expect(entry.command.endsWith(form)).toBe(true) - // Why: MSYS rewrites switches and paths, so the command must survive both shells (#14815). + // MSYS rewrites paths and switches, so exercise both shells. const gitBash = findGitBash() const shells = [ { name: 'cmd.exe', executable: 'cmd.exe', args: ['/d', '/c', entry.command] }, { name: 'Git Bash', executable: gitBash, args: ['-c', entry.command] } ] - // Why: cover guard exit, reached curl, and the launcher's missing-script fallback. const environments = [ { name: 'no Orca env', env: hookEnvironment({ USERPROFILE: home }) }, { @@ -537,10 +496,7 @@ describe('Windows managed hook stdin structure', () => { }) }, { - // Why: the encoded launcher resolves %USERPROFILE% at run time, so redirecting it is - // what makes the script vanish for that shape. The direct launcher (#18875) carries - // an absolute path, so here it asserts only that a bogus profile changes nothing; its - // missing-entry failure (never exit 2) is covered live in windows-direct-cmd-hook-command.test.ts. + // Profile redirection hides runtime-resolved scripts; direct launchers retain their absolute path. name: 'missing managed script', env: hookEnvironment({ USERPROFILE: absentProfile }) } @@ -554,7 +510,6 @@ describe('Windows managed hook stdin structure', () => { expect(() => JSON.parse(result.stdout.trim()), `${label} stdout is JSON`).not.toThrow() expect(JSON.parse(result.stdout.trim()), `${label} stdout`).toEqual({}) } - // Why (#11549): outside a pane the exe must exit without waiting on a stdin nobody closes. if (bundled) { const abandoned = await runHookProcess( shell.executable, @@ -571,7 +526,7 @@ describe('Windows managed hook stdin structure', () => { rmSync(home, { recursive: true, force: true }) } }, - // Why: six shell launches (and a cold exe build) can overrun the default under load. + // Allow cold executable builds and six shell launches under load. WINDOWS_NATIVE_HOOK_BUILD_TIMEOUT_MS ) }) @@ -597,8 +552,7 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () const firstExitIndex = script.indexOf('exit 0') expect(captureIndex, `${agent} payload capture`).toBeGreaterThanOrEqual(0) expect(firstExitIndex, `${agent} first success exit`).toBeGreaterThan(captureIndex) - // Why: the JSON reader dereferences a variable the prelude sets, so a script - // that carries the reader must carry its prelude above the capture line. + // The JSON reader needs its variable-setting prelude before capture. if (script.includes(POSIX_HOOK_JSON_STDIN_READER)) { const prelude = POSIX_HOOK_JSON_STDIN_PRELUDE.join('\n') expect(script.indexOf(prelude), `${agent} JSON reader prelude`).toBeGreaterThanOrEqual(0) @@ -638,14 +592,11 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () expect(missing.stdinErrors, 'missing script launcher stdin errors').toHaveLength(0) }) - // Why: an unread stdin still exits 0, so exit codes alone cannot prove the - // reader consumed the payload. Assert the captured byte count directly. + // Exit zero does not prove stdin was consumed; measure captured bytes. it.each([ ['empty PATH', ''], - // Why: /bin/cat is absent on NixOS-style hosts, so an absolute path alone is - // not enough; the reader must fall back to the shell's default PATH. + // NixOS may lack /bin/cat; exercise the shell's default PATH. ['PATH without coreutils', '/nonexistent'], - // Why: a worktree-local `cat` must never receive the hook payload. ['PATH whose first cat is a decoy', ''] ])('captures the whole payload with %s', async (label, pathValue) => { const decoyDir = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-decoy-')) diff --git a/src/main/agent-hooks/managed-hook-timeout.test.ts b/src/main/agent-hooks/managed-hook-timeout.test.ts index 82a498b65e9..23a55cd77bd 100644 --- a/src/main/agent-hooks/managed-hook-timeout.test.ts +++ b/src/main/agent-hooks/managed-hook-timeout.test.ts @@ -1,6 +1,3 @@ -// Why: one regression fixture proves the managed hook timeout budget across every -// managed agent (config entries, wrapper curl flags, and a real dead-endpoint -// shell run) so the cross-agent coverage lives together rather than fragmenting. import { afterEach, describe, expect, it, vi } from 'vitest' import { spawn, spawnSync } from 'node:child_process' import { createServer, type Server, type Socket } from 'node:net' @@ -45,9 +42,7 @@ import { createAgentHookMemorySftp as createFakeSftp } from './agent-hook-memory const REMOTE_HOME = '/home/dev' -// Each managed agent that ships an SSH-compatible JSON/TOML hook config. Amp and -// Hermes are intentionally excluded: they are plugin systems with no hook config -// entries, so their transport budgets live in plugin source (see design doc). +// Amp/Hermes plugins define transport budgets in source rather than JSON/TOML hook entries. const JSON_INSTALLERS = [ { agent: 'claude', @@ -64,7 +59,7 @@ const JSON_INSTALLERS = [ { agent: 'codex', timeout: MANAGED_HOOK_TIMEOUT_SECONDS, - // Why: Codex clamps Interrupt to 3s, so Orca writes that cap instead of the shared budget. + // Codex caps Interrupt at three seconds. eventTimeouts: { Interrupt: 3 }, configPath: `${REMOTE_HOME}/.codex/hooks.json`, install: (sftp: SFTPWrapper) => new CodexHookService().installRemote(sftp, REMOTE_HOME) @@ -114,14 +109,10 @@ const JSON_INSTALLERS = [ ] as const const MANAGED_HOOKS_DIR_NEEDLE = '/.orca/agent-hooks/' -// Why: statusLine is not a hook — Claude's schema has no timeout field (type/command/padding/refreshInterval), and a slow statusline can't block agent turns. +// Claude's statusLine schema has no timeout field and cannot block agent turns. const STATUSLINE_SCRIPT_NEEDLE = '-statusline.' -// Walk the parsed config and assert every Orca-managed command carrier (a node -// with a `command`/`bash`/`powershell` string pointing at the managed script -// dir) has a positive config-level timeout sibling (`timeout` or the -// provider-specific `timeoutSec`). Returns the count of managed carriers found -// so callers can assert the scan was not vacuous. +// Count managed command carriers while asserting their provider-specific timeout fields. function countManagedCarriersWithTimeout( node: unknown, expectedTimeout: number, @@ -194,7 +185,6 @@ describe('managed agent hook timeouts', () => { const status = await new KimiHookService().installRemote(sftp, REMOTE_HOME) expect(status.state).toBe('installed') const config = fs.files.get(`${REMOTE_HOME}/.kimi-code/config.toml`)! - // One timeout line per managed [[hooks]] event entry. const timeoutLines = config.match(new RegExp(`timeout = ${MANAGED_HOOK_TIMEOUT_SECONDS}`, 'g')) expect(timeoutLines?.length ?? 0).toBeGreaterThan(0) expect(config).toContain('/home/dev/.orca/agent-hooks/kimi-hook.sh') @@ -298,15 +288,10 @@ describe('managed agent hook timeouts', () => { }) } - // Why: an accepted-but-unanswered connection is the worst-case hang that only - // `--max-time` (1.5s) governs — `--connect-timeout` already passes once the - // TCP handshake completes. Point the wrapper at a real loopback listener that - // accepts and then stalls, and assert the wrapper still returns well under a - // generous CI budget. + // An accepted, unanswered connection exercises max-time after connect-timeout has already passed. it.skipIf(!hasPosixCurl)( 'returns within budget when the local listener accepts but never responds', async () => { - // Reuse a real generated POSIX wrapper rather than re-deriving the script. const { sftp, fs } = createFakeSftp() await new CodexHookService().installRemote(sftp, REMOTE_HOME) const wrapperBody = fs.files.get(`${REMOTE_HOME}/.orca/agent-hooks/codex-hook.sh`)! @@ -317,7 +302,6 @@ describe('managed agent hook timeouts', () => { chmodSync(scriptPath, 0o755) const stallingServer = createServer((socket) => { - // Accept the connection and hold it open without ever replying. openSockets.push(socket) }) server = stallingServer @@ -332,11 +316,11 @@ describe('managed agent hook timeouts', () => { const result = await runHookScript(scriptPath, port) const elapsedMs = Date.now() - startedAt - // The hook fails open (`|| true`) and exits 0 even though the POST timed out. + // POST timeout must fail open. expect(result.status).toBe(0) expect(openSockets.length).toBeGreaterThan(0) expect(elapsedMs).toBeGreaterThanOrEqual(1_000) - // --max-time 1.5s + process overhead; a hang would blow well past this. + // Allow overhead beyond curl's 1.5-second cap. expect(elapsedMs).toBeLessThan(6_000) } ) diff --git a/src/main/agent-hooks/managed-hooks-json-events.ts b/src/main/agent-hooks/managed-hooks-json-events.ts index bff2bbf6942..fac57d23e64 100644 --- a/src/main/agent-hooks/managed-hooks-json-events.ts +++ b/src/main/agent-hooks/managed-hooks-json-events.ts @@ -1,16 +1,6 @@ import { isPlainObject } from './installer-utils' -/** - * Which of `events` an Orca-owned `{ hooks: { : [{ hooks: [{ command }] }] } }` file - * still registers under a managed command. - * - * Shared by every agent whose managed hooks file Orca generates wholesale (Muse, DSH), so - * "installed", "partial" and "not_installed" cannot drift between them. - * - * Why every lookup is guarded: the file is on disk and hand-editable, so any node can be - * null, a scalar, or the wrong container. A malformed node reads as "event absent" — status - * calculation must report a broken install, never throw on it. - */ +/** Treat malformed user-edited nodes as absent events when reporting installation status. */ export function readManagedHookEventsFromJson( parsed: unknown, events: readonly string[], diff --git a/src/main/agent-hooks/managed-toml-ownership.test.ts b/src/main/agent-hooks/managed-toml-ownership.test.ts index a94ac3c2b09..5ca31c96e61 100644 --- a/src/main/agent-hooks/managed-toml-ownership.test.ts +++ b/src/main/agent-hooks/managed-toml-ownership.test.ts @@ -67,8 +67,6 @@ describe('managed TOML marker blocks', () => { expect(strip(`a = 1\n\n\n${START}\nx\n${END}\n`)).toBe('a = 1\n') }) - // CodeRabbit on #20148: a prefix match let a user's own comment open or close - // a region, deleting every byte between two quoted markers. it("ignores a marker line carrying a trailing comment of the user's own", () => { const text = [ 'a = 1', diff --git a/src/main/agent-hooks/managed-toml-ownership.ts b/src/main/agent-hooks/managed-toml-ownership.ts index 940048c0132..00af5cbf3e4 100644 --- a/src/main/agent-hooks/managed-toml-ownership.ts +++ b/src/main/agent-hooks/managed-toml-ownership.ts @@ -1,11 +1,4 @@ -// Orca appends marker-delimited blocks to user-owned TOML config files. Two -// independent things can make a byte Orca's: it sits between a matched start and -// end marker, or the provider positively recognizes it as content Orca emitted. -// The end marker is the only proof of a block's extent, so once a hand-edit -// deletes it the rest of the file is unknown text — #18861: assuming otherwise -// deleted user tables through EOF. An orphaned block therefore owns nothing but -// its own stray marker line, and anything Orca actually wrote is reclaimed by -// recognition instead, wherever in the file it ended up. +// Own only complete marker-delimited blocks or provider-recognized content; orphan markers never own through EOF. export type ManagedTomlMarkers = { startMarker: string @@ -13,16 +6,14 @@ export type ManagedTomlMarkers = { } export type ManagedTomlRegion = { - /** First removable offset — includes the blank-line run above the content. */ + /** Includes preceding blank lines. */ startOffset: number - /** Offset one past the last owned line, terminator included. */ + /** Exclusive offset, including the line terminator. */ endOffset: number } export type ManagedTomlBlockRegion = ManagedTomlRegion & { - /** Offset of the start-marker line itself. */ markerOffset: number - /** End marker found: everything between the markers is Orca's. */ terminated: boolean } @@ -57,8 +48,7 @@ function scanLines(text: string): ScannedLine[] { return lines } -// Absorb the blank run above so install/remove cycles do not accumulate -// whitespace; overlapping runs are merged away by stripManagedTomlRegions. +// Absorb preceding blanks so install/remove cycles do not accumulate whitespace. function startOffsetAbsorbingBlanksAbove(lines: readonly ScannedLine[], index: number): number { let startLine = index while (startLine > 0 && lines[startLine - 1].text.trim() === '') { @@ -72,10 +62,7 @@ export function findManagedTomlBlocks( markers: ManagedTomlMarkers ): ManagedTomlBlockRegion[] { const lines = scanLines(text) - // Exact, not startsWith: a user quoting a marker in a comment of their own - // ("# >>> ... >>> (example from the docs)") would otherwise open or close a - // region and take every byte between the two quoted lines. Both emitters - // write the marker as its own line, so nothing legitimate carries a suffix. + // Match whole marker lines so user comments quoting a marker cannot claim surrounding text. const isStart = (index: number): boolean => lines[index].text.trim() === markers.startMarker const isEnd = (index: number): boolean => lines[index].text.trim() === markers.endMarker @@ -97,8 +84,7 @@ export function findManagedTomlBlocks( break } } - // Not terminated: `last` stays on the marker line, so the orphan owns only - // the stray marker. Its body, if Orca wrote it, is reclaimed by recognition. + // Orphan markers own no body; reclaim emitted content separately by recognition. regions.push({ startOffset: startOffsetAbsorbingBlanksAbove(lines, index), markerOffset: lines[index].offset, @@ -110,11 +96,7 @@ export function findManagedTomlBlocks( return regions } -/** - * Every table the provider recognizes as its own, anywhere in the file. Marker - * position is irrelevant: content Orca emitted is Orca's to remove even when a - * hand-edit stranded it outside the block (#18861). - */ +/** Recognition reclaims emitted tables even when hand edits move them outside their marked block. */ export function findRecognizedManagedTables( text: string, recognize: ManagedTableRecognizer diff --git a/src/main/agent-hooks/manual-compact-hook-stream.test.ts b/src/main/agent-hooks/manual-compact-hook-stream.test.ts index 2d31b2ce252..eeae15bd1e6 100644 --- a/src/main/agent-hooks/manual-compact-hook-stream.test.ts +++ b/src/main/agent-hooks/manual-compact-hook-stream.test.ts @@ -50,8 +50,7 @@ function turnEnvelope(): AgentHookRelayEnvelope { } as AgentHookRelayEnvelope } -/** What a relay predating this change forwards: it ran its own shipped normalizer, so a manual - * compact arrives as a PLAIN `done` (no session boundary) and an auto compact as `working`. */ +/** Legacy relays normalize manual compacts to plain done and auto compacts to working. */ function legacyRelayCompactEnvelope( state: 'done' | 'working', overrides: Partial = {} @@ -69,9 +68,7 @@ function legacyRelayCompactEnvelope( } as AgentHookRelayEnvelope } -/** What AgentHookServer.hydrate() rebuilds for a pane that was stuck `working` across a restart: - * the previous session's connectionId, the unconfirmed flag, an older receivedAt, and the child - * the turn had spawned — restored from disk, so proof of nothing. */ +/** Seed restored state without live process or turn ownership evidence. */ function seedHydratedStuckPane(server: AgentHookServer, receivedAt: number) { const state = server._getStateForTests() const subagents: AgentSubagentSnapshot[] = [ @@ -118,8 +115,7 @@ describe('manual Claude compact hook stream', () => { }) await postHook(port, token, claudeHook('UserPromptSubmit', TURN_PROMPT_ID, { prompt: 'work' })) - // Measured stream for a successful manual /compact. PreCompact is not registered in production - // and is rejected here too; the summarizer's start-less SubagentStop only republishes state. + // Production rejects PreCompact; a start-less summarizer stop only republishes state. await postHook(port, token, claudeHook('PreCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) await postHook( port, @@ -185,7 +181,7 @@ describe('manual Claude compact hook stream', () => { const port = Number(env.ORCA_AGENT_HOOK_PORT) const token = env.ORCA_AGENT_HOOK_TOKEN! seedHydratedStuckPane(server, Date.now() - 60_000) - // A child THIS runtime observed: real agent work in flight, which a compact may not retire. + // A child observed by this runtime represents live work a compact must not retire. await postHook( port, token, @@ -200,8 +196,7 @@ describe('manual Claude compact hook stream', () => { }) await postHook(port, token, claudeHook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) - // Why: restating the row here is the regression, not the fix — it would refresh receivedAt and - // hand the stale sweep a brand-new clock for work the compact never observed. + // Republishing would refresh receivedAt and defer stale cleanup for work the compact never observed. expect(emitted).toEqual([]) expect(server.getStatusSnapshot()[0]).toEqual(before) unsubscribe() @@ -240,9 +235,7 @@ describe('manual Claude compact hook stream', () => { expect(emitted.at(-1)).toBe('PostCompact:done') expect(main.getStatusSnapshot()[0]).toMatchObject({ state: 'done', sessionBoundary: true }) - // Why: a client that was offline during the compact receives the row as a reconnect replay of - // this cache. Retaining the compact identity lets the client re-run ownership instead of - // treating an unowned completion as an ordinary status row. + // Reconnect replay must preserve compact identity so clients can recheck ownership. const replayed = relay.replayCachedPayloadsForPanes() expect(replayed).toBeGreaterThan(0) expect(forwarded.at(-1)).toMatchObject({ isReplay: true, payload: { state: 'done' } }) @@ -258,9 +251,7 @@ describe('manual Claude compact hook stream', () => { const forwarded: AgentHookRelayEnvelope[] = [] const endpointDir = mkdtempSync(join(tmpdir(), 'orca-compact-cold-')) temporaryPaths.push(endpointDir) - // A relay that restarted while the agent session kept running: hooks resolve the endpoint file - // per invocation, so they reconnect — but the relay's per-process cache is empty, and the - // compact completion may be the first event it ever sees for this pane. + // A restarted relay may see compact completion first because its cache is empty. const relay = new RelayAgentHookServer({ endpointDir, token: 'cold-cache-token', @@ -275,14 +266,12 @@ describe('manual Claude compact hook stream', () => { await postHook(port, token, claudeHook('PostCompact', COMPACT_PROMPT_ID, { trigger: 'manual' })) - // Why: the relay is a forwarder, not the authority on pane identity. Dropping the event here - // is how the one signal that can clear a remote pane goes missing after a restart. + // Forward without pane authority; only the client can decide compact ownership. expect(forwarded.map((envelope) => envelope.hookEventName)).toEqual(['PostCompact']) expect(forwarded[0]).toMatchObject({ compactTrigger: 'manual', providerPromptId: COMPACT_PROMPT_ID }) - // ...and the client, which DOES own pane identity, still refuses to mint a row it never had. expect(main.getStatusSnapshot()).toEqual([]) }) @@ -314,8 +303,7 @@ describe('manual Claude compact hook stream', () => { server.ingestRemote(legacyRelayCompactEnvelope('done'), 'conn-a') - // Why: the old relay built this payload before the flag existed, so it arrives as a plain - // `done` that every completion-reactive consumer would read as a finished turn. + // Old relays omit sessionBoundary; recover it before completion consumers read done. expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', sessionBoundary: true }) }) @@ -353,8 +341,7 @@ describe('manual Claude compact hook stream', () => { server.ingestRemote(legacyRelayCompactEnvelope('done'), 'conn-a') - // Why: the deleted ownership cache used to reject a repeat; a same-owner guard alone would - // accept it and keep refreshing the row, so suppression is keyed on the consumed compact id. + // Suppress by consumed compact ID so same-owner repeats cannot refresh the row. expect(emitted).toEqual(['UserPromptSubmit:working', 'PostCompact:done']) expect(server._getStateForTests().lastStatusByPaneKey.get(PANE_KEY)).toBe(applied) unsubscribe() @@ -371,9 +358,7 @@ describe('manual Claude compact hook stream', () => { ) expect(done.getStatusSnapshot()[0]).toMatchObject({ state: 'done', sessionBoundary: true }) - // Why: assert this arm from a pane that already FINISHED. Asserting `working` on a pane that - // was already `working` cannot fail — it holds whether the replay was dropped or applied, which - // is exactly how a trigger-stripped auto compact could start re-arming the stuck row again. + // Seed done so an incorrectly applied working replay cannot pass unnoticed. const working = new AgentHookServer() servers.push(working) working.ingestRemote(turnEnvelope(), 'conn-a') @@ -408,9 +393,7 @@ describe('manual Claude compact hook stream', () => { }) it('keeps the summarized turn as the label, with or without a trigger on the envelope', () => { - // The compact's own event carries no prompt of its own, so the row's label can only come from - // the turn it summarized. The trigger-stripped replay is the one shape that arrives with no - // trigger at all, so the carry-forward must not be keyed on the trigger being present. + // Carry the summarized turn's label even when legacy replay lacks a trigger. const blank = { state: 'done' as const, prompt: '', agentType: 'claude' } const tagged = new AgentHookServer() diff --git a/src/main/agent-hooks/migration-unsupported-pty-state.ts b/src/main/agent-hooks/migration-unsupported-pty-state.ts index 36bdc838569..90ac50230c1 100644 --- a/src/main/agent-hooks/migration-unsupported-pty-state.ts +++ b/src/main/agent-hooks/migration-unsupported-pty-state.ts @@ -48,9 +48,7 @@ export function clearMigrationUnsupportedPtysForPaneKey(paneKey: string): void { if (ptyIdsToClear.length === 0) { return } - // Why: pane teardown can clear several legacy PTYs for one stable pane. - // Persist once after the batch instead of rebuilding the full snapshot for - // every entry while still emitting individual renderer clear events. + // Batch persistence while still emitting individual renderer clear events. for (const ptyId of ptyIdsToClear) { entriesByPtyId.delete(ptyId) listener?.({ type: 'clear', ptyId }) diff --git a/src/main/agent-hooks/posix-hook-command.ts b/src/main/agent-hooks/posix-hook-command.ts index b51cc90632a..d1f681be79d 100644 --- a/src/main/agent-hooks/posix-hook-command.ts +++ b/src/main/agent-hooks/posix-hook-command.ts @@ -1,14 +1,14 @@ import { quotePosixShell } from '../../shared/shell-argument-quoting' import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' -// Why: guard for a readable executable so a stale entry at a missing script becomes a silent no-op, not an exit-127 failure on every tool call. +// Missing scripts must become silent no-ops rather than fail every tool call. export function wrapPosixHookCommand( scriptPath: string, env: Record = {}, - // Why: silence is a hard deny on gate events (Antigravity PreToolUse, #2426); those callers need the guard to still answer. + // Gate events treat silence as denial, so missing scripts may need fallback stdout. options: { fallbackStdout?: string; requiredEnvVar?: string } = {} ): string { - // Why: single-quote escape so $, `, ", \ in scriptPath stay literal — avoids shell injection from an arbitrary path. + // Keep arbitrary paths literal across shell metacharacters. const quoted = quotePosixShell(scriptPath) const envPrefix = Object.entries(env) .map(([key, value]) => `${key}=${quotePosixShell(value)}`) @@ -18,8 +18,7 @@ export function wrapPosixHookCommand( options.fallbackStdout === undefined ? POSIX_HOOK_STDIN_DRAIN_COMMAND : `printf '%s\\n' ${quotePosixShell(options.fallbackStdout)}; ${POSIX_HOOK_STDIN_DRAIN_COMMAND}` - // Why: default form avoids Grok rejecting unset vars or splicing values into shell quotes at load - // time; the child shell checks the current pane env before spawning the managed script. + // Default-form variables survive Grok prechecks and defer environment reads to invocation. const guards = [ ...(options.requiredEnvVar ? [`[ -n "\${${options.requiredEnvVar}-}" ]`] : []), `[ -f ${quoted} ]`, diff --git a/src/main/agent-hooks/posix-hook-json-stdin-reader.test.ts b/src/main/agent-hooks/posix-hook-json-stdin-reader.test.ts index 587346ec55e..4cccb8a9b27 100644 --- a/src/main/agent-hooks/posix-hook-json-stdin-reader.test.ts +++ b/src/main/agent-hooks/posix-hook-json-stdin-reader.test.ts @@ -1,7 +1,4 @@ -// Why an executable suite rather than shape assertions: the reader is a Python -// program embedded in a shell string, so only running it catches a decode that -// raises on a chunk boundary — the shape looked correct while a CJK payload -// crashed the interpreter and fell through to the `cat` hang it exists to avoid. +// Execute the embedded Python: shape assertions miss chunk-boundary decode failures and cat hangs. import { execFile, spawn } from 'node:child_process' import { promisify } from 'node:util' import { describe, expect, it } from 'vitest' @@ -15,7 +12,6 @@ import { const execFileAsync = promisify(execFile) -/** The reader plus a line that prints what it captured, so the payload is observable. */ const READER_SCRIPT = [ ...buildPosixHookPayloadCapture('empty-object', POSIX_HOOK_JSON_STDIN).slice(0, -3), 'printf %s "$payload"' @@ -32,8 +28,7 @@ type ReaderRun = { readonly timedOut: boolean } -/** Feeds `chunks` with `gapMs` between them. `closeStdin: false` is the Grok - * SessionStart shape: the payload is written and the pipe is left open. */ +/** Leaving stdin open reproduces Grok SessionStart. */ function runReader( chunks: readonly Buffer[], { @@ -104,8 +99,7 @@ async function resolveDefaultPathPython(): Promise { const hasPython = process.platform === 'win32' ? false : await resolveDefaultPathPython() describe('POSIX hook JSON stdin reader shape', () => { - // Why: the Python program is carried in a single-quoted shell assignment, so one - // apostrophe would end the string and splice the rest of it into the hook as code. + // An apostrophe would escape the single-quoted shell assignment. it('carries no single quote that would escape its shell quoting', () => { const prelude = POSIX_HOOK_JSON_STDIN_PRELUDE.join('\n') expect(prelude.split("'")).toHaveLength(3) @@ -119,15 +113,14 @@ describe('POSIX hook JSON stdin reader shape', () => { }) describe.skipIf(process.platform === 'win32')('POSIX hook JSON stdin reader', () => { - // Why skipped rather than failed: without an interpreter the chain falls back to - // `cat`, whose read-to-EOF genuinely cannot return while the writer holds the pipe. + // Without Python the fallback cat cannot finish while the writer keeps stdin open. const itWithPython = it.skipIf(!hasPython) itWithPython( 'keeps a multi-byte character intact when it is split across reads', async () => { const payload = '{"hook_event_name":"session_start","cwd":"/tmp/漢字","tool":"🚀"}' - // One byte per write: every multi-byte sequence therefore straddles a read. + // One-byte writes exercise split UTF-8 sequences. const chunks = [...Buffer.from(`${payload}\n`, 'utf8')].map((byte) => Buffer.from([byte])) const result = await runReader(chunks) @@ -140,16 +133,13 @@ describe.skipIf(process.platform === 'win32')('POSIX hook JSON stdin reader', () KILL_AFTER_MS + 1_000 ) - // Why: the chain is `python3 || python || cat`, and a reader that died after - // consuming bytes would hand the next one a truncated stream. A non-zero exit - // must therefore imply nothing was read. + // Fallback readers need the entire stream; a nonzero exit must mean no bytes were consumed. itWithPython( 'never hands a partially consumed stream to the fallback reader', async () => { const payload = `{"hook_event_name":"session_start","pad":"${'p'.repeat(200_000)}","cwd":"/漢"}` const bytes = Buffer.from(`${payload}\n`, 'utf8') - // Split inside the 3-byte sequence, with a gap long enough that the first - // read has already completed before the continuation bytes are written. + // Delay continuation bytes so the first read ends inside a three-byte UTF-8 sequence. const splitAt = bytes.length - 4 const chunks = [bytes.subarray(0, splitAt), bytes.subarray(splitAt)] @@ -164,8 +154,6 @@ describe.skipIf(process.platform === 'win32')('POSIX hook JSON stdin reader', () KILL_AFTER_MS + 1_000 ) - // Why: re-serialising the object would rewrite non-ASCII as \uXXXX and reorder - // keys, so the hook server would no longer see what the agent actually sent. itWithPython( 'emits the payload text unchanged rather than re-serialising it', async () => { @@ -180,8 +168,6 @@ describe.skipIf(process.platform === 'win32')('POSIX hook JSON stdin reader', () KILL_AFTER_MS + 1_000 ) - // Why: raw_decode does not skip leading whitespace, so a padded payload would - // otherwise never complete and would sit out the idle timeout before returning. itWithPython( 'returns immediately for a payload preceded by whitespace', async () => { @@ -196,8 +182,7 @@ describe.skipIf(process.platform === 'win32')('POSIX hook JSON stdin reader', () KILL_AFTER_MS + 1_000 ) - // Why: the first-byte wait is not the idle wait. A host that is slow to schedule - // the writer must not have its payload silently dropped. + // Scheduling delay before the first byte must not count as an idle payload. itWithPython( 'waits past the idle timeout for a writer that has not sent its first byte', async () => { @@ -216,8 +201,6 @@ describe.skipIf(process.platform === 'win32')('POSIX hook JSON stdin reader', () KILL_AFTER_MS + 1_000 ) - // Why: not every hook payload is JSON, and the reader replaces `cat` for Grok — - // it still has to hand back everything a closed stream contained. itWithPython( 'reads a non-JSON payload through to EOF', async () => { @@ -231,9 +214,7 @@ describe.skipIf(process.platform === 'win32')('POSIX hook JSON stdin reader', () KILL_AFTER_MS + 1_000 ) - // Why: macOS resolves /usr/bin/python3 through an Xcode stub that re-runs its - // whole tool lookup when it cannot reach a cache under $HOME. A HOME pointing - // nowhere cost ~6.6s per spawn, which on its own overran Grok's 10s budget. + // An invalid HOME can stall the macOS Xcode Python stub beyond the hook budget. itWithPython( 'stays fast when HOME points at a directory that does not exist', async () => { diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index ad88b7257ad..4e7ab0072be 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -372,7 +372,7 @@ describe('remote hook service installers', () => { `ORCA_ANTIGRAVITY_EVENT='${eventName}'` ) } - // Why: #2426 was an SSH report — a remote host missing the script must still answer the gate, not deny every tool. + // Remote missing-script gates need a fallback answer to avoid denying every tool call. expect( tokenizeCommandLine( antigravityConfig['orca-status'].PreToolUse[0].hooks?.[0]?.command ?? '' @@ -439,7 +439,7 @@ describe('remote hook service installers', () => { expect(command).toContain('/home/dev/.orca/agent-hooks/grok-hook.sh') expect(command).toMatch(/^if \[ -n "\$\{ORCA_PANE_KEY-\}" \] && /) } - // Why: Grok tool matchers are real regexes; bare `*` is invalid match-all. + // Grok expects regex matchers; bare * is invalid. expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('.*') expect(grokConfig.hooks.PostToolUse?.[0]?.matcher).toBe('.*') expect(grokConfig.hooks.StopFailure?.[0]?.matcher).toBeUndefined() @@ -512,7 +512,6 @@ describe('remote hook service installers', () => { expect(status.state).toBe('installed') const config = fs.files.get('/home/dev/.kimi-code/config.toml')! - // User config above the managed block is preserved. expect(config).toContain('default_model = "kimi-k2.6"') expect(config).toContain('api_key = "sk-secret"') for (const eventName of [ @@ -526,7 +525,6 @@ describe('remote hook service installers', () => { ]) { expect(config).toContain(`event = "${eventName}"`) } - // The command points at the POSIX managed script via the regular-file guard. expect(config).toContain('/home/dev/.orca/agent-hooks/kimi-hook.sh') expect(config).toMatch(/command = "if \[ -f /) expect(fs.files.get('/home/dev/.orca/agent-hooks/kimi-hook.sh')).toContain('/hook/kimi') @@ -748,7 +746,6 @@ describe('remote hook service installers', () => { installRemoteManagedAgentHooks(sftp, '/home/dev', { agents: [] }) ).resolves.toEqual([]) - // Why: fake SFTP seeds '/' only; no agent config homes or files may appear. expect([...fs.files.keys()]).toEqual([]) expect([...fs.dirs]).toEqual(['/']) }) @@ -810,7 +807,6 @@ describe('remote hook service installers', () => { expect(command).toContain('/home/dev/.orca/agent-hooks/droid-hook.sh') expect(command).toMatch(/^if \[ -f /) } - // Tool/permission events carry a `*` matcher; lifecycle events do not. expect(config.hooks.PreToolUse?.[0]?.matcher).toBe('*') expect(config.hooks.PostToolUse?.[0]?.matcher).toBe('*') expect(config.hooks.PermissionRequest?.[0]?.matcher).toBe('*') diff --git a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts index dca668b23f7..0f11db40471 100644 --- a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts +++ b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts @@ -31,14 +31,7 @@ import { jcodeHookService } from '../jcode/hook-service' import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls' import { REMOTE_MANAGED_HOOK_INSTALLER_AGENTS } from './remote-managed-hook-installers' -// Split from remote-hook-service-installers.test.ts: that file covers what each installer -// WRITES over SFTP; this one covers which agents are wired into the registries at all. describe('remote hook service registry coverage', () => { - // Why: Droid (and Copilot) each shipped a working installRemote but were never - // registered in REMOTE_MANAGED_HOOK_INSTALLERS, so their status silently never - // appeared over SSH (issue #7253). Guard the whole bug class, not one agent: - // every locally-managed hook service that implements installRemote MUST be - // wired into the remote installer. it('registers every managed agent that implements installRemote in the remote installer (issue #7253)', () => { const servicesByAgent = new Map([ ['claude', claudeHookService], @@ -66,7 +59,6 @@ describe('remote hook service registry coverage', () => { ['jcode', jcodeHookService] ]) - // Guard against a service silently missing from the map above as new agents land. for (const [agent] of MANAGED_AGENT_HOOK_INSTALLERS) { expect(servicesByAgent.has(agent)).toBe(true) } diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index d496f062cdc..1c2816be96e 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -24,21 +24,17 @@ import { zcodeHookService } from '../zcode/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' export type RemoteManagedHookInstallOptions = { - /** Explicit CODEX_HOME dir for redirected runtimes (for example WSL's managed runtime home). */ + /** Redirected runtime CODEX_HOME, including managed WSL homes. */ codexHomeDir?: string /** Skip the trust write when a redirected runtime config is seeded by the launch path. */ deferTrustUntilConfigToml?: boolean - /** Explicit GROK_HOME for remote runtimes that redirect Grok's config. */ grokHomeDir?: string /** Login-shell KIRO_HOME on the execution host; it replaces ~/.kiro outright. */ kiroHomeDir?: string - /** Version reported by Claude on this execution host. */ claudeVersion?: string - /** Stops before starting the next installer when the owning relay request - * is cancelled. Individual filesystem mutations remain atomic. */ + /** Cancellation stops the next installer; individual mutations remain atomic. */ signal?: AbortSignal - /** Positively detected and enabled agents allowed to mutate config. - * Required for any install: omit/empty fails closed (no config mutation). */ + /** Only positively detected, enabled agents may mutate configs; omitted/empty installs nothing. */ agents?: readonly AgentHookTarget[] } @@ -98,9 +94,6 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ] ] -/** Agents wired into the remote (SSH) hook installer. Exported so an invariant - * test can assert every locally-managed agent that implements `installRemote` - * is registered here — the omission that hid Droid/Copilot status over SSH. */ export const REMOTE_MANAGED_HOOK_INSTALLER_AGENTS: readonly AgentHookInstallStatus['agent'][] = REMOTE_MANAGED_HOOK_INSTALLERS.map(([agent]) => agent) @@ -109,8 +102,7 @@ export async function installRemoteManagedAgentHooks( remoteHome: string, options?: RemoteManagedHookInstallOptions ): Promise { - // Why: omit/empty allowlist must never mean "install every agent" — that - // recreates config homes for CLIs the user never installed (issue #11641). + // An empty allowlist must not create config homes for uninstalled CLIs. const allowedAgents = new Set(options?.agents ?? []) if (allowedAgents.size === 0) { return [] @@ -120,8 +112,7 @@ export async function installRemoteManagedAgentHooks( if (!allowedAgents.has(agent)) { continue } - // Why: relay requests can disappear during reconnect; do not start more - // user-config mutations after their client has gone away. + // Stop further config mutations when the owning relay request disappears. options?.signal?.throwIfAborted() try { const result = await install(sftp, remoteHome, options) @@ -134,9 +125,7 @@ export async function installRemoteManagedAgentHooks( ) } } catch (error) { - // Why: remote hook installation must not block SSH workspace startup. - // A broken agent config or transient SFTP failure should degrade status - // reporting only, while terminals/filesystem/git still come online. + // Hook failures must not prevent SSH terminals, files, and Git from becoming available. const detail = error instanceof Error ? error.message : String(error) console.warn(`[agent-hooks] Remote ${agent} managed hook install threw: ${detail}`) results.push({ diff --git a/src/main/agent-hooks/restored-status-dead-terminal-sweep.test.ts b/src/main/agent-hooks/restored-status-dead-terminal-sweep.test.ts index 6c78921cd8f..bbf7bbf9201 100644 --- a/src/main/agent-hooks/restored-status-dead-terminal-sweep.test.ts +++ b/src/main/agent-hooks/restored-status-dead-terminal-sweep.test.ts @@ -44,7 +44,6 @@ type PersistedRow = { providerSessionOnly?: boolean } -/** Start a server over the last-status.json an Orca that closed mid-turn leaves behind. */ async function restartWith(rows: PersistedRow[] = [{}]): Promise { const now = Date.now() const entries = Object.fromEntries( @@ -174,7 +173,7 @@ describe('restored status dead-terminal sweep', () => { expect(response.status).toBe(204) } - // Why: a stranded restored child would hold the next agent's finished turn `working`. + // A stranded restored child could keep the next finished turn working. expect(paneState(server)).toBe('done') } finally { server.stop() diff --git a/src/main/agent-hooks/restored-status-dead-terminal-sweep.ts b/src/main/agent-hooks/restored-status-dead-terminal-sweep.ts index 97838602df2..16899e5ea5b 100644 --- a/src/main/agent-hooks/restored-status-dead-terminal-sweep.ts +++ b/src/main/agent-hooks/restored-status-dead-terminal-sweep.ts @@ -20,16 +20,12 @@ export type RestoredStatusDeadTerminalSweepDeps = { /** Targeted provider liveness: true live, false absent, null when it cannot prove either. */ probePtyLiveness: (ptyId: string) => Promise isLocalExecutionHost: (worktreeId: string | undefined) => boolean - /** PTY bound to this pane in the current session, if it has one. */ getBoundPtyIdForPaneKey: (paneKey: string) => string | undefined - /** PTY this pane was bound to when the session was last persisted; covers panes - * whose surviving daemon session has not been reattached yet. */ + /** Covers surviving daemon PTYs not yet reattached to the current session. */ getPersistedPtyIdForPaneKey: (paneKey: string) => string | undefined } -/** A terminal that dies while Orca is down never reaches the PTY-exit teardown, so its restored - * row would claim work forever. Stand in for that exit: retire restored rows whose local PTY - * the provider proves absent, through the same path an observed exit takes. */ +/** Retire restored local rows only when the provider proves their PTY absent. */ export async function retireRestoredStatusesWithDeadTerminals( deps: RestoredStatusDeadTerminalSweepDeps ): Promise { @@ -37,7 +33,7 @@ export async function retireRestoredStatusesWithDeadTerminals( const isProvenAbsent = async (ptyId: string): Promise => { let probe = probesByPtyId.get(ptyId) if (!probe) { - // Why: a probe that throws is unverifiable, never evidence the terminal died. + // A failed probe cannot prove the terminal died. probe = deps.probePtyLiveness(ptyId).catch(() => null) probesByPtyId.set(ptyId, probe) } @@ -45,7 +41,7 @@ export async function retireRestoredStatusesWithDeadTerminals( } const candidates = deps.server.listUnconfirmedRestoredStatuses().filter( (row) => - // Why: only a local pane's PTY lives in this machine's provider. + // Only local PTYs belong to this provider. isLocalHookConnectionId(row.connectionId) && deps.isLocalExecutionHost(row.worktreeId) ) const probed = await Promise.all( @@ -55,22 +51,20 @@ export async function retireRestoredStatusesWithDeadTerminals( return { row, boundPtyId, absent: ptyId !== undefined && (await isProvenAbsent(ptyId)) } }) ) - // Why: a pane can report, or cold restore can rebind its PTY, while the probes are in flight. + // Rows and PTY bindings can change while probes are in flight. const stillRestored = new Set(deps.server.listUnconfirmedRestoredStatuses()) const ended = probed.filter( ({ row, boundPtyId, absent }) => absent && stillRestored.has(row) && deps.getBoundPtyIdForPaneKey(row.paneKey) === boundPtyId ) - // Why: the conversation outlived its terminal, so keep it resumable. + // Preserve conversations that outlived their terminals for resume. return deps.server.reconcileEndedProcessForPaneKeys( ended.map(({ row }) => row.paneKey), { preserveResumeIdentity: true } ) } -/** Index the persisted terminal layouts as `paneKey -> ptyId`. Layout leaves are - * the only persisted binding that carries a stable pane key, so tab-level PTY ids - * (legacy numeric panes) are deliberately skipped. */ +/** Only layout leaves persist stable pane keys; skip legacy tab-level PTY bindings. */ export function indexPersistedPaneKeyPtyIds( layoutsByTabId: Record } | undefined> ): Map { diff --git a/src/main/agent-hooks/runtime-home-hook-command.ts b/src/main/agent-hooks/runtime-home-hook-command.ts index 4cc61d2988f..63589e3a30b 100644 --- a/src/main/agent-hooks/runtime-home-hook-command.ts +++ b/src/main/agent-hooks/runtime-home-hook-command.ts @@ -32,7 +32,6 @@ function buildPosixBranch( return `if ${homeGuard}[ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then ${parentCapture}/bin/sh ${posixScript}; else ${missingScriptFallback}; fi` } -/** The POSIX branch of wrapRuntimeHomeHookCommand alone, for hosts known to be POSIX. */ export function wrapRuntimeHomePosixHookCommand(scriptBaseName: string): string { assertManagedScriptBaseName(scriptBaseName) return buildPosixBranch(scriptBaseName, POSIX_HOOK_STDIN_DRAIN_COMMAND, { requireHome: true }) @@ -43,34 +42,28 @@ export function wrapRuntimeHomeHookCommand( options: { neutralJsonWhenMissing?: boolean } = {} ): string { assertManagedScriptBaseName(scriptBaseName) - // Why: default-form every var — a static hook precheck (Grok) rejects the whole command on a bare - // reference it cannot resolve, even in a branch that platform never takes. + // Grok rejects unresolved bare variables even in untaken platform branches. const windowsScript = `"\${HOME-}/.orca/agent-hooks/${scriptBaseName}.cmd"` const drain = POSIX_HOOK_STDIN_DRAIN_COMMAND const neutralJson = options.neutralJsonWhenMissing ? `printf '{}\\n'` : '' - // Why two forms: the missing-script fallback owns stdin, so it follows the rule of the host - // it lands on. POSIX callers close the pipe, so capture-first is safe there and a mid-write - // exit stays visible as EPIPE (#8110). A Windows caller may abandon the pipe, so there the - // answer comes first and the drain only runs with an Orca env behind it (#11549). + // POSIX drains before answering; Windows answers and checks Orca context before reading abandoned stdin. const posixMissingScriptFallback = neutralJson ? `${drain}; ${neutralJson}` : drain const windowsMissingScriptFallback = [ ...(neutralJson ? [neutralJson] : []), WINDOWS_GIT_BASH_HOOK_ENVIRONMENT_GUARD, drain ].join('; ') - // Why platform-selected even when HOME is unset: which stdin rule applies follows the - // caller, not the reason the script could not be found. + // Stdin ownership follows the caller platform even when HOME is missing. const missingScriptFallback = `case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsMissingScriptFallback} ;; *) ${posixMissingScriptFallback} ;; esac` const powershell = '"${SYSTEMROOT-}/System32/WindowsPowerShell/v1.0/powershell.exe"' const powershellFallback = options.neutralJsonWhenMissing ? "; Write-Output '{}'" : '' - // Why the order: answer first, then the shared env guard, then own stdin — see wrapWindowsHookCommand. const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }${powershellFallback}; ${WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD}; [Console]::In.ReadToEnd() | Out-Null; exit 0` const encodedCommand = encodeWindowsPowerShellHookCommand(powershellCommand) - // Why: the Git Bash and native Windows launchers must spell the same switches — window suppression (#14815) and an AV verdict on the shape (#16003) both hit either path. + // Match native launcher switches to preserve AV compatibility across both entry paths. const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}` const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${windowsMissingScriptFallback}; fi` const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${windowsMissingScriptFallback}; fi` const posixBranch = buildPosixBranch(scriptBaseName, posixMissingScriptFallback) - // Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation. + // Shell-owned OSTYPE avoids spawning a process for each platform check. return `if [ -z "\${HOME-}" ]; then ${missingScriptFallback}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi` } diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index 250d188e3d7..b36d6b08835 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -180,7 +180,7 @@ describe('host-owned hook presence', () => { }, 'ssh-1' ) - // The relay has no identity resolution, so it can hand the nested Claude ownership. + // Relays cannot resolve process identity and may initially attribute the pane to nested Claude. server.ingestRemote( { ...base, @@ -375,7 +375,6 @@ describe('host-owned hook presence', () => { }) }) -// #24353: a remote worker's liveness reads the exit after the hook store has reconciled the row. describe('retained agent exit', () => { it('keeps a SessionEnd exit readable after the row is reconciled', async () => { const server = await createServer() @@ -558,7 +557,7 @@ describe('retained agent exit', () => { servers.push(first) await first.start({ env: 'production', userDataPath }) const attachedAt = Date.now() - 1 - // Spool stamps are whole seconds, so only one a full second before the exit predates it. + // Spool timestamps have whole-second precision. const spooledAt = Date.now() - 5_000 await hook(first, 'SessionStart') await hook(first, 'SessionEnd', 'session-a', 'prompt_input_exit') @@ -572,7 +571,6 @@ describe('retained agent exit', () => { paneKey: PANE, source: 'claude', receivedAt: spooledAt, - // An obsolete owner identity must not be installed over the proven exit. agentProcess: JSON.stringify({ pid: 4009, platform: process.platform, @@ -693,7 +691,7 @@ describe('retained agent exit lifetime', () => { expect(await server.checkAgentPresenceSince(moved, 0)).toBeNull() }) - // Why: a finished shell command retires launch authority while the PTY, and the exit's meaning, survive. + // Launch authority can retire while the PTY and retained exit remain valid. it('keeps the exit when only the pane launch authority retires', async () => { const server = await serverWithExit() server.retirePaneAuthority(PANE) @@ -717,7 +715,7 @@ describe('retained agent exit lifetime', () => { clock.mockRestore() } } - // The retained resume row still names the session, so its exit must stay with it. + // A retained resume row keeps its session exit. expect(server.getStatusSnapshot().some((row) => row.paneKey === PANE)).toBe(true) saveEightDaysLater() expect(await server.checkAgentPresenceSince(PANE, 0)).toBe('exited') diff --git a/src/main/agent-hooks/server-claude-cancel-captures.test.ts b/src/main/agent-hooks/server-claude-cancel-captures.test.ts index 11d9f7b69dd..55d1f753f05 100644 --- a/src/main/agent-hooks/server-claude-cancel-captures.test.ts +++ b/src/main/agent-hooks/server-claude-cancel-captures.test.ts @@ -1,13 +1,4 @@ -// A cancel never hides live work. These stories replay hook payloads recorded from Claude Code -// 2.1.280 over a real PTY (src/shared/__fixtures__/claude-cancel-*-hooks.jsonl, sidecars beside -// them) through the server's own HTTP ingress, and stand in for the renderer at the moments the -// driver pressed the cancel key. The captures established that a cancel fires no hook at all, -// kills only the foreground tool, and that every later Stop inventory matched the process table; -// the rules below are written against those payloads, not a remembered screen. -// -// The driver cancelled with Esc. Orca treats a bare Esc on a Claude pane as navigation (it also -// closes the /btw composer) and infers a cancel only from Ctrl+C, so each `cancel` record is -// replayed as the Ctrl+C inference the renderer would have sent. +// Replay Claude Code 2.1.280 PTY captures; recorded Esc cancellations stand in for Orca Ctrl+C inference. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS } from '../../shared/claude-owed-task-notifications' import { AgentHookServer, _internals } from './server' @@ -64,8 +55,7 @@ async function post(server: AgentHookServer, hook: CapturedHook): Promise }) } -/** The renderer's part of a cancel: capture the row as the baseline and, once the settle window - * passes with no hook, ask the server to infer the interrupt. */ +/** Infer cancellation against the captured baseline after a hook-free settle window. */ function pressCtrlC(server: AgentHookServer): boolean { const baseline = row(server) return server.inferInterrupt({ @@ -87,7 +77,7 @@ describe('a Claude cancel with a background shell (captured)', () => { for (const index of [0, 1, 2, 3, 4]) { await post(server, hookAt(records, index)) } - // The Stop that started the shell lists it; the main agent is settled and the row monitors. + // The initial Stop retains the background shell as monitoring work. expect(hookAt(records, 4).payload.background_tasks).toEqual([ expect.objectContaining({ type: 'shell', status: 'running', command: 'sleep 600' }) ]) @@ -97,7 +87,7 @@ describe('a Claude cancel with a background shell (captured)', () => { mainAgent: { state: 'done' } }) - // A foreground turn starts on top of it and is cancelled mid-tool. + // Cancel a foreground turn while the shell survives. await post(server, hookAt(records, 5)) await post(server, hookAt(records, 6)) expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) @@ -108,8 +98,7 @@ describe('a Claude cancel with a background shell (captured)', () => { expect(midTool.hooks_before_next_typed_prompt).toEqual([]) expect(hookSupersedesCancel(records, midTool)).toBe(false) expect(pressCtrlC(server)).toBe(true) - // Why: the cancel is the main agent's verdict; the shell is the inventory's fact. The row shows - // the shell, the verdict rides `mainAgent.outcome`, and `interrupted` (a done-row flag) is absent. + // Main-agent cancellation does not override live shell inventory or mark the whole row interrupted. expect(row(server)).toMatchObject({ state: 'working', workingMode: 'monitoring', @@ -117,7 +106,7 @@ describe('a Claude cancel with a background shell (captured)', () => { }) expect(row(server).interrupted).toBeUndefined() - // The next typed turn ends with the same inventory, and its plain Stop carries no verdict. + // A later plain Stop carries the same inventory without a cancellation verdict. await post(server, hookAt(records, 7)) await post(server, hookAt(records, 8)) expect(hookAt(records, 8).sleep_procs).toEqual([expect.stringContaining('sleep 600')]) @@ -128,7 +117,7 @@ describe('a Claude cancel with a background shell (captured)', () => { }) expect(row(server).mainAgent).not.toHaveProperty('outcome') - // Cancelled again while streaming a reply, with no tool running. + // Cancel again while streaming, without a foreground tool. await post(server, hookAt(records, 9)) expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) const midStream = cancelLabelled(records, 'ESC-during-stream') @@ -159,13 +148,12 @@ describe('a Claude cancel with a background shell (captured)', () => { await post(server, hookAt(records, 11)) expect(row(server)).toMatchObject({ state: 'working', workingMode: 'monitoring' }) - // The rig SIGKILLs the shell. That is not evidence yet: nothing has reported it. + // SIGKILL is not server evidence until a hook reports the changed inventory. const kill = records.find((record) => record.kind === 'kill' && record.needle === 'sleep 600') expect(kill).toBeDefined() expect(row(server)).toMatchObject({ state: 'working', workingMode: 'monitoring' }) - // The CLI notices within a second and injects a task-notification turn whose Stop reports - // an empty inventory; that Stop is what retires the shell. + // The notification turn reports empty inventory and retires the shell. const notification = hookAt(records, 12) expect(notification.payload.hook_event_name).toBe('UserPromptSubmit') expect(String(notification.payload.prompt)).toContain('') @@ -193,7 +181,6 @@ describe('a Claude cancel with a background shell (captured)', () => { ]) { await post(server, hookAt(records, index)) } - // A second shell, listed by its Stop. expect(hookAt(records, 20).payload.background_tasks).toEqual([ expect.objectContaining({ command: 'sleep 500', status: 'running' }) ]) @@ -201,22 +188,18 @@ describe('a Claude cancel with a background shell (captured)', () => { await post(server, hookAt(records, 21)) expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) - // The shell is killed, then the turn is cancelled. In the capture the CLI's death - // notification landed 0.17 s after the cancel key, inside the renderer's settle window, so - // the renderer never sent this inference; the notification turn superseded it. + // The death notification arrived inside the settle window, superseding inferred cancellation. const afterKill = cancelLabelled(records, 'ESC-during-tool-after-kill') expect(afterKill.hooks_before_next_typed_prompt).toEqual([22, 23]) expect(hookSupersedesCancel(records, afterKill)).toBe(true) - // Had the inference won the race, the row would still read the last inventory: a process - // death is not evidence until an inventory reports it, and the next Stop does so at once. + // If inference wins, retain the last inventory until the next Stop reports the death. expect(pressCtrlC(server)).toBe(true) expect(row(server)).toMatchObject({ state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done', outcome: 'cancellation' } }) - // The shell's death notification opens a real turn, so the cancel's verdict gives way to it. const notification = hookAt(records, 22) expect(String(notification.payload.prompt)).toContain('') await post(server, notification) @@ -233,8 +216,7 @@ describe('a Claude cancel with a background shell (captured)', () => { }) it('keeps a scheduled session check the cancelled turn left registered as monitoring', async () => { - // No capture registered a cron (`session_crons` was `[]` on every Stop), so this story takes - // the captured Stop and substitutes one entry; the listener reads only the list's length. + // Captures contain no crons; substitute one into a recorded Stop to exercise scheduled monitoring. const server = await startServer() try { for (const index of [0, 1, 2, 3]) { @@ -279,7 +261,7 @@ describe('a Claude cancel with a live subagent (captured)', () => { for (const index of [0, 1, 2, 3, 4, 5]) { await post(server, hookAt(records, index)) } - // 2.1.280 launches the Agent tool asynchronously: the main agent Stops with the child running. + // Claude 2.1.280 launches Agent asynchronously and Stops while the child runs. expect(hookAt(records, 3).payload.tool_response).toMatchObject({ status: 'async_launched' }) expect(hookAt(records, 5).payload.background_tasks).toEqual([ expect.objectContaining({ type: 'subagent', status: 'running' }) @@ -291,7 +273,7 @@ describe('a Claude cancel with a live subagent (captured)', () => { }) expect(row(server).workingMode).toBeUndefined() - // The main agent starts a foreground turn beside the child and is cancelled mid-tool. + // Cancel foreground work while the child survives. for (const index of [6, 7, 8]) { await post(server, hookAt(records, index)) } @@ -307,8 +289,7 @@ describe('a Claude cancel with a live subagent (captured)', () => { }) expect(row(server).workingMode).toBeUndefined() - // The child's tool activity after the cancel keeps the row working and does not resurrect - // the main agent; the next typed turn's Stop still lists the child. + // Child activity preserves working status without reviving the cancelled main agent. await post(server, hookAt(records, 9)) expect(hookAt(records, 9).payload.agent_id).toBeDefined() expect(row(server)).toMatchObject({ @@ -330,7 +311,7 @@ describe('a Claude cancel with a live subagent (captured)', () => { }) it('settles the drained row as a stopped turn, never a completed one', async () => { - // Why shouldAdvanceTime: the hooks are real loopback POSTs, which need the clock to move. + // Real loopback POSTs need fake time to advance. vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] @@ -345,15 +326,14 @@ describe('a Claude cancel with a live subagent (captured)', () => { expect(row(server)).toMatchObject({ state: 'working' }) expect(row(server).turnCompletedAt).toBeUndefined() - // Why: a completion stamp or a done without `interrupted` is what renderers announce as finished. + // Completion stamps and non-interrupted done rows trigger renderer completion announcements. for (const index of [4, 9]) { await post(server, { ...hookAt(records, index), payload: { ...hookAt(records, index).payload, hook_event_name: 'SubagentStop' } }) } - // Claude owes the main agent a task notification for the launched child, so the drained row - // is held, unstamped, until it arrives or stops being waited for. + // Wait unstamped for the launched child's owed notification or its lease expiry. expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'done', outcome: 'cancellation' } diff --git a/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts b/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts index 18a38aef82e..703a8800c88 100644 --- a/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts +++ b/src/main/agent-hooks/server-claude-child-permission-lifecycle.test.ts @@ -84,7 +84,7 @@ describe('Claude child permission lifecycle', () => { tool_name: 'Bash', tool_input: { command: 'false' } }) - // Why: the lead's own progress overwrites the listener's wait slot; only the server keeps the prompt. + // Lead progress replaces the listener wait slot; the server must retain the child prompt. await postClaudeHook({ hook_event_name: 'PreToolUse', tool_name: 'Read' }) await postClaudeHook({ hook_event_name: 'StopFailure', diff --git a/src/main/agent-hooks/server-claude-normalization.test.ts b/src/main/agent-hooks/server-claude-normalization.test.ts index 399b984a418..01fdd8cf34a 100644 --- a/src/main/agent-hooks/server-claude-normalization.test.ts +++ b/src/main/agent-hooks/server-claude-normalization.test.ts @@ -63,8 +63,6 @@ describe('Claude hook normalization', () => { }) it('flags a PostToolUse preview as tool output so native chat will not show it as the reply', () => { - // Regression: a Bash result rendered verbatim as an immortal "assistant" bubble in - // mobile native chat. The status card still gets the preview; only its provenance is new. const result = _internals.normalizeHookPayload( 'claude', buildBody({ @@ -104,7 +102,6 @@ describe('Claude hook normalization', () => { }), 'production' ) - // The prose turn must not inherit the previous tool result's flag. const stopped = _internals.normalizeHookPayload( 'claude', buildBody({ @@ -132,7 +129,7 @@ describe('Claude hook normalization', () => { }) it('PostToolUse for an unknown tool surfaces the name without input', () => { - // Why: a per-tool allowlist picks the preview field; unrecognized tools render name-only to avoid guessing a misleading field (e.g. an opaque ID). + // Unknown tools have no safe preview-field mapping; show only the name. const result = _internals.normalizeHookPayload( 'claude', buildBody({ @@ -414,11 +411,10 @@ describe('Claude hook normalization', () => { }) it('finds an assistant reply that sits past the first chunk boundary', () => { - // Why: large tool_result entries push the final reply past the first 64 KB chunk; the scan must keep reading backward to find it. + // Large tool results force the backward scan beyond its first 64-KB chunk. const filler = 'x'.repeat(70_000) const lines = [ { role: 'assistant', message: { role: 'assistant', content: 'deeply buried reply' } }, - // 70 KB of tool_result content straddling the first chunk boundary. { role: 'user', message: { @@ -476,7 +472,6 @@ describe('Claude hook normalization', () => { }), 'production' ) - // Stop event has no tool fields, so the merged snapshot must keep the earlier PreToolUse values. const stop = _internals.normalizeHookPayload( 'claude', buildBody({ hook_event_name: 'Stop' }), diff --git a/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts b/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts index 339d52d0c29..cce54f30dee 100644 --- a/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts +++ b/src/main/agent-hooks/server-claude-owed-notification-expiry.test.ts @@ -1,5 +1,4 @@ -// A task notification that never arrives must not hold the pane working for the rest of the -// session: no hook fires at the end of the lease, so the server restates the row itself. +// No hook fires at lease expiry, so the server must release owed-notification holds itself. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS } from '../../shared/claude-owed-task-notifications' import { AgentHookServer, _internals } from './server' @@ -16,7 +15,7 @@ vi.mock('../../shared/agent-process-presence-probe', () => ({ beforeEach(() => { _internals.resetCachesForTests() - // Why shouldAdvanceTime: the hooks are real loopback POSTs, which need the clock to move. + // Real loopback POSTs need fake time to advance. vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] @@ -62,7 +61,6 @@ describe('Claude owed task notification expiry on the hook server', () => { await server.post({ hook_event_name: 'SessionEnd', reason: 'prompt_input_exit' }, OWNER) vi.advanceTimersByTime(CLAUDE_OWED_TASK_NOTIFICATION_LEASE_MS) - // Only resume identity is left; nothing restates the exited agent as live. expect(server.row()).toMatchObject({ providerSessionOnly: true }) } finally { server.stop() @@ -72,7 +70,6 @@ describe('Claude owed task notification expiry on the hook server', () => { it('keeps what is owed when a nested Claude in the same pane exits', async () => { const server = await heldByAnOwedShell() try { - // The nested process is not the pane's owner, so its exit is refused. await server.post({ hook_event_name: 'SessionEnd', reason: 'prompt_input_exit' }, 5151) expect(server.row()).toMatchObject({ state: 'working' }) expect(server.row().providerSessionOnly).toBeUndefined() @@ -87,7 +84,6 @@ describe('Claude owed task notification expiry on the hook server', () => { const OWNER = 4242 -/** A pane whose owning Claude launched a background shell that then vanished unannounced. */ async function heldByAnOwedShell() { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/server-claude-statusline.test.ts b/src/main/agent-hooks/server-claude-statusline.test.ts index b40edb397d2..edb5008a285 100644 --- a/src/main/agent-hooks/server-claude-statusline.test.ts +++ b/src/main/agent-hooks/server-claude-statusline.test.ts @@ -1,5 +1,3 @@ -// Why: locks the /statusline/claude loopback contract — form-encoded posts from the -// managed statusline script must reach the listener, and junk must fail open (204). import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { AgentHookServer } from './server' import type { ClaudeStatusLineRateLimits } from '../../shared/claude-statusline-rate-limits' diff --git a/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts b/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts index 76bc5450a4e..95b45220446 100644 --- a/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts +++ b/src/main/agent-hooks/server-claude-task-wakeup-lifecycle.test.ts @@ -171,7 +171,6 @@ describe.each([false, true])('Claude wake-up production ingress remote=%s', (rem prematureCompletion.push(index) } } - // Every status subscriber must avoid a false whole-pane completion. expect(prematureCompletion).toEqual([]) expect(server.row()).toMatchObject({ state: 'done', diff --git a/src/main/agent-hooks/server-closed-tab-suppression.test.ts b/src/main/agent-hooks/server-closed-tab-suppression.test.ts index f71ba3a82a0..fe094e275a9 100644 --- a/src/main/agent-hooks/server-closed-tab-suppression.test.ts +++ b/src/main/agent-hooks/server-closed-tab-suppression.test.ts @@ -267,9 +267,7 @@ describe('AgentHookServer listener replay', () => { } }) - // STA-4114: a detach/reattach cycle retires the pane, and nothing lifted the fence. - // Reviving only on a new turn cannot help a pane re-attached mid-turn (its remaining - // events are agent_end, not a new-turn event) or one re-attached idle. + // Reattach must restore mid-turn and idle panes without waiting for a new-turn event. for (const kind of ['pi', 'omp'] as const) { it(`re-attaching a retired ${kind} pane restores status without needing a new turn`, async () => { const server = new AgentHookServer() @@ -289,8 +287,7 @@ describe('AgentHookServer listener replay', () => { await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' }) server.retirePaneAuthority(PANE) - // The turn was already running, so only its completion is left to report — - // and while retired it is suppressed. This is the reported permanent failure. + // A surviving turn may have only its completion left to report. await postHook({ hook_event_name: 'agent_end' }) expect(server.getStatusSnapshot()).toEqual([]) @@ -321,13 +318,10 @@ describe('AgentHookServer listener replay', () => { body: JSON.stringify(buildBody(payload, { launchToken: 'idle-reattach-token' })) }) - // Nothing in flight: the pane is retired and re-attached while the agent sits idle. server.retirePaneAuthority(PANE) expect(server.restorePaneAuthority(PANE)).toBe(true) - // Why: prove the fence is already down before any turn event arrives. Asserting - // only on before_agent_start would also pass if a turn boundary lifted the fence, - // so it cannot distinguish re-attach revival from turn-triggered revival (#14626). + // Use a completion event to distinguish reattach revival from new-turn revival. await postHook({ hook_event_name: 'agent_end' }) expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ paneKey: PANE, state: 'done' }) @@ -357,7 +351,6 @@ describe('AgentHookServer listener replay', () => { body: JSON.stringify(buildBody(payload, { launchToken: 'closed-tab-token' })) }) - // Pane fence AND tab fence are both standing; re-attach may lift neither. server.retirePaneAuthority(PANE) server.dropStatusEntriesByTabPrefix('tab-1') expect(server.restorePaneAuthority(PANE)).toBe(false) @@ -365,16 +358,13 @@ describe('AgentHookServer listener replay', () => { await postHook({ hook_event_name: 'before_agent_start', prompt: 'after tab close' }) expect(server.getStatusSnapshot()).toEqual([]) - // The pane fence must still be standing too, not silently lifted underneath. expect(server.restorePaneAuthority(PANE)).toBe(false) } finally { server.stop() } }) - // STA-4114: retirement fences every alias of a pane and deletes the alias itself. - // Restoring only the owner key leaves the physical key the live process still posts - // fenced forever — the detached pane, which is the canonical re-attach case. + // Reattach must rebuild retired aliases because the live process still posts its original pane key. it('re-attaching a detached pane accepts hooks on the pane key its process launched under', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -392,15 +382,13 @@ describe('AgentHookServer listener replay', () => { }) await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' }) - // Detach into another tab. The live process keeps posting PANE (server.ts:1614), - // so the alias is the only thing routing it to its new owner. + // Detached processes keep posting their original key; aliases route them to the new owner. server.transferPaneAuthority(PANE, detachedPane, 'pty-detached') server.retirePaneAuthority(detachedPane) expect(server.restorePaneAuthority(detachedPane)).toBe(true) await postHook({ hook_event_name: 'agent_end' }) - // One row under the OWNER key. Lifting the fence without rebuilding the alias - // mints a second row on the stale key instead. + // Restoring fences without aliases would create a duplicate row under the old key. expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ paneKey: detachedPane, state: 'done' }) ]) @@ -464,8 +452,7 @@ describe('AgentHookServer listener replay', () => { await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' }) server.transferPaneAuthority(PANE, detachedPane, 'pty-detached') server.retirePaneAuthority(detachedPane) - // The tab the pane was detached into is closed: the stronger claim wins, and the - // alias must not be resurrected to route a live process into a closed tab. + // Closed-tab ownership overrides pane restoration. server.dropStatusEntriesByTabPrefix('tab-2') expect(server.restorePaneAuthority(detachedPane)).toBe(false) @@ -476,9 +463,7 @@ describe('AgentHookServer listener replay', () => { } }) - // Why: the guard above short-circuits on the closed owner, so it never reaches the - // alias rebuild. Restoring the ORIGINAL key does reach it — and rebuilding the alias - // there would route a live process into the closed tab and silence it again. + // Restoring the original key must not rebuild an alias into the closed owner tab. it('re-opens the original pane instead of rebuilding an alias into a closed tab', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -500,7 +485,6 @@ describe('AgentHookServer listener replay', () => { server.retirePaneAuthority(detachedPane) server.dropStatusEntriesByTabPrefix('tab-2') - // The pane the process actually lives in is tab-1, which is still open. expect(server.restorePaneAuthority(PANE)).toBe(true) await postHook({ hook_event_name: 'agent_end' }) @@ -512,15 +496,7 @@ describe('AgentHookServer listener replay', () => { } }) - // Why: closedAgentStatusTabIds is LRU-bounded, so the tab fence is not permanent. - // If restoring a sibling key lifted the closed tab's PANE fence too, eviction of the - // tab id would leave nothing at all holding that pane shut. - // - // Why a non-boundary event: a genuine new turn deliberately lifts the pane fence - // (STA-3386), so it cannot be used to prove the fence exists. Measured on the pre-fix - // tree, `claude` + `UserPromptSubmit` already revived this pane; this case only ever - // passed with `before_agent_start` because the gate matched two raw literals and did not - // recognize pi's boundary. The control below keeps it from passing for that reason again. + // After tab-ID eviction, the pane fence must remain; use a non-boundary event that cannot lift it. it('leaves a closed tab pane fenced once its tab id is evicted', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -556,8 +532,7 @@ describe('AgentHookServer listener replay', () => { ) expect(server.getStatusSnapshot()).toEqual([]) - // Why: proves the empty snapshot above came from the fence and not from an inert - // event — the same post on an unfenced pane must produce a row. + // An unfenced control proves the event itself is active. await postHook( { hook_event_name: 'agent_end', prompt: 'unfenced control' }, { paneKey: makePaneKey('tab-9', LEAF_3), tabId: 'tab-9' } @@ -568,9 +543,7 @@ describe('AgentHookServer listener replay', () => { } }) - // Why: a detach re-points a legacy numeric alias at an owner in another tab, so the - // fence can hold keys from two tabs at once. A legacy key never parses as a stable - // one, so a stable-only tab check would wave it through when its own tab is closed. + // Detached legacy aliases can span tabs; their own closed tab must remain fenced. it('keeps a legacy alias fenced when its own tab closed but the owner tab did not', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -595,8 +568,7 @@ describe('AgentHookServer listener replay', () => { server.retirePaneAuthority(detachedPane) server.dropStatusEntriesByTabPrefix('tab-1') - // The owner tab is open, so the restore proceeds — but the legacy key's own tab - // is closed, and its alias must not be rebuilt into the still-open owner. + // Restore the open owner without reviving an alias from the closed tab. expect(server.restorePaneAuthority(detachedPane)).toBe(true) await postHook({ hook_event_name: 'before_agent_start', prompt: 'after tab-1 close' }) @@ -627,7 +599,6 @@ describe('AgentHookServer listener replay', () => { server.registerPaneKeyAlias(legacyPane, PANE, 'pty-old') server.retirePaneAuthority(PANE) - // The pane rebound to a different owner before the restore landed. server.registerPaneKeyAlias(legacyPane, reboundPane, 'pty-new') server.restorePaneAuthority(PANE) @@ -658,8 +629,7 @@ describe('AgentHookServer listener replay', () => { await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' }) server.retirePaneAuthority(PANE) - // Why: `claude --resume` in the reused shell pane emits only SessionStart while idle; - // without the un-retire the resumed session stays rowless until a prompt (STA-3386). + // Idle Claude resume emits only SessionStart; restoration cannot wait for a prompt. await postHook({ hook_event_name: 'SessionStart', source: 'resume' }) expect(server.getStatusSnapshot()).toEqual([ @@ -737,7 +707,6 @@ describe('AgentHookServer closed-tab suppression bound', () => { internals.markTabClosedForAgentStatus(`closed-tab-${i}`) } - // Set stays bounded; oldest ids are evicted, most-recent are retained. expect(internals.closedAgentStatusTabIds.size).toBe(CLOSED_AGENT_STATUS_TAB_IDS_MAX) expect(internals.closedAgentStatusTabIds.has('closed-tab-0')).toBe(false) expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true) diff --git a/src/main/agent-hooks/server-codex-interrupt-hook.test.ts b/src/main/agent-hooks/server-codex-interrupt-hook.test.ts index 9604951da09..aa8360341ef 100644 --- a/src/main/agent-hooks/server-codex-interrupt-hook.test.ts +++ b/src/main/agent-hooks/server-codex-interrupt-hook.test.ts @@ -24,8 +24,7 @@ afterEach(() => { vi.restoreAllMocks() }) -// Why: the order (and the late PostToolUse) is what codex-cli 0.159.3 posted live for Esc on an -// approval prompt, then Esc while `sleep 40` ran; the shell finished 24s after the Interrupt. +// Codex CLI 0.159.3 capture order: approval Esc, tool Esc, then PostToolUse 24 seconds after Interrupt. const TURN_1 = { session_id: 'session-1', turn_id: 'turn-1', model: 'gpt-5.5' } const TURN_2 = { session_id: 'session-1', turn_id: 'turn-2', model: 'gpt-5.5' } const BASH = { tool_name: 'Bash', tool_input: { command: 'sleep 40' }, tool_use_id: 'exec-2' } diff --git a/src/main/agent-hooks/server-codex-normalization.test.ts b/src/main/agent-hooks/server-codex-normalization.test.ts index 0cc6c0b76af..c38d3ab1855 100644 --- a/src/main/agent-hooks/server-codex-normalization.test.ts +++ b/src/main/agent-hooks/server-codex-normalization.test.ts @@ -144,7 +144,6 @@ describe('Codex hook normalization', () => { }) it('PreToolUse surfaces tool name + input preview and stays in working state', () => { - // Why: Codex's PreToolUse fires for every tool call (not an approval), so map it to `working` not `waiting`; approvals flow through PermissionRequest. const result = _internals.normalizeHookPayload( 'codex', buildBody({ @@ -160,7 +159,6 @@ describe('Codex hook normalization', () => { }) it('PermissionRequest maps to waiting and surfaces the pending tool input', () => { - // Why: PermissionRequest must map to `waiting` (sidebar red dot); treating it like PreToolUse would leave the pane looking busy while blocked on the user. const result = _internals.normalizeHookPayload( 'codex', buildBody({ @@ -177,7 +175,6 @@ describe('Codex hook normalization', () => { }) it('UserPromptSubmit does not extract tool fields even when the payload carries them', () => { - // Why: UserPromptSubmit is a turn boundary; tool extraction is gated to Pre/PostToolUse so stray tool_name can't leak into the preview. const result = _internals.normalizeHookPayload( 'codex', buildBody({ @@ -194,7 +191,6 @@ describe('Codex hook normalization', () => { }) it('SessionStart clears cached tool state from a prior session', () => { - // Seed a Stop snapshot with an assistant message. _internals.normalizeHookPayload( 'codex', buildBody({ @@ -550,7 +546,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s hook normalization', (sour }) it('SessionBusy does NOT clear the cached user prompt', () => { - // Why: OpenCode caches the user's MessagePart before SessionBusy fires, so the cached prompt is this turn's; clearing it would clobber the dashboard. + // OpenCode caches this turn's user MessagePart before SessionBusy. _internals.normalizeHookPayload( source, buildBody({ hook_event_name: 'MessagePart', role: 'user', text: 'new prompt' }), @@ -625,7 +621,6 @@ describe.each(['opencode', 'opencode2'] as const)('%s hook normalization', (sour }) it('AskUserQuestion maps to waiting', () => { - // Why: AskUserQuestion leaves the agent idle-but-waiting on a human, so it must map to `waiting` (red dot) like permission.asked, not stay `working`. const result = _internals.normalizeHookPayload( source, buildBody({ hook_event_name: 'AskUserQuestion' }), @@ -676,7 +671,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s hook normalization', (sour }) it('caps oversized MessagePart text from stale (pre-throttle) plugin builds', () => { - // Why: stale plugin builds re-post the full reply on every part update, so the listener must cap the text to keep per-event work O(cap). + // Older plugins resend full replies on each update; cap per-event work. const assistant = _internals.normalizeHookPayload( source, buildBody({ @@ -688,7 +683,6 @@ describe.each(['opencode', 'opencode2'] as const)('%s hook normalization', (sour ) expect(assistant?.payload.lastAssistantMessage?.length).toBe(8_000) - // Why: prompt is capped at 200 by normalizeAgentStatusObject; assert oversized input still stays within that bound. const user = _internals.normalizeHookPayload( source, buildBody({ diff --git a/src/main/agent-hooks/server-codex-review-turn-end.test.ts b/src/main/agent-hooks/server-codex-review-turn-end.test.ts index 9099490d648..0ff45c985d3 100644 --- a/src/main/agent-hooks/server-codex-review-turn-end.test.ts +++ b/src/main/agent-hooks/server-codex-review-turn-end.test.ts @@ -73,7 +73,7 @@ describe('Codex turn completion without Stop', () => { await startHost(false) const childPath = join(directory, 'review-child.jsonl') writeFileSync(childPath, `${reviewRecords[0]}\n`) - // Real review hooks carry the root session ID and child turn/path, with no agent_id or Stop. + // Review hooks use the root session with child turn/path and emit no agent_id or Stop. const review = { transcript_path: childPath, turn_id: 'child-turn' } await hook({ ...review, hook_event_name: 'UserPromptSubmit', prompt: 'Review changes' }) for (const hookEventName of ['PreToolUse', 'PostToolUse', 'PreToolUse', 'PostToolUse']) { @@ -96,7 +96,6 @@ describe('Codex turn completion without Stop', () => { // Desktops that predate host-owned turn verdicts still recognize a labelled Stop. expect(published.at(-1)).toBe('Stop') - // Neither a compaction queued before the next prompt nor a newer turn replays the review's end. await hook({ hook_event_name: 'SessionStart', source: 'compact', transcript_path: childPath }) await settle() expect(published.at(-1)).toBe('SessionStart') @@ -109,11 +108,10 @@ describe('Codex turn completion without Stop', () => { }) await settle() expect(desktop.getStatusSnapshot()[0]).toMatchObject({ mainAgent: { state: 'working' } }) - // A delayed hook from the review's ended turn does not end the newer turn. await hook({ ...review, hook_event_name: 'PostToolUse', tool_name: 'Bash' }) expect(desktop.getStatusSnapshot()[0]).toMatchObject({ mainAgent: { state: 'working' } }) - // A real Stop that lands before its rollout end, then a compaction, never rearms that turn. + // A Stop consumed before rollout completion must not be rearmed by compaction. await hook({ ...review, turn_id: 'next-turn', hook_event_name: 'Stop' }) await hook({ hook_event_name: 'SessionStart', source: 'compact', transcript_path: childPath }) appendFileSync(childPath, event({ type: 'task_complete', turn_id: 'next-turn' })) @@ -156,7 +154,7 @@ describe('Codex turn completion without Stop', () => { }), { timeout: 3_000 } ) - // Desktops derive a labelled Stop's outcome as success, so a failure goes out unlabelled. + // Legacy desktops interpret labelled Stops as success; leave failures unlabelled. expect(published.at(-1)).toBeUndefined() } ) diff --git a/src/main/agent-hooks/server-codex-subagent-transcript.test.ts b/src/main/agent-hooks/server-codex-subagent-transcript.test.ts index dd771d8b2be..afbb4e343e2 100644 --- a/src/main/agent-hooks/server-codex-subagent-transcript.test.ts +++ b/src/main/agent-hooks/server-codex-subagent-transcript.test.ts @@ -96,7 +96,7 @@ describe('AgentHookServer Codex subagent transcript polling', () => { } }) - // Why: the poll re-arms off the object it just stored; if that identity ever drifts it stops after the first change. + // Poll rearming depends on retaining the identity of the stored roster object. it('keeps polling across successive roster changes', async () => { const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) dirs.push(dir) @@ -153,7 +153,7 @@ describe('AgentHookServer Codex subagent transcript polling', () => { } }) - // Why: a nested non-codex CLI inherits the pane's ORCA_PANE_KEY, so its hook must not tear down the codex poll. + // Nested CLIs inherit the pane key and must not tear down the owning Codex poll. it('keeps polling when a nested non-codex hook lands on the same pane', async () => { const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) dirs.push(dir) @@ -189,7 +189,6 @@ describe('AgentHookServer Codex subagent transcript polling', () => { transcript_path: join(dir, 'nested-copilot-transcript.jsonl') }) expect(nested.status).toBe(204) - // The nested completion is suppressed, so the pane is still the same live codex turn. expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex') expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(1) diff --git a/src/main/agent-hooks/server-copilot-normalization.test.ts b/src/main/agent-hooks/server-copilot-normalization.test.ts index f5fab6520e1..159c7aba8aa 100644 --- a/src/main/agent-hooks/server-copilot-normalization.test.ts +++ b/src/main/agent-hooks/server-copilot-normalization.test.ts @@ -133,7 +133,7 @@ describe('Copilot hook normalization', () => { }), 'production' ) - // Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error. + // A retained tool name would hide the failure text in compact status cards. expect(failed?.payload).toMatchObject({ state: 'working', lastAssistantMessage: 'command not found' diff --git a/src/main/agent-hooks/server-cursor-normalization.test.ts b/src/main/agent-hooks/server-cursor-normalization.test.ts index 8a93c52e8b1..6d087e0ac8f 100644 --- a/src/main/agent-hooks/server-cursor-normalization.test.ts +++ b/src/main/agent-hooks/server-cursor-normalization.test.ts @@ -128,7 +128,7 @@ describe('Cursor hook normalization', () => { }), 'production' ) - // Why: keeping toolName would let the compact sidebar show the tool instead of the failure text, hiding the error. + // A retained tool name would hide the failure text in compact status cards. expect(failed?.payload).toMatchObject({ state: 'working', lastAssistantMessage: 'file not found' diff --git a/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts b/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts index 175fecece19..f19be269c21 100644 --- a/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts +++ b/src/main/agent-hooks/server-endpoint-file-lifecycle.test.ts @@ -78,7 +78,6 @@ describe('Endpoint file lifecycle', () => { await server.start({ env: 'production', userDataPath }) try { const filePath = server.endpointFilePath! - // Why: mask to the rwx octet so we assert only the file's mode:0o600, not umask-leaked bits on the parent dir. const mode = statSync(filePath).mode & 0o777 expect(mode).toBe(0o600) } finally { @@ -98,13 +97,12 @@ describe('Endpoint file lifecycle', () => { const secondPath = server.endpointFilePath const secondPort = server.buildPtyEnv().ORCA_AGENT_HOOK_PORT const secondToken = server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN - // Path is stable (so PTYs stamped before restart can still find the file) + // Surviving PTYs must find the same endpoint path after restart. expect(secondPath).toBe(firstPath) - // Contents refresh with a new token so stale-env survivors reach the live server. expect(secondToken).toBeTruthy() expect(secondToken).not.toBe(firstToken) const contents = readFileSync(secondPath!, 'utf8') - // Why: assert on token (randomUUID, can't collide), not port — listen(0) may legitimately reuse the ephemeral port and flake a port check. + // Compare regenerated tokens; ephemeral ports may legitimately be reused. expect(contents).toContain(`ORCA_AGENT_HOOK_PORT=${secondPort}`) expect(contents).toContain(`ORCA_AGENT_HOOK_TOKEN=${secondToken}`) expect(contents).not.toContain(`ORCA_AGENT_HOOK_TOKEN=${firstToken}`) @@ -114,7 +112,7 @@ describe('Endpoint file lifecycle', () => { }) it('leaves the endpoint file in place on stop()', async () => { - // Why: stop() leaves the file (stale = fail-open); unlinking would race a concurrent Orca instance rewriting it between token-check and unlink (TOCTOU). + // Unlinking could race another Orca instance replacing the endpoint; stale files fail open. const server = new AgentHookServer() await server.start({ env: 'production', userDataPath }) const filePath = server.endpointFilePath! @@ -154,7 +152,6 @@ describe('Endpoint file lifecycle', () => { }) it('buildPtyEnv omits ORCA_AGENT_HOOK_ENDPOINT when no userDataPath was provided', async () => { - // Why: the endpoint file is opt-in via userDataPath; without it, hooks fall back to v1 behavior (no ENDPOINT key). const server = new AgentHookServer() await server.start({ env: 'production' }) try { @@ -173,7 +170,7 @@ describe('Endpoint file lifecycle', () => { }) it('sweeps stale .endpoint-*.tmp orphans older than 5 minutes on start', async () => { - // Why: a crash between tmp-write and rename orphans a tmp; sweep must drop stale ones (>5min) but spare a concurrent writer's fresh in-flight tmp. + // Sweep crash residue while preserving fresh temporary files from concurrent writers. const dir = join(userDataPath, 'agent-hooks') mkdirSync(dir, { recursive: true }) const staleTmp = join(dir, '.endpoint-999-stale.tmp') @@ -194,13 +191,12 @@ describe('Endpoint file lifecycle', () => { }) it('refuses to write the endpoint file when a value contains shell metacharacters', async () => { - // Why: written values are sourced as shell, so isShellSafeEndpointValue must reject metacharacters to prevent command injection. + // Endpoint values are sourced as shell, so metacharacters must be rejected. const server = new AgentHookServer() await server.start({ env: 'bad;value', userDataPath }) try { expect(existsSync(server.endpointFilePath!)).toBe(false) expect(server.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() - // PORT/TOKEN still flow via PTY env — fail-open to v1 behavior. expect(server.buildPtyEnv().ORCA_AGENT_HOOK_PORT).toBeTruthy() expect(server.buildPtyEnv().ORCA_AGENT_HOOK_TOKEN).toBeTruthy() } finally { @@ -217,7 +213,6 @@ describe('Endpoint file lifecycle', () => { try { const filePath = server.endpointFilePath! const expectedPort = server.buildPtyEnv().ORCA_AGENT_HOOK_PORT - // Why: source the file exactly as the managed hook script does, catching drift from the KEY=VALUE shape before users do. const out = execFileSync('/bin/sh', ['-c', `. "${filePath}" && echo "$ORCA_AGENT_HOOK_PORT"`]) .toString() .trim() diff --git a/src/main/agent-hooks/server-escape-navigation-inference.test.ts b/src/main/agent-hooks/server-escape-navigation-inference.test.ts index ba2dba544e4..c239aaaa49e 100644 --- a/src/main/agent-hooks/server-escape-navigation-inference.test.ts +++ b/src/main/agent-hooks/server-escape-navigation-inference.test.ts @@ -43,9 +43,7 @@ function ingest(server: AgentHookServer, row: HookRow): void { ) } -/** The request `createAgentInterruptInference` emits for this row after its settle window. - * Its shape is pinned on the renderer side by agent-interrupt-inference.test.ts; the renderer - * cannot be imported here because tsconfig.node.json maps neither `@/` nor renderer sources. */ +/** Mirror the renderer inference request; node-side path mappings cannot import its implementation. */ function pressInterruptKey( server: AgentHookServer, intent: AgentInterruptInputIntent, @@ -120,7 +118,7 @@ describe('navigation Escape during an open tool call', () => { vi.setSystemTime(1_200) expect(pressInterruptKey(server, 'plain-escape')).toBe(false) - // Why: OMP's second Escape is more navigation, so even a double-press request must not infer. + // A second OMP Escape still means navigation. vi.setSystemTime(1_400) expect(pressInterruptKey(server, 'plain-escape', 2)).toBe(false) @@ -236,15 +234,12 @@ describe('navigation Escape during an open tool call', () => { vi.setSystemTime(1_200) expect(pressInterruptKey(server, 'plain-escape')).toBe(true) - // Why: dismissal restores the lead state the question displaced, so the waiting card clears. expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ state: 'working' }) }) }) -/** The rule has to hold on the path a real agent CLI uses, not only on ingestRemote: the loopback - * listener is what a locally launched agent posts to. */ describe('navigation Escape over the loopback hook listener', () => { const cases: { hookEventName: 'PreToolUse' | 'PostToolUse' diff --git a/src/main/agent-hooks/server-grok-cancel.test.ts b/src/main/agent-hooks/server-grok-cancel.test.ts index 62fd17495b4..8f775606cad 100644 --- a/src/main/agent-hooks/server-grok-cancel.test.ts +++ b/src/main/agent-hooks/server-grok-cancel.test.ts @@ -1,5 +1,4 @@ -// Grok 1.0.41, measured: Ctrl+C mid-turn fires `stop_cancelled` listing the finite tasks the turn -// left running, and Ctrl+C at the idle prompt kills no background task. +// Grok 1.0.41 captures show stop_cancelled retains live tasks and idle Ctrl+C leaves them running. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from './server' import { buildBody, PANE } from './server.test-fixtures' @@ -105,7 +104,7 @@ describe('a Grok cancel never hides a running task', () => { }) expect(row(server)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) - // Why: the inference can win the settle race; the row cannot see the task behind a working main agent. + // Inference can win before inventory exposes tasks behind a working main agent. expect(pressCtrlC(server)).toBe(true) expect(row(server)).toMatchObject({ state: 'done', diff --git a/src/main/agent-hooks/server-hook-http-ingest.test.ts b/src/main/agent-hooks/server-hook-http-ingest.test.ts index 9a2e7ae7735..805771b918b 100644 --- a/src/main/agent-hooks/server-hook-http-ingest.test.ts +++ b/src/main/agent-hooks/server-hook-http-ingest.test.ts @@ -336,7 +336,6 @@ describe('AgentHookServer listener replay', () => { } }) - // Why (agent-status-over-SSH §3): ingestRemote must run the same warn-once diagnostics as the local HTTP path so stale remote hooks signal locally. it('runs warn-once env/version diagnostics on relay-forwarded events', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -394,7 +393,6 @@ describe('AgentHookServer listener replay', () => { 'conn-1' ) expect(warn.mock.calls.length).toBe(warnsAfterFirst) - // Why: assert fanout still fires on the second event too, else a refactor that drops it would pass on warn-count alone. expect(listener).toHaveBeenCalledTimes(2) } finally { server.stop() diff --git a/src/main/agent-hooks/server-ingest-remote.test.ts b/src/main/agent-hooks/server-ingest-remote.test.ts index 2cf1310b005..9725f3d848c 100644 --- a/src/main/agent-hooks/server-ingest-remote.test.ts +++ b/src/main/agent-hooks/server-ingest-remote.test.ts @@ -114,7 +114,6 @@ describe('AgentHookServer ingestRemote', () => { 'conn-1' ) - // The session-only path returns early, so it must not skip the plugin tap. expect(rendererListener).toHaveBeenCalledWith( expect.objectContaining({ paneKey: PANE, providerSessionOnly: true }) ) @@ -461,7 +460,7 @@ describe('AgentHookServer ingestRemote', () => { const server = new AgentHookServer() const listener = vi.fn() server.setListener(listener) - // Why: bypass parseAgentStatusPayload with an invalid payload — ingestRemote is the trust boundary under test, not the parser. + // Bypass parsing to exercise ingestRemote as an untrusted-input boundary. server.ingestRemote( { paneKey: PANE, @@ -484,7 +483,6 @@ describe('AgentHookServer ingestRemote', () => { } const listener = vi.fn() server.setListener(listener) - // 201 chars — one past the listener's 200-char cap. const oversized = 'a'.repeat(201) server.ingestRemote( { paneKey: oversized, tabId: 'tab-1', worktreeId: 'wt-1', payload }, @@ -612,7 +610,6 @@ describe('AgentHookServer ingestRemote', () => { }) it('normalizes inner payload via normalizeAgentStatusPayload — clamps oversized prompt', () => { - // Why: a buggy/malicious relay could forward an over-cap field, so ingestRemote re-runs the normalizer to enforce the AGENT_STATUS_MAX_FIELD_LENGTH cap at the trust boundary. const server = new AgentHookServer() const listener = vi.fn() server.setListener(listener) diff --git a/src/main/agent-hooks/server-ingest-structured-children.test.ts b/src/main/agent-hooks/server-ingest-structured-children.test.ts index 4f61e3f5348..dbaff21b90d 100644 --- a/src/main/agent-hooks/server-ingest-structured-children.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-children.test.ts @@ -77,7 +77,6 @@ describe('structured child-work ingest', () => { membership: 'live' }) ]) - // Nothing reads the records yet: every published row is exactly what it was. expect(server.getStatusSnapshot()).toEqual(rows) expect(changed).not.toHaveBeenCalled() }) @@ -116,9 +115,7 @@ describe('structured child-work ingest', () => { expect(server.getStructuredChildWork(SUBJECT)).toEqual([first]) }) - // A quit stops hook ingress before the chat teardown decides which chats to offer to resume, - // and that decision reads these records: a chat whose only work was a background command or a - // subagent was otherwise never offered. + // Quit reads child records after hook ingress stops to decide which chats to offer for resume. it('keeps the children readable through a quit stop', () => { const server = new AgentHookServer() server.ingestStructuredStatus(summary(), SUBJECT) diff --git a/src/main/agent-hooks/server-ingest-structured-state-clock.test.ts b/src/main/agent-hooks/server-ingest-structured-state-clock.test.ts index 8d5fae03545..cbb68b9eac1 100644 --- a/src/main/agent-hooks/server-ingest-structured-state-clock.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-state-clock.test.ts @@ -1,6 +1,4 @@ -// The host's status row takes its state clock from the summary's `statusStartedAt`, the session's -// own lifecycle clock, so `worktree ps`, mobile and the dashboard date a parent the way the sidebar -// does. An older summary without the clock keeps the ingest's own continuity rule. +// Use the session lifecycle clock across projections; older summaries retain ingest continuity. import { beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' @@ -85,7 +83,6 @@ describe("the host row's state clock", () => { summary({ status: 'working', statusStartedAt: 10_000, updatedAt: 10_000 }), SUBJECT ) - // The row reads child liveness from the host's records, not the summary's task list. server.ingestStructuredChildWork(SUBJECT, [liveChild(23_000)], 'codex') server.ingestStructuredStatus(summary({ statusStartedAt: SETTLED, updatedAt: 24_000 }), SUBJECT) expect(row(server)).toMatchObject({ state: 'working', stateStartedAt: 10_000 }) diff --git a/src/main/agent-hooks/server-ingest-structured-status.test.ts b/src/main/agent-hooks/server-ingest-structured-status.test.ts index 3752a902dec..fb0ce6a3910 100644 --- a/src/main/agent-hooks/server-ingest-structured-status.test.ts +++ b/src/main/agent-hooks/server-ingest-structured-status.test.ts @@ -40,7 +40,6 @@ const TAB = structuredAgentSessionTabId(SESSION) const STRUCTURED_PANE = structuredAgentSessionPaneKey(TAB, SESSION) const OBSERVED_AT = 1_757_030_400_000 -/** A child the provider reports live, as the structured producers hand it to the host. */ function liveChild(id: string, kind: 'agent' | 'command'): AgentChildWorkEvidence { return { type: 'live', @@ -111,7 +110,7 @@ describe('AgentHookServer ingestStructuredStatus', () => { toolInput: 'sleep 30', lastAssistantMessage: 'on it', structuredHost: 'owned', - // The journal clock, not the ingest clock: a restart's republish is not new evidence. + // Restart republishing must not turn journal evidence into a fresh observation. evidenceObservedAt: OBSERVED_AT, stateStartedAt: OBSERVED_AT }) @@ -119,7 +118,6 @@ describe('AgentHookServer ingestStructuredStatus', () => { expect(server.getStatusSnapshot()[0]?.observation?.origin).toBe('structured') }) - // The same mapping the sidebar applies, so the two surfaces cannot disagree about one session. it('maps attention to blocked and idle to done', () => { const server = new AgentHookServer() server.ingestStructuredStatus(summary({ status: 'attention' }), SUBJECT) @@ -153,8 +151,6 @@ describe('AgentHookServer ingestStructuredStatus', () => { expect(server.getStatusSnapshot()[0]).not.toHaveProperty('workingMode') }) - // The summary's task list is derived from these records for older clients; the row reads the - // records themselves, so a list that disagrees with them cannot hold the parent open. it("reads child liveness from the host's records, never from the summary's task list", () => { const server = new AgentHookServer() server.ingestStructuredStatus( @@ -167,8 +163,7 @@ describe('AgentHookServer ingestStructuredStatus', () => { expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done' }) }) - // The timer beside the row belongs to the state it labels: monitoring that becomes a real turn - // must not report the watch loop's age as how long the agent has been working. + // Monitoring age must not become the age of a newly working turn. it('restarts the state clock when monitoring becomes a real turn', () => { const server = new AgentHookServer() server.ingestStructuredStatus(summary({ status: 'idle' }), SUBJECT) @@ -191,9 +186,7 @@ describe('AgentHookServer ingestStructuredStatus', () => { expect(server.getStatusSnapshot()[0]).not.toHaveProperty('workingMode') }) - // `summary.updatedAt` is the journal's last activity, which a background-task edge does not - // advance. Dating the row by it aged a genuinely live monitoring session past the 30-minute - // staleness window every reader applies, so freshness is stamped when this host observed it. + // Background edges do not advance journal activity; host receipt must keep live monitoring rows fresh. it('dates a task edge by when the host saw it, not by the journal clock', () => { const server = new AgentHookServer() const before = Date.now() @@ -227,7 +220,6 @@ describe('AgentHookServer ingestStructuredStatus', () => { }) }) - // Null status means no turn has been persisted; the chat shows nothing, so neither does this. it('holds no row for a session without a persisted turn, and drops one that regresses to none', () => { const server = new AgentHookServer() server.ingestStructuredStatus(summary({ status: null }), SUBJECT) @@ -238,9 +230,7 @@ describe('AgentHookServer ingestStructuredStatus', () => { expect(server.getStatusSnapshot()).toEqual([]) }) - // The resume-identity remnant a dismissed PTY pane keeps exists so the agent can be resumed in - // that pane. A structured session has no pane, and the record store owns its resume identity — - // so a remnant here would be an unclearable row that every null-status publish re-minted. + // Structured resume identity belongs to the record store; a PTY-style remnant here could never be cleared. it('leaves no resume-identity remnant behind, even carrying a provider session', () => { const server = new AgentHookServer() const withProviderSession = summary({ @@ -256,10 +246,7 @@ describe('AgentHookServer ingestStructuredStatus', () => { expect(server.getStatusSnapshot()).toEqual([]) }) - // Structured rows are never serialized, so persisting one could only rewrite the file already - // on disk — once per debounce window for the whole of every streaming chat. - // "Exactly one writer per pane key" has to hold for deletes too: the renderer's feed bridge owns - // this pane, so a pane-status-clear would be main reaching into a row it does not write. + // The renderer feed owns structured panes, so host deletion must neither clear them nor rewrite hook persistence. it('drops the row without sending the renderer a clear for a pane it does not write', () => { const server = new AgentHookServer() const cleared: unknown[] = [] @@ -305,8 +292,7 @@ describe('structured rows and last-status.json', () => { return join(userDataPath, 'agent-hooks', 'last-status.json') } - // The journal is the durable truth and the host republishes on restore; a persisted copy would - // hydrate as unconfirmed and fight that republish. + // Restore republishes from the journal; a second persisted copy would compete as unconfirmed state. it('are never written, while hook rows still are', async () => { const server = new AgentHookServer() await server.start({ env: 'production', userDataPath }) @@ -377,7 +363,7 @@ describe('the main agent fact on a structured row', () => { }) expect(server.getStatusSnapshot()[0]?.mainAgent).not.toHaveProperty('outcome') - // The main agent is still done while its child drains: the main agent's clock does not move. + // Child drain does not change the settled main agent's clock. server.ingestStructuredChildWork(SUBJECT, [endedChild('c')], 'codex') server.ingestStructuredStatus( summary({ status: 'idle', updatedAt: OBSERVED_AT + 5, turnOutcome: 'failure' }), diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index bbb6b9ae478..1d9fb46736c 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -164,9 +164,7 @@ describe('AgentHookServer ingestTerminalStatus', () => { expect(server.getStatusSnapshot()[0]?.turnCompletedAt).toBeUndefined() }) - // Why: the OSC 9999 payload cannot carry a provider session, so letting it overwrite the row - // erased the session id from persisted rows and from headless `orca serve` — which serves these - // rows straight to mobile — leaving Chat UI with no transcript to subscribe to (#10630). + // OSC lacks provider session identity; overwriting it would break mobile transcript subscriptions after restart. it('keeps the cached provider session when an OSC status completes the turn', () => { const server = new AgentHookServer() const providerSession = { key: 'session_id' as const, id: 'claude-session-1' } @@ -188,10 +186,7 @@ describe('AgentHookServer ingestTerminalStatus', () => { expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', providerSession }) }) - // Why: an OSC ping that names no agent — or the literal 'unknown', which - // resolveAgentStatusIdentity treats identically — makes no claim about the pane's identity, so it - // must not read as a mismatch. Missing this stripped the session from persisted and headless rows - // while the live renderer kept it, blanking mobile chat only after a restart (#10630). + // Missing or unknown OSC agent identity cannot contradict the cached provider session. it.each([undefined, 'unknown' as const])( 'keeps the cached provider session when an OSC status claims agentType %s', (agentType) => { @@ -478,7 +473,7 @@ describe('the main agent fact across an OSC repaint', () => { mainAgent: { state: 'done', stateStartedAt: 10 } }) - // OSC cannot date a turn edge: a different state is a main agent it has no fact about. + // OSC has no main-agent turn evidence to date a turn edge. server.ingestTerminalStatus({ paneKey: PANE, connectionId: 'conn-1', diff --git a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts index fa0fec6e8cd..e978248e30d 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -120,7 +120,7 @@ describe('AgentHookServer listener replay', () => { state: 'working', prompt: 'review loop', agentType: 'claude', - // Why: a working pane can be child-driven while the lead is idle; Ctrl+C doesn't stop children, so no terminal-done may be inferred here. + // Child-driven working status cannot prove Ctrl+C ended the lead's turn. subagents: [{ id: 'a1', state: 'working', startedAt: 900 }] } }, @@ -146,8 +146,7 @@ describe('AgentHookServer listener replay', () => { }) it('does not infer an interrupt while Claude reports a background shell and the row carries no main agent fact', () => { - // Why: a row from a host too old to publish `mainAgent` cannot say whether its `working` is the - // main agent's own turn or a shell holding an idle main agent open, so the evidence guard stays for it. + // Legacy hosts omit mainAgent, so working may mean an idle lead held open by live shell work. vi.useFakeTimers() vi.setSystemTime(1_000) try { @@ -239,9 +238,7 @@ describe('AgentHookServer listener replay', () => { intent: 'ctrl-c' }) ).toBe(true) - // Why: Ctrl+C ended the main agent's turn, not the shell the inventory reported. The synthesized - // row is the fold of the cancelled main agent with that shell, so it monitors; the verdict rides - // `mainAgent.outcome`, and `interrupted` is a done-row flag the normalizer keeps off it. + // Cancel the main agent without discarding live shell inventory or marking the working row interrupted. const inferred = server.getStatusSnapshot()[0] expect(inferred).toMatchObject({ state: 'working', @@ -290,8 +287,7 @@ describe('AgentHookServer listener replay', () => { vi.setSystemTime(1_500) expect(cancel('first')).toBe(true) - // Why: the relay owns this pane's main agent record, so the host's local one still says the - // first cancel; the second cancel must not inherit that clock. + // Relay-owned turns must not inherit the local record's earlier cancellation clock. vi.setSystemTime(5_000) relayTurn('second', 5_000) vi.setSystemTime(6_000) @@ -338,8 +334,7 @@ describe('AgentHookServer listener replay', () => { intent: 'ctrl-c' }) ).toBe(true) - // Why: the child outlives the cancel (a relayed pane has no local roster, so the row's own - // snapshots are the evidence); the main agent's verdict is recorded without retiring the child. + // Relayed rows carry their own child evidence; main-agent cancellation must preserve it. expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', subagents: [{ id: 'a1', state: 'working' }], @@ -375,7 +370,7 @@ describe('AgentHookServer listener replay', () => { const baseline = server.getStatusSnapshot()[0] vi.setSystemTime(1_500) - // Why: the main agent is already settled; Ctrl+C at its prompt stops nothing the row shows. + // Ctrl+C at an idle lead's prompt stops none of the child work shown. expect( server.inferInterrupt({ paneKey: PANE, diff --git a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts index aa8568f08d8..22cf9018bc0 100644 --- a/src/main/agent-hooks/server-interrupt-inference-validation.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-validation.test.ts @@ -422,7 +422,7 @@ describe('the main agent fact on an inferred interrupt', () => { ) const baseline = server.getStatusSnapshot()[0] vi.setSystemTime(1_500) - // Why: the synthesized Codex row is a plain done; it would retire the child its combine keeps working. + // A plain done inference would incorrectly retire live child work. expect( server.inferInterrupt({ paneKey: PANE, @@ -472,7 +472,6 @@ describe('the main agent fact on an inferred interrupt', () => { intent: 'ctrl-c' }) ).toBe(false) - // Why: the main agent already settled; Ctrl+C at its prompt stops nothing the row shows. expect(server.getStatusSnapshot()[0]).toEqual(baseline) } finally { vi.useRealTimers() diff --git a/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts b/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts index c5b77fd086a..a7383b43783 100644 --- a/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts +++ b/src/main/agent-hooks/server-last-status-child-held-main-agent.test.ts @@ -31,9 +31,7 @@ const CHILD_PERMISSION = { tool_input: { command: 'rm scratch' } } -// A Claude row whose main agent settled while child agents still work (or wait on a prompt) is -// decided by the row's `mainAgent` fact: OSC cannot settle it, and restart restores the settled -// main agent so the children's own lifecycle hooks can. +// Persist the settled main-agent fact so child lifecycle hooks, rather than OSC, can settle the restored row. describe('Claude rows held open by child agents', () => { let userDataPath: string @@ -182,7 +180,6 @@ describe('Claude rows held open by child agents', () => { mainAgent: expect.objectContaining({ state: 'working' }) }) ]) - // The main agent keeps working: nothing it publishes changed, so nothing is pushed. await post(server, { hook_event_name: 'PreToolUse', tool_name: 'Grep' }) expect(pushed).toHaveLength(1) } finally { @@ -310,7 +307,7 @@ describe('Claude rows held open by child agents', () => { } }) - // The shell fact rides beside `mainAgent`; a row rewritten without it would read as shell-free. + // Preserve shell evidence beside mainAgent so restart cannot mistake the row for shell-free. async function expectShellHeldRowStaysOpenAfterRestart( server: AgentHookServer, childEvents: Record[] diff --git a/src/main/agent-hooks/server-last-status-hydrate-confirmation.test.ts b/src/main/agent-hooks/server-last-status-hydrate-confirmation.test.ts index fd086f80d1e..b21ea9a92f9 100644 --- a/src/main/agent-hooks/server-last-status-hydrate-confirmation.test.ts +++ b/src/main/agent-hooks/server-last-status-hydrate-confirmation.test.ts @@ -265,7 +265,7 @@ describe('Last-status persistence', () => { baselineAgentType: 'codex', intent: 'plain-escape' }) - // Why: synthesizing `done` onto a never-confirmed `working` would fabricate a transition from stale disk state. + // Never infer a live transition from unconfirmed disk state. expect(applied).toBe(false) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) expect(server.getStatusSnapshot()[0]?.interrupted).toBeUndefined() @@ -383,7 +383,6 @@ describe('Last-status persistence', () => { subagents: undefined }) ]) - // Why: migration must be one-time, else every launch re-prunes the same persisted idle rows. const persisted = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) expect(persisted.entries[PANE].payload.subagents).toBeUndefined() } finally { diff --git a/src/main/agent-hooks/server-last-status-hydrate-validation.test.ts b/src/main/agent-hooks/server-last-status-hydrate-validation.test.ts index 3ad27b64a38..0779b071d4a 100644 --- a/src/main/agent-hooks/server-last-status-hydrate-validation.test.ts +++ b/src/main/agent-hooks/server-last-status-hydrate-validation.test.ts @@ -162,21 +162,18 @@ describe('Last-status persistence', () => { JSON.stringify({ version: 2, entries: { - // Missing colon — drop. 'no-colon': { paneKey: 'no-colon', receivedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000, payload: { state: 'done', prompt: 'bad', agentType: 'claude' } }, - // Embedded paneKey mismatch — drop. [PANE]: { paneKey: makePaneKey('tab-x', LEAF_2), receivedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000, payload: { state: 'done', prompt: 'mismatch', agentType: 'claude' } }, - // Valid. [GOOD_PANE]: { paneKey: GOOD_PANE, tabId: 'tab-good', @@ -216,7 +213,6 @@ describe('Last-status persistence', () => { JSON.stringify({ version: 2, entries: { - // Stale — should be dropped. [OLD_PANE]: { paneKey: OLD_PANE, tabId: 'tab-old', @@ -224,7 +220,6 @@ describe('Last-status persistence', () => { stateStartedAt: eightDaysAgoMs - 1000, payload: { state: 'done', prompt: 'old', agentType: 'claude' } }, - // Recent — should survive. [FRESH_PANE]: { paneKey: FRESH_PANE, tabId: 'tab-fresh', @@ -364,7 +359,6 @@ describe('Last-status persistence', () => { entries: { [TAB_A_PANE]: { paneKey: TAB_A_PANE, - // Why: paneKey says tab-A but entry claims tab-B; sanitizer must drop the inconsistent row, not hydrate it. tabId: 'tab-B', receivedAt: recentTs(), stateStartedAt: recentTs(-1000), @@ -424,10 +418,8 @@ describe('Last-status persistence', () => { server.flushStatusPersistSync() const firstMtime = statSync(lastStatusPath()).mtimeMs - // Why: clearPaneState on a paneKey not in the cache must not trigger a redundant write (clear bails when nothing was evicted). server.clearPaneState(makePaneKey('non-existent', LEAF_5)) server.flushStatusPersistSync() - // Assert no rewrite happened: mtime unchanged after a forced sync flush. const secondMtime = statSync(lastStatusPath()).mtimeMs expect(secondMtime).toBe(firstMtime) } finally { @@ -446,11 +438,10 @@ describe('Last-status persistence', () => { server, buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'flush me' }) ) - // Note: do NOT call flushStatusPersistSync explicitly — let stop() do it. + // Let stop flush the pending debounce instead of flushing explicitly. } finally { server.stop() } - // Why: stop() must synchronously drain the pending trailing-debounced timer even though we never explicitly flushed. expect(existsSync(lastStatusPath())).toBe(true) const parsed = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) expect(parsed.entries[PANE]?.payload?.prompt).toBe('flush me') diff --git a/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts index fa9c7aa5fe1..aad1a1b9313 100644 --- a/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts +++ b/src/main/agent-hooks/server-last-status-main-agent-fact.test.ts @@ -24,8 +24,6 @@ afterEach(() => { vi.restoreAllMocks() }) -// The main agent fact rides inside the persisted payload. Without these pins the field would look -// shipped while dying at every restart: hydration rebuilds only the fields it is taught. describe('The main agent fact across a restart', () => { let userDataPath: string @@ -83,7 +81,7 @@ describe('The main agent fact across a restart', () => { restoredUnconfirmed: true, mainAgent: live?.mainAgent }) - // The seeded main agent record is what lets the child's drain settle the pane, verdict intact. + // The restored main-agent fact lets child drain settle the pane without losing its verdict. await postHookEvent( server, buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' }) @@ -102,7 +100,7 @@ describe('The main agent fact across a restart', () => { } }) - // Current Claude sends no hook on a cancel, so the inferred Ctrl+C is the path users take. + // Captured Claude cancellation emits no hook; exercise the inferred Ctrl+C path. it('round-trips an inferred cancel held open by a child through disk and back', async () => { const firstServer = new AgentHookServer() await firstServer.start({ env: 'production', userDataPath }) diff --git a/src/main/agent-hooks/server-last-status-restored-children.test.ts b/src/main/agent-hooks/server-last-status-restored-children.test.ts index 270cba28dc1..df962d88077 100644 --- a/src/main/agent-hooks/server-last-status-restored-children.test.ts +++ b/src/main/agent-hooks/server-last-status-restored-children.test.ts @@ -45,7 +45,6 @@ describe('Last-status persistence', () => { } it('hydrates last-status.json into the cache before listener registration', async () => { - // Pre-populate the file directly to simulate a prior session. mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) const receivedAt = recentTs() const stateStartedAt = recentTs(-1000) @@ -142,7 +141,6 @@ describe('Last-status persistence', () => { ) const byPane = new Map(server.getStatusSnapshot().map((entry) => [entry.paneKey, entry])) expect(byPane.get(PANE)).toMatchObject({ state: 'working', restoredUnconfirmed: true }) - // Why: `done` is a terminal fact — no later transition can have been missed, so it restores confirmed. expect(byPane.get(GOOD_PANE)?.restoredUnconfirmed).toBeUndefined() server.ingestRemote( diff --git a/src/main/agent-hooks/server-last-status-write.test.ts b/src/main/agent-hooks/server-last-status-write.test.ts index c1dcee3f697..2e986e6aac4 100644 --- a/src/main/agent-hooks/server-last-status-write.test.ts +++ b/src/main/agent-hooks/server-last-status-write.test.ts @@ -66,7 +66,6 @@ describe('Last-status persistence', () => { { launchToken: 'launch-bearer-must-not-persist' } ) ) - // Synchronous flush via stop() captures the trailing-debounced write. server.flushStatusPersistSync() expect(existsSync(lastStatusPath())).toBe(true) const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) @@ -87,8 +86,7 @@ describe('Last-status persistence', () => { expect(file.entries[PANE].launchTokenHash).toBe( createHash('sha256').update('launch-bearer-must-not-persist').digest('hex') ) - // The shell fact is persisted: hydration reads it to decide whether a settled main agent held - // open by children may be seeded, which `mainAgent` alone cannot say (see server-types.ts). + // Shell evidence determines whether a settled main agent held open by children can be restored. expect(file.entries[PANE].claudeRunningNonAgentTask).toBe(true) expect(file.entries[PANE].payload.mainAgent).toEqual({ state: 'done', diff --git a/src/main/agent-hooks/server-launch-resume-identity.test.ts b/src/main/agent-hooks/server-launch-resume-identity.test.ts index 27afee0f245..ac0323b2204 100644 --- a/src/main/agent-hooks/server-launch-resume-identity.test.ts +++ b/src/main/agent-hooks/server-launch-resume-identity.test.ts @@ -15,8 +15,7 @@ vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtE const RESUMED = { key: 'session_id' as const, id: 'resumed-session' } -// #10923: Codex runs SessionStart only at its first turn, so a resumed pane is silent until the -// user types; the host that launched the resume seeds the identity every reader joins on. +// Codex may stay silent until the resumed pane's first turn; seed identity at launch for every reader. describe('a resume launch seeds the pane identity', () => { const servers: AgentHookServer[] = [] let userDataPath: string @@ -139,7 +138,6 @@ describe('a resume launch seeds the pane identity', () => { providerSession: RESUMED, launchedAt: Date.now() }) - // Synchronous flush via stop() captures the trailing-debounced write. server.stop() servers.length = 0 const restarted = await startServer() diff --git a/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts b/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts index d5f69600526..e57185ddd35 100644 --- a/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts +++ b/src/main/agent-hooks/server-main-agent-turn-verdicts.test.ts @@ -18,7 +18,7 @@ beforeEach(() => { trackMock.mockReset() getCohortAtEmitMock.mockReset() getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) - // Only the wall clock is faked, so the hook server's real sockets keep working. + // Fake only Date so real hook sockets keep working. vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(1_000_000) }) @@ -28,8 +28,6 @@ afterEach(() => { vi.restoreAllMocks() }) -// A finished turn's verdict and clock belong to that turn: an event restating the same finished -// turn keeps them, and only a new turn or a new session replaces them. describe('main agent turn verdicts and clocks', () => { let server: AgentHookServer @@ -73,7 +71,7 @@ describe('main agent turn verdicts and clocks', () => { const cancelled = server.getStatusSnapshot()[0]?.mainAgent expect(cancelled).toMatchObject({ state: 'done', outcome: 'cancellation' }) - // Past the late-event suppression window, so the restatement itself is published. + // Advance past late-event suppression so the restatement is published. vi.setSystemTime(1_030_000) await post( '/hook/grok', @@ -116,9 +114,9 @@ describe('main agent turn verdicts and clocks', () => { vi.setSystemTime(1_002_000) await post('/hook/codex', { hook_event_name: 'Stop' }) - // Readers that predate `mainAgent` must still read the restated turn as stopped, not finished. + // Older readers need interrupted even when they cannot read mainAgent. expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', interrupted: true }) - // Past the late-event suppression window, a child's activity republishes the main agent. + // Advance past suppression before child activity republishes the main agent. vi.setSystemTime(1_060_000) await post('/hook/codex', { hook_event_name: 'SubagentStart', agent_id: 'child-1' }) diff --git a/src/main/agent-hooks/server-muse-session-log-poll.test.ts b/src/main/agent-hooks/server-muse-session-log-poll.test.ts index 962c6ca429c..cc9458c58c2 100644 --- a/src/main/agent-hooks/server-muse-session-log-poll.test.ts +++ b/src/main/agent-hooks/server-muse-session-log-poll.test.ts @@ -44,7 +44,7 @@ describe('AgentHookServer Muse session log polling', () => { dirs.length = 0 }) - // Why: Muse fires no hook for request_user_input, so only the poll can surface the wait and its answer. + // Muse emits no request_user_input hook; polling must surface questions and answers. it('flips the pane to waiting for a logged question and back once it settles', async () => { const dataHome = mkdtempSync(join(tmpdir(), 'agent-hook-muse-poll-')) dirs.push(dataHome) diff --git a/src/main/agent-hooks/server-nested-agent-model.test.ts b/src/main/agent-hooks/server-nested-agent-model.test.ts index 7ee47a07aa8..1eb21d2764e 100644 --- a/src/main/agent-hooks/server-nested-agent-model.test.ts +++ b/src/main/agent-hooks/server-nested-agent-model.test.ts @@ -36,7 +36,7 @@ afterEach(() => { }) describe('AgentHookServer nested agent model fence', () => { - // Why: `claude` running `codex exec` fires codex hooks on the inherited pane key. + // Nested codex exec inherits the parent Claude pane key. it('keeps the parent model when a nested hook reports its own model', () => { const server = new AgentHookServer() ingest(server, 1_000, { @@ -127,7 +127,7 @@ describe('AgentHookServer nested agent model fence', () => { ]) }) - // Why: readers retain sparse model labels, so the host must never publish a child's label on the parent. + // Readers retain sparse model labels, so a transient child label could remain on the parent. it('never pushes a nested codex model to the renderer from real hook bodies', async () => { vi.useRealTimers() const server = new AgentHookServer() diff --git a/src/main/agent-hooks/server-observation-provenance.test.ts b/src/main/agent-hooks/server-observation-provenance.test.ts index 74d143cec11..d14ca4247b8 100644 --- a/src/main/agent-hooks/server-observation-provenance.test.ts +++ b/src/main/agent-hooks/server-observation-provenance.test.ts @@ -138,10 +138,7 @@ describe('agent status observation provenance', () => { expect(lastObservation(seen).kind).toBe('identity-only') }) - // Why: this is the whole point of stamping boundary from the listener's own classifier. - // `before_agent_start`, `BeforeAgent` and `agent.start` are NOT the two raw literals the - // retired-pane gate matches, so a second hand-written list here would report them as - // non-boundaries — exactly the defect PR #14626 fixed one instance of. + // Reuse provider boundary classification; raw event-name lists miss providers with different spellings. it.each([ { source: 'claude', hookEventName: 'UserPromptSubmit', agentType: 'claude' }, { source: 'pi', hookEventName: 'before_agent_start', agentType: 'pi' }, @@ -256,8 +253,7 @@ describe('agent status observation provenance', () => { const path = join(userDataPath, 'agent-hooks', 'last-status.json') expect(existsSync(path)).toBe(true) const file = readFileSync(path, 'utf8') - // Why: the sequencer that issued it dies with the process, so a stored copy could only - // rehydrate as an ordering claim from a dead authority. + // Process-local sequencing cannot remain ordering authority after restart. expect(file).not.toContain('observation') expect(JSON.parse(file).entries[PANE].observation).toBeUndefined() } finally { diff --git a/src/main/agent-hooks/server-opencode-binder.test.ts b/src/main/agent-hooks/server-opencode-binder.test.ts index 354d542fb17..fe9725bb539 100644 --- a/src/main/agent-hooks/server-opencode-binder.test.ts +++ b/src/main/agent-hooks/server-opencode-binder.test.ts @@ -39,10 +39,7 @@ class BinderTestServer extends AgentHookServer { const DB_PATH = '/tmp/binder-store/opencode.db' -/** - * Stands in for the worker read: OpenCode 1 rows past the cursor, oldest first. - * The SQL itself is covered by readers/opencode-binder-sessions.test.ts. - */ +/** Return OpenCode 1 worker rows oldest first; reader tests cover SQL behavior. */ class FakeSessionStore { rows: BinderSessionRow[] = [] calls: { dbPath: string; cursor: OpenCodeSessionCursor }[] = [] @@ -101,7 +98,7 @@ describe('opencode binder loop', () => { }) it('skips the round when the read answers its failure value', async () => { - // [] is what the worker client resolves to on a timeout, crash or unreadable store. + // The worker returns [] on timeout, crash, or unreadable storage. const sweep = vi.fn(async () => []) server.bindDeps({ listSessions: async () => [], sweep }) expect(await server.runBinderRound()).toBe(0) @@ -129,7 +126,6 @@ describe('opencode binder loop', () => { expect(sweep).not.toHaveBeenCalled() expect(server.readRegistry('ses_live')).toBeUndefined() - // The stale round left the watermark alone: the next round lists from the start. server.bindDeps({ listSessions: store.list }) expect(await server.runBinderRound()).toBe(0) expect(store.calls.at(-1)?.cursor).toEqual({ ms: 0, id: '' }) @@ -139,7 +135,7 @@ describe('opencode binder loop', () => { store.add() vi.useFakeTimers() try { - // Birth arrives stamped with the wrong (server-starter) pane. + // OpenCode serve initially stamps birth with the server starter's pane. server.ingest('opencode', { paneKey: PANE_B, launchToken: '', @@ -252,12 +248,9 @@ describe('opencode binder loop', () => { server.stop() server.startBinderLoop() await vi.waitFor(() => expect(sweepCalls).toBe(2)) - // The obsolete round finishes while the new round is still parked: its - // finally must not clear the flag the new round holds. + // The old round's finally must preserve the newer round's in-flight flag. releaseFirst() await new Promise((resolve) => setTimeout(resolve, 20)) - // A third round attempted now must be refused at the flag check, calling - // no sweep. With the unguarded finally it would be admitted instead. const extraRound = server.runBinderRound() expect(sweepCalls).toBe(2) releaseLater() diff --git a/src/main/agent-hooks/server-opencode-lifecycle.test.ts b/src/main/agent-hooks/server-opencode-lifecycle.test.ts index 93e31908818..59ecaa6bdb8 100644 --- a/src/main/agent-hooks/server-opencode-lifecycle.test.ts +++ b/src/main/agent-hooks/server-opencode-lifecycle.test.ts @@ -143,7 +143,6 @@ describe('AgentHookServer OpenCode lifecycle', () => { ) await post({ hook_event_name: 'SessionBusy', sessionID: 'source' }, 'source-token') - // The destination fence rejects the source token until the transfer removes it. await post( { hook_event_name: 'SessionBusy', sessionID: 'source-before-transfer' }, 'source-token', @@ -174,15 +173,12 @@ describe('AgentHookServer OpenCode lifecycle', () => { server.retirePaneAuthority(PANE) await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token') await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token') - // The stale old-token follow-up stays fenced out. await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ providerSession: { key: 'session_id', id: 'restart' } }) ]) - // Why: the runtime defers retirement while an agent stays in the foreground, so a - // genuinely new process can start in a still-authorized pane. Its SessionStart must - // replace the stale fence — not be swallowed as a stale event (rowless reuse). + // A new process may start before foreground retirement; its SessionStart must replace the stale fence. await post({ hook_event_name: 'SessionStart', sessionID: 'fresh' }, 'fresh-token') await post({ hook_event_name: 'SessionBusy', sessionID: 'fresh' }, 'fresh-token') @@ -198,8 +194,7 @@ describe('AgentHookServer OpenCode lifecycle', () => { await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token') await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token') - // Why: an untokened boundary cannot prove which process it belongs to, so dropping - // the fence for it would reopen the pane to every stale token. + // A tokenless boundary cannot prove process identity or reopen the stale-token fence. await post({ hook_event_name: 'SessionStart', sessionID: 'tokenless' }, '') await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token') @@ -215,8 +210,7 @@ describe('AgentHookServer OpenCode lifecycle', () => { await post({ hook_event_name: 'SessionStart', sessionID: 'restart' }, 'restart-token') await post({ hook_event_name: 'SessionBusy', sessionID: 'restart' }, 'restart-token') - // Why: prompts recur mid-session, so honoring one as a process boundary would hand - // the pane back to any still-live stale process. + // Mid-session prompts cannot prove a new process owns the pane. await post( { hook_event_name: 'MessagePart', @@ -239,8 +233,7 @@ describe('AgentHookServer OpenCode lifecycle', () => { await post({ hook_event_name: 'SessionBusy', sessionID: 'old' }, 'old-token', PANE, 'mimo-code') server.retirePaneAuthority(PANE) - // Why: mimo-code emits no SessionStart, so the explicit prompt is its only restart - // boundary — excluding it would strand every retired mimo-code pane. + // mimo-code has no SessionStart; its explicit prompt is the restart boundary. await post( { hook_event_name: 'MessagePart', diff --git a/src/main/agent-hooks/server-pane-authority.test.ts b/src/main/agent-hooks/server-pane-authority.test.ts index 57c3f8f8718..f4902dface3 100644 --- a/src/main/agent-hooks/server-pane-authority.test.ts +++ b/src/main/agent-hooks/server-pane-authority.test.ts @@ -53,7 +53,7 @@ describe('AgentHookServer pane authority', () => { ]) }) - // A pane move is announced by main and again by the renderer; the repeat must change nothing. + // Main and renderer can both announce the same pane transfer. it('ignores a transfer that is already in place', () => { const server = new AgentHookServer() const listener = vi.fn() diff --git a/src/main/agent-hooks/server-pi-normalization.test.ts b/src/main/agent-hooks/server-pi-normalization.test.ts index d5e83d69f4f..2aad513cb78 100644 --- a/src/main/agent-hooks/server-pi-normalization.test.ts +++ b/src/main/agent-hooks/server-pi-normalization.test.ts @@ -45,7 +45,6 @@ describe('Pi hook normalization', () => { modelSwitchCommand: 'orca-model' }) - // Pi posts carry no model, and none is invented for them. const pi = _internals.normalizeHookPayload( 'pi', buildBody({ hook_event_name: 'before_agent_start', prompt: 'status for pi' }), @@ -83,7 +82,6 @@ describe('Pi hook normalization', () => { buildBody({ hook_event_name: 'model_select', model: 'minimax-cn/MiniMax-M3' }), 'production' ) - // Why: a switch between turns changes the model, never the state or the prompt. expect(switched?.payload).toMatchObject({ state: 'done', prompt: 'status for omp', @@ -228,7 +226,6 @@ describe('Pi hook normalization', () => { buildBody({ hook_event_name: 'message_end', role: 'user', text: 'hi' }), 'production' ) - // Why: pi captures the user prompt via before_agent_start, so a user-role message_end must not flip lastAssistantMessage. expect(result?.payload.lastAssistantMessage).toBeUndefined() }) @@ -248,7 +245,7 @@ describe('Pi hook normalization', () => { buildBody({ hook_event_name: 'session_shutdown' }), 'production' ) - // Why: Pi emits shutdown on reload/replace while the PTY stays alive; only agent_end proves turn completion. + // Pi shutdown also means reload/replace while the PTY survives; it cannot prove turn completion. expect(result).toBeNull() }) @@ -343,7 +340,7 @@ describe('Pi-family child rows through the hook lane', () => { subagents: [REVIEWER] }) - // Why: the roster is a full restatement, so an update without one clears every child row. + // Roster updates replace the full roster; omitted children are removed. await post('pi', { hook_event_name: 'subagents_update' }) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', prompt: 'fan out' }) expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() @@ -363,7 +360,6 @@ describe('Pi-family child rows through the hook lane', () => { expect(placeholder).toMatchObject({ providerSessionOnly: true, state: 'done' }) expect(placeholder?.subagents).toBeUndefined() - // Why: the same guard covers Pi's model_select, which shares this path. await post('pi', { hook_event_name: 'model_select', model: 'anthropic/claude-opus-5' }) expect(server.getStatusSnapshot()[0]).toMatchObject({ providerSessionOnly: true }) expect(server.getStatusSnapshot()[0]?.model).toBeUndefined() @@ -389,9 +385,6 @@ describe('Pi-family child rows through the hook lane', () => { }) }) -// Why: publishing a roster for Pi puts its panes on the same child-work guard Claude and Codex -// already sit behind, which changes what Ctrl+C records. Pinned here so the next change to the -// guard — or to the extension, once it reports a main-agent state of its own — has to face it. describe('a Pi cancel beside a live child row', () => { let server: AgentHookServer @@ -443,9 +436,7 @@ describe('a Pi cancel beside a live child row', () => { }) }) - // Why: without a main-agent state from the extension, Orca cannot tell a cancelled turn from - // Ctrl+C at the idle prompt of a lead that children alone hold open — where it cancels nothing. - // It keeps the live row rather than claiming a cancellation the children contradict. + // Without main-agent evidence, live children make turn cancellation indistinguishable from idle-prompt Ctrl+C. it('leaves the working row alone while a child still runs', async () => { await startTurn([{ id: 'run-scout', state: 'working', startedAt: 1_000, agentType: 'scout' }]) diff --git a/src/main/agent-hooks/server-process-lifetime-status.test.ts b/src/main/agent-hooks/server-process-lifetime-status.test.ts index e0f489299af..7e7db502f52 100644 --- a/src/main/agent-hooks/server-process-lifetime-status.test.ts +++ b/src/main/agent-hooks/server-process-lifetime-status.test.ts @@ -48,8 +48,7 @@ async function nextMillisecond(): Promise { await new Promise((resolve) => setTimeout(resolve, 5)) } -// One `opencode run` command in one pane: the host reports it from the process lifetime unless a -// hook producer (OpenCode 1's in-process plugin) reports the same pane during that command. +// Process-lifetime status yields when a hook producer reports the same pane during the command. describe('process-lifetime status', () => { it('reports Working, then Done, when no hook speaks for the command', () => { const commandStartedAt = Date.now() @@ -63,7 +62,6 @@ describe('process-lifetime status', () => { const commandStartedAt = Date.now() processLifetime('working', commandStartedAt) await openCodeHook('SessionBusy') - // The hook is still Working, so the process exit must not write a second Done over it. processLifetime('done', commandStartedAt) expect(paneState()).toBe('working') await openCodeHook('SessionIdle') @@ -87,7 +85,6 @@ describe('process-lifetime status', () => { expect(paneState()).toBe('done') }) - // Why: a pane where an Orca-launched agent exited is retired; a new `opencode run` there is a new run. it('revives a retired pane on its Working, as a hook new-turn event does', () => { server.retirePaneAuthority(PANE) const commandStartedAt = Date.now() diff --git a/src/main/agent-hooks/server-program-status-hook-yield.test.ts b/src/main/agent-hooks/server-program-status-hook-yield.test.ts index 030e9b72745..d14ebf14646 100644 --- a/src/main/agent-hooks/server-program-status-hook-yield.test.ts +++ b/src/main/agent-hooks/server-program-status-hook-yield.test.ts @@ -98,7 +98,6 @@ describe('OSC 7501 status beside hooks', () => { programStatus({ state: 'done', agentType: 'opencode' }) expect(paneRow()?.state).toBe('done') vi.useRealTimers() - // The next hook event hands the pane back. await openCodeHook('SessionBusy') expect(paneRow()?.state).toBe('working') }) @@ -229,7 +228,7 @@ describe('OSC 7501 status beside hooks', () => { await openCodeHook('SessionIdle') vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) vi.advanceTimersByTime(1_000) - // One chunk: the facet holds only its last report, the lifecycle reports keep their order. + // One output chunk keeps only the latest facet report while preserving lifecycle order. server.ingestProgramStatusFacet({ paneKey: PANE, facet: { @@ -305,7 +304,6 @@ describe('OSC 7501 status beside hooks', () => { programStatus: facet }) - // Redelivering the same content is not news. server.ingestProgramStatusFacet({ paneKey: PANE, facet: { ...facet, observedAt: 2 } }) expect(published).toHaveLength(1) }) diff --git a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts index 7c35e6d5981..d114cf6d691 100644 --- a/src/main/agent-hooks/server-relayed-claude-cancel.test.ts +++ b/src/main/agent-hooks/server-relayed-claude-cancel.test.ts @@ -1,7 +1,4 @@ -// The captured Claude cancels (see server-claude-cancel-captures.test.ts) replayed on an SSH pane: -// the hooks go through a real relay-side listener, which owns the provider records, and reach the -// desktop only as relayed payloads. The relay never learns of the cancel the desktop infers from -// Ctrl+C, so everything it restates afterwards still says the main agent is working. +// Replay captured cancels over SSH; the relay never sees desktop Ctrl+C inference and may restate stale main-agent work. import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -47,7 +44,6 @@ function temporaryDir(prefix: string): string { type SshPane = { relay: RelayAgentHookServer - /** The desktop the relay forwards to; a desktop restart swaps it. */ desktop: AgentHookServer post: (payload: Record) => Promise } @@ -79,7 +75,7 @@ async function startSshPane(desktop: AgentHookServer): Promise { return pane } -/** The relay process restarts (upgrade, crash) with fresh listener state; the remote agent keeps running. */ +/** Restart relay state while the remote agent keeps running. */ async function restartRelay(pane: SshPane): Promise { pane.relay.stop() pane.relay = createRelay(() => pane.desktop) @@ -133,7 +129,7 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { subagents: [expect.objectContaining({ state: 'working' })] }) - // The child's tool activity after the cancel: the relay's record still has the main agent working. + // Child activity arrives while the relay still believes the main agent is working. const childTool = hookAt(records, 9) expect(childTool.payload.agent_id).toBeDefined() await pane.post(childTool.payload) @@ -142,8 +138,7 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { mainAgent: { state: 'done', outcome: 'cancellation' } }) - // Both children finish on the remote. Claude still owes the main agent a task notification - // for the launched one, so the relay holds the row and the desktop keeps its cancel over it. + // Owed child notification holds the relay row while desktop cancellation remains authoritative. await pane.post(subagentStop(4)) await pane.post(subagentStop(9)) expect(row(pane.desktop)).toMatchObject({ @@ -178,14 +173,14 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { const childTool = hookAt(records, 6).payload await pane.post({ ...childTool, hook_event_name: 'PermissionRequest' }) - // A denied request runs no tool; the child moves on to a different one, and the card stays up. + // Denial runs no tool; unrelated child progress must not dismiss the request. await pane.post({ ...childTool, tool_use_id: 'toolu_after_denial' }) expect(row(pane.desktop)).toMatchObject({ state: 'waiting', mainAgent: { state: 'done', outcome: 'cancellation' } }) - // The child's end takes its card down; its owed task notification holds the cancelled row. + // Child end dismisses its permission card while the owed notification still holds the row. await pane.post(subagentStop(4)) expect(row(pane.desktop)).toMatchObject({ state: 'working', @@ -211,7 +206,7 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { await postCaptured(pane, upToCancel) expect(pressCtrlC(pane.desktop)).toBe(true) - // Why: the restarted relay has no prompt cache or main agent record, so the child's hook arrives with an empty prompt. + // A restarted relay has no prompt or main-agent cache, so child hooks arrive with empty prompt. await restartRelay(pane) await pane.post(hookAt(records, 9).payload) expect(row(pane.desktop)).toMatchObject({ @@ -234,7 +229,7 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { const desktop = pane.desktop expect(pressCtrlC(desktop)).toBe(true) - // The link drops: the next prompt and the child's hook reach only the relay. + // A desktop replacement misses subsequent hooks retained only by the relay. pane.desktop = new AgentHookServer() running.push(pane.desktop) await pane.post(hookAt(records, 10).payload) @@ -250,7 +245,7 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { const firstDesktop = new AgentHookServer() await firstDesktop.start({ env: 'production', userDataPath }) const pane = await startSshPane(firstDesktop) - // The main agent Stops with the child running, and the desktop restarts. + // Restart the desktop after the main agent Stops with a live child. await postCaptured( pane, [0, 1, 2, 3, 4, 5].map((index) => hookAt(records, index)) @@ -267,11 +262,9 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { await desktop.start({ env: 'production', userDataPath }) pane.desktop = desktop running.push(desktop) - // Hydration seeds the desktop's own roster from the saved row, relayed or not. expect(desktop._getStateForTests().claudeSubagentRosterByPaneKey.has(PANE)).toBe(true) - // The child finishes on the remote and Claude tells the main agent, which ends that turn; - // then a new turn starts and is cancelled. + // Finish the remote child and notification turn, then cancel a new turn. await pane.post(subagentStop(4)) expect(row(desktop)).toMatchObject({ state: 'working' }) await pane.post({ @@ -284,7 +277,7 @@ describe('a relayed Claude cancel with a live subagent (captured)', () => { expect(row(desktop)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) expect(pressCtrlC(desktop)).toBe(true) - // Why: nothing runs on the remote; the desktop's seed is not the relay's roster. + // A desktop-restored roster cannot override current remote evidence that no work remains. expect(row(desktop)).toMatchObject({ state: 'done', interrupted: true, @@ -316,7 +309,6 @@ describe('a relayed Claude cancel with a background shell (captured)', () => { mainAgent: { state: 'done', outcome: 'cancellation' } }) - // Why: a new turn is the main agent's own fact again; the held verdict must not outlive it. await pane.post(hookAt(records, 7).payload) expect(row(pane.desktop)).toMatchObject({ state: 'working', mainAgent: { state: 'working' } }) await pane.post(hookAt(records, 8).payload) @@ -350,7 +342,7 @@ it("keeps the cancel when a teammate's idle hook arrives after the late-hook win vi.setSystemTime(Date.now() + 2_000) expect(pressCtrlC(pane.desktop)).toBe(true) - // Why: TeammateIdle names its child by `teammate_name` only, so it carries no agent id. + // TeammateIdle identifies children only by teammate_name. vi.setSystemTime(Date.now() + 20_000) await pane.post({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer' }) expect(row(pane.desktop)).toMatchObject({ diff --git a/src/main/agent-hooks/server-relayed-claude-permission-replay.test.ts b/src/main/agent-hooks/server-relayed-claude-permission-replay.test.ts index 3bca5729ead..4470a9e8778 100644 --- a/src/main/agent-hooks/server-relayed-claude-permission-replay.test.ts +++ b/src/main/agent-hooks/server-relayed-claude-permission-replay.test.ts @@ -1,7 +1,4 @@ -// Captured Claude permission hooks (claude-permission-approve-deny-hooks.jsonl) on an SSH pane: a -// real relay-side listener forwards to a real desktop server, and the desktop can drop off while -// the remote Claude keeps running. The relay caches one row per pane, so a hook that lands during -// the gap overwrites the PostToolUse that ended the request the desktop is still showing. +// Replay captured SSH permission hooks; single-row relay caching can hide the resolving event during disconnect. import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -124,7 +121,6 @@ describe('a relayed Claude permission wait across a reconnect (captured)', () => it('clears on reconnect when the request ended while the desktop was away', async () => { const pane = await waitingOnFirstRequest() pane.connected = false - // The user approved on the remote terminal; Claude ran it and started its next tool. await postAll(pane, [firstToolDone, secondTool]) reconnect(pane) diff --git a/src/main/agent-hooks/server-reminted-pane-key.test.ts b/src/main/agent-hooks/server-reminted-pane-key.test.ts index a3dac65c58c..2d5551696b9 100644 --- a/src/main/agent-hooks/server-reminted-pane-key.test.ts +++ b/src/main/agent-hooks/server-reminted-pane-key.test.ts @@ -153,8 +153,7 @@ describe('reminted $$ pane keys on the OMP hook pipeline', () => { const server = new AgentHookServer() await server.start({ env: 'production' }) try { - // Why: spawn A then spawn B can share one $$ token in env with different tab/leaf. - // Overwriting would let leftover remint posts stamp spawn B's pane. + // Different spawns can share $$ tokens; stale remints must not overwrite the newer pane owner. server.registerPaneKeyAlias(REMINTED, PANE, 'pty-a', 10, { authorityVerified: true }) server.registerPaneKeyAlias(REMINTED, GOOD_PANE, 'pty-b', 20, { authorityVerified: true }) const start = await postHookEvent( diff --git a/src/main/agent-hooks/server-removed-worktree-status.test.ts b/src/main/agent-hooks/server-removed-worktree-status.test.ts index d40540326c0..773e2e54f70 100644 --- a/src/main/agent-hooks/server-removed-worktree-status.test.ts +++ b/src/main/agent-hooks/server-removed-worktree-status.test.ts @@ -159,11 +159,9 @@ describe('AgentHookServer removed-worktree retirement', () => { const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) if (localWorktree === REMOVED) { expect(file.authorityCommitments?.[pane]).toMatchObject({ worktreeId: KEPT }) - // The retained foreign launch fence rejects the removed workspace's late repaint. expect(file.entries[pane]).toBeUndefined() } else { expect(file.authorityCommitments?.[pane]).toBeUndefined() - // The occupant keeps reporting, without the removed owner's token hash stamped on its row. expect(file.entries[pane]).toMatchObject({ worktreeId: KEPT, payload: { state: 'done' } }) expect(file.entries[pane].launchTokenHash).toBeUndefined() } diff --git a/src/main/agent-hooks/server-replay-evidence-clock.test.ts b/src/main/agent-hooks/server-replay-evidence-clock.test.ts index 7dda18b930c..056e0f9187f 100644 --- a/src/main/agent-hooks/server-replay-evidence-clock.test.ts +++ b/src/main/agent-hooks/server-replay-evidence-clock.test.ts @@ -57,7 +57,6 @@ describe('the observation clock a relay replay must not restamp', () => { expect(lastForPane().evidenceObservedAt).toBe(T0) vi.setSystemTime(T0 + 25 * 60 * 1000) - // A lost transport clears the row; the age of the evidence it restates is not a claim. server.clearStatusEntriesForConnection(CONNECTION) ingest( server, @@ -67,7 +66,7 @@ describe('the observation clock a relay replay must not restamp', () => { const replayed = lastForPane() expect(replayed.payload.state).toBe('working') - // Delivery order must still clear the connection watermark, or the renderer drops the row. + // Fresh delivery order clears the renderer connection watermark without refreshing evidence age. expect(replayed.receivedAt).toBeGreaterThan(T0 + 25 * 60 * 1000 - 1) expect(replayed.evidenceObservedAt).toBe(T0) }) @@ -82,7 +81,6 @@ describe('the observation clock a relay replay must not restamp', () => { { isReplay: true } ) - // The fleet projection reads this snapshot, not the listener payload. const row = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE)! expect(row.receivedAt).toBeGreaterThan(T0 + 25 * 60 * 1000 - 1) expect(row.evidenceObservedAt).toBe(T0) diff --git a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts index c554d90d591..c6f66cef3c2 100644 --- a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts +++ b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts @@ -18,9 +18,7 @@ beforeEach(() => { }) afterEach(() => vi.restoreAllMocks()) -/** Each source's own new-turn boundary, as `isNewTurnEvent` classifies it. `null` means the - * classifier names no boundary for that source. That is not the same as "can never revive": - * mimo-code's boundary is an explicit-prompt MessagePart, which the gate handles separately. */ +/** Null omits the classifier boundary; mimo-code revives separately through explicit-prompt MessagePart. */ const NEW_TURN_EVENT: Record = { qoder: 'SessionStart', 'qoder-cn': 'SessionStart', @@ -54,7 +52,6 @@ const NEW_TURN_EVENT: Record = { function reviveRetiredPane(source: unknown, hookEventName: string): boolean { const server = new AgentHookServer() - // Why: retirement is what command completion leaves behind on a reusable shell pane. server.retirePaneAuthority(PANE) server.ingestRemote( { @@ -89,15 +86,12 @@ describe("retired pane un-retires on each provider's own new-turn event", () => expect(reviveRetiredPane(source, hookEventName as string)).toBe(true) }) - // Why these two: every case above passes `source`, so the source-less compatibility path — - // the branch added for older relays — would otherwise ship with no coverage at all. it('revives on a literal boundary when an older relay omits source', () => { expect(reviveRetiredPane(undefined, 'UserPromptSubmit')).toBe(true) }) it('cannot revive a non-literal provider when an older relay omits source', () => { - // Why pinned: this is the accepted cost of the legacy shim, not an oversight. Widening the - // literal list to "fix" it would re-create the two-literal gate this change removes. + // Source-less legacy peers intentionally recognize only literal compatibility boundaries. expect(reviveRetiredPane(undefined, 'before_agent_start')).toBe(false) }) @@ -120,15 +114,12 @@ describe("retired pane un-retires on each provider's own new-turn event", () => ) it('leaves the pane retired for a source with no turn boundary', () => { - // Why mimo-code and command-code: neither names a boundary through `isNewTurnEvent`, so - // SessionStart must not open the gate for them. Mimo-code still revives on its own - // explicit-prompt MessagePart — that path is covered in server-opencode-lifecycle.test.ts. + // These providers lack classifier boundaries; mimo-code explicit prompts are covered by lifecycle tests. expect(reviveRetiredPane('mimo-code', 'SessionStart')).toBe(false) expect(reviveRetiredPane('command-code', 'SessionStart')).toBe(false) }) it('leaves the pane retired for a non-boundary event on a revivable source', () => { - // Why: guards the inverse — the gate must not open on any event that merely mentions a session. expect(reviveRetiredPane('gemini', 'AfterAgent')).toBe(false) }) }) diff --git a/src/main/agent-hooks/server-status-listener-fanout.test.ts b/src/main/agent-hooks/server-status-listener-fanout.test.ts index ef35674e0ed..8f78a1163ca 100644 --- a/src/main/agent-hooks/server-status-listener-fanout.test.ts +++ b/src/main/agent-hooks/server-status-listener-fanout.test.ts @@ -237,8 +237,7 @@ describe('AgentHookServer listener replay', () => { expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', prompt: 'new run' }) - // A matching eviction follows ordinary dismissal semantics, including - // preserving a resumable provider session for the still-live TUI. + // Matching eviction retains provider identity so the surviving TUI can resume. const resumed = new AgentHookServer() resumed.ingestRemote( { @@ -265,8 +264,7 @@ describe('AgentHookServer listener replay', () => { }) it('evicts when the renderer identity was stamped after receipt but pins the same turn', () => { - // Runtime-sync and recovery entries stamp updatedAt with Date.now()/capturedAt, which is - // at or after main's receivedAt; the eviction must still land for those rows. + // Recovery stamps can follow receipt; match the turn independently of that timestamp ordering. const server = new AgentHookServer() server.ingestRemote( { @@ -286,7 +284,6 @@ describe('AgentHookServer listener replay', () => { }) ).toBe(true) - // A different turn never matches, whatever the receivedAt relationship. const other = new AgentHookServer() other.ingestRemote( { @@ -333,7 +330,6 @@ describe('AgentHookServer listener replay', () => { const evicted = server.dropPersistedStatusEntries([ ...identities, - // A stale identity never matches and never blocks the rest of the batch. { ...identities[0]!, stateStartedAt: identities[0]!.stateStartedAt + 1 } ]) diff --git a/src/main/agent-hooks/server-status-persist-nonblocking.test.ts b/src/main/agent-hooks/server-status-persist-nonblocking.test.ts index 974858f236b..d85d51ec3d3 100644 --- a/src/main/agent-hooks/server-status-persist-nonblocking.test.ts +++ b/src/main/agent-hooks/server-status-persist-nonblocking.test.ts @@ -24,7 +24,6 @@ vi.mock('node:fs/promises', async (importOriginal) => { return { ...actual, rename: vi.fn(actual.rename) } }) -// Regression guard for STA-8446 / #22804: a slow last-status rename must not stall hook ingress. describe('last-status persistence stays off the main thread', () => { let userDataPath: string let server: AgentHookServer @@ -32,7 +31,7 @@ describe('last-status persistence stays off the main thread', () => { const lastStatusPath = (): string => join(userDataPath, 'agent-hooks', 'last-status.json') const readPrompt = (): unknown => JSON.parse(readFileSync(lastStatusPath(), 'utf8')).entries[PANE]?.payload?.prompt - // Why filter: the endpoint file is written synchronously at start, which is not the status path. + // Exclude synchronous endpoint startup writes from status-persistence checks. const statusRenameSyncCalls = (): number => vi.mocked(fs.renameSync).mock.calls.filter(([, to]) => String(to) === lastStatusPath()).length const prompt = (text: string): Promise => @@ -76,14 +75,13 @@ describe('last-status persistence stays off the main thread', () => { ) ]) - // Hook ingress and timers both progress while the rename is stuck. const timerFired = new Promise((resolve) => setTimeout(() => resolve(true), 5)) expect((await prompt('while renaming')).status).toBe(204) expect(await timerFired).toBe(true) expect(writeSync).not.toHaveBeenCalled() expect(statusRenameSyncCalls()).toBe(0) - // Quit flushes while the older rename is still held; releasing it afterwards must not undo that. + // An older async rename must not undo the synchronous quit flush. server.flushStatusPersistSync() expect(writeSync).toHaveBeenCalledTimes(1) expect(readPrompt()).toBe('while renaming') diff --git a/src/main/agent-hooks/server-status-persist-throttle.test.ts b/src/main/agent-hooks/server-status-persist-throttle.test.ts index 5842f350362..c9245728cd7 100644 --- a/src/main/agent-hooks/server-status-persist-throttle.test.ts +++ b/src/main/agent-hooks/server-status-persist-throttle.test.ts @@ -44,7 +44,7 @@ describe('last-status persist throttle', () => { beforeEach(async () => { _internals.resetCachesForTests() - // Why shouldAdvanceTime: the hooks are real loopback POSTs, which need the clock to move. + // Real loopback POSTs need fake time to advance. vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['setTimeout', 'clearTimeout', 'Date'] diff --git a/src/main/agent-hooks/server-transport-interference.test.ts b/src/main/agent-hooks/server-transport-interference.test.ts index dc39cebed46..42aebba2d0c 100644 --- a/src/main/agent-hooks/server-transport-interference.test.ts +++ b/src/main/agent-hooks/server-transport-interference.test.ts @@ -1,6 +1,4 @@ -// Reproduces #11217's mechanism without an IDS: an authenticated hook POST whose body is cut -// short of its own Content-Length. The listener fails open on every request error, so the only -// way this stays diagnosable is if the truncation is classified before it is swallowed. +// Diagnose authenticated body truncation before fail-open request handling swallows it. import { connect } from 'node:net' import { IncomingMessage, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -24,7 +22,7 @@ async function postTruncatedHook( socket.write( `POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: ${announcedLength}\r\n\r\n${sentBytes}` ) - // Why: an RST mid-body is what an inspecting IDS does; a FIN would be an ordinary client hangup. + // Reset mid-body to simulate interference rather than an ordinary FIN hangup. setTimeout(() => { socket.resetAndDestroy() resolve() @@ -38,11 +36,10 @@ async function postTruncatedHook( reject(new Error('truncated post never connected')) }) }) - // Why: the server settles the request on 'close', which lands a tick after the client's reset. + // Server close handling follows the client reset asynchronously. await new Promise((resolve) => setTimeout(resolve, 50)) } -/** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */ async function postStalledHook( port: number, token: string, @@ -134,7 +131,6 @@ describe('AgentHookServer transport interference', () => { ]) expect(warn.mock.calls.flat().join(' ')).toContain('security software') - // Why: warn-once — a blocked fleet must not turn every hook event into a log line. await postTruncatedHook(port, token) expect(reports).toHaveLength(1) }, 20_000) @@ -189,8 +185,7 @@ describe('AgentHookServer transport interference', () => { await postTruncatedHook(port, token) await postTruncatedHook(port, token) - // Why: Orca destroys this one itself at HOOK_REQUEST_SLOWLORIS_MS; counting it would make - // every stalled agent look like an IDS block. + // Orca's own slowloris timeout must not be misclassified as external interference. await postStalledHook(port, token) expect(reports).toEqual([]) @@ -202,7 +197,7 @@ describe('AgentHookServer transport interference', () => { it('never reports for unauthenticated probes', async () => { const { port, reports } = await startServer() - // Why: a port scanner is not interference; only a request that cleared the token check can be. + // Unauthenticated scans cannot establish interference with a legitimate hook. await postTruncatedHook(port, 'wrong-token') await postTruncatedHook(port, 'wrong-token') await postTruncatedHook(port, 'wrong-token') diff --git a/src/main/agent-hooks/server-turn-ended-at.test.ts b/src/main/agent-hooks/server-turn-ended-at.test.ts index abaf0867270..535477cfbc0 100644 --- a/src/main/agent-hooks/server-turn-ended-at.test.ts +++ b/src/main/agent-hooks/server-turn-ended-at.test.ts @@ -8,9 +8,7 @@ import { buildBody, postHookEvent, PANE } from './server.test-fixtures' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) -// The execution host labels each turn end by what it can prove now: an identified end (an unused -// opening or a new turn id) or revision (a verified re-entered Stop) mints; a proven repeat keeps -// the end; anything else is ambiguous, never mints, and leaves main's title behavior to the renderer. +// Only identified turns or verified Stop revisions mint an end; repeats and ambiguity never mint another. describe('the host-stamped turn end', () => { let server: AgentHookServer let directory: string @@ -29,7 +27,7 @@ describe('the host-stamped turn end', () => { async function post(payload: Record, path = '/hook/claude'): Promise { expect((await postHookEvent(server, buildBody(payload), path)).status).toBe(204) - // Distinct host stamps: each event lands on a later millisecond. + // Separate host timestamps by advancing real time between events. await new Promise((resolve) => setTimeout(resolve, 5)) } const row = () => server.getStatusSnapshot()[0] @@ -109,8 +107,7 @@ describe('the host-stamped turn end', () => { const first = row()?.turnEndedAt expect(first).toEqual(expect.any(Number)) - // Its turn id and continuation flag prove an unflagged same-turn Stop is a duplicate, even - // after a tool reopen. + // A known turn ID without continuation proves duplicate Stop even after tool work reopens the row. await codex({ hook_event_name: 'Stop', turn_id: 'turn-a', last_assistant_message: 'Planned.' }) await codex({ hook_event_name: 'PreToolUse', turn_id: 'turn-a', tool_name: 'Bash' }) await codex({ hook_event_name: 'Stop', turn_id: 'turn-a', last_assistant_message: 'Planned.' }) @@ -178,7 +175,6 @@ describe('the host-stamped turn end', () => { server = new AgentHookServer() await server.start({ env: 'production', userDataPath: directory }) expect(row()).toMatchObject({ turnEndedAt: ended, turnEndDisposition: 'identified' }) - // The saved turn record still knows turn-a ended, through a working reopen too. await post( { hook_event_name: 'PreToolUse', turn_id: 'turn-a', tool_name: 'Bash' }, '/hook/codex' @@ -234,7 +230,7 @@ describe('the host-stamped turn end', () => { expect(row()).toMatchObject({ state: 'done', stateStartedAt: cancelled?.stateStartedAt }) expect(row()).not.toHaveProperty('turnEndedAt') - // A late plain stop carries no cancel, yet it and every relay restatement of it keep none. + // A late plain Stop cannot replace the locally inferred cancellation end. const lateStop = { ...stop, turnEndedAt: 50, diff --git a/src/main/agent-hooks/server-turn-started-at.test.ts b/src/main/agent-hooks/server-turn-started-at.test.ts index 63ebd136be6..8b229f2a822 100644 --- a/src/main/agent-hooks/server-turn-started-at.test.ts +++ b/src/main/agent-hooks/server-turn-started-at.test.ts @@ -22,13 +22,12 @@ afterEach(() => { vi.restoreAllMocks() }) -// Distinct host stamps: each event lands on a later millisecond than the one before it. +// Separate host timestamps by advancing real time between events. async function nextMillisecond(): Promise { await new Promise((resolve) => setTimeout(resolve, 5)) } -// The execution host stamps when the main agent's turn began, so every reader (and a reload, which -// starts the renderer with no state history) gets the turn start rather than rebuilding it. +// Host-stamped turn starts survive reader reloads without reconstructing state history. describe('the host-stamped turn start', () => { const servers: AgentHookServer[] = [] let userDataPath: string @@ -73,13 +72,11 @@ describe('the host-stamped turn start', () => { await post(server, { hook_event_name: 'PermissionRequest', ...bash }) expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting' }) await post(server, { hook_event_name: 'PostToolUse', ...bash }) - // The answer restarted the state clock, not the turn's. expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) expect(server.getStatusSnapshot()[0]?.stateStartedAt).toBeGreaterThan(started!) expect(turnStartedAt(server)).toBe(started) await post(server, { hook_event_name: 'Stop' }) - // Kept on the finished turn, so a reader can time it from the host's own facts. expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', turnStartedAt: started }) }) @@ -119,7 +116,6 @@ describe('the host-stamped turn start', () => { expect(turnStartedAt(server)).toBe(started) }) - // A session start lands an idle boundary row: no turn is open until the next prompt. it('clears on a session boundary', async () => { const server = await startServer() await post(server, { hook_event_name: 'UserPromptSubmit', prompt: 'Rename the module' }) @@ -178,7 +174,6 @@ describe('the host-stamped turn start', () => { restoredUnconfirmed: true, turnStartedAt: started }) - // The turn goes on after the restart: the next event carries the restored stamp. await post(restarted, { hook_event_name: 'PostToolUse', tool_name: 'Bash', tool_input: {} }) expect(turnStartedAt(restarted)).toBe(started) }) diff --git a/src/main/agent-hooks/server.claude-interactive-question.test.ts b/src/main/agent-hooks/server.claude-interactive-question.test.ts index 8efe3fd7633..8ebbe9c2503 100644 --- a/src/main/agent-hooks/server.claude-interactive-question.test.ts +++ b/src/main/agent-hooks/server.claude-interactive-question.test.ts @@ -60,8 +60,7 @@ describe('Claude interactive-question status transitions', () => { }) it('clears a PermissionRequest-shaped AskUserQuestion wait when later tool work starts', () => { - // Why: newer Claude reports the AskUserQuestion wait as PermissionRequest; - // the question must not inherit real-permission stickiness from that shape. + // AskUserQuestion may arrive as PermissionRequest without inheriting permission stickiness. const server = new AgentHookServer() ingestClaudeStatus(server, { @@ -206,8 +205,6 @@ describe('inferQuestionAnswered', () => { }) expect(server.inferQuestionAnswered(answeredRequestFromSnapshot(server))).toBe(true) - // Why: the answered question must also drop the tool identity so the - // question card cannot linger on the working row. const [entry] = server.getStatusSnapshot() expect(entry).toMatchObject({ paneKey: PANE_KEY, state: 'working', agentType: 'claude' }) expect(entry.toolName).toBeUndefined() diff --git a/src/main/agent-hooks/server.test-fixtures.ts b/src/main/agent-hooks/server.test-fixtures.ts index 565515239e7..2eeffa80348 100644 --- a/src/main/agent-hooks/server.test-fixtures.ts +++ b/src/main/agent-hooks/server.test-fixtures.ts @@ -42,7 +42,7 @@ export function buildBody(payload: Record, overrides: Partial @@ -44,7 +44,7 @@ export function recordAgentExit( } } -/** Drops the exit and every alias key that shares it; true when one was held. */ +/** Forget every alias of the exit, not just this key. */ export function forgetAgentExit(exits: Map, paneKey: string): boolean { const exit = exits.get(paneKey) for (const key of exit?.paneKeys ?? []) { @@ -53,7 +53,6 @@ export function forgetAgentExit(exits: Map, paneKey: return exit !== undefined } -/** Whether an event proves a different agent claimed the pane after the exit. */ export function claimsPaneAfterExit( event: AgentHookEventPayload, exit: AgentExitEvidence @@ -63,7 +62,7 @@ export function claimsPaneAfterExit( return !exit.process || !isSameAgentProcess(sender, exit.process) } const agent = event.agentPresence?.agent ?? event.payload.agentType - // Why: only Claude hooks carry a process identity, so another provider reporting is the successor. + // A different known provider proves succession even without process identity. if ( event.source !== undefined && exit.agent && @@ -73,14 +72,14 @@ export function claimsPaneAfterExit( ) { return true } - // Why: an unidentified late hook from the ended session (a Stop) is no evidence of a successor. + // An unidentified late Stop cannot prove a successor. return ( opensNewTurn(event) || (event.source !== undefined && isNewTurnEvent(event.source, event.hookEventName)) ) } -/** Drops one retired key; a pane that moved away keeps the exit under its surviving keys. */ +/** Retire one key while preserving the exit under surviving aliases. */ export function releaseAgentExitKey( exits: Map, paneKey: string @@ -90,14 +89,13 @@ export function releaseAgentExitKey( return exits.delete(paneKey) } -/** Drops exits proven before `notBefore` unless a retained row still names one of their panes. */ export function pruneAgentExits( exits: Map, notBefore: number, isPaneRowRetained: (paneKey: string) => boolean ): void { for (const [key, exit] of exits) { - // Why: a kept resume row without its exit would read as a live session holder. + // A retained resume row needs its exit to avoid appearing live. if (exit.at < notBefore && ![...exit.paneKeys].some(isPaneRowRetained)) { exits.delete(key) } diff --git a/src/main/agent-hooks/server/program-status/facet-store.ts b/src/main/agent-hooks/server/program-status/facet-store.ts index 0835b0675e1..ad87036b701 100644 --- a/src/main/agent-hooks/server/program-status/facet-store.ts +++ b/src/main/agent-hooks/server/program-status/facet-store.ts @@ -4,27 +4,21 @@ import { } from '../../../../shared/program-status/facet' import type { AgentStatusState } from '../../../../shared/agent-status-types' -// Why a bound: one entry per pane that ever ran an OSC 7501 program; stale panes age out first. +// Bound retained facets across long sessions; evict the least recently changed first. const MAX_PROGRAM_STATUS_FACETS = 1024 type StoredProgramStatus = { facet: ProgramStatusFacet - /** From the ordered lifecycle reports; a facet holds only a chunk's last one. */ + /** Ordered lifecycle state; chunk facets retain only their last report. */ working?: boolean - /** When the program last entered `working`: evidence of new work after a hook-settled turn. */ + /** New working evidence can supersede a hook-settled turn. */ workingSince?: number } -/** - * Each pane's latest OSC 7501 facet, kept apart from the status rows. A row's producer (hook or OSC) - * owns its state; this map is owned by the OSC 7501 channel alone and is stitched onto rows only as - * they are published, so it is never persisted and never compared as a row change. It also keeps when - * the program last entered `working` (noteState), which relies on a chunk's facet arriving first. - */ +/** Runtime-only facets are stitched at publication; noteState requires the chunk's facet first. */ export class ProgramStatusFacetStore { private readonly byPaneKey = new Map() - /** Returns whether the pane's facet changed. `null` removes it. */ set(paneKey: string, facet: ProgramStatusFacet | null): boolean { const previous = this.byPaneKey.get(paneKey) if (facet === null) { @@ -48,8 +42,7 @@ export class ProgramStatusFacetStore { return this.byPaneKey.get(paneKey)?.facet } - /** Records the program's projected lifecycle state, in report order, and returns when it last - * entered `working`. */ + /** Return the latest working transition time while consuming states in report order. */ noteState(paneKey: string, state: AgentStatusState, at: number): number | undefined { const stored = this.byPaneKey.get(paneKey) if (stored === undefined) { @@ -66,7 +59,6 @@ export class ProgramStatusFacetStore { this.byPaneKey.delete(paneKey) } - /** A pane remint keeps the program's facet with its row. */ move(fromPaneKey: string, toPaneKey: string): void { const stored = this.byPaneKey.get(fromPaneKey) if (stored === undefined) { @@ -80,7 +72,7 @@ export class ProgramStatusFacetStore { this.byPaneKey.clear() } - /** A copy of `row` carrying its pane's facet. The stored row is never mutated. */ + /** Stitch without mutating the stored row. */ stitch(row: T): T { const facet = this.byPaneKey.get(row.paneKey)?.facet if (facet === row.programStatus) { diff --git a/src/main/agent-hooks/server/program-status/hook-precedence.ts b/src/main/agent-hooks/server/program-status/hook-precedence.ts index bece56240bb..e1cd624621c 100644 --- a/src/main/agent-hooks/server/program-status/hook-precedence.ts +++ b/src/main/agent-hooks/server/program-status/hook-precedence.ts @@ -1,14 +1,10 @@ import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' import type { EnrichedAgentHookEventPayload } from '../server-types' -/** Hook silence, while OSC 7501 disagrees, before OSC 7501 takes the pane. Why minutes: a long - * tool call sends no hooks but leaves the program's state unchanged, so only old disagreement counts. */ +// Long tool calls can be hook-silent; allow sustained disagreement before yielding to OSC 7501. export const HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS = 2 * 60 * 1000 -/** Until when an OSC 7501 projection must leave the pane's hook row alone: hooks carry richer identity, - * so they win until silent on a state they no longer describe. A later hook event hands it back. - * `undefined`: it does not yield; `Infinity`: no silence ends the yield. - * `programWorkingSince`: when the program last entered `working` (ProgramStatusFacetStore). */ +/** Hook precedence ends at this time; undefined means no yield, Infinity means silence cannot end it. */ export function programStatusHookYieldEndsAt( previous: EnrichedAgentHookEventPayload | undefined, payload: ParsedAgentStatusPayload, @@ -19,16 +15,14 @@ export function programStatusHookYieldEndsAt( } const sameAgent = payload.agentType === undefined || payload.agentType === previous.payload.agentType - // A different app after the hook agent finished is a new program in the pane. Mid-turn, the hook - // row still wins over another app id: forks report upstream's app (OpenClaude says claude-code). + // Different app IDs can be forks; only a completed hook agent lets a different program take over immediately. if (!sameAgent && previous.payload.state === 'done') { return undefined } if (payload.state === previous.payload.state) { return Infinity } - // Hooks fall silent after a settled turn by design, so silence proves nothing there; an approval or - // question only outranks them once the program has worked since (a turn the hooks missed). + // Settled turns are naturally silent; a later wait must prove intervening program work. if (previous.payload.state === 'done' && payload.state === 'waiting') { return programWorkingSince !== undefined && programWorkingSince > previous.receivedAt ? undefined diff --git a/src/main/agent-hooks/server/program-status/pane-timers.ts b/src/main/agent-hooks/server/program-status/pane-timers.ts index b1843878536..f2b286da717 100644 --- a/src/main/agent-hooks/server/program-status/pane-timers.ts +++ b/src/main/agent-hooks/server/program-status/pane-timers.ts @@ -1,16 +1,14 @@ type PaneTimer = { timer: ReturnType; paneKey: string } -/** One pending, unref'd timer per pane; `run` gets the pane's current key, which a remint may move. */ +/** One unref'd timer per pane; remints move the callback to its current key. */ export class PaneTimers { private readonly byPaneKey = new Map() - /** Replaces the pane's pending timer. */ defer(paneKey: string, delayMs: number, run: (paneKey: string) => void): void { this.cancel(paneKey) this.start(paneKey, delayMs, run) } - /** Keeps a pending timer, whose `run` reads current state when it fires. */ deferUnlessPending(paneKey: string, delayMs: number, run: (paneKey: string) => void): void { if (!this.byPaneKey.has(paneKey)) { this.start(paneKey, delayMs, run) diff --git a/src/main/agent-hooks/server/revisioned-map.ts b/src/main/agent-hooks/server/revisioned-map.ts index 3c75b7afbca..e1930b94871 100644 --- a/src/main/agent-hooks/server/revisioned-map.ts +++ b/src/main/agent-hooks/server/revisioned-map.ts @@ -1,4 +1,4 @@ -/** Map whose `revision` advances on every mutation, so a reader can detect change without diffing. */ +/** Revision tracking avoids diffing the map. */ export class RevisionedMap extends Map { revision = 0 diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index 175773066d6..81655a793ce 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -18,7 +18,7 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy } >() - /** A live hook proves its own process alive; only another process's hook casts doubt on the owner. */ + /** A hook proves its sender alive; only another process can challenge the owner. */ checkAgentPresenceAfterHook(event: AgentHookEventPayload, row: AgentHookEventPayload): void { const sender = event.agentPresence?.process const owner = row.agentPresence @@ -37,10 +37,9 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy } } - // Why: a live process already reports from these panes, so their owner's exit is not the pane's. + // A live sender means the owner's exit cannot establish pane exit. private readonly ownerChallengesByLiveSender = new Map() - /** Whether this pane's owner carries a process identity that its execution host can check. */ hasVerifiableAgentProcess(paneKey: string): boolean { const presence = this.state.lastStatusByPaneKey.get( this.resolvePaneKeyAlias(paneKey) @@ -48,7 +47,7 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy return presence?.process !== undefined && !presence.ended } - /** A local inspection keeps the exact hook row, rather than probing the pane's next owner. */ + /** Pin inspection to this row so a replacement owner cannot become its subject. */ readCliWriterObservation(expected: AgentStatusIpcPayload): CliWriterObservation | null { const resolved = this.resolvePaneKeyAlias(expected.paneKey) const row = this.state.lastStatusByPaneKey.get(resolved) @@ -79,13 +78,13 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy } } - /** As checkAgentPresence, but keeps an exit this host proved at or after `exitedSince`. */ + /** Include retained exits proven at or after `exitedSince`. */ async checkAgentPresenceSince( paneKey: string, exitedSince: number ): Promise { const verdict = await this.checkAgentPresence(paneKey) - // Why exits too: a probe's exit counts only if retained (not a replaced owner's). + // A replaced owner's probe result counts only if its exit was retained. if (verdict !== null && verdict !== 'exited') { return verdict } @@ -98,7 +97,6 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy checkAgentPresence(paneKey: string): Promise { const resolved = this.resolvePaneKeyAlias(paneKey) const row = this.state.lastStatusByPaneKey.get(resolved) - // Why: an ended owner already published its exit, and an owner no hook identified cannot be checked. if (!row?.agentPresence?.process || row.agentPresence.ended) { return Promise.resolve(null) } diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts index aea81cad7fa..64bcce71d5c 100644 --- a/src/main/agent-hooks/server/server-authority-aliases.ts +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -17,7 +17,6 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut this.paneKeyAliasPersistenceListener = listener } - /** The pane key that owns `paneKey`'s agent status once aliases are followed. */ resolvePaneAuthorityOwner(paneKey: string): string { return this.resolvePaneKeyAlias(paneKey) } @@ -43,7 +42,7 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut protected boundPaneKeyAliases(): void { while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) { - // Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup. + // Bound memory and cleanup work for untrusted renderer aliases. const oldestKey = this.legacyPaneKeyAliases.keys().next().value if (!oldestKey) { break @@ -87,7 +86,7 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut (ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId)) ) } - // Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias. + // Status does not prove PTY ownership; ID-less moves require a verified alias. return alias?.authorityVerified === true } @@ -107,8 +106,7 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut if (existing && options?.overwriteExisting === false) { return } - // Why: remint tokens have no embedded tab id; first pane wins so a later spawn - // cannot steal leftover $$…:L$$ posts onto a different tab:leaf. + // Remint tokens lack tab identity; first ownership wins to prevent cross-tab reuse. if (existing && existing.stablePaneKey !== toPaneKey && isOpaqueRemintedPaneKey(fromPaneKey)) { return } @@ -151,7 +149,7 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) const requestedPtyId = ptyId?.trim() - // Why: a repeat would clear the owner's own polls; a pane move announces from main and renderer. + // Main and renderer both announce moves; repeats must not clear the owner's polls. if ( previousOwnerPaneKey === toPaneKey && (!requestedPtyId || this.legacyPaneKeyAliases.get(physicalPaneKey)?.ptyId === requestedPtyId) @@ -253,7 +251,7 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut } this.clearAssistantMessageRetry(previousOwnerPaneKey) this.clearTranscriptPoll(previousOwnerPaneKey) - // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. + // Detached processes keep posting the physical key; retain its mapping to the current owner. this.legacyPaneKeyAliases.set(physicalPaneKey, { stablePaneKey: toPaneKey, ptyId: normalizedPtyId, diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts index 4ffa68033b9..0f06d3102f2 100644 --- a/src/main/agent-hooks/server/server-authority-fences.ts +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -8,7 +8,6 @@ import type { } from './server-types' export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuthorityAliases { - // Why: retirement fences a pane and every alias of it, then deletes those aliases. retirePaneAuthority(paneKey: string, retirementId?: string): void { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) const previousFence = this.retiredPaneFencesByKey.get(ownerPaneKey) @@ -73,12 +72,7 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth } } - // Why: retirement fences a pane and every alias of it, then deletes those aliases. - // Lifting only the key we are handed strands the rest — a detached pane's process - // keeps posting the key it launched under, so it would stay suppressed forever with - // the fence apparently lifted. Replay the recorded fence instead: same key set, same - // aliases. Keys and aliases belonging to a closed tab are skipped, so the stronger - // claim survives and a live process is never routed back into a closed tab. + // Restore the full alias fence; detached processes still post old keys, but closed tabs stay fenced. protected restoreRetiredPaneFence(fence: RetiredPaneFence): void { let aliasChanged = false for (const { physicalPaneKey, entry } of fence.aliases) { @@ -87,7 +81,7 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth this.retiredPaneFencesByKey.get(entry.stablePaneKey) !== fence || this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) || this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) || - // Why: the pane was rebound in the meantime; the newer alias is the truth. + // A newer binding supersedes the retired alias. this.legacyPaneKeyAliases.has(physicalPaneKey) ) { continue @@ -111,11 +105,7 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth if (this.isClosedAgentStatusTabForPaneKey(ownerPaneKey)) { return false } - // Why: retirement is a claim that a pane is gone. Re-attaching a live PTY to that - // exact pane disproves the claim at the moment it stops being true, so the fence - // lifts here instead of waiting for the agent to speak again — an agent re-attached - // mid-turn or left idle would otherwise stay suppressed for the rest of its life - // (STA-4114). A closed *tab* is a separate, stronger claim and is left standing. + // Reattachment disproves pane retirement immediately; idle agents may never post another hook (STA-4114). const fence = this.retiredPaneFencesByKey.get(paneKey) ?? this.retiredPaneFencesByKey.get(ownerPaneKey) let restored = false @@ -191,7 +181,7 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth ) } if (shouldClearStablePaneKey) { - // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. + // Hydrated PTYs may lack ptyPaneKey; alias cleanup must evict their stable rows. clearPaneCacheState(this.state, entry.stablePaneKey) this.activeHookTurnCompletedAtByPaneKey.delete(entry.stablePaneKey) this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) @@ -250,7 +240,7 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth if (!stablePaneKey) { return body } - // Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner. + // Detached shells still post the physical key; route both pane and tab to the current owner. return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId } } } diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts index 841c206fb0a..5f7190cf7e6 100644 --- a/src/main/agent-hooks/server/server-claude-status-rules.ts +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -8,9 +8,7 @@ import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/ import { isClaudePermissionSettledByReplay } from '../../../shared/agent-hook-listener/claude-settled-permission-requests' import type { EnrichedAgentHookEventPayload } from './server-types' -/** The shell fact a Claude row stores beside its `mainAgent`; restart seeds a settled main agent only - * when it reads `false`. The listener restates it on every event it produces; any other write keeps - * the previous fact only while `mainAgent` is unchanged, since the fact was observed with that one. */ +/** Retain the shell-work fact only with the main-agent observation that established it. */ export function pairedClaudeNonAgentWork( previous: EnrichedAgentHookEventPayload | undefined, next: AgentHookEventPayload @@ -23,10 +21,7 @@ export function pairedClaudeNonAgentWork( : undefined } -/** A child's permission prompt stays visible over the main agent's own progress, but the row must - * still carry that progress: restart seeds the main agent from it, and a stale `done` would let the - * children's drain settle a row whose main agent is working. Returns `previous` when nothing changed, - * and keeps `previous.payload` when only the unpublished shell fact did. */ +/** Keep main-agent progress behind a child's permission prompt so restart cannot settle live work. */ export function withHeldChildWaitMainAgent( previous: EnrichedAgentHookEventPayload, next: AgentHookEventPayload @@ -87,7 +82,7 @@ export function shouldKeepClaudePermissionVisible( if (isClaudePermissionResumingApprovedTool(previous, next)) { return false } - // Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides. + // AskUserQuestion can arrive as PermissionRequest; only actual permissions stay sticky. if (isAskUserQuestionTool(previous.payload.toolName)) { return false } @@ -147,7 +142,7 @@ function isClaudePermissionResumingApprovedTool( previous.payload.toolInput === undefined && next.payload.toolInput === undefined - // Why: failure events arrive with tool name/input cleared, so only the exact request and owner settle the wait. + // Failure clears tool name/input; require the exact request and owner. if (next.hookEventName === 'PostToolUseFailure') { return ( hasMatchingToolUseId && previousAgentId === nextAgentId && previousAgentType === nextAgentType @@ -158,7 +153,7 @@ function isClaudePermissionResumingApprovedTool( (next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') && nextToolUseId !== undefined && !hasConflictingToolUseId && - // Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal. + // Subagents share agent_type; concrete agent or tool-use IDs distinguish them. (hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) && sameToolName && (sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId) @@ -213,7 +208,7 @@ export function attachClaudePermissionToolUseId( } return { ...next, - // Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id. + // PermissionRequest omits tool_use_id; PostToolUse reuses the PreToolUse ID. toolUseId: inheritedToolUseId } } diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 95c5e9d7529..d805414be5c 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -19,9 +19,7 @@ import { AgentHookServerAuthorityFences } from './server-authority-fences' import { recordAgentExit } from './agent-exit-evidence' export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFences { - /** The resume-identity remnant of a dropped row: a `providerSessionOnly` entry carries no state - * claim — it cannot gate a pane `working` — so it survives teardowns that end the pane's live - * claims. Returns null when the row has no resumable session to keep. */ + /** Resume identity survives teardown without claiming live work. */ protected toRetainedProviderSessionRow( entry: EnrichedAgentHookEventPayload | null | undefined ): EnrichedAgentHookEventPayload | null { @@ -36,11 +34,10 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } } - /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ + /** Dismiss the row while preserving the live agent's caches; PTY teardown uses clearPaneState. */ dropStatusEntry( paneKey: string, - /** Defaults to true: a dismissed pane can still be resumed in place. A structured session has - * no pane to resume into and its record store owns resume identity, so it passes false. */ + /** Structured sessions store resume identity elsewhere and opt out. */ options?: { preserveResumeIdentity?: boolean } ): void { const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) @@ -63,13 +60,11 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen this.emitStatusDropped(deleted.paneKey) } - /** Evict a UI-cleared status only if no newer status has replaced it. */ dropPersistedStatusEntry(identity: AgentStatusCacheIdentity): boolean { return this.dropPersistedStatusEntries([identity]).length > 0 } - /** Batch form: one persist and one listener notification for the whole set. Returns the - * pane keys that were actually evicted. */ + /** Persist and notify once for the evicted batch. */ dropPersistedStatusEntries(identities: readonly AgentStatusCacheIdentity[]): string[] { const evicted: string[] = [] for (const identity of identities) { @@ -78,9 +73,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen | 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. + // The turn clock pins identity; renderer updatedAt is at least receivedAt, so protect only newer rows. if ( !canonical || canonical.stateStartedAt !== identity.stateStartedAt || @@ -115,20 +108,12 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen return evicted } - /** Retire panes whose owning process is certifiably dead. - * - * The ordinary teardown already does this: every attributable PTY exit reaches - * `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that - * resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may - * never rebuild — so those panes keep a `working` row and its latches for good, with no hook left - * to retire them. This is the same operation reached from the runtime's own pane-key knowledge, - * so a dead pane is cleaned up identically however its keys were resolved. */ + /** Reconcile dead panes whose restored PTYs may never rebuild the spawn-time ptyPaneKey mapping. */ reconcileEndedProcessForPaneKeys( paneKeys: Iterable, options?: { endedPresence?: AgentProcessPresence - /** The pane survives to resume into (a shell outlived the agent, or a pane restored after its - * terminal died offline), so keep the `providerSessionOnly` remnant. */ + /** Keep resume identity when the pane survives the agent. */ preserveResumeIdentity?: boolean } ): number { @@ -136,7 +121,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen for (const paneKey of paneKeys) { const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) if (options?.endedPresence) { - // Why both: cleanup below drops the alias, and a reader may still hold the original key. + // Cleanup drops aliases, but readers may still hold either key. const paneKeys = new Set([resolvedPaneKey, paneKey]) for (const [aliasKey, alias] of this.legacyPaneKeyAliases) { if (alias.stablePaneKey === resolvedPaneKey) { @@ -180,18 +165,14 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen return cleared } - /** Anything a dead pane could still be asserting: a row, or a latch that would re-gate one through - * `resolveClaudePaneState` on the pane's next event even after the row reads `done`. The list - * itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a - * different file. */ + /** Include latches that can revive work even when the visible row is done. */ protected hasLiveClaimsForPaneKey(paneKey: string): boolean { return Boolean(this.getTmuxSelectedStatus(paneKey)) || paneHasStateClaims(this.state, paneKey) } - /** Retire the panes a removed worktree occupied on `host`, and its leftover claim on any other pane. */ dropStatusEntriesForRemovedWorktree(worktreeId: string, host?: ExecutionHostScope): void { const parsed = host === ALL_EXECUTION_HOSTS_SCOPE ? null : parseExecutionHostId(host ?? 'local') - // Why: a runtime host keeps its own store, so no row here is its to retire. + // Runtime hosts own separate status stores. if (host !== ALL_EXECUTION_HOSTS_SCOPE && (!parsed || parsed.kind === 'runtime')) { return } @@ -240,7 +221,6 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen } const occupant = row ?? commitment if (occupant && !ownedByRemoved(occupant)) { - // Another owner has the pane now; only our outlived commitment is left to clear. if (commitment && ownedByRemoved(commitment)) { this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) @@ -252,7 +232,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen } continue } - // Why a pane fence, not a tab one: a surviving same-id host keeps the shared tab. + // Fence the pane; another host may still own the same tab ID. this.retirePaneAuthority(paneKey) if (row) { this.emitPaneStatusCleared({ paneKey }) @@ -260,7 +240,6 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen } } - /** Clear statuses proven to belong to one lost SSH transport. */ clearStatusEntriesForConnection(connectionId: string): void { const normalizedConnectionId = connectionId.trim() if (normalizedConnectionId.length === 0) { @@ -283,7 +262,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen statusChanged = true this.commitStatusRowMutation(deleted, undefined) if (deleted.payload.agentType === 'codex') { - // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. + // A replacement remote process may reuse the pane; discard the lost owner's children. this.state.codexSubagentRosterByPaneKey.delete(paneKey) this.state.codexLeadStateByPaneKey.delete(paneKey) } else if (deleted.payload.agentType === 'claude') { @@ -302,11 +281,10 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen } } if (statusChanged) { - // Why: persist/notify once — one disconnect can own many panes. this.scheduleStatusPersist() this.notifyStatusChangeListeners() } - // Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row. + // Send the cutoff even with no matched row; another host may have overwritten it. this.emitPaneStatusCleared({ transient: true, connectionId: normalizedConnectionId, diff --git a/src/main/agent-hooks/server/server-constants.ts b/src/main/agent-hooks/server/server-constants.ts index ace43823267..f4874dc1d14 100644 --- a/src/main/agent-hooks/server/server-constants.ts +++ b/src/main/agent-hooks/server/server-constants.ts @@ -5,18 +5,17 @@ export const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 export const ASSISTANT_MESSAGE_RETRY_MS = 50 export const CODEX_SUBAGENT_POLL_MS = 1_000 -// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt). +// Version 1 lacked ordering clocks; mismatched caches hydrate empty. export const LAST_STATUS_FILE_VERSION = 2 -// Why: recovery-cache writes are capped at one per window so the rate can't follow the hook event rate -// (#26720); quit-time flushStatusPersistSync() still captures the final state. +// Throttle ordinary writes; authority changes bypass the window and quit flushes synchronously. export const STATUS_PERSIST_MIN_INTERVAL_MS = 5_000 export const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) -// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. +// Expire orphaned PTY rows so old work cannot resurrect on hydration. export const HYDRATE_MAX_AGE_MS = AGENT_STATUS_HYDRATE_MAX_AGE_MS -// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session. +// Bound closed-tab memory across long sessions. export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 export const PANE_KEY_ALIASES_MAX = 1024 diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts index da13c5a7fdf..3fd877f5faf 100644 --- a/src/main/agent-hooks/server/server-hydration.ts +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -23,12 +23,11 @@ import { AgentHookServerTabCleanup } from './server-tab-cleanup' import { readPersistedAgentExits, recordAgentExit } from './agent-exit-evidence' export abstract class AgentHookServerHydration extends AgentHookServerTabCleanup { - /** Hydrate the durable cache, validating every row before it reaches the live listener state. */ protected hydrateLastStatusFromDisk(): void { if (!this.lastStatusFilePath) { return } - // Why: keep hydrate idempotent so a future re-start path can't merge prior-session state. + // Repeated hydration must not merge state from an earlier runtime. clearLegacyAgentStatuses(this.state) this.hydratedLaunchTokenHashByPaneKey.clear() this.persistedAuthorityCommitmentsByPaneKey.clear() @@ -71,7 +70,6 @@ export abstract class AgentHookServerHydration extends AgentHookServerTabCleanup let dropped = 0 let prunedLegacyClaudeSubagents = 0 let scrubbedLegacyLaunchTokens = 0 - // Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff). const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS for (const [paneKey, rawEntry] of Object.entries(entries)) { const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) @@ -103,7 +101,7 @@ export abstract class AgentHookServerHydration extends AgentHookServerTabCleanup entry.payload = hydratedPayload } if (entry.payload.state !== 'done') { - // Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth. + // Completion may have occurred offline; restored work needs confirmation. entry.restoredUnconfirmed = true } admitLegacyAgentStatus( @@ -113,14 +111,14 @@ export abstract class AgentHookServerHydration extends AgentHookServerTabCleanup AGENT_STATUS_PERSISTED_HYDRATION_MODE ) if (entry.connectionId) { - // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state. + // Seed ordering from disk even if the wall clock moved backward. const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId) this.connectionTimestampWatermarkById.set( entry.connectionId, Math.max(previousWatermark ?? -1, entry.receivedAt) ) } - // Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds. + // Seed child hierarchy now; provider reconciliation removes stale entries. if (entry.payload.agentType === 'codex') { seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) } else if (entry.payload.agentType === 'claude') { @@ -164,21 +162,19 @@ export abstract class AgentHookServerHydration extends AgentHookServerTabCleanup ) } if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) { - // Why: persist load-time pruning and bearer scrubbing once. this.runStatusPersist() } else if (hydrated > 0) { - // Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless. + // Reuse raw bytes for dedup only after lossless hydration. this.primeStatusPersistBaseline(raw) } } - /** Hydrated rows still claiming work that nothing in this runtime has confirmed. */ listUnconfirmedRestoredStatuses(): EnrichedAgentHookEventPayload[] { const rows: EnrichedAgentHookEventPayload[] = [] for (const [paneKey, row] of this.state.lastStatusByPaneKey) { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; the shared view declares their base event type. const enriched = row as EnrichedAgentHookEventPayload - // Why: a resume remnant carries no state claim. + // Resume identity alone makes no work claim. if ( enriched.restoredUnconfirmed && !enriched.providerSessionOnly && diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index 7d998e85c22..96a945a6c10 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -53,7 +53,6 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer event.hookEventName === 'SessionStart' && isOpenCodeSharedServerPost(source, body) ) { - // Why: a birth just arrived; bind it now instead of waiting out the poll interval. this.kickOpenCodeBinder() } return { @@ -76,7 +75,7 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer if (!event || event.paneKey !== paneKey) { return { event } } - // Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate. + // Nested CLIs inherit pane keys; rejected status must not change the owner's background-work gate. return { event, onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron) @@ -116,8 +115,7 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer return } const exit = this.agentExitedAtByPaneKey.get(this.resolvePaneKeyAlias(normalized.event.paneKey)) - // Why: a hook spooled a full second before a proven exit (stamps are whole seconds) describes - // the dead agent; replaying it would install an obsolete owner. A same-second one may not. + // Whole-second spool timestamps prove pre-exit ownership only with a full second of separation. if (exit && record.receivedAt + 1000 <= exit.at) { return } diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 3a01ba007c8..4ac2f8fa3ef 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -33,7 +33,7 @@ import { import { AgentHookServerIngestStructuredChildren } from './server-ingest-structured-children' export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestStructuredChildren { - /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ + /** Relay payloads cross the SSH trust boundary and require canonical validation here. */ ingestRemote(envelope: RemoteAgentStatusEnvelope, connectionId: string | null): void { if ( !canAdmitLegacyAgentStatus( @@ -46,7 +46,6 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS ) { return } - // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. if (connectionId !== null && typeof connectionId !== 'string') { return } @@ -57,7 +56,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS if (!envelope || typeof envelope.paneKey !== 'string') { return } - // Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge. + // Match HTTP key normalization so local and remote events share one pane. const physicalPaneKey = envelope.paneKey.trim() let paneKey = this.resolvePaneKeyAlias(physicalPaneKey) const parsedPaneKey = parsePaneKey(paneKey) @@ -74,7 +73,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS ) { return } - // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. + // Reject stale relay replay generations against hydrated authority. if (envelope.isReplay === true) { const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) const actualLaunchTokenHash = launchTokenHash(envelope.launchToken) @@ -88,7 +87,6 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { return } - // Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined. const reportedTabId = envelope.tabId !== undefined && envelope.tabId.trim().length > 0 ? envelope.tabId.trim() @@ -135,7 +133,6 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS 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) { return @@ -147,7 +144,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS ) { return } - // Why: restore a shed roster only when its digest and turn identity still match the cache. + // Restore shed fields only against a matching digest and turn. let normalizedPayload = restoreShedStatusFields( validatedPayload, envelope.shedFields, @@ -190,25 +187,19 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS tabId = parsePaneKey(paneKey)?.tabId } if (statusDisposition === 'restart') { - // Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session. - // Why paneKey, not envelope.paneKey: alias resolution already mapped it to the - // stable pane, so the rebind cannot land on a legacy key. + // Rebind the resolved stable key so a new session cannot inherit retired observation order. this.observations.rebind(paneKey) } const previousStatus = this.state.lastStatusByPaneKey.get(paneKey) let acceptedCompactCompletion = false if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') { - // Why: PreCompact is never registered and proves nothing (an aborted compact emits it alone); - // reject it here too so a host on any version cannot drive pane state from it. + // PreCompact alone proves no completed compact, including on older peers. if (hookEventName === 'PreCompact' || source !== 'claude') { return } // 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 - // without this drop, every auto compact on such a host mints exactly the stuck `working` this - // change removes. + // Older relays map auto-compaction to working; it resumes the existing turn and must not mint new work. if (effectiveTrigger !== 'manual' || normalizedPayload.agentType !== source) { return } @@ -251,7 +242,6 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS normalizedPayload.agentType === 'claude' && typeof envelope.claudeRunningNonAgentTask === 'boolean' && (envelope.isReplay !== true || !this.runtimeObservedStatusPaneKeys.has(paneKey)) - // Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected. warnOnHookEnvOrVersionMismatch(this.state, { version: envelope.version, env: envelope.env, diff --git a/src/main/agent-hooks/server/server-ingest-structured-children.ts b/src/main/agent-hooks/server/server-ingest-structured-children.ts index 9b0f898d729..5b354bc5508 100644 --- a/src/main/agent-hooks/server/server-ingest-structured-children.ts +++ b/src/main/agent-hooks/server/server-ingest-structured-children.ts @@ -21,15 +21,11 @@ import { } from '../../../shared/agent-status-subject' import { AgentHookServerIngestStructured } from './server-ingest-structured' -/** Refusals that are the fence doing its job (late or superseded evidence), not a fault. */ +// Superseded evidence is an expected refusal. const EXPECTED_REFUSALS: ReadonlySet = new Set(['stale-invocation']) export abstract class AgentHookServerIngestStructuredChildren extends AgentHookServerIngestStructured { - /** - * Admit one structured session's child-work evidence. The parent publication owns the subject - * and lands first; this refuses to act on a subject the store does not already hold, so a - * child can never conjure a parent row. - */ + /** Child evidence requires an already-published parent and cannot create one. */ ingestStructuredChildWork( subject: AgentStatusStructuredSessionSubject, evidence: AgentChildWorkEvidence[], @@ -60,13 +56,12 @@ export abstract class AgentHookServerIngestStructuredChildren extends AgentHookS return outcome } - /** Every child record this host holds for one structured session. */ getStructuredChildWork(subject: AgentStatusStructuredSessionSubject): AgentChildWorkRecord[] { const parent = parseAgentStatusSubject(subject) return parent ? this.canonicalStatusStore.getChildren(parent) : [] } - /** A record covers its current handles only while it answers to running work. */ + /** Only running work retains its handles. */ getStructuredChildWorkHandles(subject: AgentStatusStructuredSessionSubject): Set { return new Set( this.getStructuredChildWork(subject).flatMap((child) => diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index e455f77782e..b968df36d2c 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -44,8 +44,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng } const previous = this.canonicalStatusStore.getParent(parsed) const priorStatus = previous?.status - // The host's own child records, not the summary's lossy task list: the summary is derived - // from these records, and reading them here keeps the row and every view of it on one source. + // Read canonical children; the summary's task list loses information. const agentStatus = structuredAgentSessionAgentStatus({ status: summary.status, childWork: this.canonicalStatusStore.getChildren(parsed), @@ -53,8 +52,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng ...(summary.stopping ? { stopping: summary.stopping } : {}) }) const { state, workingMode } = agentStatus - // The main agent's own clock keeps continuity the same way the combined row's does below, dated - // by the journal: a restart's republish is not a new main agent state either. + // Journal dates preserve main-agent continuity across republishing after restart. const mainAgent = continueMainAgentStatus( priorStatus?.mainAgent, structuredAgentSessionDatedMainAgent(agentStatus.mainAgent, summary), @@ -77,7 +75,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng state, ...(workingMode ? { workingMode } : {}), mainAgent, - // Readers that predate `mainAgent` read a cancellation off this flag, as the hook lanes publish it. + // Older readers use interrupted instead of mainAgent. interrupted: mainAgentTurnInterrupted(mainAgent), prompt: summary.latestPrompt, agentType: summary.agent, @@ -88,15 +86,11 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng ? { lastAssistantMessage: summary.lastAssistantMessage } : {}), receivedAt: observedAt, - // The journal clock dates a lead's turn, so a restart's republish is not new evidence. It - // cannot date child work: it stopped when the lead did, and reading it as the evidence age - // retires a genuinely live roster at the 30-minute staleness window. Only then does the - // host's own observation clock stand in, matching what the hook lane stamps for its rows. + // Child-held work needs a live observation clock; the stopped main-agent journal would make it expire. evidenceObservedAt: isAgentStatusHeldOpenByChildWork({ state, mainAgent }) ? observedAt : summary.updatedAt, - // Continuity is the whole published work identity: `state` alone no longer means "a turn is - // running", so monitoring that becomes a real turn must restart the clock, not inherit it. + // Monitoring becoming a real turn must restart the clock even if state remains working. stateStartedAt: structuredAgentSessionRowStateStartedAt({ state, mainAgent }, summary) ?? (priorStatus?.state === state && priorStatus.workingMode === workingMode @@ -136,7 +130,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng this.emitEnrichedStatus(after) } - /** Pane cleanup never resolves a canonical subject; only its owning feed can forget this row. */ + /** Only the owning feed can forget a structured row; pane cleanup cannot. */ dropStructuredStatus(subject: AgentStatusStructuredSessionSubject): void { const parsed = parseAgentStatusSubject(subject) if (!parsed || parsed.kind !== 'structured-session') { diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 61f389e3c75..245bc92c6fa 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -18,11 +18,7 @@ import type { ProgramStatusFacet } from '../../../shared/program-status/facet' const PROGRAM_STATUS_FACET_REPUBLISH_MS = 250 export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { - /** - * A resume launch names its provider session before the agent reports one. Codex runs - * SessionStart only at its first turn, so until the user typed, readers had no transcript - * to address and native chat stayed empty (#10923). Seeds an identity-only row. - */ + /** Seed resume identity before the first hook; Codex SessionStart waits for the first turn (#10923). */ recordLaunchResumeIdentity(event: { paneKey: string worktreeId?: string @@ -59,14 +55,13 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges connectionId: null, providerSession: event.providerSession, providerSessionOnly: true, - // Why: hydration keeps only marked identity rows; a host restart before the first turn must not lose it. + // Hydration must retain this identity even if no first turn occurs before restart. retainedForLiveness: true, payload: { state: 'done', prompt: '', agentType: event.agentType } } this.applyNormalizedStatus(identity, undefined, 'launch') } - /** The pane's OSC 7501 facet changed. Applies whoever owns the row's state; see ProgramStatusFacetStore. */ ingestProgramStatusFacet(event: { paneKey: string; facet: ProgramStatusFacet | null }): void { const paneKey = this.resolvePaneKeyAlias(event.paneKey.trim()) if ( @@ -126,8 +121,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges event.terminalHandle?.trim() && reportedTabId === legacyPaneKey.tabId ) - // Legacy rows are accepted only from the in-process PTY ingress with both runtime identities; - // HTTP and relay paths still require a stable pane key or a registered alias. + // Only PTY ingress can admit legacy keys with runtime identities; HTTP and relay require stable keys or aliases. if (paneKey.length > MAX_PANE_KEY_LEN || (!parsedPaneKey && !runtimeOwnedLegacyPane)) { return } @@ -146,7 +140,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges connectionId }) const newRun = event.origin === 'process' && event.payload.state === 'working' - // Why: a verified process-lifetime Working proves a new agent run, as a hook new-turn event does. + // Process-lifetime working proves a new run, like a hook turn boundary. const disposition = this.getAgentStatusDisposition( paneKey, newRun @@ -192,22 +186,21 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges const previous = this.state.lastStatusByPaneKey.get(paneKey) as | EnrichedAgentHookEventPayload | undefined - // Why: a hook that reported during this command owns it (OpenCode 1 `run` loads its plugin in-process). + // OpenCode 1 run loads hooks in-process; a hook during the command owns its status. const hookOwnsCommand = event.yieldsToHookSince !== undefined && previous?.observation?.origin === 'hook' && previous.receivedAt >= event.yieldsToHookSince const now = Date.now() - // Before the removal branch: a removal is a turn boundary in the program's report order. + // Record ordering before removal, which also marks a program turn boundary. const workingSince = event.fromProgramStatus === true ? this.programStatusFacets.noteState(paneKey, event.payload.state, now) : undefined if (event.removesProgramStatusRow === true) { - // Why: gone records end only the program's own row; they are no verdict on a hook agent. this.hookSilenceRechecks.cancel(paneKey) if (previous?.observation?.origin === 'osc') { - // Why a row drop, not pane teardown: the process lives on, and so do its alias and caches. + // The program row ended; the pane, aliases and caches remain live. if (mutationBefore !== undefined) { this.commitStatusRowMutation(mutationBefore, previous) } @@ -227,7 +220,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges const delayMs = yieldEndsAt - now this.hookSilenceRechecks.defer(paneKey, delayMs + 1, (currentPaneKey) => { const row = this.state.lastStatusByPaneKey.get(currentPaneKey) - // Only against the hook observation it yielded to: a newer turn or a cleared pane drops it. + // Re-offer only against the hook observation this report yielded to. if ( row && row.providerSessionOnly !== true && @@ -245,22 +238,19 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges if ( hookOwnsCommand || hookOwnsAgent || - // OSC 7501 settles child-held rows through its own hook precedence above. + // OSC 7501 uses the hook precedence above; other terminal reports cannot settle child-held work. (event.fromProgramStatus !== true && previous?.payload.agentType === 'claude' && event.payload.agentType === 'claude' && isAgentStatusHeldOpenByChildWork(previous.payload) && previous.payload.subagents?.some((subagent) => subagent.state === 'working') === true) ) { - // Why: OSC carries no child identity, so it cannot settle or repaint a row child agents hold open - // (working, or waiting on a child's prompt); their lifecycle hooks will. if (previous && mutationBefore !== undefined) { this.commitStatusRowMutation(mutationBefore, previous) this.emitEnrichedStatus(previous) } return } - // Why: preserve the hook-completed turn stamp while OSC repaints the current state. const preserveActiveTurnStamp = previous?.payload.turnCompletedAt !== undefined && previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey) @@ -269,16 +259,14 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges previous?.connectionId === connectionId && previous.tabId === tabId && previous.worktreeId === worktreeId && - // Why in the unchanged gate: the handle is a join key readers match on, so a pane that - // only just acquired one (or moved to another) must still refresh the row it is stamped on. + // A terminal handle joins row identity; acquiring or replacing it requires publication. previous.terminalHandle === (terminalHandle ?? previous.terminalHandle) && terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) && - // OSC 7501 does carry children, so a child-only change is news (absent means none left). + // OSC 7501 child-only changes still require publication; absent children means none remain. (event.fromProgramStatus !== true || agentSubagentsEqual(previous.payload.subagents, event.payload.subagents)) ) { - // A handle-authority transfer is a new pane observation even when its payload is a - // duplicate; enriched subscribers must capture the replacement pane identity. + // An authority transfer must refresh enriched subscribers even for an identical payload. this.refreshTerminalStatusEvidence( previous, mutationBefore, @@ -287,19 +275,12 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges ) return } - // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is - // never evidence that the session ended — yet overwriting the row dropped the cached identity. - // That erased it from persisted rows (lost across restart) and from headless `orca serve`, which - // serves these rows to mobile directly instead of the renderer store, blanking Chat UI (#10630). - // A new turn after `done` still starts clean so a reused pane cannot inherit a finished session. - // Why: mirror resolveAgentStatusIdentity, which treats a literal 'unknown' exactly like an - // omitted type — an OSC ping that names no agent makes no claim about the pane's identity, so - // it must not be read as a mismatch and strip the session the renderer would have kept. + // Terminal status carries no provider session; retain eligible cached identity without treating unknown as a mismatch (#10630). const claimedAgentType = event.payload.agentType && event.payload.agentType !== 'unknown' ? event.payload.agentType : undefined - // A launch seed's `done` is a placeholder, not a finished turn, so its session carries over. + // Launch seeds are not completed turns; real completed sessions must not leak into reused panes. const preservedProviderSession = previous?.providerSession && (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) && @@ -308,8 +289,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges previous.observation?.origin === 'launch') ? previous.providerSession : undefined - // Why: OSC carries no main agent fact. While it repaints the state the hook row already holds, the - // main agent behind that state is unchanged too; a different state is a turn edge OSC cannot date. + // Without a new main-agent fact, preserve it only while the row's state and agent still match. const preservedMainAgent = event.payload.mainAgent ?? (previous?.payload.mainAgent && @@ -317,7 +297,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) ? previous.payload.mainAgent : undefined) - // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. + // Terminal observations cannot establish prompt-sent telemetry. this.applyNormalizedStatus( { paneKey, diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 3391b6548dc..0e176312415 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -28,7 +28,6 @@ type HookServerStartOptions = { export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle { private readonly startup = new AgentHookStartup() - /** Start the loopback listener after hydration and spool replay have settled. */ start(options?: HookServerStartOptions): Promise { return this.startup.start((isCurrent) => this.startListener(options, isCurrent)) } @@ -49,7 +48,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook this.env = options.env } if (options?.userDataPath) { - // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. + // Namespace dev endpoints per instance; packaged endpoints stay stable for PTY reconnect. this.configureEndpointPaths(options.userDataPath, options.endpointNamespace) } this.token = randomUUID() @@ -67,7 +66,6 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook res.end() return } - // Why: authenticate before spending work reading an untrusted body. if (req.headers['x-orca-agent-hook-token'] !== this.token) { res.writeHead(403) res.end() @@ -76,8 +74,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook const statusHookGeneration = this.statusHookGeneration const isStatusCurrent = () => isCurrent() && this.statusHooksEnabled && this.statusHookGeneration === statusHookGeneration - // Why: bound request time so a stalled client can't hold a socket open (slowloris). - // Why: track our own destroy so the slowloris cap can't be misread as outside interference. + // Track our slowloris timeout so it cannot count as external transport interference. let destroyedBySlowlorisCap = false req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { destroyedBySlowlorisCap = true @@ -128,7 +125,6 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook res.end() return } - // Why: merge transport headers before normalization so relay-compatible fields have one canonical path. const hookBody = mergeAgentHookRequestHeaders(body, req.headers) trackEmptyPaneKeyHook(hookBody) const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody) @@ -174,8 +170,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook } : normalized.event if (statusDisposition === 'restart') { - // Why: a retired pane accepting a new turn is a different agent session behind the - // same key — later observations must not be ordered against the retired one. + // A new session must not inherit the retired one's observation order. this.observations.rebind(event.paneKey) } this.recordCurrentAuthorityObservation(event) @@ -199,18 +194,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook res.end('{"allowed":false}') return } - // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut - // by something on the loopback path, not by a bad payload. Fail open as before, but count it — - // this is the one failure mode that silently stops status for every runtime at once. + // Authenticated body truncation can interrupt all hook status; record it despite fail-open handling (#11217). if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { this.transportInterference.record({ source: resolveHookSource(pathname) ?? null, error }) } - // Why: fail open — return success on malformed payloads so a broken hook never blocks the agent. + // Malformed hooks must not block the agent. res.writeHead(204) res.end() } } - // Why: node ignores a returned promise, so the handler must settle it itself; handleRequest never rejects. + // Node ignores handler promises; handleRequest catches request errors internally. this.server = createServer((req, res) => { void handleRequest(req, res) }) @@ -246,8 +239,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook this.endpointFileWritten = false } - /** `keepStatus`: quit only. The chat teardown after it still reads each chat's subagents and - * background tasks from the status store to decide what to offer to resume; exit frees it. */ + /** Quit retains status until chat teardown reads resumable child and background work. */ stop(options?: { keepStatus?: boolean }): void { this.startup.stop() // Terminal status may still have a pending write while hook ingress is disabled. @@ -298,7 +290,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook this.legacyPaneKeyAliases.clear() this.paneKeyAliasPersistenceListener = null this.ownerStateInitialized = false - // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. + // Leave the endpoint: stale files fail open, and unlinking can race another Orca instance. clearAllListenerCaches(this.state) if (!options?.keepStatus) { this.resetCanonicalStatus() diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index d9ec13aac6c..79e72d18cb9 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -25,7 +25,7 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row' import { PaneTimers } from './program-status/pane-timers' import { ProgramStatusFacetStore } from './program-status/facet-store' -// Why: the listing counter starts at 1, so an unassigned row must sort last — never above every ordered row. +// Unassigned rows sort after the listing counter, which starts at 1. const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { @@ -34,11 +34,10 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { setCliWriterExecutionMatcher(...args: Parameters): void { this.cliWriterExecutionMatches = cliWriterExecutionMatcher(...args) } - // Why here: every publish below stitches from it, and all later layers manage its lifecycle. protected readonly programStatusFacets = new ProgramStatusFacetStore() - /** Yielded OSC 7501 reports, re-offered when hook precedence lapses: programs never resend a state. */ + /** Retry yielded OSC 7501 reports when hook precedence expires; programs may not resend. */ protected readonly hookSilenceRechecks = new PaneTimers() - /** Trailing facet-only republishes: progress and message ticks can arrive at PTY chunk rate. */ + /** Coalesce facet-only ticks that arrive at PTY chunk rate. */ protected readonly programStatusFacetRepublishes = new PaneTimers() setStartupPromptClaimListener( @@ -91,11 +90,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } return rows.sort((a, b) => a.order - b.order).map(({ entry }) => entry) } - /** - * Notified once per process when repeated hook POSTs are cut off mid-body (#11217). - * Why: the listener fails open on every request error, so without this the only symptom is - * agent status quietly going stale — for every runtime at once, since they share this transport. - */ + /** Report repeated hook-body truncation once per process; fail-open handling otherwise hides it (#11217). */ setTransportInterferenceListener( listener: ((report: HookTransportInterferenceReport) => void) | null ): void { @@ -107,7 +102,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { if (!listener) { return } - // Why: replay is best-effort per pane so one throwing listener can't starve the rest. + // One failing replay subscriber must not starve other panes. for (const payload of this.combinedStatusEntries()) { try { listener({ ...payload, isReplay: true }) @@ -117,7 +112,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } } - // Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly. + // Claude statusline carries usage windows for RateLimitService, not agent status. setClaudeStatusLineListener( listener: ((event: ClaudeStatusLineRateLimits) => void) | null ): void { @@ -160,9 +155,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } } - /** Multi-subscriber tap on definitive live-row deletions. `dropStatusEntry` is a user - * dismissal, so it never routes through the pane-status-clear fan-out — pane-owned - * cleanup (synthetic spinners) still has to retire with the row it was driving. */ + /** Include user-dismissed rows, which bypass pane-clear events but must still retire pane-owned UI. */ subscribeStatusDrop(listener: StatusDropListener): () => void { this.statusDropListeners.add(listener) return () => { @@ -172,7 +165,6 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { protected emitStatusDropped(paneKey: string): void { for (const listener of this.statusDropListeners) { - // Why: matches every other fan-out here — one throwing subscriber must not strand the rest. try { listener(paneKey) } catch (err) { @@ -181,7 +173,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } } - /** Multi-subscriber tap on every enriched status change (no replay). */ + /** Enriched changes only; no replay. */ subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void { this.enrichedStatusListeners.add(listener) return () => { @@ -200,9 +192,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { this.onPaneStatusCleared = listener } - /** Multi-subscriber tap on pane status clears. Unlike `setPaneStatusClearListener` - * (a single slot the main window owns and drops on close) this survives window - * teardown and exists at all under headless serve, which never opens one. */ + /** Survives window teardown and works under headless serve, unlike the window's single listener slot. */ subscribePaneStatusClear(listener: (clear: AgentStatusClearIpcPayload) => void): () => void { this.paneStatusClearListeners.add(listener) return () => { @@ -213,8 +203,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { protected emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void { this.onPaneStatusCleared?.(clear) for (const listener of this.paneStatusClearListeners) { - // Why: callers are pane/connection teardown paths; one throwing subscriber must - // not strand the rest, matching every other fan-out here. + // One throwing subscriber must not interrupt teardown for the others. try { listener(clear) } catch (err) { @@ -223,13 +212,11 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { } } - /** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the - * dashboard catches up on hook events that fired during startup. */ getStatusSnapshot(): AgentStatusIpcPayload[] { return this.combinedStatusEntries().map(toAgentStatusIpcPayload) } - /** Provider-session identities, including Pi's metadata-only rows. */ + /** Include metadata-only provider identities. */ getProviderSessionIdentities(): AgentHookProviderSessionIdentity[] { return this.buildStatusChangeNotification().providerSessions } @@ -317,7 +304,6 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { return this.buildStatusChangeNotification().statuses } - /** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */ _getStateForTests(): HookListenerState { return this.state } diff --git a/src/main/agent-hooks/server/server-opencode-binder.ts b/src/main/agent-hooks/server/server-opencode-binder.ts index a1d2d6d8d38..350003ffc1e 100644 --- a/src/main/agent-hooks/server/server-opencode-binder.ts +++ b/src/main/agent-hooks/server/server-opencode-binder.ts @@ -19,33 +19,23 @@ import { import { lookupOpenCodeSessionPane } from '../../../shared/agent-hook-listener/opencode-session-registry' import { AgentHookServerPersistence } from './server-persistence' -/** Poll cadence; hook-triggered kicks cover births between polls. */ const OPENCODE_BINDER_INTERVAL_MS = 60_000 const OPENCODE_BINDER_KICK_DEBOUNCE_MS = 10_000 -/** Unbound sessions get re-correlated this long (pane inventory may lag births). */ +// Pane inventory can lag session births; retry unbound sessions within this window. const OPENCODE_BINDER_UNBOUND_RETRY_MS = 10 * 60_000 const OPENCODE_BINDER_PARENTS_MAX = 2_000 const OPENCODE_BINDER_UNBOUND_MAX = 500 -/** Injectable I/O for the binder loop; real singletons by default, fakes in tests. */ export type OpenCodeBinderLoopDeps = { now: () => number dbPath: () => string - /** Resolves `[]` when the store or its worker cannot answer; never rejects. */ + /** The default reader resolves [] when the store or worker cannot answer. */ listSessions: (dbPath: string, cursor: OpenCodeSessionCursor) => Promise listPanes: () => BinderPaneSnapshot[] sweep: () => Promise } -/** - * Session→pane binder loop for the OpenCode 1 `serve` process shared by - * `attach` panes (#21359). OpenCode-1-only: remove with OpenCode 1 support. - * - * Sits just above persistence in the chain so ingest layers can kick a round - * when a birth arrives early, and lifecycle can start/stop the timer. All - * I/O rides injectable deps (real singletons by default) so tests drive the - * whole loop without touching the user's opencode.db or process table. - */ +/** Bind shared OpenCode 1 serve sessions to attach panes; remove with OpenCode 1 support (#21359). */ export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersistence { private openCodeBinderTimer: ReturnType | null = null private openCodeBinderKickTimer: ReturnType | null = null @@ -62,12 +52,11 @@ export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersi sweep: () => sweepProcessIdentities() } - /** Test seam: drive the loop without the user's database or process table. */ + /** Keep tests away from the user's database and process table. */ protected _setOpenCodeBinderDepsForTests(deps: Partial): void { this.openCodeBinderDeps = { ...this.openCodeBinderDeps, ...deps } } - /** Start the 60s poll loop plus one immediate round, idempotently. */ protected startOpenCodeBinderLoop(): void { if (this.openCodeBinderTimer) { return @@ -78,15 +67,12 @@ export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersi if (this.openCodeBinderTimer.unref) { this.openCodeBinderTimer.unref() } - // Why immediately: existing sessions would otherwise keep the frozen - // stamp for up to a full interval after launch or restart. + // Bind existing sessions immediately instead of leaving their initial stamp for a full interval. void this.runOpenCodeBinderRoundOnce() } - /** Stop timers and drop ephemeral binder state; in-flight rounds are discarded by generation. */ protected stopOpenCodeBinderLoop(): void { - // Why the generation bump: a round awaiting the process sweep must not - // apply ownerships — or resurrect the watermark — after the loop stopped. + // Invalidate in-flight rounds so they cannot restore stopped state. this.openCodeBinderGeneration += 1 if (this.openCodeBinderTimer) { clearInterval(this.openCodeBinderTimer) @@ -102,11 +88,7 @@ export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersi this.openCodeBinderUnbound.clear() } - /** - * A birth may have arrived (opencode SessionStart): run one round soon so - * the session binds before its first busy stretch, instead of waiting out - * the poll interval. Trailing-edge debounced; concurrent rounds collapse. - */ + /** Coalesce birth-triggered kicks so new sessions bind before the next poll. */ protected kickOpenCodeBinder(): void { if (this.openCodeBinderKickTimer) { return @@ -120,22 +102,18 @@ export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersi } } - /** Run one correlate-and-bind round; returns applied binding count. */ protected async runOpenCodeBinderRoundOnce(): Promise { if (this.openCodeBinderRunning) { return 0 } this.openCodeBinderRunning = true - // Why capture before the try: if stop() lands while the sweep is in - // flight and a restart begins a new round, the obsolete round must not - // clear the new round's running flag (or two rounds overlap and apply - // ownership snapshots out of order). + // An obsolete round's finally must not clear a restarted loop's running flag. const generation = this.openCodeBinderGeneration try { const deps = this.openCodeBinderDeps const nowMs = deps.now() const fresh = await deps.listSessions(deps.dbPath(), this.openCodeBinderWatermark) - // Why: stop() may land while the worker reads; its state and watermark were reset. + // Stop resets state and watermark while the worker may still be reading. if (generation !== this.openCodeBinderGeneration) { return 0 } @@ -195,9 +173,7 @@ export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersi this.openCodeBinderUnbound.set(session.id, { row: session, firstSeenMs: nowMs }) } } - // Why from handled rows only: a session the full map could not track - // must stay re-listable next round instead of being silently passed by - // the watermark. + // Untracked rows must remain before the watermark so the next round can retry them. this.openCodeBinderWatermark = advanceBinderCursor({ fresh, isHandled: (sessionId) => @@ -208,8 +184,7 @@ export abstract class AgentHookServerOpenCodeBinder extends AgentHookServerPersi }) return applied } catch (err) { - // Why swallow: a binder failure must never break hook serving; the next - // round retries, and unbound sessions keep today's stamped behavior. + // Binder failure must not break hook serving; the next round retries. console.warn('[opencode-binder] round failed; keeping stamped attribution', err) return 0 } finally { diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index c5187dfdd5e..b4189d6e164 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -30,17 +30,14 @@ export function dropHydratedIdleClaudeSubagents( return payload } const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle') - // Why: an idle teammate's liveness can't be proven across a restart (its TeammateIdle confirmation is in-memory); prune so a dead pile can't resurrect — a live teammate re-earns its row via SubagentStart. + // Idle teammates cannot prove liveness across restart; fresh hooks must restore them. return { ...payload, subagents: activeSubagents.length > 0 ? activeSubagents : undefined } } -/** Rows written before `mainAgent` existed persisted `claudeLeadBoundaryChildOnly: true` instead: the - * main agent had settled and child agents alone held the row `working`. That is `mainAgent.state === 'done'` - * stored as a boolean, so it only fills an absent `mainAgent`; a row carrying both keeps `mainAgent`. The - * flag stays readable until every user's file has been rewritten without it. */ +/** Old caches use claudeLeadBoundaryChildOnly for a settled main agent; honor it only without mainAgent. */ function legacyChildOnlyBoundaryMainAgent( payload: ParsedAgentStatusPayload, record: Record, @@ -53,8 +50,7 @@ function legacyChildOnlyBoundaryMainAgent( ) { return undefined } - // Why: the gated working row stamps the main agent's end as `turnCompletedAt`; the row clock is the - // nearest fact an older row that lacks it can offer. + // Older rows may lack the main-agent end stamp; use their row clock as the fallback. return { state: 'done', stateStartedAt: payload.turnCompletedAt ?? stateStartedAt } } @@ -73,7 +69,7 @@ export function sanitizeHydratedEntry( if (record.paneKey !== paneKey) { return null } - // Why: structured rows are never written; the host republishes the live projection on restore. + // Structured rows come from the journal projection, never this cache. if (record.structuredHost !== undefined) { return null } @@ -81,7 +77,6 @@ export function sanitizeHydratedEntry( if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { return null } - // Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row. if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) { return null } @@ -101,7 +96,6 @@ export function sanitizeHydratedEntry( ) { return null } - // Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest. const connectionIdRaw = record.connectionId let connectionId: string | null if (connectionIdRaw === null || connectionIdRaw === undefined) { @@ -126,7 +120,7 @@ export function sanitizeHydratedEntry( const claudeRunningNonAgentTask = typeof record.claudeRunningNonAgentTask === 'boolean' ? record.claudeRunningNonAgentTask - : // Why: the legacy flag was only ever written while no shell ran beside the children. + : // The legacy child-only flag also proved no shell work remained. legacyBoundaryMainAgent ? false : undefined diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index 193fee1fda1..83849ab5eda 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -35,24 +35,22 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio continue } const enrichedPayload = payload as EnrichedAgentHookEventPayload - // Why: the session journal is the durable truth for a structured row and the host republishes - // it on restore; a persisted copy would hydrate unconfirmed and fight that republish. + // Journal projections must not compete with unconfirmed copies hydrated from this cache. if (enrichedPayload.structuredHost) { continue } const { promptInteractionKey: _promptInteractionKey, - // Why: stitched on at publish only; guards a stitched copy that reached the map by mistake. + // Facets are stitched at publication; discard any copy that reached the map. programStatus: _programStatus, programStatusOnly: _programStatusOnly, - // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. + // Hydration restamps restoredUnconfirmed. restoredUnconfirmed: _restoredUnconfirmed, - // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). + // Observation sequences belong to this runtime. observation: _observation, // Replay provenance is runtime-only and must not survive another restart. isReplay: _isReplay, - // A terminal handle belongs to the runtime that issued it; a hydrated one could only - // rejoin a row to somebody else's terminal. + // Terminal handles belong to this runtime and cannot safely rejoin a hydrated row. terminalHandle: _terminalHandle, hostTurnRevision: _hostTurnRevision, launchToken, @@ -61,8 +59,6 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio const launchTokenHash = launchToken?.trim() ? createHash('sha256').update(launchToken.trim()).digest('hex') : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) - // `payload.mainAgent` rides inside the payload; the legacy `claudeLeadBoundaryChildOnly` flag it - // replaced is read at hydrate and never written again. const { claudeTaskWakeupPending: _pendingWakeup, ...persistedStatus } = persistedPayload.payload entries[paneKey] = { @@ -81,7 +77,7 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio } } } - // Why: only a successor claim forgets an exit, so a pane that never runs another agent would keep it. + // Panes without successor agents would otherwise retain exit evidence forever. pruneAgentExits(this.agentExitedAtByPaneKey, Date.now() - HYDRATE_MAX_AGE_MS, (paneKey) => this.state.lastStatusByPaneKey.has(paneKey) ) @@ -95,16 +91,15 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio return JSON.stringify(file) } - // Why: leading-edge throttle — an isolated change is written on the next turn, while a stream of - // changes gets at most one write per window and cannot postpone it (#26720). + // Leading-edge throttling prevents event streams from postponing writes indefinitely (#26720). protected scheduleStatusPersist(): void { if (!this.lastStatusFilePath) { return } const now = Date.now() - // Why: authority loss after a crash weakens the spool token filter, so it skips the window. + // Persist authority changes immediately to preserve spool replay filtering after a crash. const urgent = this.currentAuthorityFingerprint() !== this.lastPersistedAuthorityFingerprint - // Why: clamp so a wall-clock rollback can't push the next write far into the future. + // A wall-clock rollback must not defer persistence indefinitely. const dueAt = urgent ? now : Math.min( @@ -112,7 +107,6 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio now + STATUS_PERSIST_MIN_INTERVAL_MS ) if (this.statusPersistTimer) { - // Why: never push an armed write back; only pull it forward for urgent work. if (dueAt >= this.statusPersistDueAt) { return } @@ -123,7 +117,7 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio this.statusPersistTimer = null this.startStatusPersist() }, this.statusPersistDueAt - now) - // Why: don't keep the event loop alive just for a status flush — quit already flushes sync. + // Quit flushes synchronously; this timer must not keep the runtime alive. if (typeof this.statusPersistTimer.unref === 'function') { this.statusPersistTimer.unref() } @@ -137,7 +131,7 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio this.runStatusPersist() } - /** Synchronous write for quit, hook-disable and hydration repair; ordinary updates use the async path. */ + /** Quit, hook-disable and hydration repair need synchronous writes; routine updates are async. */ protected runStatusPersist(): void { const writer = this.getStatusSnapshotWriter() if (!writer) { @@ -185,7 +179,7 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio return this.statusSnapshotWriter } - /** Cheap per-event check: revision counters plus live launch tokens, no hashing or serialization. */ + /** Revision counters and live tokens avoid per-event hashing or serialization. */ protected currentAuthorityFingerprint(): string { let fingerprint = `${this.hydratedLaunchTokenHashByPaneKey.revision}:${this.persistedAuthorityCommitmentsByPaneKey.revision}` for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { @@ -216,7 +210,7 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio return } this.reportStatusPersistFailure(err) - // Why: the failed state may never be followed by another mutation; retry next window. + // Retry even if no later mutation arrives. this.scheduleStatusPersist() } ) @@ -230,7 +224,6 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio } private reportStatusPersistFailure(err: unknown): void { - // Why: one warning per failure streak, not one per hook event. if (!this.statusPersistFailing) { console.warn('[agent-hooks] failed to write last-status file:', err) } diff --git a/src/main/agent-hooks/server/server-remote-envelope-normalization.ts b/src/main/agent-hooks/server/server-remote-envelope-normalization.ts index 3211a7c30d6..1e863e48cfe 100644 --- a/src/main/agent-hooks/server/server-remote-envelope-normalization.ts +++ b/src/main/agent-hooks/server/server-remote-envelope-normalization.ts @@ -35,10 +35,10 @@ export type RemoteAgentStatusEnvelope = { isReplay?: boolean turnEndedAt?: unknown turnEndDisposition?: unknown - /** Payload fields the relay dropped to fit an oversized frame; validated below. */ + /** Fields shed to fit the relay frame; validate before restoration. */ shedFields?: unknown 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. */ + /** Producing peer capabilities; absent on older peers, which use legacy defaults. */ advertisedAgentStatusCapabilities?: readonly string[] statusUnavailable?: unknown evidenceAgeMs?: unknown @@ -110,7 +110,7 @@ export function normalizeRemoteEnvelopeFields(envelope: { typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0 ? envelope.toolUseId.trim() : undefined, - // Why replay only: a live hook carries its own settlement, so the list matters only after a gap. + // Live hooks settle their own requests; retained settlements only repair replay gaps. claudeSettledRequests: source === 'claude' && envelope.isReplay === true ? readClaudeSettledRequests(envelope.claudeSettledRequests) diff --git a/src/main/agent-hooks/server/server-row-ownership.test.ts b/src/main/agent-hooks/server/server-row-ownership.test.ts index 3a63a0f2a75..f6ff977c4b1 100644 --- a/src/main/agent-hooks/server/server-row-ownership.test.ts +++ b/src/main/agent-hooks/server/server-row-ownership.test.ts @@ -87,7 +87,7 @@ describe('status-row change detection', () => { expect(probe.commit(before, reordered)).toBe(false) }) - // Why: paired clients read the whole payload off session.tabs, so no field may be skipped. + // Paired clients read the entire payload through session.tabs. it.each<[string, Partial]>([ ['state', { payload: { state: 'done', prompt: 'ship it' } }], [ diff --git a/src/main/agent-hooks/server/server-row-ownership.ts b/src/main/agent-hooks/server/server-row-ownership.ts index e8115d32661..e350aa3a2b3 100644 --- a/src/main/agent-hooks/server/server-row-ownership.ts +++ b/src/main/agent-hooks/server/server-row-ownership.ts @@ -35,8 +35,7 @@ function semanticRow(row: EnrichedAgentHookEventPayload): Record() - /** When this host proved a pane's agent process ended; outlives the row the exit reconciles. */ + /** Proven exits outlive the rows they reconcile. */ protected readonly agentExitedAtByPaneKey = new Map() - /** A new agent (or a new worker attach) claimed the pane; the previous exit no longer describes it. */ + /** A successor or worker attach invalidates the old agent's exit. */ forgetAgentExit(paneKey: string): void { const forgotten = forgetAgentExit(this.agentExitedAtByPaneKey, paneKey) if ( @@ -100,7 +98,7 @@ export abstract class AgentHookServerState { protected server: ReturnType | null = null protected port = 0 protected token = '' - // Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge. + // Detect cross-talk between dev and packaged hook endpoints. protected env = 'production' protected onAgentStatus: ServerAgentStatusListener = null protected onClaudeStatusLine: ServerStatusLineListener = null @@ -117,18 +115,14 @@ export abstract class AgentHookServerState { protected statusRowMutationListeners = new Set() // Hydration and spool replay belong to the owner lifetime, not each transport bind attempt. protected ownerStateInitialized = false - // Runtime terminal handles are stable across pane remints, unlike tab/leaf keys. This index is - // deliberately in-memory only and contains no rows of its own. + // Runtime handles survive pane remints; this in-memory index owns no rows. protected paneKeyByTerminalHandle = new Map() - // Why: setListener is a single slot owned by the main-window fanout; the - // plugin event bus (and future consumers) need an additive subscription - // that also works in headless serve, where no window listener exists. + // Additive subscribers survive window teardown and work under headless serve. protected enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>() - // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). + // start() supplies paths so the hook server has no Electron dependency. protected endpointDir: string | null = null protected endpointFilePathCache: string | null = null protected endpointFileWritten = false - // Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination. protected state: HookListenerState = createHookListenerState({ nextListingOrder: this.nextStatusListingOrder, isCanonicalPaneKey: (paneKey) => this.canonicalSubjectsByPane.has(paneKey) @@ -141,11 +135,10 @@ export abstract class AgentHookServerState { this.onTransportInterference?.(report) } ) - // Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime. + // Hydrated work is UI continuity, not current-runtime liveness evidence. protected runtimeObservedStatusPaneKeys = new Set() protected hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([]) - // Why: revisioned so a persisted-authority change can bypass the write throttle (spool replay and - // restored-terminal attestation read these after a crash). + // Authority revisions bypass throttling to preserve replay fencing after a crash. protected hydratedLaunchTokenHashByPaneKey = new RevisionedMap() protected persistedAuthorityCommitmentsByPaneKey = new RevisionedMap< string, @@ -154,14 +147,11 @@ export abstract class AgentHookServerState { protected revokedHydratedAuthorityCommitments = new WeakSet() protected currentAuthorityObservations = new Map() protected legacyPaneKeyAliases = new Map() - // Why: indexed by every key the retirement fenced, so a re-attach on any of them - // (owner, physical, or a deleted alias) finds the same record. Bounded like the maps - // it mirrors; an evicted record simply degrades to lifting the key it was handed. + // Index every retired alias so reattachment finds the full fence; eviction falls back to single-key lifting. protected retiredPaneFencesByKey = new Map() protected paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null - // Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies. + // Without userDataPath, persistence is disabled and replay remains in memory. protected lastStatusFilePath: string | null = null - // Why: leading-edge throttle timer, per-instance so test servers in one process don't share state. protected statusPersistTimer: ReturnType | null = null protected statusPersistDueAt = 0 protected lastStatusPersistStartedAt = Number.NEGATIVE_INFINITY @@ -177,12 +167,9 @@ export abstract class AgentHookServerState { { hash: string; allowRetainedOwner?: true } >() protected connectionTimestampWatermarkById = new Map() - // Why: survives the row itself. A transport clear deletes the pane's status row on purpose - // (absence, not completion), but the *age* of the evidence a later replay restates is not a - // claim about the pane and must not be lost with it. Bounded like its sibling maps. + // Transport clears remove rows, but replay must retain evidence age rather than renew it. protected evidenceObservedAtByPaneKey = new Map() - // Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own - // OSC parse all converge on applyNormalizedStatus, so one sequencer covers every ingress here. + // Main sequences HTTP, relay and OSC observations for local, WSL and SSH panes. protected readonly observations = new AgentStatusObservationSequencer( createAgentStatusAuthorityId('main-agent-hooks') ) diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index c636ef42231..ae632a0ec85 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -18,18 +18,14 @@ import type { EnrichedAgentHookEventPayload } from './server-types' import { agentTypeToPromptSentAgentKind } from './server-status-identity' import { AgentHookServerStatusDisposition } from './server-status-disposition' -/** Bounds the retained observation clock; eviction only degrades a replay to `now`. */ +// Evicting remembered evidence makes later replay fall back to its arrival time. const MAX_REMEMBERED_EVIDENCE_OBSERVATIONS = 1024 function mainAgentState(payload: ParsedAgentStatusPayload): AgentStatusState { return payload.mainAgent?.state ?? payload.state } -/** - * When the main agent's current turn began. Its own turn-opening event stamps it (the same - * classifier the observation boundary uses), except a session boundary, which lands idle with no - * turn open. Replays, child events and identity-only rows restate a turn rather than open one. - */ +/** Only live main-agent turn boundaries establish a start; replay and child events preserve it. */ function resolveTurnStartedAt( previous: EnrichedAgentHookEventPayload | undefined, payload: AgentHookEventPayload, @@ -44,8 +40,7 @@ function resolveTurnStartedAt( ) { return payload.payload.sessionBoundary === true ? undefined : at } - // Why: a settled main agent running again with no opening event (an OSC repaint racing the - // prompt hook) is a turn edge nothing dated; carrying the old stamp would count from that turn. + // An undated return to work cannot inherit the completed turn's start. if ( previous && mainAgentState(previous.payload) === 'done' && @@ -101,9 +96,7 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt } this.commitStatusRowMutation(mutationBefore ?? previous, refreshed) this.scheduleStatusPersist() - // A dismissed row may retain only provider resume identity. Its preserved payload can still - // read `working`, but it is deliberately hidden from live readers and must not renew awake or - // mobile freshness leases. + // Hidden resume identity can still say working; it must not renew awake or mobile leases. if (refreshed.providerSessionOnly === true) { return } @@ -131,8 +124,7 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt AGENT_STATUS_2A_CURRENT_PRODUCER_MODE ) } - /** `observedAt` is the producer's own clock for evidence that has one (a session journal); it - * stamps the evidence and state-start times while `receivedAt` keeps delivery order. */ + /** Producer clocks date evidence and state; receivedAt retains delivery order. */ protected attachStatusTiming( payload: AgentHookEventPayload, now = Date.now(), @@ -158,7 +150,7 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt ? previous.stateStartedAt : (observedAt ?? now) const turnStartedAt = resolveTurnStartedAt(previous, payload, observedAt ?? now) - // Why: a relay's row was minted on the relay, which saw the event; main only restates it. + // Relay rows carry their execution host's turn-end evidence. const { hostTurn, ...turnEnd } = reduceHostTurnEnd( previous, payload, @@ -173,26 +165,19 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt hostTurn: _hostTurn, ...event } = payload - // Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival. return { ...event, receivedAt: now, evidenceObservedAt: observedAt ?? this.resolveEvidenceObservedAt(payload, previous, now), stateStartedAt, - // Always written here, so a producer can never declare it. + // Producers cannot declare a main-agent turn start. turnStartedAt, ...turnEnd, ...(hostTurn ? { hostTurn } : {}) } } - /** - * A replay restates evidence already observed; it is not a new observation. Keeping - * `receivedAt` at `now` preserves delivery order (the connection-clear watermark and the - * renderer's four `<` drops all depend on it), while this clock records when the evidence - * was actually seen — so the staleness window measures age, not reconnect count. - * Without a remembered time the honest answer is `now`, which is today's behaviour. - */ + /** Replay keeps evidence age separate from arrival order; unknown age falls back to now. */ private resolveEvidenceObservedAt( payload: AgentHookEventPayload, previous: EnrichedAgentHookEventPayload | undefined, @@ -270,7 +255,7 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt promptInteractionKey }) try { - // Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality. + // Hooks prove prompt submission but cannot identify the launching UI. track('agent_prompt_sent', { agent_kind: agentKind, launch_source: 'unknown', @@ -282,9 +267,7 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt } } - /** Stamp who observed this event, in what order, on main's clock. Nothing reads it yet - * (STA-4293) — it is stamped here because every main-side ingress funnels through - * applyNormalizedStatus, so no origin can silently arrive untagged. */ + /** Sequence every ingress with runtime provenance, including hook/OSC precedence. */ protected stampObservation( payload: AgentHookEventPayload, origin: AgentStatusObservationOrigin, @@ -293,14 +276,12 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt return this.observations.observe(payload.paneKey, { origin, observedAt, - // Why: reuse the listener's own per-provider classifier; a second list of raw event-name - // literals here would strand the providers whose boundary event is named anything else. + // Use provider classifiers; boundary event names vary. boundary: payload.source !== undefined && isNewTurnEvent(payload.source, payload.hookEventName), kind: payload.providerSessionOnly ? 'identity-only' - : // Why: a replay restates a turn that already happened, and OSC 9999 repaints the - // current state rather than announcing a change — neither is a fresh transition. + : // Replay and OSC restate status rather than establish a fresh transition. payload.isReplay === true || origin === 'osc' ? 'snapshot' : 'transition' diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index 8b709aeb46a..89bab5175f9 100644 --- a/src/main/agent-hooks/server/server-status-disposition.ts +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -13,7 +13,7 @@ import { AgentHookServerStatusInference } from './server-status-inference' export abstract class AgentHookServerStatusDisposition extends AgentHookServerStatusInference { protected markTabClosedForAgentStatus(tabId: string): void { - // Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids. + // Refresh insertion order so only the oldest closed tabs are evicted. this.closedAgentStatusTabIds.delete(tabId) this.closedAgentStatusTabIds.add(tabId) for (const key of this.state.lastStatusByPaneKey.keys()) { @@ -44,7 +44,7 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt paneKey: string, event?: { source?: AgentHookSource - /** Raw wire value, so the gate can tell "field absent" from "field present but unknown". */ + /** Distinguish absent legacy source from unknown or malformed wire values. */ rawSource?: unknown hookEventName?: string isReplay?: boolean @@ -52,7 +52,7 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt launchToken?: string /** Host/workspace provenance matched internally to a retained authority commitment. */ retainedLaunchTokenHash?: string - /** A process-lifetime Working: a fresh command whose foreground argv proves a new agent run. */ + /** Foreground argv proves a fresh agent command. */ processNewTurn?: boolean } ): 'accept' | 'restart' | 'suppress' { @@ -82,8 +82,7 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt } if (!paneRetired) { const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey) - // Why: deferred retirement lets a new process start in a still-authorized pane, so - // its tokened SessionStart re-fences; prompts recur, so a stale process would win. + // Only tokened SessionStart re-fences a deferred retirement; recurring prompts could revive a stale owner. if ( event?.hookEventName === 'SessionStart' && event.isReplay !== true && @@ -108,38 +107,23 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt } return 'accept' } - // Why: command completion retires launch authority but leaves its shell pane reusable. - // A live new-turn event proves a new agent process owns the retired pane just like a - // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). - // Why the classifier, not literals: only 5 of 18 sources name their boundary - // `UserPromptSubmit`/`SessionStart`; the rest stayed retired forever. - // Why four branches: `source` collapses to undefined when an older relay omits the field, - // when a newer host sends an unknown string, and when the wire value is malformed. Only an - // unknown string is valid future-provider evidence. Unreachable from the local path, which - // 404s an unresolvable source. + // Reusable panes revive on provider boundaries; older missing sources and future unknown sources need distinct fallbacks. const isNewTurn = event?.source !== undefined ? isNewTurnEvent(event.source, event.hookEventName) : typeof event?.rawSource === 'string' && event.rawSource.trim().length > 0 - ? // Why fail OPEN for an unknown provider: its boundary event is unknowable here, and - // the costs are asymmetric — a stranded pane is invisible and permanent with no user - // recovery, while a spurious revive decays after AGENT_STATUS_STALE_AFTER_MS. + ? // Unknown providers may use any boundary; false revival expires, but permanent suppression strands the pane. true : event?.rawSource === undefined - ? // Why literals here: an older relay omits `source` entirely. Legacy shim only — it - // cannot revive a provider whose boundary event is named anything else. + ? // Older relays omit source; retain their literal boundary fallback. event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart' : false - // Why in addition to the classifier: the OpenCode family carries its mid-session boundary in - // an explicit-prompt MessagePart, which isNewTurnEvent cannot name — and mimo-code has no - // SessionStart at all, so without this its retired panes never come back. + // OpenCode MessagePart carries mid-session prompts; mimo-code has no SessionStart. const freshOpenCodeFamilyPrompt = (event?.source === 'opencode' || event?.source === 'mimo-code') && event.hookEventName === 'MessagePart' && event.hasExplicitPrompt === true - // Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up - // status on that launch token stops a stale process reclaiming the pane's row without - // restoring retired orchestration authority. + // Fence the revived status by launch token without restoring retired orchestration authority. if ( (isNewTurn || freshOpenCodeFamilyPrompt || event?.processNewTurn === true) && event?.isReplay !== true @@ -159,8 +143,7 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt return 'suppress' } - // Why: a fence can span tabs (a pane detached into another tab), and legacy numeric - // keys never parse as stable ones — resolve both forms so neither slips the tab check. + // Detached fences can span tabs; resolve stable and legacy keys for the closed-tab check. protected isClosedAgentStatusTabForPaneKey(paneKey: string): boolean { const tabId = parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? undefined @@ -199,7 +182,7 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt ...(closed ? { closed: true as const } : {}) } for (const key of paneKeys) { - // Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest. + // Refresh insertion order so only the oldest fences are evicted. this.retiredPaneFencesByKey.delete(key) this.retiredPaneFencesByKey.set(key, fence) } diff --git a/src/main/agent-hooks/server/server-status-hook-lifecycle.ts b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts index 14d8ca8613f..0a8ef5ab2f1 100644 --- a/src/main/agent-hooks/server/server-status-hook-lifecycle.ts +++ b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts @@ -38,12 +38,12 @@ export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServer const token = this.token const statusHookGeneration = this.statusHookGeneration if (!this.ownerStateInitialized) { - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + // Hydrate before binding so the first live hook sees restored ownership. if (this.lastStatusFilePath) { this.hydrateLastStatusFromDisk() } this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. + // Drain before binding so replay cannot race live hooks. if (this.endpointDir) { const replayedPaneKeys = new Set() await drainAgentHookSpoolAsync({ @@ -61,7 +61,7 @@ export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServer if (this.token !== token || this.statusHookGeneration !== statusHookGeneration) { return } - // Why: the owner may have died while Orca was down; check each replayed pane once. + // Replayed owners may have died while Orca was down; probe each pane once. for (const paneKey of replayedPaneKeys) { void this.checkAgentPresence(paneKey) } diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index 6144fca8994..e93ffff4d25 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -35,14 +35,14 @@ export function equivalentInterruptAgentType( return normalizedActual === normalizedBaseline } -// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed. +// Durable caches reject legacy numeric keys; only stable pane IDs survive restart. export function isValidPaneKey(value: unknown): value is string { return ( typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null ) } -// Why: remote metadata-only rows are currently a Pi contract; user-dismissed rows use an internal persisted marker instead. +// Remote identity-only rows are a Pi contract; dismissed rows use a separate retained marker. export function isValidPiProviderSessionOnly( providerSession: AgentProviderSessionMetadata | undefined, agentType: AgentType | undefined diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts index 7a88b80f053..274f98758d2 100644 --- a/src/main/agent-hooks/server/server-status-inference.ts +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -90,7 +90,7 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO if (existing.providerSessionOnly) { return false } - // Why: inference must not fabricate a `done` onto a row whose `working` was never confirmed this runtime. + // Restored work needs live confirmation before inference can settle it. if (existing.restoredUnconfirmed) { return false } @@ -99,7 +99,7 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO if (shouldIgnoreInterruptIntent(agentType, request.intent)) { return false } - // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt. + // These TUIs use one Escape for navigation; only a double Escape proves cancel intent. if (requiresDoubleEscapeInterrupt(agentType, request.intent) && request.inputCount !== 2) { return false } @@ -111,7 +111,7 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO if (dismissesClaudeQuestion) { return this.inferQuestionAnswered(request) } - // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook. + // Match the baseline so delayed inference cannot overwrite a newer hook. if ( payload.state !== 'working' || !equivalentInterruptAgentType(agentType, request.baselineAgentType) || @@ -122,8 +122,7 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ) { return false } - // Why: re-checked here, not only in the renderer, so a stale or direct inference request - // cannot route around the renderer's skip and synthesize a false stopped row. + // Enforce navigation exclusions here too; direct requests can bypass the renderer. if ( isNavigationEscapeIntent(agentType, request.intent) && !(agentType === 'claude' && claudeNativeTitleConfirmed) @@ -135,15 +134,11 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO (agentType === 'claude' && (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey))) - // Why: a 'working' pane can be child-driven, and Ctrl+C at the idle prompt of a main agent that - // child work holds open cancels nothing, so the main agent fact decides. A row from a host too - // old to publish `mainAgent` keeps the evidence guard. + // Ctrl+C at an idle main prompt cannot cancel child work; older peers use the child-evidence guard. if (payload.mainAgent ? payload.mainAgent.state !== 'working' : childWorkEvidenced) { return false } - // Why: whoever owns the provider records folds the cancel with the child work the turn left - // running. A local pane's listener record must learn it too, or a later child event re-emits the - // stale 'working' state; a relayed pane's records live on the relay, so only its row is evidence. + // Update local producer records too, or later child hooks revive stale work; remote records belong to the relay. const local = agentType === 'claude' && !existing.connectionId ? markClaudeLeadTurnInterrupted(this.state, existing.paneKey) @@ -163,8 +158,7 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO launchToken: existing.launchToken, hostTurnRevision: existing.hostTurnRevision, source: existing.source, - // Why: a cancel leaves the shell fact as it was; dropping it would stop restart from seeding - // the cancelled main agent, so a child's later drain could never settle the row. + // Retain shell evidence so restart can seed the cancelled main agent and later settle drained children. ...(existing.claudeRunningNonAgentTask !== undefined ? { claudeRunningNonAgentTask: existing.claudeRunningNonAgentTask } : {}), @@ -180,10 +174,9 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO prompt: payload.prompt, agentType, ...(payload.model ? { model: payload.model } : {}), - // Why: `interrupted` is the settled row's restatement of the verdict for readers that - // predate `mainAgent`; a row the cancel left monitoring carries it on `mainAgent.outcome` only. + // Older readers use interrupted on done rows; monitoring rows keep cancellation on mainAgent.outcome. ...(state === 'done' ? { interrupted: true } : {}), - // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. + // Idle children remain display state across inferred cancellation. ...(payload.subagents ? { subagents: payload.subagents } : {}), mainAgent: local?.mainAgent ?? { state: 'done', @@ -228,7 +221,7 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO return true } - /** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */ + /** Claude can omit the final hook after AskUserQuestion is answered or dismissed. */ inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean { if (!isValidPaneKey(request.paneKey)) { return false @@ -239,12 +232,12 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO if (!existing) { return false } - // Why: inference must not fabricate a transition onto a row whose state was never confirmed this runtime. + // Restored waits need live confirmation before inference can clear them. if (existing.restoredUnconfirmed) { return false } const payload = existing.payload - // Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky. + // AskUserQuestion clears on input; actual permission waits remain sticky. if ( payload.agentType !== 'claude' || payload.state !== 'waiting' || @@ -261,7 +254,7 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO ) { return false } - // Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card. + // Update the producer too so later child hooks cannot resurrect the answered question. const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey) const inferred = this.applyNormalizedStatus({ paneKey: existing.paneKey, diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts index 40b6945b731..81baa80c6ec 100644 --- a/src/main/agent-hooks/server/server-status-retries.ts +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -54,7 +54,7 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus body: unknown, original: EnrichedAgentHookEventPayload ): void { - // Why: a nested CLI of another kind inherits ORCA_PANE_KEY, so clearing here would silently end a live poll. + // Nested CLIs inherit pane keys; another provider must not clear this transcript poll. if (source !== 'codex' && source !== 'muse') { return } @@ -71,8 +71,7 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus private runTranscriptPoll(paneKey: string, poll: TranscriptPoll): void { const { source, body, original } = poll - // Keep the identity check at callback time: a newer event supersedes this - // payload even when its pane still has transcript children. + // Recheck ownership at callback time; newer events supersede this payload even if children remain. if ( paneKey !== original.paneKey || !this.server || @@ -112,7 +111,7 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus if (!discoveryReady) { const discovery = preparePendingGrokResultDiscovery(source, body) if (discovery) { - // Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically. + // Discovery may outlast flush timers; retry as soon as it completes. void discovery .then(() => { if (this.server) { @@ -163,8 +162,7 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal) return } - // Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed. - // The retry only adds text to the same turn end, so it restates that end instead of minting one. + // Stop can precede transcript flush; retries add text while preserving the same turn end. this.applyNormalizedStatus( { ...normalized.event, ...pickHostTurnEnd(current) }, normalized.onAccepted diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index f4c994f81dc..9d78ebb1294 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -29,7 +29,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA this.state ) - // Why here: every stored row passes through, including a cancel inference and a pane move. + // Every row mutation passes here, including cancellation and pane moves. protected override commitStatusRowMutation( before: EnrichedAgentHookEventPayload | null | undefined, after: EnrichedAgentHookEventPayload | null | undefined, @@ -74,7 +74,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA const priorExit = this.agentExitedAtByPaneKey.get(this.resolvePaneKeyAlias(payload.paneKey)) ?? this.agentExitedAtByPaneKey.get(payload.paneKey) - // Why the incoming presence: the transition may stamp a stale owner over the sender's own identity. + // Use the sender's identity before transition logic can replace it with the cached owner. if ( priorExit && claimsPaneAfterExit({ ...payload, agentPresence: incoming.agentPresence }, priorExit) @@ -94,7 +94,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA const terminalOwnedPayload = terminalHandle === payload.terminalHandle ? payload : { ...payload, terminalHandle } if (previous && isStaleGrokTurnEnd(previous, terminalOwnedPayload)) { - // Why: Grok turn-end hooks may arrive after the next prompt, including across relay restart. + // Grok end hooks can lag the next prompt, even across relay restart. this.commitStatusRowMutation(rowBefore, previous) return previous } @@ -120,7 +120,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA } : undefined, incoming: terminalOwnedPayload.payload.agentType, - // Why local only: a relayed row carries its execution host's owner verdict, not a sender. + // Relay presence is the execution host's owner verdict, not the sender's identity. incomingProcess: payload.connectionId ? undefined : incoming.agentPresence?.process, now }) @@ -134,14 +134,14 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA return previous } if (payload.hookEventName === 'UserPromptSubmit') { - // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. + // Repeated prompt text still opens a new turn; OSC must not inherit the old completion stamp. this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) } if (terminalOwnedPayload.connectionId) { this.connectionTimestampWatermarkById.set(terminalOwnedPayload.connectionId, now) } if (terminalOwnedPayload.providerSessionOnly) { - // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. + // Identity-only replay must not fabricate status or prompt telemetry. onAccepted?.() const enriched = deriveCliWriterTopology( { @@ -192,7 +192,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA const preservedRootModel = !stateReconciledPayload.payload.model ? previousCodexRoot?.payload.model : undefined - // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. + // Relay restart loses root context; child hooks must not erase persisted resume/model identity. const rootContextPreservingPayload = preservedProviderSession || preservedRootModel ? { @@ -211,8 +211,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA payload: { ...rootContextPreservingPayload.payload, agentType: identity.agentType } } const attachedPayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) - // Why before the permission hold: that hold adopts the event's `mainAgent`, and a relay's - // restatement of a main agent the desktop cancelled must not replace the cancel. + // Resolve cancellation before permission holds adopt a replayed main-agent observation. const latch = resolveCancelVerdictLatch(previous, attachedPayload, Date.now()) if (latch.hold) { if ( @@ -237,8 +236,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA const effectivePayload = latch.event if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { const held = withHeldChildWaitMainAgent(previous, effectivePayload) - // Why: a child's prompt leaves the main agent running, so the held row takes its `mainAgent` and - // must take the same event's background evidence; a main agent's own prompt blocks it, so not there. + // Child prompts preserve main-agent progress and its paired shell evidence; the main agent's own waits do not. if (previous.toolAgentId) { onAccepted?.() } @@ -249,7 +247,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA this.scheduleStatusPersist() } this.commitStatusRowMutation(rowBefore, held) - // Why: pushed readers must see the new `mainAgent` a snapshot reader already does. + // Pushed readers need main-agent changes already visible in snapshots. if (held.payload !== previous.payload) { this.emitEnrichedStatus(held) } @@ -265,9 +263,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA if (!identity.inheritedFromActivePane) { this.maybeTrackAgentPromptSent(effectivePayload, previous) } - // Why carried forward only within one host: main's OSC parse resolves the handle, so a later - // hook must not erase its terminal join; a connection change must not inherit another host's. - // Why drop the settled list: it vouches for this replay's transition only, not for the stored row. + // Preserve terminal joins only within one host; settlement lists vouch only for this transition. const { claudeRunningNonAgentTask: _unpaired, claudeSettledRequests: _settled, @@ -297,7 +293,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA enriched.payload.turnCompletedAt ) } - // Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible. + // Other restored children can still hold the aggregate open; keep liveness reconciliation eligible. if (enriched.restoredUnconfirmed) { this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) } else { @@ -307,8 +303,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA return undefined } this.commitStatusRowMutation(rowBefore, enriched) - // Why skipped for structured rows: the serializer drops them, so the whole walk and stringify - // can only ever reproduce the last file — once per debounce window for a streaming chat. + // Structured rows serialize nothing; skip the persistence walk for streaming chat updates. if (!enriched.structuredHost) { this.scheduleStatusPersist() } diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts index 5966d243368..67094fe706f 100644 --- a/src/main/agent-hooks/server/server-tab-cleanup.ts +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -5,7 +5,6 @@ import type { EnrichedAgentHookEventPayload } from './server-types' import { releaseAgentExitKey } from './agent-exit-evidence' export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { - /** Drop every status/cache claim attributable to a closed tab prefix. */ dropStatusEntriesByTabPrefix(tabId: string): void { this.markTabClosedForAgentStatus(tabId) this.clearTmuxTabSubjects(tabId) @@ -89,7 +88,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { this.hookSilenceRechecks.cancel(paneKey) this.programStatusFacetRepublishes.cancel(paneKey) } - // Why: an exit outlives its row, so it is matched by tab rather than by the keys cleared above. + // Exits outlive their rows; match tab identity independently of cleared status keys. let exitsChanged = false for (const key of this.agentExitedAtByPaneKey.keys()) { if (paneCacheKeyMatchesTab(key, tabId)) { @@ -108,7 +107,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { } else if (exitsChanged) { this.scheduleStatusPersist() } - // Why: tab teardown must retire status subscribers' pane-scoped memo state too. + // Subscribers must release pane-scoped state when the tab closes. for (const paneKey of statusPaneKeysToClear) { this.emitPaneStatusCleared({ paneKey }) } @@ -144,7 +143,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { this.currentAuthorityObservations.delete(resolvedPaneKey) this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) - // Why: the pane itself is gone, so its observation clock describes nothing a later pane owns. + // A retired pane's evidence clock cannot belong to its successor. this.evidenceObservedAtByPaneKey.delete(resolvedPaneKey) this.programStatusFacets.delete(resolvedPaneKey) this.hookSilenceRechecks.cancel(resolvedPaneKey) diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 6b88d4e85ad..d3c8ef9a82e 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -10,35 +10,28 @@ import type { AgentStatusObservation } from '../../../shared/agent-status-observ import type { AgentKind } from '../../../shared/telemetry-events' import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' -// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { /** Live acknowledgement of the matching renderer retirement; never cached. */ authorityRestartId?: string receivedAt: number - /** When this evidence was first observed, as distinct from `receivedAt`. A relay reconnect - * replays cached rows and `receivedAt` must restamp to clear the connection watermark, so - * only this clock can answer how old the evidence itself is. Persisted so it survives a - * main restart; absent means "never separately observed" and consumers use `receivedAt`. */ + /** Persisted evidence age survives replay; absent values fall back to receivedAt, which tracks delivery order. */ evidenceObservedAt?: number stateStartedAt: number - /** When the main agent's current turn began, on this server's clock. Stamped only here, from the - * main agent's own turn-opening event; absent when no such event was seen. */ + /** Server-stamped main-agent turn start; absent until a live boundary establishes it. */ turnStartedAt?: number - /** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */ + /** Runtime provenance and ordering, including hook/OSC precedence. */ observation?: AgentStatusObservation - /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ + /** Hydrated work remains unconfirmed until reconciled; this marker is never persisted. */ restoredUnconfirmed?: true /** User-hidden resume identity retained solely for destructive liveness checks. */ retainedForLiveness?: true - /** The pane's OSC 7501 facet, stitched on at publish only (ProgramStatusFacetStore); never stored. */ + /** OSC 7501 facet stitched at publication, never persisted. */ programStatus?: ProgramStatusFacet - /** Marks a publish where only `programStatus` changed: state subscribers must not read a new state. */ + /** Facet-only publication; state readers must not infer a transition. */ programStatusOnly?: true } -// `claudeRunningNonAgentTask` is persisted on purpose: it is the one child-work fact the row's -// `mainAgent` cannot express (a shell, a cron or an owed task notification beside the agents), and hydration reads it to decide whether a -// settled main agent may be seeded. It replaced the derived `claudeLeadBoundaryChildOnly` flag. +// Persist non-agent work too: mainAgent cannot describe shell/cron/notification work that affects hydration. export type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, | 'authorityRestartId' @@ -48,10 +41,9 @@ export type PersistedAgentHookEventPayload = Omit< | 'programStatus' | 'programStatusOnly' | 'restoredUnconfirmed' - // Why: revision counters are in-memory and the authority id is regenerated per process, so - // a stored observation could only rehydrate as a stale ordering claim from a dead authority. + // Authority IDs and revision counters belong to the runtime that issued them. | 'observation' - // Same: a terminal handle is issued by one runtime and means nothing to the next. + // Terminal handles belong to one runtime. | 'terminalHandle' > & { launchTokenHash?: string diff --git a/src/main/agent-hooks/terminal-handle-row-identity.test.ts b/src/main/agent-hooks/terminal-handle-row-identity.test.ts index 3c361595a24..c2e46899aac 100644 --- a/src/main/agent-hooks/terminal-handle-row-identity.test.ts +++ b/src/main/agent-hooks/terminal-handle-row-identity.test.ts @@ -32,8 +32,7 @@ describe('the terminal handle a status row is stamped with', () => { }) it('survives a later write that resolved no handle', () => { - // Only main's OSC parse resolves one; an HTTP hook post for the same pane carries none and - // must not erase the row's only join back to its terminal. + // HTTP hooks lack OSC terminal handles and must not erase the row's terminal association. const server = new AgentHookServer() ingest(server) ingest(server, { terminalHandle: undefined, payload: { state: 'done', prompt: 'ship it' } }) @@ -210,7 +209,6 @@ describe('the terminal handle a status row is stamped with', () => { if (!row) { throw new Error('expected seeded status row') } - // A settled main agent whose only live work is a child agent: the boundary is derived from these facts. const childOnlyRow = { ...row, claudeRunningNonAgentTask: false, diff --git a/src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts b/src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts index 77d4267d3c3..13ac0722f27 100644 --- a/src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts +++ b/src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts @@ -155,7 +155,6 @@ describe.skipIf( const command = installedCommand(configPath, event.eventName) const scriptPath = join(home, '.orca', 'agent-hooks', event.windowsWrapperFileName) const directCommand = wrapWindowsDirectCmdHookCommand(scriptPath) - // Why: an ASCII profile registers the forward-slash path Git Bash can start too. expect(command).toBe(wrapWindowsBatchHookCommand(scriptPath)) if (!WINDOWS_CMD_SAFE_PATH.test(scriptPath)) { expect(decodeCommand(command)).not.toContain('Set-ExecutionPolicy') diff --git a/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts b/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts index 64766365873..51f6a82bb3b 100644 --- a/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts +++ b/src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts @@ -1,4 +1,3 @@ -// The registered command must not depend on the shell Claude selects. import { describe, expect, it } from 'vitest' import { runProcess } from '@orca/process-host' import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' @@ -27,14 +26,12 @@ describe('wrapWindowsDirectCmdHookCommand', () => { it('spells nothing either shell would rewrite or reinterpret', () => { const command = wrapWindowsDirectCmdHookCommand(SAFE_PATH)! - // Why: MSYS rewrites `/c`-shaped tokens into drive paths — a literal `cmd.exe /d /c ` - // does not survive Git Bash (measured), which is why no interpreter is spelled at all. + // MSYS rewrites /c-style switches into drive paths, so direct commands cannot name an interpreter. expect(command).not.toMatch(/ \/[a-zA-Z]+( |$)/) expect(command).not.toMatch(/\\/) expect(command).not.toMatch(/["']/) expect(command).not.toMatch(/powershell|cmd\.exe|conhost/i) - // Why: `2>nul` writes a literal file named `nul` into the cwd under MSYS (measured), and no - // stderr sink parses in both hosts. The missing-script line is left on stderr deliberately. + // MSYS turns 2>nul into a repo file; leave missing-script stderr unredirected. expect(command).not.toContain('2>') expect(command).not.toMatch(/[|&;]/) }) @@ -48,8 +45,7 @@ describe('wrapWindowsDirectCmdHookCommand', () => { 'C:\\Users\\a(b)\\.orca\\agent-hooks\\claude-hook.cmd', 'C:\\Users\\rené\\.orca\\agent-hooks\\claude-hook.cmd', '/home/alice/.orca/agent-hooks/claude-hook.sh', - // Why: WINDOWS_CMD_SAFE_PATH admits a UNC profile, but `//server/share/...` is not a - // command cmd.exe reliably starts — keep those on the encoded launcher. + // UNC profiles need the encoded launcher because cmd.exe cannot reliably start them bare. '\\\\server\\share\\alice\\.orca\\agent-hooks\\claude-hook.cmd' ]) { expect(wrapWindowsDirectCmdHookCommand(path), path).toBeNull() @@ -65,7 +61,6 @@ describe('wrapWindowsBatchHookCommand', () => { it('keeps the encoded launcher for a path the shells cannot carry bare', () => { const spaced = 'C:\\Users\\Bob Smith\\.orca\\agent-hooks\\gemini-hook.cmd' expect(wrapWindowsBatchHookCommand(spaced)).toBe(wrapWindowsHookCommand(spaced)) - // Guarded Unicode .cmd paths take the cheaper policy switch. const unicode = 'C:\\Users\\rené\\.orca\\agent-hooks\\gemini-hook.cmd' expect(wrapWindowsBatchHookCommand(unicode)).toBe( wrapWindowsHookCommand(unicode, {}, { useProcessPolicyEnvironment: true }) @@ -109,14 +104,12 @@ describe.skipIf(process.platform !== 'win32')( return results } - // Why async runProcess: runProcessSync cannot take a string stdin (it forces encoding 'buffer'). async function runCapture(file: string, args: string[], cwd: string) { const result = await runProcess({ program: file, args, cwd, - // Why PATHEXT: without it Windows PowerShell 5.1 prints nothing and exits 0 for a .cmd path - // (measured); every real hook host inherits it. + // Windows PowerShell needs inherited PATHEXT to execute a .cmd path. env: { SystemRoot: process.env.SystemRoot, PATH: process.env.PATH, @@ -134,8 +127,7 @@ describe.skipIf(process.platform !== 'win32')( return { stdout: result.stdout, status: result.code, label: `${file}: ${result.stderr}` } } - // Why: a runner whose TEMP sits under a profile with a space is the encoded-launcher case, - // so these legs skip rather than assert a contract that shape never claimed. + // Spaced temp profiles require the encoded launcher, outside this direct-path contract. const tempIsCmdSafe = WINDOWS_CMD_SAFE_PATH.test(join(tmpdir(), 'orca-direct-hook-x', 'x.cmd')) const canRunLive = tempIsCmdSafe @@ -149,8 +141,7 @@ describe.skipIf(process.platform !== 'win32')( expect(command, 'precondition: temp path must be cmd-safe').not.toBeNull() await run(dir, scriptPath, command!) } finally { - // Why: cmd.exe/bash have just exited in this tree; a raw recursive rm throws EPERM on - // Windows while their handles drain. + // Retry cleanup while Windows shell handles drain. removeTreeSync(dir) } } @@ -203,9 +194,6 @@ describe.skipIf(process.platform !== 'win32')( it.skipIf(!canRunLive)( 'leaves no stray `nul` file behind in the working directory', async () => { - // Why this is worth a test: adding `2>nul` to silence the missing-script line looks like - // tidy-up, but under MSYS it creates a real file named `nul` in the cwd — which is the - // user's repo. Measured on Windows 11. Keep stderr unredirected. await withTempDir(async (dir, _scriptPath, command) => { await runInHosts(command, dir) expect(readdirSync(dir)).not.toContain('nul') diff --git a/src/main/agent-hooks/windows-direct-cmd-hook-command.ts b/src/main/agent-hooks/windows-direct-cmd-hook-command.ts index ebb3560a47c..1b5219e4cf3 100644 --- a/src/main/agent-hooks/windows-direct-cmd-hook-command.ts +++ b/src/main/agent-hooks/windows-direct-cmd-hook-command.ts @@ -7,7 +7,7 @@ import { // UNC paths cannot be started consistently by all Windows hook hosts. const WINDOWS_DRIVE_LETTER_PATH = /^[A-Za-z]:\\/ -/** A bare path runs the same under cmd.exe, PowerShell and Git Bash, so no host shell is guessed. */ +/** Bare paths avoid guessing which Windows shell the hook host uses. */ export function wrapWindowsDirectCmdHookCommand(scriptPath: string): string | null { if (!WINDOWS_CMD_SAFE_PATH.test(scriptPath) || !WINDOWS_DRIVE_LETTER_PATH.test(scriptPath)) { return null @@ -16,11 +16,7 @@ export function wrapWindowsDirectCmdHookCommand(scriptPath: string): string | nu return scriptPath.replaceAll('\\', '/') } -/** - * Registration for a managed `.cmd` hook: the bare path skips a PowerShell start-up per event - * (#18875); paths the shells cannot carry bare keep the encoded launcher. A missing script then - * prints "not recognized" until Orca reinstalls it instead of exiting silently. - */ +/** Cmd-safe paths avoid per-event PowerShell startup; other paths use the encoded launcher. */ export function wrapWindowsBatchHookCommand(scriptPath: string): string { return ( wrapWindowsDirectCmdHookCommand(scriptPath) ?? diff --git a/src/main/agent-hooks/windows-git-bash-path.test-fixture.ts b/src/main/agent-hooks/windows-git-bash-path.test-fixture.ts index 9bef35da989..8e518dfc3ff 100644 --- a/src/main/agent-hooks/windows-git-bash-path.test-fixture.ts +++ b/src/main/agent-hooks/windows-git-bash-path.test-fixture.ts @@ -1,8 +1,7 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' -// Why: Windows hook tests must run the registered command through MSYS too — it -// rewrites switches and paths, so a launcher can pass under cmd.exe and fail here. +// MSYS rewrites paths and switches, so Windows hook tests also exercise Git Bash. export function findGitBash(): string { if (process.env.KIMI_SHELL_PATH) { return process.env.KIMI_SHELL_PATH diff --git a/src/main/agent-hooks/windows-hook-command.ts b/src/main/agent-hooks/windows-hook-command.ts index 287e37eec29..ed6a5d6797c 100644 --- a/src/main/agent-hooks/windows-hook-command.ts +++ b/src/main/agent-hooks/windows-hook-command.ts @@ -19,10 +19,9 @@ export function wrapWindowsHookCommand( export function buildWindowsHookPowerShellCommand( scriptPath: string, env: Record = {}, - // Why: POSIX wrap already answers missing-script with stdout; Windows must match so gate events cannot drift (#15462). + // Match POSIX fallback answers so missing-script gate events cannot default to denial. options: { fallbackStdout?: string } = {} ): string { - // Why: the encoded launcher protects paths across Windows shells and drains stdin when the config points at a missing script. const quoted = quotePowerShellLiteral(scriptPath) const envPrefix = Object.entries(env) .map(([key, value]) => `$env:${key} = ${quotePowerShellLiteral(value)}; `) @@ -31,9 +30,7 @@ export function buildWindowsHookPowerShellCommand( options.fallbackStdout === undefined ? '' : `Write-Output ${quotePowerShellLiteral(options.fallbackStdout)}; ` - // Why the order: answer first (a gate event reads silence as deny), then the shared - // env guard, and only then own stdin — outside an Orca pane the caller may abandon the - // pipe, and ReadToEnd would strand the launcher there forever (#11549). + // Answer gates before checking Orca context, then guard ReadToEnd against abandoned stdin. return `${envPrefix}if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; ${fallback}${WINDOWS_POWERSHELL_HOOK_ENVIRONMENT_GUARD}; [Console]::In.ReadToEnd() | Out-Null; exit 0` } diff --git a/src/main/agent-hooks/windows-hook-payload-delivery.test.ts b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts index 4fef31072dc..10793f834d6 100644 --- a/src/main/agent-hooks/windows-hook-payload-delivery.test.ts +++ b/src/main/agent-hooks/windows-hook-payload-delivery.test.ts @@ -1,10 +1,4 @@ -// Why: 1.4.184 registered `conhost.exe --headless /d /c