Compare commits

..

4 Commits

Author SHA1 Message Date
claude-code-best
644ae33c19 fix: 修复 Bun 的 polyfill 问题 2026-04-15 10:04:39 +08:00
claude-code-best
2273a0bcfe docs: 修复链接 2026-04-14 21:19:36 +08:00
claude-code-best
b80483c23e fix: 修复 node 下 ws 没打包问题 2026-04-14 21:19:25 +08:00
claude-code-best
8442aaadd2 fix: 修复 n 快捷键导致关闭的问题 2026-04-14 21:18:36 +08:00
1668 changed files with 62980 additions and 63348 deletions

2
.gitignore vendored
View File

@@ -15,7 +15,7 @@ src/utils/vendor/
.claude/ .claude/
.codex/ .codex/
.omx/ .omx/
.docs/task/
# Binary / screenshot files (root only) # Binary / screenshot files (root only)
/*.png /*.png
*.bmp *.bmp

View File

@@ -1,204 +0,0 @@
# KAIROS — 永不关机的 Claude
> 源码位置:`src/assistant/`、`src/proactive/`、`src/services/autoDream/`
> 编译开关:`feature('KAIROS')`、`feature('KAIROS_BRIEF')`、`feature('KAIROS_CHANNELS')`
> 远程开关GrowthBook `tengu_kairos`
关掉终端 Claude 还在运行的持久助手模式。KAIROS 是 Claude Code 中最复杂的隐藏功能之一。
---
## 核心概念
KAIROS 让 Claude 从"一次性对话工具"变成"持久运行的 AI 助手"
- 关闭终端后 Claude 仍在后台运行
- 每天自动写日志
- 晚上自动"做梦"整理记忆
- 没人说话时自己找活干
- 命令超 15 秒自动丢后台
---
## 激活流程
定义在 `src/main.tsx`(约第 1054-1092 行),需要通过五层检查:
```
1. feature('KAIROS') ← 编译时 flag
2. settings.assistant: true ← .claude/settings.json
3. 目录信任状态检查 ← 防恶意仓库劫持
4. tengu_kairos ← GrowthBook 远程开关
5. setKairosActive(true) ← 全局状态激活
```
`--assistant` CLI 参数可跳过远程开关检查(用于 Agent SDK daemon 模式)。
全局状态存储在 `src/bootstrap/state.ts`
- `kairosActive: boolean`(默认 `false`
- `getKairosActive()` / `setKairosActive(true)`
---
## 跨会话持久运行
### 会话恢复
`src/utils/conversationRecovery.ts` 中使用 `feature('KAIROS')` 条件导入 `BriefTool``SendUserFileTool`。在反序列化会话时识别这些工具的结果为"终端工具结果",判断 turn 是正常完成还是被中断。
### 持久 Cron 任务
关键在 `.claude/scheduled_tasks.json`。标记为 `permanent: true` 的任务不受 7 天自动过期限制:
- `catch-up`:恢复中断的工作
- `morning-checkin`:每日早间签到
- `dream`:记忆整合
### 会话历史 API
`src/assistant/sessionHistory.ts` 通过 OAuth API 加载远程会话历史,使用 `v1/sessions/{sessionId}/events` 端点,支持分页拉取。
---
## 做梦机制Dream
KAIROS 最精巧的子系统——后台运行的子代理,将分散的会话记忆整合为持久的结构化知识。
### 触发条件(三层门控,由廉到贵)
定义在 `src/services/autoDream/autoDream.ts`
```
1. 时间门控:距上次整合超过 24 小时minHours
2. 会话门控:至少 5 个新会话minSessions
3. 锁门控:没有其他进程正在整合
```
阈值通过 GrowthBook `tengu_onyx_plover` 远程配置动态控制。
### 四阶段整合流程
定义在 `src/services/autoDream/consolidationPrompt.ts`
| 阶段 | 动作 |
|------|------|
| **Orient** | 列出记忆目录、读取 `MEMORY.md` 索引、浏览已有主题文件 |
| **Gather** | 从每日日志、已有记忆、JSONL transcript 中搜集新信号 |
| **Consolidate** | 合并新信号到主题文件,转换相对日期为绝对日期,删除过时事实 |
| **Prune** | 更新 `MEMORY.md` 索引,保持在行数和大小限制内 |
### 锁机制
`src/services/autoDream/consolidationLock.ts`
- 使用 `.consolidate-lock` 文件
- 文件 mtime = `lastConsolidatedAt`
- 文件内容 = 持有者 PID
- 支持 PID 存活检查1 小时超时)
- double-write 后 re-read 验证防竞争
### 每日日志
路径由 `src/memdir/paths.ts``getAutoMemDailyLogPath()` 计算:
```
<autoMemPath>/logs/YYYY/MM/YYYY-MM-DD.md
```
### UI 呈现
- Footer pill 标签显示 **"dreaming"**
- `src/components/tasks/DreamDetailDialog.tsx` 提供专门的详情对话框
- 支持查看实时进度和手动中止
- `Shift+Down` 打开后台任务对话框
---
## 主动模式Proactive Mode
没人说话时 Claude 自己找活干。
### 核心状态
`src/proactive/index.ts` 维护三个状态:
| 状态 | 说明 |
|------|------|
| `active` | 是否激活 |
| `paused` | 是否暂停(用户按 Esc 取消时暂停,下次输入恢复) |
| `contextBlocked` | API 错误时阻塞 tick防止 tick-error-tick 死循环 |
### 激活方式
- `--proactive` CLI 参数
- `CLAUDE_CODE_PROACTIVE` 环境变量
-`feature('PROACTIVE') || feature('KAIROS')` 保护
### 系统提示
激活后追加:
```
# Proactive Mode
You are in proactive mode. Take initiative -- explore, act, and make progress
without waiting for instructions.
Start by briefly greeting the user.
You will receive periodic <tick> prompts. These are check-ins. Do whatever
seems most useful, or call Sleep if there's nothing to do.
```
### SleepTool 集成
设置中的 `minSleepDurationMs``maxSleepDurationMs` 控制 Sleep 持续时间范围,节流 proactive tick 频率。没活干就 Sleep 等着。
---
## 后台任务管理
### Cron 调度器
`src/utils/cronScheduler.ts`
- 每 1 秒 tick 一次(`CHECK_INTERVAL_MS = 1000`
- 使用 chokidar 监视 `.claude/scheduled_tasks.json`
- 支持调度器锁(`src/utils/cronTasksLock.ts`),防止多实例重复触发
- 锁探测间隔 5 秒,持有者崩溃时自动接管
### 任务类型
| 类型 | 说明 |
|------|------|
| 一次性(`recurring: false` | 触发后自动删除,支持错过任务检测 |
| 循环(`recurring: true` | 触发后重新调度,默认 7 天过期 |
| 永久(`permanent: true` | 不受过期限制KAIROS 专用) |
| 会话级(`durable: false` | 仅内存中,进程退出即消失 |
### Jitter 防雷群机制
`src/utils/cronJitterConfig.ts`
- 循环任务:基于 taskId 的确定性延迟interval 的 10%,上限 15 分钟)
- 一次性任务:在 :00 和 :30 施加最多 90 秒提前量
- 运维可在事故期间推送配置变更60 秒内全客户端生效
---
## 关键源码文件
| 文件 | 职责 |
|------|------|
| `src/bootstrap/state.ts` | KAIROS 全局状态 |
| `src/assistant/index.ts` | 助手模式入口 |
| `src/assistant/sessionHistory.ts` | 远程会话历史 API |
| `src/proactive/index.ts` | 主动模式状态管理 |
| `src/services/autoDream/autoDream.ts` | Auto-Dream 引擎 |
| `src/services/autoDream/consolidationPrompt.ts` | 整合提示(四阶段) |
| `src/services/autoDream/consolidationLock.ts` | 整合锁 |
| `src/services/autoDream/config.ts` | Dream 配置 |
| `src/tasks/DreamTask/DreamTask.ts` | Dream 任务定义 |
| `src/utils/cronScheduler.ts` | Cron 调度器 |
| `src/utils/cronTasks.ts` | Cron 任务持久化 |
| `src/skills/bundled/dream.ts` | `/dream` Skill存根 |

283
AGENTS.md
View File

@@ -1,283 +0,0 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
This is a **reverse-engineered / decompiled** version of Anthropic's official Codex CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. TypeScript strict mode is enforced — **`bunx tsc --noEmit` must pass with zero errors**.
## Git Commit Message Convention
使用 **Conventional Commits** 规范:
```
<type>: <描述>
```
常见 type`feat``fix``docs``chore``refactor`
示例:
- `feat: 添加模型 1M 上下文切换`
- `fix: 修复初次登陆的校验问题`
- `chore: remove prefetchOfficialMcpUrls call on startup`
## Commands
```bash
# Install dependencies
bun install
# Dev mode (runs cli.tsx with MACRO defines injected via -d flags)
bun run dev
# Dev mode with debugger (set BUN_INSPECT=9229 to pick port)
bun run dev:inspect
# Pipe mode
echo "say hello" | bun run src/entrypoints/cli.tsx -p
# Build (code splitting, outputs dist/cli.js + chunk files)
bun run build
# Test
bun test # run all tests (2453 tests / 137 files / 0 fail)
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/
# Health check
bun run health
# Check unused exports
bun run check:unused
# Remote Control Server
bun run rcs
# Docs dev server (Mintlify)
bun run docs:dev
```
详细的测试规范、覆盖状态和改进计划见 `docs/testing-spec.md`
## Architecture
### Runtime & Build
- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs.
- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。Build 默认启用 19 个 feature见下方 Feature Flag 段)。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。
- **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 — 14 个 internal packages in `packages/` resolved via `workspace:*`
- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`
- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`
- **CI**: GitHub Actions — `ci.yml`(构建+测试)、`release-rcs.yml`RCS 发布)、`update-contributors.yml`(自动更新贡献者)。
### Entry & Bootstrap
1. **`src/entrypoints/cli.tsx`** (323 行) — True entrypoint。`main()` 函数按优先级处理多条快速路径:
- `--version` / `-v` — 零模块加载
- `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT)
- `--Codex-in-chrome-mcp` / `--chrome-native-host`
- `--computer-use-mcp` — 独立 MCP server 模式
- `--daemon-worker=<kind>` — feature-gated (DAEMON)
- `remote-control` / `rc` / `remote` / `sync` / `bridge` — feature-gated (BRIDGE_MODE)
- `daemon` [subcommand] — feature-gated (DAEMON)
- `ps` / `logs` / `attach` / `kill` / `--bg` — feature-gated (BG_SESSIONS)
- `new` / `list` / `reply` — Template job commands
- `environment-runner` / `self-hosted-runner` — BYOC runner
- `--tmux` + `--worktree` 组合
- 默认路径:加载 `main.tsx` 启动完整 CLI
2. **`src/main.tsx`** (~6970 行) — Commander.js CLI definition。注册大量 subcommands`mcp` (serve/add/remove/list...)、`server``ssh``open``auth``plugin``agents``auto-mode``doctor``update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。
3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。
### Core Loop
- **`src/query.ts`** — The main API query function. Sends messages to Codex API, handles streaming responses, processes tool calls, and manages the conversation turn loop.
- **`src/QueryEngine.ts`** — Higher-level orchestrator wrapping `query()`. Manages conversation state, compaction, file history snapshots, attribution, and turn-level bookkeeping. Used by the REPL screen.
- **`src/screens/REPL.tsx`** — The interactive REPL screen (React/Ink component). Handles user input, message display, tool permission prompts, and keyboard shortcuts.
### API Layer
- **`src/services/api/Codex.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events.
- **7 providers**: `firstParty` (Anthropic direct), `bedrock` (AWS), `vertex` (Google Cloud), `foundry`, `openai`, `gemini`, `grok` (xAI)。
- Provider selection in `src/utils/model/providers.ts`。优先级modelType 参数 > 环境变量 > 默认 firstParty。
### Tool System
- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`).
- **`src/tools.ts`** (387 行) — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`.
- **`src/tools/<ToolName>/`** — 55 个 tool 目录。主要分类:
- **文件操作**: FileEditTool, FileReadTool, FileWriteTool, GlobTool, GrepTool
- **Shell/执行**: BashTool, PowerShellTool, REPLTool
- **Agent 系统**: AgentTool, TaskCreateTool, TaskUpdateTool, TaskListTool, TaskGetTool
- **规划**: EnterPlanModeTool, ExitPlanModeV2Tool, VerifyPlanExecutionTool
- **Web/MCP**: WebFetchTool, WebSearchTool, MCPTool, McpAuthTool
- **调度**: CronCreateTool, CronDeleteTool, CronListTool
- **其他**: LSPTool, ConfigTool, SkillTool, EnterWorktreeTool, ExitWorktreeTool 等
- **`src/tools/shared/`** — Tool 共享工具函数。
### UI Layer (Ink)
- **`src/ink.ts`** — Ink render wrapper with ThemeProvider injection.
- **`packages/@ant/ink/`** — Custom Ink frameworkforked/internal包含 components、core、hooks、keybindings、theme、utils。注意不是 `src/ink/`
- **`src/components/`** — 149 个组件目录/文件,渲染于终端 Ink 环境中。关键组件:
- `App.tsx` — Root provider (AppState, Stats, FpsMetrics)
- `Messages.tsx` / `MessageRow.tsx` — Conversation message rendering
- `PromptInput/` — User input handling
- `permissions/` — Tool permission approval UI
- `design-system/` — 复用 UI 组件Dialog, FuzzyPicker, ProgressBar, ThemeProvider 等)
- Components use React Compiler runtime (`react/compiler-runtime`) — decompiled output has `_c()` memoization calls throughout.
### State Management
- **`src/state/AppState.tsx`** — Central app state type and context provider. Contains messages, tools, permissions, MCP connections, etc.
- **`src/state/AppStateStore.ts`** — Default state and store factory.
- **`src/state/store.ts`** — Zustand-style store for AppState (`createStore`).
- **`src/state/selectors.ts`** — State selectors.
- **`src/bootstrap/state.ts`** — Module-level singletons for session-global state (session ID, CWD, project root, token counts, model overrides, client type, permission mode).
### Workspace Packages
| Package | 说明 |
|---------|------|
| `packages/@ant/ink/` | Forked Ink 框架components、hooks、keybindings、theme |
| `packages/@ant/computer-use-mcp/` | Computer Use MCP server截图/键鼠/剪贴板/应用管理) |
| `packages/@ant/computer-use-input/` | 键鼠模拟dispatcher + darwin/win32/linux backend |
| `packages/@ant/computer-use-swift/` | 截图 + 应用管理dispatcher + per-platform backend |
| `packages/@ant/Codex-for-chrome-mcp/` | Chrome 浏览器控制(通过 `--chrome` 启用) |
| `packages/remote-control-server/` | 自托管 Remote Control ServerDocker 部署,含 Web UI |
| `packages/swarm/` | Swarm 解耦模块 |
| `packages/shell/` | Shell 抽象 |
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
| `packages/color-diff-napi/` | 颜色差异计算完整实现11 tests |
| `packages/image-processor-napi/` | 图像处理(已恢复) |
| `packages/modifiers-napi/` | 键盘修饰键检测stub |
| `packages/url-handler-napi/` | URL scheme 处理stub |
### Bridge / Remote Control
- **`src/bridge/`** (~37 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`
- **`packages/remote-control-server/`** — 自托管 RCS支持 Docker 部署,含 Web UI 控制面板。通过 `bun run rcs` 启动。
- CLI 快速路径: `Codex remote-control` / `Codex rc` / `Codex bridge`
- 详见 `docs/features/remote-control-self-hosting.md`
### Daemon Mode
- **`src/daemon/`** — Daemon 模式(长驻 supervisor。feature-gated by `DAEMON`。包含 `main.ts`entry`workerRegistry.ts`worker 管理)。
### Context & System Prompt
- **`src/context.ts`** — Builds system/user context for the API call (git status, date, AGENTS.md contents, memory files).
- **`src/utils/claudemd.ts`** — Discovers and loads AGENTS.md files from project hierarchy.
### Feature Flag System
Feature flags control which functionality is enabled at runtime. 代码中统一通过 `import { feature } from 'bun:bundle'` 导入,调用 `feature('FLAG_NAME')` 返回 `boolean`
**启用方式**: 环境变量 `FEATURE_<FLAG_NAME>=1`。例如 `FEATURE_BUDDY=1 bun run dev`
**Build 默认 features**19 个,见 `build.ts`:
- 基础: `BUDDY`, `TRANSCRIPT_CLASSIFIER`, `BRIDGE_MODE`, `AGENT_TRIGGERS_REMOTE`, `CHICAGO_MCP`, `VOICE_MODE`
- 统计/缓存: `SHOT_STATS`, `PROMPT_CACHE_BREAK_DETECTION`, `TOKEN_BUDGET`
- P0 本地: `AGENT_TRIGGERS`, `ULTRATHINK`, `BUILTIN_EXPLORE_PLAN_AGENTS`, `LODESTONE`
- P1 API 依赖: `EXTRACT_MEMORIES`, `VERIFICATION_AGENT`, `KAIROS_BRIEF`, `AWAY_SUMMARY`, `ULTRAPLAN`
- P2: `DAEMON`
**Dev mode 默认**: 全部启用(见 `scripts/dev.ts`)。
**类型声明**: `src/types/internal-modules.d.ts` 中声明了 `bun:bundle` 模块的 `feature` 函数签名。
**新增功能的正确做法**: 保留 `import { feature } from 'bun:bundle'` + `feature('FLAG_NAME')` 的标准模式,在运行时通过环境变量或配置控制,不要绕过 feature flag 直接 import。
### Multi-API 兼容层
所有兼容层均采用流适配器模式:将第三方 API 格式转为 Anthropic 内部格式,下游代码完全不改。
#### OpenAI 兼容层
通过 `CLAUDE_CODE_USE_OPENAI=1` 启用,支持 Ollama/DeepSeek/vLLM 等任意 OpenAI Chat Completions 协议端点。含 DeepSeek thinking mode 支持。
- **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射
- 关键环境变量:`CLAUDE_CODE_USE_OPENAI``OPENAI_API_KEY``OPENAI_BASE_URL``OPENAI_MODEL`
#### Gemini 兼容层
通过 `CLAUDE_CODE_USE_GEMINI=1` 启用。独立环境变量体系。
- **`src/services/api/gemini/`** — client、模型映射、类型定义
- 关键环境变量:`GEMINI_API_KEY`(必填)、`GEMINI_MODEL`(直接指定)、`GEMINI_DEFAULT_SONNET_MODEL`/`GEMINI_DEFAULT_OPUS_MODEL`(按能力映射)
- 模型映射优先级:`GEMINI_MODEL` > `GEMINI_DEFAULT_*_MODEL` > `ANTHROPIC_DEFAULT_*_MODEL`(已废弃) > 原样返回
#### Grok 兼容层
通过 `CLAUDE_CODE_USE_GROK=1` 启用。自定义模型映射支持 xAI Grok API。
- **`src/services/api/grok/`** — client、模型映射
详见各兼容层的 docs 文档。
### Stubbed/Deleted Modules
| Module | Status |
|--------|--------|
| Computer Use (`@ant/*`) | Restored — macOS + Windows + Linux后端完整度不一 |
| `*-napi` packages | `audio-capture-napi``image-processor-napi` 已恢复;`color-diff-napi` 完整;`modifiers-napi``url-handler-napi` 仍为 stub |
| Voice Mode | Restored — Push-to-Talk 语音输入(需 Anthropic OAuth |
| OpenAI/Gemini/Grok 兼容层 | Restored |
| Remote Control Server | Restored — 自托管 RCS + Web UI |
| Analytics / GrowthBook / Sentry | Empty implementations |
| Magic Docs / LSP Server | Removed |
| Plugins / Marketplace | Removed |
| MCP OAuth | Simplified |
### Key Type Files
- **`src/types/global.d.ts`** — Declares `MACRO`, `BUILD_TARGET`, `BUILD_ENV` and internal Anthropic-only identifiers.
- **`src/types/internal-modules.d.ts`** — Type declarations for `bun:bundle`, `bun:ffi`, `@anthropic-ai/mcpb`.
- **`src/types/message.ts`** — Message type hierarchy (UserMessage, AssistantMessage, SystemMessage, etc.).
- **`src/types/permissions.ts`** — Permission mode and result types.
## Testing
- **框架**: `bun:test`(内置断言 + mock
- **当前状态**: 2472 tests / 138 files / 0 fail
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
- **集成测试**: `tests/integration/` — 4 个文件cli-arguments, context-build, message-pipeline, tool-chain
- **共享 mock/fixture**: `tests/mocks/`api-responses, file-system, fixtures/
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入)
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests
### 类型检查
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
```bash
bunx tsc --noEmit
```
**类型规范**
- 生产代码禁止 `as any`;测试文件中 mock 数据可用 `as any`
- 类型不匹配优先用 `as unknown as SpecificType` 双重断言,或补充 interface
- 未知结构对象用 `Record<string, unknown>` 替代 `any`
- 联合类型用类型守卫type guard收窄不要强转
- `msg.request` 属性访问:`const req = msg.request as Record<string, unknown>`
- Ink `color` prop`as keyof Theme` 而非 `as any`
## Working with This Codebase
- **tsc must pass** — `bunx tsc --noEmit` 必须零错误,任何修改都不能引入新的类型错误。
- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。
- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal.
- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。**`feature()` 只能直接用在 `if` 语句或三元表达式的条件位置**Bun 编译器限制),不能赋值给变量、不能放在箭头函数体里、不能作为 `&&` 链的一部分。正确:`if (feature('X')) {}``feature('X') ? a : b`
- **`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 行宽 + 按需分号。
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`该目录不存在。Ink 相关的组件、hooks、keybindings 都在 packages 中。
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。

View File

@@ -23,7 +23,7 @@
| 自定义模型供应商 | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) | | 自定义模型供应商 | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) | | Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) | | Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
| Chrome Use | 浏览器自动化、表单填写、数据抓取 | [魔改版](docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) | | Chrome Use | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) |
| Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) | | Sentry | 企业级错误追踪 | [文档](https://ccb.agent-aura.top/docs/internals/sentry-setup) |
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) | | GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
| Langfuse 监控 | LLM 调用/工具执行/多 Agent 全链路追踪 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) | | Langfuse 监控 | LLM 调用/工具执行/多 Agent 全链路追踪 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |

View File

@@ -40,8 +40,6 @@ const DEFAULT_BUILD_FEATURES = [
'KAIROS', 'KAIROS',
'COORDINATOR_MODE', 'COORDINATOR_MODE',
'LAN_PIPES', 'LAN_PIPES',
'BG_SESSIONS',
'TEMPLATES',
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性 // 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
// P3: poor mode (disable extract_memories + prompt_suggestion) // P3: poor mode (disable extract_memories + prompt_suggestion)
'POOR', 'POOR',
@@ -90,8 +88,27 @@ for (const file of files) {
} }
} }
// Also patch unguarded globalThis.Bun destructuring from third-party deps
// (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 : {};'
for (const file of files) {
if (!file.endsWith('.js')) continue
const filePath = join(outdir, file)
const content = await readFile(filePath, 'utf-8')
if (BUN_DESTRUCTURE.test(content)) {
await writeFile(
filePath,
content.replace(BUN_DESTRUCTURE, BUN_DESTRUCTURE_SAFE),
)
bunPatched++
}
}
BUN_DESTRUCTURE.lastIndex = 0
console.log( console.log(
`Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for Node.js compat)`, `Bundled ${result.outputs.length} files to ${outdir}/ (patched ${patched} for import.meta.require, ${bunPatched} for Bun destructure)`,
) )
// Step 4: Copy native .node addon files (audio-capture) // Step 4: Copy native .node addon files (audio-capture)
@@ -121,46 +138,7 @@ const cliNode = join(outdir, 'cli-node.js')
await writeFile(cliBun, '#!/usr/bin/env bun\nimport "./cli.js"\n') await writeFile(cliBun, '#!/usr/bin/env bun\nimport "./cli.js"\n')
// Node.js entry needs a Bun API polyfill because Bun.build({ target: 'bun' }) await writeFile(cliNode, '#!/usr/bin/env node\nimport "./cli.js"\n')
// emits globalThis.Bun references (e.g. Bun.$ shell tag in computer-use-input,
// Bun.which in chunk-ys6smqg9) that crash at import time under plain Node.js.
const NODE_BUN_POLYFILL = `#!/usr/bin/env node
// Bun API polyfill for Node.js runtime
if (typeof globalThis.Bun === "undefined") {
const { execFileSync } = await import("child_process");
const { resolve, delimiter } = await import("path");
const { accessSync, constants: { X_OK } } = await import("fs");
function which(bin) {
const isWin = process.platform === "win32";
const pathExt = isWin ? (process.env.PATHEXT || ".EXE").split(";") : [""];
for (const dir of (process.env.PATH || "").split(delimiter)) {
for (const ext of pathExt) {
const candidate = resolve(dir, bin + ext);
try { accessSync(candidate, X_OK); return candidate; } catch {}
}
}
return null;
}
// Bun.$ is the shell template tag (e.g. $\`osascript ...\`). Only used by
// computer-use-input/darwin — stub it so the top-level destructuring
// \`var { $ } = globalThis.Bun\` doesn't crash.
function $(parts, ...args) {
throw new Error("Bun.$ shell API is not available in Node.js. Use Bun runtime for this feature.");
}
function hash(data, seed) {
let h = ((seed || 0) ^ 0x811c9dc5) >>> 0;
for (let i = 0; i < data.length; i++) {
h ^= data.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h;
}
globalThis.Bun = { which, $, hash };
}
import "./cli.js"
`
await writeFile(cliNode, NODE_BUN_POLYFILL)
// NOTE: when new Bun-specific globals appear in bundled output, add them here.
// Make both executable // Make both executable
const { chmodSync } = await import('fs') const { chmodSync } = await import('fs')

View File

@@ -6,6 +6,7 @@
"name": "claude-code-best", "name": "claude-code-best",
"dependencies": { "dependencies": {
"@claude-code-best/mcp-chrome-bridge": "^2.0.7", "@claude-code-best/mcp-chrome-bridge": "^2.0.7",
"ws": "^8.20.0",
}, },
"devDependencies": { "devDependencies": {
"@alcalzone/ansi-tokenize": "^0.3.0", "@alcalzone/ansi-tokenize": "^0.3.0",
@@ -57,10 +58,11 @@
"@sentry/node": "^10.47.0", "@sentry/node": "^10.47.0",
"@smithy/core": "^3.23.13", "@smithy/core": "^3.23.13",
"@smithy/node-http-handler": "^4.5.1", "@smithy/node-http-handler": "^4.5.1",
"@types/bun": "^1.3.11", "@types/bun": "^1.3.12",
"@types/cacache": "^20.0.1", "@types/cacache": "^20.0.1",
"@types/he": "^1.2.3", "@types/he": "^1.2.3",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^25.6.0",
"@types/picomatch": "^4.0.3", "@types/picomatch": "^4.0.3",
"@types/plist": "^3.0.5", "@types/plist": "^3.0.5",
"@types/proper-lockfile": "^4.1.4", "@types/proper-lockfile": "^4.1.4",
@@ -134,7 +136,6 @@
"vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-protocol": "^3.17.5",
"vscode-languageserver-types": "^3.17.5", "vscode-languageserver-types": "^3.17.5",
"wrap-ansi": "^10.0.0", "wrap-ansi": "^10.0.0",
"ws": "^8.20.0",
"xss": "^1.0.15", "xss": "^1.0.15",
"yaml": "^2.8.3", "yaml": "^2.8.3",
"zod": "^4.3.6", "zod": "^4.3.6",
@@ -443,7 +444,7 @@
"@claude-code-best/builtin-tools": ["@claude-code-best/builtin-tools@workspace:packages/builtin-tools"], "@claude-code-best/builtin-tools": ["@claude-code-best/builtin-tools@workspace:packages/builtin-tools"],
"@claude-code-best/mcp-chrome-bridge": ["@claude-code-best/mcp-chrome-bridge@2.0.7", "", { "dependencies": { "@fastify/cors": "^11.0.1", "@modelcontextprotocol/sdk": "^1.11.0", "chalk": "^5.4.1", "chrome-mcp-shared": "^1.0.2", "commander": "^13.1.0", "fastify": "^5.3.2", "is-admin": "^4.0.0", "pino": "^9.6.0", "uuid": "^11.1.0" }, "bin": { "mcp-chrome-bridge": "dist/cli.js", "mcp-chrome-stdio": "dist/mcp/mcp-server-stdio.js" } }, "sha512-gb64+Ga6li3A8Ll9NKV+ePBn5/U0fccCdrH43tGYveLKZIZxURz8cbY+Z3BdbTdYSPVdFXtfUlp3TMxu4OT5gg=="], "@claude-code-best/mcp-chrome-bridge": ["@claude-code-best/mcp-chrome-bridge@2.0.7", "https://registry.npmmirror.com/@claude-code-best/mcp-chrome-bridge/-/mcp-chrome-bridge-2.0.7.tgz", { "dependencies": { "@fastify/cors": "^11.0.1", "@modelcontextprotocol/sdk": "^1.11.0", "chalk": "^5.4.1", "chrome-mcp-shared": "^1.0.2", "commander": "^13.1.0", "fastify": "^5.3.2", "is-admin": "^4.0.0", "pino": "^9.6.0", "uuid": "^11.1.0" }, "bin": { "mcp-chrome-bridge": "dist/cli.js", "mcp-chrome-stdio": "dist/mcp/mcp-server-stdio.js" } }, "sha512-gb64+Ga6li3A8Ll9NKV+ePBn5/U0fccCdrH43tGYveLKZIZxURz8cbY+Z3BdbTdYSPVdFXtfUlp3TMxu4OT5gg=="],
"@claude-code-best/mcp-client": ["@claude-code-best/mcp-client@workspace:packages/mcp-client"], "@claude-code-best/mcp-client": ["@claude-code-best/mcp-client@workspace:packages/mcp-client"],
@@ -507,21 +508,21 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "https://registry.npmmirror.com/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
"@fastify/cors": ["@fastify/cors@11.2.0", "", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw=="], "@fastify/cors": ["@fastify/cors@11.2.0", "https://registry.npmmirror.com/@fastify/cors/-/cors-11.2.0.tgz", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw=="],
"@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], "@fastify/error": ["@fastify/error@4.2.0", "https://registry.npmmirror.com/@fastify/error/-/error-4.2.0.tgz", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "https://registry.npmmirror.com/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
"@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="], "@fastify/forwarded": ["@fastify/forwarded@3.0.1", "https://registry.npmmirror.com/@fastify/forwarded/-/forwarded-3.0.1.tgz", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="],
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "https://registry.npmmirror.com/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
"@fastify/otel": ["@fastify/otel@0.18.0", "https://registry.npmmirror.com/@fastify/otel/-/otel-0.18.0.tgz", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="], "@fastify/otel": ["@fastify/otel@0.18.0", "https://registry.npmmirror.com/@fastify/otel/-/otel-0.18.0.tgz", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="],
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "https://registry.npmmirror.com/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
"@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "https://registry.npmmirror.com/@growthbook/growthbook/-/growthbook-1.6.5.tgz", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="], "@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "https://registry.npmmirror.com/@growthbook/growthbook/-/growthbook-1.6.5.tgz", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
@@ -1099,7 +1100,7 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], "abstract-logging": ["abstract-logging@2.0.1", "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
@@ -1133,7 +1134,7 @@
"auto-bind": ["auto-bind@5.0.1", "https://registry.npmmirror.com/auto-bind/-/auto-bind-5.0.1.tgz", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], "auto-bind": ["auto-bind@5.0.1", "https://registry.npmmirror.com/auto-bind/-/auto-bind-5.0.1.tgz", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="], "avvio": ["avvio@9.2.0", "https://registry.npmmirror.com/avvio/-/avvio-9.2.0.tgz", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="],
"axios": ["axios@1.15.0", "https://registry.npmmirror.com/axios/-/axios-1.15.0.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="], "axios": ["axios@1.15.0", "https://registry.npmmirror.com/axios/-/axios-1.15.0.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
@@ -1247,7 +1248,7 @@
"depd": ["depd@2.0.0", "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "depd": ["depd@2.0.0", "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "dequal": ["dequal@2.0.3", "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -1305,15 +1306,15 @@
"external-editor": ["external-editor@3.1.0", "https://registry.npmmirror.com/external-editor/-/external-editor-3.1.0.tgz", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="], "external-editor": ["external-editor@3.1.0", "https://registry.npmmirror.com/external-editor/-/external-editor-3.1.0.tgz", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "https://registry.npmmirror.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-glob": ["fast-glob@3.3.3", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stringify": ["fast-json-stringify@6.3.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA=="], "fast-json-stringify": ["fast-json-stringify@6.3.0", "https://registry.npmmirror.com/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA=="],
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], "fast-querystring": ["fast-querystring@1.1.2", "https://registry.npmmirror.com/fast-querystring/-/fast-querystring-1.1.2.tgz", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
"fast-uri": ["fast-uri@3.1.0", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "fast-uri": ["fast-uri@3.1.0", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
@@ -1321,9 +1322,9 @@
"fast-xml-parser": ["fast-xml-parser@5.5.8", "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="], "fast-xml-parser": ["fast-xml-parser@5.5.8", "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
"fastify": ["fastify@5.8.4", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ=="], "fastify": ["fastify@5.8.4", "https://registry.npmmirror.com/fastify/-/fastify-5.8.4.tgz", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ=="],
"fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="], "fastify-plugin": ["fastify-plugin@5.1.0", "https://registry.npmmirror.com/fastify-plugin/-/fastify-plugin-5.1.0.tgz", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="],
"fastq": ["fastq@1.20.1", "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fastq": ["fastq@1.20.1", "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
@@ -1341,7 +1342,7 @@
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], "finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="], "find-my-way": ["find-my-way@9.5.0", "https://registry.npmmirror.com/find-my-way/-/find-my-way-9.5.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
"find-up": ["find-up@4.1.0", "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "find-up": ["find-up@4.1.0", "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@@ -1483,7 +1484,7 @@
"json-bigint": ["json-bigint@1.0.0", "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], "json-bigint": ["json-bigint@1.0.0", "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="], "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "https://registry.npmmirror.com/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
@@ -1505,7 +1506,7 @@
"knip": ["knip@6.4.0", "https://registry.npmmirror.com/knip/-/knip-6.4.0.tgz", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "get-tsconfig": "4.13.7", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.121.0", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-SAEeggehgkPdoLZWVEcFKzPw+vNlnrUBDqcX8cOcHGydRInSn5pnn9LN3dDJ8SkDHKXR7xYzNq3HtRJaYmxOHg=="], "knip": ["knip@6.4.0", "https://registry.npmmirror.com/knip/-/knip-6.4.0.tgz", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "get-tsconfig": "4.13.7", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.121.0", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-SAEeggehgkPdoLZWVEcFKzPw+vNlnrUBDqcX8cOcHGydRInSn5pnn9LN3dDJ8SkDHKXR7xYzNq3HtRJaYmxOHg=="],
"light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], "light-my-request": ["light-my-request@6.6.0", "https://registry.npmmirror.com/light-my-request/-/light-my-request-6.6.0.tgz", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="],
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -1745,13 +1746,13 @@
"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=="], "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=="],
"ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], "ret": ["ret@0.5.0", "https://registry.npmmirror.com/ret/-/ret-0.5.0.tgz", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
"retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "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=="], "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=="], "rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rollup": ["rollup@4.60.1", "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], "rollup": ["rollup@4.60.1", "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
@@ -1763,7 +1764,7 @@
"safe-buffer": ["safe-buffer@5.2.1", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-buffer": ["safe-buffer@5.2.1", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-regex2": ["safe-regex2@5.1.0", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="], "safe-regex2": ["safe-regex2@5.1.0", "https://registry.npmmirror.com/safe-regex2/-/safe-regex2-5.1.0.tgz", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
@@ -1771,7 +1772,7 @@
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], "secure-json-parse": ["secure-json-parse@4.1.0", "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
"semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -1781,7 +1782,7 @@
"set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], "set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
@@ -1851,7 +1852,7 @@
"to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], "toad-cache": ["toad-cache@3.7.0", "https://registry.npmmirror.com/toad-cache/-/toad-cache-3.7.0.tgz", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="],
"toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -2127,7 +2128,7 @@
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="], "@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-2.3.0.tgz", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@grpc/proto-loader/yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "@grpc/proto-loader/yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -2295,9 +2296,9 @@
"is-admin/execa": ["execa@5.1.1", "https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "is-admin/execa": ["execa@5.1.1", "https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"light-my-request/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "light-my-request/cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], "light-my-request/process-warning": ["process-warning@4.0.1", "https://registry.npmmirror.com/process-warning/-/process-warning-4.0.1.tgz", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
"micromatch/picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "micromatch/picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],

View File

@@ -6,7 +6,7 @@
### 第一步:安装 Chrome 扩展 ### 第一步:安装 Chrome 扩展
1. 下载扩展https://github.com/hangwin/mcp-chrome/releases(下载最新 zip 1. 下载扩展https://github.com/hangwin/mcp-chrome/releases
2. 解压 zip 文件 2. 解压 zip 文件
3. 打开 Chrome 访问 `chrome://extensions/` 3. 打开 Chrome 访问 `chrome://extensions/`
4. 开启右上角「开发者模式」 4. 开启右上角「开发者模式」

View File

@@ -1,318 +0,0 @@
# Daemon 重构设计方案
> 分支: `feat/integrate-5-branches`
> 基于: `f41745cb` (= main `11bb3f62` 内容)
> 日期: 2026-04-13
## 一、问题概述
### 1.1 命令结构散乱
当前后台进程相关的命令分布在三个不同的位置,没有统一的命名空间:
| 命令 | 注册位置 | 入口 |
|------|---------|------|
| `claude daemon start/status/stop` | `cli.tsx` 快速路径 L203 | `daemon/main.ts` |
| `claude ps` | `cli.tsx` 快速路径 L220 | `cli/bg.ts` |
| `claude logs <x>` | `cli.tsx` 快速路径 L232 | `cli/bg.ts` |
| `claude attach <x>` | `cli.tsx` 快速路径 L236 | `cli/bg.ts` |
| `claude kill <x>` | `cli.tsx` 快速路径 L238 | `cli/bg.ts` |
| `claude --bg` | `cli.tsx` 快速路径 L244 | `cli/bg.ts` |
| `claude new/list/reply` | `cli.tsx` 快速路径 L250 | `cli/handlers/templateJobs.ts` |
| `claude rollback` | `main.tsx` Commander.js L6525 | `cli/rollback.ts` |
| `claude up` | `main.tsx` Commander.js L6511 | `cli/up.ts` |
**问题**:
- `ps/logs/attach/kill``daemon` 逻辑上都是后台进程管理,但互不关联
- 这些命令都**只有 CLI 入口**REPL 里输入 `/daemon``/ps` 不存在
- `new/list/reply` 是模板任务系统的顶级命令,容易与其他命令冲突(特别是 `list`
### 1.2 Windows 不支持
`--bg``attach` 硬依赖 tmux
- `bg.ts:handleBgFlag()` 第一步就检查 tmux不可用直接报错退出
- `bg.ts:attachHandler()``tmux attach-session`,无 tmux 替代方案
- Windows (包括 VS Code 终端) 完全无法使用后台会话功能
### 1.3 无 REPL 入口
对比 `/mcp` 的双注册模式:
- **CLI**: `claude mcp serve/add/remove/list` (Commander.js, `main.tsx:5760`)
- **REPL**: `/mcp enable/disable/reconnect` (slash command, `commands/mcp/index.ts`)
`daemon`/`bg`/`job` 系列只有 CLI 快速路径REPL 中完全不可用。
## 二、目标
1. **层级化命令结构**: 参照 `/mcp` 模式,将后台管理收归 `/daemon`,模板任务收归 `/job`
2. **跨平台后台会话**: Windows / macOS / Linux 都能启动、附着、终止后台会话
3. **双注册**: CLI (`claude daemon ...`) + REPL (`/daemon ...`) 同时可用
4. **向后兼容**: 旧命令保留但输出 deprecation 提示
## 三、命令结构设计
### 3.1 `/daemon` — 后台进程管理
合并 daemon supervisor + bg sessions 为统一命名空间:
```
claude daemon <subcommand> ← CLI 入口 (cli.tsx 快速路径)
/daemon <subcommand> ← REPL 入口 (slash command, local-jsx)
子命令:
status 综合状态面板 (daemon + 所有会话)
start [--dir <path>] 启动 daemon supervisor
stop 停止 daemon
bg [args...] 启动后台会话
attach [target] 附着到后台会话
logs [target] 查看会话日志
kill [target] 终止会话
(无参数) 等同于 status
```
**CLI 快速路径路由** (`cli.tsx`):
```typescript
// 新: 统一入口
if (feature('DAEMON') && args[0] === 'daemon') {
const sub = args[1] || 'status'
switch (sub) {
case 'start': case 'stop': case 'status':
await daemonMain([sub, ...args.slice(2)])
break
case 'bg':
await bg.handleBgStart(args.slice(2))
break
case 'attach': case 'logs': case 'kill':
await bg[`${sub}Handler`](args[2])
break
}
}
// 向后兼容 (deprecated)
if (feature('BG_SESSIONS') && ['ps','logs','attach','kill'].includes(args[0])) {
console.warn(`[deprecated] Use: claude daemon ${args[0] === 'ps' ? 'status' : args[0]}`)
// ... delegate to daemon subcommand
}
```
**REPL 斜杠命令** (`commands/daemon/index.ts`):
```typescript
const daemon = {
type: 'local-jsx',
name: 'daemon',
description: 'Manage background sessions and daemon',
argumentHint: '[status|start|stop|bg|attach|logs|kill]',
isEnabled: () => feature('DAEMON') || feature('BG_SESSIONS'),
load: () => import('./daemon.js'),
} satisfies Command
```
### 3.2 `/job` — 模板任务管理
```
claude job <subcommand> ← CLI 入口
/job <subcommand> ← REPL 入口
子命令:
list 列出模板和活跃任务
new <template> [args] 从模板创建任务
reply <id> <text> 回复任务
status <id> 查看任务状态
(无参数) 等同于 list
```
### 3.3 独立命令 (不变)
```
claude up 保持顶级 (简短的 bootstrap 命令)
claude rollback [target] 保持顶级 (低频运维命令)
```
## 四、跨平台后台引擎
### 4.1 引擎抽象
```typescript
// src/cli/bg/engine.ts
export interface BgEngine {
readonly name: string
/** 当前平台是否可用 */
available(): Promise<boolean>
/** 启动后台会话 */
start(opts: BgStartOptions): Promise<BgStartResult>
/** 附着到后台会话blocking */
attach(session: SessionEntry): Promise<void>
}
export interface BgStartOptions {
sessionName: string
args: string[]
env: Record<string, string | undefined>
logPath: string
cwd: string
}
export interface BgStartResult {
pid: number
sessionName: string
logPath: string
engineUsed: string
}
```
### 4.2 三种引擎实现
| 引擎 | 平台 | 启动方式 | attach 方式 |
|------|------|---------|------------|
| TmuxEngine | macOS/Linux (有 tmux) | `tmux new-session -d` | `tmux attach-session` |
| DetachedEngine | Windows / 无 tmux 的 macOS/Linux | `spawn({ detached, stdio→logFile })` | `tail -f` 日志文件 |
#### DetachedEngine 详细设计
**启动 (`start`)**:
```typescript
// 1. 打开日志文件 fd
const logFd = fs.openSync(logPath, 'a')
// 2. detached spawn, stdout/stderr 重定向到日志
const child = spawn(process.execPath, execArgs, {
detached: true,
stdio: ['ignore', logFd, logFd],
env,
cwd,
})
child.unref()
fs.closeSync(logFd)
// 3. 写 sessions/<PID>.json
```
**附着 (`attach`)**:
```typescript
// 跨平台 tail -f 实现
// 1. 读取已有日志内容输出到 stdout
// 2. fs.watch(logPath) 监听变化
// 3. 每次变化读取新增内容
// 4. Ctrl+C 退出 tail不杀后台进程
```
#### 引擎选择逻辑
```typescript
// src/cli/bg/engines/index.ts
export async function selectEngine(): Promise<BgEngine> {
if (process.platform === 'win32') {
return new DetachedEngine()
}
const tmux = new TmuxEngine()
if (await tmux.available()) {
return tmux
}
return new DetachedEngine()
}
```
### 4.3 SessionEntry 扩展
```typescript
interface SessionEntry {
// ... 现有字段
engine: 'tmux' | 'detached' // 新增: 记录使用的引擎
tmuxSessionName?: string // tmux 引擎才有
logPath?: string // 两种引擎都有
}
```
`attach` 时根据 `session.engine` 选择对应的 attach 策略。
## 五、文件变更清单
### 新增文件 (10 个)
```
src/cli/bg/engine.ts BgEngine 接口定义
src/cli/bg/engines/tmux.ts TmuxEngine (从 bg.ts 提取)
src/cli/bg/engines/detached.ts DetachedEngine (新实现)
src/cli/bg/engines/index.ts 引擎选择 + re-export
src/cli/bg/tail.ts 跨平台日志 tail (用于 detached attach)
src/commands/daemon/index.ts /daemon REPL 斜杠命令注册
src/commands/daemon/daemon.tsx /daemon 子命令路由 + status UI
src/commands/job/index.ts /job REPL 斜杠命令注册
src/commands/job/job.tsx /job 子命令路由 + UI
docs/features/daemon-restructure-design.md 本设计文档
```
### 修改文件 (6 个)
```
src/cli/bg.ts 重构: handler 函数改为调用 BgEngine
src/entrypoints/cli.tsx 快速路径: daemon 统一入口 + 向后兼容
src/commands.ts 注册 /daemon 和 /job 斜杠命令
src/daemon/main.ts daemonMain() 增加 bg/ps/logs 子命令分发
src/main.tsx Commander.js: 可选注册 daemon/job 子命令
src/cli/handlers/templateJobs.ts 适配 /job 入口 (可能不需改)
```
### 不动的文件
```
src/daemon/state.ts daemon PID 状态管理 (无需改)
src/jobs/state.ts job 状态管理 (无需改)
src/jobs/templates.ts 模板发现 (无需改)
src/jobs/classifier.ts 任务分类器 (无需改)
src/cli/rollback.ts 保持顶级命令 (无需改)
src/cli/up.ts 保持顶级命令 (无需改)
```
## 六、可行性分析
### 6.1 风险评估
| 风险 | 级别 | 缓解措施 |
|------|------|---------|
| cli.tsx 快速路径修改影响启动性能 | 低 | 仅改路由逻辑import 仍然 lazy |
| DetachedEngine 的 attach 在 Windows 上 fs.watch 不可靠 | 中 | 使用轮询 fallback (setInterval + fs.stat) |
| 向后兼容的 deprecation 可能破坏脚本 | 低 | 旧命令保持可用,仅输出 stderr 警告 |
| REPL 中 /daemon bg 需要 spawn 子进程 | 中 | 参考 /assistant 的 NewInstallWizard (已有 spawn 先例) |
| tsc 类型兼容 | 低 | 接口定义清晰,不引入 any |
### 6.2 工作量估计
| Task | 文件数 | 复杂度 |
|------|--------|--------|
| Task 013: BgEngine 抽象 + 引擎实现 | 5 新增 + 1 修改 | 中 |
| Task 014: /daemon 命令层级化 | 3 新增 + 3 修改 | 中 |
| Task 015: /job 命令层级化 | 2 新增 + 2 修改 | 低 |
| Task 016: 向后兼容 + 测试 | 0 新增 + 2 修改 | 低 |
### 6.3 依赖关系
```
Task 013 (BgEngine) ← 无依赖,可独立开发
Task 014 (/daemon) ← 依赖 Task 013 (引擎选择)
Task 015 (/job) ← 无依赖,可与 013 并行
Task 016 (兼容) ← 依赖 Task 014 + 015
```
## 七、设计决策记录
### D1: 为什么 daemon + bg sessions 合为一个命名空间?
用户视角:都是"后台运行的东西"。分开会导致 `claude daemon status` 看 supervisor + `claude ps` 看会话,割裂感强。合并后 `claude daemon status` 一次性展示 supervisor 状态 + 所有会话列表。
### D2: 为什么 rollback/up 不收入 daemon
它们本质是**版本管理/环境初始化**,不是后台进程管理。`claude up` 是同步阻塞的 setup 脚本,不涉及 daemon 或后台会话。保持顶级更直观。
### D3: 为什么 DetachedEngine 的 attach 用 tail 而不是 IPC
1. 日志文件是最简单的跨平台方案,无需额外依赖
2. UDS Pipe IPC 系统 (usePipeIpc) 设计用于实例间通信,不是终端附着
3. tmux attach 的体验(完整 PTY无法在纯 detached 模式下复制tail 是最诚实的替代
### D4: 为什么不用 Windows Terminal 的 tab/pane API
Windows Terminal 的 `wt.exe` 新窗口/标签功能不够通用——用户可能在 VS Code、ConEmu、cmder 等终端中。detached + log 是唯一跨终端方案。

View File

@@ -1,310 +0,0 @@
# Stub 恢复设计 1-4
> 日期2026-04-12
> 目标:基于当前代码边界,为下一阶段 4 个 stub/半 stub 命令面给出可实施的设计方案。
> 排序原则:按建议实施顺序排序,不按问题严重性排序。
## 设计原则
- 先做能独立闭环、收益明确、改动边界清晰的项。
- 大项拆成 `MVP``Phase 2+`,避免一次性掉进大范围恢复。
- 优先复用已有状态、传输层、日志与配置能力,不重造协议。
- 设计以当前仓库实际代码为准,不以旧文档的理想状态为准。
## 1. `claude daemon status` / `claude daemon stop`
### 现状
- `start` 路径已有完整 supervisor + worker 生命周期:
[src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:1>)
[src/daemon/workerRegistry.ts](</e:/Source_code/Claude-code-bast/src/daemon/workerRegistry.ts:1>)
- `status` / `stop` 目前只是占位输出:
[src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:49>)
- `/remote-control-server` 有自己的命令内 UI 状态,但只维护当前进程内的 `daemonProcess`,并不适合作为跨进程 CLI 管理基础:
[src/commands/remoteControlServer/remoteControlServer.tsx](</e:/Source_code/Claude-code-bast/src/commands/remoteControlServer/remoteControlServer.tsx:32>)
### 目标
-`claude daemon status``claude daemon stop` 在另一个 CLI 进程中也能正确工作。
- 不依赖 TUI 内存态,不要求当前命令进程就是启动 daemon 的那个进程。
### MVP 方案
- 新增 daemon 状态文件,例如:
`~/.claude/daemon/remote-control.json`
- `start` 时写入:
- supervisor pid
- cwd
- startedAt
- worker kinds
- 最近状态
- `status`
- 读取状态文件
- 用现有进程探测能力验证 pid 是否存活
- 输出 `running / stopped / stale`
- stale 时自动清理状态文件
- `stop`
- 读取 pid
- 发送 `SIGTERM`
- 等待退出
- 超时后 `SIGKILL`
- 清理状态文件
### 代码范围
- 新增 `src/daemon/state.ts`
- 修改 [src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:1>)
- 轻量修改 [src/commands/remoteControlServer/remoteControlServer.tsx](</e:/Source_code/Claude-code-bast/src/commands/remoteControlServer/remoteControlServer.tsx:32>),让 UI 尽量读取同一份状态文件
### 验证
1. `claude daemon start`
2. 新开终端执行 `claude daemon status`
3. 执行 `claude daemon stop`
4. 再次执行 `claude daemon status`,确认返回 `stopped` 或清晰的 `stale cleaned`
### 风险
- Windows 信号模型和 Unix 不同,`stop` 需要超时兜底。
- 当前设计默认单 supervisor不处理多实例并发。
### 工作量判断
-
- 适合作为下一步的首选实现项
## 2. `BG_SESSIONS`
### 现状
- fast-path 已接好:
[src/entrypoints/cli.tsx](</e:/Source_code/Claude-code-bast/src/entrypoints/cli.tsx:218>)
- session registry 已有真实实现:
[src/utils/concurrentSessions.ts](</e:/Source_code/Claude-code-bast/src/utils/concurrentSessions.ts:1>)
- `exit` 在 bg session 内已会 `tmux detach-client`
[src/commands/exit/exit.tsx](</e:/Source_code/Claude-code-bast/src/commands/exit/exit.tsx:20>)
- 但 CLI handler 仍全空:
[src/cli/bg.ts](</e:/Source_code/Claude-code-bast/src/cli/bg.ts:1>)
- task summary 仍然是 stub
[src/utils/taskSummary.ts](</e:/Source_code/Claude-code-bast/src/utils/taskSummary.ts:1>)
### 目标
- 先把 `ps` / `logs` / `kill` 做成真正有用的 session 管理命令。
- 不在第一阶段就强行补完 `attach` / `--bg`
### Phase 2AMVP
- 实现 `ps`
- 从 registry 读取 live sessions
- 展示 pid、kind、sessionId、cwd、name、startedAt、bridgeSessionId
- 如果有 activity/status则一并展示
- 实现 `logs`
- 支持按 `sessionId / pid / name` 查找
- 优先复用本地 transcript/log 读取能力
- 如果 registry 里存在 `logPath`,支持 tail 文件
- 实现 `kill`
- 解析目标 session
- 发退出信号
- 清理 stale registry
### Phase 2B后续
- 实现 `attach`
- 实现 `--bg`
- 实现 `taskSummary` 的中途状态更新
### 为什么要拆
- 现有 registry 记录了 `pid / sessionId / name / logPath`
- 但没有可靠的 tmux attach target
- 所以 `attach``--bg` 不是简单补 handler而是需要补启动/附着元数据设计
### 代码范围
- 修改 [src/cli/bg.ts](</e:/Source_code/Claude-code-bast/src/cli/bg.ts:1>)
- 修改 [src/utils/concurrentSessions.ts](</e:/Source_code/Claude-code-bast/src/utils/concurrentSessions.ts:1>) 以便后续 attach/--bg 扩展
- 修改 [src/utils/taskSummary.ts](</e:/Source_code/Claude-code-bast/src/utils/taskSummary.ts:1>)
- 复用:
[src/utils/sessionStorage.ts](</e:/Source_code/Claude-code-bast/src/utils/sessionStorage.ts:3870>)
[src/utils/udsClient.ts](</e:/Source_code/Claude-code-bast/src/utils/udsClient.ts:1>)
### 验证
1. `ps` 能列出 live sessions
2. `logs <sessionId|pid|name>` 能输出对应日志
3. `kill <sessionId|pid|name>` 能结束目标 session
### 风险
- `attach` / `--bg` 第二阶段需要 tmux 元数据设计
- Windows 下 tmux 路径需要明确降级策略
### 工作量判断
- `ps/logs/kill` 中等
- `attach/--bg` 明显更大,应分阶段
## 3. `TEMPLATES`
### 现状
- 命令入口只有 fast-path
[src/entrypoints/cli.tsx](</e:/Source_code/Claude-code-bast/src/entrypoints/cli.tsx:249>)
- handler 是空的:
[src/cli/handlers/templateJobs.ts](</e:/Source_code/Claude-code-bast/src/cli/handlers/templateJobs.ts:1>)
- `markdownConfigLoader` 已把 `templates` 纳入配置目录:
[src/utils/markdownConfigLoader.ts](</e:/Source_code/Claude-code-bast/src/utils/markdownConfigLoader.ts:29>)
- `query / stopHooks` 已预留 job classifier 链路:
[src/query/stopHooks.ts](</e:/Source_code/Claude-code-bast/src/query/stopHooks.ts:103>)
- `jobs/classifier.ts` 仍是 stub
[src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
### 目标
-`new / list / reply` 做成可用的模板任务系统。
- 第一阶段不碰复杂的自动分类与自动执行。
### MVP 方案
- 模板来源:
`.claude/templates/*.md`
- 模板格式:
复用现有 markdown + frontmatter 解析,不另外设计 DSL
- `list`
- 列出所有模板
- 显示模板名、description、路径
- `new <template> [args...]`
- 解析模板
-`~/.claude/jobs/<job-id>/` 下创建 job 目录
- 写入 `template.md``input.txt``state.json`
- 返回 job id 与目录
- `reply <job-id> <text>`
- 将回复写入 `replies.jsonl``input.txt`
- 更新 `state.json`
### Phase 2
- 恢复 [src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
- 让带 `CLAUDE_JOB_DIR` 的 job session 在 turn 完成后自动更新 `state.json`
- 再决定是否补自动 job runner
### 为什么要拆
- 当前证据表明这是“template job commands”不是单纯模板列表
- 但自动 job 运行链路没有足够现成实现,先做文件系统 job lifecycle 更稳
### 代码范围
- 修改 [src/cli/handlers/templateJobs.ts](</e:/Source_code/Claude-code-bast/src/cli/handlers/templateJobs.ts:1>)
- 新增 `src/jobs/state.ts`
- 新增 `src/jobs/templates.ts`
- Phase 2 再改 [src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
### 验证
1. `list` 能列出 `.claude/templates`
2. `new` 能创建 job 目录和状态文件
3. `reply` 能更新 job 内容和状态
4. Phase 2 再验证 classifier 写状态
### 风险
- frontmatter schema 需要先定义最小字段集
- 一旦扩展到“自动运行 job”范围会明显膨胀
### 工作量判断
- MVP 中等
- 完整 job 系统偏大
## 4. `assistant [sessionId]`
### 现状
- attach 主流程其实已经存在:
[src/main.tsx](</e:/Source_code/Claude-code-bast/src/main.tsx:4708>)
- 远端 viewer 所需基础模块已存在:
[src/remote/RemoteSessionManager.ts](</e:/Source_code/Claude-code-bast/src/remote/RemoteSessionManager.ts:1>)
[src/hooks/useAssistantHistory.ts](</e:/Source_code/Claude-code-bast/src/hooks/useAssistantHistory.ts:1>)
[src/assistant/sessionHistory.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionHistory.ts:1>)
- 真正 stub 的主要是:
[src/assistant/sessionDiscovery.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionDiscovery.ts:1>)
[src/assistant/AssistantSessionChooser.ts](</e:/Source_code/Claude-code-bast/src/assistant/AssistantSessionChooser.ts:1>)
[src/commands/assistant/assistant.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/assistant.ts:7>)
[src/assistant/index.ts](</e:/Source_code/Claude-code-bast/src/assistant/index.ts:1>)
### 目标
- 不一次性恢复整个 KAIROS 助手系统。
- 先做“明确 sessionId 的 viewer attach 可用”,再逐步补 discovery / chooser / install。
### Phase 4AMVP
- 只支持 `claude assistant <sessionId>`
-`claude assistant` 无参数模式,先返回明确提示:
- 当前版本需要显式 `sessionId`
- discovery 尚未启用
- 这样可以直接复用现有 attach 分支,不必先恢复 chooser/install wizard
### Phase 4B
- 恢复 `discoverAssistantSessions()`
- 数据来源优先复用现有 sessions / bridge / teleport API而不是新协议
-`claude assistant` 无参数时能拿到候选 session 列表
### Phase 4C
- 恢复 `AssistantSessionChooser`
- 多 session 时可交互选择
### Phase 4D
- 最后考虑 install wizard 辅助函数
- 这部分属于“没有 session 时如何引导”,不是 attach 核心路径
### 为什么要拆
- attach 渲染层与远端消息通道大部分已经在
- 真正缺的是“如何发现目标 session”和“如何交互选择”
- 如果把 `src/assistant/index.ts` 的整套 KAIROS 正常模式也一起拉进来,范围会失控
### 代码范围
- Phase 4A
- [src/main.tsx](</e:/Source_code/Claude-code-bast/src/main.tsx:4708>)
- [src/commands/assistant/index.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/index.ts:1>)
- Phase 4B
- [src/assistant/sessionDiscovery.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionDiscovery.ts:1>)
- Phase 4C
- [src/assistant/AssistantSessionChooser.ts](</e:/Source_code/Claude-code-bast/src/assistant/AssistantSessionChooser.ts:1>)
- Phase 4D
- [src/commands/assistant/assistant.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/assistant.ts:7>)
### 验证
1. `claude assistant <sessionId>` 能进入 remote viewer
2. 历史懒加载工作正常
3. 无参数模式先给出明确提示
4. 后续阶段再分别验证 discovery / chooser / install
### 风险
- 这是四项里范围最大的
- 一旦把 KAIROS 正常模式整体拉入会从“viewer attach”膨胀成“完整 assistant mode 恢复”
### 工作量判断
- Phase 4A 中等
- 4A-4D 全做完很大
## 建议执行顺序
1. `claude daemon status` / `claude daemon stop`
2. `BG_SESSIONS` 先做 `ps/logs/kill`
3. `TEMPLATES` 先做 job 文件系统 MVP
4. `assistant [sessionId]` 先做显式 sessionId attach再补 discovery/chooser/install
## 简短结论
这四项里,最适合立刻实现的是 `daemon status/stop``BG_SESSIONS``TEMPLATES` 适合按 MVP 先补 handler 与文件系统闭环。`assistant [sessionId]` 不能整块硬上应该按“attach → discovery → chooser → install”拆开恢复。

View File

@@ -1,77 +0,0 @@
# Task 001: daemon status / stop
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 1 项
> 优先级: P0 (首选实现项)
> 工作量: 小
> 状态: DONE
## 目标
`claude daemon status``claude daemon stop` 在任意 CLI 进程中都能正确工作,不依赖 TUI 内存态。
## 背景
- `start` 路径已有完整 supervisor + worker 生命周期 (`src/daemon/main.ts`, `src/daemon/workerRegistry.ts`)
- `status` / `stop` 目前只是占位输出 (`src/daemon/main.ts:49`)
- `/remote-control-server` 有自己的命令内 UI 状态,但只维护当前进程内的 `daemonProcess`,不适合跨进程管理
## 实现方案
### 新增文件
| 文件 | 说明 |
|------|------|
| `src/daemon/state.ts` | daemon 状态文件读写模块 |
### 修改文件
| 文件 | 改动 |
|------|------|
| `src/daemon/main.ts` | `start` 写入状态文件;`status`/`stop` 调用 state 模块 |
| `src/commands/remoteControlServer/remoteControlServer.tsx` | 读取同一份状态文件(轻量改动) |
### 状态文件
路径: `~/.claude/daemon/remote-control.json`
```json
{
"pid": 12345,
"cwd": "/path/to/project",
"startedAt": "2026-04-12T10:00:00Z",
"workerKinds": ["bridge", "rcs"],
"lastStatus": "running"
}
```
### status 逻辑
1. 读取状态文件
2. 用进程探测验证 pid 是否存活
3. 输出 `running` / `stopped` / `stale`
4. stale 时自动清理状态文件
### stop 逻辑
1. 读取 pid
2. 发送 `SIGTERM`
3. 等待退出(超时兜底)
4. 超时后 `SIGKILL`
5. 清理状态文件
## 验证步骤
- [ ] `claude daemon start` 正常启动并写入状态文件
- [ ] 新开终端执行 `claude daemon status`,显示 `running`
- [ ] 执行 `claude daemon stop`daemon 正常退出
- [ ] 再次执行 `claude daemon status`,返回 `stopped``stale cleaned`
- [ ] Windows 下 stop 超时兜底正常工作
## 风险
- Windows 信号模型和 Unix 不同,`stop` 需要超时兜底
- 当前设计默认单 supervisor不处理多实例并发
## 依赖
无外部依赖,可独立实施。

View File

@@ -1,80 +0,0 @@
# Task 002: BG_SESSIONS — ps / logs / kill
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 2 项
> 优先级: P1
> 工作量: 中等
> 状态: DONE
> 阶段: Phase 2A (MVP)
## 目标
`ps` / `logs` / `kill` 做成真正有用的 session 管理命令。不在第一阶段补完 `attach` / `--bg`
## 背景
- fast-path 已接好 (`src/entrypoints/cli.tsx:218`)
- session registry 已有真实实现 (`src/utils/concurrentSessions.ts`)
- `exit` 在 bg session 内已会 `tmux detach-client` (`src/commands/exit/exit.tsx:20`)
- CLI handler 仍全空 (`src/cli/bg.ts`)
- task summary 仍然是 stub (`src/utils/taskSummary.ts`)
## 实现方案
### 修改文件
| 文件 | 改动 |
|------|------|
| `src/cli/bg.ts` | 实现 `ps` / `logs` / `kill` handler |
| `src/utils/concurrentSessions.ts` | 扩展以便后续 attach/--bg 使用 |
| `src/utils/taskSummary.ts` | 补充基础实现 |
### 复用模块
- `src/utils/sessionStorage.ts` — session 存储
- `src/utils/udsClient.ts` — UDS 通信
### ps 命令
- 从 registry 读取 live sessions
- 展示: pid, kind, sessionId, cwd, name, startedAt, bridgeSessionId
- 如果有 activity/status一并展示
### logs 命令
- 支持按 `sessionId` / `pid` / `name` 查找
- 优先复用本地 transcript/log 读取能力
- 如果 registry 里存在 `logPath`,支持 tail 文件
### kill 命令
- 解析目标 session
- 发退出信号
- 清理 stale registry
## 验证步骤
- [ ] `ps` 能列出当前 live sessions
- [ ] `logs <sessionId|pid|name>` 能输出对应日志
- [ ] `kill <sessionId|pid|name>` 能结束目标 session 并清理 registry
- [ ] 无 live session 时各命令有明确提示
## Phase 2B (后续)
- [ ] 实现 `attach`
- [ ] 实现 `--bg`
- [ ] 实现 `taskSummary` 的中途状态更新
### 为什么拆分
- 现有 registry 记录了 `pid / sessionId / name / logPath`
- 但没有可靠的 tmux attach target
- `attach``--bg` 需要补启动/附着元数据设计,不是简单补 handler
## 风险
- `attach` / `--bg` 第二阶段需要 tmux 元数据设计
- Windows 下 tmux 路径需要明确降级策略
## 依赖
- Task 001 (daemon 状态管理可复用模式,但非硬性依赖)

View File

@@ -1,87 +0,0 @@
# Task 003: TEMPLATES — job 文件系统 MVP
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 3 项
> 优先级: P2
> 工作量: 中等
> 状态: DONE
> 阶段: MVP
## 目标
`new` / `list` / `reply` 做成可用的模板任务系统。第一阶段不碰复杂的自动分类与自动执行。
## 背景
- 命令入口只有 fast-path (`src/entrypoints/cli.tsx:249`)
- handler 是空的 (`src/cli/handlers/templateJobs.ts`)
- `markdownConfigLoader` 已把 `templates` 纳入配置目录 (`src/utils/markdownConfigLoader.ts:29`)
- `query/stopHooks` 已预留 job classifier 链路 (`src/query/stopHooks.ts:103`)
- `jobs/classifier.ts` 仍是 stub (`src/jobs/classifier.ts`)
## 实现方案
### 新增文件
| 文件 | 说明 |
|------|------|
| `src/jobs/state.ts` | job 状态管理 |
| `src/jobs/templates.ts` | 模板解析与列表 |
### 修改文件
| 文件 | 改动 |
|------|------|
| `src/cli/handlers/templateJobs.ts` | 实现 `new` / `list` / `reply` handler |
### 模板来源
`.claude/templates/*.md`
### 模板格式
复用现有 markdown + frontmatter 解析,不另外设计 DSL。
### list 命令
- 列出所有模板
- 显示: 模板名, description, 路径
### new 命令
- 解析模板
-`~/.claude/jobs/<job-id>/` 下创建 job 目录
- 写入 `template.md`, `input.txt`, `state.json`
- 返回 job id 与目录路径
### reply 命令
- 将回复写入 `replies.jsonl``input.txt`
- 更新 `state.json`
## 验证步骤
- [ ] `list` 能列出 `.claude/templates` 下的所有模板
- [ ] `new <template> [args...]` 能创建 job 目录和状态文件
- [ ] `reply <job-id> <text>` 能更新 job 内容和状态
- [ ] frontmatter schema 最小字段集已定义
## Phase 2 (后续)
- [ ] 恢复 `src/jobs/classifier.ts`
- [ ] 让带 `CLAUDE_JOB_DIR` 的 job session 在 turn 完成后自动更新 `state.json`
- [ ] 再决定是否补自动 job runner
### 为什么拆分
- 当前是 "template job commands",不是单纯模板列表
- 自动 job 运行链路没有足够现成实现
- 先做文件系统 job lifecycle 更稳
## 风险
- frontmatter schema 需要先定义最小字段集
- 一旦扩展到"自动运行 job",范围会明显膨胀
## 依赖
无硬性依赖,可独立实施。

View File

@@ -1,103 +0,0 @@
# Task 004: assistant [sessionId] — 分阶段恢复
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 4 项
> 优先级: P3
> 工作量: Phase 4A 中等4A-4D 全做完很大
> 状态: Phase 4A DONE, 4B-4D TODO
## 目标
不一次性恢复整个 KAIROS 助手系统。先做"明确 sessionId 的 viewer attach 可用",再逐步补 discovery / chooser / install。
## 背景
- attach 主流程已存在 (`src/main.tsx:4708`)
- 远端 viewer 所需基础模块已存在:
- `src/remote/RemoteSessionManager.ts`
- `src/hooks/useAssistantHistory.ts`
- `src/assistant/sessionHistory.ts`
- 真正 stub 的主要是:
- `src/assistant/sessionDiscovery.ts`
- `src/assistant/AssistantSessionChooser.ts`
- `src/commands/assistant/assistant.ts:7`
- `src/assistant/index.ts`
## 分阶段实现
### Phase 4A: MVP — 显式 sessionId attach
**修改文件:**
| 文件 | 改动 |
|------|------|
| `src/main.tsx` | 确保 attach 分支可用 |
| `src/commands/assistant/index.ts` | 实现显式 sessionId 参数入口 |
**行为:**
- `claude assistant <sessionId>` — 进入 remote viewer
- `claude assistant` (无参数) — 返回明确提示: 当前版本需要显式 sessionIddiscovery 尚未启用
**验证:**
- [ ] `claude assistant <sessionId>` 能进入 remote viewer
- [ ] 历史懒加载工作正常
- [ ] 无参数模式给出明确提示
### Phase 4B: session discovery
**修改文件:**
| 文件 | 改动 |
|------|------|
| `src/assistant/sessionDiscovery.ts` | 恢复 `discoverAssistantSessions()` |
**行为:**
- 数据来源优先复用现有 sessions / bridge / teleport API不新增协议
- `claude assistant` 无参数时能拿到候选 session 列表
**验证:**
- [ ] 无参数调用能列出可用 sessions
- [ ] 数据来源复用现有通道
### Phase 4C: session chooser
**修改文件:**
| 文件 | 改动 |
|------|------|
| `src/assistant/AssistantSessionChooser.ts` | 恢复交互式选择器 |
**行为:**
- 多 session 时可交互选择
**验证:**
- [ ] 多个 session 时弹出选择器
- [ ] 选择后正确 attach
### Phase 4D: install wizard
**修改文件:**
| 文件 | 改动 |
|------|------|
| `src/commands/assistant/assistant.ts` | 恢复 install wizard 辅助函数 |
**行为:**
- 没有 session 时如何引导用户
**验证:**
- [ ] 无可用 session 时引导用户创建/连接
## 为什么拆分
- attach 渲染层与远端消息通道大部分已在
- 真正缺的是"如何发现目标 session"和"如何交互选择"
- 如果把 `src/assistant/index.ts` 的整套 KAIROS 正常模式也一起拉进来,范围会失控
## 风险
- 这是四项里范围最大的
- 一旦把 KAIROS 正常模式整体拉入,会从"viewer attach"膨胀成"完整 assistant mode 恢复"
## 依赖
- Task 002 的 session registry 模式可复用

View File

@@ -1,196 +0,0 @@
# Task 013: BgEngine 跨平台后台引擎抽象
> 设计文档: [daemon-restructure-design.md](../features/daemon-restructure-design.md) § 四
> 依赖: 无
> 分支: `feat/integrate-5-branches`
## 目标
`src/cli/bg.ts` 中硬编码的 tmux 逻辑提取为引擎抽象层,实现 TmuxEngine + DetachedEngine使后台会话功能在 Windows / macOS / Linux 上都能工作。
## 背景
当前 `bg.ts``handleBgFlag()``attachHandler()` 直接调用 tmux 命令。Windows 上 `--bg` 直接报错退出。需要一个引擎抽象层,根据平台和可用工具自动选择最佳方案。
## 文件清单
### 新增
| 文件 | 说明 |
|------|------|
| `src/cli/bg/engine.ts` | BgEngine 接口 + BgStartOptions/BgStartResult 类型 |
| `src/cli/bg/engines/tmux.ts` | TmuxEngine: 从 `bg.ts` 提取 tmux 相关逻辑 |
| `src/cli/bg/engines/detached.ts` | DetachedEngine: spawn({ detached }) + logFile 重定向 |
| `src/cli/bg/engines/index.ts` | selectEngine() 自动选择 + re-export |
| `src/cli/bg/tail.ts` | 跨平台日志 tail: fs.watch + 轮询 fallback |
### 修改
| 文件 | 变更 |
|------|------|
| `src/cli/bg.ts` | `handleBgFlag()` 改为调用 `selectEngine().start()``attachHandler()` 改为调用 `engine.attach()` |
## 实现方案
### 1. BgEngine 接口 (`src/cli/bg/engine.ts`)
```typescript
export interface BgEngine {
readonly name: string
available(): Promise<boolean>
start(opts: BgStartOptions): Promise<BgStartResult>
attach(session: SessionEntry): Promise<void>
}
export interface BgStartOptions {
sessionName: string
args: string[] // CLI args (去除 --bg)
env: Record<string, string | undefined>
logPath: string
cwd: string
}
export interface BgStartResult {
pid: number
sessionName: string
logPath: string
engineUsed: 'tmux' | 'detached'
}
```
### 2. TmuxEngine (`src/cli/bg/engines/tmux.ts`)
`bg.ts:handleBgFlag()``bg.ts:attachHandler()` 提取:
- `available()`: `execFileNoThrow('tmux', ['-V'])` 返回 code === 0
- `start()`: `tmux new-session -d -s <name> <cmd>`
- `attach()`: `tmux attach-session -t <session.tmuxSessionName>`
### 3. DetachedEngine (`src/cli/bg/engines/detached.ts`)
```typescript
export class DetachedEngine implements BgEngine {
readonly name = 'detached'
async available(): Promise<boolean> {
return true // 总是可用
}
async start(opts: BgStartOptions): Promise<BgStartResult> {
const logFd = openSync(opts.logPath, 'a')
const child = spawn(process.execPath, [process.argv[1]!, ...opts.args], {
detached: true,
stdio: ['ignore', logFd, logFd],
env: opts.env,
cwd: opts.cwd,
})
child.unref()
closeSync(logFd)
return {
pid: child.pid!,
sessionName: opts.sessionName,
logPath: opts.logPath,
engineUsed: 'detached',
}
}
async attach(session: SessionEntry): Promise<void> {
// 委托给 tail.ts
await tailLog(session.logPath!)
}
}
```
### 4. 日志 Tail (`src/cli/bg/tail.ts`)
```typescript
/**
* 跨平台实时日志输出。Ctrl+C 退出,不杀后台进程。
*
* 策略:
* 1. 读取已有内容输出
* 2. fs.watch() 监听文件变化 (主方案)
* 3. 如果 fs.watch 不可靠 (某些 Windows 网络驱动器)fallback 到 500ms 轮询
*/
export async function tailLog(logPath: string): Promise<void>
```
### 5. 引擎选择 (`src/cli/bg/engines/index.ts`)
```typescript
export async function selectEngine(): Promise<BgEngine> {
if (process.platform === 'win32') {
return new DetachedEngine()
}
const tmux = new TmuxEngine()
if (await tmux.available()) {
return tmux
}
return new DetachedEngine()
}
```
### 6. bg.ts 重构
`handleBgFlag()` 改名为 `handleBgStart()`,内部逻辑:
```typescript
export async function handleBgStart(args: string[]): Promise<void> {
const engine = await selectEngine()
const sessionName = `claude-bg-${randomUUID().slice(0, 8)}`
const logPath = join(getClaudeConfigHomeDir(), 'sessions', 'logs', `${sessionName}.log`)
const result = await engine.start({
sessionName,
args: filteredArgs,
env: { ...process.env, CLAUDE_CODE_SESSION_KIND: 'bg', ... },
logPath,
cwd: process.cwd(),
})
console.log(`Background session started: ${result.sessionName}`)
console.log(` Engine: ${result.engineUsed}`)
console.log(` Log: ${result.logPath}`)
console.log(` Use \`claude daemon attach ${result.sessionName}\` to reconnect.`)
}
```
`attachHandler()` 根据 `session.engine` 字段选择引擎:
```typescript
export async function attachHandler(target: string | undefined): Promise<void> {
// ... 找到 session
if (session.engine === 'tmux' && session.tmuxSessionName) {
const tmux = new TmuxEngine()
await tmux.attach(session)
} else {
const detached = new DetachedEngine()
await detached.attach(session)
}
}
```
## SessionEntry 扩展
`sessions/<PID>.json` 新增 `engine` 字段:
```json
{
"pid": 12345,
"engine": "detached",
"logPath": "~/.claude/sessions/logs/claude-bg-a1b2c3d4.log",
"sessionId": "...",
"cwd": "..."
}
```
兼容旧格式: 如果 `engine` 字段缺失,检查 `tmuxSessionName` 存在则为 `tmux`,否则为 `detached`
## 验证清单
- [ ] Windows: `claude daemon bg` 启动后台会话,无 tmux 依赖
- [ ] Windows: `claude daemon attach <name>` 以 tail 模式附着Ctrl+C 退出不杀进程
- [ ] macOS/Linux (有 tmux): 行为与当前一致
- [ ] macOS/Linux (无 tmux): 自动 fallback 到 detached 引擎
- [ ] `claude daemon status` 正确显示 engine 类型
- [ ] 旧格式 session JSON (无 engine 字段) 兼容
- [ ] tsc --noEmit 零错误
- [ ] bun test 通过

View File

@@ -1,275 +0,0 @@
# Task 014: /daemon 命令层级化
> 设计文档: [daemon-restructure-design.md](../features/daemon-restructure-design.md) § 三.1
> 依赖: Task 013 (BgEngine 抽象)
> 分支: `feat/integrate-5-branches`
## 目标
将散落的 `daemon start/stop/status` + `ps/logs/attach/kill` + `--bg` 统一收归 `/daemon` 命名空间,实现 CLI + REPL 双注册。
## 背景
当前这些命令注册在两个互不关联的位置:
- `cli.tsx:203-212`: `daemon [start|status|stop]``daemon/main.ts`
- `cli.tsx:217-246`: `ps|logs|attach|kill|--bg``cli/bg.ts`
需要合并为统一的 `claude daemon <subcommand>` 入口,并新增 REPL `/daemon` 斜杠命令。
## 文件清单
### 新增
| 文件 | 说明 |
|------|------|
| `src/commands/daemon/index.ts` | `/daemon` REPL 斜杠命令注册 (type: local-jsx) |
| `src/commands/daemon/daemon.tsx` | `/daemon` 子命令路由 + status UI 组件 |
### 修改
| 文件 | 变更 |
|------|------|
| `src/entrypoints/cli.tsx` | 统一 daemon 快速路径: `daemon <sub>` 路由到对应 handler。旧命令 `ps/logs/attach/kill` 保留但输出 deprecation 警告后代理 |
| `src/commands.ts` | 注册 `/daemon` 斜杠命令 (feature-gated: DAEMON \|\| BG_SESSIONS) |
| `src/daemon/main.ts` | `daemonMain()` 扩展: 支持 `bg/attach/logs/kill/ps` 子命令 (委托给 bg.ts handlers) |
## 实现方案
### 1. CLI 快速路径统一 (`cli.tsx`)
**改前** (两段独立路由):
```typescript
// 段 1: daemon
if (feature('DAEMON') && args[0] === 'daemon') {
await daemonMain(args.slice(1))
}
// 段 2: bg sessions
if (feature('BG_SESSIONS') && ['ps','logs','attach','kill'].includes(args[0])) {
// ...switch/case
}
```
**改后** (统一入口):
```typescript
// 统一 daemon 入口 — 合并 daemon supervisor + bg sessions
if (
(feature('DAEMON') || feature('BG_SESSIONS')) &&
args[0] === 'daemon'
) {
profileCheckpoint('cli_daemon_path')
const { enableConfigs } = await import('../utils/config.js')
enableConfigs()
const { initSinks } = await import('../utils/sinks.js')
initSinks()
const { daemonMain } = await import('../daemon/main.js')
await daemonMain(args.slice(1))
return
}
// --bg 快捷方式 → daemon bg
if (
feature('BG_SESSIONS') &&
(args.includes('--bg') || args.includes('--background'))
) {
profileCheckpoint('cli_daemon_path')
const { enableConfigs } = await import('../utils/config.js')
enableConfigs()
const bg = await import('../cli/bg.js')
await bg.handleBgStart(args.filter(a => a !== '--bg' && a !== '--background'))
return
}
// 向后兼容: ps/logs/attach/kill → daemon <sub> (deprecated)
if (
feature('BG_SESSIONS') &&
['ps', 'logs', 'attach', 'kill'].includes(args[0] ?? '')
) {
const mapped = args[0] === 'ps' ? 'status' : args[0]
console.error(`[deprecated] Use: claude daemon ${mapped} ${args.slice(1).join(' ')}`.trim())
const { enableConfigs } = await import('../utils/config.js')
enableConfigs()
const { daemonMain } = await import('../daemon/main.js')
await daemonMain([args[0]!, ...args.slice(1)])
return
}
```
### 2. daemonMain 扩展 (`daemon/main.ts`)
```typescript
export async function daemonMain(args: string[]): Promise<void> {
const subcommand = args[0] || 'status'
switch (subcommand) {
// --- Supervisor 管理 ---
case 'start':
await runSupervisor(args.slice(1))
break
case 'stop':
await handleDaemonStop()
break
// --- 会话管理 (委托给 bg.ts) ---
case 'status':
case 'ps':
await showUnifiedStatus() // 新: daemon 状态 + 会话列表
break
case 'bg':
const bg = await import('../cli/bg.js')
await bg.handleBgStart(args.slice(1))
break
case 'attach':
const bg2 = await import('../cli/bg.js')
await bg2.attachHandler(args[1])
break
case 'logs':
const bg3 = await import('../cli/bg.js')
await bg3.logsHandler(args[1])
break
case 'kill':
const bg4 = await import('../cli/bg.js')
await bg4.killHandler(args[1])
break
case '--help': case '-h': case 'help':
printHelp()
break
default:
console.error(`Unknown daemon subcommand: ${subcommand}`)
printHelp()
process.exitCode = 1
}
}
```
### 3. 统一状态面板 (`showUnifiedStatus`)
```typescript
async function showUnifiedStatus(): Promise<void> {
// 1. Daemon supervisor 状态
const daemonResult = queryDaemonStatus()
console.log('=== Daemon Supervisor ===')
switch (daemonResult.status) {
case 'running':
console.log(` Status: running (PID: ${daemonResult.state!.pid})`)
console.log(` Workers: ${daemonResult.state!.workerKinds.join(', ')}`)
break
case 'stopped':
console.log(' Status: stopped')
break
case 'stale':
console.log(' Status: stale (cleaned up)')
break
}
// 2. 后台会话列表
console.log('\n=== Background Sessions ===')
const bg = await import('../cli/bg.js')
await bg.psHandler([])
}
```
### 4. REPL 斜杠命令注册
**`src/commands/daemon/index.ts`**:
```typescript
import type { Command } from '../../commands.js'
import { feature } from 'bun:bundle'
const daemon = {
type: 'local-jsx',
name: 'daemon',
description: 'Manage background sessions and daemon',
argumentHint: '[status|start|stop|bg|attach|logs|kill]',
isEnabled: () => {
if (feature('DAEMON')) return true
if (feature('BG_SESSIONS')) return true
return false
},
load: () => import('./daemon.js'),
} satisfies Command
export default daemon
```
**`src/commands/daemon/daemon.tsx`**:
```typescript
export async function call(
onDone: LocalJSXCommandOnDone,
context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const parts = args.trim().split(/\s+/)
const sub = parts[0] || 'status'
switch (sub) {
case 'status':
case 'ps':
// 调用 showUnifiedStatus捕获输出
// 返回文本结果
break
case 'bg':
// REPL 中启动后台会话
break
case 'start':
case 'stop':
case 'attach':
case 'logs':
case 'kill':
// 委托给对应 handler
break
default:
onDone(`Unknown: ${sub}. Use: status|start|stop|bg|attach|logs|kill`)
return null
}
}
```
**`src/commands.ts`** 添加:
```typescript
// 条件导入
const daemonCmd =
feature('DAEMON') || feature('BG_SESSIONS')
? require('./commands/daemon/index.js').default
: null
// COMMANDS 数组中添加
...(daemonCmd ? [daemonCmd] : []),
```
### 5. 更新 help 文本 (`daemon/main.ts`)
```
Claude Code Daemon — background process management
USAGE
claude daemon [subcommand]
SUBCOMMANDS
status Show daemon and session status (default)
start Start the daemon supervisor
stop Stop the daemon
bg Start a background session
attach Attach to a background session
logs Show session logs
kill Kill a session
help Show this help
REPL
/daemon [subcommand] Same commands available in interactive mode
```
## 验证清单
- [ ] `claude daemon` (无参数) 显示统一状态面板
- [ ] `claude daemon status` 显示 supervisor + 会话列表
- [ ] `claude daemon start/stop` 与当前行为一致
- [ ] `claude daemon bg` 启动后台会话 (调用 BgEngine)
- [ ] `claude daemon attach/logs/kill <target>` 功能正常
- [ ] `claude ps` 输出 deprecation 警告 + 正常工作
- [ ] `claude logs/attach/kill` 同上
- [ ] `claude --bg` 快捷方式正常
- [ ] REPL 中 `/daemon` 可用tab 补全显示
- [ ] REPL 中 `/daemon status` 显示状态信息
- [ ] tsc --noEmit 零错误
- [ ] bun test 通过

View File

@@ -1,177 +0,0 @@
# Task 015: /job 命令层级化
> 设计文档: [daemon-restructure-design.md](../features/daemon-restructure-design.md) § 三.2
> 依赖: 无 (可与 Task 013 并行)
> 分支: `feat/integrate-5-branches`
## 目标
`claude new/list/reply` 收归 `/job` 命名空间,实现 CLI + REPL 双注册。
## 背景
当前 `new`, `list`, `reply` 是顶级 CLI 命令 (`cli.tsx:250-261`),容易与其他命令冲突(特别是 `list` 这种通用词)。需要收归 `claude job <subcommand>` 并新增 REPL `/job` 入口。
## 文件清单
### 新增
| 文件 | 说明 |
|------|------|
| `src/commands/job/index.ts` | `/job` REPL 斜杠命令注册 |
| `src/commands/job/job.tsx` | `/job` 子命令路由 |
### 修改
| 文件 | 变更 |
|------|------|
| `src/entrypoints/cli.tsx` | 新增 `job` 快速路径 + 旧 `new/list/reply` deprecation 代理 |
| `src/commands.ts` | 注册 `/job` 斜杠命令 |
### 不动
| 文件 | 说明 |
|------|------|
| `src/cli/handlers/templateJobs.ts` | 内部 handler 不变,只是被调用方式变了 |
| `src/jobs/state.ts` | job 状态管理不变 |
| `src/jobs/templates.ts` | 模板发现不变 |
| `src/jobs/classifier.ts` | 任务分类器不变 |
## 实现方案
### 1. CLI 快速路径 (`cli.tsx`)
**改后**:
```typescript
// 新: claude job <subcommand>
if (
feature('TEMPLATES') &&
args[0] === 'job'
) {
profileCheckpoint('cli_templates_path')
const { templatesMain } = await import('../cli/handlers/templateJobs.js')
await templatesMain(args.slice(1))
process.exit(0)
}
// 向后兼容 (deprecated)
if (
feature('TEMPLATES') &&
(args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')
) {
console.error(`[deprecated] Use: claude job ${args[0]} ${args.slice(1).join(' ')}`.trim())
profileCheckpoint('cli_templates_path')
const { templatesMain } = await import('../cli/handlers/templateJobs.js')
await templatesMain(args)
process.exit(0)
}
```
### 2. templateJobs.ts 新增 status 子命令
在现有 `switch` 中增加:
```typescript
case 'status':
handleStatus(args.slice(1))
break
```
```typescript
function handleStatus(args: string[]): void {
const jobId = args[0]
if (!jobId) {
console.error('Usage: claude job status <job-id>')
process.exitCode = 1
return
}
const state = readJobState(jobId)
if (!state) {
console.error(`Job not found: ${jobId}`)
process.exitCode = 1
return
}
console.log(`Job: ${state.jobId}`)
console.log(` Template: ${state.templateName}`)
console.log(` Status: ${state.status}`)
console.log(` Created: ${state.createdAt}`)
console.log(` Updated: ${state.updatedAt}`)
}
```
### 3. REPL 斜杠命令
**`src/commands/job/index.ts`**:
```typescript
import type { Command } from '../../commands.js'
import { feature } from 'bun:bundle'
const job = {
type: 'local-jsx',
name: 'job',
description: 'Manage template jobs',
argumentHint: '[list|new|reply|status]',
isEnabled: () => {
if (feature('TEMPLATES')) return true
return false
},
load: () => import('./job.js'),
} satisfies Command
export default job
```
**`src/commands/job/job.tsx`**:
```typescript
export async function call(
onDone: LocalJSXCommandOnDone,
_context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const parts = args.trim().split(/\s+/)
const sub = parts[0] || 'list'
// 委托给 templatesMain
const { templatesMain } = await import('../../cli/handlers/templateJobs.js')
// 捕获 console.log 输出作为结果返回给 REPL
const lines: string[] = []
const origLog = console.log
const origError = console.error
console.log = (...a: unknown[]) => lines.push(a.join(' '))
console.error = (...a: unknown[]) => lines.push(a.join(' '))
try {
await templatesMain([sub, ...parts.slice(1)])
} finally {
console.log = origLog
console.error = origError
}
onDone(lines.join('\n') || 'Done.', { display: 'system' })
return null
}
```
### 4. commands.ts 注册
```typescript
const jobCmd = feature('TEMPLATES')
? require('./commands/job/index.js').default
: null
// COMMANDS 数组:
...(jobCmd ? [jobCmd] : []),
```
## 验证清单
- [ ] `claude job list` 列出模板
- [ ] `claude job new <template>` 创建任务
- [ ] `claude job reply <id> <text>` 回复任务
- [ ] `claude job status <id>` 显示任务状态
- [ ] `claude job` (无参数) 等同于 `claude job list`
- [ ] `claude new/list/reply` 输出 deprecation 警告 + 正常工作
- [ ] REPL 中 `/job` 可用
- [ ] REPL 中 `/job list` 显示模板列表
- [ ] tsc --noEmit 零错误
- [ ] bun test 通过

View File

@@ -1,123 +0,0 @@
# Task 016: 向后兼容 + 测试
> 设计文档: [daemon-restructure-design.md](../features/daemon-restructure-design.md) § 五
> 依赖: Task 014, Task 015
> 分支: `feat/integrate-5-branches`
## 目标
确保旧命令向后兼容 (deprecation 警告 + 正常代理),并为重构后的命令结构编写测试。
## 文件清单
### 新增
| 文件 | 说明 |
|------|------|
| `src/daemon/__tests__/daemonMain.test.ts` | daemonMain 子命令路由测试 |
| `src/cli/bg/__tests__/engine.test.ts` | BgEngine 选择逻辑测试 |
| `src/cli/bg/__tests__/detached.test.ts` | DetachedEngine 启动/停止测试 |
| `src/cli/bg/__tests__/tail.test.ts` | 日志 tail 功能测试 |
### 修改
| 文件 | 变更 |
|------|------|
| `src/entrypoints/cli.tsx` | 确认 deprecation 路径正确代理 |
## 实现方案
### 1. 向后兼容矩阵
| 旧命令 | 新命令 | 处理方式 |
|--------|--------|---------|
| `claude ps` | `claude daemon status` | stderr 输出 `[deprecated] Use: claude daemon status`,然后执行 |
| `claude logs <x>` | `claude daemon logs <x>` | 同上 |
| `claude attach <x>` | `claude daemon attach <x>` | 同上 |
| `claude kill <x>` | `claude daemon kill <x>` | 同上 |
| `claude --bg` | `claude daemon bg` | 保留为快捷方式,**不** deprecate (太常用) |
| `claude new <t>` | `claude job new <t>` | stderr deprecation + 执行 |
| `claude list` | `claude job list` | stderr deprecation + 执行 |
| `claude reply <id>` | `claude job reply <id>` | stderr deprecation + 执行 |
**关键**: deprecation 输出到 stderr 而非 stdout不影响脚本管道。
### 2. 测试计划
#### 2.1 daemonMain 路由测试
```typescript
describe('daemonMain', () => {
test('无参数默认 status', async () => { ... })
test('start 调用 runSupervisor', async () => { ... })
test('stop 调用 handleDaemonStop', async () => { ... })
test('bg 委托给 bg.handleBgStart', async () => { ... })
test('attach 委托给 bg.attachHandler', async () => { ... })
test('logs 委托给 bg.logsHandler', async () => { ... })
test('kill 委托给 bg.killHandler', async () => { ... })
test('未知子命令设置 exitCode=1', async () => { ... })
})
```
#### 2.2 引擎选择测试
```typescript
describe('selectEngine', () => {
test('win32 返回 DetachedEngine', async () => { ... })
test('darwin + tmux 可用返回 TmuxEngine', async () => { ... })
test('darwin + tmux 不可用返回 DetachedEngine', async () => { ... })
test('linux + tmux 可用返回 TmuxEngine', async () => { ... })
})
```
#### 2.3 DetachedEngine 测试
```typescript
describe('DetachedEngine', () => {
test('available 始终返回 true', async () => { ... })
test('start 创建 detached 子进程并写入日志', async () => { ... })
test('start 返回的 PID 文件存在', async () => { ... })
})
```
#### 2.4 Tail 测试
```typescript
describe('tailLog', () => {
test('输出已有日志内容', async () => { ... })
test('追加内容时实时输出', async () => { ... })
test('SIGINT 退出 tail', async () => { ... })
})
```
### 3. 集成验证脚本
可选: 在 `scripts/` 下添加一个手动验证脚本:
```bash
#!/bin/bash
# scripts/verify-daemon-restructure.sh
echo "=== 1. claude daemon status ==="
bun run dev -- daemon status
echo "=== 2. claude daemon bg (should start) ==="
bun run dev -- daemon bg --help
echo "=== 3. claude ps (deprecated) ==="
bun run dev -- ps 2>&1 | head -1
echo "=== 4. claude job list ==="
bun run dev -- job list
echo "=== 5. claude list (deprecated) ==="
bun run dev -- list 2>&1 | head -1
```
## 验证清单
- [ ] 旧命令全部正常工作 (仅多一行 stderr 警告)
- [ ] `--bg` 保持无警告
- [ ] 所有新增测试通过
- [ ] 现有 2695 个测试无回归
- [ ] tsc --noEmit 零错误
- [ ] 手动在 Windows + macOS/Linux 上验证关键路径

View File

@@ -1,88 +0,0 @@
# OpenClaw Autonomy Baseline Test Spec
## Purpose
This test spec locks the current behavior of the existing trigger and context layers before any formal autonomy-subsystem implementation begins.
At this stage, production code is read-only. Only test files, fixtures, and planning documents may change.
## Goal
Establish a stable baseline around the parts of `Claude-code-bast` that later autonomy work is most likely to touch:
- proactive state handling
- cron task storage semantics
- cron scheduler helper semantics
- user-context cache and `CLAUDE.md` injection behavior
## Out of Scope for This Baseline Round
- New authority behavior (`AGENTS.md` / `HEARTBEAT.md`)
- New detached-run ledger behavior
- New flow behavior
- UI redesign
## Files Under Baseline Protection
- `src/proactive/index.ts`
- `src/utils/cronTasks.ts`
- `src/utils/cronScheduler.ts`
- `src/context.ts`
## Test Files Added In This Round
- `src/proactive/__tests__/state.baseline.test.ts`
- `src/commands/__tests__/proactive.baseline.test.ts`
- `src/utils/__tests__/cronTasks.baseline.test.ts`
- `src/utils/__tests__/cronScheduler.baseline.test.ts`
- `src/__tests__/context.baseline.test.ts`
## Baseline Assertions
### Proactive state
1. Activating proactive mode sets active state and activation source.
2. Pausing proactive mode suppresses `shouldTick()` and clears `nextTickAt`.
3. Blocking context suppresses `shouldTick()` and clears `nextTickAt`.
4. Subscribers are notified on state transitions.
5. The `/proactive` command enables proactive mode and emits the expected hidden reminder.
6. The `/proactive` command disables proactive mode on the second invocation.
### Cron task storage
1. Session-only cron tasks remain in memory only.
2. Durable cron tasks are persisted to `.claude/scheduled_tasks.json`.
3. Daemon-style `dir`-scoped reads exclude session-only cron tasks.
4. `removeCronTasks()` without `dir` can remove session-only tasks.
5. `removeCronTasks()` with `dir` does not mutate session-only task storage.
### Cron scheduler helpers
1. `isRecurringTaskAged()` preserves current aging semantics.
2. `buildMissedTaskNotification()` preserves the current AskUserQuestion safety wording.
3. `buildMissedTaskNotification()` preserves code-fence hardening for prompt bodies that contain backticks.
### User context caching
1. `getUserContext()` includes `currentDate`.
2. `getUserContext()` includes mocked `claudeMd` content when memory loading is enabled.
3. `CLAUDE_CODE_DISABLE_CLAUDE_MDS` suppresses `claudeMd`.
4. `setSystemPromptInjection()` clears the memoized user-context cache.
5. `getSystemContext()` reflects the injection after cache invalidation.
## Remaining Baseline Gaps
The following areas are intentionally deferred because they require higher-cost harnessing and should still avoid production-code changes:
1. `useScheduledTasks.ts` hook-level runtime behavior
2. `src/cli/print.ts` full headless scheduler loop behavior
3. `useProactive.ts` hook timer behavior
4. end-to-end queue interaction between proactive ticks and `SleepTool`
## Acceptance
This baseline round is complete when:
1. The four new test files pass.
2. No production source files are modified.
3. The tests are stable enough to serve as a pre-implementation guardrail.

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-code-best", "name": "claude-code-best",
"version": "1.3.5", "version": "1.3.6",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module", "type": "module",
"author": "claude-code-best <claude-code-best@proton.me>", "author": "claude-code-best <claude-code-best@proton.me>",
@@ -55,13 +55,10 @@
"rcs": "bun run scripts/rcs.ts" "rcs": "bun run scripts/rcs.ts"
}, },
"dependencies": { "dependencies": {
"ws": "^8.20.0",
"@claude-code-best/mcp-chrome-bridge": "^2.0.7" "@claude-code-best/mcp-chrome-bridge": "^2.0.7"
}, },
"devDependencies": { "devDependencies": {
"@types/he": "^1.2.3",
"@langfuse/otel": "^5.1.0",
"@langfuse/tracing": "^5.1.0",
"@types/lodash-es": "^4.17.12",
"@alcalzone/ansi-tokenize": "^0.3.0", "@alcalzone/ansi-tokenize": "^0.3.0",
"@ant/claude-for-chrome-mcp": "workspace:*", "@ant/claude-for-chrome-mcp": "workspace:*",
"@ant/computer-use-input": "workspace:*", "@ant/computer-use-input": "workspace:*",
@@ -75,9 +72,6 @@
"@anthropic-ai/sdk": "^0.80.0", "@anthropic-ai/sdk": "^0.80.0",
"@anthropic-ai/vertex-sdk": "^0.14.4", "@anthropic-ai/vertex-sdk": "^0.14.4",
"@anthropic/ink": "workspace:*", "@anthropic/ink": "workspace:*",
"@claude-code-best/builtin-tools": "workspace:*",
"@claude-code-best/agent-tools": "workspace:*",
"@claude-code-best/mcp-client": "workspace:*",
"@aws-sdk/client-bedrock": "^3.1020.0", "@aws-sdk/client-bedrock": "^3.1020.0",
"@aws-sdk/client-bedrock-runtime": "^3.1020.0", "@aws-sdk/client-bedrock-runtime": "^3.1020.0",
"@aws-sdk/client-sts": "^3.1020.0", "@aws-sdk/client-sts": "^3.1020.0",
@@ -85,8 +79,13 @@
"@aws-sdk/credential-providers": "^3.1020.0", "@aws-sdk/credential-providers": "^3.1020.0",
"@azure/identity": "^4.13.1", "@azure/identity": "^4.13.1",
"@biomejs/biome": "^2.4.10", "@biomejs/biome": "^2.4.10",
"@claude-code-best/agent-tools": "workspace:*",
"@claude-code-best/builtin-tools": "workspace:*",
"@claude-code-best/mcp-client": "workspace:*",
"@commander-js/extra-typings": "^14.0.0", "@commander-js/extra-typings": "^14.0.0",
"@growthbook/growthbook": "^1.6.5", "@growthbook/growthbook": "^1.6.5",
"@langfuse/otel": "^5.1.0",
"@langfuse/tracing": "^5.1.0",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.1", "@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.214.0", "@opentelemetry/api-logs": "^0.214.0",
@@ -109,8 +108,11 @@
"@sentry/node": "^10.47.0", "@sentry/node": "^10.47.0",
"@smithy/core": "^3.23.13", "@smithy/core": "^3.23.13",
"@smithy/node-http-handler": "^4.5.1", "@smithy/node-http-handler": "^4.5.1",
"@types/bun": "^1.3.11", "@types/bun": "^1.3.12",
"@types/cacache": "^20.0.1", "@types/cacache": "^20.0.1",
"@types/he": "^1.2.3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.6.0",
"@types/picomatch": "^4.0.3", "@types/picomatch": "^4.0.3",
"@types/plist": "^3.0.5", "@types/plist": "^3.0.5",
"@types/proper-lockfile": "^4.1.4", "@types/proper-lockfile": "^4.1.4",
@@ -184,7 +186,6 @@
"vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-protocol": "^3.17.5",
"vscode-languageserver-types": "^3.17.5", "vscode-languageserver-types": "^3.17.5",
"wrap-ansi": "^10.0.0", "wrap-ansi": "^10.0.0",
"ws": "^8.20.0",
"xss": "^1.0.15", "xss": "^1.0.15",
"yaml": "^2.8.3", "yaml": "^2.8.3",
"zod": "^4.3.6" "zod": "^4.3.6"

View File

@@ -5,9 +5,12 @@
* mouse and keyboard via CoreGraphics events and System Events. * mouse and keyboard via CoreGraphics events and System Events.
*/ */
import { $ } from 'bun' import { execFile, execFileSync } from 'child_process'
import { promisify } from 'util'
import type { FrontmostAppInfo, InputBackend } from '../types.js' import type { FrontmostAppInfo, InputBackend } from '../types.js'
const execFileAsync = promisify(execFile)
const KEY_MAP: Record<string, number> = { const KEY_MAP: Record<string, number> = {
return: 36, enter: 36, tab: 48, space: 49, delete: 51, backspace: 51, return: 36, enter: 36, tab: 48, space: 49, delete: 51, backspace: 51,
escape: 53, esc: 53, escape: 53, esc: 53,
@@ -25,13 +28,17 @@ const MODIFIER_MAP: Record<string, string> = {
} }
async function osascript(script: string): Promise<string> { async function osascript(script: string): Promise<string> {
const result = await $`osascript -e ${script}`.quiet().nothrow().text() const { stdout } = await execFileAsync('osascript', ['-e', script], {
return result.trim() encoding: 'utf-8',
})
return stdout.trim()
} }
async function jxa(script: string): Promise<string> { async function jxa(script: string): Promise<string> {
const result = await $`osascript -l JavaScript -e ${script}`.quiet().nothrow().text() const { stdout } = await execFileAsync('osascript', ['-l', 'JavaScript', '-e', script], {
return result.trim() 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 {
@@ -115,19 +122,14 @@ export const typeText: InputBackend['typeText'] = async (text) => {
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => { export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
try { try {
const result = Bun.spawnSync({ const output = execFileSync('osascript', ['-e', `
cmd: ['osascript', '-e', ` tell application "System Events"
tell application "System Events" set frontApp to first application process whose frontmost is true
set frontApp to first application process whose frontmost is true set appName to name of frontApp
set appName to name of frontApp set bundleId to bundle identifier of frontApp
set bundleId to bundle identifier of frontApp return bundleId & "|" & appName
return bundleId & "|" & appName end tell
end tell `], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim()
`],
stdout: 'pipe',
stderr: 'pipe',
})
const output = new TextDecoder().decode(result.stdout).trim()
if (!output || !output.includes('|')) return null if (!output || !output.includes('|')) return null
const [bundleId, appName] = output.split('|', 2) const [bundleId, appName] = output.split('|', 2)
return { bundleId: bundleId!, appName: appName! } return { bundleId: bundleId!, appName: appName! }

View File

@@ -37,21 +37,16 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
/** Detect actual image MIME type from base64 data by decoding the magic bytes. */ /** Detect actual image MIME type from base64 data using magic bytes. */
function detectMimeFromBase64(b64: string): string { function detectMimeFromBase64(b64: string): string {
// Decode first 12 raw bytes (16 base64 chars is enough) and check standard magic bytes. // First byte is enough to distinguish PNG (0x89) from JPEG (0xFF)
// PNG: 89 50 4E 47 const c = b64.charCodeAt(0);
// JPEG: FF D8 FF if (c === 0x89) return "image/png";
// RIFF+WEBP: "RIFF" at 0..3 + "WEBP" at 8..11 if (c === 0xFF) return "image/jpeg";
// GIF: "GIF" at 0..2 // RIFF = WebP
const raw = Buffer.from(b64.slice(0, 16), "base64"); if (c === 0x52) return "image/webp";
if (raw[0] === 0x89 && raw[1] === 0x50 && raw[2] === 0x4e && raw[3] === 0x47) return "image/png"; // GIF
if (raw[0] === 0xff && raw[1] === 0xd8 && raw[2] === 0xff) return "image/jpeg"; if (c === 0x47) return "image/gif";
if (
raw[0] === 0x52 && raw[1] === 0x49 && raw[2] === 0x46 && raw[3] === 0x46 && // RIFF
raw[8] === 0x57 && raw[9] === 0x45 && raw[10] === 0x42 && raw[11] === 0x50 // WEBP
) return "image/webp";
if (raw[0] === 0x47 && raw[1] === 0x49 && raw[2] === 0x46) return "image/gif";
return "image/png"; return "image/png";
} }

View File

@@ -1,9 +1,7 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4' import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js' import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js' import { buildTool } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js' import { lazySchema } from 'src/utils/lazySchema.js'
import { logForDebugging } from 'src/utils/debug.js'
const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification' const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification'
@@ -76,58 +74,14 @@ Requires Remote Control to be configured. Respects user notification settings (t
} }
}, },
async call(input: PushInput, context) { async call(_input: PushInput) {
const appState = context.getAppState() // Push delivery is handled by the Remote Control / KAIROS transport layer.
// Without the KAIROS runtime, this tool is not available.
// Try bridge delivery first (for remote/mobile viewers) return {
if (appState.replBridgeEnabled) { data: {
if (feature('BRIDGE_MODE')) { sent: false,
try { error: 'PushNotification requires the KAIROS transport layer.',
const { getBridgeAccessToken, getBridgeBaseUrl } = await import( },
'src/bridge/bridgeConfig.js'
)
const { getSessionId } = await import('src/bootstrap/state.js')
const token = getBridgeAccessToken()
const sessionId = getSessionId()
if (token && sessionId) {
const baseUrl = getBridgeBaseUrl()
const axios = (await import('axios')).default
const response = await axios.post(
`${baseUrl}/v1/sessions/${sessionId}/events`,
{
events: [
{
type: 'push_notification',
title: input.title,
body: input.body,
priority: input.priority ?? 'normal',
},
],
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
},
timeout: 10_000,
validateStatus: (s: number) => s < 500,
},
)
if (response.status >= 200 && response.status < 300) {
logForDebugging(`[PushNotification] delivered via bridge session=${sessionId}`)
return { data: { sent: true } }
}
logForDebugging(`[PushNotification] bridge delivery failed: status=${response.status}`)
}
} catch (e) {
logForDebugging(`[PushNotification] bridge delivery error: ${e}`)
}
}
} }
// Fallback: no bridge available, push was not delivered to a remote device.
logForDebugging(`[PushNotification] no bridge available, not delivered: ${input.title}`)
return { data: { sent: false, error: 'No Remote Control bridge configured. Notification not delivered.' } }
}, },
}) })

View File

@@ -70,51 +70,14 @@ Guidelines:
} }
}, },
async call(input: SendUserFileInput, context) { async call(_input: SendUserFileInput) {
const { file_path } = input // File transfer is handled by the KAIROS assistant transport layer.
const { stat } = await import('fs/promises') // Without the KAIROS runtime, this tool is not available.
// Verify file exists and is readable
let fileSize: number
try {
const fileStat = await stat(file_path)
if (!fileStat.isFile()) {
return {
data: { sent: false, file_path, error: 'Path is not a file.' },
}
}
fileSize = fileStat.size
} catch {
return {
data: { sent: false, file_path, error: 'File does not exist or is not readable.' },
}
}
// Attempt bridge upload if available (so web viewers can download)
const appState = context.getAppState()
let fileUuid: string | undefined
if (appState.replBridgeEnabled) {
try {
const { uploadBriefAttachment } = await import(
'@claude-code-best/builtin-tools/tools/BriefTool/upload.js'
)
fileUuid = await uploadBriefAttachment(file_path, fileSize, {
replBridgeEnabled: true,
signal: context.abortController.signal,
})
} catch {
// Best-effort upload — local path is always available
}
}
const delivered = !appState.replBridgeEnabled || Boolean(fileUuid)
return { return {
data: { data: {
sent: delivered, sent: false,
file_path, file_path: _input.file_path,
size: fileSize, error: 'SendUserFile requires the KAIROS assistant transport layer.',
...(fileUuid ? { file_uuid: fileUuid } : {}),
...(!delivered ? { error: 'Bridge upload failed. File available at local path.' } : {}),
}, },
} }
}, },

View File

@@ -1,10 +0,0 @@
/** Thin logging wrapper — silent in test environment, uses console in production. */
const isTest = process.env.NODE_ENV === "test" || (typeof Bun !== "undefined" && !!Bun.env.BUN_TEST);
export function log(...args: unknown[]): void {
if (!isTest) console.log(...args);
}
export function error(...args: unknown[]): void {
if (!isTest) console.error(...args);
}

View File

@@ -1,4 +1,3 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono"; import { Hono } from "hono";
import { createBunWebSocket } from "hono/bun"; import { createBunWebSocket } from "hono/bun";
import { validateApiKey } from "../../auth/api-key"; import { validateApiKey } from "../../auth/api-key";
@@ -31,14 +30,14 @@ function authenticateRequest(c: any, label: string, expectedSessionId?: string):
const payload = verifyWorkerJwt(token); const payload = verifyWorkerJwt(token);
if (payload) { if (payload) {
if (expectedSessionId && payload.session_id !== expectedSessionId) { if (expectedSessionId && payload.session_id !== expectedSessionId) {
log(`[Auth] ${label}: FAILED — JWT session_id mismatch`); console.log(`[Auth] ${label}: FAILED — JWT session_id mismatch`);
return false; return false;
} }
return true; return true;
} }
} }
log(`[Auth] ${label}: FAILED — no valid API key or JWT`); console.log(`[Auth] ${label}: FAILED — no valid API key or JWT`);
return false; return false;
} }
@@ -84,7 +83,7 @@ app.get(
const session = getSession(sessionId); const session = getSession(sessionId);
if (!session) { if (!session) {
log(`[WS] Upgrade rejected: session ${sessionId} not found`); console.log(`[WS] Upgrade rejected: session ${sessionId} not found`);
return { return {
onOpen(_evt, ws) { onOpen(_evt, ws) {
ws.close(4001, "session not found"); ws.close(4001, "session not found");
@@ -92,7 +91,7 @@ app.get(
}; };
} }
log(`[WS] Upgrade accepted: session=${sessionId}`); console.log(`[WS] Upgrade accepted: session=${sessionId}`);
return { return {
onOpen(_evt, ws) { onOpen(_evt, ws) {
handleWebSocketOpen(ws as any, sessionId); handleWebSocketOpen(ws as any, sessionId);
@@ -109,7 +108,7 @@ app.get(
handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason); handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason);
}, },
onError(evt, ws) { onError(evt, ws) {
logError(`[WS] Error on session=${sessionId}:`, evt); console.error(`[WS] Error on session=${sessionId}:`, evt);
handleWebSocketClose(ws as any, sessionId, 1006, "websocket error"); handleWebSocketClose(ws as any, sessionId, 1006, "websocket error");
}, },
}; };

View File

@@ -1,4 +1,3 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono"; import { Hono } from "hono";
import { import {
createSession, createSession,
@@ -23,7 +22,7 @@ app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
try { try {
await createWorkItem(body.environment_id, session.id); await createWorkItem(body.environment_id, session.id);
} catch (err) { } catch (err) {
logError(`[RCS] Failed to create work item: ${(err as Error).message}`); console.error(`[RCS] Failed to create work item: ${(err as Error).message}`);
} }
} }

View File

@@ -1,4 +1,3 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono"; import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware"; import { uuidAuth } from "../../auth/middleware";
import { getSession, updateSessionStatus } from "../../services/session"; import { getSession, updateSessionStatus } from "../../services/session";
@@ -30,9 +29,9 @@ app.post("/sessions/:id/events", uuidAuth, async (c) => {
const body = await c.req.json(); const body = await c.req.json();
const eventType = body.type || "user"; const eventType = body.type || "user";
log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`); console.log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`);
const event = publishSessionEvent(sessionId, eventType, body, "outbound"); const event = publishSessionEvent(sessionId, eventType, body, "outbound");
log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`); console.log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`);
return c.json({ status: "ok", event }, 200); return c.json({ status: "ok", event }, 200);
}); });

View File

@@ -1,4 +1,3 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono"; import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware"; import { uuidAuth } from "../../auth/middleware";
import { getSession, createSession } from "../../services/session"; import { getSession, createSession } from "../../services/session";
@@ -29,7 +28,7 @@ app.post("/sessions", uuidAuth, async (c) => {
try { try {
await createWorkItem(body.environment_id, session.id); await createWorkItem(body.environment_id, session.id);
} catch (err) { } catch (err) {
logError(`[RCS] Failed to create work item: ${(err as Error).message}`); console.error(`[RCS] Failed to create work item: ${(err as Error).message}`);
} }
} }

View File

@@ -1,4 +1,3 @@
import { log, error as logError } from "../logger";
import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store"; import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store";
import { storeListSessions, storeUpdateSession } from "../store"; import { storeListSessions, storeUpdateSession } from "../store";
import { config } from "../config"; import { config } from "../config";
@@ -13,7 +12,7 @@ export function startDisconnectMonitor() {
const envs = storeListActiveEnvironments(); const envs = storeListActiveEnvironments();
for (const env of envs) { for (const env of envs) {
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) { if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`); console.log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
storeUpdateEnvironment(env.id, { status: "disconnected" }); storeUpdateEnvironment(env.id, { status: "disconnected" });
} }
} }
@@ -24,7 +23,7 @@ export function startDisconnectMonitor() {
if (session.status === "running" || session.status === "idle") { if (session.status === "running" || session.status === "idle") {
const elapsed = now - session.updatedAt.getTime(); const elapsed = now - session.updatedAt.getTime();
if (elapsed > timeoutMs * 2) { if (elapsed > timeoutMs * 2) {
log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`); console.log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`);
storeUpdateSession(session.id, { status: "inactive" }); storeUpdateSession(session.id, { status: "inactive" });
} }
} }

View File

@@ -1,4 +1,3 @@
import { log, error as logError } from "../logger";
import { import {
storeCreateWorkItem, storeCreateWorkItem,
storeGetWorkItem, storeGetWorkItem,
@@ -36,7 +35,7 @@ export async function createWorkItem(environmentId: string, sessionId: string):
const secret = encodeWorkSecret(); const secret = encodeWorkSecret();
const record = storeCreateWorkItem({ environmentId, sessionId, secret }); const record = storeCreateWorkItem({ environmentId, sessionId, secret });
log(`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`); console.log(`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`);
return record.id; return record.id;
} }

View File

@@ -1,5 +1,3 @@
import { log, error as logError } from "../logger";
export interface SessionEvent { export interface SessionEvent {
id: string; id: string;
sessionId: string; sessionId: string;
@@ -35,12 +33,12 @@ export class EventBus {
createdAt: Date.now(), createdAt: Date.now(),
}; };
this.events.push(full); this.events.push(full);
log(`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`); console.log(`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`);
for (const cb of this.subscribers) { for (const cb of this.subscribers) {
try { try {
cb(full); cb(full);
} catch (err) { } catch (err) {
logError(`[RC-DEBUG] bus subscriber error:`, err); console.error(`[RC-DEBUG] bus subscriber error:`, err);
} }
} }
return full; return full;

View File

@@ -1,4 +1,3 @@
import { log, error as logError } from "../logger";
import type { Context } from "hono"; import type { Context } from "hono";
import type { SessionEvent } from "./event-bus"; import type { SessionEvent } from "./event-bus";
import { getEventBus } from "./event-bus"; import { getEventBus } from "./event-bus";
@@ -77,7 +76,7 @@ export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
seqNum: event.seqNum, seqNum: event.seqNum,
}); });
try { try {
log(`[RC-DEBUG] SSE -> web: sessionId=${sessionId} type=${event.type} dir=${event.direction} seq=${event.seqNum}`); console.log(`[RC-DEBUG] SSE -> web: sessionId=${sessionId} type=${event.type} dir=${event.direction} seq=${event.seqNum}`);
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`)); controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
} catch { } catch {
unsub(); unsub();

View File

@@ -2,7 +2,6 @@ import type { WSContext } from "hono/ws";
import { getEventBus } from "./event-bus"; import { getEventBus } from "./event-bus";
import type { SessionEvent } from "./event-bus"; import type { SessionEvent } from "./event-bus";
import { publishSessionEvent } from "../services/transport"; import { publishSessionEvent } from "../services/transport";
import { log, error as logError } from "../logger";
// Per-connection cleanup, keyed by sessionId (only one WS per session) // Per-connection cleanup, keyed by sessionId (only one WS per session)
interface CleanupEntry { interface CleanupEntry {
@@ -97,13 +96,13 @@ function toSDKMessage(event: SessionEvent): string {
/** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */ /** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */
export function handleWebSocketOpen(ws: WSContext, sessionId: string) { export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
const openTime = Date.now(); const openTime = Date.now();
log(`[RC-DEBUG] [WS] Open session=${sessionId}`); console.log(`[RC-DEBUG] [WS] Open session=${sessionId}`);
activeConnections.add(ws); activeConnections.add(ws);
// If there's an existing connection for this session, clean it up first // If there's an existing connection for this session, clean it up first
const existing = cleanupBySession.get(sessionId); const existing = cleanupBySession.get(sessionId);
if (existing) { if (existing) {
log(`[WS] Replacing existing connection for session=${sessionId}`); console.log(`[WS] Replacing existing connection for session=${sessionId}`);
existing.unsub(); existing.unsub();
clearInterval(existing.keepalive); clearInterval(existing.keepalive);
activeConnections.delete(existing.ws); activeConnections.delete(existing.ws);
@@ -115,7 +114,7 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
// the full conversation history — assistant replies are inbound events. // the full conversation history — assistant replies are inbound events.
const missed = bus.getEventsSince(0); const missed = bus.getEventsSince(0);
if (missed.length > 0) { if (missed.length > 0) {
log(`[WS] Replaying ${missed.length} missed event(s)`); console.log(`[WS] Replaying ${missed.length} missed event(s)`);
for (const event of missed) { for (const event of missed) {
if (ws.readyState !== 1) break; if (ws.readyState !== 1) break;
try { try {
@@ -131,10 +130,10 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
if (event.direction !== "outbound") return; if (event.direction !== "outbound") return;
try { try {
const sdkMsg = toSDKMessage(event); const sdkMsg = toSDKMessage(event);
log(`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`); console.log(`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`);
ws.send(sdkMsg); ws.send(sdkMsg);
} catch (err) { } catch (err) {
logError("[RC-DEBUG] [WS] send error:", err); console.error("[RC-DEBUG] [WS] send error:", err);
} }
}); });
@@ -162,7 +161,7 @@ export function handleWebSocketMessage(ws: WSContext, sessionId: string, data: s
try { try {
ingestBridgeMessage(sessionId, JSON.parse(line)); ingestBridgeMessage(sessionId, JSON.parse(line));
} catch (err) { } catch (err) {
logError("[WS] parse error:", err); console.error("[WS] parse error:", err);
} }
} }
} }
@@ -174,7 +173,7 @@ export function handleWebSocketClose(ws: WSContext, sessionId: string, code?: nu
const entry = cleanupBySession.get(sessionId); const entry = cleanupBySession.get(sessionId);
const duration = entry ? Math.round((Date.now() - entry.openTime) / 1000) : -1; const duration = entry ? Math.round((Date.now() - entry.openTime) / 1000) : -1;
log(`[WS] Close session=${sessionId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`); console.log(`[WS] Close session=${sessionId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
if (entry) { if (entry) {
entry.unsub(); entry.unsub();
@@ -216,7 +215,7 @@ export function ingestBridgeMessage(sessionId: string, msg: Record<string, unkno
const eventType = deriveEventType(msg); const eventType = deriveEventType(msg);
log(`[RC-DEBUG] [WS] <- bridge (inbound): sessionId=${sessionId} type=${eventType}${msg.uuid ? ` uuid=${msg.uuid}` : ""} msg=${JSON.stringify(msg).slice(0, 300)}`); console.log(`[RC-DEBUG] [WS] <- bridge (inbound): sessionId=${sessionId} type=${eventType}${msg.uuid ? ` uuid=${msg.uuid}` : ""} msg=${JSON.stringify(msg).slice(0, 300)}`);
let payload: unknown; let payload: unknown;
@@ -256,7 +255,7 @@ export function closeAllConnections(): void {
const count = activeConnections.size; const count = activeConnections.size;
if (count === 0) return; if (count === 0) return;
log(`[WS] Gracefully closing ${count} active connection(s)...`); console.log(`[WS] Gracefully closing ${count} active connection(s)...`);
for (const [sessionId, entry] of cleanupBySession) { for (const [sessionId, entry] of cleanupBySession) {
try { try {
entry.unsub(); entry.unsub();
@@ -270,5 +269,5 @@ export function closeAllConnections(): void {
} }
cleanupBySession.clear(); cleanupBySession.clear();
activeConnections.clear(); activeConnections.clear();
log("[WS] All connections closed"); console.log("[WS] All connections closed");
} }

View File

@@ -47,8 +47,6 @@ const DEFAULT_FEATURES = [
"KAIROS", "KAIROS",
"COORDINATOR_MODE", "COORDINATOR_MODE",
"LAN_PIPES", "LAN_PIPES",
"BG_SESSIONS",
"TEMPLATES",
// "REVIEW_ARTIFACT", // API 请求无响应,需进一步排查 schema 兼容性 // "REVIEW_ARTIFACT", // API 请求无响应,需进一步排查 schema 兼容性
// P3: poor mode (disable extract_memories + prompt_suggestion) // P3: poor mode (disable extract_memories + prompt_suggestion)
"POOR", "POOR",

View File

@@ -41,11 +41,7 @@ import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js'
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js' import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js'
import type { APIError } from '@anthropic-ai/sdk' import type { APIError } from '@anthropic-ai/sdk'
import type { import type { CompactMetadata, Message, SystemCompactBoundaryMessage } from './types/message.js'
CompactMetadata,
Message,
SystemCompactBoundaryMessage,
} from './types/message.js'
import type { OrphanedPermission } from './types/textInputTypes.js' import type { OrphanedPermission } from './types/textInputTypes.js'
import { createAbortController } from './utils/abortController.js' import { createAbortController } from './utils/abortController.js'
import type { AttributionState } from './utils/commitAttribution.js' import type { AttributionState } from './utils/commitAttribution.js'
@@ -712,8 +708,7 @@ export class QueryEngine {
message.subtype === 'compact_boundary' message.subtype === 'compact_boundary'
) { ) {
const compactMsg = message as SystemCompactBoundaryMessage const compactMsg = message as SystemCompactBoundaryMessage
const tailUuid = const tailUuid = compactMsg.compactMetadata?.preservedSegment?.tailUuid
compactMsg.compactMetadata?.preservedSegment?.tailUuid
if (tailUuid) { if (tailUuid) {
const tailIdx = this.mutableMessages.findLastIndex( const tailIdx = this.mutableMessages.findLastIndex(
m => m.uuid === tailUuid, m => m.uuid === tailUuid,
@@ -773,10 +768,7 @@ export class QueryEngine {
// streamed responses, this is null at content_block_stop time; // streamed responses, this is null at content_block_stop time;
// the real value arrives via message_delta (handled below). // the real value arrives via message_delta (handled below).
const msg = message as Message const msg = message as Message
const stopReason = msg.message?.stop_reason as const stopReason = msg.message?.stop_reason as string | null | undefined
| string
| null
| undefined
if (stopReason != null) { if (stopReason != null) {
lastStopReason = stopReason lastStopReason = stopReason
} }
@@ -806,15 +798,11 @@ export class QueryEngine {
break break
} }
case 'stream_event': { case 'stream_event': {
const event = ( const event = (message as unknown as { event: Record<string, unknown> }).event
message as unknown as { event: Record<string, unknown> }
).event
if (event.type === 'message_start') { if (event.type === 'message_start') {
// Reset current message usage for new message // Reset current message usage for new message
currentMessageUsage = EMPTY_USAGE currentMessageUsage = EMPTY_USAGE
const eventMessage = event.message as { const eventMessage = event.message as { usage: BetaMessageDeltaUsage }
usage: BetaMessageDeltaUsage
}
currentMessageUsage = updateUsage( currentMessageUsage = updateUsage(
currentMessageUsage, currentMessageUsage,
eventMessage.usage, eventMessage.usage,
@@ -863,15 +851,7 @@ export class QueryEngine {
void recordTranscript(messages) void recordTranscript(messages)
} }
const attachment = msg.attachment as { const attachment = msg.attachment as { type: string; data?: unknown; turnCount?: number; maxTurns?: number; prompt?: string; source_uuid?: string; [key: string]: unknown }
type: string
data?: unknown
turnCount?: number
maxTurns?: number
prompt?: string
source_uuid?: string
[key: string]: unknown
}
// Extract structured output from StructuredOutput tool calls // Extract structured output from StructuredOutput tool calls
if (attachment.type === 'structured_output') { if (attachment.type === 'structured_output') {
@@ -912,7 +892,10 @@ export class QueryEngine {
return return
} }
// Yield queued_command attachments as SDK user message replays // Yield queued_command attachments as SDK user message replays
else if (replayUserMessages && attachment.type === 'queued_command') { else if (
replayUserMessages &&
attachment.type === 'queued_command'
) {
yield { yield {
type: 'user', type: 'user',
message: { message: {
@@ -940,7 +923,10 @@ export class QueryEngine {
// never shrinks (memory leak in long SDK sessions). The subtype // never shrinks (memory leak in long SDK sessions). The subtype
// check lives inside the injected callback so feature-gated strings // check lives inside the injected callback so feature-gated strings
// stay out of this file (excluded-strings check). // stay out of this file (excluded-strings check).
const snipResult = this.config.snipReplay?.(msg, this.mutableMessages) const snipResult = this.config.snipReplay?.(
msg,
this.mutableMessages,
)
if (snipResult !== undefined) { if (snipResult !== undefined) {
if (snipResult.executed) { if (snipResult.executed) {
this.mutableMessages.length = 0 this.mutableMessages.length = 0
@@ -950,7 +936,10 @@ export class QueryEngine {
} }
this.mutableMessages.push(msg) this.mutableMessages.push(msg)
// Yield compact boundary messages to SDK // Yield compact boundary messages to SDK
if (msg.subtype === 'compact_boundary' && msg.compactMetadata) { if (
msg.subtype === 'compact_boundary' &&
msg.compactMetadata
) {
const compactMsg = msg as SystemCompactBoundaryMessage const compactMsg = msg as SystemCompactBoundaryMessage
// Release pre-compaction messages for GC. The boundary was just // Release pre-compaction messages for GC. The boundary was just
// pushed so it's the last element. query.ts already uses // pushed so it's the last element. query.ts already uses
@@ -970,18 +959,11 @@ export class QueryEngine {
subtype: 'compact_boundary' as const, subtype: 'compact_boundary' as const,
session_id: getSessionId(), session_id: getSessionId(),
uuid: msg.uuid, uuid: msg.uuid,
compact_metadata: toSDKCompactMetadata( compact_metadata: toSDKCompactMetadata(compactMsg.compactMetadata),
compactMsg.compactMetadata,
),
} }
} }
if (msg.subtype === 'api_error') { if (msg.subtype === 'api_error') {
const apiErrorMsg = msg as Message & { const apiErrorMsg = msg as Message & { retryAttempt: number; maxRetries: number; retryInMs: number; error: APIError }
retryAttempt: number
maxRetries: number
retryInMs: number
error: APIError
}
yield { yield {
type: 'system', type: 'system',
subtype: 'api_retry' as const, subtype: 'api_retry' as const,
@@ -998,10 +980,7 @@ export class QueryEngine {
break break
} }
case 'tool_use_summary': { case 'tool_use_summary': {
const msg = message as Message & { const msg = message as Message & { summary: unknown; precedingToolUseIds: unknown }
summary: unknown
precedingToolUseIds: unknown
}
// Yield tool use summary messages to SDK // Yield tool use summary messages to SDK
yield { yield {
type: 'tool_use_summary' as const, type: 'tool_use_summary' as const,
@@ -1110,10 +1089,7 @@ export class QueryEngine {
const edeResultType = result?.type ?? 'undefined' const edeResultType = result?.type ?? 'undefined'
const edeLastContentType = const edeLastContentType =
result?.type === 'assistant' result?.type === 'assistant'
? (last( ? (last(result.message!.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[])?.type ?? 'none')
result.message!
.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[],
)?.type ?? 'none')
: 'n/a' : 'n/a'
// Flush buffered transcript writes before yielding result. // Flush buffered transcript writes before yielding result.
@@ -1171,10 +1147,7 @@ export class QueryEngine {
let isApiError = false let isApiError = false
if (result.type === 'assistant') { if (result.type === 'assistant') {
const lastContent = last( const lastContent = last(result.message!.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[])
result.message!
.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[],
)
if ( if (
lastContent?.type === 'text' && lastContent?.type === 'text' &&
!SYNTHETIC_MESSAGES.has(lastContent.text) !SYNTHETIC_MESSAGES.has(lastContent.text)

View File

@@ -1,95 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from '../bootstrap/state'
import {
getSystemContext,
getUserContext,
setSystemPromptInjection,
} from '../context'
import { clearMemoryFileCaches } from '../utils/claudemd'
import {
cleanupTempDir,
createTempDir,
writeTempFile,
} from '../../tests/mocks/file-system'
let tempDir = ''
let projectClaudeMdContent = ''
beforeEach(async () => {
tempDir = await createTempDir('context-baseline-')
projectClaudeMdContent = `baseline-${Date.now()}`
resetStateForTests()
setOriginalCwd(tempDir)
setProjectRoot(tempDir)
await writeTempFile(tempDir, 'CLAUDE.md', projectClaudeMdContent)
clearMemoryFileCaches()
getUserContext.cache.clear?.()
getSystemContext.cache.clear?.()
setSystemPromptInjection(null)
delete process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS
})
afterEach(async () => {
clearMemoryFileCaches()
getUserContext.cache.clear?.()
getSystemContext.cache.clear?.()
setSystemPromptInjection(null)
delete process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS
resetStateForTests()
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('context baseline', () => {
test('getUserContext includes currentDate and project CLAUDE.md content', async () => {
const ctx = await getUserContext()
expect(ctx.currentDate).toContain("Today's date is")
expect(ctx.claudeMd).toContain(projectClaudeMdContent)
})
test('CLAUDE_CODE_DISABLE_CLAUDE_MDS suppresses claudeMd loading', async () => {
process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = '1'
const ctx = await getUserContext()
expect(ctx.currentDate).toContain("Today's date is")
expect(ctx.claudeMd).toBeUndefined()
})
test('setSystemPromptInjection clears the memoized user-context cache', async () => {
const first = await getUserContext()
process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = '1'
const second = await getUserContext()
expect(first.claudeMd).toContain(projectClaudeMdContent)
expect(second.claudeMd).toContain(projectClaudeMdContent)
setSystemPromptInjection('cache-break')
const third = await getUserContext()
expect(third.claudeMd).toBeUndefined()
})
test('getSystemContext reflects system prompt injection after cache invalidation', async () => {
const first = await getSystemContext()
expect(first.gitStatus).toBeUndefined()
expect(first.cacheBreaker).toBeUndefined()
setSystemPromptInjection('baseline-cache-break')
const second = await getSystemContext()
if ('cacheBreaker' in second) {
expect(second.cacheBreaker).toContain('baseline-cache-break')
} else {
expect(second.gitStatus).toBeUndefined()
}
})
})

View File

@@ -0,0 +1,3 @@
// Auto-generated stub — replace with real implementation
export {};
export const AssistantSessionChooser: (props: Record<string, unknown>) => null = () => null;

View File

@@ -1,54 +0,0 @@
import * as React from 'react';
import { useState } from 'react';
import { Box, Text } from '@anthropic/ink';
import { Dialog } from '../components/design-system/Dialog.js';
import { ListItem } from '../components/design-system/ListItem.js';
import { useRegisterOverlay } from '../context/overlayContext.js';
import { useKeybindings } from '../keybindings/useKeybinding.js';
import type { AssistantSession } from './sessionDiscovery.js';
interface Props {
sessions: AssistantSession[];
onSelect: (id: string) => void;
onCancel: () => void;
}
/**
* Interactive session chooser for `claude assistant` when multiple
* CCR sessions are discovered. Renders a Dialog with up/down navigation.
*
* Session IDs are in `session_*` compat format — passed directly to
* createRemoteSessionConfig() for viewer attach.
*/
export function AssistantSessionChooser({ sessions, onSelect, onCancel }: Props): React.ReactNode {
useRegisterOverlay('assistant-session-chooser');
const [focusIndex, setFocusIndex] = useState(0);
useKeybindings(
{
'select:next': () => setFocusIndex(i => (i + 1) % sessions.length),
'select:previous': () => setFocusIndex(i => (i - 1 + sessions.length) % sessions.length),
'select:accept': () => onSelect(sessions[focusIndex]!.id),
},
{ context: 'Select' },
);
return (
<Dialog title="Select Assistant Session" onCancel={onCancel} hideInputGuide>
<Box flexDirection="column" gap={1}>
<Text>Multiple sessions found. Select one to attach:</Text>
<Box flexDirection="column">
{sessions.map((s, i) => (
<ListItem key={s.id} isFocused={focusIndex === i}>
<Box>
<Text>{s.title || s.id.slice(0, 20)}</Text>
<Text dimColor> [{s.status}]</Text>
</Box>
</ListItem>
))}
</Box>
<Text dimColor> navigate · Enter select · Esc cancel</Text>
</Box>
</Dialog>
);
}

View File

@@ -5,20 +5,21 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growt
/** /**
* Runtime gate for KAIROS features. * Runtime gate for KAIROS features.
* *
* Two-layer gate: * Build-time: feature('KAIROS') must be on (checked by caller before
* 1. Build-time: feature('KAIROS') must be on * this module is required).
* 2. Runtime: tengu_kairos_assistant GrowthBook flag (remote kill switch)
* *
* Called by main.tsx BEFORE setKairosActive(true) — must NOT check * Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill
* kairosActive (that would deadlock: gate needs active, active needs gate). * switch, and kairosActive state must be true (set during bootstrap when
* The caller (main.tsx L1826-1832) sets kairosActive after this returns true. * the session qualifies for KAIROS features).
*/ */
export async function isKairosEnabled(): Promise<boolean> { export async function isKairosEnabled(): Promise<boolean> {
if (!feature('KAIROS')) { if (!feature('KAIROS')) {
return false return false
} }
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)) { if (
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)
) {
return false return false
} }
return true return getKairosActive()
} }

View File

@@ -1,64 +1,9 @@
import { readFileSync } from 'fs' // Auto-generated stub — replace with real implementation
import { join } from 'path' export {}
import { getKairosActive } from '../bootstrap/state.js' export const isAssistantMode: () => boolean = () => false
import { getClaudeConfigHomeDir } from '../utils/envUtils.js' export const initializeAssistantTeam: () => Promise<void> = async () => {}
export const markAssistantForced: () => void = () => {}
let _assistantForced = false export const isAssistantForced: () => boolean = () => false
export const getAssistantSystemPromptAddendum: () => string = () => ''
/** export const getAssistantActivationPath: () => string | undefined = () =>
* Whether the current session is in assistant (KAIROS) daemon mode. undefined
* Wraps the bootstrap kairosActive state set by main.tsx after gate check.
*/
export function isAssistantMode(): boolean {
return getKairosActive()
}
/**
* Mark this session as forced assistant mode (--assistant flag).
* Skips the GrowthBook gate check — daemon is pre-entitled.
*/
export function markAssistantForced(): void {
_assistantForced = true
}
export function isAssistantForced(): boolean {
return _assistantForced
}
/**
* Pre-create an in-process team so Agent(name) can spawn teammates
* without TeamCreate.
*
* Phase 1: returns undefined so main.tsx's `assistantTeamContext ?? computeInitialTeamContext()`
* correctly falls back. Returning {} would bypass the ?? operator since {} is truthy.
*
* Phase 2: should return a full team context object matching AppState.teamContext shape.
*/
export async function initializeAssistantTeam(): Promise<undefined> {
return undefined
}
/**
* Assistant-specific system prompt addendum loaded from ~/.claude/agents/assistant.md.
* Returns empty string if the file doesn't exist.
*/
export function getAssistantSystemPromptAddendum(): string {
try {
return readFileSync(
join(getClaudeConfigHomeDir(), 'agents', 'assistant.md'),
'utf-8',
)
} catch {
return ''
}
}
/**
* How assistant mode was activated. Used for diagnostics/analytics.
* - 'daemon': via --assistant flag (Agent SDK daemon)
* - 'gate': via GrowthBook gate check
*/
export function getAssistantActivationPath(): string | undefined {
if (!isAssistantMode()) return undefined
return _assistantForced ? 'daemon' : 'gate'
}

View File

@@ -1,51 +1,3 @@
import { logForDebugging } from '../utils/debug.js' // Auto-generated stub — replace with real implementation
export type AssistantSession = { id: string; [key: string]: unknown };
/** export const discoverAssistantSessions: () => Promise<AssistantSession[]> = () => Promise.resolve([]);
* Minimal session type for assistant discovery.
* Only `id` is consumed by main.tsx (L4757); other fields are for chooser display.
* ID format is `session_*` (compat prefix) — viewer endpoints use /v1/sessions/*.
*/
export type AssistantSession = {
id: string
title: string
status: string
created_at: string
}
/**
* Discover assistant sessions on Anthropic CCR.
*
* Reuses the existing fetchCodeSessionsFromSessionsAPI() which calls
* GET /v1/sessions with proper OAuth + anthropic-beta headers.
*
* Throws on failure — main.tsx L4720-4725 catch displays the error.
* Does NOT return [] on error (that would silently redirect to install wizard).
*/
export async function discoverAssistantSessions(): Promise<AssistantSession[]> {
const { fetchCodeSessionsFromSessionsAPI } = await import(
'../utils/teleport/api.js'
)
let allSessions
try {
allSessions = await fetchCodeSessionsFromSessionsAPI()
} catch (err) {
logForDebugging(
`[assistant:discovery] fetchCodeSessionsFromSessionsAPI failed: ${err}`,
)
throw err
}
// Filter to active/working sessions only — completed/archived are not attachable
return allSessions
.filter(
s =>
s.status === 'idle' || s.status === 'working' || s.status === 'waiting',
)
.map(s => ({
id: s.id,
title: s.title || 'Untitled',
status: s.status,
created_at: s.created_at ?? '',
}))
}

View File

@@ -1,3 +1,3 @@
// Auto-generated type stub — replace with real implementation // Auto-generated type stub — replace with real implementation
export type HookEvent = any export type HookEvent = any;
export type ModelUsage = any export type ModelUsage = any;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1755,6 +1755,4 @@ export function getPromptId(): string | null {
export function setPromptId(id: string | null): void { export function setPromptId(id: string | null): void {
STATE.promptId = id STATE.promptId = id
} }
export function isReplBridgeActive(): boolean { export function isReplBridgeActive(): boolean { return false; }
return false
}

View File

@@ -225,9 +225,7 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
) )
handleErrorStatus(response.status, response.data, 'Poll') handleErrorStatus(response.status, response.data, 'Poll')
rcLog( rcLog(`poll response: status=${response.status} hasData=${!!response.data} url=${deps.baseUrl}`)
`poll response: status=${response.status} hasData=${!!response.data} url=${deps.baseUrl}`,
)
// Empty body or null = no work available // Empty body or null = no work available
if (!response.data) { if (!response.data) {

View File

@@ -12,7 +12,6 @@ import {
logEventAsync, logEventAsync,
} from '../services/analytics/index.js' } from '../services/analytics/index.js'
import { isInBundledMode } from '../utils/bundledMode.js' import { isInBundledMode } from '../utils/bundledMode.js'
import { getBootstrapArgs, getScriptPath } from '../utils/cliLaunch.js'
import { logForDebugging } from '../utils/debug.js' import { logForDebugging } from '../utils/debug.js'
import { rcLog } from './rcDebugLog.js' import { rcLog } from './rcDebugLog.js'
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js' import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
@@ -112,15 +111,17 @@ function pollSleepDetectionThresholdMs(backoff: BackoffConfig): number {
/** /**
* Returns the args that must precede CLI flags when spawning a child claude * Returns the args that must precede CLI flags when spawning a child claude
* process. Delegates to the centralized cliLaunch module which handles * process. In compiled binaries, process.execPath is the claude binary itself
* bundled-vs-script mode, execArgv sanitization, and the Bun execArgv leak * and args go directly to it. In npm installs (node running cli.js),
* quirk. See anthropics/claude-code#28334. * process.execPath is the node runtime — the child spawn must pass the script
* path as the first arg, otherwise node interprets --sdk-url as a node option
* and exits with "bad option: --sdk-url". See anthropics/claude-code#28334.
*/ */
function spawnScriptArgs(): string[] { function spawnScriptArgs(): string[] {
const bootstrap = [...getBootstrapArgs()] if (isInBundledMode() || !process.argv[1]) {
const script = getScriptPath() return []
if (script) bootstrap.push(script) }
return bootstrap return [process.argv[1]]
} }
/** Attempt to spawn a session; returns error string if spawn throws. */ /** Attempt to spawn a session; returns error string if spawn throws. */
@@ -447,11 +448,9 @@ export async function runBridgeLoop(
): (status: SessionDoneStatus) => void { ): (status: SessionDoneStatus) => void {
return (rawStatus: SessionDoneStatus): void => { return (rawStatus: SessionDoneStatus): void => {
const workId = sessionWorkIds.get(sessionId) const workId = sessionWorkIds.get(sessionId)
rcLog( rcLog(`session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` +
`session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` + ` wasTimedOut=${timedOutSessions.has(sessionId)} duration=${Math.round((Date.now() - startTime) / 1000)}s` +
` wasTimedOut=${timedOutSessions.has(sessionId)} duration=${Math.round((Date.now() - startTime) / 1000)}s` + ` stderr=${handle.lastStderr.length > 0 ? handle.lastStderr.join('\\n').slice(0, 500) : '(none)'}`)
` stderr=${handle.lastStderr.length > 0 ? handle.lastStderr.join('\\n').slice(0, 500) : '(none)'}`,
)
activeSessions.delete(sessionId) activeSessions.delete(sessionId)
sessionStartTimes.delete(sessionId) sessionStartTimes.delete(sessionId)
sessionWorkIds.delete(sessionId) sessionWorkIds.delete(sessionId)
@@ -610,9 +609,7 @@ export async function runBridgeLoop(
const pollConfig = getPollIntervalConfig() const pollConfig = getPollIntervalConfig()
try { try {
rcLog( rcLog(`poll: envId=${environmentId} activeSessions=${activeSessions.size}`)
`poll: envId=${environmentId} activeSessions=${activeSessions.size}`,
)
const work = await api.pollForWork( const work = await api.pollForWork(
environmentId, environmentId,
environmentSecret, environmentSecret,
@@ -867,9 +864,7 @@ export async function runBridgeLoop(
break break
case 'session': { case 'session': {
const sessionId = work.data.id const sessionId = work.data.id
rcLog( rcLog(`work received: type=session sessionId=${sessionId} workId=${work.id}`)
`work received: type=session sessionId=${sessionId} workId=${work.id}`,
)
try { try {
validateBridgeId(sessionId, 'session_id') validateBridgeId(sessionId, 'session_id')
} catch { } catch {
@@ -1037,9 +1032,9 @@ export async function runBridgeLoop(
rcLog( rcLog(
`spawning session: sessionId=${sessionId} sdkUrl=${sdkUrl}` + `spawning session: sessionId=${sessionId} sdkUrl=${sdkUrl}` +
` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` + ` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` +
` dir=${sessionDir}` + ` dir=${sessionDir}` +
` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`, ` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`,
) )
const spawnResult = safeSpawn( const spawnResult = safeSpawn(
spawner, spawner,
@@ -1286,8 +1281,8 @@ export async function runBridgeLoop(
const errMsg = describeAxiosError(err) const errMsg = describeAxiosError(err)
rcLog( rcLog(
`poll error: ${errMsg}` + `poll error: ${errMsg}` +
` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` + ` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` +
` activeSessions=${activeSessions.size}`, ` activeSessions=${activeSessions.size}`,
) )
if (isConnectionError(err) || isServerError(err)) { if (isConnectionError(err) || isServerError(err)) {
@@ -1681,7 +1676,7 @@ async function stopWorkWithRetry(
} }
const errMsg = errorMessage(err) const errMsg = errorMessage(err)
if (attempt < MAX_ATTEMPTS) { if (attempt < MAX_ATTEMPTS) {
const delay = addJitter(baseDelayMs * 2 ** (attempt - 1)) const delay = addJitter(baseDelayMs * Math.pow(2, attempt - 1))
logger.logVerbose( logger.logVerbose(
`Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`, `Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`,
) )
@@ -1969,6 +1964,7 @@ NOTES
- You must be logged in with a Claude account that has a subscription - You must be logged in with a Claude account that has a subscription
- Run \`claude\` first in the directory to accept the workspace trust dialog - Run \`claude\` first in the directory to accept the workspace trust dialog
${serverNote}` ${serverNote}`
// biome-ignore lint/suspicious/noConsole: intentional help output
console.log(help) console.log(help)
} }
@@ -2007,6 +2003,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
return return
} }
if (parsed.error) { if (parsed.error) {
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error(`Error: ${parsed.error}`) console.error(`Error: ${parsed.error}`)
// eslint-disable-next-line custom-rules/no-process-exit // eslint-disable-next-line custom-rules/no-process-exit
process.exit(1) process.exit(1)
@@ -2045,6 +2042,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
const { PERMISSION_MODES } = await import('../types/permissions.js') const { PERMISSION_MODES } = await import('../types/permissions.js')
const valid: readonly string[] = PERMISSION_MODES const valid: readonly string[] = PERMISSION_MODES
if (!valid.includes(permissionMode)) { if (!valid.includes(permissionMode)) {
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
`Error: Invalid permission mode '${permissionMode}'. Valid modes: ${valid.join(', ')}`, `Error: Invalid permission mode '${permissionMode}'. Valid modes: ${valid.join(', ')}`,
) )
@@ -2087,6 +2085,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
Promise.all([shutdown1PEventLogging(), shutdownDatadog()]), Promise.all([shutdown1PEventLogging(), shutdownDatadog()]),
sleep(500, undefined, { unref: true }), sleep(500, undefined, { unref: true }),
]).catch(() => {}) ]).catch(() => {})
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
'Error: Multi-session Remote Control is not enabled for your account yet.', 'Error: Multi-session Remote Control is not enabled for your account yet.',
) )
@@ -2103,6 +2102,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
// The bridge bypasses main.tsx (which renders the interactive TrustDialog via showSetupScreens), // The bridge bypasses main.tsx (which renders the interactive TrustDialog via showSetupScreens),
// so we must verify trust was previously established by a normal `claude` session. // so we must verify trust was previously established by a normal `claude` session.
if (!checkHasTrustDialogAccepted()) { if (!checkHasTrustDialogAccepted()) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
`Error: Workspace not trusted. Please run \`claude\` in ${dir} first to review and accept the workspace trust dialog.`, `Error: Workspace not trusted. Please run \`claude\` in ${dir} first to review and accept the workspace trust dialog.`,
) )
@@ -2119,6 +2119,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
const bridgeToken = getBridgeAccessToken() const bridgeToken = getBridgeAccessToken()
if (!bridgeToken) { if (!bridgeToken) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(BRIDGE_LOGIN_ERROR) console.error(BRIDGE_LOGIN_ERROR)
// eslint-disable-next-line custom-rules/no-process-exit // eslint-disable-next-line custom-rules/no-process-exit
process.exit(1) process.exit(1)
@@ -2137,6 +2138,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
input: process.stdin, input: process.stdin,
output: process.stdout, output: process.stdout,
}) })
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log( console.log(
'\nRemote Control lets you access this CLI session from the web (claude.ai/code)\nor the Claude app, so you can pick up where you left off on any device.\n\nYou can disconnect remote access anytime by running /remote-control again.\n', '\nRemote Control lets you access this CLI session from the web (claude.ai/code)\nor the Claude app, so you can pick up where you left off on any device.\n\nYou can disconnect remote access anytime by running /remote-control again.\n',
) )
@@ -2168,6 +2170,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
) )
const found = await readBridgePointerAcrossWorktrees(dir) const found = await readBridgePointerAcrossWorktrees(dir)
if (!found) { if (!found) {
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
`Error: No recent session found in this directory or its worktrees. Run \`claude remote-control\` to start a new one.`, `Error: No recent session found in this directory or its worktrees. Run \`claude remote-control\` to start a new one.`,
) )
@@ -2178,6 +2181,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
const ageMin = Math.round(pointer.ageMs / 60_000) const ageMin = Math.round(pointer.ageMs / 60_000)
const ageStr = ageMin < 60 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h` const ageStr = ageMin < 60 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h`
const fromWt = pointerDir !== dir ? ` from worktree ${pointerDir}` : '' const fromWt = pointerDir !== dir ? ` from worktree ${pointerDir}` : ''
// biome-ignore lint/suspicious/noConsole: intentional info output
console.error( console.error(
`Resuming session ${pointer.sessionId} (${ageStr} ago)${fromWt}\u2026`, `Resuming session ${pointer.sessionId} (${ageStr} ago)${fromWt}\u2026`,
) )
@@ -2198,6 +2202,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
!baseUrl.includes('localhost') && !baseUrl.includes('localhost') &&
!baseUrl.includes('127.0.0.1') !baseUrl.includes('127.0.0.1')
) { ) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
'Error: Remote Control base URL uses HTTP. Only HTTPS or localhost HTTP is allowed.', 'Error: Remote Control base URL uses HTTP. Only HTTPS or localhost HTTP is allowed.',
) )
@@ -2233,6 +2238,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
? getCurrentProjectConfig().remoteControlSpawnMode ? getCurrentProjectConfig().remoteControlSpawnMode
: undefined : undefined
if (savedSpawnMode === 'worktree' && !worktreeAvailable) { if (savedSpawnMode === 'worktree' && !worktreeAvailable) {
// biome-ignore lint/suspicious/noConsole: intentional warning output
console.error( console.error(
'Warning: Saved spawn mode is worktree but this directory is not a git repository. Falling back to same-dir.', 'Warning: Saved spawn mode is worktree but this directory is not a git repository. Falling back to same-dir.',
) )
@@ -2259,6 +2265,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
input: process.stdin, input: process.stdin,
output: process.stdout, output: process.stdout,
}) })
// biome-ignore lint/suspicious/noConsole: intentional dialog output
console.log( console.log(
`\nClaude Remote Control is launching in spawn mode which lets you create new sessions in this project from Claude Code on Web or your Mobile app. Learn more here: https://code.claude.com/docs/en/remote-control\n\n` + `\nClaude Remote Control is launching in spawn mode which lets you create new sessions in this project from Claude Code on Web or your Mobile app. Learn more here: https://code.claude.com/docs/en/remote-control\n\n` +
`Spawn mode for this project:\n` + `Spawn mode for this project:\n` +
@@ -2337,6 +2344,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
// Only reachable via explicit --spawn=worktree (default is same-dir); // Only reachable via explicit --spawn=worktree (default is same-dir);
// saved worktree pref was already guarded above. // saved worktree pref was already guarded above.
if (spawnMode === 'worktree' && !worktreeAvailable) { if (spawnMode === 'worktree' && !worktreeAvailable) {
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
`Error: Worktree mode requires a git repository or WorktreeCreate hooks configured. Use --spawn=session for single-session mode.`, `Error: Worktree mode requires a git repository or WorktreeCreate hooks configured. Use --spawn=session for single-session mode.`,
) )
@@ -2371,6 +2379,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
try { try {
validateBridgeId(resumeSessionId, 'sessionId') validateBridgeId(resumeSessionId, 'sessionId')
} catch { } catch {
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
`Error: Invalid session ID "${resumeSessionId}". Session IDs must not contain unsafe characters.`, `Error: Invalid session ID "${resumeSessionId}". Session IDs must not contain unsafe characters.`,
) )
@@ -2396,6 +2405,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
const { clearBridgePointer } = await import('./bridgePointer.js') const { clearBridgePointer } = await import('./bridgePointer.js')
await clearBridgePointer(resumePointerDir) await clearBridgePointer(resumePointerDir)
} }
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
`Error: Session ${resumeSessionId} not found. It may have been archived or expired, or your login may have lapsed (run \`claude /login\`).`, `Error: Session ${resumeSessionId} not found. It may have been archived or expired, or your login may have lapsed (run \`claude /login\`).`,
) )
@@ -2407,6 +2417,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
const { clearBridgePointer } = await import('./bridgePointer.js') const { clearBridgePointer } = await import('./bridgePointer.js')
await clearBridgePointer(resumePointerDir) await clearBridgePointer(resumePointerDir)
} }
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
`Error: Session ${resumeSessionId} has no environment_id. It may never have been attached to a bridge.`, `Error: Session ${resumeSessionId} has no environment_id. It may never have been attached to a bridge.`,
) )
@@ -2460,6 +2471,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
status: err instanceof BridgeFatalError ? err.status : undefined, status: err instanceof BridgeFatalError ? err.status : undefined,
}) })
// Registration failures are fatal — print a clean message instead of a stack trace. // Registration failures are fatal — print a clean message instead of a stack trace.
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
err instanceof BridgeFatalError && err.status === 404 err instanceof BridgeFatalError && err.status === 404
? 'Remote Control environments are not available for your account.' ? 'Remote Control environments are not available for your account.'
@@ -2484,6 +2496,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
`Bridge resume env mismatch: requested ${reuseEnvironmentId}, backend returned ${environmentId}. Falling back to fresh session.`, `Bridge resume env mismatch: requested ${reuseEnvironmentId}, backend returned ${environmentId}. Falling back to fresh session.`,
), ),
) )
// biome-ignore lint/suspicious/noConsole: intentional warning output
console.warn( console.warn(
`Warning: Could not resume session ${resumeSessionId} — its environment has expired. Creating a fresh session instead.`, `Warning: Could not resume session ${resumeSessionId} — its environment has expired. Creating a fresh session instead.`,
) )
@@ -2534,6 +2547,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
const { clearBridgePointer } = await import('./bridgePointer.js') const { clearBridgePointer } = await import('./bridgePointer.js')
await clearBridgePointer(resumePointerDir) await clearBridgePointer(resumePointerDir)
} }
// biome-ignore lint/suspicious/noConsole: intentional error output
console.error( console.error(
isFatal isFatal
? `Error: ${errorMessage(err)}` ? `Error: ${errorMessage(err)}`

View File

@@ -104,8 +104,7 @@ export function isEligibleBridgeMessage(m: Message): boolean {
export function extractTitleText(m: Message): string | undefined { export function extractTitleText(m: Message): string | undefined {
if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary) if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary)
return undefined return undefined
if (m.origin && (m.origin as { kind?: string }).kind !== 'human') if (m.origin && (m.origin as { kind?: string }).kind !== 'human') return undefined
return undefined
const content = m.message!.content const content = m.message!.content
let raw: string | undefined let raw: string | undefined
if (typeof content === 'string') { if (typeof content === 'string') {
@@ -267,13 +266,7 @@ export function handleServerControlRequest(
// Outbound-only: reply error for mutable requests so claude.ai doesn't show // Outbound-only: reply error for mutable requests so claude.ai doesn't show
// false success. initialize must still succeed (server kills the connection // false success. initialize must still succeed (server kills the connection
// if it doesn't — see comment above). // if it doesn't — see comment above).
const req = request.request as { const req = request.request as { subtype: string; model?: string; max_thinking_tokens?: number | null; mode?: string; [key: string]: unknown }
subtype: string
model?: string
max_thinking_tokens?: number | null
mode?: string
[key: string]: unknown
}
if (outboundOnly && req.subtype !== 'initialize') { if (outboundOnly && req.subtype !== 'initialize') {
response = { response = {
type: 'control_response', type: 'control_response',
@@ -396,8 +389,8 @@ export function handleServerControlRequest(
void transport.write(event) void transport.write(event)
rcLog( rcLog(
`control_response: subtype=${req.subtype}` + `control_response: subtype=${req.subtype}` +
` request_id=${request.request_id}` + ` request_id=${request.request_id}` +
` result=${(response.response as { subtype?: string }).subtype}`, ` result=${(response.response as { subtype?: string }).subtype}`,
) )
logForDebugging( logForDebugging(
`[bridge:repl] Sent control_response for ${req.subtype} request_id=${request.request_id} result=${(response.response as { subtype?: string }).subtype}`, `[bridge:repl] Sent control_response for ${req.subtype} request_id=${request.request_id} result=${(response.response as { subtype?: string }).subtype}`,

View File

@@ -24,9 +24,7 @@ export function extractInboundMessageFields(
| { content: string | Array<ContentBlockParam>; uuid: UUID | undefined } | { content: string | Array<ContentBlockParam>; uuid: UUID | undefined }
| undefined { | undefined {
if (msg.type !== 'user') return undefined if (msg.type !== 'user') return undefined
const content = ( const content = (msg.message as { content?: string | Array<ContentBlockParam> } | undefined)?.content
msg.message as { content?: string | Array<ContentBlockParam> } | undefined
)?.content
if (!content) return undefined if (!content) return undefined
if (Array.isArray(content) && content.length === 0) return undefined if (Array.isArray(content) && content.length === 0) return undefined

View File

@@ -290,9 +290,7 @@ export async function initReplBridge(
isSyntheticMessage(msg) isSyntheticMessage(msg)
) )
continue continue
const rawContent = getContentText( const rawContent = getContentText(msg.message!.content as string | ContentBlockParam[])
msg.message!.content as string | ContentBlockParam[],
)
if (!rawContent) continue if (!rawContent) continue
const derived = deriveTitle(rawContent) const derived = deriveTitle(rawContent)
if (!derived) continue if (!derived) continue

View File

@@ -20,10 +20,7 @@ export function rcLog(msg: string): void {
try { try {
if (!headerWritten) { if (!headerWritten) {
ensureLogDir() ensureLogDir()
appendFileSync( appendFileSync(LOG_PATH, `\n===== RC-DEBUG session ${new Date().toISOString()} =====\n`)
LOG_PATH,
`\n===== RC-DEBUG session ${new Date().toISOString()} =====\n`,
)
headerWritten = true headerWritten = true
} }
const ts = new Date().toISOString().slice(11, 23) // HH:mm:ss.SSS const ts = new Date().toISOString().slice(11, 23) // HH:mm:ss.SSS

View File

@@ -834,10 +834,7 @@ export async function initEnvLessBridgeCore(
for (const msg of filtered) { for (const msg of filtered) {
if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string) if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string)
} }
const events = filtered.map(m => ({ const events = filtered.map(m => ({ ...m, session_id: sessionId })) as StdoutMessage[]
...m,
session_id: sessionId,
})) as StdoutMessage[]
void transport.writeBatch(events) void transport.writeBatch(events)
}, },
sendControlRequest(request: SDKControlRequest) { sendControlRequest(request: SDKControlRequest) {
@@ -847,14 +844,8 @@ export async function initEnvLessBridgeCore(
) )
return return
} }
const event: TransportMessage = { const event: TransportMessage = { ...request, session_id: sessionId } as TransportMessage
...request, if ((request as { request?: { subtype?: string } }).request?.subtype === 'can_use_tool') {
session_id: sessionId,
} as TransportMessage
if (
(request as { request?: { subtype?: string } }).request?.subtype ===
'can_use_tool'
) {
transport.reportState('requires_action') transport.reportState('requires_action')
} }
void transport.write(event as StdoutMessage) void transport.write(event as StdoutMessage)
@@ -869,10 +860,7 @@ export async function initEnvLessBridgeCore(
) )
return return
} }
const event: TransportMessage = { const event: TransportMessage = { ...response, session_id: sessionId } as TransportMessage
...response,
session_id: sessionId,
} as TransportMessage
transport.reportState('running') transport.reportState('running')
void transport.write(event as StdoutMessage) void transport.write(event as StdoutMessage)
logForDebugging('[remote-bridge] Sent control_response') logForDebugging('[remote-bridge] Sent control_response')

View File

@@ -452,6 +452,7 @@ export async function initBridgeCore(
// re-created after a connection loss. // re-created after a connection loss.
let currentSessionId: string let currentSessionId: string
if (reusedPriorSession && prior) { if (reusedPriorSession && prior) {
currentSessionId = prior.sessionId currentSessionId = prior.sessionId
logForDebugging( logForDebugging(
@@ -631,9 +632,9 @@ export async function initBridgeCore(
environmentRecreations++ environmentRecreations++
rcLog( rcLog(
`doReconnect: attempt=${environmentRecreations}/${MAX_ENVIRONMENT_RECREATIONS}` + `doReconnect: attempt=${environmentRecreations}/${MAX_ENVIRONMENT_RECREATIONS}` +
` envId=${environmentId}` + ` envId=${environmentId}` +
` sessionId=${currentSessionId}` + ` sessionId=${currentSessionId}` +
` workId=${currentWorkId}`, ` workId=${currentWorkId}`,
) )
// Invalidate any in-flight v2 handshake — the environment is being // Invalidate any in-flight v2 handshake — the environment is being
// recreated, so a stale transport arriving post-reconnect would be // recreated, so a stale transport arriving post-reconnect would be
@@ -845,6 +846,7 @@ export async function initBridgeCore(
// UUIDs are scoped per-session on the server, so re-flushing is safe. // UUIDs are scoped per-session on the server, so re-flushing is safe.
previouslyFlushedUUIDs?.clear() previouslyFlushedUUIDs?.clear()
// Reset the counter so independent reconnections hours apart don't // Reset the counter so independent reconnections hours apart don't
// exhaust the limit — it guards against rapid consecutive failures, // exhaust the limit — it guards against rapid consecutive failures,
// not lifetime total. // not lifetime total.
@@ -905,8 +907,8 @@ export async function initBridgeCore(
function handleTransportPermanentClose(closeCode: number | undefined): void { function handleTransportPermanentClose(closeCode: number | undefined): void {
rcLog( rcLog(
`handleTransportPermanentClose: code=${closeCode}` + `handleTransportPermanentClose: code=${closeCode}` +
` transport=${transport ? 'exists' : 'null'}` + ` transport=${transport ? 'exists' : 'null'}` +
` pollAborted=${pollController.signal.aborted}`, ` pollAborted=${pollController.signal.aborted}`,
) )
logForDebugging( logForDebugging(
`[bridge:repl] Transport permanently closed: code=${closeCode}`, `[bridge:repl] Transport permanently closed: code=${closeCode}`,
@@ -1301,9 +1303,7 @@ export async function initBridgeCore(
session_id: currentSessionId, session_id: currentSessionId,
})) as TransportMessage[] })) as TransportMessage[]
const dropsBefore = newTransport.droppedBatchCount const dropsBefore = newTransport.droppedBatchCount
void newTransport void newTransport.writeBatch(events as StdoutMessage[]).then(() => {
.writeBatch(events as StdoutMessage[])
.then(() => {
// If any batch was dropped during this flush (SI down for // If any batch was dropped during this flush (SI down for
// maxConsecutiveFailures attempts), flush() still resolved // maxConsecutiveFailures attempts), flush() still resolved
// normally but the events were NOT delivered. Don't mark // normally but the events were NOT delivered. Don't mark
@@ -1357,10 +1357,10 @@ export async function initBridgeCore(
const parsed = JSON.parse(data) const parsed = JSON.parse(data)
rcLog( rcLog(
`ingress: type=${parsed.type}` + `ingress: type=${parsed.type}` +
`${parsed.type === 'control_request' ? ` subtype=${(parsed.request as Record<string, unknown>)?.subtype} request_id=${parsed.request_id}` : ''}` + `${parsed.type === 'control_request' ? ` subtype=${(parsed.request as Record<string, unknown>)?.subtype} request_id=${parsed.request_id}` : ''}` +
`${parsed.type === 'control_response' ? ` subtype=${(parsed.response as Record<string, unknown>)?.subtype} request_id=${(parsed.response as Record<string, unknown>)?.request_id}` : ''}` + `${parsed.type === 'control_response' ? ` subtype=${(parsed.response as Record<string, unknown>)?.subtype} request_id=${(parsed.response as Record<string, unknown>)?.request_id}` : ''}` +
`${parsed.type === 'user' ? ` uuid=${parsed.uuid}` : ''}` + `${parsed.type === 'user' ? ` uuid=${parsed.uuid}` : ''}` +
`${parsed.type === 'keep_alive' ? '' : ` len=${data.length}`}`, `${parsed.type === 'keep_alive' ? '' : ` len=${data.length}`}`,
) )
} catch { } catch {
rcLog(`ingress (non-JSON): ${String(data).slice(0, 200)}`) rcLog(`ingress (non-JSON): ${String(data).slice(0, 200)}`)
@@ -1387,9 +1387,9 @@ export async function initBridgeCore(
if (transport !== newTransport) return if (transport !== newTransport) return
rcLog( rcLog(
`transport onClose: code=${closeCode}` + `transport onClose: code=${closeCode}` +
` connected=${newTransport.isConnectedStatus()}` + ` connected=${newTransport.isConnectedStatus()}` +
` state=${newTransport.getStateLabel()}` + ` state=${newTransport.getStateLabel()}` +
` seq=${newTransport.getLastSequenceNum()}`, ` seq=${newTransport.getLastSequenceNum()}`,
) )
handleTransportPermanentClose(closeCode) handleTransportPermanentClose(closeCode)
}) })
@@ -1818,10 +1818,7 @@ export async function initBridgeCore(
for (const msg of filtered) { for (const msg of filtered) {
if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string) if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string)
} }
const events: TransportMessage[] = filtered.map(m => ({ const events: TransportMessage[] = filtered.map(m => ({ ...m, session_id: currentSessionId })) as TransportMessage[]
...m,
session_id: currentSessionId,
})) as TransportMessage[]
void transport.writeBatch(events as StdoutMessage[]) void transport.writeBatch(events as StdoutMessage[])
}, },
sendControlRequest(request: SDKControlRequest) { sendControlRequest(request: SDKControlRequest) {
@@ -1831,10 +1828,7 @@ export async function initBridgeCore(
) )
return return
} }
const event: TransportMessage = { const event: TransportMessage = { ...request, session_id: currentSessionId } as TransportMessage
...request,
session_id: currentSessionId,
} as TransportMessage
void transport.write(event as StdoutMessage) void transport.write(event as StdoutMessage)
logForDebugging( logForDebugging(
`[bridge:repl] Sent control_request request_id=${request.request_id}`, `[bridge:repl] Sent control_request request_id=${request.request_id}`,
@@ -1847,10 +1841,7 @@ export async function initBridgeCore(
) )
return return
} }
const event: TransportMessage = { const event: TransportMessage = { ...response, session_id: currentSessionId } as TransportMessage
...response,
session_id: currentSessionId,
} as TransportMessage
void transport.write(event as StdoutMessage) void transport.write(event as StdoutMessage)
logForDebugging('[bridge:repl] Sent control_response') logForDebugging('[bridge:repl] Sent control_response')
}, },

View File

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

View File

@@ -11,44 +11,21 @@
/** Patterns that match known secret/token formats. */ /** Patterns that match known secret/token formats. */
const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [ const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
// GitHub tokens (PAT, OAuth, App, Server-to-server) // GitHub tokens (PAT, OAuth, App, Server-to-server)
{ { pattern: /\b(ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{10,}\b/g, replacement: '[REDACTED_GITHUB_TOKEN]' },
pattern: /\b(ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{10,}\b/g,
replacement: '[REDACTED_GITHUB_TOKEN]',
},
// Anthropic API keys // Anthropic API keys
{ { pattern: /\bsk-ant-[A-Za-z0-9_-]{10,}\b/g, replacement: '[REDACTED_ANTHROPIC_KEY]' },
pattern: /\bsk-ant-[A-Za-z0-9_-]{10,}\b/g,
replacement: '[REDACTED_ANTHROPIC_KEY]',
},
// Generic Bearer tokens in headers // Generic Bearer tokens in headers
{ { pattern: /(Bearer\s+)[A-Za-z0-9._\-/+=]{20,}/gi, replacement: '$1[REDACTED_TOKEN]' },
pattern: /(Bearer\s+)[A-Za-z0-9._\-/+=]{20,}/gi,
replacement: '$1[REDACTED_TOKEN]',
},
// AWS access keys // AWS access keys
{ { pattern: /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, replacement: '[REDACTED_AWS_KEY]' },
pattern: /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g,
replacement: '[REDACTED_AWS_KEY]',
},
// AWS secret keys (40-char base64-like strings after common labels) // AWS secret keys (40-char base64-like strings after common labels)
{ { pattern: /(aws_secret_access_key|secret_key|SecretAccessKey)['":\s=]+[A-Za-z0-9/+=]{30,}/gi, replacement: '$1=[REDACTED_AWS_SECRET]' },
pattern:
/(aws_secret_access_key|secret_key|SecretAccessKey)['":\s=]+[A-Za-z0-9/+=]{30,}/gi,
replacement: '$1=[REDACTED_AWS_SECRET]',
},
// Generic API key patterns (key=value or "key": "value") // Generic API key patterns (key=value or "key": "value")
{ { pattern: /(api[_-]?key|apikey|secret|password|token|credential)['":\s=]+["']?[A-Za-z0-9._\-/+=]{16,}["']?/gi, replacement: '$1=[REDACTED]' },
pattern:
/(api[_-]?key|apikey|secret|password|token|credential)['":\s=]+["']?[A-Za-z0-9._\-/+=]{16,}["']?/gi,
replacement: '$1=[REDACTED]',
},
// npm tokens // npm tokens
{ pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, replacement: '[REDACTED_NPM_TOKEN]' }, { pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, replacement: '[REDACTED_NPM_TOKEN]' },
// Slack tokens // Slack tokens
{ { pattern: /\bxox[bporas]-[A-Za-z0-9-]{10,}\b/g, replacement: '[REDACTED_SLACK_TOKEN]' },
pattern: /\bxox[bporas]-[A-Za-z0-9-]{10,}\b/g,
replacement: '[REDACTED_SLACK_TOKEN]',
},
] ]
/** Maximum content length before truncation (100KB). */ /** Maximum content length before truncation (100KB). */

View File

@@ -1,50 +1,50 @@
import { feature } from 'bun:bundle'; import { feature } from 'bun:bundle'
import figures from 'figures'; import figures from 'figures'
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react'
import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { Box, Text, stringWidth } from '@anthropic/ink'; import { Box, Text, stringWidth } from '@anthropic/ink'
import { useAppState, useSetAppState } from '../state/AppState.js'; import { useAppState, useSetAppState } from '../state/AppState.js'
import type { AppState } from '../state/AppStateStore.js'; import type { AppState } from '../state/AppStateStore.js'
import { getGlobalConfig } from '../utils/config.js'; import { getGlobalConfig } from '../utils/config.js'
import { isFullscreenActive } from '../utils/fullscreen.js'; import { isFullscreenActive } from '../utils/fullscreen.js'
import type { Theme } from '../utils/theme.js'; import type { Theme } from '../utils/theme.js'
import { getCompanion } from './companion.js'; import { getCompanion } from './companion.js'
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'; import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'
import { RARITY_COLORS } from './types.js'; import { RARITY_COLORS } from './types.js'
const TICK_MS = 500; const TICK_MS = 500
const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms const BUBBLE_SHOW = 20 // ticks → ~10s at 500ms
const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go const FADE_WINDOW = 6 // last ~3s the bubble dims so you know it's about to go
const PET_BURST_MS = 2500; // how long hearts float after /buddy pet const PET_BURST_MS = 2500 // how long hearts float after /buddy pet
// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink. // Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.
// Sequence indices map to sprite frames; -1 means "blink on frame 0". // Sequence indices map to sprite frames; -1 means "blink on frame 0".
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]; const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]
// Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite. // Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite.
const H = figures.heart; const H = figures.heart
const PET_HEARTS = [ const PET_HEARTS = [
` ${H} ${H} `, ` ${H} ${H} `,
` ${H} ${H} ${H} `, ` ${H} ${H} ${H} `,
` ${H} ${H} ${H} `, ` ${H} ${H} ${H} `,
`${H} ${H} ${H} `, `${H} ${H} ${H} `,
'· · · ', '· · · ',
]; ]
function wrap(text: string, width: number): string[] { function wrap(text: string, width: number): string[] {
const words = text.split(' '); const words = text.split(' ')
const lines: string[] = []; const lines: string[] = []
let cur = ''; let cur = ''
for (const w of words) { for (const w of words) {
if (cur.length + w.length + 1 > width && cur) { if (cur.length + w.length + 1 > width && cur) {
lines.push(cur); lines.push(cur)
cur = w; cur = w
} else { } else {
cur = cur ? `${cur} ${w}` : w; cur = cur ? `${cur} ${w}` : w
} }
} }
if (cur) lines.push(cur); if (cur) lines.push(cur)
return lines; return lines
} }
function SpeechBubble({ function SpeechBubble({
@@ -53,29 +53,40 @@ function SpeechBubble({
fading, fading,
tail, tail,
}: { }: {
text: string; text: string
color: keyof Theme; color: keyof Theme
fading: boolean; fading: boolean
tail: 'down' | 'right'; tail: 'down' | 'right'
}): React.ReactNode { }): React.ReactNode {
const lines = wrap(text, 30); const lines = wrap(text, 30)
const borderColor = fading ? 'inactive' : color; const borderColor = fading ? 'inactive' : color
const bubble = ( const bubble = (
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={1} width={34}> <Box
flexDirection="column"
borderStyle="round"
borderColor={borderColor}
paddingX={1}
width={34}
>
{lines.map((l, i) => ( {lines.map((l, i) => (
<Text key={i} italic dimColor={!fading} color={fading ? 'inactive' : undefined}> <Text
key={i}
italic
dimColor={!fading}
color={fading ? 'inactive' : undefined}
>
{l} {l}
</Text> </Text>
))} ))}
</Box> </Box>
); )
if (tail === 'right') { if (tail === 'right') {
return ( return (
<Box flexDirection="row" alignItems="center"> <Box flexDirection="row" alignItems="center">
{bubble} {bubble}
<Text color={borderColor}></Text> <Text color={borderColor}></Text>
</Box> </Box>
); )
} }
return ( return (
<Box flexDirection="column" alignItems="flex-end" marginRight={1}> <Box flexDirection="column" alignItems="flex-end" marginRight={1}>
@@ -85,18 +96,18 @@ function SpeechBubble({
<Text color={borderColor}></Text> <Text color={borderColor}></Text>
</Box> </Box>
</Box> </Box>
); )
} }
export const MIN_COLS_FOR_FULL_SPRITE = 100; export const MIN_COLS_FOR_FULL_SPRITE = 100
const SPRITE_BODY_WIDTH = 12; const SPRITE_BODY_WIDTH = 12
const NAME_ROW_PAD = 2; // focused state wraps name in spaces: ` name ` const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name `
const SPRITE_PADDING_X = 2; const SPRITE_PADDING_X = 2
const BUBBLE_WIDTH = 36; // SpeechBubble box (34) + tail column const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column
const NARROW_QUIP_CAP = 24; const NARROW_QUIP_CAP = 24
function spriteColWidth(nameWidth: number): number { function spriteColWidth(nameWidth: number): number {
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD); return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD)
} }
// Width the sprite area consumes. PromptInput subtracts this so text wraps // Width the sprite area consumes. PromptInput subtracts this so text wraps
@@ -104,73 +115,89 @@ function spriteColWidth(nameWidth: number): number {
// width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more. // width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more.
// Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row // Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row
// (above input in fullscreen, below in scrollback), so no reservation. // (above input in fullscreen, below in scrollback), so no reservation.
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number { export function companionReservedColumns(
if (!feature('BUDDY')) return 0; terminalColumns: number,
const companion = getCompanion(); speaking: boolean,
if (!companion || getGlobalConfig().companionMuted) return 0; ): number {
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0; if (!feature('BUDDY')) return 0
const nameWidth = stringWidth(companion.name); const companion = getCompanion()
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0; if (!companion || getGlobalConfig().companionMuted) return 0
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble; if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0
const nameWidth = stringWidth(companion.name)
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble
} }
export function CompanionSprite(): React.ReactNode { export function CompanionSprite(): React.ReactNode {
const reaction = useAppState(s => s.companionReaction); const reaction = useAppState(s => s.companionReaction)
const petAt = useAppState(s => s.companionPetAt); const petAt = useAppState(s => s.companionPetAt)
const focused = useAppState(s => s.footerSelection === 'companion'); const focused = useAppState(s => s.footerSelection === 'companion')
const setAppState = useSetAppState(); const setAppState = useSetAppState()
const { columns } = useTerminalSize(); const { columns } = useTerminalSize()
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0)
const lastSpokeTick = useRef(0); const lastSpokeTick = useRef(0)
// Sync-during-render (not useEffect) so the first post-pet render already // Sync-during-render (not useEffect) so the first post-pet render already
// has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped. // has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped.
const [{ petStartTick, forPetAt }, setPetStart] = useState({ const [{ petStartTick, forPetAt }, setPetStart] = useState({
petStartTick: 0, petStartTick: 0,
forPetAt: petAt, forPetAt: petAt,
}); })
if (petAt !== forPetAt) { if (petAt !== forPetAt) {
setPetStart({ petStartTick: tick, forPetAt: petAt }); setPetStart({ petStartTick: tick, forPetAt: petAt })
} }
useEffect(() => { useEffect(() => {
const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick); const timer = setInterval(
return () => clearInterval(timer); setT => setT((t: number) => t + 1),
}, []); TICK_MS,
setTick,
)
return () => clearInterval(timer)
}, [])
useEffect(() => { useEffect(() => {
if (!reaction) return; if (!reaction) return
lastSpokeTick.current = tick; lastSpokeTick.current = tick
const timer = setTimeout( const timer = setTimeout(
setA => setA =>
setA((prev: AppState) => setA((prev: AppState) =>
prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined }, prev.companionReaction === undefined
? prev
: { ...prev, companionReaction: undefined },
), ),
BUBBLE_SHOW * TICK_MS, BUBBLE_SHOW * TICK_MS,
setAppState, setAppState,
); )
return () => clearTimeout(timer); return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked // eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
}, [reaction, setAppState]); }, [reaction, setAppState])
if (!feature('BUDDY')) return null; if (!feature('BUDDY')) return null
const companion = getCompanion(); const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return null; if (!companion || getGlobalConfig().companionMuted) return null
const color = RARITY_COLORS[companion.rarity]; const color = RARITY_COLORS[companion.rarity]
const colWidth = spriteColWidth(stringWidth(companion.name)); const colWidth = spriteColWidth(stringWidth(companion.name))
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0; const bubbleAge = reaction ? tick - lastSpokeTick.current : 0
const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW; const fading =
reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW
const petAge = petAt ? tick - petStartTick : Infinity; const petAge = petAt ? tick - petStartTick : Infinity
const petting = petAge * TICK_MS < PET_BURST_MS; const petting = petAge * TICK_MS < PET_BURST_MS
// Narrow terminals: collapse to one-line face. When speaking, the quip // Narrow terminals: collapse to one-line face. When speaking, the quip
// replaces the name beside the face (no room for a bubble). // replaces the name beside the face (no room for a bubble).
if (columns < MIN_COLS_FOR_FULL_SPRITE) { if (columns < MIN_COLS_FOR_FULL_SPRITE) {
const quip = const quip =
reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction; reaction && reaction.length > NARROW_QUIP_CAP
const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name; ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…'
: reaction
const label = quip
? `"${quip}"`
: focused
? ` ${companion.name} `
: companion.name
return ( return (
<Box paddingX={1} alignSelf="flex-end"> <Box paddingX={1} alignSelf="flex-end">
<Text> <Text>
@@ -183,34 +210,44 @@ export function CompanionSprite(): React.ReactNode {
dimColor={!focused && !reaction} dimColor={!focused && !reaction}
bold={focused} bold={focused}
inverse={focused && !reaction} inverse={focused && !reaction}
color={reaction ? (fading ? 'inactive' : color) : focused ? color : undefined} color={
reaction
? fading
? 'inactive'
: color
: focused
? color
: undefined
}
> >
{label} {label}
</Text> </Text>
</Text> </Text>
</Box> </Box>
); )
} }
const frameCount = spriteFrameCount(companion.species); const frameCount = spriteFrameCount(companion.species)
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null; const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null
let spriteFrame: number; let spriteFrame: number
let blink = false; let blink = false
if (reaction || petting) { if (reaction || petting) {
// Excited: cycle all fidget frames fast // Excited: cycle all fidget frames fast
spriteFrame = tick % frameCount; spriteFrame = tick % frameCount
} else { } else {
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!; const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
if (step === -1) { if (step === -1) {
spriteFrame = 0; spriteFrame = 0
blink = true; blink = true
} else { } else {
spriteFrame = step % frameCount; spriteFrame = step % frameCount
} }
} }
const body = renderSprite(companion, spriteFrame).map(line => (blink ? line.replaceAll(companion.eye, '-') : line)); const body = renderSprite(companion, spriteFrame).map(line =>
const sprite = heartFrame ? [heartFrame, ...body] : body; blink ? line.replaceAll(companion.eye, '-') : line,
)
const sprite = heartFrame ? [heartFrame, ...body] : body
// Name row doubles as hint row — unfocused shows dim name + ↓ discovery, // Name row doubles as hint row — unfocused shows dim name + ↓ discovery,
// focused shows inverse name. The enter-to-open hint lives in // focused shows inverse name. The enter-to-open hint lives in
@@ -218,20 +255,31 @@ export function CompanionSprite(): React.ReactNode {
// sprite doesn't jump up when selected. flexShrink=0 stops the // sprite doesn't jump up when selected. flexShrink=0 stops the
// inline-bubble row wrapper from squeezing the sprite to fit. // inline-bubble row wrapper from squeezing the sprite to fit.
const spriteColumn = ( const spriteColumn = (
<Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}> <Box
flexDirection="column"
flexShrink={0}
alignItems="center"
width={colWidth}
>
{sprite.map((line, i) => ( {sprite.map((line, i) => (
<Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}> <Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
{line} {line}
</Text> </Text>
))} ))}
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}> <Text
italic
bold={focused}
dimColor={!focused}
color={focused ? color : undefined}
inverse={focused}
>
{focused ? ` ${companion.name} ` : companion.name} {focused ? ` ${companion.name} ` : companion.name}
</Text> </Text>
</Box> </Box>
); )
if (!reaction) { if (!reaction) {
return <Box paddingX={1}>{spriteColumn}</Box>; return <Box paddingX={1}>{spriteColumn}</Box>
} }
// Fullscreen: bubble renders separately via CompanionFloatingBubble in // Fullscreen: bubble renders separately via CompanionFloatingBubble in
@@ -240,14 +288,19 @@ export function CompanionSprite(): React.ReactNode {
// Non-fullscreen: bubble sits inline beside the sprite (input shrinks) // Non-fullscreen: bubble sits inline beside the sprite (input shrinks)
// because floating into Static scrollback can't be cleared. // because floating into Static scrollback can't be cleared.
if (isFullscreenActive()) { if (isFullscreenActive()) {
return <Box paddingX={1}>{spriteColumn}</Box>; return <Box paddingX={1}>{spriteColumn}</Box>
} }
return ( return (
<Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}> <Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
<SpeechBubble text={reaction} color={color} fading={fading} tail="right" /> <SpeechBubble
text={reaction}
color={color}
fading={fading}
tail="right"
/>
{spriteColumn} {spriteColumn}
</Box> </Box>
); )
} }
// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's // Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's
@@ -255,29 +308,33 @@ export function CompanionSprite(): React.ReactNode {
// the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this // the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this
// just reads companionReaction and renders the fade. // just reads companionReaction and renders the fade.
export function CompanionFloatingBubble(): React.ReactNode { export function CompanionFloatingBubble(): React.ReactNode {
const reaction = useAppState(s => s.companionReaction); const reaction = useAppState(s => s.companionReaction)
const [{ tick, forReaction }, setTick] = useState({ const [{ tick, forReaction }, setTick] = useState({
tick: 0, tick: 0,
forReaction: reaction, forReaction: reaction,
}); })
// Reset tick synchronously when reaction changes (not in useEffect, which // Reset tick synchronously when reaction changes (not in useEffect, which
// runs post-render and would show one stale-faded frame). Storing the // runs post-render and would show one stale-faded frame). Storing the
// reaction the tick is counting FOR alongside the tick itself means the // reaction the tick is counting FOR alongside the tick itself means the
// fade computation never sees a tick from a previous reaction. // fade computation never sees a tick from a previous reaction.
if (reaction !== forReaction) { if (reaction !== forReaction) {
setTick({ tick: 0, forReaction: reaction }); setTick({ tick: 0, forReaction: reaction })
} }
useEffect(() => { useEffect(() => {
if (!reaction) return; if (!reaction) return
const timer = setInterval(set => set(s => ({ ...s, tick: s.tick + 1 })), TICK_MS, setTick); const timer = setInterval(
return () => clearInterval(timer); set => set(s => ({ ...s, tick: s.tick + 1 })),
}, [reaction]); TICK_MS,
setTick,
)
return () => clearInterval(timer)
}, [reaction])
if (!feature('BUDDY') || !reaction) return null; if (!feature('BUDDY') || !reaction) return null
const companion = getCompanion(); const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return null; if (!companion || getGlobalConfig().companionMuted) return null
return ( return (
<SpeechBubble <SpeechBubble
@@ -286,5 +343,5 @@ export function CompanionFloatingBubble(): React.ReactNode {
fading={tick >= BUBBLE_SHOW - FADE_WINDOW} fading={tick >= BUBBLE_SHOW - FADE_WINDOW}
tail="down" tail="down"
/> />
); )
} }

View File

@@ -1,23 +1,25 @@
import { feature } from 'bun:bundle'; import { feature } from 'bun:bundle'
import React, { useEffect } from 'react'; import React, { useEffect } from 'react'
import { useNotifications } from '../context/notifications.js'; import { useNotifications } from '../context/notifications.js'
import { Text } from '@anthropic/ink'; import { Text } from '@anthropic/ink'
import { getGlobalConfig } from '../utils/config.js'; import { getGlobalConfig } from '../utils/config.js'
import { getRainbowColor } from '../utils/thinking.js'; import { getRainbowColor } from '../utils/thinking.js'
// Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter // Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter
// buzz instead of a single UTC-midnight spike, gentler on soul-gen load. // buzz instead of a single UTC-midnight spike, gentler on soul-gen load.
// Teaser window: April 1-7, 2026 only. Command stays live forever after. // Teaser window: April 1-7, 2026 only. Command stays live forever after.
export function isBuddyTeaserWindow(): boolean { export function isBuddyTeaserWindow(): boolean {
if (process.env.USER_TYPE === 'ant') return true; if (process.env.USER_TYPE === 'ant') return true
const d = new Date(); const d = new Date()
return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7; return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7
} }
export function isBuddyLive(): boolean { export function isBuddyLive(): boolean {
if (process.env.USER_TYPE === 'ant') return true; if (process.env.USER_TYPE === 'ant') return true
const d = new Date(); const d = new Date()
return d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3); return (
d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3)
)
} }
function RainbowText({ text }: { text: string }): React.ReactNode { function RainbowText({ text }: { text: string }): React.ReactNode {
@@ -29,35 +31,37 @@ function RainbowText({ text }: { text: string }): React.ReactNode {
</Text> </Text>
))} ))}
</> </>
); )
} }
// Rainbow /buddy teaser shown on startup when no companion hatched yet. // Rainbow /buddy teaser shown on startup when no companion hatched yet.
// Idle presence and reactions are handled by CompanionSprite directly. // Idle presence and reactions are handled by CompanionSprite directly.
export function useBuddyNotification(): void { export function useBuddyNotification(): void {
const { addNotification, removeNotification } = useNotifications(); const { addNotification, removeNotification } = useNotifications()
useEffect(() => { useEffect(() => {
if (!feature('BUDDY')) return; if (!feature('BUDDY')) return
const config = getGlobalConfig(); const config = getGlobalConfig()
if (config.companion || !isBuddyTeaserWindow()) return; if (config.companion || !isBuddyTeaserWindow()) return
addNotification({ addNotification({
key: 'buddy-teaser', key: 'buddy-teaser',
jsx: <RainbowText text="/buddy" />, jsx: <RainbowText text="/buddy" />,
priority: 'immediate', priority: 'immediate',
timeoutMs: 15_000, timeoutMs: 15_000,
}); })
return () => removeNotification('buddy-teaser'); return () => removeNotification('buddy-teaser')
}, [addNotification, removeNotification]); }, [addNotification, removeNotification])
} }
export function findBuddyTriggerPositions(text: string): Array<{ start: number; end: number }> { export function findBuddyTriggerPositions(
if (!feature('BUDDY')) return []; text: string,
const triggers: Array<{ start: number; end: number }> = []; ): Array<{ start: number; end: number }> {
const re = /\/buddy\b/g; if (!feature('BUDDY')) return []
let m: RegExpExecArray | null; const triggers: Array<{ start: number; end: number }> = []
const re = /\/buddy\b/g
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) { while ((m = re.exec(text)) !== null) {
triggers.push({ start: m.index, end: m.index + m[0].length }); triggers.push({ start: m.index, end: m.index + m[0].length })
} }
return triggers; return triggers
} }

View File

@@ -1,341 +1,7 @@
import { readdir, readFile, unlink } from 'fs/promises' // Auto-generated stub — replace with real implementation
import { join } from 'path' export {};
import { randomUUID } from 'crypto' export const psHandler: (args: string[]) => Promise<void> = (async () => {}) as (args: string[]) => Promise<void>;
import { getClaudeConfigHomeDir } from '../utils/envUtils.js' export const logsHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
import { isProcessRunning } from '../utils/genericProcessUtils.js' export const attachHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
import { jsonParse } from '../utils/slowOperations.js' export const killHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
import { selectEngine } from './bg/engines/index.js' export const handleBgFlag: (args: string[]) => Promise<void> = (async () => {}) as (args: string[]) => Promise<void>;
import type { SessionEntry } from './bg/engine.js'
export type { SessionEntry } from './bg/engine.js'
function getSessionsDir(): string {
return join(getClaudeConfigHomeDir(), 'sessions')
}
export async function listLiveSessions(): Promise<SessionEntry[]> {
const dir = getSessionsDir()
let files: string[]
try {
files = await readdir(dir)
} catch {
return []
}
const sessions: SessionEntry[] = []
for (const file of files) {
if (!/^\d+\.json$/.test(file)) continue
const pid = parseInt(file.slice(0, -5), 10)
if (!isProcessRunning(pid)) {
void unlink(join(dir, file)).catch(() => {})
continue
}
try {
const raw = await readFile(join(dir, file), 'utf-8')
const entry = jsonParse(raw) as SessionEntry
sessions.push(entry)
} catch {
// Corrupt file — skip
}
}
return sessions
}
export function findSession(
sessions: SessionEntry[],
target: string,
): SessionEntry | undefined {
const asNum = parseInt(target, 10)
return sessions.find(
s =>
s.sessionId === target ||
s.pid === asNum ||
(s.name && s.name === target),
)
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleString()
}
/**
* Resolve the engine type for an existing session.
* Backward-compatible: sessions without an `engine` field are inferred
* from the presence of `tmuxSessionName`.
*/
function resolveSessionEngine(session: SessionEntry): 'tmux' | 'detached' {
if (session.engine) return session.engine
return session.tmuxSessionName ? 'tmux' : 'detached'
}
/**
* `claude daemon status` / `claude ps` — list live sessions.
*/
export async function psHandler(_args: string[]): Promise<void> {
const sessions = await listLiveSessions()
if (sessions.length === 0) {
console.log('No active sessions.')
return
}
console.log(
`${sessions.length} active session${sessions.length > 1 ? 's' : ''}:\n`,
)
for (const s of sessions) {
const engineType = resolveSessionEngine(s)
const parts: string[] = [
` PID: ${s.pid}`,
` Kind: ${s.kind}`,
` Engine: ${engineType}`,
` Session: ${s.sessionId}`,
` CWD: ${s.cwd}`,
]
if (s.name) parts.push(` Name: ${s.name}`)
if (s.startedAt) parts.push(` Started: ${formatTime(s.startedAt)}`)
if (s.status) parts.push(` Status: ${s.status}`)
if (s.waitingFor) parts.push(` Waiting for: ${s.waitingFor}`)
if (s.bridgeSessionId) parts.push(` Bridge: ${s.bridgeSessionId}`)
if (s.tmuxSessionName) parts.push(` Tmux: ${s.tmuxSessionName}`)
if (s.logPath) parts.push(` Log: ${s.logPath}`)
console.log(parts.join('\n'))
console.log()
}
}
/**
* `claude daemon logs <target>` — show logs for a session.
*/
export async function logsHandler(target: string | undefined): Promise<void> {
const sessions = await listLiveSessions()
if (!target) {
if (sessions.length === 0) {
console.log('No active sessions.')
return
}
if (sessions.length === 1) {
target = sessions[0]!.sessionId
} else {
console.log('Multiple sessions active. Specify one:')
for (const s of sessions) {
const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId
console.log(` ${label} PID=${s.pid}`)
}
return
}
}
const session = findSession(sessions, target)
if (!session) {
console.error(`Session not found: ${target}`)
process.exitCode = 1
return
}
if (!session.logPath) {
console.log(`No log path recorded for session ${session.sessionId}`)
return
}
try {
const content = await readFile(session.logPath, 'utf-8')
process.stdout.write(content)
} catch (e) {
console.error(`Failed to read log file: ${session.logPath}`)
console.error(e instanceof Error ? e.message : String(e))
process.exitCode = 1
}
}
/**
* `claude daemon attach <target>` — attach to a background session.
*
* Engine-aware: tmux sessions use tmux attach, detached sessions use log tail.
*/
export async function attachHandler(target: string | undefined): Promise<void> {
const sessions = await listLiveSessions()
if (!target) {
// Find bg sessions (tmux or detached)
const bgSessions = sessions.filter(
s => s.tmuxSessionName || s.engine === 'detached',
)
if (bgSessions.length === 0) {
console.log(
'No background sessions to attach to. Start one with `claude daemon bg`.',
)
return
}
if (bgSessions.length === 1) {
target = bgSessions[0]!.sessionId
} else {
console.log('Multiple background sessions. Specify one:')
for (const s of bgSessions) {
const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId
const engineType = resolveSessionEngine(s)
console.log(` ${label} PID=${s.pid} engine=${engineType}`)
}
return
}
}
const session = findSession(sessions, target)
if (!session) {
console.error(`Session not found: ${target}`)
process.exitCode = 1
return
}
const engineType = resolveSessionEngine(session)
try {
if (engineType === 'tmux') {
const { TmuxEngine } = await import('./bg/engines/tmux.js')
const tmux = new TmuxEngine()
if (!(await tmux.available())) {
console.error(
'tmux is no longer available. Cannot attach to tmux session.',
)
process.exitCode = 1
return
}
await tmux.attach(session)
} else {
const { DetachedEngine } = await import('./bg/engines/detached.js')
const detached = new DetachedEngine()
await detached.attach(session)
}
} catch (e) {
console.error(e instanceof Error ? e.message : String(e))
process.exitCode = 1
}
}
/**
* `claude daemon kill <target>` — kill a session.
*/
export async function killHandler(target: string | undefined): Promise<void> {
const sessions = await listLiveSessions()
if (!target) {
if (sessions.length === 0) {
console.log('No active sessions to kill.')
return
}
console.log('Specify a session to kill:')
for (const s of sessions) {
const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId
console.log(` ${label} PID=${s.pid}`)
}
return
}
const session = findSession(sessions, target)
if (!session) {
console.error(`Session not found: ${target}`)
process.exitCode = 1
return
}
console.log(`Killing session ${session.sessionId} (PID: ${session.pid})...`)
try {
process.kill(session.pid, 'SIGTERM')
} catch {
console.log('Session already exited.')
return
}
await new Promise(resolve => setTimeout(resolve, 2000))
if (isProcessRunning(session.pid)) {
try {
process.kill(session.pid, 'SIGKILL')
console.log('Session force-killed.')
} catch {
console.log('Session exited during grace period.')
}
} else {
console.log('Session stopped.')
}
const pidFile = join(getSessionsDir(), `${session.pid}.json`)
void unlink(pidFile).catch(() => {})
}
/**
* `claude daemon bg [args]` — start a background session.
*
* Cross-platform: uses TmuxEngine on macOS/Linux when tmux is available,
* falls back to DetachedEngine on Windows or when tmux is absent.
*/
export async function handleBgStart(args: string[]): Promise<void> {
const engine = await selectEngine()
// Strip --bg/--background from args (for backward-compat shortcut)
const filteredArgs = args.filter(a => a !== '--bg' && a !== '--background')
// Engines without interactive TTY input (e.g. detached) require -p/--print
// or piped input. Tmux provides a virtual terminal so it works without -p.
if (
!engine.supportsInteractiveInput &&
!filteredArgs.some(a => a === '-p' || a === '--print' || a === '--pipe')
) {
console.error(
'Error: Background sessions with detached engine require -p/--print flag.\n' +
'The detached engine has no terminal for interactive input.\n\n' +
'Usage:\n' +
' claude daemon bg -p "your prompt here"\n' +
' echo "prompt" | claude daemon bg --pipe',
)
if (process.platform !== 'win32') {
console.error(
'\nAlternatively, install tmux for interactive background sessions:\n' +
` ${process.platform === 'darwin' ? 'brew install tmux' : 'sudo apt install tmux'}`,
)
}
process.exitCode = 1
return
}
const sessionName = `claude-bg-${randomUUID().slice(0, 8)}`
const logPath = join(
getClaudeConfigHomeDir(),
'sessions',
'logs',
`${sessionName}.log`,
)
try {
const result = await engine.start({
sessionName,
args: filteredArgs,
env: { ...process.env },
logPath,
cwd: process.cwd(),
})
console.log(`Background session started: ${result.sessionName}`)
console.log(` Engine: ${result.engineUsed}`)
console.log(` Log: ${result.logPath}`)
console.log()
console.log(
`Use \`claude daemon attach ${result.sessionName}\` to reconnect.`,
)
console.log(`Use \`claude daemon status\` to check status.`)
console.log(`Use \`claude daemon kill ${result.sessionName}\` to stop.`)
} catch (e) {
console.error(e instanceof Error ? e.message : String(e))
process.exitCode = 1
}
}
// Legacy export alias — kept for backward compatibility with cli.tsx
export const handleBgFlag = handleBgStart

View File

@@ -1,15 +0,0 @@
import { describe, test, expect } from 'bun:test'
import { DetachedEngine } from '../engines/detached.js'
describe('DetachedEngine', () => {
test('name is "detached"', () => {
const engine = new DetachedEngine()
expect(engine.name).toBe('detached')
})
test('available always returns true', async () => {
const engine = new DetachedEngine()
const result = await engine.available()
expect(result).toBe(true)
})
})

View File

@@ -1,37 +0,0 @@
import { describe, test, expect } from 'bun:test'
describe('selectEngine', () => {
test('returns engine with valid BgEngine interface', async () => {
const { selectEngine } = await import('../engines/index.js')
const engine = await selectEngine()
expect(engine.name).toBeDefined()
expect(['tmux', 'detached']).toContain(engine.name)
expect(typeof engine.available).toBe('function')
expect(typeof engine.start).toBe('function')
expect(typeof engine.attach).toBe('function')
})
test('engine.available() returns a boolean', async () => {
const { selectEngine } = await import('../engines/index.js')
const engine = await selectEngine()
const result = await engine.available()
expect(typeof result).toBe('boolean')
})
})
describe('SessionEntry type', () => {
test('engine field accepts tmux or detached', async () => {
// Verify the module loads and exports the expected interface shape
const mod = await import('../engine.js')
expect(mod).toBeDefined()
const entry = {
pid: 123,
sessionId: 'test',
cwd: '/tmp',
startedAt: Date.now(),
kind: 'bg',
engine: 'detached' as const,
}
expect(entry.engine).toBe('detached')
})
})

View File

@@ -1,8 +0,0 @@
import { describe, test, expect } from 'bun:test'
describe('tailLog', () => {
test('module exports tailLog function', async () => {
const mod = await import('../tail.js')
expect(typeof mod.tailLog).toBe('function')
})
})

View File

@@ -1,49 +0,0 @@
/**
* BgEngine — cross-platform background session engine abstraction.
*
* Implementations:
* TmuxEngine — macOS/Linux with tmux installed
* DetachedEngine — Windows, or macOS/Linux without tmux (fallback)
*/
export interface SessionEntry {
pid: number
sessionId: string
cwd: string
startedAt: number
kind: string
name?: string
logPath?: string
entrypoint?: string
status?: string
waitingFor?: string
updatedAt?: number
bridgeSessionId?: string
agent?: string
tmuxSessionName?: string
engine?: 'tmux' | 'detached'
}
export interface BgStartOptions {
sessionName: string
args: string[]
env: Record<string, string | undefined>
logPath: string
cwd: string
}
export interface BgStartResult {
pid: number
sessionName: string
logPath: string
engineUsed: 'tmux' | 'detached'
}
export interface BgEngine {
readonly name: 'tmux' | 'detached'
/** Whether the engine provides a TTY for interactive REPL input. */
readonly supportsInteractiveInput: boolean
available(): Promise<boolean>
start(opts: BgStartOptions): Promise<BgStartResult>
attach(session: SessionEntry): Promise<void>
}

View File

@@ -1,59 +0,0 @@
import { closeSync, mkdirSync, openSync } from 'fs'
import { dirname } from 'path'
import { buildCliLaunch, spawnCli } from '../../../utils/cliLaunch.js'
import type {
BgEngine,
BgStartOptions,
BgStartResult,
SessionEntry,
} from '../engine.js'
import { tailLog } from '../tail.js'
export class DetachedEngine implements BgEngine {
readonly name = 'detached' as const
readonly supportsInteractiveInput = false
async available(): Promise<boolean> {
return true
}
async start(opts: BgStartOptions): Promise<BgStartResult> {
mkdirSync(dirname(opts.logPath), { recursive: true })
const logFd = openSync(opts.logPath, 'a')
const launch = buildCliLaunch(opts.args, {
env: {
...opts.env,
CLAUDE_CODE_SESSION_KIND: 'bg',
CLAUDE_CODE_SESSION_NAME: opts.sessionName,
CLAUDE_CODE_SESSION_LOG: opts.logPath,
} as NodeJS.ProcessEnv,
})
const child = spawnCli(launch, {
detached: true,
stdio: ['ignore', logFd, logFd],
cwd: opts.cwd,
})
child.unref()
closeSync(logFd)
const pid = child.pid ?? 0
return {
pid,
sessionName: opts.sessionName,
logPath: opts.logPath,
engineUsed: 'detached',
}
}
async attach(session: SessionEntry): Promise<void> {
if (!session.logPath) {
throw new Error(`Session ${session.sessionId} has no log path.`)
}
await tailLog(session.logPath)
}
}

View File

@@ -1,22 +0,0 @@
export type {
BgEngine,
BgStartOptions,
BgStartResult,
SessionEntry,
} from '../engine.js'
export async function selectEngine(): Promise<import('../engine.js').BgEngine> {
if (process.platform === 'win32') {
const { DetachedEngine } = await import('./detached.js')
return new DetachedEngine()
}
const { TmuxEngine } = await import('./tmux.js')
const tmux = new TmuxEngine()
if (await tmux.available()) {
return tmux
}
const { DetachedEngine } = await import('./detached.js')
return new DetachedEngine()
}

View File

@@ -1,80 +0,0 @@
import { spawnSync } from 'child_process'
import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
import { buildCliLaunch, quoteCliLaunch } from '../../../utils/cliLaunch.js'
import type {
BgEngine,
BgStartOptions,
BgStartResult,
SessionEntry,
} from '../engine.js'
export class TmuxEngine implements BgEngine {
readonly name = 'tmux' as const
readonly supportsInteractiveInput = true
async available(): Promise<boolean> {
const { code } = await execFileNoThrow('tmux', ['-V'], { useCwd: false })
return code === 0
}
async start(opts: BgStartOptions): Promise<BgStartResult> {
const launch = buildCliLaunch(opts.args, {
env: {
...opts.env,
CLAUDE_CODE_SESSION_KIND: 'bg',
CLAUDE_CODE_SESSION_NAME: opts.sessionName,
CLAUDE_CODE_SESSION_LOG: opts.logPath,
CLAUDE_CODE_TMUX_SESSION: opts.sessionName,
} as NodeJS.ProcessEnv,
})
const cmd = quoteCliLaunch(launch)
const result = spawnSync(
'tmux',
['new-session', '-d', '-s', opts.sessionName, cmd],
{ stdio: 'inherit', env: launch.env },
)
if (result.status !== 0) {
throw new Error('Failed to create tmux session.')
}
// tmux doesn't directly report the child PID; we return 0.
// The actual session process writes its own PID file.
return {
pid: 0,
sessionName: opts.sessionName,
logPath: opts.logPath,
engineUsed: 'tmux',
}
}
async attach(session: SessionEntry): Promise<void> {
if (!session.tmuxSessionName) {
throw new Error(`Session ${session.sessionId} has no tmux session name.`)
}
const result = spawnSync(
'tmux',
['attach-session', '-t', session.tmuxSessionName],
{ stdio: 'inherit' },
)
if (result.status !== 0) {
throw new Error(
`Failed to attach to tmux session '${session.tmuxSessionName}'.`,
)
}
}
}
export function getTmuxInstallHint(): string {
if (process.platform === 'darwin') {
return 'Install with: brew install tmux'
}
if (process.platform === 'win32') {
return 'tmux is not natively available on Windows. Consider using WSL.'
}
return 'Install with: sudo apt install tmux (or your package manager)'
}

View File

@@ -1,70 +0,0 @@
import {
openSync,
readSync,
closeSync,
statSync,
watchFile,
unwatchFile,
createReadStream,
} from 'fs'
import { createInterface } from 'readline'
/**
* Cross-platform real-time log output. Ctrl+C exits tail without killing
* the background process.
*
* Strategy:
* 1. Read existing content and output to stdout
* 2. Use fs.watchFile() (polling-based — works everywhere including Windows)
* 3. On change, read new bytes from the last known position
* 4. SIGINT exits cleanly
*/
export async function tailLog(logPath: string): Promise<void> {
let position = 0
// Output existing content
try {
const stat = statSync(logPath)
position = stat.size
if (position > 0) {
const stream = createReadStream(logPath, { start: 0, end: position - 1 })
const rl = createInterface({ input: stream })
for await (const line of rl) {
process.stdout.write(line + '\n')
}
}
} catch {
// File may not exist yet — that's fine
}
console.log('\n[tail] Watching for new output... (Ctrl+C to detach)\n')
return new Promise<void>(resolve => {
const onSignal = (): void => {
unwatchFile(logPath)
process.removeListener('SIGINT', onSignal)
console.log('\n[tail] Detached from session.')
resolve()
}
process.on('SIGINT', onSignal)
watchFile(logPath, { interval: 300 }, () => {
try {
const stat = statSync(logPath)
if (stat.size <= position) return
const fd = openSync(logPath, 'r')
try {
const buf = Buffer.alloc(stat.size - position)
readSync(fd, buf, 0, buf.length, position)
process.stdout.write(buf)
position = stat.size
} finally {
closeSync(fd)
}
} catch {
// File may have been deleted or truncated
}
})
})
}

View File

@@ -17,6 +17,7 @@
/** Write an error message to stderr (if given) and exit with code 1. */ /** Write an error message to stderr (if given) and exit with code 1. */
export function cliError(msg?: string): never { export function cliError(msg?: string): never {
// biome-ignore lint/suspicious/noConsole: centralized CLI error output
if (msg) console.error(msg) if (msg) console.error(msg)
process.exit(1) process.exit(1)
return undefined as never return undefined as never

View File

@@ -59,9 +59,12 @@ export async function agentsHandler(): Promise<void> {
} }
if (lines.length === 0) { if (lines.length === 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('No agents found.') console.log('No agents found.')
} else { } else {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${totalActive} active agents\n`) console.log(`${totalActive} active agents\n`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(lines.join('\n').trimEnd()) console.log(lines.join('\n').trimEnd())
} }
} }

View File

@@ -1,216 +1,13 @@
import type { Command } from '@commander-js/extra-typings' // Auto-generated stub — replace with real implementation
import { import type { Command } from '@commander-js/extra-typings';
createTask,
getTask,
updateTask,
listTasks,
getTasksDir,
} from '../../utils/tasks.js'
import { getRecentActivity } from '../../utils/logoV2Utils.js'
import type { LogOption } from '../../types/logs.js'
const DEFAULT_LIST = 'default' export {};
export const logHandler: (logId: string | number | undefined) => Promise<void> = (async () => {}) as (logId: string | number | undefined) => Promise<void>;
// ─── Group C: Task CRUD ────────────────────────────────────────────────────── export const errorHandler: (num: number | undefined) => Promise<void> = (async () => {}) as (num: number | undefined) => Promise<void>;
export const exportHandler: (source: string, outputFile: string) => Promise<void> = (async () => {}) as (source: string, outputFile: string) => Promise<void>;
export async function taskCreateHandler( export const taskCreateHandler: (subject: string, opts: { description?: string; list?: string }) => Promise<void> = (async () => {}) as (subject: string, opts: { description?: string; list?: string }) => Promise<void>;
subject: string, export const taskListHandler: (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise<void> = (async () => {}) as (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise<void>;
opts: { description?: string; list?: string }, export const taskGetHandler: (id: string, opts: { list?: string }) => Promise<void> = (async () => {}) as (id: string, opts: { list?: string }) => Promise<void>;
): Promise<void> { export const taskUpdateHandler: (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise<void> = (async () => {}) as (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise<void>;
const listId = opts.list || DEFAULT_LIST export const taskDirHandler: (opts: { list?: string }) => Promise<void> = (async () => {}) as (opts: { list?: string }) => Promise<void>;
const id = await createTask(listId, { export const completionHandler: (shell: string, opts: { output?: string }, program: Command) => Promise<void> = (async () => {}) as (shell: string, opts: { output?: string }, program: Command) => Promise<void>;
subject,
description: opts.description || '',
status: 'pending',
blocks: [],
blockedBy: [],
})
console.log(`Created task ${id}: ${subject}`)
}
export async function taskListHandler(opts: {
list?: string
pending?: boolean
json?: boolean
}): Promise<void> {
const listId = opts.list || DEFAULT_LIST
let tasks = await listTasks(listId)
if (opts.pending) {
tasks = tasks.filter(t => t.status === 'pending')
}
if (opts.json) {
console.log(JSON.stringify(tasks, null, 2))
return
}
if (tasks.length === 0) {
console.log('No tasks found.')
return
}
for (const t of tasks) {
console.log(` [${t.status}] ${t.id}: ${t.subject}`)
if (t.description) console.log(` ${t.description}`)
if (t.owner) console.log(` owner: ${t.owner}`)
}
}
export async function taskGetHandler(
id: string,
opts: { list?: string },
): Promise<void> {
const listId = opts.list || DEFAULT_LIST
const task = await getTask(listId, id)
if (!task) {
console.error(`Task not found: ${id}`)
process.exitCode = 1
return
}
console.log(JSON.stringify(task, null, 2))
}
export async function taskUpdateHandler(
id: string,
opts: {
list?: string
status?: string
subject?: string
description?: string
owner?: string
clearOwner?: boolean
},
): Promise<void> {
const listId = opts.list || DEFAULT_LIST
const updates: Record<string, unknown> = {}
if (opts.status) updates.status = opts.status
if (opts.subject) updates.subject = opts.subject
if (opts.description) updates.description = opts.description
if (opts.owner) updates.owner = opts.owner
if (opts.clearOwner) updates.owner = undefined
const task = await updateTask(listId, id, updates)
if (!task) {
console.error(`Task not found: ${id}`)
process.exitCode = 1
return
}
console.log(`Updated task ${id}: [${task.status}] ${task.subject}`)
}
export async function taskDirHandler(opts: { list?: string }): Promise<void> {
const listId = opts.list || DEFAULT_LIST
console.log(getTasksDir(listId))
}
// ─── Group B: Log / Error / Export ───────────────────────────────────────────
export async function logHandler(
logId: string | number | undefined,
): Promise<void> {
const logs = await getRecentActivity()
if (logId === undefined) {
if (logs.length === 0) {
console.log('No recent sessions.')
return
}
for (let i = 0; i < Math.min(logs.length, 20); i++) {
const log = logs[i]!
const date = log.modified
? new Date(log.modified).toLocaleString()
: 'unknown'
const title =
(log as Record<string, unknown>).title || log.sessionId || 'untitled'
console.log(` ${i}: ${title} (${date})`)
}
return
}
const idx = typeof logId === 'string' ? parseInt(logId, 10) : logId
const log =
Number.isFinite(idx) && idx >= 0 && idx < logs.length
? logs[idx]
: logs.find(l => l.sessionId === String(logId))
if (!log) {
console.error(`Session not found: ${logId}`)
process.exitCode = 1
return
}
console.log(JSON.stringify(log, null, 2))
}
export async function errorHandler(num: number | undefined): Promise<void> {
// Error log viewing — shows recent session errors
const logs = await getRecentActivity()
const count = num ?? 5
console.log(`Last ${count} sessions:`)
for (let i = 0; i < Math.min(count, logs.length); i++) {
const log = logs[i]!
const date = log.modified
? new Date(log.modified).toLocaleString()
: 'unknown'
console.log(` ${i}: ${log.sessionId} (${date})`)
}
}
export async function exportHandler(
source: string,
outputFile: string,
): Promise<void> {
const { writeFile, readFile } = await import('fs/promises')
const logs = await getRecentActivity()
// Try as index first
const idx = parseInt(source, 10)
let log: LogOption | undefined
if (Number.isFinite(idx) && idx >= 0 && idx < logs.length) {
log = logs[idx]
} else {
log = logs.find(l => l.sessionId === source)
}
if (!log) {
// Try as file path
try {
const content = await readFile(source, 'utf-8')
await writeFile(outputFile, content, 'utf-8')
console.log(`Exported ${source}${outputFile}`)
return
} catch {
console.error(`Source not found: ${source}`)
process.exitCode = 1
return
}
}
await writeFile(outputFile, JSON.stringify(log, null, 2), 'utf-8')
console.log(`Exported session ${log.sessionId}${outputFile}`)
}
// ─── Group D: Completion ─────────────────────────────────────────────────────
export async function completionHandler(
shell: string,
opts: { output?: string },
_program: Command,
): Promise<void> {
const { regenerateCompletionCache } = await import(
'../../utils/completionCache.js'
)
if (opts.output) {
// Generate and write to file
await regenerateCompletionCache()
console.log(`Completion cache regenerated for ${shell}.`)
} else {
// Regenerate and output to stdout
await regenerateCompletionCache()
console.log(`Completion cache regenerated for ${shell}.`)
}
}

View File

@@ -159,9 +159,7 @@ export async function authLogin({
const orgResult = await validateForceLoginOrg() const orgResult = await validateForceLoginOrg()
if (!orgResult.valid) { if (!orgResult.valid) {
process.stderr.write( process.stderr.write((orgResult as { valid: false; message: string }).message + '\n')
(orgResult as { valid: false; message: string }).message + '\n',
)
process.exit(1) process.exit(1)
} }
@@ -211,9 +209,7 @@ export async function authLogin({
const orgResult = await validateForceLoginOrg() const orgResult = await validateForceLoginOrg()
if (!orgResult.valid) { if (!orgResult.valid) {
process.stderr.write( process.stderr.write((orgResult as { valid: false; message: string }).message + '\n')
(orgResult as { valid: false; message: string }).message + '\n',
)
process.exit(1) process.exit(1)
} }

View File

@@ -3,163 +3,203 @@
* These are dynamically imported only when the corresponding `claude mcp *` command runs. * These are dynamically imported only when the corresponding `claude mcp *` command runs.
*/ */
import { stat } from 'fs/promises'; import { stat } from 'fs/promises'
import pMap from 'p-map'; import pMap from 'p-map'
import { cwd } from 'process'; import { cwd } from 'process'
import React from 'react'; import React from 'react'
import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js'; import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js'
import { wrappedRender as render } from '@anthropic/ink'; import { wrappedRender as render } from '@anthropic/ink'
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
import { import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent, logEvent,
} from '../../services/analytics/index.js'; } from '../../services/analytics/index.js'
import { import {
clearMcpClientConfig, clearMcpClientConfig,
clearServerTokensFromLocalStorage, clearServerTokensFromLocalStorage,
getMcpClientConfig, getMcpClientConfig,
readClientSecret, readClientSecret,
saveMcpClientSecret, saveMcpClientSecret,
} from '../../services/mcp/auth.js'; } from '../../services/mcp/auth.js'
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js'; import {
connectToServer,
getMcpServerConnectionBatchSize,
} from '../../services/mcp/client.js'
import { import {
addMcpConfig, addMcpConfig,
getAllMcpConfigs, getAllMcpConfigs,
getMcpConfigByName, getMcpConfigByName,
getMcpConfigsByScope, getMcpConfigsByScope,
removeMcpConfig, removeMcpConfig,
} from '../../services/mcp/config.js'; } from '../../services/mcp/config.js'
import type { ConfigScope, ScopedMcpServerConfig } from '../../services/mcp/types.js'; import type {
import { describeMcpConfigFilePath, ensureConfigScope, getScopeLabel } from '../../services/mcp/utils.js'; ConfigScope,
import { AppStateProvider } from '../../state/AppState.js'; ScopedMcpServerConfig,
import { getCurrentProjectConfig, getGlobalConfig, saveCurrentProjectConfig } from '../../utils/config.js'; } from '../../services/mcp/types.js'
import { isFsInaccessible } from '../../utils/errors.js'; import {
import { gracefulShutdown } from '../../utils/gracefulShutdown.js'; describeMcpConfigFilePath,
import { safeParseJSON } from '../../utils/json.js'; ensureConfigScope,
import { getPlatform } from '../../utils/platform.js'; getScopeLabel,
import { cliError, cliOk } from '../exit.js'; } from '../../services/mcp/utils.js'
import { AppStateProvider } from '../../state/AppState.js'
import {
getCurrentProjectConfig,
getGlobalConfig,
saveCurrentProjectConfig,
} from '../../utils/config.js'
import { isFsInaccessible } from '../../utils/errors.js'
import { gracefulShutdown } from '../../utils/gracefulShutdown.js'
import { safeParseJSON } from '../../utils/json.js'
import { getPlatform } from '../../utils/platform.js'
import { cliError, cliOk } from '../exit.js'
async function checkMcpServerHealth(name: string, server: ScopedMcpServerConfig): Promise<string> { async function checkMcpServerHealth(
name: string,
server: ScopedMcpServerConfig,
): Promise<string> {
try { try {
const result = await connectToServer(name, server); const result = await connectToServer(name, server)
if (result.type === 'connected') { if (result.type === 'connected') {
return '✓ Connected'; return '✓ Connected'
} else if (result.type === 'needs-auth') { } else if (result.type === 'needs-auth') {
return '! Needs authentication'; return '! Needs authentication'
} else { } else {
return '✗ Failed to connect'; return '✗ Failed to connect'
} }
} catch (_error) { } catch (_error) {
return '✗ Connection error'; return '✗ Connection error'
} }
} }
// mcp serve (lines 45124532) // mcp serve (lines 45124532)
export async function mcpServeHandler({ debug, verbose }: { debug?: boolean; verbose?: boolean }): Promise<void> { export async function mcpServeHandler({
const providedCwd = cwd(); debug,
logEvent('tengu_mcp_start', {}); verbose,
}: {
debug?: boolean
verbose?: boolean
}): Promise<void> {
const providedCwd = cwd()
logEvent('tengu_mcp_start', {})
try { try {
await stat(providedCwd); await stat(providedCwd)
} catch (error) { } catch (error) {
if (isFsInaccessible(error)) { if (isFsInaccessible(error)) {
cliError(`Error: Directory ${providedCwd} does not exist`); cliError(`Error: Directory ${providedCwd} does not exist`)
} }
throw error; throw error
} }
try { try {
const { setup } = await import('../../setup.js'); const { setup } = await import('../../setup.js')
await setup(providedCwd, 'default', false, false, undefined, false); await setup(providedCwd, 'default', false, false, undefined, false)
const { startMCPServer } = await import('../../entrypoints/mcp.js'); const { startMCPServer } = await import('../../entrypoints/mcp.js')
await startMCPServer(providedCwd, debug ?? false, verbose ?? false); await startMCPServer(providedCwd, debug ?? false, verbose ?? false)
} catch (error) { } catch (error) {
cliError(`Error: Failed to start MCP server: ${error}`); cliError(`Error: Failed to start MCP server: ${error}`)
} }
} }
// mcp remove (lines 45454635) // mcp remove (lines 45454635)
export async function mcpRemoveHandler(name: string, options: { scope?: string }): Promise<void> { export async function mcpRemoveHandler(
name: string,
options: { scope?: string },
): Promise<void> {
// Look up config before removing so we can clean up secure storage // Look up config before removing so we can clean up secure storage
const serverBeforeRemoval = getMcpConfigByName(name); const serverBeforeRemoval = getMcpConfigByName(name)
const cleanupSecureStorage = () => { const cleanupSecureStorage = () => {
if (serverBeforeRemoval && (serverBeforeRemoval.type === 'sse' || serverBeforeRemoval.type === 'http')) { if (
clearServerTokensFromLocalStorage(name, serverBeforeRemoval); serverBeforeRemoval &&
clearMcpClientConfig(name, serverBeforeRemoval); (serverBeforeRemoval.type === 'sse' ||
serverBeforeRemoval.type === 'http')
) {
clearServerTokensFromLocalStorage(name, serverBeforeRemoval)
clearMcpClientConfig(name, serverBeforeRemoval)
} }
}; }
try { try {
if (options.scope) { if (options.scope) {
const scope = ensureConfigScope(options.scope); const scope = ensureConfigScope(options.scope)
logEvent('tengu_mcp_delete', { logEvent('tengu_mcp_delete', {
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, scope:
}); scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
await removeMcpConfig(name, scope); await removeMcpConfig(name, scope)
cleanupSecureStorage(); cleanupSecureStorage()
process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`); process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`)
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`); cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`)
} }
// If no scope specified, check where the server exists // If no scope specified, check where the server exists
const projectConfig = getCurrentProjectConfig(); const projectConfig = getCurrentProjectConfig()
const globalConfig = getGlobalConfig(); const globalConfig = getGlobalConfig()
// Check if server exists in project scope (.mcp.json) // Check if server exists in project scope (.mcp.json)
const { servers: projectServers } = getMcpConfigsByScope('project'); const { servers: projectServers } = getMcpConfigsByScope('project')
const mcpJsonExists = !!projectServers[name]; const mcpJsonExists = !!projectServers[name]
// Count how many scopes contain this server // Count how many scopes contain this server
const scopes: Array<Exclude<ConfigScope, 'dynamic'>> = []; const scopes: Array<Exclude<ConfigScope, 'dynamic'>> = []
if (projectConfig.mcpServers?.[name]) scopes.push('local'); if (projectConfig.mcpServers?.[name]) scopes.push('local')
if (mcpJsonExists) scopes.push('project'); if (mcpJsonExists) scopes.push('project')
if (globalConfig.mcpServers?.[name]) scopes.push('user'); if (globalConfig.mcpServers?.[name]) scopes.push('user')
if (scopes.length === 0) { if (scopes.length === 0) {
cliError(`No MCP server found with name: "${name}"`); cliError(`No MCP server found with name: "${name}"`)
} else if (scopes.length === 1) { } else if (scopes.length === 1) {
// Server exists in only one scope, remove it // Server exists in only one scope, remove it
const scope = scopes[0]!; const scope = scopes[0]!
logEvent('tengu_mcp_delete', { logEvent('tengu_mcp_delete', {
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, scope:
}); scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
await removeMcpConfig(name, scope); await removeMcpConfig(name, scope)
cleanupSecureStorage(); cleanupSecureStorage()
process.stdout.write(`Removed MCP server "${name}" from ${scope} config\n`); process.stdout.write(
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`); `Removed MCP server "${name}" from ${scope} config\n`,
)
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`)
} else { } else {
// Server exists in multiple scopes // Server exists in multiple scopes
process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`); process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`)
scopes.forEach(scope => { scopes.forEach(scope => {
process.stderr.write(` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`); process.stderr.write(
}); ` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`,
process.stderr.write('\nTo remove from a specific scope, use:\n'); )
})
process.stderr.write('\nTo remove from a specific scope, use:\n')
scopes.forEach(scope => { scopes.forEach(scope => {
process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`); process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`)
}); })
cliError(); cliError()
} }
} catch (error) { } catch (error) {
cliError((error as Error).message); cliError((error as Error).message)
} }
} }
// mcp list (lines 46414688) // mcp list (lines 46414688)
export async function mcpListHandler(): Promise<void> { export async function mcpListHandler(): Promise<void> {
logEvent('tengu_mcp_list', {}); logEvent('tengu_mcp_list', {})
const { servers: configs } = await getAllMcpConfigs(); const { servers: configs } = await getAllMcpConfigs()
if (Object.keys(configs).length === 0) { if (Object.keys(configs).length === 0) {
console.log('No MCP servers configured. Use `claude mcp add` to add a server.'); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(
'No MCP servers configured. Use `claude mcp add` to add a server.',
)
} else { } else {
console.log('Checking MCP server health...\n'); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('Checking MCP server health...\n')
// Check servers concurrently // Check servers concurrently
const entries = Object.entries(configs); const entries = Object.entries(configs)
const results = await pMap( const results = await pMap(
entries, entries,
async ([name, server]) => ({ async ([name, server]) => ({
@@ -168,100 +208,127 @@ export async function mcpListHandler(): Promise<void> {
status: await checkMcpServerHealth(name, server), status: await checkMcpServerHealth(name, server),
}), }),
{ concurrency: getMcpServerConnectionBatchSize() }, { concurrency: getMcpServerConnectionBatchSize() },
); )
for (const { name, server, status } of results) { for (const { name, server, status } of results) {
// Intentionally excluding sse-ide servers here since they're internal // Intentionally excluding sse-ide servers here since they're internal
if (server.type === 'sse') { if (server.type === 'sse') {
console.log(`${name}: ${server.url} (SSE) - ${status}`); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${name}: ${server.url} (SSE) - ${status}`)
} else if (server.type === 'http') { } else if (server.type === 'http') {
console.log(`${name}: ${server.url} (HTTP) - ${status}`); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${name}: ${server.url} (HTTP) - ${status}`)
} else if (server.type === 'claudeai-proxy') { } else if (server.type === 'claudeai-proxy') {
console.log(`${name}: ${server.url} - ${status}`); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${name}: ${server.url} - ${status}`)
} else if (!server.type || server.type === 'stdio') { } else if (!server.type || server.type === 'stdio') {
const stdioServer = server as { command: string; args: string[]; type?: string }; const stdioServer = server as { command: string; args: string[]; type?: string }
const args = Array.isArray(stdioServer.args) ? stdioServer.args : []; const args = Array.isArray(stdioServer.args) ? stdioServer.args : []
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`)
} }
} }
} }
// Use gracefulShutdown to properly clean up MCP server connections // Use gracefulShutdown to properly clean up MCP server connections
// (process.exit bypasses cleanup handlers, leaving child processes orphaned) // (process.exit bypasses cleanup handlers, leaving child processes orphaned)
await gracefulShutdown(0); await gracefulShutdown(0)
} }
// mcp get (lines 46944786) // mcp get (lines 46944786)
export async function mcpGetHandler(name: string): Promise<void> { export async function mcpGetHandler(name: string): Promise<void> {
logEvent('tengu_mcp_get', { logEvent('tengu_mcp_get', {
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}); })
const server = getMcpConfigByName(name); const server = getMcpConfigByName(name)
if (!server) { if (!server) {
cliError(`No MCP server found with name: ${name}`); cliError(`No MCP server found with name: ${name}`)
} }
console.log(`${name}:`); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Scope: ${getScopeLabel(server.scope)}`); console.log(`${name}:`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Scope: ${getScopeLabel(server.scope)}`)
// Check server health // Check server health
const status = await checkMcpServerHealth(name, server); const status = await checkMcpServerHealth(name, server)
console.log(` Status: ${status}`); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Status: ${status}`)
// Intentionally excluding sse-ide servers here since they're internal // Intentionally excluding sse-ide servers here since they're internal
if (server.type === 'sse') { if (server.type === 'sse') {
console.log(` Type: sse`); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` URL: ${server.url}`); console.log(` Type: sse`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` URL: ${server.url}`)
if (server.headers) { if (server.headers) {
console.log(' Headers:'); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(' Headers:')
for (const [key, value] of Object.entries(server.headers)) { for (const [key, value] of Object.entries(server.headers)) {
console.log(` ${key}: ${value}`); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${key}: ${value}`)
} }
} }
if (server.oauth?.clientId || server.oauth?.callbackPort) { if (server.oauth?.clientId || server.oauth?.callbackPort) {
const parts: string[] = []; const parts: string[] = []
if (server.oauth.clientId) { if (server.oauth.clientId) {
parts.push('client_id configured'); parts.push('client_id configured')
const clientConfig = getMcpClientConfig(name, server); const clientConfig = getMcpClientConfig(name, server)
if (clientConfig?.clientSecret) parts.push('client_secret configured'); if (clientConfig?.clientSecret) parts.push('client_secret configured')
} }
if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); if (server.oauth.callbackPort)
console.log(` OAuth: ${parts.join(', ')}`); parts.push(`callback_port ${server.oauth.callbackPort}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` OAuth: ${parts.join(', ')}`)
} }
} else if (server.type === 'http') { } else if (server.type === 'http') {
console.log(` Type: http`); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` URL: ${server.url}`); console.log(` Type: http`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` URL: ${server.url}`)
if (server.headers) { if (server.headers) {
console.log(' Headers:'); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(' Headers:')
for (const [key, value] of Object.entries(server.headers)) { for (const [key, value] of Object.entries(server.headers)) {
console.log(` ${key}: ${value}`); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${key}: ${value}`)
} }
} }
if (server.oauth?.clientId || server.oauth?.callbackPort) { if (server.oauth?.clientId || server.oauth?.callbackPort) {
const parts: string[] = []; const parts: string[] = []
if (server.oauth.clientId) { if (server.oauth.clientId) {
parts.push('client_id configured'); parts.push('client_id configured')
const clientConfig = getMcpClientConfig(name, server); const clientConfig = getMcpClientConfig(name, server)
if (clientConfig?.clientSecret) parts.push('client_secret configured'); if (clientConfig?.clientSecret) parts.push('client_secret configured')
} }
if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); if (server.oauth.callbackPort)
console.log(` OAuth: ${parts.join(', ')}`); parts.push(`callback_port ${server.oauth.callbackPort}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` OAuth: ${parts.join(', ')}`)
} }
} else if (server.type === 'stdio') { } else if (server.type === 'stdio') {
console.log(` Type: stdio`); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Command: ${server.command}`); console.log(` Type: stdio`)
const args = Array.isArray(server.args) ? server.args : []; // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Args: ${args.join(' ')}`); console.log(` Command: ${server.command}`)
const args = Array.isArray(server.args) ? server.args : []
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Args: ${args.join(' ')}`)
if (server.env) { if (server.env) {
console.log(' Environment:'); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(' Environment:')
for (const [key, value] of Object.entries(server.env)) { for (const [key, value] of Object.entries(server.env)) {
console.log(` ${key}=${value}`); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${key}=${value}`)
} }
} }
} }
console.log(`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`); // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(
`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`,
)
// Use gracefulShutdown to properly clean up MCP server connections // Use gracefulShutdown to properly clean up MCP server connections
// (process.exit bypasses cleanup handlers, leaving child processes orphaned) // (process.exit bypasses cleanup handlers, leaving child processes orphaned)
await gracefulShutdown(0); await gracefulShutdown(0)
} }
// mcp add-json (lines 48014870) // mcp add-json (lines 48014870)
@@ -271,8 +338,8 @@ export async function mcpAddJsonHandler(
options: { scope?: string; clientSecret?: true }, options: { scope?: string; clientSecret?: true },
): Promise<void> { ): Promise<void> {
try { try {
const scope = ensureConfigScope(options.scope); const scope = ensureConfigScope(options.scope)
const parsedJson = safeParseJSON(json); const parsedJson = safeParseJSON(json)
// Read secret before writing config so cancellation doesn't leave partial state // Read secret before writing config so cancellation doesn't leave partial state
const needsSecret = const needsSecret =
@@ -286,15 +353,15 @@ export async function mcpAddJsonHandler(
'oauth' in parsedJson && 'oauth' in parsedJson &&
parsedJson.oauth && parsedJson.oauth &&
typeof parsedJson.oauth === 'object' && typeof parsedJson.oauth === 'object' &&
'clientId' in parsedJson.oauth; 'clientId' in parsedJson.oauth
const clientSecret = needsSecret ? await readClientSecret() : undefined; const clientSecret = needsSecret ? await readClientSecret() : undefined
await addMcpConfig(name, parsedJson, scope); await addMcpConfig(name, parsedJson, scope)
const transportType = const transportType =
parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson
? String(parsedJson.type || 'stdio') ? String(parsedJson.type || 'stdio')
: 'stdio'; : 'stdio'
if ( if (
clientSecret && clientSecret &&
@@ -305,38 +372,53 @@ export async function mcpAddJsonHandler(
'url' in parsedJson && 'url' in parsedJson &&
typeof parsedJson.url === 'string' typeof parsedJson.url === 'string'
) { ) {
saveMcpClientSecret(name, { type: parsedJson.type, url: parsedJson.url }, clientSecret); saveMcpClientSecret(
name,
{ type: parsedJson.type, url: parsedJson.url },
clientSecret,
)
} }
logEvent('tengu_mcp_add', { logEvent('tengu_mcp_add', {
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, scope:
source: 'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source:
'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
type: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}); })
cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`); cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`)
} catch (error) { } catch (error) {
cliError((error as Error).message); cliError((error as Error).message)
} }
} }
// mcp add-from-claude-desktop (lines 48814927) // mcp add-from-claude-desktop (lines 48814927)
export async function mcpAddFromDesktopHandler(options: { scope?: string }): Promise<void> { export async function mcpAddFromDesktopHandler(options: {
scope?: string
}): Promise<void> {
try { try {
const scope = ensureConfigScope(options.scope); const scope = ensureConfigScope(options.scope)
const platform = getPlatform(); const platform = getPlatform()
logEvent('tengu_mcp_add', { logEvent('tengu_mcp_add', {
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, scope:
platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source: 'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, platform:
}); platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source:
'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
const { readClaudeDesktopMcpServers } = await import('../../utils/claudeDesktop.js'); const { readClaudeDesktopMcpServers } = await import(
const servers = await readClaudeDesktopMcpServers(); '../../utils/claudeDesktop.js'
)
const servers = await readClaudeDesktopMcpServers()
if (Object.keys(servers).length === 0) { if (Object.keys(servers).length === 0) {
cliOk('No MCP servers found in Claude Desktop configuration or configuration file does not exist.'); cliOk(
'No MCP servers found in Claude Desktop configuration or configuration file does not exist.',
)
} }
const { unmount } = await render( const { unmount } = await render(
@@ -346,29 +428,29 @@ export async function mcpAddFromDesktopHandler(options: { scope?: string }): Pro
servers={servers} servers={servers}
scope={scope} scope={scope}
onDone={() => { onDone={() => {
unmount(); unmount()
}} }}
/> />
</KeybindingSetup> </KeybindingSetup>
</AppStateProvider>, </AppStateProvider>,
{ exitOnCtrlC: true }, { exitOnCtrlC: true },
); )
} catch (error) { } catch (error) {
cliError((error as Error).message); cliError((error as Error).message)
} }
} }
// mcp reset-project-choices (lines 49354952) // mcp reset-project-choices (lines 49354952)
export async function mcpResetChoicesHandler(): Promise<void> { export async function mcpResetChoicesHandler(): Promise<void> {
logEvent('tengu_mcp_reset_mcpjson_choices', {}); logEvent('tengu_mcp_reset_mcpjson_choices', {})
saveCurrentProjectConfig(current => ({ saveCurrentProjectConfig(current => ({
...current, ...current,
enabledMcpjsonServers: [], enabledMcpjsonServers: [],
disabledMcpjsonServers: [], disabledMcpjsonServers: [],
enableAllProjectMcpServers: false, enableAllProjectMcpServers: false,
})); }))
cliOk( cliOk(
'All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' + 'All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' +
'You will be prompted for approval next time you start Claude Code.', 'You will be prompted for approval next time you start Claude Code.',
); )
} }

View File

@@ -72,21 +72,27 @@ export function handleMarketplaceError(error: unknown, action: string): never {
function printValidationResult(result: ValidationResult): void { function printValidationResult(result: ValidationResult): void {
if (result.errors.length > 0) { if (result.errors.length > 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log( console.log(
`${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n`, `${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n`,
) )
result.errors.forEach(error => { result.errors.forEach(error => {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${figures.pointer} ${error.path}: ${error.message}`) console.log(` ${figures.pointer} ${error.path}: ${error.message}`)
}) })
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('') console.log('')
} }
if (result.warnings.length > 0) { if (result.warnings.length > 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log( console.log(
`${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n`, `${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n`,
) )
result.warnings.forEach(warning => { result.warnings.forEach(warning => {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${figures.pointer} ${warning.path}: ${warning.message}`) console.log(` ${figures.pointer} ${warning.path}: ${warning.message}`)
}) })
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('') console.log('')
} }
} }
@@ -100,6 +106,7 @@ export async function pluginValidateHandler(
try { try {
const result = await validateManifest(manifestPath) const result = await validateManifest(manifestPath)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`Validating ${result.fileType} manifest: ${result.filePath}\n`) console.log(`Validating ${result.fileType} manifest: ${result.filePath}\n`)
printValidationResult(result) printValidationResult(result)
@@ -113,6 +120,7 @@ export async function pluginValidateHandler(
if (basename(manifestDir) === '.claude-plugin') { if (basename(manifestDir) === '.claude-plugin') {
contentResults = await validatePluginContents(dirname(manifestDir)) contentResults = await validatePluginContents(dirname(manifestDir))
for (const r of contentResults) { for (const r of contentResults) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`Validating ${r.fileType}: ${r.filePath}\n`) console.log(`Validating ${r.fileType}: ${r.filePath}\n`)
printValidationResult(r) printValidationResult(r)
} }
@@ -131,11 +139,13 @@ export async function pluginValidateHandler(
: `${figures.tick} Validation passed`, : `${figures.tick} Validation passed`,
) )
} else { } else {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${figures.cross} Validation failed`) console.log(`${figures.cross} Validation failed`)
process.exit(1) process.exit(1)
} }
} catch (error) { } catch (error) {
logError(error) logError(error)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error( console.error(
`${figures.cross} Unexpected error during validation: ${errorMessage(error)}`, `${figures.cross} Unexpected error during validation: ${errorMessage(error)}`,
) )
@@ -348,6 +358,7 @@ export async function pluginListHandler(options: {
} }
if (pluginIds.length > 0) { if (pluginIds.length > 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('Installed plugins:\n') console.log('Installed plugins:\n')
} }
@@ -372,18 +383,25 @@ export async function pluginListHandler(options: {
const version = installation.version || 'unknown' const version = installation.version || 'unknown'
const scope = installation.scope const scope = installation.scope
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${figures.pointer} ${pluginId}`) console.log(` ${figures.pointer} ${pluginId}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Version: ${version}`) console.log(` Version: ${version}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Scope: ${scope}`) console.log(` Scope: ${scope}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Status: ${status}`) console.log(` Status: ${status}`)
for (const error of pluginErrors) { for (const error of pluginErrors) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Error: ${getPluginErrorMessage(error)}`) console.log(` Error: ${getPluginErrorMessage(error)}`)
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('') console.log('')
} }
} }
if (inlinePlugins.length > 0 || inlineLoadErrors.length > 0) { if (inlinePlugins.length > 0 || inlineLoadErrors.length > 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('Session-only plugins (--plugin-dir):\n') console.log('Session-only plugins (--plugin-dir):\n')
for (const p of inlinePlugins) { for (const p of inlinePlugins) {
// Same dirName≠manifestName fallback as the JSON path above — error // Same dirName≠manifestName fallback as the JSON path above — error
@@ -395,13 +413,19 @@ export async function pluginListHandler(options: {
pErrors.length > 0 pErrors.length > 0
? `${figures.cross} loaded with errors` ? `${figures.cross} loaded with errors`
: `${figures.tick} loaded` : `${figures.tick} loaded`
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${figures.pointer} ${p.source}`) console.log(` ${figures.pointer} ${p.source}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Version: ${p.manifest.version ?? 'unknown'}`) console.log(` Version: ${p.manifest.version ?? 'unknown'}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Path: ${p.path}`) console.log(` Path: ${p.path}`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Status: ${status}`) console.log(` Status: ${status}`)
for (const e of pErrors) { for (const e of pErrors) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Error: ${getPluginErrorMessage(e)}`) console.log(` Error: ${getPluginErrorMessage(e)}`)
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('') console.log('')
} }
// Path-level failures: no LoadedPlugin object exists. Show them so // Path-level failures: no LoadedPlugin object exists. Show them so
@@ -409,6 +433,7 @@ export async function pluginListHandler(options: {
for (const e of inlineLoadErrors.filter(e => for (const e of inlineLoadErrors.filter(e =>
e.source.startsWith('inline['), e.source.startsWith('inline['),
)) { )) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log( console.log(
` ${figures.pointer} ${e.source}: ${figures.cross} ${getPluginErrorMessage(e)}\n`, ` ${figures.pointer} ${e.source}: ${figures.cross} ${getPluginErrorMessage(e)}\n`,
) )
@@ -464,10 +489,12 @@ export async function marketplaceAddHandler(
} }
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('Adding marketplace...') console.log('Adding marketplace...')
const { name, alreadyMaterialized, resolvedSource } = const { name, alreadyMaterialized, resolvedSource } =
await addMarketplaceSource(marketplaceSource, message => { await addMarketplaceSource(marketplaceSource, message => {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(message) console.log(message)
}) })
@@ -528,25 +555,33 @@ export async function marketplaceListHandler(options: {
cliOk('No marketplaces configured') cliOk('No marketplaces configured')
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('Configured marketplaces:\n') console.log('Configured marketplaces:\n')
names.forEach(name => { names.forEach(name => {
const marketplace = config[name] const marketplace = config[name]
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` ${figures.pointer} ${name}`) console.log(` ${figures.pointer} ${name}`)
if (marketplace?.source) { if (marketplace?.source) {
const src = marketplace.source const src = marketplace.source
if (src.source === 'github') { if (src.source === 'github') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Source: GitHub (${src.repo})`) console.log(` Source: GitHub (${src.repo})`)
} else if (src.source === 'git') { } else if (src.source === 'git') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Source: Git (${src.url})`) console.log(` Source: Git (${src.url})`)
} else if (src.source === 'url') { } else if (src.source === 'url') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Source: URL (${src.url})`) console.log(` Source: URL (${src.url})`)
} else if (src.source === 'directory') { } else if (src.source === 'directory') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Source: Directory (${src.path})`) console.log(` Source: Directory (${src.path})`)
} else if (src.source === 'file') { } else if (src.source === 'file') {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(` Source: File (${src.path})`) console.log(` Source: File (${src.path})`)
} }
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('') console.log('')
}) })
@@ -585,9 +620,11 @@ export async function marketplaceUpdateHandler(
if (options.cowork) setUseCoworkPlugins(true) if (options.cowork) setUseCoworkPlugins(true)
try { try {
if (name) { if (name) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`Updating marketplace: ${name}...`) console.log(`Updating marketplace: ${name}...`)
await refreshMarketplace(name, message => { await refreshMarketplace(name, message => {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(message) console.log(message)
}) })
@@ -607,6 +644,7 @@ export async function marketplaceUpdateHandler(
cliOk('No marketplaces configured') cliOk('No marketplaces configured')
} }
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`Updating ${marketplaceNames.length} marketplace(s)...`) console.log(`Updating ${marketplaceNames.length} marketplace(s)...`)
await refreshAllMarketplaces() await refreshAllMarketplaces()

View File

@@ -1,158 +1,3 @@
import { randomUUID } from 'crypto' // Auto-generated stub — replace with real implementation
import { listTemplates, loadTemplate } from '../../jobs/templates.js' export {};
import { export const templatesMain: (args: string[]) => Promise<void> = () => Promise.resolve();
createJob,
readJobState,
appendJobReply,
getJobDir,
} from '../../jobs/state.js'
/**
* Entry point for template job commands: `new`, `list`, `reply`.
* Called from cli.tsx fast-path.
*/
export async function templatesMain(args: string[]): Promise<void> {
const subcommand = args[0]
switch (subcommand) {
case 'list':
handleList()
break
case 'new':
handleNew(args.slice(1))
break
case 'reply':
handleReply(args.slice(1))
break
case 'status':
handleStatus(args.slice(1))
break
default:
console.error(`Unknown template command: ${subcommand}`)
printUsage()
process.exitCode = 1
}
}
function printUsage(): void {
console.log(`
Template Job Commands:
claude job list List available templates
claude job new <template> [args] Create a new job from a template
claude job reply <job-id> <text> Reply to an existing job
claude job status <job-id> Show job status
`)
}
function handleStatus(args: string[]): void {
const jobId = args[0]
if (!jobId) {
console.error('Usage: claude job status <job-id>')
process.exitCode = 1
return
}
const state = readJobState(jobId)
if (!state) {
console.error(`Job not found: ${jobId}`)
process.exitCode = 1
return
}
console.log(`Job: ${state.jobId}`)
console.log(` Template: ${state.templateName}`)
console.log(` Status: ${state.status}`)
console.log(` Created: ${state.createdAt}`)
console.log(` Updated: ${state.updatedAt}`)
console.log(` Args: ${state.args.join(' ') || '(none)'}`)
}
function handleList(): void {
const templates = listTemplates()
if (templates.length === 0) {
console.log('No templates found.')
console.log('Place .md files in .claude/templates/ or ~/.claude/templates/')
return
}
console.log(
`${templates.length} template${templates.length > 1 ? 's' : ''} found:\n`,
)
for (const t of templates) {
console.log(` ${t.name}`)
console.log(` ${t.description}`)
console.log(` Path: ${t.filePath}`)
console.log()
}
}
function handleNew(args: string[]): void {
const templateName = args[0]
if (!templateName) {
console.error('Usage: claude job new <template> [args...]')
process.exitCode = 1
return
}
const template = loadTemplate(templateName)
if (!template) {
console.error(`Template not found: ${templateName}`)
console.log('\nAvailable templates:')
for (const t of listTemplates()) {
console.log(` ${t.name}`)
}
process.exitCode = 1
return
}
const jobId = randomUUID().slice(0, 8)
const inputText = args.slice(1).join(' ')
const rawContent = `---\n${Object.entries(template.frontmatter)
.map(([k, v]) => `${k}: ${v}`)
.join('\n')}\n---\n${template.content}`
const dir = createJob(
jobId,
templateName,
rawContent,
inputText,
args.slice(1),
)
console.log(`Job created: ${jobId}`)
console.log(` Template: ${templateName}`)
console.log(` Directory: ${dir}`)
if (inputText) {
console.log(` Input: ${inputText}`)
}
}
function handleReply(args: string[]): void {
const jobId = args[0]
const text = args.slice(1).join(' ')
if (!jobId || !text) {
console.error('Usage: claude job reply <job-id> <text>')
process.exitCode = 1
return
}
const state = readJobState(jobId)
if (!state) {
console.error(`Job not found: ${jobId}`)
process.exitCode = 1
return
}
const ok = appendJobReply(jobId, text)
if (ok) {
console.log(`Reply added to job ${jobId}`)
console.log(` Directory: ${getJobDir(jobId)}`)
} else {
console.error(`Failed to append reply to job ${jobId}`)
process.exitCode = 1
}
}

View File

@@ -4,24 +4,26 @@
*/ */
/* eslint-disable custom-rules/no-process-exit -- CLI subcommand handlers intentionally exit */ /* eslint-disable custom-rules/no-process-exit -- CLI subcommand handlers intentionally exit */
import { cwd } from 'process'; import { cwd } from 'process'
import React from 'react'; import React from 'react'
import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js'; import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js'
import { useManagePlugins } from '../../hooks/useManagePlugins.js'; import { useManagePlugins } from '../../hooks/useManagePlugins.js'
import type { Root } from '@anthropic/ink'; import type { Root } from '@anthropic/ink'
import { Box, Text } from '@anthropic/ink'; import { Box, Text } from '@anthropic/ink'
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
import { logEvent } from '../../services/analytics/index.js'; import { logEvent } from '../../services/analytics/index.js'
import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js'; import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js'
import { AppStateProvider } from '../../state/AppState.js'; import { AppStateProvider } from '../../state/AppState.js'
import { onChangeAppState } from '../../state/onChangeAppState.js'; import { onChangeAppState } from '../../state/onChangeAppState.js'
import { isAnthropicAuthEnabled } from '../../utils/auth.js'; import { isAnthropicAuthEnabled } from '../../utils/auth.js'
export async function setupTokenHandler(root: Root): Promise<void> { export async function setupTokenHandler(root: Root): Promise<void> {
logEvent('tengu_setup_token_command', {}); logEvent('tengu_setup_token_command', {})
const showAuthWarning = !isAnthropicAuthEnabled(); const showAuthWarning = !isAnthropicAuthEnabled()
const { ConsoleOAuthFlow } = await import('../../components/ConsoleOAuthFlow.js'); const { ConsoleOAuthFlow } = await import(
'../../components/ConsoleOAuthFlow.js'
)
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
root.render( root.render(
<AppStateProvider onChangeAppState={onChangeAppState}> <AppStateProvider onChangeAppState={onChangeAppState}>
@@ -31,16 +33,18 @@ export async function setupTokenHandler(root: Root): Promise<void> {
{showAuthWarning && ( {showAuthWarning && (
<Box flexDirection="column"> <Box flexDirection="column">
<Text color="warning"> <Text color="warning">
Warning: You already have authentication configured via environment variable or API key helper. Warning: You already have authentication configured via
environment variable or API key helper.
</Text> </Text>
<Text color="warning"> <Text color="warning">
The setup-token command will create a new OAuth token which you can use instead. The setup-token command will create a new OAuth token which
you can use instead.
</Text> </Text>
</Box> </Box>
)} )}
<ConsoleOAuthFlow <ConsoleOAuthFlow
onDone={() => { onDone={() => {
void resolve(); void resolve()
}} }}
mode="setup-token" mode="setup-token"
startingMessage="This will guide you through long-lived (1-year) auth token setup for your Claude account. Claude subscription required." startingMessage="This will guide you through long-lived (1-year) auth token setup for your Claude account. Claude subscription required."
@@ -48,63 +52,75 @@ export async function setupTokenHandler(root: Root): Promise<void> {
</Box> </Box>
</KeybindingSetup> </KeybindingSetup>
</AppStateProvider>, </AppStateProvider>,
); )
}); })
root.unmount(); root.unmount()
process.exit(0); process.exit(0)
} }
// DoctorWithPlugins wrapper + doctor handler // DoctorWithPlugins wrapper + doctor handler
const DoctorLazy = React.lazy(() => import('../../screens/Doctor.js').then(m => ({ default: m.Doctor }))); const DoctorLazy = React.lazy(() =>
import('../../screens/Doctor.js').then(m => ({ default: m.Doctor })),
)
function DoctorWithPlugins({ onDone }: { onDone: () => void }): React.ReactNode { function DoctorWithPlugins({
useManagePlugins(); onDone,
}: {
onDone: () => void
}): React.ReactNode {
useManagePlugins()
return ( return (
<React.Suspense fallback={null}> <React.Suspense fallback={null}>
<DoctorLazy onDone={onDone} /> <DoctorLazy onDone={onDone} />
</React.Suspense> </React.Suspense>
); )
} }
export async function doctorHandler(root: Root): Promise<void> { export async function doctorHandler(root: Root): Promise<void> {
logEvent('tengu_doctor_command', {}); logEvent('tengu_doctor_command', {})
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
root.render( root.render(
<AppStateProvider> <AppStateProvider>
<KeybindingSetup> <KeybindingSetup>
<MCPConnectionManager dynamicMcpConfig={undefined} isStrictMcpConfig={false}> <MCPConnectionManager
dynamicMcpConfig={undefined}
isStrictMcpConfig={false}
>
<DoctorWithPlugins <DoctorWithPlugins
onDone={() => { onDone={() => {
void resolve(); void resolve()
}} }}
/> />
</MCPConnectionManager> </MCPConnectionManager>
</KeybindingSetup> </KeybindingSetup>
</AppStateProvider>, </AppStateProvider>,
); )
}); })
root.unmount(); root.unmount()
process.exit(0); process.exit(0)
} }
// install handler // install handler
export async function installHandler(target: string | undefined, options: { force?: boolean }): Promise<void> { export async function installHandler(
const { setup } = await import('../../setup.js'); target: string | undefined,
await setup(cwd(), 'default', false, false, undefined, false); options: { force?: boolean },
const { install } = await import('../../commands/install.js'); ): Promise<void> {
const { setup } = await import('../../setup.js')
await setup(cwd(), 'default', false, false, undefined, false)
const { install } = await import('../../commands/install.js')
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
const args: string[] = []; const args: string[] = []
if (target) args.push(target); if (target) args.push(target)
if (options.force) args.push('--force'); if (options.force) args.push('--force')
void install.call( void install.call(
result => { result => {
void resolve(); void resolve()
process.exit(result.includes('failed') ? 1 : 0); process.exit(result.includes('failed') ? 1 : 0)
}, },
{}, {},
args, args,
); )
}); })
} }

View File

@@ -320,17 +320,6 @@ import {
logQueryProfileReport, logQueryProfileReport,
} from 'src/utils/queryProfiler.js' } from 'src/utils/queryProfiler.js'
import { asSessionId } from 'src/types/ids.js' import { asSessionId } from 'src/types/ids.js'
import {
commitAutonomyQueuedPrompt,
createAutonomyQueuedPrompt,
createProactiveAutonomyCommands,
finalizeAutonomyRunCompleted,
finalizeAutonomyRunFailed,
markAutonomyRunCompleted,
markAutonomyRunFailed,
markAutonomyRunRunning,
} from 'src/utils/autonomyRuns.js'
import { prepareAutonomyTurnPrompt } from 'src/utils/autonomyAuthority.js'
import { jsonStringify } from '../utils/slowOperations.js' import { jsonStringify } from '../utils/slowOperations.js'
import { skillChangeDetector } from '../utils/skills/skillChangeDetector.js' import { skillChangeDetector } from '../utils/skills/skillChangeDetector.js'
import { getCommands, clearCommandsCache } from '../commands.js' import { getCommands, clearCommandsCache } from '../commands.js'
@@ -373,12 +362,9 @@ const proactiveModule =
feature('PROACTIVE') || feature('KAIROS') feature('PROACTIVE') || feature('KAIROS')
? (require('../proactive/index.js') as typeof import('../proactive/index.js')) ? (require('../proactive/index.js') as typeof import('../proactive/index.js'))
: null : null
const cronSchedulerModule = const cronSchedulerModule = require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')
require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js') const cronJitterConfigModule = require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
const cronJitterConfigModule = const cronGate = require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js')
require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
const cronGate =
require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js')
const extractMemoriesModule = feature('EXTRACT_MEMORIES') const extractMemoriesModule = feature('EXTRACT_MEMORIES')
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
: null : null
@@ -1194,9 +1180,7 @@ function runHeadlessStreaming(
removeInterruptedMessage(mutableMessages, turnInterruptionState.message) removeInterruptedMessage(mutableMessages, turnInterruptionState.message)
enqueue({ enqueue({
mode: 'prompt', mode: 'prompt',
value: turnInterruptionState.message.message!.content as value: turnInterruptionState.message.message!.content as string | ContentBlockParam[],
| string
| ContentBlockParam[],
uuid: randomUUID(), uuid: randomUUID(),
}) })
} }
@@ -1658,10 +1642,7 @@ function runHeadlessStreaming(
connection.config.type === 'stdio' || connection.config.type === 'stdio' ||
connection.config.type === undefined connection.config.type === undefined
) { ) {
const stdioConfig = connection.config as { const stdioConfig = connection.config as { command: string; args: string[] }
command: string
args: string[]
}
config = { config = {
type: 'stdio' as const, type: 'stdio' as const,
command: stdioConfig.command, command: stdioConfig.command,
@@ -1823,8 +1804,7 @@ function runHeadlessStreaming(
} }
for (const [name, config] of Object.entries(sdkMcpConfigs)) { for (const [name, config] of Object.entries(sdkMcpConfigs)) {
if (config.type === 'sdk' && !(name in supportedConfigs)) { if (config.type === 'sdk' && !(name in supportedConfigs)) {
supportedConfigs[name] = supportedConfigs[name] = config as unknown as McpServerConfigForProcessTransport
config as unknown as McpServerConfigForProcessTransport
} }
} }
const { response, sdkServersChanged } = const { response, sdkServersChanged } =
@@ -1859,23 +1839,15 @@ function runHeadlessStreaming(
) { ) {
return return
} }
void (async () => { const tickContent = `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`
const commands = await createProactiveAutonomyCommands({ enqueue({
basePrompt: `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`, mode: 'prompt' as const,
currentDir: cwd(), value: tickContent,
shouldCreate: () => !inputClosed, uuid: randomUUID(),
}) priority: 'later',
for (const command of commands) { isMeta: true,
if (inputClosed) { })
return void run()
}
enqueue({
...command,
uuid: randomUUID(),
})
}
void run()
})()
}, 0) }, 0)
} }
: undefined : undefined
@@ -2120,9 +2092,6 @@ function runHeadlessStreaming(
} }
const input = command.value const input = command.value
const autonomyRunIds = batch
.map(item => item.autonomy?.runId)
.filter((runId): runId is string => Boolean(runId))
if (structuredIO instanceof RemoteIO && command.mode === 'prompt') { if (structuredIO instanceof RemoteIO && command.mode === 'prompt') {
logEvent('tengu_bridge_message_received', { logEvent('tengu_bridge_message_received', {
@@ -2172,151 +2141,107 @@ function runHeadlessStreaming(
// const-capture: TS loses `while ((command = dequeue()))` narrowing // const-capture: TS loses `while ((command = dequeue()))` narrowing
// inside the closure. // inside the closure.
const cmd = command const cmd = command
for (const runId of autonomyRunIds) { await runWithWorkload(cmd.workload ?? options.workload, async () => {
await markAutonomyRunRunning(runId) for await (const message of ask({
} commands: uniqBy(
let lastResultIsError = false [...currentCommands, ...appState.mcp.commands],
try { 'name',
await runWithWorkload( ),
cmd.workload ?? options.workload, prompt: input,
async () => { promptUuid: cmd.uuid,
for await (const message of ask({ isMeta: cmd.isMeta,
commands: uniqBy( cwd: cwd(),
[...currentCommands, ...appState.mcp.commands], tools: allTools,
'name', verbose: options.verbose,
), mcpClients: allMcpClients,
prompt: input, thinkingConfig: options.thinkingConfig,
promptUuid: cmd.uuid, maxTurns: options.maxTurns,
isMeta: cmd.isMeta, maxBudgetUsd: options.maxBudgetUsd,
cwd: cwd(), taskBudget: options.taskBudget,
tools: allTools, canUseTool,
verbose: options.verbose, userSpecifiedModel: activeUserSpecifiedModel,
mcpClients: allMcpClients, fallbackModel: options.fallbackModel,
thinkingConfig: options.thinkingConfig, jsonSchema: getInitJsonSchema() ?? options.jsonSchema,
maxTurns: options.maxTurns, mutableMessages,
maxBudgetUsd: options.maxBudgetUsd, getReadFileCache: () =>
taskBudget: options.taskBudget, pendingSeeds.size === 0
canUseTool, ? readFileState
userSpecifiedModel: activeUserSpecifiedModel, : mergeFileStateCaches(readFileState, pendingSeeds),
fallbackModel: options.fallbackModel, setReadFileCache: cache => {
jsonSchema: getInitJsonSchema() ?? options.jsonSchema, readFileState = cache
mutableMessages, for (const [path, seed] of pendingSeeds.entries()) {
getReadFileCache: () => const existing = readFileState.get(path)
pendingSeeds.size === 0 if (!existing || seed.timestamp > existing.timestamp) {
? readFileState readFileState.set(path, seed)
: mergeFileStateCaches(readFileState, pendingSeeds),
setReadFileCache: cache => {
readFileState = cache
for (const [path, seed] of pendingSeeds.entries()) {
const existing = readFileState.get(path)
if (!existing || seed.timestamp > existing.timestamp) {
readFileState.set(path, seed)
}
}
pendingSeeds.clear()
},
customSystemPrompt: options.systemPrompt,
appendSystemPrompt: options.appendSystemPrompt,
getAppState,
setAppState,
abortController,
replayUserMessages: options.replayUserMessages,
includePartialMessages: options.includePartialMessages,
handleElicitation: (serverName, params, elicitSignal) =>
structuredIO.handleElicitation(
serverName,
params.message,
undefined,
elicitSignal,
params.mode,
params.url,
'elicitationId' in params
? params.elicitationId
: undefined,
),
agents: currentAgents,
orphanedPermission: cmd.orphanedPermission,
setSDKStatus: status => {
output.enqueue({
type: 'system',
subtype: 'status',
status: status as 'compacting' | null,
session_id: getSessionId(),
uuid: randomUUID(),
})
},
})) {
// Forward messages to bridge incrementally (mid-turn) so
// claude.ai sees progress and the connection stays alive
// while blocked on permission requests.
forwardMessagesToBridge()
if (message.type === 'result') {
lastResultIsError = !!(message as Record<string, unknown>)
.is_error
// Flush pending SDK events so they appear before result on the stream.
for (const event of drainSdkEvents()) {
output.enqueue(event)
}
// Hold-back: don't emit result while background agents are running
const currentState = getAppState()
if (
getRunningTasks(currentState).some(
t =>
(t.type === 'local_agent' ||
t.type === 'local_workflow') &&
isBackgroundTask(t),
)
) {
heldBackResult = message as StdoutMessage
} else {
heldBackResult = null
output.enqueue(message as StdoutMessage)
}
} else {
// Flush SDK events (task_started, task_progress) so background
// agent progress is streamed in real-time, not batched until result.
for (const event of drainSdkEvents()) {
output.enqueue(event)
}
output.enqueue(message as StdoutMessage)
} }
} }
pendingSeeds.clear()
}, },
) // end runWithWorkload customSystemPrompt: options.systemPrompt,
if (lastResultIsError) { appendSystemPrompt: options.appendSystemPrompt,
for (const runId of autonomyRunIds) { getAppState,
await finalizeAutonomyRunFailed({ setAppState,
runId, abortController,
error: 'ask() returned an error result', replayUserMessages: options.replayUserMessages,
includePartialMessages: options.includePartialMessages,
handleElicitation: (serverName, params, elicitSignal) =>
structuredIO.handleElicitation(
serverName,
params.message,
undefined,
elicitSignal,
params.mode,
params.url,
'elicitationId' in params ? params.elicitationId : undefined,
),
agents: currentAgents,
orphanedPermission: cmd.orphanedPermission,
setSDKStatus: status => {
output.enqueue({
type: 'system',
subtype: 'status',
status: status as 'compacting' | null,
session_id: getSessionId(),
uuid: randomUUID(),
}) })
} },
} else { })) {
for (const runId of autonomyRunIds) { // Forward messages to bridge incrementally (mid-turn) so
const nextCommands = await finalizeAutonomyRunCompleted({ // claude.ai sees progress and the connection stays alive
runId, // while blocked on permission requests.
currentDir: cwd(), forwardMessagesToBridge()
priority: 'later',
workload: cmd.workload ?? options.workload, if (message.type === 'result') {
}) // Flush pending SDK events so they appear before result on the stream.
for (const nextCommand of nextCommands) { for (const event of drainSdkEvents()) {
enqueue({ output.enqueue(event)
...nextCommand,
uuid: randomUUID(),
})
} }
// Hold-back: don't emit result while background agents are running
const currentState = getAppState()
if (
getRunningTasks(currentState).some(
t =>
(t.type === 'local_agent' ||
t.type === 'local_workflow') &&
isBackgroundTask(t),
)
) {
heldBackResult = message as StdoutMessage
} else {
heldBackResult = null
output.enqueue(message as StdoutMessage)
}
} else {
// Flush SDK events (task_started, task_progress) so background
// agent progress is streamed in real-time, not batched until result.
for (const event of drainSdkEvents()) {
output.enqueue(event)
}
output.enqueue(message as StdoutMessage)
} }
} }
} catch (error) { }) // end runWithWorkload
for (const runId of autonomyRunIds) {
await finalizeAutonomyRunFailed({
runId,
error: String(error),
})
}
throw error
}
for (const uuid of batchUuids) { for (const uuid of batchUuids) {
notifyCommandLifecycle(uuid, 'completed') notifyCommandLifecycle(uuid, 'completed')
@@ -2328,15 +2253,10 @@ function runHeadlessStreaming(
if (feature('FILE_PERSISTENCE') && turnStartTime !== undefined) { if (feature('FILE_PERSISTENCE') && turnStartTime !== undefined) {
void executeFilePersistence( void executeFilePersistence(
{ { turnStartTime } as import('src/utils/filePersistence/types.js').TurnStartTime,
turnStartTime,
} as import('src/utils/filePersistence/types.js').TurnStartTime,
abortController.signal, abortController.signal,
result => { result => {
const filesResult = result as unknown as { const filesResult = result as unknown as { persistedFiles: { filename: string; file_id: string }[]; failedFiles: { filename: string; error: string }[] }
persistedFiles: { filename: string; file_id: string }[]
failedFiles: { filename: string; error: string }[]
}
output.enqueue({ output.enqueue({
type: 'system' as const, type: 'system' as const,
subtype: 'files_persisted' as const, subtype: 'files_persisted' as const,
@@ -2780,73 +2700,28 @@ function runHeadlessStreaming(
// the end of run() picks up the queued command. // the end of run() picks up the queued command.
let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null = let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null =
null null
if (cronGate.isKairosCronEnabled()) { if (
cronGate.isKairosCronEnabled()
) {
cronScheduler = cronSchedulerModule.createCronScheduler({ cronScheduler = cronSchedulerModule.createCronScheduler({
onFire: prompt => { onFire: prompt => {
if (inputClosed) return if (inputClosed) return
void (async () => { enqueue({
const prepared = await prepareAutonomyTurnPrompt({ mode: 'prompt',
basePrompt: prompt, value: prompt,
trigger: 'scheduled-task', uuid: randomUUID(),
currentDir: cwd(), priority: 'later',
}) // System-generated — matches useScheduledTasks.ts REPL equivalent.
if (inputClosed) return // Without this, messages.ts metaProp eval is {} → prompt leaks
const command = await commitAutonomyQueuedPrompt({ // into visible transcript when cron fires mid-turn in -p mode.
prepared, isMeta: true,
currentDir: cwd(), // Threaded to cc_workload= in the billing-header attribution block
workload: WORKLOAD_CRON, // so the API can serve cron requests at lower QoS. drainCommandQueue
}) // reads this per-iteration and hoists it into bootstrap state for
if (inputClosed) return // the ask() call.
enqueue({ workload: WORKLOAD_CRON,
...command, })
uuid: randomUUID(), void run()
})
void run()
})()
},
onFireTask: task => {
if (inputClosed) return
void (async () => {
if (task.agentId) {
const prepared = await prepareAutonomyTurnPrompt({
basePrompt: task.prompt,
trigger: 'scheduled-task',
currentDir: cwd(),
})
if (inputClosed) return
const command = await commitAutonomyQueuedPrompt({
prepared,
currentDir: cwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
})
await markAutonomyRunFailed(
command.autonomy!.runId,
`No teammate runtime available for scheduled task owner ${task.agentId} in headless mode.`,
)
return
}
const prepared = await prepareAutonomyTurnPrompt({
basePrompt: task.prompt,
trigger: 'scheduled-task',
currentDir: cwd(),
})
if (inputClosed) return
const command = await commitAutonomyQueuedPrompt({
prepared,
currentDir: cwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
})
if (inputClosed) return
enqueue({
...command,
uuid: randomUUID(),
})
void run()
})()
}, },
isLoading: () => running || inputClosed, isLoading: () => running || inputClosed,
getJitterConfig: cronJitterConfigModule?.getCronJitterConfig, getJitterConfig: cronJitterConfigModule?.getCronJitterConfig,
@@ -3121,9 +2996,7 @@ function runHeadlessStreaming(
sdkClient.type === 'connected' && sdkClient.type === 'connected' &&
sdkClient.client?.transport?.onmessage sdkClient.client?.transport?.onmessage
) { ) {
sdkClient.client.transport.onmessage( sdkClient.client.transport.onmessage(mcpRequest.message as import('@modelcontextprotocol/sdk/types.js').JSONRPCMessage)
mcpRequest.message as import('@modelcontextprotocol/sdk/types.js').JSONRPCMessage,
)
} }
sendControlResponseSuccess(msg) sendControlResponseSuccess(msg)
} else if (msg.request.subtype === 'rewind_files') { } else if (msg.request.subtype === 'rewind_files') {
@@ -3188,10 +3061,7 @@ function runHeadlessStreaming(
sendControlResponseSuccess(msg) sendControlResponseSuccess(msg)
} else if (msg.request.subtype === 'mcp_set_servers') { } else if (msg.request.subtype === 'mcp_set_servers') {
const { response, sdkServersChanged } = await applyMcpServerChanges( const { response, sdkServersChanged } = await applyMcpServerChanges(
msg.request.servers as Record< msg.request.servers as Record<string, McpServerConfigForProcessTransport>,
string,
McpServerConfigForProcessTransport
>,
) )
sendControlResponseSuccess(msg, response) sendControlResponseSuccess(msg, response)
@@ -3261,8 +3131,7 @@ function runHeadlessStreaming(
model: a.model === 'inherit' ? undefined : a.model, model: a.model === 'inherit' ? undefined : a.model,
})), })),
plugins, plugins,
mcpServers: mcpServers: buildMcpServerStatuses() as SDKControlReloadPluginsResponse['mcpServers'],
buildMcpServerStatuses() as SDKControlReloadPluginsResponse['mcpServers'],
error_count: r.error_count, error_count: r.error_count,
} satisfies SDKControlReloadPluginsResponse) } satisfies SDKControlReloadPluginsResponse)
} catch (error) { } catch (error) {
@@ -3537,7 +3406,7 @@ function runHeadlessStreaming(
mcp: { mcp: {
...prev.mcp, ...prev.mcp,
clients: prev.mcp.clients.map(c => clients: prev.mcp.clients.map(c =>
c.name === (serverName as string) ? result.client : c, c.name === serverName as string ? result.client : c,
), ),
tools: [ tools: [
...reject(prev.mcp.tools, t => ...reject(prev.mcp.tools, t =>
@@ -3586,9 +3455,7 @@ function runHeadlessStreaming(
}) })
.finally(() => { .finally(() => {
// Clean up only if this is still the active flow // Clean up only if this is still the active flow
if ( if (activeOAuthFlows.get(serverName as string) === controller) {
activeOAuthFlows.get(serverName as string) === controller
) {
activeOAuthFlows.delete(serverName as string) activeOAuthFlows.delete(serverName as string)
oauthCallbackSubmitters.delete(serverName as string) oauthCallbackSubmitters.delete(serverName as string)
oauthManualCallbackUsed.delete(serverName as string) oauthManualCallbackUsed.delete(serverName as string)
@@ -3703,9 +3570,7 @@ function runHeadlessStreaming(
// next API call re-reads keychain/file and works. No respawn. // next API call re-reads keychain/file and works. No respawn.
await installOAuthTokens(tokens) await installOAuthTokens(tokens)
logEvent('tengu_oauth_success', { logEvent('tengu_oauth_success', {
loginWithClaudeAi: (loginWithClaudeAi ?? true) as loginWithClaudeAi: (loginWithClaudeAi ?? true) as boolean | number,
| boolean
| number,
}) })
}) })
.finally(() => { .finally(() => {
@@ -3753,7 +3618,10 @@ function runHeadlessStreaming(
req.subtype === 'claude_oauth_wait_for_completion' req.subtype === 'claude_oauth_wait_for_completion'
) { ) {
if (!claudeOAuth) { if (!claudeOAuth) {
sendControlResponseError(msg, 'No active claude_authenticate flow') sendControlResponseError(
msg,
'No active claude_authenticate flow',
)
} else { } else {
// Inject the manual code synchronously — must happen in stdin // Inject the manual code synchronously — must happen in stdin
// message order so a subsequent claude_authenticate doesn't // message order so a subsequent claude_authenticate doesn't
@@ -3813,7 +3681,7 @@ function runHeadlessStreaming(
mcp: { mcp: {
...prev.mcp, ...prev.mcp,
clients: prev.mcp.clients.map(c => clients: prev.mcp.clients.map(c =>
c.name === (serverName as string) ? result.client : c, c.name === serverName as string ? result.client : c,
), ),
tools: [ tools: [
...reject(prev.mcp.tools, t => t.name?.startsWith(prefix)), ...reject(prev.mcp.tools, t => t.name?.startsWith(prefix)),
@@ -4248,13 +4116,9 @@ function runHeadlessStreaming(
mode: 'prompt' as const, mode: 'prompt' as const,
// file_attachments rides the protobuf catchall from the web composer. // file_attachments rides the protobuf catchall from the web composer.
// Same-ref no-op when absent (no 'file_attachments' key). // Same-ref no-op when absent (no 'file_attachments' key).
value: await resolveAndPrepend( value: await resolveAndPrepend(userMsg, (userMsg.message as { content: ContentBlockParam[] }).content),
userMsg,
(userMsg.message as { content: ContentBlockParam[] }).content,
),
uuid: userMsg.uuid as `${string}-${string}-${string}-${string}-${string}`, uuid: userMsg.uuid as `${string}-${string}-${string}-${string}-${string}`,
priority: (userMsg as { priority?: string }) priority: (userMsg as { priority?: string }).priority as import('src/types/textInputTypes.js').QueuePriority,
.priority as import('src/types/textInputTypes.js').QueuePriority,
}) })
// Increment prompt count for attribution tracking and save snapshot // Increment prompt count for attribution tracking and save snapshot
// The snapshot persists promptCount so it survives compaction // The snapshot persists promptCount so it survives compaction
@@ -4583,10 +4447,7 @@ async function handleInitializeRequest(
const accountInfo = getAccountInformation() const accountInfo = getAccountInformation()
if (request.hooks) { if (request.hooks) {
const hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>> = {} const hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>> = {}
for (const [event, matchers] of Object.entries(request.hooks) as [ for (const [event, matchers] of Object.entries(request.hooks) as [string, Array<{ hookCallbackIds: string[]; timeout?: number; matcher?: string }>][]) {
string,
Array<{ hookCallbackIds: string[]; timeout?: number; matcher?: string }>,
][]) {
hooks[event as HookEvent] = matchers.map(matcher => { hooks[event as HookEvent] = matchers.map(matcher => {
const callbacks = matcher.hookCallbackIds.map(callbackId => { const callbacks = matcher.hookCallbackIds.map(callbackId => {
return structuredIO.createHookCallback(callbackId, matcher.timeout) return structuredIO.createHookCallback(callbackId, matcher.timeout)
@@ -4628,11 +4489,7 @@ async function handleInitializeRequest(
// getAccountInformation() returns undefined under 3P providers, so the // getAccountInformation() returns undefined under 3P providers, so the
// other fields are all absent. apiProvider disambiguates "not logged // other fields are all absent. apiProvider disambiguates "not logged
// in" (firstParty + tokenSource:none) from "3P, login not applicable". // in" (firstParty + tokenSource:none) from "3P, login not applicable".
apiProvider: getAPIProvider() as apiProvider: getAPIProvider() as 'firstParty' | 'bedrock' | 'vertex' | 'foundry',
| 'firstParty'
| 'bedrock'
| 'vertex'
| 'foundry',
}, },
pid: process.pid, pid: process.pid,
} }
@@ -4680,11 +4537,7 @@ async function handleRewindFiles(
dryRun: boolean, dryRun: boolean,
): Promise<RewindFilesResult> { ): Promise<RewindFilesResult> {
if (!fileHistoryEnabled()) { if (!fileHistoryEnabled()) {
return { return { canRewind: false, error: 'File rewinding is not enabled.', filesChanged: [] }
canRewind: false,
error: 'File rewinding is not enabled.',
filesChanged: [],
}
} }
if (!fileHistoryCanRestore(appState.fileHistory, userMessageId)) { if (!fileHistoryCanRestore(appState.fileHistory, userMessageId)) {
return { return {
@@ -4989,10 +4842,7 @@ function reregisterChannelHandlerAfterReconnect(
value: wrapChannelMessage(connection.name, content, meta), value: wrapChannelMessage(connection.name, content, meta),
priority: 'next', priority: 'next',
isMeta: true, isMeta: true,
origin: { origin: { kind: 'channel', server: connection.name } as unknown as string,
kind: 'channel',
server: connection.name,
} as unknown as string,
skipSlashCommands: true, skipSlashCommands: true,
}) })
}, },
@@ -5416,21 +5266,13 @@ export async function handleOrphanedPermissionResponse({
onEnqueued?: () => void onEnqueued?: () => void
handledToolUseIds: Set<string> handledToolUseIds: Set<string>
}): Promise<boolean> { }): Promise<boolean> {
const responseInner = message.response as const responseInner = message.response as { subtype?: string; response?: Record<string, unknown>; request_id?: string } | undefined
| {
subtype?: string
response?: Record<string, unknown>
request_id?: string
}
| undefined
if ( if (
responseInner?.subtype === 'success' && responseInner?.subtype === 'success' &&
responseInner.response?.toolUseID && responseInner.response?.toolUseID &&
typeof responseInner.response.toolUseID === 'string' typeof responseInner.response.toolUseID === 'string'
) { ) {
const permissionResult = responseInner.response as PermissionResult & { const permissionResult = responseInner.response as PermissionResult & { toolUseID?: string }
toolUseID?: string
}
const toolUseID = permissionResult.toolUseID const toolUseID = permissionResult.toolUseID
if (!toolUseID) { if (!toolUseID) {
return false return false

View File

@@ -1,70 +1,2 @@
/** // Auto-generated stub
* `claude rollback [target]` — roll back to a previous Claude Code version. export async function rollback(target?: string, options?: { list?: boolean; dryRun?: boolean; safe?: boolean }): Promise<void> {}
*
* ANT-only command (USER_TYPE === "ant").
*
* Options:
* --list List recent published versions
* --dry-run Show what would be installed without installing
* --safe Roll back to the server-pinned safe version
*/
export async function rollback(
target?: string,
options?: { list?: boolean; dryRun?: boolean; safe?: boolean },
): Promise<void> {
if (options?.list) {
console.log('Recent versions:')
console.log(' (version listing requires access to the release registry)')
console.log(' Use `claude update --list` for available versions.')
return
}
if (options?.safe) {
console.log('Safe rollback: would install the server-pinned safe version.')
if (options.dryRun) {
console.log(' (dry run — no changes made)')
return
}
console.log(' Safe version pinning requires access to the release API.')
console.log(' Contact oncall for the current safe version.')
return
}
if (!target) {
console.error(
'Usage: claude rollback [target]\n\n' +
'Options:\n' +
' -l, --list List recent published versions\n' +
' --dry-run Show what would be installed\n' +
' --safe Roll back to server-pinned safe version\n\n' +
'Examples:\n' +
' claude rollback 2.1.880\n' +
' claude rollback --list\n' +
' claude rollback --safe',
)
process.exitCode = 1
return
}
console.log(`Rolling back to version ${target}...`)
if (options?.dryRun) {
console.log(` (dry run — would install ${target})`)
return
}
// Version rollback via npm/bun
const { spawnSync } = await import('child_process')
const result = spawnSync(
'npm',
['install', '-g', `@anthropic-ai/claude-code@${target}`],
{ stdio: 'inherit' },
)
if (result.status !== 0) {
console.error(`Rollback failed with exit code ${result.status}`)
process.exitCode = result.status ?? 1
} else {
console.log(`Rolled back to ${target} successfully.`)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
// Auto-generated type stub — replace with real implementation // Auto-generated type stub — replace with real implementation
export type SDKStatus = any export type SDKStatus = any;
export type ModelInfo = any export type ModelInfo = any;
export type SDKMessage = any export type SDKMessage = any;
export type SDKUserMessage = any export type SDKUserMessage = any;
export type SDKUserMessageReplay = any export type SDKUserMessageReplay = any;
export type PermissionResult = any export type PermissionResult = any;
export type McpServerConfigForProcessTransport = any export type McpServerConfigForProcessTransport = any;
export type McpServerStatus = any export type McpServerStatus = any;
export type RewindFilesResult = any export type RewindFilesResult = any;
export type HookEvent = any export type HookEvent = any;
export type HookInput = any export type HookInput = any;
export type HookJSONOutput = any export type HookJSONOutput = any;
export type PermissionUpdate = any export type PermissionUpdate = any;

View File

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

View File

@@ -1,9 +1,9 @@
// Auto-generated type stub — replace with real implementation // Auto-generated type stub — replace with real implementation
export type StdoutMessage = any export type StdoutMessage = any;
export type SDKControlInitializeRequest = any export type SDKControlInitializeRequest = any;
export type SDKControlInitializeResponse = any export type SDKControlInitializeResponse = any;
export type SDKControlRequest = any export type SDKControlRequest = any;
export type SDKControlResponse = any export type SDKControlResponse = any;
export type SDKControlMcpSetServersResponse = any export type SDKControlMcpSetServersResponse = any;
export type SDKControlReloadPluginsResponse = any export type SDKControlReloadPluginsResponse = any;
export type StdinMessage = any export type StdinMessage = any;

View File

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

View File

@@ -1,5 +1,5 @@
// Auto-generated type stub — replace with real implementation // Auto-generated type stub — replace with real implementation
export type tryGenerateSuggestion = any export type tryGenerateSuggestion = any;
export type logSuggestionOutcome = any export type logSuggestionOutcome = any;
export type logSuggestionSuppressed = any export type logSuggestionSuppressed = any;
export type PromptVariant = any export type PromptVariant = any;

View File

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

View File

@@ -1,3 +1,3 @@
// Auto-generated type stub — replace with real implementation // Auto-generated type stub — replace with real implementation
export type logEvent = any export type logEvent = any;
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any;

View File

@@ -1,3 +1,3 @@
// Auto-generated type stub — replace with real implementation // Auto-generated type stub — replace with real implementation
export type isQualifiedForGrove = any export type isQualifiedForGrove = any;
export type checkGroveForNonInteractive = any export type checkGroveForNonInteractive = any;

View File

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

View File

@@ -1,3 +1,3 @@
// Auto-generated type stub — replace with real implementation // Auto-generated type stub — replace with real implementation
export type statusListeners = any export type statusListeners = any;
export type ClaudeAILimits = any export type ClaudeAILimits = any;

View File

@@ -1,3 +1,3 @@
// Auto-generated type stub — replace with real implementation // Auto-generated type stub — replace with real implementation
export type performMCPOAuthFlow = any export type performMCPOAuthFlow = any;
export type revokeServerTokens = any export type revokeServerTokens = any;

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