claude-code-best/src/types/textInputTypes.ts
unraid f2e9af4927 feat: harden autonomy lifecycle, OOM bounds, and provider-boundary finalization
This PR consolidates a coordinated batch of fixes around autonomy run/flow lifecycle, scheduled task deduplication, provider-boundary state finalization, and matching memory-bound treatments for adjacent long-running subsystems (REPL fullscreen scrollback, skill-search/skill-learning runtime activation). All changes were developed and reviewed together because they touched the same lifecycle invariants and were uncovered by the same long-running session reproductions.

## Lifecycle correctness

- Queued autonomy prompts are not injected unless the persisted run was successfully claimed; queued run claiming is now terminal-safe so a once-consumed/cancelled/failed run can not slip back into `queued`.
- Autonomy run/flow finalization happens on completion, provider error, generator close, and cancellation — not just the happy path. New `src/__tests__/queryAutonomyProviderBoundary.test.ts` covers these provider-boundary transitions.
- `requestManagedAutonomyFlowCancel` and `resumeManagedAutonomyFlowPrompt` carry `rootDir` and `currentDir` explicitly across detached async boundaries (proactive-tick, cron, daemon restart) instead of inferring from process state.
- Active runs/flows are protected from janitor pruning so a running step can not be garbage-collected mid-flight (`src/utils/autonomyAuthority.ts`).
- Heartbeat parser now ignores fenced code blocks; the two-phase commit window for autonomy state transitions is documented in `docs/internals/autonomy-jira.md`.

## Ownership and dedup

- `src/utils/autonomyRuns.ts`: ownership stamping (run id + rootDir carried end-to-end), source-based dedup against active runs.
- `src/hooks/useScheduledTasks.ts`: scheduled ticks deduplicate against runs already active on the same source label.
- `src/utils/processUserInput/processSlashCommand.tsx`: forked slash commands now thread the autonomy `runId` so completion finalizers can find the originating run for deferred completion.
- New `src/utils/autonomyQueueLifecycle.ts` and tests collect the queue-side lifecycle invariants in one place.

## Memory bounds (related, same review pass)

- `src/screens/REPL.tsx`: caps fullscreen scrollback after the compact boundary and updates trailing progress rows in place. Long-running fullscreen sessions could otherwise retain thousands of post-compaction messages and duplicate progress rows, keeping Ink trees alive long after their useful context had moved on.
- `src/services/skillSearch/*` and `src/services/skillLearning/*`: runtime activation is strictly opt-in via existing env toggles; session caches are capped so long-running processes can not grow them forever. Build presence is preserved so operators can still discover and opt into the slash commands.

## CI / test contract

- `tests/integration/dependency-overrides.test.ts`: smoke test no longer drives Mermaid's browser renderer; it validates the package-resolution contract directly so CI does not regress on unrelated browser timing.
- New `tests/integration/autonomy-lifecycle-user-flow.test.ts`: end-to-end CLI subprocess flow exercising `status --deep`, `flows`, `flow <id>`, `flow resume`, `flow cancel` against persisted state.
- `src/entrypoints/cli.tsx`: `claude autonomy …` routes through an entrypoint fast path that reuses the slash-command formatter without booting the full interactive CLI. Stdout is flushed before forced exit so coverage subprocesses do not terminate with empty stdout.
- `packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/RemoteTriggerTool.test.ts`: stabilized to prevent audit flake under coverage.

## Tests added

- `src/__tests__/queryAutonomyProviderBoundary.test.ts`
- `src/hooks/__tests__/useScheduledTasks.test.ts`
- `src/utils/__tests__/autonomyAuthority.test.ts`
- `src/utils/__tests__/autonomyFlows.test.ts` (extended)
- `src/utils/__tests__/autonomyPersistence.test.ts` (extended)
- `src/utils/__tests__/autonomyQueueLifecycle.test.ts`
- `src/utils/__tests__/autonomyRuns.test.ts` (extended)
- `src/utils/processUserInput/__tests__/processSlashCommand.test.ts`
- `tests/integration/autonomy-lifecycle-user-flow.test.ts`

## Docs

- `docs/agent/sur-loop-scheduled-oom.md`: System Understanding Report covering the scheduled/loop OOM problem, the call graphs investigated, and the lifecycle invariants this PR establishes.
- `docs/agent/sur-skill-overflow-bugs.md`: SUR for the related skill-overflow context.
- `docs/internals/autonomy-jira.md`: documents the two-phase commit window and ownership stamping invariants.
- `docs/memory-leak-audit.md`: audit notes covering the REPL/scrollback and skill-search bounds.

## Invariants this PR establishes

1. Queued autonomy prompts are not injected unless the persisted run was successfully claimed.
2. Terminal run/flow states are terminal — completion, failure, and cancellation all finalize state regardless of which provider/error path triggered them.
3. Autonomy run/flow `rootDir` is carried explicitly across detached async boundaries instead of inferred from a shared singleton.
4. State-only CLI subcommands (`autonomy status|runs|flows|flow …`) bypass full interactive bootstrap so they do not hold unrelated handles open.
5. REPL fullscreen scrollback and skill-search/skill-learning session caches are explicitly bounded.

## Validation

```bash
bun run typecheck
CI=true GITHUB_ACTIONS=true bun test            # 3996 pass / 0 fail across 305 files
bun test src/__tests__/queryAutonomyProviderBoundary.test.ts \
         src/hooks/__tests__/useScheduledTasks.test.ts \
         src/utils/__tests__/autonomy{Runs,Flows,Authority,QueueLifecycle,Persistence}.test.ts \
         src/utils/processUserInput/__tests__/processSlashCommand.test.ts \
         tests/integration/autonomy-lifecycle-user-flow.test.ts
```

## Origin

This PR is the consolidated, upstream-targeted version of two fork-side review PRs (fix/loop-scheduled-autonomy-oom and fix/autonomy-lifecycle). The fork-side review history is preserved at https://github.com/amDosion/claude-code-bast/pull/7 . The fork's own internal `chore: keep fork current with upstream` sync commits and the `docs: update contributors` automation are intentionally not included in this PR.

The autonomy CLI handler `rootDir` threading that the fork added (78f64d8a, 98d04ddb) is intentionally omitted here because upstream `a2cfaf91` (fix: 修复 RemoteTriggerTool 和 autonomy 测试的全量运行失败) already performed the equivalent change with an additional `currentDir` option. Keeping the upstream version avoids regressing that improvement.
2026-04-29 14:04:27 +08:00

402 lines
12 KiB
TypeScript

import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
import type { UUID } from 'crypto'
import type React from 'react'
import type { PermissionResult } from '../entrypoints/agentSdkTypes.js'
import type { Key } from '@anthropic/ink'
import type { PastedContent } from '../utils/config.js'
import type { ImageDimensions } from '../utils/imageResizer.js'
import type { TextHighlight } from '../utils/textHighlighting.js'
import type { AgentId } from './ids.js'
import type { AssistantMessage, MessageOrigin } from './message.js'
/**
* Inline ghost text for mid-input command autocomplete
*/
export type InlineGhostText = {
/** The ghost text to display (e.g., "mit" for /commit) */
readonly text: string
/** The full command name (e.g., "commit") */
readonly fullCommand: string
/** Position in the input where the ghost text should appear */
readonly insertPosition: number
}
/**
* Base props for text input components
*/
export type BaseTextInputProps = {
/**
* Optional callback for handling history navigation on up arrow at start of input
*/
readonly onHistoryUp?: () => void
/**
* Optional callback for handling history navigation on down arrow at end of input
*/
readonly onHistoryDown?: () => void
/**
* Text to display when `value` is empty.
*/
readonly placeholder?: string
/**
* Allow multi-line input via line ending with backslash (default: `true`)
*/
readonly multiline?: boolean
/**
* Listen to user's input. Useful in case there are multiple input components
* at the same time and input must be "routed" to a specific component.
*/
readonly focus?: boolean
/**
* Replace all chars and mask the value. Useful for password inputs.
*/
readonly mask?: string
/**
* Whether to show cursor and allow navigation inside text input with arrow keys.
*/
readonly showCursor?: boolean
/**
* Highlight pasted text
*/
readonly highlightPastedText?: boolean
/**
* Value to display in a text input.
*/
readonly value: string
/**
* Function to call when value updates.
*/
readonly onChange: (value: string) => void
/**
* Function to call when `Enter` is pressed, where first argument is a value of the input.
*/
readonly onSubmit?: (value: string) => void
/**
* Function to call when Ctrl+C is pressed to exit.
*/
readonly onExit?: () => void
/**
* Optional callback to show exit message
*/
readonly onExitMessage?: (show: boolean, key?: string) => void
/**
* Optional callback to show custom message
*/
// readonly onMessage?: (show: boolean, message?: string) => void
/**
* Optional callback to reset history position
*/
readonly onHistoryReset?: () => void
/**
* Optional callback when input is cleared (e.g., double-escape)
*/
readonly onClearInput?: () => void
/**
* Number of columns to wrap text at
*/
readonly columns: number
/**
* Maximum visible lines for the input viewport. When the wrapped input
* exceeds this many lines, only lines around the cursor are rendered.
*/
readonly maxVisibleLines?: number
/**
* Optional callback when an image is pasted
*/
readonly onImagePaste?: (
base64Image: string,
mediaType?: string,
filename?: string,
dimensions?: ImageDimensions,
sourcePath?: string,
) => void
/**
* Optional callback when a large text (over 800 chars) is pasted
*/
readonly onPaste?: (text: string) => void
/**
* Callback when the pasting state changes
*/
readonly onIsPastingChange?: (isPasting: boolean) => void
/**
* Whether to disable cursor movement for up/down arrow keys
*/
readonly disableCursorMovementForUpDownKeys?: boolean
/**
* Skip the text-level double-press escape handler. Set this when a
* keybinding context (e.g. Autocomplete) owns escape — the keybinding's
* stopImmediatePropagation can't shield the text input because child
* effects register useInput listeners before parent effects.
*/
readonly disableEscapeDoublePress?: boolean
/**
* The offset of the cursor within the text
*/
readonly cursorOffset: number
/**
* Callback to set the offset of the cursor
*/
onChangeCursorOffset: (offset: number) => void
/**
* Optional hint text to display after command input
* Used for showing available arguments for commands
*/
readonly argumentHint?: string
/**
* Optional callback for undo functionality
*/
readonly onUndo?: () => void
/**
* Whether to render the text with dim color
*/
readonly dimColor?: boolean
/**
* Optional text highlights for search results or other highlighting
*/
readonly highlights?: TextHighlight[]
/**
* Optional custom React element to render as placeholder.
* When provided, overrides the standard `placeholder` string rendering.
*/
readonly placeholderElement?: React.ReactNode
/**
* Optional inline ghost text for mid-input command autocomplete
*/
readonly inlineGhostText?: InlineGhostText
/**
* Optional filter applied to raw input before key routing. Return the
* (possibly transformed) input string; returning '' for a non-empty
* input drops the event.
*/
readonly inputFilter?: (input: string, key: Key) => string
}
/**
* Extended props for VimTextInput
*/
export type VimTextInputProps = BaseTextInputProps & {
/**
* Initial vim mode to use
*/
readonly initialMode?: VimMode
/**
* Optional callback for mode changes
*/
readonly onModeChange?: (mode: VimMode) => void
}
/**
* Vim editor modes
*/
export type VimMode = 'INSERT' | 'NORMAL'
/**
* Common properties for input hook results
*/
export type BaseInputState = {
onInput: (input: string, key: Key) => void
renderedValue: string
offset: number
setOffset: (offset: number) => void
/** Cursor line (0-indexed) within the rendered text, accounting for wrapping. */
cursorLine: number
/** Cursor column (display-width) within the current line. */
cursorColumn: number
/** Character offset in the full text where the viewport starts (0 when no windowing). */
viewportCharOffset: number
/** Character offset in the full text where the viewport ends (text.length when no windowing). */
viewportCharEnd: number
// For paste handling
isPasting?: boolean
pasteState?: {
chunks: string[]
timeoutId: ReturnType<typeof setTimeout> | null
}
}
/**
* State for text input
*/
export type TextInputState = BaseInputState
/**
* State for vim input with mode
*/
export type VimInputState = BaseInputState & {
mode: VimMode
setMode: (mode: VimMode) => void
}
/**
* Input modes for the prompt
*/
export type PromptInputMode =
| 'bash'
| 'prompt'
| 'orphaned-permission'
| 'task-notification'
export type EditablePromptInputMode = Exclude<
PromptInputMode,
`${string}-notification`
>
/**
* Queue priority levels. Same semantics in both normal and proactive mode.
*
* - `now` — Interrupt and send immediately. Aborts any in-flight tool
* call (equivalent to Esc + send). Consumers (print.ts,
* REPL.tsx) subscribe to queue changes and abort when they
* see a 'now' command.
* - `next` — Mid-turn drain. Let the current tool call finish, then
* send this message between the tool result and the next API
* round-trip. Wakes an in-progress SleepTool call.
* - `later` — End-of-turn drain. Wait for the current turn to finish,
* then process as a new query. Wakes an in-progress SleepTool
* call (query.ts upgrades the drain threshold after sleep so
* the message is attached to the same turn).
*
* The SleepTool is only available in proactive mode, so "wakes SleepTool"
* is a no-op in normal mode.
*/
export type QueuePriority = 'now' | 'next' | 'later'
/**
* Queued command type
*/
export type QueuedCommand = {
value: string | Array<ContentBlockParam>
mode: PromptInputMode
/** Defaults to the priority implied by `mode` when enqueued. */
priority?: QueuePriority
uuid?: UUID
orphanedPermission?: OrphanedPermission
/** Raw pasted contents including images. Images are resized at execution time. */
pastedContents?: Record<number, PastedContent>
/**
* The input string before [Pasted text #N] placeholders were expanded.
* Used for ultraplan keyword detection so pasted content containing the
* keyword does not trigger a CCR session. Falls back to `value` when
* unset (bridge/UDS/MCP sources have no paste expansion).
*/
preExpansionValue?: string
/**
* When true, the input is treated as plain text even if it starts with `/`.
* Used for remotely-received messages (e.g. bridge/CCR) that should not
* trigger local slash commands or skills.
*/
skipSlashCommands?: boolean
/**
* When true, slash commands are dispatched but filtered through
* isBridgeSafeCommand() — 'local-jsx' and terminal-only commands return
* a helpful error instead of executing. Set by the Remote Control bridge
* inbound path so mobile/web clients can run skills and benign commands
* without re-exposing the PR #19134 bug (/model popping the local picker).
*/
bridgeOrigin?: boolean
/**
* When true, the resulting UserMessage gets `isMeta: true` — hidden in the
* transcript UI but visible to the model. Used by system-generated prompts
* (proactive ticks, teammate messages, resource updates) that route through
* the queue instead of calling `onQuery` directly.
*/
isMeta?: boolean
/**
* Provenance of this command. Stamped onto the resulting UserMessage so the
* transcript records origin structurally (not just via XML tags in content).
* undefined = human (keyboard).
*/
origin?: MessageOrigin
/**
* Workload tag threaded through to cc_workload= in the billing-header
* attribution block. The queue is the async boundary between the cron
* scheduler firing and the turn actually running — a user prompt can slip
* in between — so the tag rides on the QueuedCommand itself and is only
* hoisted into bootstrap state when THIS command is dequeued.
*/
workload?: string
/**
* Agent that should receive this notification. Undefined = main thread.
* Subagents run in-process and share the module-level command queue; the
* drain gate in query.ts filters by this field so a subagent's background
* task notifications don't leak into the coordinator's context (PR #18453
* unified the queue but lost the isolation the dual-queue accidentally had).
*/
agentId?: AgentId
/**
* Autonomy-run provenance for system-generated automatic turns.
* Used by the autonomy ledger to track queue → execution lifecycle.
*/
autonomy?: {
runId: string
rootDir?: string
trigger: 'scheduled-task' | 'proactive-tick' | 'managed-flow-step'
sourceId?: string
sourceLabel?: string
flowId?: string
flowStepId?: string
flowStepName?: string
}
}
/**
* Type guard for image PastedContent with non-empty data. Empty-content
* images (e.g. from a 0-byte file drag) yield empty base64 strings that
* the API rejects with `image cannot be empty`. Use this at every site
* that converts PastedContent → ImageBlockParam so the filter and the
* ID list stay in sync.
*/
export function isValidImagePaste(c: PastedContent): boolean {
return c.type === 'image' && c.content.length > 0
}
/** Extract image paste IDs from a QueuedCommand's pastedContents. */
export function getImagePasteIds(
pastedContents: Record<number, PastedContent> | undefined,
): number[] | undefined {
if (!pastedContents) {
return undefined
}
const ids = Object.values(pastedContents)
.filter(isValidImagePaste)
.map(c => c.id)
return ids.length > 0 ? ids : undefined
}
export type OrphanedPermission = {
permissionResult: PermissionResult
assistantMessage: AssistantMessage
}