Compare commits

..

1 Commits

Author SHA1 Message Date
unraid
bddffa216a feat: integrate 5 feature branches, upstream fixes, and MIME detection fix
Squashed 5 commits:

Features (from 5 feature branches):
- MCP fix, pipe mute, stub recovery
- KAIROS activation, openclaw autonomy
- Daemon/job command hierarchy + cross-platform bg engine

Upstream fixes:
- fix: Bun.hash compatibility
- chore: chrome dependency update
- docs: browser support guide

MIME detection fix:
- Screenshot detectMimeFromBase64(): decode raw bytes from base64
  instead of broken charCodeAt comparison
- Fixes API 400 on Windows (JPEG) and macOS (PNG) screenshots
2026-04-14 18:32:19 +08:00
1606 changed files with 61950 additions and 54087 deletions

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

@@ -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,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,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

@@ -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

@@ -10,11 +10,7 @@ import {
setSystemPromptInjection, setSystemPromptInjection,
} from '../context' } from '../context'
import { clearMemoryFileCaches } from '../utils/claudemd' import { clearMemoryFileCaches } from '../utils/claudemd'
import { import { cleanupTempDir, createTempDir, writeTempFile } from '../../tests/mocks/file-system'
cleanupTempDir,
createTempDir,
writeTempFile,
} from '../../tests/mocks/file-system'
let tempDir = '' let tempDir = ''
let projectClaudeMdContent = '' let projectClaudeMdContent = ''

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,19 +1,35 @@
import { readdir, readFile, unlink } from 'fs/promises' import { readdir, readFile, unlink } from 'fs/promises'
import { join } from 'path' import { join } from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { spawnSync } from 'child_process'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js' import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
import { isProcessRunning } from '../utils/genericProcessUtils.js' import { isProcessRunning } from '../utils/genericProcessUtils.js'
import { jsonParse } from '../utils/slowOperations.js' import { jsonParse } from '../utils/slowOperations.js'
import { selectEngine } from './bg/engines/index.js' import { execFileNoThrow } from '../utils/execFileNoThrow.js'
import type { SessionEntry } from './bg/engine.js' import { quote } from '../utils/bash/shellQuote.js'
export type { SessionEntry } from './bg/engine.js' 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
}
function getSessionsDir(): string { function getSessionsDir(): string {
return join(getClaudeConfigHomeDir(), 'sessions') return join(getClaudeConfigHomeDir(), 'sessions')
} }
export async function listLiveSessions(): Promise<SessionEntry[]> { async function listLiveSessions(): Promise<SessionEntry[]> {
const dir = getSessionsDir() const dir = getSessionsDir()
let files: string[] let files: string[]
try { try {
@@ -44,7 +60,7 @@ export async function listLiveSessions(): Promise<SessionEntry[]> {
return sessions return sessions
} }
export function findSession( function findSession(
sessions: SessionEntry[], sessions: SessionEntry[],
target: string, target: string,
): SessionEntry | undefined { ): SessionEntry | undefined {
@@ -62,17 +78,7 @@ function formatTime(ts: number): string {
} }
/** /**
* Resolve the engine type for an existing session. * `claude ps` — list live sessions.
* 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> { export async function psHandler(_args: string[]): Promise<void> {
const sessions = await listLiveSessions() const sessions = await listLiveSessions()
@@ -87,11 +93,9 @@ export async function psHandler(_args: string[]): Promise<void> {
) )
for (const s of sessions) { for (const s of sessions) {
const engineType = resolveSessionEngine(s)
const parts: string[] = [ const parts: string[] = [
` PID: ${s.pid}`, ` PID: ${s.pid}`,
` Kind: ${s.kind}`, ` Kind: ${s.kind}`,
` Engine: ${engineType}`,
` Session: ${s.sessionId}`, ` Session: ${s.sessionId}`,
` CWD: ${s.cwd}`, ` CWD: ${s.cwd}`,
] ]
@@ -102,7 +106,6 @@ export async function psHandler(_args: string[]): Promise<void> {
if (s.waitingFor) parts.push(` Waiting for: ${s.waitingFor}`) if (s.waitingFor) parts.push(` Waiting for: ${s.waitingFor}`)
if (s.bridgeSessionId) parts.push(` Bridge: ${s.bridgeSessionId}`) if (s.bridgeSessionId) parts.push(` Bridge: ${s.bridgeSessionId}`)
if (s.tmuxSessionName) parts.push(` Tmux: ${s.tmuxSessionName}`) if (s.tmuxSessionName) parts.push(` Tmux: ${s.tmuxSessionName}`)
if (s.logPath) parts.push(` Log: ${s.logPath}`)
console.log(parts.join('\n')) console.log(parts.join('\n'))
console.log() console.log()
@@ -110,7 +113,7 @@ export async function psHandler(_args: string[]): Promise<void> {
} }
/** /**
* `claude daemon logs <target>` — show logs for a session. * `claude logs <target>` — show logs for a session.
*/ */
export async function logsHandler(target: string | undefined): Promise<void> { export async function logsHandler(target: string | undefined): Promise<void> {
const sessions = await listLiveSessions() const sessions = await listLiveSessions()
@@ -155,21 +158,28 @@ export async function logsHandler(target: string | undefined): Promise<void> {
} }
/** /**
* `claude daemon attach <target>` — attach to a background session. * `claude attach <target>` — attach to a background tmux session.
*
* Engine-aware: tmux sessions use tmux attach, detached sessions use log tail.
*/ */
export async function attachHandler(target: string | undefined): Promise<void> { export async function attachHandler(target: string | undefined): Promise<void> {
// Check tmux availability
const { code: tmuxCode } = await execFileNoThrow('tmux', ['-V'])
if (tmuxCode !== 0) {
console.error(
'tmux is required for attach. Install tmux to use background sessions.',
)
console.error(getTmuxHint())
process.exitCode = 1
return
}
const sessions = await listLiveSessions() const sessions = await listLiveSessions()
if (!target) { if (!target) {
// Find bg sessions (tmux or detached) // Find bg sessions with tmux metadata
const bgSessions = sessions.filter( const bgSessions = sessions.filter(s => s.tmuxSessionName)
s => s.tmuxSessionName || s.engine === 'detached',
)
if (bgSessions.length === 0) { if (bgSessions.length === 0) {
console.log( console.log(
'No background sessions to attach to. Start one with `claude daemon bg`.', 'No background sessions to attach to. Start one with `claude --bg`.',
) )
return return
} }
@@ -179,8 +189,7 @@ export async function attachHandler(target: string | undefined): Promise<void> {
console.log('Multiple background sessions. Specify one:') console.log('Multiple background sessions. Specify one:')
for (const s of bgSessions) { for (const s of bgSessions) {
const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId
const engineType = resolveSessionEngine(s) console.log(` ${label} PID=${s.pid} tmux=${s.tmuxSessionName}`)
console.log(` ${label} PID=${s.pid} engine=${engineType}`)
} }
return return
} }
@@ -193,33 +202,33 @@ export async function attachHandler(target: string | undefined): Promise<void> {
return return
} }
const engineType = resolveSessionEngine(session) if (!session.tmuxSessionName) {
console.error(
`Session ${session.sessionId} was not started with --bg (no tmux session).`,
)
process.exitCode = 1
return
}
try { // tmux attach is a blocking call — replaces this process's terminal
if (engineType === 'tmux') { const result = spawnSync(
const { TmuxEngine } = await import('./bg/engines/tmux.js') 'tmux',
const tmux = new TmuxEngine() ['attach-session', '-t', session.tmuxSessionName],
if (!(await tmux.available())) { {
console.error( stdio: 'inherit',
'tmux is no longer available. Cannot attach to tmux session.', },
) )
process.exitCode = 1
return if (result.status !== 0) {
} console.error(
await tmux.attach(session) `Failed to attach to tmux session '${session.tmuxSessionName}'.`,
} 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 process.exitCode = 1
} }
} }
/** /**
* `claude daemon kill <target>` — kill a session. * `claude kill <target>` — kill a session.
*/ */
export async function killHandler(target: string | undefined): Promise<void> { export async function killHandler(target: string | undefined): Promise<void> {
const sessions = await listLiveSessions() const sessions = await listLiveSessions()
@@ -271,36 +280,16 @@ export async function killHandler(target: string | undefined): Promise<void> {
} }
/** /**
* `claude daemon bg [args]` — start a background session. * `claude --bg [args]` — start a session in a background tmux pane.
*
* 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> { export async function handleBgFlag(args: string[]): Promise<void> {
const engine = await selectEngine() // Check tmux availability
const { code: tmuxCode } = await execFileNoThrow('tmux', ['-V'])
// Strip --bg/--background from args (for backward-compat shortcut) if (tmuxCode !== 0) {
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( console.error(
'Error: Background sessions with detached engine require -p/--print flag.\n' + 'tmux is required for --bg. Install tmux to use background sessions.',
'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(getTmuxHint())
console.error(
'\nAlternatively, install tmux for interactive background sessions:\n' +
` ${process.platform === 'darwin' ? 'brew install tmux' : 'sudo apt install tmux'}`,
)
}
process.exitCode = 1 process.exitCode = 1
return return
} }
@@ -313,29 +302,47 @@ export async function handleBgStart(args: string[]): Promise<void> {
`${sessionName}.log`, `${sessionName}.log`,
) )
try { // Strip --bg/--background from args
const result = await engine.start({ const filteredArgs = args.filter(a => a !== '--bg' && a !== '--background')
sessionName,
args: filteredArgs,
env: { ...process.env },
logPath,
cwd: process.cwd(),
})
console.log(`Background session started: ${result.sessionName}`) // Build the command to run inside tmux — use array form to avoid shell injection
console.log(` Engine: ${result.engineUsed}`) const entrypoint = process.argv[1]!
console.log(` Log: ${result.logPath}`) const tmuxEnv = {
console.log() ...process.env,
console.log( CLAUDE_CODE_SESSION_KIND: 'bg',
`Use \`claude daemon attach ${result.sessionName}\` to reconnect.`, CLAUDE_CODE_SESSION_NAME: sessionName,
) CLAUDE_CODE_SESSION_LOG: logPath,
console.log(`Use \`claude daemon status\` to check status.`) CLAUDE_CODE_TMUX_SESSION: sessionName,
console.log(`Use \`claude daemon kill ${result.sessionName}\` to stop.`)
} catch (e) {
console.error(e instanceof Error ? e.message : String(e))
process.exitCode = 1
} }
const cmd = quote([process.execPath, entrypoint, ...filteredArgs])
const result = spawnSync(
'tmux',
['new-session', '-d', '-s', sessionName, cmd],
{ stdio: 'inherit', env: tmuxEnv },
)
if (result.status !== 0) {
console.error('Failed to create tmux session.')
process.exitCode = 1
return
}
console.log(`Background session started: ${sessionName}`)
console.log(` tmux session: ${sessionName}`)
console.log(` log: ${logPath}`)
console.log()
console.log(`Use \`claude attach ${sessionName}\` to reconnect.`)
console.log(`Use \`claude ps\` to check status.`)
console.log(`Use \`claude kill ${sessionName}\` to stop.`)
} }
// Legacy export alias — kept for backward compatibility with cli.tsx function getTmuxHint(): string {
export const handleBgFlag = handleBgStart 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,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

@@ -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

@@ -24,9 +24,6 @@ export async function templatesMain(args: string[]): Promise<void> {
case 'reply': case 'reply':
handleReply(args.slice(1)) handleReply(args.slice(1))
break break
case 'status':
handleStatus(args.slice(1))
break
default: default:
console.error(`Unknown template command: ${subcommand}`) console.error(`Unknown template command: ${subcommand}`)
printUsage() printUsage()
@@ -38,36 +35,12 @@ function printUsage(): void {
console.log(` console.log(`
Template Job Commands: Template Job Commands:
claude job list List available templates claude list List available templates
claude job new <template> [args] Create a new job from a template claude new <template> [args] Create a new job from a template
claude job reply <job-id> <text> Reply to an existing job claude 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 { function handleList(): void {
const templates = listTemplates() const templates = listTemplates()
@@ -92,7 +65,7 @@ function handleList(): void {
function handleNew(args: string[]): void { function handleNew(args: string[]): void {
const templateName = args[0] const templateName = args[0]
if (!templateName) { if (!templateName) {
console.error('Usage: claude job new <template> [args...]') console.error('Usage: claude new <template> [args...]')
process.exitCode = 1 process.exitCode = 1
return return
} }
@@ -135,7 +108,7 @@ function handleReply(args: string[]): void {
const text = args.slice(1).join(' ') const text = args.slice(1).join(' ')
if (!jobId || !text) { if (!jobId || !text) {
console.error('Usage: claude job reply <job-id> <text>') console.error('Usage: claude reply <job-id> <text>')
process.exitCode = 1 process.exitCode = 1
return return
} }

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

@@ -373,12 +373,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 +1191,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 +1653,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 +1815,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 } =
@@ -2175,137 +2166,120 @@ function runHeadlessStreaming(
for (const runId of autonomyRunIds) { for (const runId of autonomyRunIds) {
await markAutonomyRunRunning(runId) await markAutonomyRunRunning(runId)
} }
let lastResultIsError = false
try { try {
await runWithWorkload( await runWithWorkload(cmd.workload ?? options.workload, async () => {
cmd.workload ?? options.workload, for await (const message of ask({
async () => { commands: uniqBy(
for await (const message of ask({ [...currentCommands, ...appState.mcp.commands],
commands: uniqBy( 'name',
[...currentCommands, ...appState.mcp.commands], ),
'name', prompt: input,
), promptUuid: cmd.uuid,
prompt: input, isMeta: cmd.isMeta,
promptUuid: cmd.uuid, cwd: cwd(),
isMeta: cmd.isMeta, tools: allTools,
cwd: cwd(), verbose: options.verbose,
tools: allTools, mcpClients: allMcpClients,
verbose: options.verbose, thinkingConfig: options.thinkingConfig,
mcpClients: allMcpClients, maxTurns: options.maxTurns,
thinkingConfig: options.thinkingConfig, maxBudgetUsd: options.maxBudgetUsd,
maxTurns: options.maxTurns, taskBudget: options.taskBudget,
maxBudgetUsd: options.maxBudgetUsd, canUseTool,
taskBudget: options.taskBudget, userSpecifiedModel: activeUserSpecifiedModel,
canUseTool, fallbackModel: options.fallbackModel,
userSpecifiedModel: activeUserSpecifiedModel, jsonSchema: getInitJsonSchema() ?? options.jsonSchema,
fallbackModel: options.fallbackModel, mutableMessages,
jsonSchema: getInitJsonSchema() ?? options.jsonSchema, getReadFileCache: () =>
mutableMessages, pendingSeeds.size === 0
getReadFileCache: () => ? readFileState
pendingSeeds.size === 0 : mergeFileStateCaches(readFileState, pendingSeeds),
? readFileState setReadFileCache: cache => {
: mergeFileStateCaches(readFileState, pendingSeeds), readFileState = cache
setReadFileCache: cache => { for (const [path, seed] of pendingSeeds.entries()) {
readFileState = cache const existing = readFileState.get(path)
for (const [path, seed] of pendingSeeds.entries()) { if (!existing || seed.timestamp > existing.timestamp) {
const existing = readFileState.get(path) readFileState.set(path, seed)
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)
}
}
}) // end runWithWorkload
for (const runId of autonomyRunIds) {
const nextCommands = await finalizeAutonomyRunCompleted({
runId,
currentDir: cwd(),
priority: 'later',
workload: cmd.workload ?? options.workload,
})
for (const nextCommand of nextCommands) {
enqueue({
...nextCommand,
uuid: randomUUID(),
})
} }
} }
} catch (error) { } catch (error) {
@@ -2328,15 +2302,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,7 +2749,9 @@ 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
@@ -3121,9 +3092,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 +3157,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 +3227,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 +3502,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 +3551,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 +3666,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 +3714,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 +3777,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 +4212,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 +4543,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 +4585,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 +4633,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 +4938,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 +5362,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

@@ -31,7 +31,7 @@ export async function rollback(
} }
if (!target) { if (!target) {
console.error( console.log(
'Usage: claude rollback [target]\n\n' + 'Usage: claude rollback [target]\n\n' +
'Options:\n' + 'Options:\n' +
' -l, --list List recent published versions\n' + ' -l, --list List recent published versions\n' +
@@ -42,7 +42,6 @@ export async function rollback(
' claude rollback --list\n' + ' claude rollback --list\n' +
' claude rollback --safe', ' claude rollback --safe',
) )
process.exitCode = 1
return return
} }

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;

View File

@@ -1,3 +1,3 @@
// Auto-generated type stub — replace with real implementation // Auto-generated type stub — replace with real implementation
export type isChannelAllowlisted = any export type isChannelAllowlisted = any;
export type isChannelsEnabled = any export type isChannelsEnabled = 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 ChannelMessageNotificationSchema = any export type ChannelMessageNotificationSchema = any;
export type gateChannelServer = any export type gateChannelServer = any;
export type wrapChannelMessage = any export type wrapChannelMessage = any;
export type findChannelEntry = any export type findChannelEntry = any;

View File

@@ -1,7 +1,7 @@
// Auto-generated type stub — replace with real implementation // Auto-generated type stub — replace with real implementation
export type setupSdkMcpClients = any export type setupSdkMcpClients = any;
export type connectToServer = any export type connectToServer = any;
export type clearServerCache = any export type clearServerCache = any;
export type fetchToolsForClient = any export type fetchToolsForClient = any;
export type areMcpConfigsEqual = any export type areMcpConfigsEqual = any;
export type reconnectMcpServerImpl = any export type reconnectMcpServerImpl = any;

View File

@@ -1,6 +1,6 @@
// Auto-generated type stub — replace with real implementation // Auto-generated type stub — replace with real implementation
export type filterMcpServersByPolicy = any export type filterMcpServersByPolicy = any;
export type getMcpConfigByName = any export type getMcpConfigByName = any;
export type isMcpServerDisabled = any export type isMcpServerDisabled = any;
export type setMcpServerEnabled = any export type setMcpServerEnabled = any;
export type getAllMcpConfigs = any export type getAllMcpConfigs = 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 runElicitationHooks = any export type runElicitationHooks = any;
export type runElicitationResultHooks = any export type runElicitationResultHooks = 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 getMcpPrefix = any export type getMcpPrefix = any;

View File

@@ -1,4 +1,4 @@
// Auto-generated type stub — replace with real implementation // Auto-generated type stub — replace with real implementation
export type MCPServerConnection = any export type MCPServerConnection = any;
export type McpSdkServerConfig = any export type McpSdkServerConfig = any;
export type ScopedMcpServerConfig = any export type ScopedMcpServerConfig = 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 commandBelongsToServer = any export type commandBelongsToServer = any;
export type filterToolsByServer = any export type filterToolsByServer = 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 setupVscodeSdkMcp = any export type setupVscodeSdkMcp = 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 OAuthService = any export type OAuthService = 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 isPolicyAllowed = any export type isPolicyAllowed = 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 waitForRemoteManagedSettingsToLoad = any export type waitForRemoteManagedSettingsToLoad = 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 downloadUserSettings = any export type downloadUserSettings = any;
export type redownloadUserSettings = any export type redownloadUserSettings = 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 AppState = any export type AppState = 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 externalMetadataToAppState = any export type externalMetadataToAppState = 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 assembleToolPool = any export type assembleToolPool = any;
export type filterToolsByDenyRules = any export type filterToolsByDenyRules = 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 createAbortController = any export type createAbortController = 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 uniq = any export type uniq = 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 getAccountInformation = any export type getAccountInformation = any;

View File

@@ -1,4 +1,4 @@
// Auto-generated type stub — replace with real implementation // Auto-generated type stub — replace with real implementation
export type getLatestVersion = any export type getLatestVersion = any;
export type InstallStatus = any export type InstallStatus = any;
export type installGlobalPackage = any export type installGlobalPackage = 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 AwsAuthStatusManager = any export type AwsAuthStatusManager = 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 modelSupportsAutoMode = any export type modelSupportsAutoMode = 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 registerCleanup = any export type registerCleanup = 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 createCombinedAbortSignal = any export type createCombinedAbortSignal = any;

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