fix: resolve dependency audit findings precisely (#361)

* fix: harden ACP communication boundaries

Harden ACP communication boundaries

Remote ACP sessions now cannot widen permission mode through untrusted
metadata or client payloads. WebSocket ACP ingress measures payloads by bytes
before binary decode, and prompt queue handoff keeps exactly one prompt active
while queued prompts are drained FIFO.

Constraint: ACP remote clients must not be able to open bypassPermissions without local launch intent
Constraint: WebSocket payload limits must be byte-based and checked before binary decode
Rejected: Keep promptToQueryContent wrapper | no production consumers remained after prompt conversion single-sourcing
Confidence: high
Scope-risk: moderate
Directive: Do not re-enable remote bypassPermissions from _meta unless a local launch gate is verified in both acp-link and agent
Tested: targeted ACP/RCS/acp-link prompt queue, bridge, permission, payload, and prompt conversion tests; bun run typecheck; bun run build
Not-tested: Manual live ACP/RCS session against an external client

* fix: restore repository verification gates

Keep the full repository test, typecheck, build, and Biome lint gates usable
after the ACP fix pass. This commit is intentionally separate from the ACP
behavior change: it fixes Windows-safe Langfuse home redaction, removes stale
lint suppressions, resolves Biome warning/info diagnostics, and keeps env
expansion tests explicit without template-placeholder lint noise.

Constraint: The project completion contract requires full typecheck, lint, test, and build evidence
Rejected: Leave warning/info diagnostics as historical noise | they obscure future gate regressions and weaken flow-impact claims
Confidence: high
Scope-risk: narrow
Directive: Keep repository gate cleanup separate from feature fixes when it is not part of the same runtime path
Tested: bunx biome lint src/; bunx tsc --noEmit; bun test src/services/mcp/__tests__/envExpansion.test.ts src/utils/__tests__/sliceAnsi.test.ts src/utils/__tests__/stringUtils.test.ts; bun test; bun run build
Not-tested: Manual Langfuse export against a real external Langfuse service

* fix: harden ACP failure boundaries after review

Deep review found several paths that made ACP communication failures look normal: prompt errors could finish as end_turn, permission pipeline exceptions could fall through to client approval, tool rawInput was deep-copied with JSON, and acp-link accepted unbounded or unvalidated WebSocket payloads. This keeps the behavior fail-closed, validates WS payloads before dispatch, caps payload size before JSON parse, and preserves cancellation intent with a generation counter.

Constraint: User explicitly rejected pseudo-fixes, fallback behavior, and unbounded payload handling

Rejected: Keep JSON stringify/parse rawInput copy | duplicates large payloads and silently drops non-JSON inputs

Rejected: Delegate permission pipeline errors to client approval | allows a broken local permission check to be bypassed

Confidence: high

Scope-risk: moderate

Directive: Do not convert ACP errors into normal end_turn responses without a protocol-level reason and regression tests

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test packages/acp-link/src/__tests__/server.test.ts

Tested: bunx tsc --noEmit

Tested: bunx biome lint src/ packages/acp-link/src/

Tested: bun run test:all

Tested: bun run build

Not-tested: Manual end-to-end ACP client session over a real editor WebSocket

* fix: prevent ACP coverage runs from seeing partial mocks

GitHub Actions failed under bun test --coverage because permissions.test.ts replaced ../bridge.js with a partial mock that omitted forwardSessionUpdates. Coverage worker ordering on Linux let sibling tests observe that incomplete module.

This isolates ACP test mocks by snapshotting real exports, overriding only requested symbols, and restoring mocks in LIFO order. The shared helper also keeps the same behavior in agent.test.ts without duplicating mock infrastructure.

Constraint: bun:test mock.module is process-global inside a worker.

Rejected: Add fallback exports or production guards | the bridge export exists; the failure was test mock pollution.

Rejected: Keep per-file helper copies | duplication would let restore semantics drift again.

Confidence: high

Scope-risk: narrow

Directive: Prefer safeMockModule for partial mocks of real modules in ACP tests; plain mock.module is only appropriate for fully synthetic modules or isolated tests.

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test --coverage --coverage-reporter=lcov

Tested: bunx tsc --noEmit

Tested: bun run lint

Tested: git diff --check

Not-tested: Linux runner directly before push

* fix: normalize ACP bypass requests without warning noise

The previous CI repair removed the failing partial bridge mock, but it also added a shared safeMockModule helper and left the acp-link bypass normalization warning in the real new_session path.

This tightens the fix: acp-link now treats an unauthorized client bypass request as normal permission-mode normalization without emitting a warning, and the ACP permission test explicitly preserves the real bridge and permission exports instead of using a shared helper. The agent test keeps its local mock preservation but names it by behavior and restores mocks in LIFO order.

Constraint: CI output should not contain expected warning noise for covered policy branches.

Rejected: Silence the test only | the normal new_session path would still warn for an expected normalization branch.

Rejected: Keep the shared safeMockModule helper | the failing module was specific and should be fixed by preserving real exports at the mocking site.

Confidence: high

Scope-risk: narrow

Directive: Treat client-requested bypassPermissions as data to normalize unless the local default explicitly enables bypass.

Tested: bun test packages/acp-link/src/__tests__/server.test.ts

Tested: bun test src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/bridge.test.ts src/services/acp/__tests__/permissions.test.ts

Tested: bun test --coverage --coverage-reporter=lcov with UPPER_WARN_COUNT=0

Tested: bun run test:all

Tested: bun run lint

Tested: bunx tsc --noEmit

Tested: git diff --check

* fix: harden ACP bypass and CI warning gates

ACP clients must not be able to enter bypassPermissions unless the local ACP gate and process environment both allow it. The same gate now controls session creation, explicit mode changes, and the ExitPlanMode option list, while session setup restores process.cwd so coverage and later work do not inherit ACP session state.

Constraint: CI must stay warning-clean without hiding real ACP permission failures

Rejected: Logging rejected bypass requests on the normal new_session path | it preserves audit text but reintroduces warning noise the runtime should not emit

Rejected: Broad CI=true postinstall skip | it hides explicit Chrome MCP setup checks outside the install path

Confidence: high

Scope-risk: moderate

Directive: Keep bypassPermissions gated through one ACP availability decision before exposing it to clients

Tested: bun test src/services/acp/__tests__/permissions.test.ts src/services/acp/__tests__/agent.test.ts packages/acp-link/src/__tests__/server.test.ts

Tested: bun run test:all

Tested: bun run lint

Tested: bun run build:vite with zero warning matches

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage produced non-empty lcov with SF records and zero filtered warning matches

Not-tested: GitHub Actions result after this push

* fix: remove remaining CI warning noise

The CI log still had three non-failing warnings after the ACP hardening commit: git init default-branch advice from checkout, a Node 20 action-runtime deprecation, and one additional known Vite dynamic-import diagnostic that only surfaced on Linux. The workflow now provides explicit git config and opts actions into Node 24, while Vite keeps a narrow allowlist for acknowledged optimizer diagnostics.

Constraint: Do not use shell log filtering to hide warnings after they happen

Rejected: Grep warning lines out of CI output | it would make future diagnostics harder to find

Confidence: high

Scope-risk: narrow

Directive: Add new Vite warning allowlist entries only after checking that they are existing optimizer diagnostics, not new application defects

Tested: bunx tsc --noEmit --pretty false

Tested: bunx biome lint .github/workflows/ci.yml vite.config.ts

Tested: bun run build:vite with zero warning matches

Not-tested: GitHub Actions result after this push

* fix: reject unauthorized ACP bypass and harden CI actions

ACP clients now fail closed when permissionMode is malformed, unknown, or requests bypass without a local bypass opt-in. acp-link validates new_session input before forwarding to the agent and returns client error frames for expected unauthorized requests without logging create-failed noise. The direct AcpAgent path independently rejects invalid _meta.permissionMode and unauthorized bypass instead of falling back to settings.

CI workflows and generated GitHub App templates now use Node 24-compatible actions pinned to immutable commit SHAs, and acp-link startup output no longer prints the auth token.

Constraint: Must not hide warnings with test isolation or log filtering

Rejected: Silent fallback to local permission mode | accepts invalid client intent and masks boundary behavior

Rejected: Broad dependency churn from bun update | audit remained failing while package and lockfile churn expanded scope

Confidence: high

Scope-risk: moderate

Directive: Client-provided permissionMode must stay fail-closed before reaching AcpAgent; only local settings.defaultMode may fall back to default on invalid local config

Tested: bun test packages/acp-link/src/__tests__/server.test.ts src/services/acp/__tests__/agent.test.ts src/services/acp/__tests__/permissions.test.ts src/services/skillLearning/__tests__/skillLifecycle.test.ts src/utils/settings/__tests__/config.test.ts

Tested: bunx tsc -p packages/acp-link/tsconfig.json --noEmit --pretty false

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun run test:all

Tested: local CI equivalent install/typecheck/coverage/build with warning_scan=0

Not-tested: Pre-existing bun audit vulnerabilities require a separate dependency-hardening PR

* fix: resolve dependency audit findings precisely

Use dependency-native upgrades and lockfile resolution to close the audit findings without suppressions. Keep the chrome MCP setup aligned with the new dependency graph and add real integration coverage so the override behavior stays verified.

Constraint: no audit ignores or warning suppression
Rejected: broad google-auth/protobuf overrides | replaced with upstream-compatible resolution
Confidence: high
Scope-risk: moderate
Directive: keep dependency fixes upstream-compatible; do not reintroduce blanket overrides unless the audit surface changes materially
Tested: bun audit; bun audit --json; bun install --frozen-lockfile with CLAUDE_CODE_SKIP_CHROME_MCP_SETUP=1; bunx tsc --noEmit --pretty false; bun run lint; targeted tests; bun run test:all; bun test --coverage --coverage-reporter lcov --coverage-dir coverage; bun run build:vite
Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks

* fix: keep ACP auth tokens out of URLs

Replace the ad hoc URL-token flow with crypto UUID-backed transport identifiers so the bearer token stays in structured request data instead of query strings. Keep the server, web client, and transport helpers aligned so the ACP/RCS handshake remains compatible after the API shape change.

Constraint: token must not be embedded in the URL
Rejected: token-as-uuid query fallback | leaked bearer tokens in URLs
Confidence: high
Scope-risk: moderate
Directive: preserve the structured auth path; do not reintroduce query-token fallback when adjusting ACP transport code
Tested: targeted ACP/RCS transport tests
Not-tested: unrelated pre-existing ACP/CORS/token fallback residual risks

* fix: normalize WebFetch request headers

Normalize WebFetch headers before dispatch so canonicalization preserves auth semantics and duplicate forms do not slip through. Keep the behavior locked with a focused header test instead of broadening the request pipeline.

Constraint: preserve header semantics without widening the fetch surface
Rejected: ad hoc caller-side normalization | too easy to bypass in future call sites
Confidence: high
Scope-risk: narrow
Directive: keep header normalization close to the WebFetch utility so future callers inherit the same behavior automatically
Tested: targeted WebFetch header tests
Not-tested: unrelated fetch backend behavior beyond header normalization

* fix: harden ACP remote auth surfaces

Tighten the remaining Claude security artifact items by requiring API keys on ACP global reads and relay upgrades, moving WebSocket tokens out of URLs, and replacing open web CORS with an explicit allowlist.

Constraint: Browser WebSocket clients cannot set arbitrary Authorization headers, so the token is carried in a selected subprotocol instead of a query string.
Rejected: Keep UUID auth for ACP channel groups | any caller can mint a UUID and read global ACP data.
Rejected: Preserve ?token= compatibility | secrets leak into logs, history, referrers, and intermediaries.
Confidence: high
Scope-risk: moderate
Directive: Do not reintroduce query-string bearer tokens; use Authorization or rcs.auth.<base64url-token>.
Tested: bunx tsc --noEmit --pretty false
Tested: bun run typecheck in packages/remote-control-server
Tested: bun run build in packages/acp-link
Tested: bun run lint
Tested: bun audit
Tested: focused RCS/acp-link/web tests, 160 pass
Tested: Edge headless browser WebSocket subprotocol handshake
Tested: bun run test:all, 3669 pass
Tested: bun run build:vite
Tested: bun run build
Not-tested: Manual end-to-end relay with a live external ACP agent

* fix: resolve CI dependency override lookup

The CI runner does not expose @grpc/proto-loader as a root-resolvable package, and the test was relying on local hoisting rather than the real dependency owner. Resolve proto-loader through @opentelemetry/exporter-trace-otlp-grpc and @grpc/grpc-js so the smoke test follows the package graph it is validating.

Constraint: Do not add a new root dependency for a transitive smoke test.

Rejected: Skip or weaken the test | the test protects the protobuf 7 override path and should keep exercising loadSync.

Rejected: Add @grpc/proto-loader directly to root package.json | that hides the owning-package resolution issue and broadens dependency surface.

Confidence: high

Scope-risk: narrow

Directive: Dependency override smoke tests should resolve from the package that actually owns the dependency, not from incidental root hoisting.

Tested: bun test tests/integration/dependency-overrides.test.ts; bunx tsc --noEmit --pretty false; bun run lint; bun audit; bun run test:all; git diff --check

---------

Co-authored-by: unraid <local@unraid.local>
This commit is contained in:
Dosion
2026-04-26 19:49:54 +08:00
committed by GitHub
parent fc438bd222
commit c2ac9a74c1
144 changed files with 4406 additions and 1644 deletions

View File

@@ -1675,7 +1675,7 @@ async function stopWorkWithRetry(
}
const errMsg = errorMessage(err)
if (attempt < MAX_ATTEMPTS) {
const delay = addJitter(baseDelayMs * Math.pow(2, attempt - 1))
const delay = addJitter(baseDelayMs * 2 ** (attempt - 1))
logger.logVerbose(
`Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`,
)

View File

@@ -518,7 +518,7 @@ export class SSETransport implements Transport {
this.reconnectAttempts++
const baseDelay = Math.min(
RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts - 1),
RECONNECT_BASE_DELAY_MS * 2 ** (this.reconnectAttempts - 1),
RECONNECT_MAX_DELAY_MS,
)
// Add ±25% jitter
@@ -668,7 +668,7 @@ export class SSETransport implements Transport {
}
const delayMs = Math.min(
POST_BASE_DELAY_MS * Math.pow(2, attempt - 1),
POST_BASE_DELAY_MS * 2 ** (attempt - 1),
POST_MAX_DELAY_MS,
)
await sleep(delayMs)

View File

@@ -516,7 +516,7 @@ export class WebSocketTransport implements Transport {
this.reconnectAttempts++
const baseDelay = Math.min(
DEFAULT_BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1),
DEFAULT_BASE_RECONNECT_DELAY * 2 ** (this.reconnectAttempts - 1),
DEFAULT_MAX_RECONNECT_DELAY,
)
// Add ±25% jitter to avoid thundering herd

View File

@@ -61,7 +61,7 @@ function IDEScreen({
} else if (value === 'None' && shouldShowDisableAutoConnectDialog()) {
setShowDisableAutoConnectDialog(true)
} else {
onSelect(availableIDEs.find(ide => ide.port === parseInt(value)))
onSelect(availableIDEs.find(ide => ide.port === parseInt(value, 10)))
}
},
[availableIDEs, onSelect],
@@ -216,7 +216,7 @@ function IDEOpenSelection({
const handleSelectIDE = useCallback(
(value: string) => {
const selectedIDE = availableIDEs.find(
ide => ide.port === parseInt(value),
ide => ide.port === parseInt(value, 10),
)
onSelectIDE(selectedIDE)
},

View File

@@ -78,7 +78,7 @@ function ModelPickerWrapper({
}
// Turn off fast mode if switching to unsupported model
let wasFastModeToggledOn = undefined
let wasFastModeToggledOn
if (isFastModeEnabled()) {
clearFastModeCooldown()
if (!isFastModeSupportedByModel(model) && isFastMode) {
@@ -214,7 +214,7 @@ function SetModelAndClose({
}))
let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`
let wasFastModeToggledOn = undefined
let wasFastModeToggledOn
if (isFastModeEnabled()) {
clearFastModeCooldown()
if (!isFastModeSupportedByModel(modelValue) && isFastMode) {

View File

@@ -133,7 +133,6 @@ export function AddMarketplace({
void handleAdd()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
}, []) // Only run once on mount
return (

View File

@@ -190,7 +190,6 @@ export function ManageMarketplaces({
}
void loadMarketplaces()
// eslint-disable-next-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
}, [targetMarketplace, action, error])
// Check if there are any pending changes

View File

@@ -204,7 +204,6 @@ export function AutoUpdater({
// instead so the guard is always current without changing callback
// identity (which would re-trigger the initial-check useEffect below).
// eslint-disable-next-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref
}, [onAutoUpdaterResult])
// Initial check

View File

@@ -13,7 +13,6 @@ export function MemoryUsageIndicator(): React.ReactNode {
}
// eslint-disable-next-line react-hooks/rules-of-hooks
// biome-ignore lint/correctness/useHookAtTopLevel: USER_TYPE is a build-time constant
const memoryUsage = useMemoryUsage()
if (!memoryUsage) {

View File

@@ -879,7 +879,6 @@ function computeDiffStatsBetweenMessages(
}
}
} catch {
continue
}
}

View File

@@ -169,7 +169,6 @@ export function NativeAutoUpdater({
// instead so the guard is always current without changing callback
// identity (which would re-trigger the initial-check useEffect below).
// eslint-disable-next-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref
}, [onAutoUpdaterResult, channel])
// Initial check

View File

@@ -254,18 +254,17 @@ function NotificationContent({
// Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook)
const voiceState = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useVoiceState(s => s.voiceState)
: ('idle' as const)
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
const voiceError = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useVoiceState(s => s.voiceError)
: null
const isBriefOnly =
feature('KAIROS') || feature('KAIROS_BRIEF')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAppState(s => s.isBriefOnly)
: false

View File

@@ -449,7 +449,7 @@ function PromptInput({
// its own marginTop, so the gap stays even without ours.
const briefOwnsGap =
feature('KAIROS') || feature('KAIROS_BRIEF')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAppState(s => s.isBriefOnly) && !viewingAgentTaskId
: false
const mainLoopModel_ = useAppState(s => s.mainLoopModel)
@@ -2384,7 +2384,7 @@ function PromptInput({
useBuddyNotification()
const companionSpeaking = feature('BUDDY')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAppState(s => s.companionReaction !== undefined)
: false
const { columns, rows } = useTerminalSize()

View File

@@ -258,14 +258,13 @@ function ModeIndicator({
proactiveModule?.getNextTickAt ?? NULL,
NULL,
)
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
const voiceState = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useVoiceState(s => s.voiceState)
: ('idle' as const)
const voiceWarmingUp = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useVoiceState(s => s.voiceWarmingUp)
: false
const hasSelection = useHasSelection()
@@ -302,7 +301,7 @@ function ModeIndicator({
'ctrl+x ctrl+k',
)
const voiceKeyShortcut = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space')
: ''
// Captured at mount so the hint doesn't flicker mid-session if another
@@ -311,14 +310,13 @@ function ModeIndicator({
// shown" without tracking the exact render-time condition (which depends
// on parts/hintParts computed after the early-return hooks boundary).
const [voiceHintUnderCap] = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useState(
() =>
(getGlobalConfig().voiceFooterHintSeenCount ?? 0) <
MAX_VOICE_HINT_SHOWS,
)
: [false]
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null
useEffect(() => {
if (feature('VOICE_MODE')) {

View File

@@ -100,7 +100,7 @@ function PromptInputQueuedCommandsImpl(): React.ReactNode {
// component early-returns when viewing a teammate.
const useBriefLayout =
feature('KAIROS') || feature('KAIROS_BRIEF')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAppState(s => s.isBriefOnly)
: false

View File

@@ -258,7 +258,7 @@ export function computeWheelStep(
// the curve handles it (gap=1000ms → m≈0.01 → mult≈1). No frac —
// rounding loss is minor at high mult, and frac persisting across idle
// was causing off-by-one on the first click back.
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
const m = 0.5 ** (gap / WHEEL_DECAY_HALFLIFE_MS)
const cap = Math.max(WHEEL_MODE_CAP, state.base * 2)
const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m
state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP)
@@ -299,7 +299,7 @@ export function computeWheelStep(
state.mult = 2
state.frac = 0
} else {
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
const m = 0.5 ** (gap / WHEEL_DECAY_HALFLIFE_MS)
const cap =
gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST
state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m)

View File

@@ -95,7 +95,7 @@ export function SpinnerWithVerb(props: Props): React.ReactNode {
// Hoisted to mount-time — this component re-renders at animation framerate.
const briefEnvEnabled =
feature('KAIROS') || feature('KAIROS_BRIEF')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
: false

View File

@@ -370,7 +370,6 @@ function StatusLineInner({
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
}, []) // Only run once on mount - settings stable for initial logging
// Initial update on mount + cleanup on unmount
@@ -384,7 +383,6 @@ function StatusLineInner({
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
}, []) // Only run once on mount, not when doUpdate changes
// Get padding from settings or default to 0

View File

@@ -48,20 +48,20 @@ export default function TextInput(props: Props): React.ReactNode {
const reducedMotion = settings.prefersReducedMotion ?? false
const voiceState = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useVoiceState(s => s.voiceState)
: ('idle' as const)
const isVoiceRecording = voiceState === 'recording'
const audioLevels = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useVoiceState(s => s.voiceAudioLevels)
: []
const smoothedRef = useRef<number[]>(new Array(CURSOR_WAVEFORM_WIDTH).fill(0))
const needsAnimation = isVoiceRecording && !reducedMotion
const [animRef, animTime] = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAnimationFrame(needsAnimation ? 50 : null)
: [() => {}, 0]

View File

@@ -63,7 +63,7 @@ export function WorktreeExitDialog({
'--count',
`${worktreeSession.originalHeadCommit}..HEAD`,
])
const count = parseInt(commitsStr.trim()) || 0
const count = parseInt(commitsStr.trim(), 10) || 0
setCommitCount(count)
// If no changes and no commits, clean up silently
@@ -94,7 +94,6 @@ export function WorktreeExitDialog({
}
void loadChanges()
// eslint-disable-next-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
}, [worktreeSession])
useEffect(() => {

View File

@@ -27,7 +27,7 @@ export function ColorPicker({
const [selectedIndex, setSelectedIndex] = useState(
Math.max(
0,
COLOR_OPTIONS.findIndex(opt => opt === currentColor),
COLOR_OPTIONS.indexOf(currentColor),
),
)

View File

@@ -60,7 +60,7 @@ export function MCPSettings({ onComplete }: Props): React.ReactNode {
const isSSE = client.config.type === 'sse'
const isHTTP = client.config.type === 'http'
const isClaudeAIProxy = client.config.type === 'claudeai-proxy'
let isAuthenticated: boolean | undefined = undefined
let isAuthenticated: boolean | undefined
if (isSSE || isHTTP) {
const authProvider = new ClaudeAuthProvider(

View File

@@ -88,7 +88,7 @@ export function MCPToolListView({
<Select
options={toolOptions}
onChange={value => {
const index = parseInt(value)
const index = parseInt(value, 10)
const tool = serverTools[index]
if (tool) {
onSelectTool(tool, index)

View File

@@ -48,7 +48,7 @@ export function AttachmentMessage({
const bg = useSelectedMessageBg()
// Hoisted to mount-time — per-message component, re-renders on every scroll.
const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useMemo(() => isEnvTruthy(process.env.IS_DEMO), [])
: false
// Handle teammate_mailbox BEFORE switch

View File

@@ -50,18 +50,18 @@ export function UserPromptMessage({
// external builds.
const isBriefOnly =
feature('KAIROS') || feature('KAIROS_BRIEF')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAppState(s => s.isBriefOnly)
: false
const viewingAgentTaskId =
feature('KAIROS') || feature('KAIROS_BRIEF')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAppState(s => s.viewingAgentTaskId)
: null
// Hoisted to mount-time — per-message component, re-renders on every scroll.
const briefEnvEnabled =
feature('KAIROS') || feature('KAIROS_BRIEF')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), [])
: false
const useBriefLayout =

View File

@@ -53,7 +53,7 @@ export function UserToolSuccessMessage({
// UserPromptMessage.tsx.
const isBriefOnly =
feature('KAIROS') || feature('KAIROS_BRIEF')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAppState(s => s.isBriefOnly)
: false

View File

@@ -77,7 +77,6 @@ export function TeamsDialog({ initialTeams, onDone }: Props): React.ReactNode {
const teammateStatuses = useMemo(() => {
return getTeammateStatuses(dialogLevel.teamName);
// eslint-disable-next-line react-hooks/exhaustive-deps
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional
}, [dialogLevel.teamName, refreshKey]);
// Periodically refresh to pick up mode changes from teammates

View File

@@ -31,13 +31,13 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1, 2026-04-25
with:
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
@@ -126,13 +126,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2, 2026-04-25
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1, 2026-04-25
with:
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'

View File

@@ -80,7 +80,6 @@ async function main(): Promise<void> {
(args[0] === '--version' || args[0] === '-v' || args[0] === '-V')
) {
// MACRO.VERSION is inlined at build time
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${MACRO.VERSION} (Claude Code)`)
return
}
@@ -101,7 +100,6 @@ async function main(): Promise<void> {
const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel()
const { getSystemPrompt } = await import('../constants/prompts.js')
const prompt = await getSystemPrompt([], model)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(prompt.join('\n'))
return
}

View File

@@ -22,7 +22,7 @@ export function renderPlaceholder({
renderedPlaceholder: string | undefined
showPlaceholder: boolean
} {
let renderedPlaceholder: string | undefined = undefined
let renderedPlaceholder: string | undefined
if (placeholder) {
if (hidePlaceholderText) {

View File

@@ -17,7 +17,7 @@ const HISTORY_CHUNK_SIZE = 10
// Mode filter is included to ensure we don't mix filtered and unfiltered caches
let pendingLoad: Promise<HistoryEntry[]> | null = null
let pendingLoadTarget = 0
let pendingLoadModeFilter: HistoryMode | undefined = undefined
let pendingLoadModeFilter: HistoryMode | undefined
async function loadHistoryEntries(
minCount: number,

View File

@@ -92,7 +92,7 @@ export function GlobalKeybindingHandlers({
// Brief view has its own dedicated toggle on ctrl+shift+b.
const isBriefOnly =
feature('KAIROS') || feature('KAIROS_BRIEF')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAppState(s => s.isBriefOnly)
: false
const handleToggleTranscript = useCallback(() => {
@@ -202,7 +202,6 @@ export function GlobalKeybindingHandlers({
context: 'Global',
})
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useKeybinding('app:toggleBrief', handleToggleBrief, {
context: 'Global',
})

View File

@@ -123,23 +123,23 @@ export function useReplBridge(
const store = useAppStateStore()
const { addNotification } = useNotifications()
const replBridgeEnabled = feature('BRIDGE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAppState(s => s.replBridgeEnabled)
: false
const replBridgeConnected = feature('BRIDGE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAppState(s => s.replBridgeConnected)
: false
const replBridgeSessionActive = feature('BRIDGE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAppState(s => s.replBridgeSessionActive)
: false
const replBridgeOutboundOnly = feature('BRIDGE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAppState(s => s.replBridgeOutboundOnly)
: false
const replBridgeInitialName = feature('BRIDGE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useAppState(s => s.replBridgeInitialName)
: undefined

View File

@@ -236,14 +236,13 @@ export function useVoiceIntegration({
// Voice state selectors. useVoiceEnabled = user intent (settings) +
// auth + GB kill-switch, with the auth half memoized on authVersion so
// render loops never hit a cold keychain spawn.
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
const voiceState = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useVoiceState(s => s.voiceState)
: ('idle' as const)
const voiceInterimTranscript = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useVoiceState(s => s.voiceInterimTranscript)
: ''
@@ -416,10 +415,9 @@ export function useVoiceKeybindingHandler({
const setVoiceState = useSetVoiceState()
const keybindingContext = useOptionalKeybindingContext()
const isModalOverlayActive = useIsModalOverlayActive()
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false
const voiceState = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
?
useVoiceState(s => s.voiceState)
: 'idle'

View File

@@ -1758,7 +1758,6 @@ async function run(): Promise<CommanderCommand> {
// Ignore "code" as a prompt - treat it the same as no prompt
if (prompt === "code") {
logEvent("tengu_code_prompt_ignored", {});
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.warn(
chalk.yellow(
"Tip: You can launch Claude Code with just `claude`",
@@ -1826,7 +1825,6 @@ async function run(): Promise<CommanderCommand> {
kairosGate
) {
if (!checkHasTrustDialogAccepted()) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.warn(
chalk.yellow(
"Assistant mode disabled: directory is not trusted. Accept the trust dialog and restart.",
@@ -2460,7 +2458,6 @@ async function run(): Promise<CommanderCommand> {
});
logForDebugging(`[Claude in Chrome] Error: ${error}`);
logError(error);
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(
`Error: Failed to run with Claude in Chrome.`,
);
@@ -2745,7 +2742,6 @@ async function run(): Promise<CommanderCommand> {
// Print any warnings from initialization
warnings.forEach((warning) => {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(warning);
});
@@ -2807,7 +2803,6 @@ async function run(): Promise<CommanderCommand> {
inputFormat !== "text" &&
inputFormat !== "stream-json"
) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`Error: Invalid input format "${inputFormat}".`);
process.exit(1);
}
@@ -2815,7 +2810,6 @@ async function run(): Promise<CommanderCommand> {
inputFormat === "stream-json" &&
outputFormat !== "stream-json"
) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(
`Error: --input-format=stream-json requires output-format=stream-json.`,
);
@@ -2828,7 +2822,6 @@ async function run(): Promise<CommanderCommand> {
inputFormat !== "stream-json" ||
outputFormat !== "stream-json"
) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(
`Error: --sdk-url requires both --input-format=stream-json and --output-format=stream-json.`,
);
@@ -2842,7 +2835,6 @@ async function run(): Promise<CommanderCommand> {
inputFormat !== "stream-json" ||
outputFormat !== "stream-json"
) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(
`Error: --replay-user-messages requires both --input-format=stream-json and --output-format=stream-json.`,
);
@@ -6061,7 +6053,6 @@ async function run(): Promise<CommanderCommand> {
setDirectConnectServerUrl(serverUrl);
connectConfig = session.config;
} catch (err) {
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error(
err instanceof DirectConnectError
? err.message

View File

@@ -206,7 +206,7 @@ export class FileIndex {
const { paths, lowerPaths, charBits, pathLens, readyCount } = this
outer: for (let i = 0; i < readyCount; i++) {
for (let i = 0; i < readyCount; i++) {
// O(1) bitmap reject: path must contain every letter in the needle
if ((charBits[i]! & needleBitmap) !== needleBitmap) continue

View File

@@ -277,7 +277,6 @@ export async function* query(
for (const uuid of consumedCommandUuids) {
notifyCommandLifecycle(uuid, 'completed')
}
// biome-ignore lint/style/noNonNullAssertion: terminal is always assigned when queryLoop returns normally
return terminal!
}
@@ -331,7 +330,7 @@ async function* queryLoop(
// multiple compacts: each subtracts the final context at that compact's
// trigger point. Loop-local (not on State) to avoid touching the 7 continue
// sites.
let taskBudgetRemaining: number | undefined = undefined
let taskBudgetRemaining: number | undefined
// Snapshot immutable env/statsig/session state once at entry. See QueryConfig
// for what's included and why feature() gates are intentionally excluded.

View File

@@ -639,6 +639,7 @@ function TranscriptSearchBar({
const [indexStatus, setIndexStatus] = React.useState<'building' | { ms: number } | null>('building');
React.useEffect(() => {
let alive = true;
let hideTimeout: ReturnType<typeof setTimeout> | undefined;
const warm = jumpRef.current?.warmSearchIndex;
if (!warm) {
setIndexStatus(null); // VML not mounted yet — rare, skip indicator
@@ -652,14 +653,14 @@ function TranscriptSearchBar({
setIndexStatus(null);
} else {
setIndexStatus({ ms });
setTimeout(() => alive && setIndexStatus(null), 2000);
hideTimeout = setTimeout(() => alive && setIndexStatus(null), 2000);
}
});
return () => {
alive = false;
if (hideTimeout) clearTimeout(hideTimeout);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // mount-only: bar opens once per /
}, [jumpRef]); // mount-only per stable search bar ref
// Gate the query effect on warm completion. setHighlight stays instant
// (screen-space overlay, no indexing). setSearchQuery (the scan) waits.
const warmDone = indexStatus !== 'building';
@@ -667,8 +668,7 @@ function TranscriptSearchBar({
if (!warmDone) return;
jumpRef.current?.setSearchQuery(query);
setHighlight(query);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, warmDone]);
}, [jumpRef, query, setHighlight, warmDone]);
const off = cursorOffset;
const cursorChar = off < query.length ? query[off] : ' ';
return (
@@ -4992,16 +4992,19 @@ export function REPL({
}
}, [queuedCommands]);
const onInitRef = useRef(onInit);
onInitRef.current = onInit;
const diagnosticTrackerRef = useRef(diagnosticTracker);
diagnosticTrackerRef.current = diagnosticTracker;
// Initial load
useEffect(() => {
void onInit();
void onInitRef.current();
// Cleanup on unmount
return () => {
void diagnosticTracker.shutdown();
void diagnosticTrackerRef.current.shutdown();
};
// TODO: fix this
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Listen for suspend/resume events

View File

@@ -86,7 +86,7 @@ function substituteVariables(
// (replacer fn treats $ literally), and (2) double-substitution when user
// content happens to contain {{varName}} matching a later variable.
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
Object.prototype.hasOwnProperty.call(variables, key)
Object.hasOwn(variables, key)
? variables[key]!
: match,
)

View File

@@ -206,7 +206,7 @@ function substituteVariables(
// (replacer fn treats $ literally), and (2) double-substitution when user
// content happens to contain {{varName}} matching a later variable.
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
Object.prototype.hasOwnProperty.call(variables, key)
Object.hasOwn(variables, key)
? variables[key]!
: match,
)

View File

@@ -4,56 +4,74 @@ import {
test,
mock,
beforeEach,
afterEach,
afterAll,
spyOn,
} from 'bun:test'
// ── Mock infrastructure ──────────────────────────────────────────
// bun:test mock.module is process-global: it leaks to sibling test files
// in the same worker. safeMockModule snapshots real exports before mocking
// in the same worker. Preserve real exports before partial module mocking
// so afterAll can restore them, preventing cross-file pollution.
const _restores: (() => void)[] = []
const originalCwd = process.cwd()
const originalAcpPermissionMode = process.env.ACP_PERMISSION_MODE
const originalAcpAllowBypass = process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
function safeMockModule(tsPath: string, overrides: Record<string, unknown>) {
function mockModulePreservingExports(
tsPath: string,
overrides: Record<string, unknown>,
) {
const jsPath = tsPath.replace(/\.ts$/, '.js')
const real = require(tsPath)
const snapshot = { ...real }
const snapshot = { ...(require(tsPath) as Record<string, unknown>) }
mock.module(jsPath, () => ({ ...snapshot, ...overrides }))
_restores.push(() => mock.module(jsPath, () => snapshot))
}
afterAll(() => {
for (let i = _restores.length - 1; i >= 0; i--) {
_restores[i]()
}
_restores.length = 0
restoreEnv('ACP_PERMISSION_MODE', originalAcpPermissionMode)
restoreEnv(
'CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS',
originalAcpAllowBypass,
)
})
// ── Module mocks (must precede any import of the module under test) ──
const mockSetModel = mock(() => {})
const mockSubmitMessage = mock(async function* (_input: string) {})
// Fully synthetic — no real module to snapshot, so plain mock.module suffices.
mock.module('../../../QueryEngine.js', () => ({
mockModulePreservingExports('../../../QueryEngine.ts', {
QueryEngine: class MockQueryEngine {
submitMessage = mock(async function* () {})
submitMessage = mockSubmitMessage
interrupt = mock(() => {})
resetAbortController = mock(() => {})
getAbortSignal = mock(() => new AbortController().signal)
setModel = mockSetModel
},
}))
})
safeMockModule('../../../tools.ts', {
mockModulePreservingExports('../../../tools.ts', {
getTools: mock(() => []),
})
safeMockModule('../../../Tool.ts', {
mockModulePreservingExports('../../../Tool.ts', {
toolMatchesName: mock(() => false),
findToolByName: mock(() => undefined),
filterToolProgressMessages: mock(() => []),
buildTool: mock((def: any) => def),
})
safeMockModule('../../../utils/config.ts', {
mockModulePreservingExports('../../../utils/config.ts', {
enableConfigs: mock(() => {}),
})
safeMockModule('../../../bootstrap/state.ts', {
mockModulePreservingExports('../../../bootstrap/state.ts', {
setOriginalCwd: mock(() => {}),
addSlowOperation: mock(() => {}),
})
@@ -75,24 +93,16 @@ const mockGetDefaultAppState = mock(() => ({
mainLoopModelForSession: null,
}))
safeMockModule('../../../state/AppStateStore.ts', {
mockModulePreservingExports('../../../state/AppStateStore.ts', {
getDefaultAppState: mockGetDefaultAppState,
})
// Single export, fully synthetic — no real module to snapshot.
mock.module('../permissions.js', () => ({
createAcpCanUseTool: mock(() =>
mock(async () => ({ behavior: 'allow', updatedInput: {} })),
),
}))
safeMockModule('../utils.ts', {
resolvePermissionMode: mock(() => 'default'),
mockModulePreservingExports('../utils.ts', {
computeSessionFingerprint: mock(() => '{}'),
sanitizeTitle: mock((s: string) => s),
})
safeMockModule('../bridge.ts', {
mockModulePreservingExports('../bridge.ts', {
forwardSessionUpdates: mock(async () => ({
stopReason: 'end_turn' as const,
})),
@@ -105,33 +115,38 @@ safeMockModule('../bridge.ts', {
})),
})
safeMockModule('../../../utils/listSessionsImpl.ts', {
mockModulePreservingExports('../../../utils/listSessionsImpl.ts', {
listSessionsImpl: mock(async () => []),
})
const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6')
safeMockModule('../../../utils/model/model.ts', {
mockModulePreservingExports('../../../utils/model/model.ts', {
getMainLoopModel: mockGetMainLoopModel,
})
safeMockModule('../../../utils/model/modelOptions.ts', {
mockModulePreservingExports('../../../utils/model/modelOptions.ts', {
getModelOptions: mock(() => []),
})
const mockApplySafeEnvVars = mock(() => {})
safeMockModule('../../../utils/managedEnv.ts', {
mockModulePreservingExports('../../../utils/managedEnv.ts', {
applySafeConfigEnvironmentVariables: mockApplySafeEnvVars,
})
const mockGetSettings = mock(() => ({}))
mockModulePreservingExports('../../../utils/settings/settings.ts', {
getSettings_DEPRECATED: mockGetSettings,
})
const mockDeserializeMessages = mock((msgs: unknown[]) => msgs)
safeMockModule('../../../utils/conversationRecovery.ts', {
mockModulePreservingExports('../../../utils/conversationRecovery.ts', {
deserializeMessages: mockDeserializeMessages,
})
const mockGetLastSessionLog = mock(async () => null)
const mockSessionIdExists = mock(() => false)
safeMockModule('../../../utils/sessionStorage.ts', {
mockModulePreservingExports('../../../utils/sessionStorage.ts', {
getLastSessionLog: mockGetLastSessionLog,
sessionIdExists: mockSessionIdExists,
})
@@ -161,7 +176,7 @@ const mockGetCommands = mock(async () => [
},
])
safeMockModule('../../../commands.ts', {
mockModulePreservingExports('../../../commands.ts', {
getCommands: mockGetCommands,
})
@@ -181,16 +196,48 @@ function makeConn() {
} as any
}
function removeBypassMode(session: any) {
session.modes = {
...session.modes,
availableModes: session.modes.availableModes.filter(
(mode: any) => mode.id !== 'bypassPermissions',
),
}
session.appState.toolPermissionContext = {
...session.appState.toolPermissionContext,
isBypassPermissionsModeAvailable: false,
}
}
function restoreEnv(name: string, value: string | undefined) {
if (value === undefined) {
delete process.env[name]
} else {
process.env[name] = value
}
}
// ── Tests ─────────────────────────────────────────────────────────
describe('AcpAgent', () => {
afterAll(() => {
for (const restore of _restores) restore()
})
beforeEach(() => {
delete process.env.ACP_PERMISSION_MODE
delete process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
mockSetModel.mockClear()
mockSubmitMessage.mockReset()
mockSubmitMessage.mockImplementation(async function* (_input: string) {})
mockGetMainLoopModel.mockClear()
mockGetDefaultAppState.mockClear()
mockGetSettings.mockReset()
mockGetSettings.mockImplementation(() => ({}))
;(forwardSessionUpdates as ReturnType<typeof mock>).mockReset()
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementation(
async () => ({ stopReason: 'end_turn' as const }),
)
})
afterEach(() => {
process.chdir(originalCwd)
})
describe('initialize', () => {
@@ -255,6 +302,13 @@ describe('AcpAgent', () => {
expect(r1.sessionId).not.toBe(r2.sessionId)
})
test('does not leave process cwd changed after session creation', async () => {
const cwdBeforeSession = process.cwd()
const agent = new AcpAgent(makeConn())
await agent.newSession({ cwd: '/tmp' } as any)
expect(process.cwd()).toBe(cwdBeforeSession)
})
test('calls getDefaultAppState to build session appState', async () => {
const agent = new AcpAgent(makeConn())
await agent.newSession({ cwd: '/tmp' } as any)
@@ -290,6 +344,99 @@ describe('AcpAgent', () => {
const res = await agent.newSession({ cwd: '/tmp' } as any)
expect(res.sessionId).toBeDefined()
})
test('uses settings permissions.defaultMode when _meta does not provide a mode', async () => {
mockGetSettings.mockImplementationOnce(() => ({
permissions: { defaultMode: 'acceptEdits' },
}))
const agent = new AcpAgent(makeConn())
const res = await agent.newSession({ cwd: '/tmp' } as any)
expect(res.modes?.currentModeId).toBe('acceptEdits')
})
test('uses _meta.permissionMode before settings permissions.defaultMode', async () => {
mockGetSettings.mockImplementationOnce(() => ({
permissions: { defaultMode: 'acceptEdits' },
}))
const agent = new AcpAgent(makeConn())
const res = await agent.newSession({
cwd: '/tmp',
_meta: { permissionMode: 'plan' },
} as any)
expect(res.modes?.currentModeId).toBe('plan')
})
test('rejects _meta.permissionMode bypass without a local ACP bypass gate', async () => {
mockGetSettings.mockImplementationOnce(() => ({
permissions: { defaultMode: 'acceptEdits' },
}))
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
const agent = new AcpAgent(makeConn())
try {
await expect(
agent.newSession({
cwd: '/tmp',
_meta: { permissionMode: 'bypassPermissions' },
} as any),
).rejects.toThrow('Mode not available: bypassPermissions')
expect(consoleErrorSpy).not.toHaveBeenCalled()
} finally {
consoleErrorSpy.mockRestore()
}
})
test('honors _meta.permissionMode bypass with a local ACP bypass gate', async () => {
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
const agent = new AcpAgent(makeConn())
const res = await agent.newSession({
cwd: '/tmp',
_meta: { permissionMode: 'bypassPermissions' },
} as any)
expect(res.modes?.currentModeId).toBe('bypassPermissions')
expect(res.modes?.availableModes.map((mode: any) => mode.id)).toContain(
'bypassPermissions',
)
})
test('falls back to default when settings permissions.defaultMode is invalid', async () => {
mockGetSettings.mockImplementationOnce(() => ({
permissions: { defaultMode: 'invalid-mode' },
}))
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
const agent = new AcpAgent(makeConn())
try {
const res = await agent.newSession({ cwd: '/tmp' } as any)
expect(res.modes?.currentModeId).toBe('default')
expect(consoleErrorSpy).toHaveBeenCalled()
} finally {
consoleErrorSpy.mockRestore()
}
})
test('rejects invalid _meta.permissionMode without falling back to settings', async () => {
mockGetSettings.mockImplementationOnce(() => ({
permissions: { defaultMode: 'acceptEdits' },
}))
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
const agent = new AcpAgent(makeConn())
try {
await expect(
agent.newSession({
cwd: '/tmp',
_meta: { permissionMode: 'invalid-mode' },
} as any),
).rejects.toThrow('Invalid _meta.permissionMode: invalid-mode')
expect(consoleErrorSpy).not.toHaveBeenCalled()
} finally {
consoleErrorSpy.mockRestore()
}
})
})
describe('prompt', () => {
@@ -375,7 +522,7 @@ describe('AcpAgent', () => {
expect(res2.stopReason).toBe('end_turn')
})
test('returns end_turn on unexpected error', async () => {
test('propagates unexpected prompt errors', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
;(
@@ -383,16 +530,13 @@ describe('AcpAgent', () => {
).mockImplementationOnce(async () => {
throw new Error('unexpected')
})
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
try {
const res = await agent.prompt({
await expect(
agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'hello' }],
} as any)
expect(res.stopReason).toBe('end_turn')
} finally {
errorSpy.mockRestore()
}
} as any),
).rejects.toThrow('unexpected')
})
test('returns usage from forwardSessionUpdates', async () => {
@@ -676,15 +820,28 @@ describe('AcpAgent', () => {
).rejects.toThrow('Session not found')
})
test('availableModes includes bypassPermissions when not root', async () => {
test('availableModes excludes bypassPermissions without a local ACP bypass gate', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
const session = agent.sessions.get(sessionId)
const modeIds = session?.modes.availableModes.map((m: any) => m.id)
expect(modeIds).toContain('bypassPermissions')
expect(modeIds).not.toContain('bypassPermissions')
})
test('can switch to bypassPermissions mode', async () => {
test('rejects bypassPermissions without a local ACP bypass gate', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
await expect(
agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any),
).rejects.toThrow('Mode not available')
const session = agent.sessions.get(sessionId)
expect(session?.modes.currentModeId).toBe('default')
expect(session?.appState.toolPermissionContext.mode).toBe('default')
})
test('can switch to bypassPermissions mode with a local ACP bypass gate', async () => {
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
await agent.setSessionMode({
@@ -697,6 +854,21 @@ describe('AcpAgent', () => {
'bypassPermissions',
)
})
test('rejects bypassPermissions when the session does not expose it', async () => {
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1'
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
const session = agent.sessions.get(sessionId)
removeBypassMode(session)
await expect(
agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any),
).rejects.toThrow('Mode not available')
expect(session?.modes.currentModeId).toBe('default')
expect(session?.appState.toolPermissionContext.mode).toBe('default')
})
})
describe('setSessionConfigOption', () => {
@@ -723,6 +895,24 @@ describe('AcpAgent', () => {
} as any),
).rejects.toThrow('Invalid value')
})
test('rejects unavailable mode config values', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
const session = agent.sessions.get(sessionId)
removeBypassMode(session)
await expect(
agent.setSessionConfigOption({
sessionId,
configId: 'mode',
value: 'bypassPermissions',
} as any),
).rejects.toThrow('Mode not available')
expect(session?.modes.currentModeId).toBe('default')
expect(session?.appState.toolPermissionContext.mode).toBe('default')
})
})
describe('prompt queueing', () => {
@@ -758,6 +948,94 @@ describe('AcpAgent', () => {
expect(r2.stopReason).toBe('end_turn')
})
test('drains 1000 queued prompts in FIFO order without sorting the pending map', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
let resolveFirst!: () => void
;(
forwardSessionUpdates as ReturnType<typeof mock>
).mockImplementationOnce(
() =>
new Promise<{ stopReason: string }>(resolve => {
resolveFirst = () => resolve({ stopReason: 'end_turn' })
}),
)
const first = agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'first' }],
} as any)
const queued = Array.from({ length: 1000 }, (_, index) =>
agent.prompt({
sessionId,
prompt: [{ type: 'text', text: `queued-${index}` }],
} as any),
)
resolveFirst()
const results = await Promise.all([first, ...queued])
expect(results.every(result => result.stopReason === 'end_turn')).toBe(true)
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
'first',
...Array.from({ length: 1000 }, (_, index) => `queued-${index}`),
])
})
test('keeps promptRunning true while handing off to the next queued prompt', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
let resolveFirst!: () => void
let resolveSecond!: () => void
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
() =>
new Promise<{ stopReason: string }>(resolve => {
resolveFirst = () => resolve({ stopReason: 'end_turn' })
}),
)
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
() =>
new Promise<{ stopReason: string }>(resolve => {
resolveSecond = () => resolve({ stopReason: 'end_turn' })
}),
)
const p1 = agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'first' }],
} as any)
const p2 = agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'second' }],
} as any)
const p3 = p1.then(() =>
agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'third' }],
} as any),
)
resolveFirst()
await p1
const session = agent.sessions.get(sessionId)
expect(session?.promptRunning).toBe(true)
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
'first',
'second',
])
resolveSecond()
await Promise.all([p2, p3])
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
'first',
'second',
'third',
])
})
test('queued prompts return cancelled when session is cancelled', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
@@ -787,6 +1065,46 @@ describe('AcpAgent', () => {
expect(r1.stopReason).toBe('cancelled')
expect(r2.stopReason).toBe('cancelled')
})
test('queued prompt does not clear active prompt cancellation', async () => {
const agent = new AcpAgent(makeConn())
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
let resolveFirst!: () => void
;(
forwardSessionUpdates as ReturnType<typeof mock>
).mockImplementationOnce(
() =>
new Promise<{ stopReason: string }>(resolve => {
resolveFirst = () => resolve({ stopReason: 'end_turn' })
}),
)
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
{ stopReason: 'end_turn' },
)
const p1 = agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'first' }],
} as any)
await agent.cancel({ sessionId } as any)
const p2 = agent.prompt({
sessionId,
prompt: [{ type: 'text', text: 'second' }],
} as any)
resolveFirst()
const [r1, r2] = await Promise.all([p1, p2])
expect(r1.stopReason).toBe('cancelled')
expect(r2.stopReason).toBe('end_turn')
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
'first',
'second',
])
})
})
describe('commands', () => {

View File

@@ -5,6 +5,7 @@ import {
toolUpdateFromEditToolResponse,
forwardSessionUpdates,
} from '../bridge.js'
import { promptToQueryInput } from '../promptConversion.js'
import { markdownEscape, toDisplayPath } from '../utils.js'
import type { AgentSideConnection, ToolKind } from '@agentclientprotocol/sdk'
import type { SDKMessage } from '../../../entrypoints/sdk/coreTypes.js'
@@ -336,6 +337,20 @@ describe('toolInfoFromToolUse', () => {
})
})
describe('promptToQueryInput', () => {
test('uses shared prompt conversion for resource links', () => {
expect(
promptToQueryInput([
{
type: 'resource_link',
name: 'Spec',
uri: 'file:///tmp/spec.md',
} as any,
]),
).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md')
})
})
// ── toolUpdateFromToolResult ───────────────────────────────────────
describe('toolUpdateFromToolResult', () => {
@@ -709,6 +724,87 @@ describe('forwardSessionUpdates', () => {
expect(result.stopReason).toBe('cancelled')
})
test('cleans abort listeners when sdkMessages.next wins repeatedly', async () => {
const ac = new AbortController()
let abortListeners = 0
const add = ac.signal.addEventListener.bind(ac.signal)
const remove = ac.signal.removeEventListener.bind(ac.signal)
const addEventListener: AbortSignal['addEventListener'] = (
type: keyof AbortSignalEventMap,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
) => {
if (type === 'abort') abortListeners++
return add(type, listener, options)
}
const removeEventListener: AbortSignal['removeEventListener'] = (
type: keyof AbortSignalEventMap,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
) => {
if (type === 'abort') abortListeners--
return remove(type, listener, options)
}
ac.signal.addEventListener = addEventListener
ac.signal.removeEventListener = removeEventListener
const msgs = Array.from({ length: 10_000 }, () => ({
type: 'system',
subtype: 'api_retry',
}) as unknown as SDKMessage)
const result = await forwardSessionUpdates(
's1',
makeStream(msgs),
makeConn(),
ac.signal,
{},
)
expect(result.stopReason).toBe('end_turn')
expect(abortListeners).toBe(0)
})
test('cleans abort listeners when abort wins the race', async () => {
const ac = new AbortController()
let abortListeners = 0
const add = ac.signal.addEventListener.bind(ac.signal)
const remove = ac.signal.removeEventListener.bind(ac.signal)
ac.signal.addEventListener = (
type: keyof AbortSignalEventMap,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
) => {
if (type === 'abort') abortListeners++
return add(type, listener, options)
}
ac.signal.removeEventListener = (
type: keyof AbortSignalEventMap,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
) => {
if (type === 'abort') abortListeners--
return remove(type, listener, options)
}
async function* never(): AsyncGenerator<SDKMessage, void, unknown> {
await new Promise(() => {})
}
const resultPromise = forwardSessionUpdates(
's1',
never(),
makeConn(),
ac.signal,
{},
)
ac.abort()
const result = await resultPromise
expect(result.stopReason).toBe('cancelled')
expect(abortListeners).toBe(0)
})
test('forwards assistant text message as agent_message_chunk', async () => {
const conn = makeConn()
const msgs: SDKMessage[] = [
@@ -765,6 +861,7 @@ describe('forwardSessionUpdates', () => {
test('forwards tool_use block as tool_call', async () => {
const conn = makeConn()
const input = { command: 'ls' }
const msgs: SDKMessage[] = [
{
type: 'assistant',
@@ -774,7 +871,7 @@ describe('forwardSessionUpdates', () => {
type: 'tool_use',
id: 'tu_1',
name: 'Bash',
input: { command: 'ls' },
input,
},
],
role: 'assistant',
@@ -794,6 +891,8 @@ describe('forwardSessionUpdates', () => {
expect(update.toolCallId).toBe('tu_1')
expect(update.kind).toBe('execute' as ToolKind)
expect(update.status).toBe('pending')
expect(update.rawInput).toEqual(input)
expect(update.rawInput).not.toBe(input)
})
test('sends usage_update on result message with correct tokens', async () => {

View File

@@ -1,144 +1,306 @@
import { describe, expect, test, mock } from 'bun:test'
import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
import type { AgentSideConnection } from '@agentclientprotocol/sdk'
import type { Tool as ToolType } from '../../../Tool.js'
import type { Tool as ToolType, ToolUseContext } from '../../../Tool.js'
import type { AssistantMessage } from '../../../types/message.js'
// ── Inline re-implementation of createAcpCanUseTool for isolated testing ──
// We cannot import the real permissions.js because agent.test.ts mocks it globally.
// Instead we re-implement the core logic here, using our own mocked bridge.js.
const askDecision = {
behavior: 'ask',
message: 'approval required',
decisionReason: { type: 'mode', mode: 'default' },
} as const
function createAcpCanUseTool(
conn: AgentSideConnection,
sessionId: string,
getCurrentMode: () => string,
): any {
return async (
tool: { name: string },
input: Record<string, unknown>,
_context: any,
_assistantMessage: any,
toolUseID: string,
): Promise<{ behavior: string; message?: string; updatedInput?: Record<string, unknown> }> => {
if (getCurrentMode() === 'bypassPermissions') {
return { behavior: 'allow', updatedInput: input }
}
const hasPermissionsMock = mock(async (): Promise<unknown> => askDecision)
const toolInfoMock = mock(() => ({
title: 'Bash',
kind: 'execute',
content: [],
locations: [],
}))
const TOOL_KIND_MAP: Record<string, string> = {
Read: 'read', Edit: 'edit', Write: 'edit',
Bash: 'execute', Glob: 'search', Grep: 'search',
WebFetch: 'fetch', WebSearch: 'fetch',
}
const toolCall = {
toolCallId: toolUseID,
title: tool.name,
kind: TOOL_KIND_MAP[tool.name] ?? 'other',
status: 'pending',
rawInput: input,
}
const options = [
{ kind: 'allow_always', name: 'Always Allow', optionId: 'allow_always' },
{ kind: 'allow_once', name: 'Allow', optionId: 'allow' },
{ kind: 'reject_once', name: 'Reject', optionId: 'reject' },
]
try {
const response = await (conn as any).requestPermission({ sessionId, toolCall, options })
if (response.outcome.outcome === 'cancelled') {
return { behavior: 'deny', message: 'Permission request cancelled by client' }
}
if (response.outcome.outcome === 'selected' && response.outcome.optionId !== undefined) {
const optionId = response.outcome.optionId
if (optionId === 'allow' || optionId === 'allow_always') {
return { behavior: 'allow', updatedInput: input }
}
}
return { behavior: 'deny', message: 'Permission denied by client' }
} catch {
return { behavior: 'deny', message: 'Permission request failed' }
}
}
const permissionsModuleSnapshot = {
...(require('../../../utils/permissions/permissions.ts') as Record<
string,
unknown
>),
}
const bridgeModuleSnapshot = {
...(require('../bridge.ts') as Record<string, unknown>),
}
function makeConn(permissionResponse: Record<string, unknown>) {
afterAll(() => {
mock.module('../bridge.js', () => bridgeModuleSnapshot)
mock.module('../../../utils/permissions/permissions.js', () => permissionsModuleSnapshot)
})
mock.module('../../../utils/permissions/permissions.js', () => ({
...permissionsModuleSnapshot,
hasPermissionsToUseTool: hasPermissionsMock,
}))
mock.module('../bridge.js', () => ({
...bridgeModuleSnapshot,
toolInfoFromToolUse: toolInfoMock,
}))
const { createAcpCanUseTool } = await import('../permissions.js')
type PermissionResponse =
| { outcome: { outcome: 'cancelled' } }
| { outcome: { outcome: 'selected'; optionId: string } }
function makeConn(
permissionResponse: PermissionResponse = {
outcome: { outcome: 'selected', optionId: 'allow' },
},
): AgentSideConnection {
return {
requestPermission: mock(async () => permissionResponse),
sessionUpdate: mock(async () => {}),
} as unknown as AgentSideConnection
}
function makeTool(name: string) {
function makeTool(name: string): ToolType {
return { name } as unknown as ToolType
}
const dummyContext = {} as Record<string, unknown>
const dummyMsg = {} as Record<string, unknown>
const dummyContext = {} as unknown as ToolUseContext
const dummyMsg = {} as unknown as AssistantMessage
describe('createAcpCanUseTool', () => {
test('returns allow when client selects allow option', async () => {
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(makeTool('Bash'), { command: 'ls' }, dummyContext as any, dummyMsg as any, 'tu_1')
expect(result.behavior).toBe('allow')
beforeEach(() => {
hasPermissionsMock.mockReset()
hasPermissionsMock.mockResolvedValue(askDecision)
toolInfoMock.mockClear()
})
test('returns deny when client selects reject option', async () => {
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'reject' } })
test('returns pipeline allow without client delegation', async () => {
const conn = makeConn()
const input = { command: 'ls' }
hasPermissionsMock.mockResolvedValueOnce({
behavior: 'allow',
updatedInput: input,
})
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(makeTool('Bash'), {}, dummyContext as any, dummyMsg as any, 'tu_2')
const result = await canUseTool(
makeTool('Bash'),
input,
dummyContext,
dummyMsg,
'tu_1',
)
expect(result).toEqual({ behavior: 'allow', updatedInput: input })
expect(
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
).toHaveLength(0)
})
test('returns pipeline deny without client delegation', async () => {
const conn = makeConn()
hasPermissionsMock.mockResolvedValueOnce({
behavior: 'deny',
message: 'blocked by policy',
decisionReason: { type: 'other', reason: 'blocked by policy' },
})
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(
makeTool('Bash'),
{ command: 'rm -rf /' },
dummyContext,
dummyMsg,
'tu_2',
)
expect(result.behavior).toBe('deny')
expect(
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
).toHaveLength(0)
})
test('returns deny when client cancels', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
test('denies when the permission pipeline throws', async () => {
const conn = makeConn()
hasPermissionsMock.mockRejectedValueOnce(new Error('rule loader failed'))
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
try {
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(
makeTool('Edit'),
{ file_path: '/tmp/x' },
dummyContext,
dummyMsg,
'tu_3',
)
expect(result).toMatchObject({
behavior: 'deny',
decisionReason: { type: 'other', reason: 'Permission pipeline failed' },
toolUseID: 'tu_3',
})
if (result.behavior !== 'deny') {
throw new Error('expected deny result')
}
expect(result.message).toBe('Permission pipeline failed')
expect(
(conn.requestPermission as ReturnType<typeof mock>).mock.calls,
).toHaveLength(0)
} finally {
errorSpy.mockRestore()
}
})
test('delegates ask decisions to the ACP client', async () => {
const conn = makeConn({
outcome: { outcome: 'selected', optionId: 'allow' },
})
const input = { command: 'ls' }
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(makeTool('Read'), { file_path: '/tmp/x' }, dummyContext as any, dummyMsg as any, 'tu_3')
expect(result.behavior).toBe('deny')
const result = await canUseTool(
makeTool('Bash'),
input,
dummyContext,
dummyMsg,
'tu_4',
)
expect(result).toEqual({ behavior: 'allow', updatedInput: input })
const callArgs = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
expect(callArgs.sessionId).toBe('sess-1')
expect((callArgs.toolCall as Record<string, unknown>).toolCallId).toBe(
'tu_4',
)
})
test('returns deny when requestPermission throws', async () => {
test('returns deny when the client rejects or cancels', async () => {
const rejectConn = makeConn({
outcome: { outcome: 'selected', optionId: 'reject' },
})
const cancelConn = makeConn({ outcome: { outcome: 'cancelled' } })
const rejectResult = await createAcpCanUseTool(
rejectConn,
'sess-1',
() => 'default',
)(makeTool('Bash'), {}, dummyContext, dummyMsg, 'tu_5')
const cancelResult = await createAcpCanUseTool(
cancelConn,
'sess-1',
() => 'default',
)(makeTool('Read'), {}, dummyContext, dummyMsg, 'tu_6')
expect(rejectResult.behavior).toBe('deny')
expect(cancelResult.behavior).toBe('deny')
})
test('returns deny when client permission request fails', async () => {
const conn = {
requestPermission: mock(async () => { throw new Error('connection lost') }),
requestPermission: mock(async () => {
throw new Error('connection lost')
}),
sessionUpdate: mock(async () => {}),
} as unknown as AgentSideConnection
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
const result = await canUseTool(makeTool('Edit'), {}, dummyContext as any, dummyMsg as any, 'tu_4')
expect(result.behavior).toBe('deny')
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
try {
const result = await createAcpCanUseTool(conn, 'sess-1', () => 'default')(
makeTool('Write'),
{},
dummyContext,
dummyMsg,
'tu_7',
)
expect(result.behavior).toBe('deny')
if (result.behavior !== 'deny') {
throw new Error('expected deny result')
}
expect(result.message).toContain('Permission request failed')
} finally {
errorSpy.mockRestore()
}
})
test('passes correct sessionId and toolCallId to requestPermission', async () => {
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
const canUseTool = createAcpCanUseTool(conn, 'my-session', () => 'default')
await canUseTool(makeTool('Glob'), { pattern: '**/*.ts' }, dummyContext as any, dummyMsg as any, 'tu_99')
const rpMock = conn.requestPermission as ReturnType<typeof mock>
expect(rpMock.mock.calls.length).toBeGreaterThan(0)
const callArgs = rpMock.mock.calls[0][0] as Record<string, unknown>
expect(callArgs.sessionId).toBe('my-session')
expect((callArgs.toolCall as Record<string, unknown>).toolCallId).toBe('tu_99')
})
test('returns allow in bypassPermissions mode without calling requestPermission', async () => {
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
const canUseTool = createAcpCanUseTool(conn, 'sess-bypass', () => 'bypassPermissions')
const result = await canUseTool(makeTool('Bash'), { command: 'rm -rf /' }, dummyContext as any, dummyMsg as any, 'tu_bp')
expect(result.behavior).toBe('allow')
const rpMock = conn.requestPermission as ReturnType<typeof mock>
expect(rpMock.mock.calls).toHaveLength(0)
})
test('options include allow_always, allow_once and reject_once', async () => {
test('options include allow always, allow once, and reject once', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
const canUseTool = createAcpCanUseTool(conn, 'sess-3', () => 'default')
await canUseTool(makeTool('Write'), {}, dummyContext as any, dummyMsg as any, 'tu_6')
const rpMock = conn.requestPermission as ReturnType<typeof mock>
expect(rpMock.mock.calls.length).toBeGreaterThan(0)
const { options } = rpMock.mock.calls[0][0] as Record<string, unknown>
await canUseTool(makeTool('Write'), {}, dummyContext, dummyMsg, 'tu_8')
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
const opts = options as Array<Record<string, unknown>>
expect(opts.find((o) => o.kind === 'allow_always')).toBeTruthy()
expect(opts.find((o) => o.kind === 'allow_once')).toBeTruthy()
expect(opts.find((o) => o.kind === 'reject_once')).toBeTruthy()
expect(opts.find(option => option.kind === 'allow_always')).toBeTruthy()
expect(opts.find(option => option.kind === 'allow_once')).toBeTruthy()
expect(opts.find(option => option.kind === 'reject_once')).toBeTruthy()
})
test('ExitPlanMode omits bypass option when the session does not expose it', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
const canUseTool = createAcpCanUseTool(
conn,
'sess-4',
() => 'plan',
undefined,
undefined,
undefined,
() => false,
)
await canUseTool(makeTool('ExitPlanMode'), {}, dummyContext, dummyMsg, 'tu_9')
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
const opts = options as Array<Record<string, unknown>>
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(false)
})
test('ExitPlanMode includes bypass option when the session exposes it', async () => {
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
const canUseTool = createAcpCanUseTool(
conn,
'sess-5',
() => 'plan',
undefined,
undefined,
undefined,
() => true,
)
await canUseTool(makeTool('ExitPlanMode'), {}, dummyContext, dummyMsg, 'tu_10')
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
.calls[0][0] as Record<string, unknown>
const opts = options as Array<Record<string, unknown>>
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(true)
})
test('ExitPlanMode rejects a bypass selection that was not offered', async () => {
const conn = makeConn({
outcome: { outcome: 'selected', optionId: 'bypassPermissions' },
})
const onModeChange = mock(() => {})
const canUseTool = createAcpCanUseTool(
conn,
'sess-6',
() => 'plan',
undefined,
undefined,
onModeChange,
() => false,
)
const result = await canUseTool(
makeTool('ExitPlanMode'),
{},
dummyContext,
dummyMsg,
'tu_11',
)
expect(result.behavior).toBe('deny')
expect(onModeChange).not.toHaveBeenCalled()
expect((conn.sessionUpdate as ReturnType<typeof mock>).mock.calls).toHaveLength(0)
})
})

View File

@@ -0,0 +1,28 @@
import { describe, expect, test } from 'bun:test'
import { promptToQueryInput } from '../promptConversion.js'
describe('promptToQueryInput', () => {
test('converts text and embedded text resources', () => {
expect(
promptToQueryInput([
{ type: 'text', text: 'hello' },
{
type: 'resource',
resource: { text: 'resource body' },
} as any,
]),
).toBe('hello\nresource body')
})
test('renders resource_link as plain metadata instead of markdown link', () => {
expect(
promptToQueryInput([
{
type: 'resource_link',
name: 'Spec',
uri: 'file:///tmp/spec.md',
} as any,
]),
).toBe('Resource link: name=Spec, uri=file:///tmp/spec.md')
})
})

View File

@@ -33,7 +33,6 @@ import type {
SetSessionModelResponse,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
ContentBlock,
ClientCapabilities,
SessionModeState,
SessionModelState,
@@ -63,31 +62,39 @@ import {
computeSessionFingerprint,
sanitizeTitle,
} from './utils.js'
import { promptToQueryInput } from './promptConversion.js'
import {
listSessionsImpl,
} from '../../utils/listSessionsImpl.js'
import { getMainLoopModel } from '../../utils/model/model.js'
import { getModelOptions } from '../../utils/model/modelOptions.js'
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
// ── Session state ─────────────────────────────────────────────────
type AcpSession = {
queryEngine: QueryEngine
cancelled: boolean
cancelGeneration: number
cwd: string
sessionFingerprint: string
modes: SessionModeState
models: SessionModelState
configOptions: SessionConfigOption[]
promptRunning: boolean
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>
nextPendingOrder: number
pendingMessages: Map<string, PendingPrompt>
pendingQueue: string[]
pendingQueueHead: number
toolUseCache: ToolUseCache
clientCapabilities?: ClientCapabilities
appState: AppState
commands: Command[]
}
type PendingPrompt = {
resolve: (cancelled: boolean) => void
}
// ── Agent class ───────────────────────────────────────────────────
export class AcpAgent implements Agent {
@@ -157,7 +164,9 @@ export class AcpAgent implements Agent {
// ── newSession ────────────────────────────────────────────────
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
return this.createSession(params)
const result = await this.createSession(params)
this.scheduleAvailableCommandsUpdate(result.sessionId)
return result
}
// ── resumeSession ──────────────────────────────────────────────
@@ -166,9 +175,7 @@ export class AcpAgent implements Agent {
params: ResumeSessionRequest,
): Promise<ResumeSessionResponse> {
const result = await this.getOrCreateSession(params)
setTimeout(() => {
this.sendAvailableCommandsUpdate(params.sessionId)
}, 0)
this.scheduleAvailableCommandsUpdate(result.sessionId)
return result
}
@@ -176,9 +183,7 @@ export class AcpAgent implements Agent {
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
const result = await this.getOrCreateSession(params)
setTimeout(() => {
this.sendAvailableCommandsUpdate(params.sessionId)
}, 0)
this.scheduleAvailableCommandsUpdate(result.sessionId)
return result
}
@@ -216,9 +221,7 @@ export class AcpAgent implements Agent {
_meta: params._meta,
},
)
setTimeout(() => {
this.sendAvailableCommandsUpdate(response.sessionId)
}, 0)
this.scheduleAvailableCommandsUpdate(response.sessionId)
return response
}
@@ -243,9 +246,6 @@ export class AcpAgent implements Agent {
throw new Error(`Session ${params.sessionId} not found`)
}
// Reset cancelled state at the start of each prompt (matches official impl)
session.cancelled = false
// Extract text/image content from the prompt
const promptInput = promptToQueryInput(params.prompt)
@@ -253,18 +253,27 @@ export class AcpAgent implements Agent {
return { stopReason: 'end_turn' }
}
const promptCancelGeneration = session.cancelGeneration
// Handle prompt queuing — if a prompt is already running, queue this one
if (session.promptRunning) {
const order = session.nextPendingOrder++
const promptUuid = randomUUID()
const cancelled = await new Promise<boolean>((resolve) => {
session.pendingMessages.set(promptUuid, { resolve, order })
session.pendingQueue.push(promptUuid)
session.pendingMessages.set(promptUuid, { resolve })
})
if (cancelled) {
return { stopReason: 'cancelled' }
}
}
if (session.cancelGeneration !== promptCancelGeneration) {
return { stopReason: 'cancelled' }
}
// Reset cancellation only when this prompt is about to run. Queued prompts
// must not clear the cancellation state for the active prompt.
session.cancelled = false
session.promptRunning = true
try {
@@ -324,19 +333,15 @@ export class AcpAgent implements Agent {
)
}
console.error('[ACP] prompt error:', err)
return { stopReason: 'end_turn' }
throw err
} finally {
session.promptRunning = false
// Resolve next pending prompt if any
if (session.pendingMessages.size > 0) {
const next = [...session.pendingMessages.entries()].sort(
(a, b) => a[1].order - b[1].order,
)[0]
if (next) {
next[1].resolve(false)
session.pendingMessages.delete(next[0])
}
const nextPrompt = popNextPendingPrompt(session)
if (nextPrompt) {
session.promptRunning = true
nextPrompt.resolve(false)
} else {
session.promptRunning = false
}
}
}
@@ -349,12 +354,15 @@ export class AcpAgent implements Agent {
// Set cancelled flag — checked by prompt() loop to break out
session.cancelled = true
session.cancelGeneration += 1
// Cancel any queued prompts
for (const [, pending] of session.pendingMessages) {
pending.resolve(true)
}
session.pendingMessages.clear()
session.pendingQueue = []
session.pendingQueueHead = 0
// Interrupt the query engine to abort the current API call
session.queryEngine.interrupt()
@@ -379,7 +387,7 @@ export class AcpAgent implements Agent {
async unstable_setSessionModel(
params: SetSessionModelRequest,
): Promise<SetSessionModelResponse | void> {
): Promise<SetSessionModelResponse> {
const session = this.sessions.get(params.sessionId)
if (!session) {
throw new Error('Session not found')
@@ -388,6 +396,7 @@ export class AcpAgent implements Agent {
// parseUserSpecifiedModel() to resolve aliases (e.g. "sonnet" → "glm-5.1-turbo")
session.queryEngine.setModel(params.modelId)
await this.updateConfigOption(params.sessionId, 'model', params.modelId)
return {}
}
// ── setSessionConfigOption ──────────────────────────────────────
@@ -449,23 +458,32 @@ export class AcpAgent implements Agent {
// Set CWD for the session
setOriginalCwd(cwd)
const previousProcessCwd = process.cwd()
let processCwdChanged = false
try {
process.chdir(cwd)
processCwdChanged = true
} catch {
// CWD may not exist yet; best-effort
}
try {
// Build tools with a permissive permission context.
const permissionContext = getEmptyToolPermissionContext()
const tools: Tools = getTools(permissionContext)
// Parse permission mode from _meta (passed by RCS/acp-link) or fall back to settings
const metaPermissionMode = (params._meta as Record<string, unknown> | null | undefined)?.permissionMode as string | undefined
console.log('[ACP Agent] Session create _meta:', JSON.stringify(params._meta), 'extracted mode:', metaPermissionMode)
const permissionMode = resolvePermissionMode(
metaPermissionMode ?? this.getSetting<string>('permissions.defaultMode'),
// Parse permission mode from _meta (passed by RCS/acp-link) or settings.
const meta = params._meta as Record<string, unknown> | null | undefined
const hasMetaPermissionMode = hasOwnField(meta, 'permissionMode')
const metaPermissionMode = hasMetaPermissionMode
? meta?.permissionMode
: undefined
const settingsPermissionMode = this.getSetting<string>('permissions.defaultMode')
const permissionMode = resolveSessionPermissionMode(
metaPermissionMode,
hasMetaPermissionMode,
settingsPermissionMode,
)
console.log('[ACP Agent] Resolved permissionMode:', permissionMode)
// Create the permission bridge canUseTool function
const canUseTool = createAcpCanUseTool(
@@ -475,15 +493,15 @@ export class AcpAgent implements Agent {
this.clientCapabilities,
cwd,
(modeId: string) => { this.applySessionMode(sessionId, modeId) },
() => this.sessions.get(sessionId)?.appState
.toolPermissionContext.isBypassPermissionsModeAvailable ?? false,
)
// Parse MCP servers from ACP params
// MCP server config is handled separately in the tools system
// Check if bypass permissions is available (not running as root unless in sandbox)
const isBypassAvailable =
(typeof process.geteuid === 'function' ? process.geteuid() !== 0 : true) ||
!!process.env.IS_SANDBOX
// ACP clients can expose bypass only when both the process and local config allow it.
const isBypassAvailable = isAcpBypassPermissionModeAvailable(settingsPermissionMode)
// Create a mutable AppState for the session
const appState: AppState = {
@@ -519,7 +537,7 @@ export class AcpAgent implements Agent {
const queryEngine = new QueryEngine(engineConfig)
// Build modes — bypassPermissions only available when not running as root (or in sandbox)
// Build modes — bypassPermissions is opt-in for ACP clients.
const availableModes = [
{ id: 'default', name: 'Default', description: 'Standard behavior, prompts for dangerous operations' },
{ id: 'acceptEdits', name: 'Accept Edits', description: 'Auto-accept file edit operations' },
@@ -557,13 +575,15 @@ export class AcpAgent implements Agent {
const session: AcpSession = {
queryEngine,
cancelled: false,
cancelGeneration: 0,
cwd,
modes,
models,
configOptions,
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
pendingQueue: [],
pendingQueueHead: 0,
toolUseCache: {},
clientCapabilities: this.clientCapabilities,
appState,
@@ -576,17 +596,17 @@ export class AcpAgent implements Agent {
this.sessions.set(sessionId, session)
// Send available commands after session creation
setTimeout(() => {
this.sendAvailableCommandsUpdate(sessionId)
}, 0)
return {
sessionId,
models,
modes,
configOptions,
}
} finally {
if (processCwdChanged) {
process.chdir(previousProcessCwd)
}
}
}
private async getOrCreateSession(params: {
@@ -672,12 +692,22 @@ export class AcpAgent implements Agent {
}
private applySessionMode(sessionId: string, modeId: string): void {
const validModes = ['auto', 'default', 'acceptEdits', 'bypassPermissions', 'dontAsk', 'plan']
if (!validModes.includes(modeId)) {
if (!isPermissionMode(modeId)) {
throw new Error(`Invalid mode: ${modeId}`)
}
const session = this.sessions.get(sessionId)
if (session) {
if (
modeId === 'bypassPermissions' &&
!session.appState.toolPermissionContext.isBypassPermissionsModeAvailable
) {
throw new Error(`Mode not available: ${modeId}`)
}
const isAvailable = session.modes.availableModes.some(mode => mode.id === modeId)
if (!isAvailable) {
throw new Error(`Mode not available: ${modeId}`)
}
session.modes = { ...session.modes, currentModeId: modeId }
// Sync mode to appState so the permission pipeline sees the correct mode
session.appState.toolPermissionContext = {
@@ -750,38 +780,160 @@ export class AcpAgent implements Agent {
})
}
private scheduleAvailableCommandsUpdate(sessionId: string): void {
setTimeout(() => {
void this.sendAvailableCommandsUpdate(sessionId).catch(err => {
console.error('[ACP] Failed to send available commands update:', err)
})
}, 0)
}
/** Read a setting from Claude config (simplified — no file watching) */
private getSetting<T>(key: string): T | undefined {
// Simplified: read from environment or return undefined
// In a full implementation, this would read from settings.json
return undefined as T | undefined
const settings = getSettings_DEPRECATED() as Record<string, unknown>
const value = key.split('.').reduce<unknown>((current, segment) => {
if (!current || typeof current !== 'object') return undefined
return (current as Record<string, unknown>)[segment]
}, settings)
return value as T | undefined
}
}
// ── Helpers ────────────────────────────────────────────────────────
/** Extract prompt text from ACP ContentBlock array for QueryEngine input */
function promptToQueryInput(
prompt: Array<ContentBlock> | undefined,
): string {
if (!prompt || prompt.length === 0) return ''
const permissionModeIds: readonly PermissionMode[] = [
'auto',
'default',
'acceptEdits',
'bypassPermissions',
'dontAsk',
'plan',
]
const parts: string[] = []
for (const block of prompt) {
const b = block as Record<string, unknown>
if (b.type === 'text') {
parts.push(b.text as string)
} else if (b.type === 'resource_link') {
parts.push(`[${b.name ?? ''}](${b.uri as string})`)
} else if (b.type === 'resource') {
const resource = b.resource as Record<string, unknown> | undefined
if (resource && 'text' in resource) {
parts.push(resource.text as string)
}
function isPermissionMode(modeId: string): modeId is PermissionMode {
return (permissionModeIds as readonly string[]).includes(modeId)
}
function resolveSessionPermissionMode(
metaMode: unknown,
hasMetaMode: boolean,
settingsMode: unknown,
): PermissionMode {
if (hasMetaMode) {
const metaResolved = resolveRequiredPermissionMode(
metaMode,
'_meta.permissionMode',
)
if (
metaResolved === 'bypassPermissions' &&
!isAcpBypassPermissionModeAvailable(settingsMode)
) {
throw new Error(
'Mode not available: bypassPermissions requires a local ACP bypass opt-in.',
)
}
// Ignore image and other types for text-based prompt
return metaResolved
}
const settingsResolved = resolveConfiguredPermissionMode(settingsMode)
return settingsResolved ?? 'default'
}
function resolveRequiredPermissionMode(
mode: unknown,
source: string,
): PermissionMode {
if (mode === undefined || mode === null) {
throw new Error(`Invalid ${source}: expected a string.`)
}
return resolvePermissionMode(mode, source) as PermissionMode
}
function resolveConfiguredPermissionMode(mode: unknown): PermissionMode | undefined {
if (mode === undefined || mode === null) return undefined
try {
return resolvePermissionMode(mode, 'permissions.defaultMode') as PermissionMode
} catch (err: unknown) {
const reason = err instanceof Error ? err.message : String(err)
console.error('[ACP] Invalid permissions.defaultMode, using default:', reason)
return undefined
}
}
function hasOwnField(
value: Record<string, unknown> | null | undefined,
key: string,
): boolean {
return !!value && Object.hasOwn(value, key)
}
function isAcpBypassPermissionModeAvailable(settingsMode?: unknown): boolean {
return (
isProcessBypassPermissionModeAvailable() &&
(isAcpBypassLocallyEnabled() || isSettingsBypassPermissionMode(settingsMode))
)
}
function isProcessBypassPermissionModeAvailable(): boolean {
if (process.env.IS_SANDBOX) return true
if (typeof process.geteuid === 'function') return process.geteuid() !== 0
if (typeof process.getuid === 'function') return process.getuid() !== 0
return true
}
function isAcpBypassLocallyEnabled(): boolean {
return (
process.env.ACP_PERMISSION_MODE === 'bypassPermissions' ||
isTruthyEnv(process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS)
)
}
function isSettingsBypassPermissionMode(settingsMode: unknown): boolean {
try {
return resolvePermissionMode(settingsMode) === 'bypassPermissions'
} catch {
return false
}
}
function isTruthyEnv(value: string | undefined): boolean {
return value === '1' || value?.toLowerCase() === 'true'
}
function popNextPendingPrompt(session: AcpSession): PendingPrompt | undefined {
while (session.pendingQueueHead < session.pendingQueue.length) {
const nextId = session.pendingQueue[session.pendingQueueHead++]
if (!nextId) continue
const next = session.pendingMessages.get(nextId)
if (!next) continue
session.pendingMessages.delete(nextId)
compactPendingQueue(session)
return next
}
compactPendingQueue(session)
return undefined
}
function compactPendingQueue(session: AcpSession): void {
if (session.pendingQueueHead === 0) return
if (session.pendingQueueHead >= session.pendingQueue.length) {
session.pendingQueue = []
session.pendingQueueHead = 0
return
}
if (
session.pendingQueueHead > 1024 &&
session.pendingQueueHead * 2 > session.pendingQueue.length
) {
session.pendingQueue = session.pendingQueue.slice(session.pendingQueueHead)
session.pendingQueueHead = 0
}
return parts.join('\n')
}
function buildConfigOptions(

View File

@@ -514,28 +514,25 @@ export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
return result
}
// ── Prompt conversion ─────────────────────────────────────────────
function nextSdkMessageOrAbort(
sdkMessages: AsyncGenerator<SDKMessage, void, unknown>,
abortSignal: AbortSignal,
): Promise<IteratorResult<SDKMessage, void>> {
if (abortSignal.aborted) {
return Promise.resolve({ done: true, value: undefined })
}
/**
* Convert ACP PromptRequest content blocks into content for QueryEngine.
*/
export function promptToQueryContent(
prompt: Array<ContentBlock> | undefined,
): string {
if (!prompt) return ''
return prompt
.map((block) => {
const b = block as Record<string, unknown>
if (b.type === 'text') return b.text as string
if (b.type === 'resource_link') return `[${b.name ?? ''}](${b.uri as string})`
if (b.type === 'resource') {
const resource = b.resource as Record<string, unknown> | undefined
if (resource && 'text' in resource) return resource.text as string
}
return ''
})
.filter(Boolean)
.join('\n')
let abortHandler: (() => void) | undefined
const abortPromise = new Promise<IteratorResult<SDKMessage, void>>((resolve) => {
abortHandler = () => resolve({ done: true, value: undefined })
abortSignal.addEventListener('abort', abortHandler, { once: true })
})
return Promise.race([sdkMessages.next(), abortPromise]).finally(() => {
if (abortHandler) {
abortSignal.removeEventListener('abort', abortHandler)
}
})
}
// ── Main forwarding function ──────────────────────────────────────
@@ -573,17 +570,7 @@ export async function forwardSessionUpdates(
// Race the next message against the abort signal so we unblock
// immediately when cancelled, even if the generator is waiting for
// a slow API response.
const nextResult = await Promise.race([
sdkMessages.next(),
new Promise<IteratorResult<SDKMessage, void>>((resolve) => {
if (abortSignal.aborted) {
resolve({ done: true, value: undefined })
return
}
const handler = () => resolve({ done: true, value: undefined })
abortSignal.addEventListener('abort', handler, { once: true })
}),
])
const nextResult = await nextSdkMessageOrAbort(sdkMessages, abortSignal)
if (nextResult.done || abortSignal.aborted) break
const msg = nextResult.value
@@ -1059,12 +1046,7 @@ function toAcpNotifications(
}
} else {
// Regular tool call
let rawInput: Record<string, unknown> | undefined
try {
rawInput = JSON.parse(JSON.stringify(toolInput ?? {}))
} catch {
// Ignore parse failures
}
const rawInput = toolInput ? { ...toolInput } : {}
if (alreadyCached) {
// Second encounter — send as tool_call_update

View File

@@ -25,14 +25,6 @@ import type { AssistantMessage } from '../../types/message.js'
import { hasPermissionsToUseTool } from '../../utils/permissions/permissions.js'
import { toolInfoFromToolUse } from './bridge.js'
const IS_ROOT =
typeof process.geteuid === 'function'
? process.geteuid() === 0
: typeof process.getuid === 'function'
? process.getuid() === 0
: false
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX
/**
* Creates a CanUseToolFn that delegates permission decisions to the
* ACP client via requestPermission().
@@ -44,6 +36,7 @@ export function createAcpCanUseTool(
clientCapabilities?: ClientCapabilities,
cwd?: string,
onModeChange?: (modeId: string) => void,
isBypassModeAvailable?: () => boolean,
): CanUseToolFn {
return async (
tool: ToolType,
@@ -59,6 +52,7 @@ export function createAcpCanUseTool(
if (tool.name === 'ExitPlanMode') {
return handleExitPlanMode(
conn, sessionId, toolUseID, input, supportsTerminalOutput, cwd, onModeChange,
isBypassModeAvailable,
)
}
@@ -84,8 +78,16 @@ export function createAcpCanUseTool(
}
// behavior === 'ask' → fall through to client delegation
} catch (err) {
// If the pipeline fails, fall through to client delegation
console.error('[ACP Permissions] Pipeline error, falling back to client:', err)
console.error('[ACP Permissions] Pipeline error:', err)
return {
behavior: 'deny',
message: 'Permission pipeline failed',
decisionReason: {
type: 'other',
reason: 'Permission pipeline failed',
},
toolUseID,
}
}
// ── Delegate to ACP client for interactive permission decision ──
@@ -144,7 +146,8 @@ export function createAcpCanUseTool(
message: 'Permission denied by client',
decisionReason: { type: 'mode', mode: 'default' },
}
} catch {
} catch (err) {
console.error('[ACP Permissions] Client request error:', err)
return {
behavior: 'deny',
message: 'Permission request failed',
@@ -162,6 +165,7 @@ async function handleExitPlanMode(
supportsTerminalOutput: boolean,
cwd?: string,
onModeChange?: (modeId: string) => void,
isBypassModeAvailable?: () => boolean,
): Promise<PermissionAllowDecision | PermissionDenyDecision> {
const options: Array<PermissionOption> = [
{ kind: 'allow_always', name: 'Yes, and use "auto" mode', optionId: 'auto' },
@@ -169,7 +173,7 @@ async function handleExitPlanMode(
{ kind: 'allow_once', name: 'Yes, and manually approve edits', optionId: 'default' },
{ kind: 'reject_once', name: 'No, keep planning', optionId: 'plan' },
]
if (ALLOW_BYPASS) {
if (isBypassModeAvailable?.() === true) {
options.unshift({
kind: 'allow_always',
name: 'Yes, and bypass permissions',
@@ -211,11 +215,15 @@ async function handleExitPlanMode(
response.outcome.optionId !== undefined
) {
const selectedOption = response.outcome.optionId
const isOfferedOption = options.some(option => option.optionId === selectedOption)
if (
selectedOption === 'default' ||
selectedOption === 'acceptEdits' ||
selectedOption === 'auto' ||
selectedOption === 'bypassPermissions'
isOfferedOption &&
(
selectedOption === 'default' ||
selectedOption === 'acceptEdits' ||
selectedOption === 'auto' ||
selectedOption === 'bypassPermissions'
)
) {
// Sync mode to session state and appState
onModeChange?.(selectedOption)

View File

@@ -0,0 +1,40 @@
import type { ContentBlock } from '@agentclientprotocol/sdk'
export function promptToQueryInput(
prompt: Array<ContentBlock> | undefined,
): string {
if (!prompt || prompt.length === 0) return ''
const parts: string[] = []
for (const block of prompt) {
const b = block as Record<string, unknown>
if (b.type === 'text') {
parts.push(String(b.text ?? ''))
} else if (b.type === 'resource_link') {
const name = typeof b.name === 'string' ? b.name : undefined
const uri = typeof b.uri === 'string' ? b.uri : undefined
// Keep resource links as metadata, not markdown links, so models do not
// infer user-visible click targets or silently rewrite URI semantics.
parts.push(formatResourceLink(name, uri))
} else if (b.type === 'resource') {
const resource = b.resource as Record<string, unknown> | undefined
if (resource && typeof resource.text === 'string') {
parts.push(resource.text)
}
}
}
return parts.filter(part => part.length > 0).join('\n')
}
function formatResourceLink(
name: string | undefined,
uri: string | undefined,
): string {
const details: string[] = []
if (name && name.length > 0) details.push(`name=${name}`)
if (uri && uri.length > 0) details.push(`uri=${uri}`)
return details.length > 0
? `Resource link: ${details.join(', ')}`
: 'Resource link'
}

View File

@@ -121,30 +121,31 @@ const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
bypass: 'bypassPermissions',
}
export function resolvePermissionMode(defaultMode?: unknown): PermissionMode {
export function resolvePermissionMode(
defaultMode?: unknown,
source = 'permissions.defaultMode',
): PermissionMode {
if (defaultMode === undefined) {
return 'default'
}
if (typeof defaultMode !== 'string') {
throw new Error('Invalid permissions.defaultMode: expected a string.')
throw new Error(`Invalid ${source}: expected a string.`)
}
const normalized = defaultMode.trim().toLowerCase()
if (normalized === '') {
throw new Error(
'Invalid permissions.defaultMode: expected a non-empty string.',
)
throw new Error(`Invalid ${source}: expected a non-empty string.`)
}
const mapped = PERMISSION_MODE_ALIASES[normalized]
if (!mapped) {
throw new Error(`Invalid permissions.defaultMode: ${defaultMode}.`)
throw new Error(`Invalid ${source}: ${defaultMode}.`)
}
if (mapped === 'bypassPermissions' && !ALLOW_BYPASS) {
throw new Error(
'Invalid permissions.defaultMode: bypassPermissions is not available when running as root.',
`Invalid ${source}: bypassPermissions is not available when running as root.`,
)
}

View File

@@ -20,7 +20,7 @@ type LogEventMetadata = { [key: string]: boolean | number | undefined }
const DATADOG_GATE_NAME = 'tengu_log_datadog_events'
// Module-level gate state - starts undefined, initialized during startup
let isDatadogGateEnabled: boolean | undefined = undefined
let isDatadogGateEnabled: boolean | undefined
/**
* Check if Datadog tracking is enabled.

View File

@@ -1544,11 +1544,11 @@ async function* queryModel(
let start = Date.now()
let attemptNumber = 0
const attemptStartTimes: number[] = []
let stream: Stream<BetaRawMessageStreamEvent> | undefined = undefined
let streamRequestId: string | null | undefined = undefined
let clientRequestId: string | undefined = undefined
let stream: Stream<BetaRawMessageStreamEvent> | undefined
let streamRequestId: string | null | undefined
let clientRequestId: string | undefined
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins -- Response is available in Node 18+ and is used by the SDK
let streamResponse: Response | undefined = undefined
let streamResponse: Response | undefined
// Release all stream resources to prevent native memory leaks.
// The Response object holds native TLS/socket buffers that live outside the
@@ -1634,7 +1634,7 @@ async function* queryModel(
const hasThinking =
thinkingConfig.type !== 'disabled' &&
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_THINKING)
let thinking: BetaMessageStreamParams['thinking'] | undefined = undefined
let thinking: BetaMessageStreamParams['thinking'] | undefined
// IMPORTANT: Do not change the adaptive-vs-budget thinking selection below
// without notifying the model launch DRI and research. This is a sensitive
@@ -1804,7 +1804,7 @@ async function* queryModel(
const newMessages: AssistantMessage[] = []
let ttftMs = 0
let partialMessage: BetaMessage | undefined = undefined
let partialMessage: BetaMessage | undefined
const contentBlocks: (BetaContentBlock | ConnectorTextBlock)[] = []
let usage: NonNullableUsage = EMPTY_USAGE
let costUSD = 0
@@ -1812,8 +1812,8 @@ async function* queryModel(
let didFallBackToNonStreaming = false
let fallbackMessage: AssistantMessage | undefined
let maxOutputTokens = 0
let responseHeaders: globalThis.Headers | undefined = undefined
let research: unknown = undefined
let responseHeaders: globalThis.Headers | undefined
let research: unknown
let isFastModeRequest = isFastMode // Keep separate state as it may change if falling back
let isAdvisorInProgress = false

View File

@@ -113,7 +113,7 @@ async function retryWithBackoff<T>(
)
if (attempt < MAX_RETRIES) {
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1)
const delayMs = BASE_DELAY_MS * 2 ** (attempt - 1)
logDebug(`Retrying ${operation} in ${delayMs}ms...`)
await sleep(delayMs)
}

View File

@@ -103,7 +103,7 @@ export async function* queryModelGemini(
const adaptedStream = adaptGeminiStreamToAnthropic(stream, geminiModel)
const contentBlocks: Record<number, any> = {}
const collectedMessages: AssistantMessage[] = []
let partialMessage: any = undefined
let partialMessage: any
let ttftMs = 0
const start = Date.now()

View File

@@ -95,7 +95,7 @@ export async function* queryModelGrok(
const contentBlocks: Record<number, any> = {}
const collectedMessages: AssistantMessage[] = []
let partialMessage: any = undefined
let partialMessage: any
let usage = {
input_tokens: 0,
output_tokens: 0,

View File

@@ -175,7 +175,7 @@ async function appendSessionLogImpl(
return false
}
const delayMs = Math.min(BASE_DELAY_MS * Math.pow(2, attempt - 1), 8000)
const delayMs = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), 8000)
logForDebugging(
`Remote persistence attempt ${attempt}/${MAX_RETRIES} failed, retrying in ${delayMs}ms…`,
)

View File

@@ -540,7 +540,7 @@ export function getRetryDelay(
}
const baseDelay = Math.min(
BASE_DELAY_MS * Math.pow(2, attempt - 1),
BASE_DELAY_MS * 2 ** (attempt - 1),
maxDelayMs,
)
const jitter = Math.random() * 0.25 * baseDelay

View File

@@ -1,12 +1,31 @@
import { homedir } from 'os'
const MAX_OUTPUT_LENGTH = 500
const REDACTED_FILE_TOOLS = new Set(['FileReadTool', 'FileWriteTool', 'FileEditTool'])
const REDACTED_SHELL_TOOLS = new Set(['BashTool', 'PowerShellTool'])
const SENSITIVE_OUTPUT_TOOLS = new Set(['ConfigTool', 'MCPTool'])
const HOME_DIR_PATTERN = new RegExp(
(process.env.HOME ?? '/Users/[^/]+').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
'g',
)
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function homePathPatterns(): string[] {
const homes = new Set<string>()
for (const value of [process.env.HOME, process.env.USERPROFILE, homedir()]) {
if (value) {
homes.add(value)
homes.add(value.replace(/\\/g, '/'))
}
}
return [
...Array.from(homes, escapeRegExp),
'/Users/[^/\\\\]+',
'[A-Za-z]:[/\\\\]Users[/\\\\][^/\\\\]+',
]
}
const HOME_DIR_PATTERN = new RegExp(`(?:${homePathPatterns().join('|')})`, 'g')
const SENSITIVE_KEY_PATTERN = /(?:api_?key|token|secret|password|credential|auth_header)/i

View File

@@ -387,7 +387,7 @@ export function createLSPServerInstance(
isContentModifiedError &&
attempt < MAX_RETRIES_FOR_TRANSIENT_ERRORS
) {
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt)
const delay = RETRY_BASE_DELAY_MS * 2 ** attempt
logForDebugging(
`LSP request '${method}' to '${name}' got ContentModified error, ` +
`retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES_FOR_TRANSIENT_ERRORS})…`,

View File

@@ -1,6 +1,10 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { expandEnvVarsInString } from "../envExpansion";
const ENV_OPEN = "$" + "{";
const ENV_CLOSE = "}";
const envExpr = (value: string): string => `${ENV_OPEN}${value}${ENV_CLOSE}`;
describe("expandEnvVarsInString", () => {
// Save and restore env vars touched by tests
const savedEnv: Record<string, string | undefined> = {};
@@ -33,21 +37,21 @@ describe("expandEnvVarsInString", () => {
test("expands a single env var that exists", () => {
process.env.TEST_HOME = "/home/user";
const result = expandEnvVarsInString("${TEST_HOME}");
const result = expandEnvVarsInString(envExpr("TEST_HOME"));
expect(result.expanded).toBe("/home/user");
expect(result.missingVars).toEqual([]);
});
test("returns original placeholder and tracks missing var when not found", () => {
delete process.env.MISSING;
const result = expandEnvVarsInString("${MISSING}");
expect(result.expanded).toBe("${MISSING}");
const result = expandEnvVarsInString(envExpr("MISSING"));
expect(result.expanded).toBe(envExpr("MISSING"));
expect(result.missingVars).toEqual(["MISSING"]);
});
test("uses default value when var is missing and default is provided", () => {
delete process.env.MISSING;
const result = expandEnvVarsInString("${MISSING:-fallback}");
const result = expandEnvVarsInString(envExpr("MISSING:-fallback"));
expect(result.expanded).toBe("fallback");
expect(result.missingVars).toEqual([]);
});
@@ -55,7 +59,9 @@ describe("expandEnvVarsInString", () => {
test("expands multiple vars", () => {
process.env.TEST_A = "hello";
process.env.TEST_B = "world";
const result = expandEnvVarsInString("${TEST_A}/${TEST_B}");
const result = expandEnvVarsInString(
`${envExpr("TEST_A")}/${envExpr("TEST_B")}`,
);
expect(result.expanded).toBe("hello/world");
expect(result.missingVars).toEqual([]);
});
@@ -63,8 +69,10 @@ describe("expandEnvVarsInString", () => {
test("handles mix of found and missing vars", () => {
process.env.TEST_FOUND = "yes";
delete process.env.MISSING;
const result = expandEnvVarsInString("${TEST_FOUND}-${MISSING}");
expect(result.expanded).toBe("yes-${MISSING}");
const result = expandEnvVarsInString(
`${envExpr("TEST_FOUND")}-${envExpr("MISSING")}`,
);
expect(result.expanded).toBe(`yes-${envExpr("MISSING")}`);
expect(result.missingVars).toEqual(["MISSING"]);
});
@@ -76,14 +84,14 @@ describe("expandEnvVarsInString", () => {
test("expands empty env var value", () => {
process.env.TEST_EMPTY = "";
const result = expandEnvVarsInString("${TEST_EMPTY}");
const result = expandEnvVarsInString(envExpr("TEST_EMPTY"));
expect(result.expanded).toBe("");
expect(result.missingVars).toEqual([]);
});
test("prefers env var value over default when var exists", () => {
process.env.TEST_X = "real";
const result = expandEnvVarsInString("${TEST_X:-default}");
const result = expandEnvVarsInString(envExpr("TEST_X:-default"));
expect(result.expanded).toBe("real");
expect(result.missingVars).toEqual([]);
});
@@ -91,7 +99,7 @@ describe("expandEnvVarsInString", () => {
test("handles default value containing colons", () => {
// split(':-', 2) means only the first :- is the delimiter
delete process.env.TEST_X;
const result = expandEnvVarsInString("${TEST_X:-value:-with:-colons}");
const result = expandEnvVarsInString(envExpr("TEST_X:-value:-with:-colons"));
// The default is "value" because split(':-', 2) gives ["TEST_X", "value"]
// Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives:
// ["TEST_X", "value"] because limit=2 stops at 2 pieces
@@ -103,11 +111,12 @@ describe("expandEnvVarsInString", () => {
// ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first })
// so varName would be "${VAR" which won't be found in env
delete process.env.VAR;
const result = expandEnvVarsInString("${${VAR}}");
const nestedExpr = `${ENV_OPEN}${envExpr("VAR")}${ENV_CLOSE}`;
const result = expandEnvVarsInString(nestedExpr);
// The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR"
// That env var won't exist, so it stays as "${${VAR}" + remaining "}"
expect(result.missingVars).toEqual(["${VAR"]);
expect(result.expanded).toBe("${${VAR}}");
expect(result.missingVars).toEqual([`${ENV_OPEN}VAR`]);
expect(result.expanded).toBe(nestedExpr);
});
test("handles empty string input", () => {
@@ -118,14 +127,14 @@ describe("expandEnvVarsInString", () => {
test("handles var surrounded by text", () => {
process.env.TEST_A = "middle";
const result = expandEnvVarsInString("before-${TEST_A}-after");
const result = expandEnvVarsInString(`before-${envExpr("TEST_A")}-after`);
expect(result.expanded).toBe("before-middle-after");
expect(result.missingVars).toEqual([]);
});
test("handles default value that is empty string", () => {
delete process.env.MISSING;
const result = expandEnvVarsInString("${MISSING:-}");
const result = expandEnvVarsInString(envExpr("MISSING:-"));
expect(result.expanded).toBe("");
expect(result.missingVars).toEqual([]);
});

View File

@@ -2346,7 +2346,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider {
return undefined
}
const delayMs = 1000 * Math.pow(2, attempt - 1) // 1s, 2s, 4s
const delayMs = 1000 * 2 ** (attempt - 1) // 1s, 2s, 4s
logMCPDebug(
this.serverName,
`Token refresh failed, retrying in ${delayMs}ms (attempt ${attempt}/${MAX_ATTEMPTS})`,

View File

@@ -57,8 +57,6 @@ export async function findAvailablePort(): Promise<number> {
})
return port
} catch {
// Port in use, try another random port
continue
}
}

View File

@@ -14,7 +14,7 @@ type RegistryResponse = {
// URLs stripped of query string and trailing slash — matches the normalization
// done by getLoggingSafeMcpBaseUrl so direct Set.has() lookup works.
let officialUrls: Set<string> | undefined = undefined
let officialUrls: Set<string> | undefined
function normalizeUrl(url: string): string | undefined {
try {

View File

@@ -445,7 +445,7 @@ export function useManageMCPConnections(
// Schedule next retry with exponential backoff
const backoffMs = Math.min(
INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1),
INITIAL_BACKOFF_MS * 2 ** (attempt - 1),
MAX_BACKOFF_MS,
)
logMCPDebug(

View File

@@ -353,7 +353,6 @@ export async function installPluginOp(
}
} catch (error) {
logError(toError(error))
continue
}
}
}

View File

@@ -310,7 +310,6 @@ export async function countTokensViaHaikuFallback(
? betas.filter(b => VERTEX_COUNT_TOKENS_ALLOWED_BETAS.has(b))
: betas
// biome-ignore lint/plugin: token counting needs specialized parameters (thinking, betas) that sideQuery doesn't support
const apiStart = Date.now()
const langfuseTrace = isLangfuseEnabled()
? createTrace({

View File

@@ -98,7 +98,6 @@ export function AppStateProvider({
),
}))
}
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-only effect
}, [])
// Listen for external settings changes and sync to AppState.

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
const sliceAnsi = (await import("../sliceAnsi")).default;
const ESC = "\x1b";
describe("sliceAnsi", () => {
test("plain text slice identical to String.slice", () => {
@@ -29,7 +30,7 @@ describe("sliceAnsi", () => {
expect(result).toContain("\x1b[31m");
expect(result).toContain("hello");
// undoAnsiCodes uses specific close codes (e.g. \x1b[39m for foreground)
expect(result).toMatch(new RegExp("\\x1b\\[\\d+m"));
expect(result).toMatch(new RegExp(`${ESC}\\[\\d+m`));
// The result should start with open code and end with a close code
const withoutText = result.replace("hello", "");
// Should have at least one open and one close code
@@ -80,6 +81,6 @@ describe("sliceAnsi", () => {
// undoAnsiCodes uses \x1b[39m for foreground reset, not \x1b[0m
expect(result).toContain("b");
expect(result).toContain("\x1b[31m");
expect(result).toMatch(new RegExp("\\x1b\\[\\d+m.*\\x1b\\[\\d+m")); // open + close codes
expect(result).toMatch(new RegExp(`${ESC}\\[\\d+m.*${ESC}\\[\\d+m`)); // open + close codes
});
});

View File

@@ -22,7 +22,8 @@ describe("escapeRegExp", () => {
});
test("escapes all special chars", () => {
expect(escapeRegExp("^${}()|[]\\.*+?")).toBe(
const allSpecialChars = "^$" + "{}()|[]\\.*+?";
expect(escapeRegExp(allSpecialChars)).toBe(
"\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\\\.\\*\\+\\?"
);
});

View File

@@ -172,10 +172,10 @@ function fillBackground(px: Uint8Array, bg: AnsiColor): void {
// not the classic VGA dither pattern. Alpha-blend toward background for the
// same look.
const SHADE_ALPHA: Record<number, number> = {
0x2591: 0.25, // ░
0x2592: 0.5, // ▒
0x2593: 0.75, // ▓
0x2588: 1.0, // █
9617: 0.25, // ░
9618: 0.5, // ▒
9619: 0.75, // ▓
9608: 1.0, // █
}
function blitShade(

View File

@@ -721,7 +721,6 @@ function collectCommands(
child.type === 'select' ||
child.type === ';'
) {
continue // structural tokens
} else if (child.type === 'command_substitution') {
// `for i in $(seq 1 3)` — inner cmd IS extracted and rule-checked.
const err = collectCommandSubstitution(child, commands, varScope)
@@ -1793,7 +1792,6 @@ function walkVariableAssignment(
// node. Without this case it falls through to walkArgument below
// → tooComplex on unknown type `+=`.
isAppend = child.type === '+='
continue
} else if (child.type === 'command_substitution') {
// $() as the variable's value. The output becomes a STRING stored in
// the variable — it's NOT a positional argument (no path/flag concern).

View File

@@ -853,15 +853,11 @@ function parseStatements(P: ParseState, terminator: string | null): TsNode[] {
after.value === 'done' ||
after.value === 'esac'))
) {
// Trailing separator — don't include it at program level unless
// there's content after. But at inner levels we keep it.
continue
}
} else if (sep.type === 'NEWLINE') {
if (P.L.heredocs.length > 0) {
scanHeredocBodies(P)
}
continue
} else {
restoreLex(P.L, save2)
}

View File

@@ -257,7 +257,6 @@ export function hasShellQuoteSingleQuoteBug(command: string): boolean {
return true
}
}
continue
}
}

View File

@@ -67,7 +67,7 @@ export function shouldEnableClaudeInChrome(chromeFlag?: boolean): boolean {
return false
}
let shouldAutoEnable: boolean | undefined = undefined
let shouldAutoEnable: boolean | undefined
export function shouldAutoEnableClaudeInChrome(): boolean {
if (shouldAutoEnable !== undefined) {

View File

@@ -276,7 +276,7 @@ async function animatedMove(
const totalFrames = Math.floor(durationSec * frameRate)
for (let frame = 1; frame <= totalFrames; frame++) {
const t = frame / totalFrames
const eased = 1 - Math.pow(1 - t, 3)
const eased = 1 - (1 - t) ** 3
await input.moveMouse(
Math.round(start.x + deltaX * eased),
Math.round(start.y + deltaY * eased),

View File

@@ -42,7 +42,6 @@ export async function handleDeepLinkUri(uri: string): Promise<number> {
action = parseDeepLink(uri)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error(`Deep link error: ${message}`)
return 1
}
@@ -65,7 +64,6 @@ export async function handleDeepLinkUri(uri: string): Promise<number> {
lastFetchMs: lastFetch?.getTime(),
})
if (!launched) {
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error(
'Failed to open a terminal. Make sure a supported terminal emulator is installed.',
)

View File

@@ -454,7 +454,7 @@ export const NodeFsOperations: FsOperations = {
readSync(fsPath, options) {
using _ = slowLogging`fs.readSync(${fsPath}, ${options.length} bytes)`
let fd: number | undefined = undefined
let fd: number | undefined
try {
fd = fs.openSync(fsPath, 'r')
const buffer = Buffer.alloc(options.length)

View File

@@ -1235,7 +1235,6 @@ async function execCommandHook(
child.stdin.destroy()
}
})
continue
}
} catch {
// Not JSON, just a normal line
@@ -1750,7 +1749,7 @@ export async function getMatchingHooks(
// If you change the criteria below, then you must change
// src/utils/hooks/hooksConfigManager.ts as well.
let matchQuery: string | undefined = undefined
let matchQuery: string | undefined
switch (hookInput.hook_event_name) {
case 'PreToolUse':
case 'PostToolUse':

View File

@@ -121,8 +121,6 @@ async function detectPluginDirectories(ideName: string): Promise<string[]> {
}
}
} catch {
// Ignore errors from stale IDE directories (ENOENT, EACCES, etc.)
continue
}
}

View File

@@ -958,7 +958,6 @@ export function reorderMessagesInUI(
})
}
toolUseGroups.get(toolUseID)!.postHooks.push(message)
continue
}
}
@@ -2293,7 +2292,6 @@ export function normalizeMessagesForAPI(
result[i] = mergeAssistantMessages(msg, normalizedMessage)
return
}
continue
}
}

View File

@@ -224,7 +224,7 @@ async function tryWithVersionLock(
if (attempts < maxAttempts) {
// Wait before retrying with exponential backoff
const timeout = Math.min(
minTimeout * Math.pow(2, attempts - 1),
minTimeout * 2 ** (attempts - 1),
maxTimeout,
)
await sleep(timeout)

View File

@@ -1188,7 +1188,7 @@ export async function migrateFromEnabledPlugins(): Promise<void> {
let installPath: string
let version = 'unknown'
let gitCommitSha: string | undefined = undefined
let gitCommitSha: string | undefined
if (typeof entry.source === 'string') {
installPath = join(marketplaceInstallLocation, entry.source)

View File

@@ -66,7 +66,7 @@ export const RETRY_CONFIG = {
function calculateNextRetryDelay(retryCount: number): number {
const delay =
RETRY_CONFIG.INITIAL_DELAY_MS *
Math.pow(RETRY_CONFIG.BACKOFF_MULTIPLIER, retryCount)
RETRY_CONFIG.BACKOFF_MULTIPLIER ** retryCount
return Math.min(delay, RETRY_CONFIG.MAX_DELAY_MS)
}

View File

@@ -518,7 +518,7 @@ export const countFilesRoundedRg = memoize(
if (count === 0) return 0
const magnitude = Math.floor(Math.log10(count))
const power = Math.pow(10, magnitude)
const power = 10 ** magnitude
// Round to nearest power of 10
// e.g., 8 -> 10, 42 -> 100, 350 -> 100, 750 -> 1000

View File

@@ -10,7 +10,7 @@ import { getPlatform } from './platform.js'
// undefined = not yet loaded (need to check disk)
// null = checked disk, no files exist (don't check again)
// string = loaded and cached (use cached value)
let sessionEnvScript: string | null | undefined = undefined
let sessionEnvScript: string | null | undefined
export async function getSessionEnvDirPath(): Promise<string> {
const sessionEnvDir = join(

View File

@@ -4901,7 +4901,6 @@ function extractFirstPromptFromChunk(chunk: string): string {
return result
}
} catch {
continue
}
}
// Session started with a slash command but had no subsequent real message —

View File

@@ -194,7 +194,6 @@ export function extractFirstPromptFromHead(head: string): string {
return result
}
} catch {
continue
}
}
if (commandFallback) return commandFallback

View File

@@ -152,9 +152,7 @@ function slowLoggingExternal(): Disposable {
* using _ = slowLogging`structuredClone(${value})`
* const result = structuredClone(value)
*/
export const slowLogging: {
(strings: TemplateStringsArray, ...values: unknown[]): Disposable
} = feature('SLOW_OPERATION_LOGGING') ? slowLoggingAnt : slowLoggingExternal
export const slowLogging: (strings: TemplateStringsArray, ...values: unknown[]) => Disposable = feature('SLOW_OPERATION_LOGGING') ? slowLoggingAnt : slowLoggingExternal
// --- Wrapped operations ---

View File

@@ -54,42 +54,37 @@ function extractFirstFrame(output: string): string {
/**
* Renders a React node to a string with ANSI escape codes (for terminal output).
*/
export function renderToAnsiString(
export async function renderToAnsiString(
node: React.ReactNode,
columns?: number,
): Promise<string> {
return new Promise(async resolve => {
let output = ''
let output = ''
// Capture all writes. Set .columns so Ink (ink.tsx:~165) picks up a
// chosen width instead of PassThrough's undefined → 80 fallback —
// useful for rendering at terminal width for file dumps that should
// match what the user sees on screen.
const stream = new PassThrough()
if (columns !== undefined) {
;(stream as unknown as { columns: number }).columns = columns
}
stream.on('data', chunk => {
output += chunk.toString()
})
// Render the component wrapped in RenderOnceAndExit
// Non-TTY stdout (PassThrough) gives full-frame output instead of diffs
const instance = await render(
<RenderOnceAndExit>{node}</RenderOnceAndExit>,
{
stdout: stream as unknown as NodeJS.WriteStream,
patchConsole: false,
},
)
// Wait for the component to exit naturally
await instance.waitUntilExit()
// Extract only the first frame's content to avoid duplication
// (Ink outputs multiple frames in non-TTY mode)
await resolve(extractFirstFrame(output))
// Capture all writes. Set .columns so Ink (ink.tsx:~165) picks up a
// chosen width instead of PassThrough's undefined → 80 fallback —
// useful for rendering at terminal width for file dumps that should
// match what the user sees on screen.
const stream = new PassThrough()
if (columns !== undefined) {
;(stream as unknown as { columns: number }).columns = columns
}
stream.on('data', chunk => {
output += chunk.toString()
})
// Render the component wrapped in RenderOnceAndExit
// Non-TTY stdout (PassThrough) gives full-frame output instead of diffs
const instance = await render(<RenderOnceAndExit>{node}</RenderOnceAndExit>, {
stdout: stream as unknown as NodeJS.WriteStream,
patchConsole: false,
})
// Wait for the component to exit naturally
await instance.waitUntilExit()
// Extract only the first frame's content to avoid duplication
// (Ink outputs multiple frames in non-TTY mode)
return extractFirstFrame(output)
}
/**

View File

@@ -48,7 +48,7 @@ export function getSkillUsageScore(skillName: string): number {
// Recency decay: halve score every 7 days
const daysSinceUse = (Date.now() - usage.lastUsedAt) / (1000 * 60 * 60 * 24)
const recencyFactor = Math.pow(0.5, daysSinceUse / 7)
const recencyFactor = 0.5 ** (daysSinceUse / 7)
// Minimum recency factor of 0.1 to avoid completely dropping old but heavily used skills
return usage.usageCount * Math.max(recencyFactor, 0.1)