mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Compare commits
4 Commits
v2.6.7
...
lint/previ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5f52cd668 | ||
|
|
4c409df35d | ||
|
|
ee369549a8 | ||
|
|
637c9081f6 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,7 +15,7 @@ src/utils/vendor/
|
||||
.claude/
|
||||
.codex/
|
||||
.omx/
|
||||
|
||||
.docs/task/
|
||||
# Binary / screenshot files (root only)
|
||||
/*.png
|
||||
*.bmp
|
||||
|
||||
204
02-kairos (1).md
Normal file
204
02-kairos (1).md
Normal file
@@ -0,0 +1,204 @@
|
||||
# KAIROS — 永不关机的 Claude
|
||||
|
||||
> 源码位置:`src/assistant/`、`src/proactive/`、`src/services/autoDream/`
|
||||
> 编译开关:`feature('KAIROS')`、`feature('KAIROS_BRIEF')`、`feature('KAIROS_CHANNELS')`
|
||||
> 远程开关:GrowthBook `tengu_kairos`
|
||||
|
||||
关掉终端 Claude 还在运行的持久助手模式。KAIROS 是 Claude Code 中最复杂的隐藏功能之一。
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
KAIROS 让 Claude 从"一次性对话工具"变成"持久运行的 AI 助手":
|
||||
|
||||
- 关闭终端后 Claude 仍在后台运行
|
||||
- 每天自动写日志
|
||||
- 晚上自动"做梦"整理记忆
|
||||
- 没人说话时自己找活干
|
||||
- 命令超 15 秒自动丢后台
|
||||
|
||||
---
|
||||
|
||||
## 激活流程
|
||||
|
||||
定义在 `src/main.tsx`(约第 1054-1092 行),需要通过五层检查:
|
||||
|
||||
```
|
||||
1. feature('KAIROS') ← 编译时 flag
|
||||
2. settings.assistant: true ← .claude/settings.json
|
||||
3. 目录信任状态检查 ← 防恶意仓库劫持
|
||||
4. tengu_kairos ← GrowthBook 远程开关
|
||||
5. setKairosActive(true) ← 全局状态激活
|
||||
```
|
||||
|
||||
`--assistant` CLI 参数可跳过远程开关检查(用于 Agent SDK daemon 模式)。
|
||||
|
||||
全局状态存储在 `src/bootstrap/state.ts`:
|
||||
- `kairosActive: boolean`(默认 `false`)
|
||||
- `getKairosActive()` / `setKairosActive(true)`
|
||||
|
||||
---
|
||||
|
||||
## 跨会话持久运行
|
||||
|
||||
### 会话恢复
|
||||
|
||||
`src/utils/conversationRecovery.ts` 中使用 `feature('KAIROS')` 条件导入 `BriefTool` 和 `SendUserFileTool`。在反序列化会话时识别这些工具的结果为"终端工具结果",判断 turn 是正常完成还是被中断。
|
||||
|
||||
### 持久 Cron 任务
|
||||
|
||||
关键在 `.claude/scheduled_tasks.json`。标记为 `permanent: true` 的任务不受 7 天自动过期限制:
|
||||
|
||||
- `catch-up`:恢复中断的工作
|
||||
- `morning-checkin`:每日早间签到
|
||||
- `dream`:记忆整合
|
||||
|
||||
### 会话历史 API
|
||||
|
||||
`src/assistant/sessionHistory.ts` 通过 OAuth API 加载远程会话历史,使用 `v1/sessions/{sessionId}/events` 端点,支持分页拉取。
|
||||
|
||||
---
|
||||
|
||||
## 做梦机制(Dream)
|
||||
|
||||
KAIROS 最精巧的子系统——后台运行的子代理,将分散的会话记忆整合为持久的结构化知识。
|
||||
|
||||
### 触发条件(三层门控,由廉到贵)
|
||||
|
||||
定义在 `src/services/autoDream/autoDream.ts`:
|
||||
|
||||
```
|
||||
1. 时间门控:距上次整合超过 24 小时(minHours)
|
||||
2. 会话门控:至少 5 个新会话(minSessions)
|
||||
3. 锁门控:没有其他进程正在整合
|
||||
```
|
||||
|
||||
阈值通过 GrowthBook `tengu_onyx_plover` 远程配置动态控制。
|
||||
|
||||
### 四阶段整合流程
|
||||
|
||||
定义在 `src/services/autoDream/consolidationPrompt.ts`:
|
||||
|
||||
| 阶段 | 动作 |
|
||||
|------|------|
|
||||
| **Orient** | 列出记忆目录、读取 `MEMORY.md` 索引、浏览已有主题文件 |
|
||||
| **Gather** | 从每日日志、已有记忆、JSONL transcript 中搜集新信号 |
|
||||
| **Consolidate** | 合并新信号到主题文件,转换相对日期为绝对日期,删除过时事实 |
|
||||
| **Prune** | 更新 `MEMORY.md` 索引,保持在行数和大小限制内 |
|
||||
|
||||
### 锁机制
|
||||
|
||||
`src/services/autoDream/consolidationLock.ts`:
|
||||
|
||||
- 使用 `.consolidate-lock` 文件
|
||||
- 文件 mtime = `lastConsolidatedAt`
|
||||
- 文件内容 = 持有者 PID
|
||||
- 支持 PID 存活检查(1 小时超时)
|
||||
- double-write 后 re-read 验证防竞争
|
||||
|
||||
### 每日日志
|
||||
|
||||
路径由 `src/memdir/paths.ts` 的 `getAutoMemDailyLogPath()` 计算:
|
||||
|
||||
```
|
||||
<autoMemPath>/logs/YYYY/MM/YYYY-MM-DD.md
|
||||
```
|
||||
|
||||
### UI 呈现
|
||||
|
||||
- Footer pill 标签显示 **"dreaming"**
|
||||
- `src/components/tasks/DreamDetailDialog.tsx` 提供专门的详情对话框
|
||||
- 支持查看实时进度和手动中止
|
||||
- `Shift+Down` 打开后台任务对话框
|
||||
|
||||
---
|
||||
|
||||
## 主动模式(Proactive Mode)
|
||||
|
||||
没人说话时 Claude 自己找活干。
|
||||
|
||||
### 核心状态
|
||||
|
||||
`src/proactive/index.ts` 维护三个状态:
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| `active` | 是否激活 |
|
||||
| `paused` | 是否暂停(用户按 Esc 取消时暂停,下次输入恢复) |
|
||||
| `contextBlocked` | API 错误时阻塞 tick,防止 tick-error-tick 死循环 |
|
||||
|
||||
### 激活方式
|
||||
|
||||
- `--proactive` CLI 参数
|
||||
- `CLAUDE_CODE_PROACTIVE` 环境变量
|
||||
- 受 `feature('PROACTIVE') || feature('KAIROS')` 保护
|
||||
|
||||
### 系统提示
|
||||
|
||||
激活后追加:
|
||||
|
||||
```
|
||||
# Proactive Mode
|
||||
|
||||
You are in proactive mode. Take initiative -- explore, act, and make progress
|
||||
without waiting for instructions.
|
||||
|
||||
Start by briefly greeting the user.
|
||||
|
||||
You will receive periodic <tick> prompts. These are check-ins. Do whatever
|
||||
seems most useful, or call Sleep if there's nothing to do.
|
||||
```
|
||||
|
||||
### SleepTool 集成
|
||||
|
||||
设置中的 `minSleepDurationMs` 和 `maxSleepDurationMs` 控制 Sleep 持续时间范围,节流 proactive tick 频率。没活干就 Sleep 等着。
|
||||
|
||||
---
|
||||
|
||||
## 后台任务管理
|
||||
|
||||
### Cron 调度器
|
||||
|
||||
`src/utils/cronScheduler.ts`:
|
||||
|
||||
- 每 1 秒 tick 一次(`CHECK_INTERVAL_MS = 1000`)
|
||||
- 使用 chokidar 监视 `.claude/scheduled_tasks.json`
|
||||
- 支持调度器锁(`src/utils/cronTasksLock.ts`),防止多实例重复触发
|
||||
- 锁探测间隔 5 秒,持有者崩溃时自动接管
|
||||
|
||||
### 任务类型
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| 一次性(`recurring: false`) | 触发后自动删除,支持错过任务检测 |
|
||||
| 循环(`recurring: true`) | 触发后重新调度,默认 7 天过期 |
|
||||
| 永久(`permanent: true`) | 不受过期限制(KAIROS 专用) |
|
||||
| 会话级(`durable: false`) | 仅内存中,进程退出即消失 |
|
||||
|
||||
### Jitter 防雷群机制
|
||||
|
||||
`src/utils/cronJitterConfig.ts`:
|
||||
|
||||
- 循环任务:基于 taskId 的确定性延迟(interval 的 10%,上限 15 分钟)
|
||||
- 一次性任务:在 :00 和 :30 施加最多 90 秒提前量
|
||||
- 运维可在事故期间推送配置变更,60 秒内全客户端生效
|
||||
|
||||
---
|
||||
|
||||
## 关键源码文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/bootstrap/state.ts` | KAIROS 全局状态 |
|
||||
| `src/assistant/index.ts` | 助手模式入口 |
|
||||
| `src/assistant/sessionHistory.ts` | 远程会话历史 API |
|
||||
| `src/proactive/index.ts` | 主动模式状态管理 |
|
||||
| `src/services/autoDream/autoDream.ts` | Auto-Dream 引擎 |
|
||||
| `src/services/autoDream/consolidationPrompt.ts` | 整合提示(四阶段) |
|
||||
| `src/services/autoDream/consolidationLock.ts` | 整合锁 |
|
||||
| `src/services/autoDream/config.ts` | Dream 配置 |
|
||||
| `src/tasks/DreamTask/DreamTask.ts` | Dream 任务定义 |
|
||||
| `src/utils/cronScheduler.ts` | Cron 调度器 |
|
||||
| `src/utils/cronTasks.ts` | Cron 任务持久化 |
|
||||
| `src/skills/bundled/dream.ts` | `/dream` Skill(存根) |
|
||||
283
AGENTS.md
Normal file
283
AGENTS.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# 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 framework(forked/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 Server(Docker 部署,含 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` 注册。
|
||||
2
build.ts
2
build.ts
@@ -40,6 +40,8 @@ const DEFAULT_BUILD_FEATURES = [
|
||||
'KAIROS',
|
||||
'COORDINATOR_MODE',
|
||||
'LAN_PIPES',
|
||||
'BG_SESSIONS',
|
||||
'TEMPLATES',
|
||||
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
|
||||
// P3: poor mode (disable extract_memories + prompt_suggestion)
|
||||
'POOR',
|
||||
|
||||
318
docs/features/daemon-restructure-design.md
Normal file
318
docs/features/daemon-restructure-design.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# 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 是唯一跨终端方案。
|
||||
310
docs/features/stub-recovery-design-1-4.md
Normal file
310
docs/features/stub-recovery-design-1-4.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Stub 恢复设计 1-4
|
||||
|
||||
> 日期:2026-04-12
|
||||
> 目标:基于当前代码边界,为下一阶段 4 个 stub/半 stub 命令面给出可实施的设计方案。
|
||||
> 排序原则:按建议实施顺序排序,不按问题严重性排序。
|
||||
|
||||
## 设计原则
|
||||
|
||||
- 先做能独立闭环、收益明确、改动边界清晰的项。
|
||||
- 大项拆成 `MVP` 和 `Phase 2+`,避免一次性掉进大范围恢复。
|
||||
- 优先复用已有状态、传输层、日志与配置能力,不重造协议。
|
||||
- 设计以当前仓库实际代码为准,不以旧文档的理想状态为准。
|
||||
|
||||
## 1. `claude daemon status` / `claude daemon stop`
|
||||
|
||||
### 现状
|
||||
|
||||
- `start` 路径已有完整 supervisor + worker 生命周期:
|
||||
[src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:1>)
|
||||
[src/daemon/workerRegistry.ts](</e:/Source_code/Claude-code-bast/src/daemon/workerRegistry.ts:1>)
|
||||
- `status` / `stop` 目前只是占位输出:
|
||||
[src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:49>)
|
||||
- `/remote-control-server` 有自己的命令内 UI 状态,但只维护当前进程内的 `daemonProcess`,并不适合作为跨进程 CLI 管理基础:
|
||||
[src/commands/remoteControlServer/remoteControlServer.tsx](</e:/Source_code/Claude-code-bast/src/commands/remoteControlServer/remoteControlServer.tsx:32>)
|
||||
|
||||
### 目标
|
||||
|
||||
- 让 `claude daemon status` 和 `claude daemon stop` 在另一个 CLI 进程中也能正确工作。
|
||||
- 不依赖 TUI 内存态,不要求当前命令进程就是启动 daemon 的那个进程。
|
||||
|
||||
### MVP 方案
|
||||
|
||||
- 新增 daemon 状态文件,例如:
|
||||
`~/.claude/daemon/remote-control.json`
|
||||
- `start` 时写入:
|
||||
- supervisor pid
|
||||
- cwd
|
||||
- startedAt
|
||||
- worker kinds
|
||||
- 最近状态
|
||||
- `status`:
|
||||
- 读取状态文件
|
||||
- 用现有进程探测能力验证 pid 是否存活
|
||||
- 输出 `running / stopped / stale`
|
||||
- stale 时自动清理状态文件
|
||||
- `stop`:
|
||||
- 读取 pid
|
||||
- 发送 `SIGTERM`
|
||||
- 等待退出
|
||||
- 超时后 `SIGKILL`
|
||||
- 清理状态文件
|
||||
|
||||
### 代码范围
|
||||
|
||||
- 新增 `src/daemon/state.ts`
|
||||
- 修改 [src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:1>)
|
||||
- 轻量修改 [src/commands/remoteControlServer/remoteControlServer.tsx](</e:/Source_code/Claude-code-bast/src/commands/remoteControlServer/remoteControlServer.tsx:32>),让 UI 尽量读取同一份状态文件
|
||||
|
||||
### 验证
|
||||
|
||||
1. `claude daemon start`
|
||||
2. 新开终端执行 `claude daemon status`
|
||||
3. 执行 `claude daemon stop`
|
||||
4. 再次执行 `claude daemon status`,确认返回 `stopped` 或清晰的 `stale cleaned`
|
||||
|
||||
### 风险
|
||||
|
||||
- Windows 信号模型和 Unix 不同,`stop` 需要超时兜底。
|
||||
- 当前设计默认单 supervisor,不处理多实例并发。
|
||||
|
||||
### 工作量判断
|
||||
|
||||
- 小
|
||||
- 适合作为下一步的首选实现项
|
||||
|
||||
## 2. `BG_SESSIONS`
|
||||
|
||||
### 现状
|
||||
|
||||
- fast-path 已接好:
|
||||
[src/entrypoints/cli.tsx](</e:/Source_code/Claude-code-bast/src/entrypoints/cli.tsx:218>)
|
||||
- session registry 已有真实实现:
|
||||
[src/utils/concurrentSessions.ts](</e:/Source_code/Claude-code-bast/src/utils/concurrentSessions.ts:1>)
|
||||
- `exit` 在 bg session 内已会 `tmux detach-client`:
|
||||
[src/commands/exit/exit.tsx](</e:/Source_code/Claude-code-bast/src/commands/exit/exit.tsx:20>)
|
||||
- 但 CLI handler 仍全空:
|
||||
[src/cli/bg.ts](</e:/Source_code/Claude-code-bast/src/cli/bg.ts:1>)
|
||||
- task summary 仍然是 stub:
|
||||
[src/utils/taskSummary.ts](</e:/Source_code/Claude-code-bast/src/utils/taskSummary.ts:1>)
|
||||
|
||||
### 目标
|
||||
|
||||
- 先把 `ps` / `logs` / `kill` 做成真正有用的 session 管理命令。
|
||||
- 不在第一阶段就强行补完 `attach` / `--bg`。
|
||||
|
||||
### Phase 2A:MVP
|
||||
|
||||
- 实现 `ps`
|
||||
- 从 registry 读取 live sessions
|
||||
- 展示 pid、kind、sessionId、cwd、name、startedAt、bridgeSessionId
|
||||
- 如果有 activity/status,则一并展示
|
||||
- 实现 `logs`
|
||||
- 支持按 `sessionId / pid / name` 查找
|
||||
- 优先复用本地 transcript/log 读取能力
|
||||
- 如果 registry 里存在 `logPath`,支持 tail 文件
|
||||
- 实现 `kill`
|
||||
- 解析目标 session
|
||||
- 发退出信号
|
||||
- 清理 stale registry
|
||||
|
||||
### Phase 2B:后续
|
||||
|
||||
- 实现 `attach`
|
||||
- 实现 `--bg`
|
||||
- 实现 `taskSummary` 的中途状态更新
|
||||
|
||||
### 为什么要拆
|
||||
|
||||
- 现有 registry 记录了 `pid / sessionId / name / logPath`
|
||||
- 但没有可靠的 tmux attach target
|
||||
- 所以 `attach` 和 `--bg` 不是简单补 handler,而是需要补启动/附着元数据设计
|
||||
|
||||
### 代码范围
|
||||
|
||||
- 修改 [src/cli/bg.ts](</e:/Source_code/Claude-code-bast/src/cli/bg.ts:1>)
|
||||
- 修改 [src/utils/concurrentSessions.ts](</e:/Source_code/Claude-code-bast/src/utils/concurrentSessions.ts:1>) 以便后续 attach/--bg 扩展
|
||||
- 修改 [src/utils/taskSummary.ts](</e:/Source_code/Claude-code-bast/src/utils/taskSummary.ts:1>)
|
||||
- 复用:
|
||||
[src/utils/sessionStorage.ts](</e:/Source_code/Claude-code-bast/src/utils/sessionStorage.ts:3870>)
|
||||
[src/utils/udsClient.ts](</e:/Source_code/Claude-code-bast/src/utils/udsClient.ts:1>)
|
||||
|
||||
### 验证
|
||||
|
||||
1. `ps` 能列出 live sessions
|
||||
2. `logs <sessionId|pid|name>` 能输出对应日志
|
||||
3. `kill <sessionId|pid|name>` 能结束目标 session
|
||||
|
||||
### 风险
|
||||
|
||||
- `attach` / `--bg` 第二阶段需要 tmux 元数据设计
|
||||
- Windows 下 tmux 路径需要明确降级策略
|
||||
|
||||
### 工作量判断
|
||||
|
||||
- `ps/logs/kill` 中等
|
||||
- `attach/--bg` 明显更大,应分阶段
|
||||
|
||||
## 3. `TEMPLATES`
|
||||
|
||||
### 现状
|
||||
|
||||
- 命令入口只有 fast-path:
|
||||
[src/entrypoints/cli.tsx](</e:/Source_code/Claude-code-bast/src/entrypoints/cli.tsx:249>)
|
||||
- handler 是空的:
|
||||
[src/cli/handlers/templateJobs.ts](</e:/Source_code/Claude-code-bast/src/cli/handlers/templateJobs.ts:1>)
|
||||
- `markdownConfigLoader` 已把 `templates` 纳入配置目录:
|
||||
[src/utils/markdownConfigLoader.ts](</e:/Source_code/Claude-code-bast/src/utils/markdownConfigLoader.ts:29>)
|
||||
- `query / stopHooks` 已预留 job classifier 链路:
|
||||
[src/query/stopHooks.ts](</e:/Source_code/Claude-code-bast/src/query/stopHooks.ts:103>)
|
||||
- `jobs/classifier.ts` 仍是 stub:
|
||||
[src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
|
||||
|
||||
### 目标
|
||||
|
||||
- 把 `new / list / reply` 做成可用的模板任务系统。
|
||||
- 第一阶段不碰复杂的自动分类与自动执行。
|
||||
|
||||
### MVP 方案
|
||||
|
||||
- 模板来源:
|
||||
`.claude/templates/*.md`
|
||||
- 模板格式:
|
||||
复用现有 markdown + frontmatter 解析,不另外设计 DSL
|
||||
- `list`
|
||||
- 列出所有模板
|
||||
- 显示模板名、description、路径
|
||||
- `new <template> [args...]`
|
||||
- 解析模板
|
||||
- 在 `~/.claude/jobs/<job-id>/` 下创建 job 目录
|
||||
- 写入 `template.md`、`input.txt`、`state.json`
|
||||
- 返回 job id 与目录
|
||||
- `reply <job-id> <text>`
|
||||
- 将回复写入 `replies.jsonl` 或 `input.txt`
|
||||
- 更新 `state.json`
|
||||
|
||||
### Phase 2
|
||||
|
||||
- 恢复 [src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
|
||||
- 让带 `CLAUDE_JOB_DIR` 的 job session 在 turn 完成后自动更新 `state.json`
|
||||
- 再决定是否补自动 job runner
|
||||
|
||||
### 为什么要拆
|
||||
|
||||
- 当前证据表明这是“template job commands”,不是单纯模板列表
|
||||
- 但自动 job 运行链路没有足够现成实现,先做文件系统 job lifecycle 更稳
|
||||
|
||||
### 代码范围
|
||||
|
||||
- 修改 [src/cli/handlers/templateJobs.ts](</e:/Source_code/Claude-code-bast/src/cli/handlers/templateJobs.ts:1>)
|
||||
- 新增 `src/jobs/state.ts`
|
||||
- 新增 `src/jobs/templates.ts`
|
||||
- Phase 2 再改 [src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
|
||||
|
||||
### 验证
|
||||
|
||||
1. `list` 能列出 `.claude/templates`
|
||||
2. `new` 能创建 job 目录和状态文件
|
||||
3. `reply` 能更新 job 内容和状态
|
||||
4. Phase 2 再验证 classifier 写状态
|
||||
|
||||
### 风险
|
||||
|
||||
- frontmatter schema 需要先定义最小字段集
|
||||
- 一旦扩展到“自动运行 job”,范围会明显膨胀
|
||||
|
||||
### 工作量判断
|
||||
|
||||
- MVP 中等
|
||||
- 完整 job 系统偏大
|
||||
|
||||
## 4. `assistant [sessionId]`
|
||||
|
||||
### 现状
|
||||
|
||||
- attach 主流程其实已经存在:
|
||||
[src/main.tsx](</e:/Source_code/Claude-code-bast/src/main.tsx:4708>)
|
||||
- 远端 viewer 所需基础模块已存在:
|
||||
[src/remote/RemoteSessionManager.ts](</e:/Source_code/Claude-code-bast/src/remote/RemoteSessionManager.ts:1>)
|
||||
[src/hooks/useAssistantHistory.ts](</e:/Source_code/Claude-code-bast/src/hooks/useAssistantHistory.ts:1>)
|
||||
[src/assistant/sessionHistory.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionHistory.ts:1>)
|
||||
- 真正 stub 的主要是:
|
||||
[src/assistant/sessionDiscovery.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionDiscovery.ts:1>)
|
||||
[src/assistant/AssistantSessionChooser.ts](</e:/Source_code/Claude-code-bast/src/assistant/AssistantSessionChooser.ts:1>)
|
||||
[src/commands/assistant/assistant.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/assistant.ts:7>)
|
||||
[src/assistant/index.ts](</e:/Source_code/Claude-code-bast/src/assistant/index.ts:1>)
|
||||
|
||||
### 目标
|
||||
|
||||
- 不一次性恢复整个 KAIROS 助手系统。
|
||||
- 先做“明确 sessionId 的 viewer attach 可用”,再逐步补 discovery / chooser / install。
|
||||
|
||||
### Phase 4A:MVP
|
||||
|
||||
- 只支持 `claude assistant <sessionId>`
|
||||
- 对 `claude assistant` 无参数模式,先返回明确提示:
|
||||
- 当前版本需要显式 `sessionId`
|
||||
- discovery 尚未启用
|
||||
- 这样可以直接复用现有 attach 分支,不必先恢复 chooser/install wizard
|
||||
|
||||
### Phase 4B
|
||||
|
||||
- 恢复 `discoverAssistantSessions()`
|
||||
- 数据来源优先复用现有 sessions / bridge / teleport API,而不是新协议
|
||||
- 让 `claude assistant` 无参数时能拿到候选 session 列表
|
||||
|
||||
### Phase 4C
|
||||
|
||||
- 恢复 `AssistantSessionChooser`
|
||||
- 多 session 时可交互选择
|
||||
|
||||
### Phase 4D
|
||||
|
||||
- 最后考虑 install wizard 辅助函数
|
||||
- 这部分属于“没有 session 时如何引导”,不是 attach 核心路径
|
||||
|
||||
### 为什么要拆
|
||||
|
||||
- attach 渲染层与远端消息通道大部分已经在
|
||||
- 真正缺的是“如何发现目标 session”和“如何交互选择”
|
||||
- 如果把 `src/assistant/index.ts` 的整套 KAIROS 正常模式也一起拉进来,范围会失控
|
||||
|
||||
### 代码范围
|
||||
|
||||
- Phase 4A:
|
||||
- [src/main.tsx](</e:/Source_code/Claude-code-bast/src/main.tsx:4708>)
|
||||
- [src/commands/assistant/index.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/index.ts:1>)
|
||||
- Phase 4B:
|
||||
- [src/assistant/sessionDiscovery.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionDiscovery.ts:1>)
|
||||
- Phase 4C:
|
||||
- [src/assistant/AssistantSessionChooser.ts](</e:/Source_code/Claude-code-bast/src/assistant/AssistantSessionChooser.ts:1>)
|
||||
- Phase 4D:
|
||||
- [src/commands/assistant/assistant.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/assistant.ts:7>)
|
||||
|
||||
### 验证
|
||||
|
||||
1. `claude assistant <sessionId>` 能进入 remote viewer
|
||||
2. 历史懒加载工作正常
|
||||
3. 无参数模式先给出明确提示
|
||||
4. 后续阶段再分别验证 discovery / chooser / install
|
||||
|
||||
### 风险
|
||||
|
||||
- 这是四项里范围最大的
|
||||
- 一旦把 KAIROS 正常模式整体拉入,会从“viewer attach”膨胀成“完整 assistant mode 恢复”
|
||||
|
||||
### 工作量判断
|
||||
|
||||
- Phase 4A 中等
|
||||
- 4A-4D 全做完很大
|
||||
|
||||
## 建议执行顺序
|
||||
|
||||
1. `claude daemon status` / `claude daemon stop`
|
||||
2. `BG_SESSIONS` 先做 `ps/logs/kill`
|
||||
3. `TEMPLATES` 先做 job 文件系统 MVP
|
||||
4. `assistant [sessionId]` 先做显式 sessionId attach,再补 discovery/chooser/install
|
||||
|
||||
## 简短结论
|
||||
|
||||
这四项里,最适合立刻实现的是 `daemon status/stop`。`BG_SESSIONS` 和 `TEMPLATES` 适合按 MVP 先补 handler 与文件系统闭环。`assistant [sessionId]` 不能整块硬上,应该按“attach → discovery → chooser → install”拆开恢复。
|
||||
77
docs/task/task-001-daemon-status-stop.md
Normal file
77
docs/task/task-001-daemon-status-stop.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Task 001: daemon status / stop
|
||||
|
||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 1 项
|
||||
> 优先级: P0 (首选实现项)
|
||||
> 工作量: 小
|
||||
> 状态: DONE
|
||||
|
||||
## 目标
|
||||
|
||||
让 `claude daemon status` 和 `claude daemon stop` 在任意 CLI 进程中都能正确工作,不依赖 TUI 内存态。
|
||||
|
||||
## 背景
|
||||
|
||||
- `start` 路径已有完整 supervisor + worker 生命周期 (`src/daemon/main.ts`, `src/daemon/workerRegistry.ts`)
|
||||
- `status` / `stop` 目前只是占位输出 (`src/daemon/main.ts:49`)
|
||||
- `/remote-control-server` 有自己的命令内 UI 状态,但只维护当前进程内的 `daemonProcess`,不适合跨进程管理
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/daemon/state.ts` | daemon 状态文件读写模块 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/daemon/main.ts` | `start` 写入状态文件;`status`/`stop` 调用 state 模块 |
|
||||
| `src/commands/remoteControlServer/remoteControlServer.tsx` | 读取同一份状态文件(轻量改动) |
|
||||
|
||||
### 状态文件
|
||||
|
||||
路径: `~/.claude/daemon/remote-control.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"pid": 12345,
|
||||
"cwd": "/path/to/project",
|
||||
"startedAt": "2026-04-12T10:00:00Z",
|
||||
"workerKinds": ["bridge", "rcs"],
|
||||
"lastStatus": "running"
|
||||
}
|
||||
```
|
||||
|
||||
### status 逻辑
|
||||
|
||||
1. 读取状态文件
|
||||
2. 用进程探测验证 pid 是否存活
|
||||
3. 输出 `running` / `stopped` / `stale`
|
||||
4. stale 时自动清理状态文件
|
||||
|
||||
### stop 逻辑
|
||||
|
||||
1. 读取 pid
|
||||
2. 发送 `SIGTERM`
|
||||
3. 等待退出(超时兜底)
|
||||
4. 超时后 `SIGKILL`
|
||||
5. 清理状态文件
|
||||
|
||||
## 验证步骤
|
||||
|
||||
- [ ] `claude daemon start` 正常启动并写入状态文件
|
||||
- [ ] 新开终端执行 `claude daemon status`,显示 `running`
|
||||
- [ ] 执行 `claude daemon stop`,daemon 正常退出
|
||||
- [ ] 再次执行 `claude daemon status`,返回 `stopped` 或 `stale cleaned`
|
||||
- [ ] Windows 下 stop 超时兜底正常工作
|
||||
|
||||
## 风险
|
||||
|
||||
- Windows 信号模型和 Unix 不同,`stop` 需要超时兜底
|
||||
- 当前设计默认单 supervisor,不处理多实例并发
|
||||
|
||||
## 依赖
|
||||
|
||||
无外部依赖,可独立实施。
|
||||
80
docs/task/task-002-bg-sessions-ps-logs-kill.md
Normal file
80
docs/task/task-002-bg-sessions-ps-logs-kill.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Task 002: BG_SESSIONS — ps / logs / kill
|
||||
|
||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 2 项
|
||||
> 优先级: P1
|
||||
> 工作量: 中等
|
||||
> 状态: DONE
|
||||
> 阶段: Phase 2A (MVP)
|
||||
|
||||
## 目标
|
||||
|
||||
把 `ps` / `logs` / `kill` 做成真正有用的 session 管理命令。不在第一阶段补完 `attach` / `--bg`。
|
||||
|
||||
## 背景
|
||||
|
||||
- fast-path 已接好 (`src/entrypoints/cli.tsx:218`)
|
||||
- session registry 已有真实实现 (`src/utils/concurrentSessions.ts`)
|
||||
- `exit` 在 bg session 内已会 `tmux detach-client` (`src/commands/exit/exit.tsx:20`)
|
||||
- CLI handler 仍全空 (`src/cli/bg.ts`)
|
||||
- task summary 仍然是 stub (`src/utils/taskSummary.ts`)
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/cli/bg.ts` | 实现 `ps` / `logs` / `kill` handler |
|
||||
| `src/utils/concurrentSessions.ts` | 扩展以便后续 attach/--bg 使用 |
|
||||
| `src/utils/taskSummary.ts` | 补充基础实现 |
|
||||
|
||||
### 复用模块
|
||||
|
||||
- `src/utils/sessionStorage.ts` — session 存储
|
||||
- `src/utils/udsClient.ts` — UDS 通信
|
||||
|
||||
### ps 命令
|
||||
|
||||
- 从 registry 读取 live sessions
|
||||
- 展示: pid, kind, sessionId, cwd, name, startedAt, bridgeSessionId
|
||||
- 如果有 activity/status,一并展示
|
||||
|
||||
### logs 命令
|
||||
|
||||
- 支持按 `sessionId` / `pid` / `name` 查找
|
||||
- 优先复用本地 transcript/log 读取能力
|
||||
- 如果 registry 里存在 `logPath`,支持 tail 文件
|
||||
|
||||
### kill 命令
|
||||
|
||||
- 解析目标 session
|
||||
- 发退出信号
|
||||
- 清理 stale registry
|
||||
|
||||
## 验证步骤
|
||||
|
||||
- [ ] `ps` 能列出当前 live sessions
|
||||
- [ ] `logs <sessionId|pid|name>` 能输出对应日志
|
||||
- [ ] `kill <sessionId|pid|name>` 能结束目标 session 并清理 registry
|
||||
- [ ] 无 live session 时各命令有明确提示
|
||||
|
||||
## Phase 2B (后续)
|
||||
|
||||
- [ ] 实现 `attach`
|
||||
- [ ] 实现 `--bg`
|
||||
- [ ] 实现 `taskSummary` 的中途状态更新
|
||||
|
||||
### 为什么拆分
|
||||
|
||||
- 现有 registry 记录了 `pid / sessionId / name / logPath`
|
||||
- 但没有可靠的 tmux attach target
|
||||
- `attach` 和 `--bg` 需要补启动/附着元数据设计,不是简单补 handler
|
||||
|
||||
## 风险
|
||||
|
||||
- `attach` / `--bg` 第二阶段需要 tmux 元数据设计
|
||||
- Windows 下 tmux 路径需要明确降级策略
|
||||
|
||||
## 依赖
|
||||
|
||||
- Task 001 (daemon 状态管理可复用模式,但非硬性依赖)
|
||||
87
docs/task/task-003-templates-job-mvp.md
Normal file
87
docs/task/task-003-templates-job-mvp.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Task 003: TEMPLATES — job 文件系统 MVP
|
||||
|
||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 3 项
|
||||
> 优先级: P2
|
||||
> 工作量: 中等
|
||||
> 状态: DONE
|
||||
> 阶段: MVP
|
||||
|
||||
## 目标
|
||||
|
||||
把 `new` / `list` / `reply` 做成可用的模板任务系统。第一阶段不碰复杂的自动分类与自动执行。
|
||||
|
||||
## 背景
|
||||
|
||||
- 命令入口只有 fast-path (`src/entrypoints/cli.tsx:249`)
|
||||
- handler 是空的 (`src/cli/handlers/templateJobs.ts`)
|
||||
- `markdownConfigLoader` 已把 `templates` 纳入配置目录 (`src/utils/markdownConfigLoader.ts:29`)
|
||||
- `query/stopHooks` 已预留 job classifier 链路 (`src/query/stopHooks.ts:103`)
|
||||
- `jobs/classifier.ts` 仍是 stub (`src/jobs/classifier.ts`)
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/jobs/state.ts` | job 状态管理 |
|
||||
| `src/jobs/templates.ts` | 模板解析与列表 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/cli/handlers/templateJobs.ts` | 实现 `new` / `list` / `reply` handler |
|
||||
|
||||
### 模板来源
|
||||
|
||||
`.claude/templates/*.md`
|
||||
|
||||
### 模板格式
|
||||
|
||||
复用现有 markdown + frontmatter 解析,不另外设计 DSL。
|
||||
|
||||
### list 命令
|
||||
|
||||
- 列出所有模板
|
||||
- 显示: 模板名, description, 路径
|
||||
|
||||
### new 命令
|
||||
|
||||
- 解析模板
|
||||
- 在 `~/.claude/jobs/<job-id>/` 下创建 job 目录
|
||||
- 写入 `template.md`, `input.txt`, `state.json`
|
||||
- 返回 job id 与目录路径
|
||||
|
||||
### reply 命令
|
||||
|
||||
- 将回复写入 `replies.jsonl` 或 `input.txt`
|
||||
- 更新 `state.json`
|
||||
|
||||
## 验证步骤
|
||||
|
||||
- [ ] `list` 能列出 `.claude/templates` 下的所有模板
|
||||
- [ ] `new <template> [args...]` 能创建 job 目录和状态文件
|
||||
- [ ] `reply <job-id> <text>` 能更新 job 内容和状态
|
||||
- [ ] frontmatter schema 最小字段集已定义
|
||||
|
||||
## Phase 2 (后续)
|
||||
|
||||
- [ ] 恢复 `src/jobs/classifier.ts`
|
||||
- [ ] 让带 `CLAUDE_JOB_DIR` 的 job session 在 turn 完成后自动更新 `state.json`
|
||||
- [ ] 再决定是否补自动 job runner
|
||||
|
||||
### 为什么拆分
|
||||
|
||||
- 当前是 "template job commands",不是单纯模板列表
|
||||
- 自动 job 运行链路没有足够现成实现
|
||||
- 先做文件系统 job lifecycle 更稳
|
||||
|
||||
## 风险
|
||||
|
||||
- frontmatter schema 需要先定义最小字段集
|
||||
- 一旦扩展到"自动运行 job",范围会明显膨胀
|
||||
|
||||
## 依赖
|
||||
|
||||
无硬性依赖,可独立实施。
|
||||
103
docs/task/task-004-assistant-session-attach.md
Normal file
103
docs/task/task-004-assistant-session-attach.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Task 004: assistant [sessionId] — 分阶段恢复
|
||||
|
||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 4 项
|
||||
> 优先级: P3
|
||||
> 工作量: Phase 4A 中等,4A-4D 全做完很大
|
||||
> 状态: Phase 4A DONE, 4B-4D TODO
|
||||
|
||||
## 目标
|
||||
|
||||
不一次性恢复整个 KAIROS 助手系统。先做"明确 sessionId 的 viewer attach 可用",再逐步补 discovery / chooser / install。
|
||||
|
||||
## 背景
|
||||
|
||||
- attach 主流程已存在 (`src/main.tsx:4708`)
|
||||
- 远端 viewer 所需基础模块已存在:
|
||||
- `src/remote/RemoteSessionManager.ts`
|
||||
- `src/hooks/useAssistantHistory.ts`
|
||||
- `src/assistant/sessionHistory.ts`
|
||||
- 真正 stub 的主要是:
|
||||
- `src/assistant/sessionDiscovery.ts`
|
||||
- `src/assistant/AssistantSessionChooser.ts`
|
||||
- `src/commands/assistant/assistant.ts:7`
|
||||
- `src/assistant/index.ts`
|
||||
|
||||
## 分阶段实现
|
||||
|
||||
### Phase 4A: MVP — 显式 sessionId attach
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/main.tsx` | 确保 attach 分支可用 |
|
||||
| `src/commands/assistant/index.ts` | 实现显式 sessionId 参数入口 |
|
||||
|
||||
**行为:**
|
||||
- `claude assistant <sessionId>` — 进入 remote viewer
|
||||
- `claude assistant` (无参数) — 返回明确提示: 当前版本需要显式 sessionId,discovery 尚未启用
|
||||
|
||||
**验证:**
|
||||
- [ ] `claude assistant <sessionId>` 能进入 remote viewer
|
||||
- [ ] 历史懒加载工作正常
|
||||
- [ ] 无参数模式给出明确提示
|
||||
|
||||
### Phase 4B: session discovery
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/assistant/sessionDiscovery.ts` | 恢复 `discoverAssistantSessions()` |
|
||||
|
||||
**行为:**
|
||||
- 数据来源优先复用现有 sessions / bridge / teleport API,不新增协议
|
||||
- `claude assistant` 无参数时能拿到候选 session 列表
|
||||
|
||||
**验证:**
|
||||
- [ ] 无参数调用能列出可用 sessions
|
||||
- [ ] 数据来源复用现有通道
|
||||
|
||||
### Phase 4C: session chooser
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/assistant/AssistantSessionChooser.ts` | 恢复交互式选择器 |
|
||||
|
||||
**行为:**
|
||||
- 多 session 时可交互选择
|
||||
|
||||
**验证:**
|
||||
- [ ] 多个 session 时弹出选择器
|
||||
- [ ] 选择后正确 attach
|
||||
|
||||
### Phase 4D: install wizard
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/commands/assistant/assistant.ts` | 恢复 install wizard 辅助函数 |
|
||||
|
||||
**行为:**
|
||||
- 没有 session 时如何引导用户
|
||||
|
||||
**验证:**
|
||||
- [ ] 无可用 session 时引导用户创建/连接
|
||||
|
||||
## 为什么拆分
|
||||
|
||||
- attach 渲染层与远端消息通道大部分已在
|
||||
- 真正缺的是"如何发现目标 session"和"如何交互选择"
|
||||
- 如果把 `src/assistant/index.ts` 的整套 KAIROS 正常模式也一起拉进来,范围会失控
|
||||
|
||||
## 风险
|
||||
|
||||
- 这是四项里范围最大的
|
||||
- 一旦把 KAIROS 正常模式整体拉入,会从"viewer attach"膨胀成"完整 assistant mode 恢复"
|
||||
|
||||
## 依赖
|
||||
|
||||
- Task 002 的 session registry 模式可复用
|
||||
196
docs/task/task-013-bg-engine-abstraction.md
Normal file
196
docs/task/task-013-bg-engine-abstraction.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# 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 通过
|
||||
275
docs/task/task-014-daemon-command-hierarchy.md
Normal file
275
docs/task/task-014-daemon-command-hierarchy.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# 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 通过
|
||||
177
docs/task/task-015-job-command-hierarchy.md
Normal file
177
docs/task/task-015-job-command-hierarchy.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# 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 通过
|
||||
123
docs/task/task-016-backward-compat-tests.md
Normal file
123
docs/task/task-016-backward-compat-tests.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 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 上验证关键路径
|
||||
88
docs/test-plans/openclaw-autonomy-baseline.md
Normal file
88
docs/test-plans/openclaw-autonomy-baseline.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# OpenClaw Autonomy Baseline Test Spec
|
||||
|
||||
## Purpose
|
||||
|
||||
This test spec locks the current behavior of the existing trigger and context layers before any formal autonomy-subsystem implementation begins.
|
||||
|
||||
At this stage, production code is read-only. Only test files, fixtures, and planning documents may change.
|
||||
|
||||
## Goal
|
||||
|
||||
Establish a stable baseline around the parts of `Claude-code-bast` that later autonomy work is most likely to touch:
|
||||
|
||||
- proactive state handling
|
||||
- cron task storage semantics
|
||||
- cron scheduler helper semantics
|
||||
- user-context cache and `CLAUDE.md` injection behavior
|
||||
|
||||
## Out of Scope for This Baseline Round
|
||||
|
||||
- New authority behavior (`AGENTS.md` / `HEARTBEAT.md`)
|
||||
- New detached-run ledger behavior
|
||||
- New flow behavior
|
||||
- UI redesign
|
||||
|
||||
## Files Under Baseline Protection
|
||||
|
||||
- `src/proactive/index.ts`
|
||||
- `src/utils/cronTasks.ts`
|
||||
- `src/utils/cronScheduler.ts`
|
||||
- `src/context.ts`
|
||||
|
||||
## Test Files Added In This Round
|
||||
|
||||
- `src/proactive/__tests__/state.baseline.test.ts`
|
||||
- `src/commands/__tests__/proactive.baseline.test.ts`
|
||||
- `src/utils/__tests__/cronTasks.baseline.test.ts`
|
||||
- `src/utils/__tests__/cronScheduler.baseline.test.ts`
|
||||
- `src/__tests__/context.baseline.test.ts`
|
||||
|
||||
## Baseline Assertions
|
||||
|
||||
### Proactive state
|
||||
|
||||
1. Activating proactive mode sets active state and activation source.
|
||||
2. Pausing proactive mode suppresses `shouldTick()` and clears `nextTickAt`.
|
||||
3. Blocking context suppresses `shouldTick()` and clears `nextTickAt`.
|
||||
4. Subscribers are notified on state transitions.
|
||||
5. The `/proactive` command enables proactive mode and emits the expected hidden reminder.
|
||||
6. The `/proactive` command disables proactive mode on the second invocation.
|
||||
|
||||
### Cron task storage
|
||||
|
||||
1. Session-only cron tasks remain in memory only.
|
||||
2. Durable cron tasks are persisted to `.claude/scheduled_tasks.json`.
|
||||
3. Daemon-style `dir`-scoped reads exclude session-only cron tasks.
|
||||
4. `removeCronTasks()` without `dir` can remove session-only tasks.
|
||||
5. `removeCronTasks()` with `dir` does not mutate session-only task storage.
|
||||
|
||||
### Cron scheduler helpers
|
||||
|
||||
1. `isRecurringTaskAged()` preserves current aging semantics.
|
||||
2. `buildMissedTaskNotification()` preserves the current AskUserQuestion safety wording.
|
||||
3. `buildMissedTaskNotification()` preserves code-fence hardening for prompt bodies that contain backticks.
|
||||
|
||||
### User context caching
|
||||
|
||||
1. `getUserContext()` includes `currentDate`.
|
||||
2. `getUserContext()` includes mocked `claudeMd` content when memory loading is enabled.
|
||||
3. `CLAUDE_CODE_DISABLE_CLAUDE_MDS` suppresses `claudeMd`.
|
||||
4. `setSystemPromptInjection()` clears the memoized user-context cache.
|
||||
5. `getSystemContext()` reflects the injection after cache invalidation.
|
||||
|
||||
## Remaining Baseline Gaps
|
||||
|
||||
The following areas are intentionally deferred because they require higher-cost harnessing and should still avoid production-code changes:
|
||||
|
||||
1. `useScheduledTasks.ts` hook-level runtime behavior
|
||||
2. `src/cli/print.ts` full headless scheduler loop behavior
|
||||
3. `useProactive.ts` hook timer behavior
|
||||
4. end-to-end queue interaction between proactive ticks and `SleepTool`
|
||||
|
||||
## Acceptance
|
||||
|
||||
This baseline round is complete when:
|
||||
|
||||
1. The four new test files pass.
|
||||
2. No production source files are modified.
|
||||
3. The tests are stable enough to serve as a pre-implementation guardrail.
|
||||
@@ -37,16 +37,21 @@
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
/** Detect actual image MIME type from base64 data using magic bytes. */
|
||||
/** Detect actual image MIME type from base64 data by decoding the magic bytes. */
|
||||
function detectMimeFromBase64(b64: string): string {
|
||||
// First byte is enough to distinguish PNG (0x89) from JPEG (0xFF)
|
||||
const c = b64.charCodeAt(0);
|
||||
if (c === 0x89) return "image/png";
|
||||
if (c === 0xFF) return "image/jpeg";
|
||||
// RIFF = WebP
|
||||
if (c === 0x52) return "image/webp";
|
||||
// GIF
|
||||
if (c === 0x47) return "image/gif";
|
||||
// Decode first 12 raw bytes (16 base64 chars is enough) and check standard magic bytes.
|
||||
// PNG: 89 50 4E 47
|
||||
// JPEG: FF D8 FF
|
||||
// RIFF+WEBP: "RIFF" at 0..3 + "WEBP" at 8..11
|
||||
// GIF: "GIF" at 0..2
|
||||
const raw = Buffer.from(b64.slice(0, 16), "base64");
|
||||
if (raw[0] === 0x89 && raw[1] === 0x50 && raw[2] === 0x4e && raw[3] === 0x47) return "image/png";
|
||||
if (raw[0] === 0xff && raw[1] === 0xd8 && raw[2] === 0xff) return "image/jpeg";
|
||||
if (
|
||||
raw[0] === 0x52 && raw[1] === 0x49 && raw[2] === 0x46 && raw[3] === 0x46 && // RIFF
|
||||
raw[8] === 0x57 && raw[9] === 0x45 && raw[10] === 0x42 && raw[11] === 0x50 // WEBP
|
||||
) return "image/webp";
|
||||
if (raw[0] === 0x47 && raw[1] === 0x49 && raw[2] === 0x46) return "image/gif";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { z } from 'zod/v4'
|
||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
|
||||
const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification'
|
||||
|
||||
@@ -74,14 +76,58 @@ Requires Remote Control to be configured. Respects user notification settings (t
|
||||
}
|
||||
},
|
||||
|
||||
async call(_input: PushInput) {
|
||||
// Push delivery is handled by the Remote Control / KAIROS transport layer.
|
||||
// Without the KAIROS runtime, this tool is not available.
|
||||
return {
|
||||
data: {
|
||||
sent: false,
|
||||
error: 'PushNotification requires the KAIROS transport layer.',
|
||||
},
|
||||
async call(input: PushInput, context) {
|
||||
const appState = context.getAppState()
|
||||
|
||||
// Try bridge delivery first (for remote/mobile viewers)
|
||||
if (appState.replBridgeEnabled) {
|
||||
if (feature('BRIDGE_MODE')) {
|
||||
try {
|
||||
const { getBridgeAccessToken, getBridgeBaseUrl } = await import(
|
||||
'src/bridge/bridgeConfig.js'
|
||||
)
|
||||
const { getSessionId } = await import('src/bootstrap/state.js')
|
||||
const token = getBridgeAccessToken()
|
||||
const sessionId = getSessionId()
|
||||
if (token && sessionId) {
|
||||
const baseUrl = getBridgeBaseUrl()
|
||||
const axios = (await import('axios')).default
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/v1/sessions/${sessionId}/events`,
|
||||
{
|
||||
events: [
|
||||
{
|
||||
type: 'push_notification',
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
priority: input.priority ?? 'normal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
timeout: 10_000,
|
||||
validateStatus: (s: number) => s < 500,
|
||||
},
|
||||
)
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
logForDebugging(`[PushNotification] delivered via bridge session=${sessionId}`)
|
||||
return { data: { sent: true } }
|
||||
}
|
||||
logForDebugging(`[PushNotification] bridge delivery failed: status=${response.status}`)
|
||||
}
|
||||
} catch (e) {
|
||||
logForDebugging(`[PushNotification] bridge delivery error: ${e}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no bridge available, push was not delivered to a remote device.
|
||||
logForDebugging(`[PushNotification] no bridge available, not delivered: ${input.title}`)
|
||||
return { data: { sent: false, error: 'No Remote Control bridge configured. Notification not delivered.' } }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -70,14 +70,51 @@ Guidelines:
|
||||
}
|
||||
},
|
||||
|
||||
async call(_input: SendUserFileInput) {
|
||||
// File transfer is handled by the KAIROS assistant transport layer.
|
||||
// Without the KAIROS runtime, this tool is not available.
|
||||
async call(input: SendUserFileInput, context) {
|
||||
const { file_path } = input
|
||||
const { stat } = await import('fs/promises')
|
||||
|
||||
// Verify file exists and is readable
|
||||
let fileSize: number
|
||||
try {
|
||||
const fileStat = await stat(file_path)
|
||||
if (!fileStat.isFile()) {
|
||||
return {
|
||||
data: { sent: false, file_path, error: 'Path is not a file.' },
|
||||
}
|
||||
}
|
||||
fileSize = fileStat.size
|
||||
} catch {
|
||||
return {
|
||||
data: { sent: false, file_path, error: 'File does not exist or is not readable.' },
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt bridge upload if available (so web viewers can download)
|
||||
const appState = context.getAppState()
|
||||
let fileUuid: string | undefined
|
||||
if (appState.replBridgeEnabled) {
|
||||
try {
|
||||
const { uploadBriefAttachment } = await import(
|
||||
'@claude-code-best/builtin-tools/tools/BriefTool/upload.js'
|
||||
)
|
||||
fileUuid = await uploadBriefAttachment(file_path, fileSize, {
|
||||
replBridgeEnabled: true,
|
||||
signal: context.abortController.signal,
|
||||
})
|
||||
} catch {
|
||||
// Best-effort upload — local path is always available
|
||||
}
|
||||
}
|
||||
|
||||
const delivered = !appState.replBridgeEnabled || Boolean(fileUuid)
|
||||
return {
|
||||
data: {
|
||||
sent: false,
|
||||
file_path: _input.file_path,
|
||||
error: 'SendUserFile requires the KAIROS assistant transport layer.',
|
||||
sent: delivered,
|
||||
file_path,
|
||||
size: fileSize,
|
||||
...(fileUuid ? { file_uuid: fileUuid } : {}),
|
||||
...(!delivered ? { error: 'Bridge upload failed. File available at local path.' } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
10
packages/remote-control-server/src/logger.ts
Normal file
10
packages/remote-control-server/src/logger.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** 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);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import { createBunWebSocket } from "hono/bun";
|
||||
import { validateApiKey } from "../../auth/api-key";
|
||||
@@ -30,14 +31,14 @@ function authenticateRequest(c: any, label: string, expectedSessionId?: string):
|
||||
const payload = verifyWorkerJwt(token);
|
||||
if (payload) {
|
||||
if (expectedSessionId && payload.session_id !== expectedSessionId) {
|
||||
console.log(`[Auth] ${label}: FAILED — JWT session_id mismatch`);
|
||||
log(`[Auth] ${label}: FAILED — JWT session_id mismatch`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Auth] ${label}: FAILED — no valid API key or JWT`);
|
||||
log(`[Auth] ${label}: FAILED — no valid API key or JWT`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ app.get(
|
||||
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
console.log(`[WS] Upgrade rejected: session ${sessionId} not found`);
|
||||
log(`[WS] Upgrade rejected: session ${sessionId} not found`);
|
||||
return {
|
||||
onOpen(_evt, ws) {
|
||||
ws.close(4001, "session not found");
|
||||
@@ -91,7 +92,7 @@ app.get(
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[WS] Upgrade accepted: session=${sessionId}`);
|
||||
log(`[WS] Upgrade accepted: session=${sessionId}`);
|
||||
return {
|
||||
onOpen(_evt, ws) {
|
||||
handleWebSocketOpen(ws as any, sessionId);
|
||||
@@ -108,7 +109,7 @@ app.get(
|
||||
handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason);
|
||||
},
|
||||
onError(evt, ws) {
|
||||
console.error(`[WS] Error on session=${sessionId}:`, evt);
|
||||
logError(`[WS] Error on session=${sessionId}:`, evt);
|
||||
handleWebSocketClose(ws as any, sessionId, 1006, "websocket error");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
createSession,
|
||||
@@ -22,7 +23,7 @@ app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
try {
|
||||
await createWorkItem(body.environment_id, session.id);
|
||||
} catch (err) {
|
||||
console.error(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||
logError(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import { uuidAuth } from "../../auth/middleware";
|
||||
import { getSession, updateSessionStatus } from "../../services/session";
|
||||
@@ -29,9 +30,9 @@ app.post("/sessions/:id/events", uuidAuth, async (c) => {
|
||||
|
||||
const body = await c.req.json();
|
||||
const eventType = body.type || "user";
|
||||
console.log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`);
|
||||
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");
|
||||
console.log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`);
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import { uuidAuth } from "../../auth/middleware";
|
||||
import { getSession, createSession } from "../../services/session";
|
||||
@@ -28,7 +29,7 @@ app.post("/sessions", uuidAuth, async (c) => {
|
||||
try {
|
||||
await createWorkItem(body.environment_id, session.id);
|
||||
} catch (err) {
|
||||
console.error(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||
logError(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { log, error as logError } from "../logger";
|
||||
import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store";
|
||||
import { storeListSessions, storeUpdateSession } from "../store";
|
||||
import { config } from "../config";
|
||||
@@ -12,7 +13,7 @@ export function startDisconnectMonitor() {
|
||||
const envs = storeListActiveEnvironments();
|
||||
for (const env of envs) {
|
||||
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
|
||||
console.log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
|
||||
log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
|
||||
storeUpdateEnvironment(env.id, { status: "disconnected" });
|
||||
}
|
||||
}
|
||||
@@ -23,7 +24,7 @@ export function startDisconnectMonitor() {
|
||||
if (session.status === "running" || session.status === "idle") {
|
||||
const elapsed = now - session.updatedAt.getTime();
|
||||
if (elapsed > timeoutMs * 2) {
|
||||
console.log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`);
|
||||
log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`);
|
||||
storeUpdateSession(session.id, { status: "inactive" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { log, error as logError } from "../logger";
|
||||
import {
|
||||
storeCreateWorkItem,
|
||||
storeGetWorkItem,
|
||||
@@ -35,7 +36,7 @@ export async function createWorkItem(environmentId: string, sessionId: string):
|
||||
|
||||
const secret = encodeWorkSecret();
|
||||
const record = storeCreateWorkItem({ environmentId, sessionId, secret });
|
||||
console.log(`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`);
|
||||
log(`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`);
|
||||
return record.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { log, error as logError } from "../logger";
|
||||
|
||||
export interface SessionEvent {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
@@ -33,12 +35,12 @@ export class EventBus {
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.events.push(full);
|
||||
console.log(`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`);
|
||||
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) {
|
||||
try {
|
||||
cb(full);
|
||||
} catch (err) {
|
||||
console.error(`[RC-DEBUG] bus subscriber error:`, err);
|
||||
logError(`[RC-DEBUG] bus subscriber error:`, err);
|
||||
}
|
||||
}
|
||||
return full;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { log, error as logError } from "../logger";
|
||||
import type { Context } from "hono";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { getEventBus } from "./event-bus";
|
||||
@@ -76,7 +77,7 @@ export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
|
||||
seqNum: event.seqNum,
|
||||
});
|
||||
try {
|
||||
console.log(`[RC-DEBUG] SSE -> web: sessionId=${sessionId} type=${event.type} dir=${event.direction} seq=${event.seqNum}`);
|
||||
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`));
|
||||
} catch {
|
||||
unsub();
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { WSContext } from "hono/ws";
|
||||
import { getEventBus } from "./event-bus";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { publishSessionEvent } from "../services/transport";
|
||||
import { log, error as logError } from "../logger";
|
||||
|
||||
// Per-connection cleanup, keyed by sessionId (only one WS per session)
|
||||
interface CleanupEntry {
|
||||
@@ -96,13 +97,13 @@ function toSDKMessage(event: SessionEvent): string {
|
||||
/** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */
|
||||
export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
||||
const openTime = Date.now();
|
||||
console.log(`[RC-DEBUG] [WS] Open session=${sessionId}`);
|
||||
log(`[RC-DEBUG] [WS] Open session=${sessionId}`);
|
||||
activeConnections.add(ws);
|
||||
|
||||
// If there's an existing connection for this session, clean it up first
|
||||
const existing = cleanupBySession.get(sessionId);
|
||||
if (existing) {
|
||||
console.log(`[WS] Replacing existing connection for session=${sessionId}`);
|
||||
log(`[WS] Replacing existing connection for session=${sessionId}`);
|
||||
existing.unsub();
|
||||
clearInterval(existing.keepalive);
|
||||
activeConnections.delete(existing.ws);
|
||||
@@ -114,7 +115,7 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
||||
// the full conversation history — assistant replies are inbound events.
|
||||
const missed = bus.getEventsSince(0);
|
||||
if (missed.length > 0) {
|
||||
console.log(`[WS] Replaying ${missed.length} missed event(s)`);
|
||||
log(`[WS] Replaying ${missed.length} missed event(s)`);
|
||||
for (const event of missed) {
|
||||
if (ws.readyState !== 1) break;
|
||||
try {
|
||||
@@ -130,10 +131,10 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
||||
if (event.direction !== "outbound") return;
|
||||
try {
|
||||
const sdkMsg = toSDKMessage(event);
|
||||
console.log(`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`);
|
||||
log(`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`);
|
||||
ws.send(sdkMsg);
|
||||
} catch (err) {
|
||||
console.error("[RC-DEBUG] [WS] send error:", err);
|
||||
logError("[RC-DEBUG] [WS] send error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -161,7 +162,7 @@ export function handleWebSocketMessage(ws: WSContext, sessionId: string, data: s
|
||||
try {
|
||||
ingestBridgeMessage(sessionId, JSON.parse(line));
|
||||
} catch (err) {
|
||||
console.error("[WS] parse error:", err);
|
||||
logError("[WS] parse error:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,7 +174,7 @@ export function handleWebSocketClose(ws: WSContext, sessionId: string, code?: nu
|
||||
const entry = cleanupBySession.get(sessionId);
|
||||
const duration = entry ? Math.round((Date.now() - entry.openTime) / 1000) : -1;
|
||||
|
||||
console.log(`[WS] Close session=${sessionId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
|
||||
log(`[WS] Close session=${sessionId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
|
||||
|
||||
if (entry) {
|
||||
entry.unsub();
|
||||
@@ -215,7 +216,7 @@ export function ingestBridgeMessage(sessionId: string, msg: Record<string, unkno
|
||||
|
||||
const eventType = deriveEventType(msg);
|
||||
|
||||
console.log(`[RC-DEBUG] [WS] <- bridge (inbound): sessionId=${sessionId} type=${eventType}${msg.uuid ? ` uuid=${msg.uuid}` : ""} msg=${JSON.stringify(msg).slice(0, 300)}`);
|
||||
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;
|
||||
|
||||
@@ -255,7 +256,7 @@ export function closeAllConnections(): void {
|
||||
const count = activeConnections.size;
|
||||
if (count === 0) return;
|
||||
|
||||
console.log(`[WS] Gracefully closing ${count} active connection(s)...`);
|
||||
log(`[WS] Gracefully closing ${count} active connection(s)...`);
|
||||
for (const [sessionId, entry] of cleanupBySession) {
|
||||
try {
|
||||
entry.unsub();
|
||||
@@ -269,5 +270,5 @@ export function closeAllConnections(): void {
|
||||
}
|
||||
cleanupBySession.clear();
|
||||
activeConnections.clear();
|
||||
console.log("[WS] All connections closed");
|
||||
log("[WS] All connections closed");
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ const DEFAULT_FEATURES = [
|
||||
"KAIROS",
|
||||
"COORDINATOR_MODE",
|
||||
"LAN_PIPES",
|
||||
"BG_SESSIONS",
|
||||
"TEMPLATES",
|
||||
// "REVIEW_ARTIFACT", // API 请求无响应,需进一步排查 schema 兼容性
|
||||
// P3: poor mode (disable extract_memories + prompt_suggestion)
|
||||
"POOR",
|
||||
|
||||
@@ -41,7 +41,11 @@ import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.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 type { APIError } from '@anthropic-ai/sdk'
|
||||
import type { CompactMetadata, Message, SystemCompactBoundaryMessage } from './types/message.js'
|
||||
import type {
|
||||
CompactMetadata,
|
||||
Message,
|
||||
SystemCompactBoundaryMessage,
|
||||
} from './types/message.js'
|
||||
import type { OrphanedPermission } from './types/textInputTypes.js'
|
||||
import { createAbortController } from './utils/abortController.js'
|
||||
import type { AttributionState } from './utils/commitAttribution.js'
|
||||
@@ -708,7 +712,8 @@ export class QueryEngine {
|
||||
message.subtype === 'compact_boundary'
|
||||
) {
|
||||
const compactMsg = message as SystemCompactBoundaryMessage
|
||||
const tailUuid = compactMsg.compactMetadata?.preservedSegment?.tailUuid
|
||||
const tailUuid =
|
||||
compactMsg.compactMetadata?.preservedSegment?.tailUuid
|
||||
if (tailUuid) {
|
||||
const tailIdx = this.mutableMessages.findLastIndex(
|
||||
m => m.uuid === tailUuid,
|
||||
@@ -768,7 +773,10 @@ export class QueryEngine {
|
||||
// streamed responses, this is null at content_block_stop time;
|
||||
// the real value arrives via message_delta (handled below).
|
||||
const msg = message as Message
|
||||
const stopReason = msg.message?.stop_reason as string | null | undefined
|
||||
const stopReason = msg.message?.stop_reason as
|
||||
| string
|
||||
| null
|
||||
| undefined
|
||||
if (stopReason != null) {
|
||||
lastStopReason = stopReason
|
||||
}
|
||||
@@ -798,11 +806,15 @@ export class QueryEngine {
|
||||
break
|
||||
}
|
||||
case 'stream_event': {
|
||||
const event = (message as unknown as { event: Record<string, unknown> }).event
|
||||
const event = (
|
||||
message as unknown as { event: Record<string, unknown> }
|
||||
).event
|
||||
if (event.type === 'message_start') {
|
||||
// Reset current message usage for new message
|
||||
currentMessageUsage = EMPTY_USAGE
|
||||
const eventMessage = event.message as { usage: BetaMessageDeltaUsage }
|
||||
const eventMessage = event.message as {
|
||||
usage: BetaMessageDeltaUsage
|
||||
}
|
||||
currentMessageUsage = updateUsage(
|
||||
currentMessageUsage,
|
||||
eventMessage.usage,
|
||||
@@ -851,7 +863,15 @@ export class QueryEngine {
|
||||
void recordTranscript(messages)
|
||||
}
|
||||
|
||||
const attachment = msg.attachment as { type: string; data?: unknown; turnCount?: number; maxTurns?: number; prompt?: string; source_uuid?: string; [key: string]: unknown }
|
||||
const attachment = msg.attachment as {
|
||||
type: string
|
||||
data?: unknown
|
||||
turnCount?: number
|
||||
maxTurns?: number
|
||||
prompt?: string
|
||||
source_uuid?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// Extract structured output from StructuredOutput tool calls
|
||||
if (attachment.type === 'structured_output') {
|
||||
@@ -892,10 +912,7 @@ export class QueryEngine {
|
||||
return
|
||||
}
|
||||
// Yield queued_command attachments as SDK user message replays
|
||||
else if (
|
||||
replayUserMessages &&
|
||||
attachment.type === 'queued_command'
|
||||
) {
|
||||
else if (replayUserMessages && attachment.type === 'queued_command') {
|
||||
yield {
|
||||
type: 'user',
|
||||
message: {
|
||||
@@ -923,10 +940,7 @@ export class QueryEngine {
|
||||
// never shrinks (memory leak in long SDK sessions). The subtype
|
||||
// check lives inside the injected callback so feature-gated strings
|
||||
// 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.executed) {
|
||||
this.mutableMessages.length = 0
|
||||
@@ -936,10 +950,7 @@ export class QueryEngine {
|
||||
}
|
||||
this.mutableMessages.push(msg)
|
||||
// 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
|
||||
// Release pre-compaction messages for GC. The boundary was just
|
||||
// pushed so it's the last element. query.ts already uses
|
||||
@@ -959,11 +970,18 @@ export class QueryEngine {
|
||||
subtype: 'compact_boundary' as const,
|
||||
session_id: getSessionId(),
|
||||
uuid: msg.uuid,
|
||||
compact_metadata: toSDKCompactMetadata(compactMsg.compactMetadata),
|
||||
compact_metadata: toSDKCompactMetadata(
|
||||
compactMsg.compactMetadata,
|
||||
),
|
||||
}
|
||||
}
|
||||
if (msg.subtype === 'api_error') {
|
||||
const apiErrorMsg = msg as Message & { retryAttempt: number; maxRetries: number; retryInMs: number; error: APIError }
|
||||
const apiErrorMsg = msg as Message & {
|
||||
retryAttempt: number
|
||||
maxRetries: number
|
||||
retryInMs: number
|
||||
error: APIError
|
||||
}
|
||||
yield {
|
||||
type: 'system',
|
||||
subtype: 'api_retry' as const,
|
||||
@@ -980,7 +998,10 @@ export class QueryEngine {
|
||||
break
|
||||
}
|
||||
case 'tool_use_summary': {
|
||||
const msg = message as Message & { summary: unknown; precedingToolUseIds: unknown }
|
||||
const msg = message as Message & {
|
||||
summary: unknown
|
||||
precedingToolUseIds: unknown
|
||||
}
|
||||
// Yield tool use summary messages to SDK
|
||||
yield {
|
||||
type: 'tool_use_summary' as const,
|
||||
@@ -1089,7 +1110,10 @@ export class QueryEngine {
|
||||
const edeResultType = result?.type ?? 'undefined'
|
||||
const edeLastContentType =
|
||||
result?.type === 'assistant'
|
||||
? (last(result.message!.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[])?.type ?? 'none')
|
||||
? (last(
|
||||
result.message!
|
||||
.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[],
|
||||
)?.type ?? 'none')
|
||||
: 'n/a'
|
||||
|
||||
// Flush buffered transcript writes before yielding result.
|
||||
@@ -1147,7 +1171,10 @@ export class QueryEngine {
|
||||
let isApiError = false
|
||||
|
||||
if (result.type === 'assistant') {
|
||||
const lastContent = last(result.message!.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[])
|
||||
const lastContent = last(
|
||||
result.message!
|
||||
.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[],
|
||||
)
|
||||
if (
|
||||
lastContent?.type === 'text' &&
|
||||
!SYNTHETIC_MESSAGES.has(lastContent.text)
|
||||
|
||||
95
src/__tests__/context.baseline.test.ts
Normal file
95
src/__tests__/context.baseline.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
resetStateForTests,
|
||||
setOriginalCwd,
|
||||
setProjectRoot,
|
||||
} from '../bootstrap/state'
|
||||
import {
|
||||
getSystemContext,
|
||||
getUserContext,
|
||||
setSystemPromptInjection,
|
||||
} from '../context'
|
||||
import { clearMemoryFileCaches } from '../utils/claudemd'
|
||||
import {
|
||||
cleanupTempDir,
|
||||
createTempDir,
|
||||
writeTempFile,
|
||||
} from '../../tests/mocks/file-system'
|
||||
|
||||
let tempDir = ''
|
||||
let projectClaudeMdContent = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir('context-baseline-')
|
||||
projectClaudeMdContent = `baseline-${Date.now()}`
|
||||
|
||||
resetStateForTests()
|
||||
setOriginalCwd(tempDir)
|
||||
setProjectRoot(tempDir)
|
||||
await writeTempFile(tempDir, 'CLAUDE.md', projectClaudeMdContent)
|
||||
|
||||
clearMemoryFileCaches()
|
||||
getUserContext.cache.clear?.()
|
||||
getSystemContext.cache.clear?.()
|
||||
setSystemPromptInjection(null)
|
||||
delete process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearMemoryFileCaches()
|
||||
getUserContext.cache.clear?.()
|
||||
getSystemContext.cache.clear?.()
|
||||
setSystemPromptInjection(null)
|
||||
delete process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS
|
||||
resetStateForTests()
|
||||
if (tempDir) {
|
||||
await cleanupTempDir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
describe('context baseline', () => {
|
||||
test('getUserContext includes currentDate and project CLAUDE.md content', async () => {
|
||||
const ctx = await getUserContext()
|
||||
|
||||
expect(ctx.currentDate).toContain("Today's date is")
|
||||
expect(ctx.claudeMd).toContain(projectClaudeMdContent)
|
||||
})
|
||||
|
||||
test('CLAUDE_CODE_DISABLE_CLAUDE_MDS suppresses claudeMd loading', async () => {
|
||||
process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = '1'
|
||||
|
||||
const ctx = await getUserContext()
|
||||
|
||||
expect(ctx.currentDate).toContain("Today's date is")
|
||||
expect(ctx.claudeMd).toBeUndefined()
|
||||
})
|
||||
|
||||
test('setSystemPromptInjection clears the memoized user-context cache', async () => {
|
||||
const first = await getUserContext()
|
||||
process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = '1'
|
||||
|
||||
const second = await getUserContext()
|
||||
expect(first.claudeMd).toContain(projectClaudeMdContent)
|
||||
expect(second.claudeMd).toContain(projectClaudeMdContent)
|
||||
|
||||
setSystemPromptInjection('cache-break')
|
||||
|
||||
const third = await getUserContext()
|
||||
expect(third.claudeMd).toBeUndefined()
|
||||
})
|
||||
|
||||
test('getSystemContext reflects system prompt injection after cache invalidation', async () => {
|
||||
const first = await getSystemContext()
|
||||
expect(first.gitStatus).toBeUndefined()
|
||||
expect(first.cacheBreaker).toBeUndefined()
|
||||
|
||||
setSystemPromptInjection('baseline-cache-break')
|
||||
|
||||
const second = await getSystemContext()
|
||||
if ('cacheBreaker' in second) {
|
||||
expect(second.cacheBreaker).toContain('baseline-cache-break')
|
||||
} else {
|
||||
expect(second.gitStatus).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const AssistantSessionChooser: (props: Record<string, unknown>) => null = () => null;
|
||||
54
src/assistant/AssistantSessionChooser.tsx
Normal file
54
src/assistant/AssistantSessionChooser.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { Dialog } from '../components/design-system/Dialog.js';
|
||||
import { ListItem } from '../components/design-system/ListItem.js';
|
||||
import { useRegisterOverlay } from '../context/overlayContext.js';
|
||||
import { useKeybindings } from '../keybindings/useKeybinding.js';
|
||||
import type { AssistantSession } from './sessionDiscovery.js';
|
||||
|
||||
interface Props {
|
||||
sessions: AssistantSession[];
|
||||
onSelect: (id: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive session chooser for `claude assistant` when multiple
|
||||
* CCR sessions are discovered. Renders a Dialog with up/down navigation.
|
||||
*
|
||||
* Session IDs are in `session_*` compat format — passed directly to
|
||||
* createRemoteSessionConfig() for viewer attach.
|
||||
*/
|
||||
export function AssistantSessionChooser({ sessions, onSelect, onCancel }: Props): React.ReactNode {
|
||||
useRegisterOverlay('assistant-session-chooser');
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
|
||||
useKeybindings(
|
||||
{
|
||||
'select:next': () => setFocusIndex(i => (i + 1) % sessions.length),
|
||||
'select:previous': () => setFocusIndex(i => (i - 1 + sessions.length) % sessions.length),
|
||||
'select:accept': () => onSelect(sessions[focusIndex]!.id),
|
||||
},
|
||||
{ context: 'Select' },
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog title="Select Assistant Session" onCancel={onCancel} hideInputGuide>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>Multiple sessions found. Select one to attach:</Text>
|
||||
<Box flexDirection="column">
|
||||
{sessions.map((s, i) => (
|
||||
<ListItem key={s.id} isFocused={focusIndex === i}>
|
||||
<Box>
|
||||
<Text>{s.title || s.id.slice(0, 20)}</Text>
|
||||
<Text dimColor> [{s.status}]</Text>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</Box>
|
||||
<Text dimColor>↑↓ navigate · Enter select · Esc cancel</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,21 +5,20 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growt
|
||||
/**
|
||||
* Runtime gate for KAIROS features.
|
||||
*
|
||||
* Build-time: feature('KAIROS') must be on (checked by caller before
|
||||
* this module is required).
|
||||
* Two-layer gate:
|
||||
* 1. Build-time: feature('KAIROS') must be on
|
||||
* 2. Runtime: tengu_kairos_assistant GrowthBook flag (remote kill switch)
|
||||
*
|
||||
* Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill
|
||||
* switch, and kairosActive state must be true (set during bootstrap when
|
||||
* the session qualifies for KAIROS features).
|
||||
* Called by main.tsx BEFORE setKairosActive(true) — must NOT check
|
||||
* kairosActive (that would deadlock: gate needs active, active needs gate).
|
||||
* The caller (main.tsx L1826-1832) sets kairosActive after this returns true.
|
||||
*/
|
||||
export async function isKairosEnabled(): Promise<boolean> {
|
||||
if (!feature('KAIROS')) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)
|
||||
) {
|
||||
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)) {
|
||||
return false
|
||||
}
|
||||
return getKairosActive()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,9 +1,64 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {}
|
||||
export const isAssistantMode: () => boolean = () => false
|
||||
export const initializeAssistantTeam: () => Promise<void> = async () => {}
|
||||
export const markAssistantForced: () => void = () => {}
|
||||
export const isAssistantForced: () => boolean = () => false
|
||||
export const getAssistantSystemPromptAddendum: () => string = () => ''
|
||||
export const getAssistantActivationPath: () => string | undefined = () =>
|
||||
undefined
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { getKairosActive } from '../bootstrap/state.js'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
|
||||
let _assistantForced = false
|
||||
|
||||
/**
|
||||
* Whether the current session is in assistant (KAIROS) daemon mode.
|
||||
* Wraps the bootstrap kairosActive state set by main.tsx after gate check.
|
||||
*/
|
||||
export function isAssistantMode(): boolean {
|
||||
return getKairosActive()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this session as forced assistant mode (--assistant flag).
|
||||
* Skips the GrowthBook gate check — daemon is pre-entitled.
|
||||
*/
|
||||
export function markAssistantForced(): void {
|
||||
_assistantForced = true
|
||||
}
|
||||
|
||||
export function isAssistantForced(): boolean {
|
||||
return _assistantForced
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-create an in-process team so Agent(name) can spawn teammates
|
||||
* without TeamCreate.
|
||||
*
|
||||
* Phase 1: returns undefined so main.tsx's `assistantTeamContext ?? computeInitialTeamContext()`
|
||||
* correctly falls back. Returning {} would bypass the ?? operator since {} is truthy.
|
||||
*
|
||||
* Phase 2: should return a full team context object matching AppState.teamContext shape.
|
||||
*/
|
||||
export async function initializeAssistantTeam(): Promise<undefined> {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Assistant-specific system prompt addendum loaded from ~/.claude/agents/assistant.md.
|
||||
* Returns empty string if the file doesn't exist.
|
||||
*/
|
||||
export function getAssistantSystemPromptAddendum(): string {
|
||||
try {
|
||||
return readFileSync(
|
||||
join(getClaudeConfigHomeDir(), 'agents', 'assistant.md'),
|
||||
'utf-8',
|
||||
)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How assistant mode was activated. Used for diagnostics/analytics.
|
||||
* - 'daemon': via --assistant flag (Agent SDK daemon)
|
||||
* - 'gate': via GrowthBook gate check
|
||||
*/
|
||||
export function getAssistantActivationPath(): string | undefined {
|
||||
if (!isAssistantMode()) return undefined
|
||||
return _assistantForced ? 'daemon' : 'gate'
|
||||
}
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type AssistantSession = { id: string; [key: string]: unknown };
|
||||
export const discoverAssistantSessions: () => Promise<AssistantSession[]> = () => Promise.resolve([]);
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
|
||||
/**
|
||||
* Minimal session type for assistant discovery.
|
||||
* Only `id` is consumed by main.tsx (L4757); other fields are for chooser display.
|
||||
* ID format is `session_*` (compat prefix) — viewer endpoints use /v1/sessions/*.
|
||||
*/
|
||||
export type AssistantSession = {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover assistant sessions on Anthropic CCR.
|
||||
*
|
||||
* Reuses the existing fetchCodeSessionsFromSessionsAPI() which calls
|
||||
* GET /v1/sessions with proper OAuth + anthropic-beta headers.
|
||||
*
|
||||
* Throws on failure — main.tsx L4720-4725 catch displays the error.
|
||||
* Does NOT return [] on error (that would silently redirect to install wizard).
|
||||
*/
|
||||
export async function discoverAssistantSessions(): Promise<AssistantSession[]> {
|
||||
const { fetchCodeSessionsFromSessionsAPI } = await import(
|
||||
'../utils/teleport/api.js'
|
||||
)
|
||||
|
||||
let allSessions
|
||||
try {
|
||||
allSessions = await fetchCodeSessionsFromSessionsAPI()
|
||||
} catch (err) {
|
||||
logForDebugging(
|
||||
`[assistant:discovery] fetchCodeSessionsFromSessionsAPI failed: ${err}`,
|
||||
)
|
||||
throw err
|
||||
}
|
||||
|
||||
// Filter to active/working sessions only — completed/archived are not attachable
|
||||
return allSessions
|
||||
.filter(
|
||||
s =>
|
||||
s.status === 'idle' || s.status === 'working' || s.status === 'waiting',
|
||||
)
|
||||
.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title || 'Untitled',
|
||||
status: s.status,
|
||||
created_at: s.created_at ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type HookEvent = any;
|
||||
export type ModelUsage = any;
|
||||
export type HookEvent = any
|
||||
export type ModelUsage = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type AgentColorName = any;
|
||||
export type AgentColorName = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type HookCallbackMatcher = any;
|
||||
export type HookCallbackMatcher = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SessionId = any;
|
||||
export type SessionId = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type randomUUID = any;
|
||||
export type randomUUID = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ModelSetting = any;
|
||||
export type ModelSetting = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ModelStrings = any;
|
||||
export type ModelStrings = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SettingSource = any;
|
||||
export type SettingSource = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type resetSettingsCache = any;
|
||||
export type resetSettingsCache = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type PluginHookMatcher = any;
|
||||
export type PluginHookMatcher = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type createSignal = any;
|
||||
export type createSignal = any
|
||||
|
||||
@@ -1755,4 +1755,6 @@ export function getPromptId(): string | null {
|
||||
export function setPromptId(id: string | null): void {
|
||||
STATE.promptId = id
|
||||
}
|
||||
export function isReplBridgeActive(): boolean { return false; }
|
||||
export function isReplBridgeActive(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -225,7 +225,9 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
||||
)
|
||||
|
||||
handleErrorStatus(response.status, response.data, 'Poll')
|
||||
rcLog(`poll response: status=${response.status} hasData=${!!response.data} url=${deps.baseUrl}`)
|
||||
rcLog(
|
||||
`poll response: status=${response.status} hasData=${!!response.data} url=${deps.baseUrl}`,
|
||||
)
|
||||
|
||||
// Empty body or null = no work available
|
||||
if (!response.data) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
logEventAsync,
|
||||
} from '../services/analytics/index.js'
|
||||
import { isInBundledMode } from '../utils/bundledMode.js'
|
||||
import { getBootstrapArgs, getScriptPath } from '../utils/cliLaunch.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { rcLog } from './rcDebugLog.js'
|
||||
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
||||
@@ -111,17 +112,15 @@ function pollSleepDetectionThresholdMs(backoff: BackoffConfig): number {
|
||||
|
||||
/**
|
||||
* Returns the args that must precede CLI flags when spawning a child claude
|
||||
* process. In compiled binaries, process.execPath is the claude binary itself
|
||||
* and args go directly to it. In npm installs (node running cli.js),
|
||||
* 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.
|
||||
* process. Delegates to the centralized cliLaunch module which handles
|
||||
* bundled-vs-script mode, execArgv sanitization, and the Bun execArgv leak
|
||||
* quirk. See anthropics/claude-code#28334.
|
||||
*/
|
||||
function spawnScriptArgs(): string[] {
|
||||
if (isInBundledMode() || !process.argv[1]) {
|
||||
return []
|
||||
}
|
||||
return [process.argv[1]]
|
||||
const bootstrap = [...getBootstrapArgs()]
|
||||
const script = getScriptPath()
|
||||
if (script) bootstrap.push(script)
|
||||
return bootstrap
|
||||
}
|
||||
|
||||
/** Attempt to spawn a session; returns error string if spawn throws. */
|
||||
@@ -448,9 +447,11 @@ export async function runBridgeLoop(
|
||||
): (status: SessionDoneStatus) => void {
|
||||
return (rawStatus: SessionDoneStatus): void => {
|
||||
const workId = sessionWorkIds.get(sessionId)
|
||||
rcLog(`session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` +
|
||||
` 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)'}`)
|
||||
rcLog(
|
||||
`session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` +
|
||||
` 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)'}`,
|
||||
)
|
||||
activeSessions.delete(sessionId)
|
||||
sessionStartTimes.delete(sessionId)
|
||||
sessionWorkIds.delete(sessionId)
|
||||
@@ -609,7 +610,9 @@ export async function runBridgeLoop(
|
||||
const pollConfig = getPollIntervalConfig()
|
||||
|
||||
try {
|
||||
rcLog(`poll: envId=${environmentId} activeSessions=${activeSessions.size}`)
|
||||
rcLog(
|
||||
`poll: envId=${environmentId} activeSessions=${activeSessions.size}`,
|
||||
)
|
||||
const work = await api.pollForWork(
|
||||
environmentId,
|
||||
environmentSecret,
|
||||
@@ -864,7 +867,9 @@ export async function runBridgeLoop(
|
||||
break
|
||||
case 'session': {
|
||||
const sessionId = work.data.id
|
||||
rcLog(`work received: type=session sessionId=${sessionId} workId=${work.id}`)
|
||||
rcLog(
|
||||
`work received: type=session sessionId=${sessionId} workId=${work.id}`,
|
||||
)
|
||||
try {
|
||||
validateBridgeId(sessionId, 'session_id')
|
||||
} catch {
|
||||
@@ -1032,9 +1037,9 @@ export async function runBridgeLoop(
|
||||
|
||||
rcLog(
|
||||
`spawning session: sessionId=${sessionId} sdkUrl=${sdkUrl}` +
|
||||
` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` +
|
||||
` dir=${sessionDir}` +
|
||||
` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`,
|
||||
` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` +
|
||||
` dir=${sessionDir}` +
|
||||
` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`,
|
||||
)
|
||||
const spawnResult = safeSpawn(
|
||||
spawner,
|
||||
@@ -1281,8 +1286,8 @@ export async function runBridgeLoop(
|
||||
const errMsg = describeAxiosError(err)
|
||||
rcLog(
|
||||
`poll error: ${errMsg}` +
|
||||
` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` +
|
||||
` activeSessions=${activeSessions.size}`,
|
||||
` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` +
|
||||
` activeSessions=${activeSessions.size}`,
|
||||
)
|
||||
|
||||
if (isConnectionError(err) || isServerError(err)) {
|
||||
@@ -1676,7 +1681,7 @@ async function stopWorkWithRetry(
|
||||
}
|
||||
const errMsg = errorMessage(err)
|
||||
if (attempt < MAX_ATTEMPTS) {
|
||||
const delay = addJitter(baseDelayMs * Math.pow(2, attempt - 1))
|
||||
const delay = addJitter(baseDelayMs * 2 ** (attempt - 1))
|
||||
logger.logVerbose(
|
||||
`Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`,
|
||||
)
|
||||
@@ -1964,7 +1969,6 @@ NOTES
|
||||
- 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
|
||||
${serverNote}`
|
||||
// biome-ignore lint/suspicious/noConsole: intentional help output
|
||||
console.log(help)
|
||||
}
|
||||
|
||||
@@ -2003,7 +2007,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
return
|
||||
}
|
||||
if (parsed.error) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(`Error: ${parsed.error}`)
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(1)
|
||||
@@ -2042,7 +2045,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
const { PERMISSION_MODES } = await import('../types/permissions.js')
|
||||
const valid: readonly string[] = PERMISSION_MODES
|
||||
if (!valid.includes(permissionMode)) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
`Error: Invalid permission mode '${permissionMode}'. Valid modes: ${valid.join(', ')}`,
|
||||
)
|
||||
@@ -2085,7 +2087,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
Promise.all([shutdown1PEventLogging(), shutdownDatadog()]),
|
||||
sleep(500, undefined, { unref: true }),
|
||||
]).catch(() => {})
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
'Error: Multi-session Remote Control is not enabled for your account yet.',
|
||||
)
|
||||
@@ -2102,7 +2103,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
// 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.
|
||||
if (!checkHasTrustDialogAccepted()) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`Error: Workspace not trusted. Please run \`claude\` in ${dir} first to review and accept the workspace trust dialog.`,
|
||||
)
|
||||
@@ -2119,7 +2119,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
|
||||
const bridgeToken = getBridgeAccessToken()
|
||||
if (!bridgeToken) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(BRIDGE_LOGIN_ERROR)
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(1)
|
||||
@@ -2138,7 +2137,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
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',
|
||||
)
|
||||
@@ -2170,7 +2168,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
)
|
||||
const found = await readBridgePointerAcrossWorktrees(dir)
|
||||
if (!found) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
`Error: No recent session found in this directory or its worktrees. Run \`claude remote-control\` to start a new one.`,
|
||||
)
|
||||
@@ -2181,7 +2178,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
const ageMin = Math.round(pointer.ageMs / 60_000)
|
||||
const ageStr = ageMin < 60 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h`
|
||||
const fromWt = pointerDir !== dir ? ` from worktree ${pointerDir}` : ''
|
||||
// biome-ignore lint/suspicious/noConsole: intentional info output
|
||||
console.error(
|
||||
`Resuming session ${pointer.sessionId} (${ageStr} ago)${fromWt}\u2026`,
|
||||
)
|
||||
@@ -2202,7 +2198,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
!baseUrl.includes('localhost') &&
|
||||
!baseUrl.includes('127.0.0.1')
|
||||
) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
'Error: Remote Control base URL uses HTTP. Only HTTPS or localhost HTTP is allowed.',
|
||||
)
|
||||
@@ -2238,7 +2233,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
? getCurrentProjectConfig().remoteControlSpawnMode
|
||||
: undefined
|
||||
if (savedSpawnMode === 'worktree' && !worktreeAvailable) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional warning output
|
||||
console.error(
|
||||
'Warning: Saved spawn mode is worktree but this directory is not a git repository. Falling back to same-dir.',
|
||||
)
|
||||
@@ -2265,7 +2259,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
// biome-ignore lint/suspicious/noConsole: intentional dialog output
|
||||
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` +
|
||||
`Spawn mode for this project:\n` +
|
||||
@@ -2344,7 +2337,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
// Only reachable via explicit --spawn=worktree (default is same-dir);
|
||||
// saved worktree pref was already guarded above.
|
||||
if (spawnMode === 'worktree' && !worktreeAvailable) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
`Error: Worktree mode requires a git repository or WorktreeCreate hooks configured. Use --spawn=session for single-session mode.`,
|
||||
)
|
||||
@@ -2379,7 +2371,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
try {
|
||||
validateBridgeId(resumeSessionId, 'sessionId')
|
||||
} catch {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
`Error: Invalid session ID "${resumeSessionId}". Session IDs must not contain unsafe characters.`,
|
||||
)
|
||||
@@ -2405,7 +2396,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
const { clearBridgePointer } = await import('./bridgePointer.js')
|
||||
await clearBridgePointer(resumePointerDir)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
`Error: Session ${resumeSessionId} not found. It may have been archived or expired, or your login may have lapsed (run \`claude /login\`).`,
|
||||
)
|
||||
@@ -2417,7 +2407,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
const { clearBridgePointer } = await import('./bridgePointer.js')
|
||||
await clearBridgePointer(resumePointerDir)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
`Error: Session ${resumeSessionId} has no environment_id. It may never have been attached to a bridge.`,
|
||||
)
|
||||
@@ -2471,7 +2460,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
status: err instanceof BridgeFatalError ? err.status : undefined,
|
||||
})
|
||||
// Registration failures are fatal — print a clean message instead of a stack trace.
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
err instanceof BridgeFatalError && err.status === 404
|
||||
? 'Remote Control environments are not available for your account.'
|
||||
@@ -2496,7 +2484,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
`Bridge resume env mismatch: requested ${reuseEnvironmentId}, backend returned ${environmentId}. Falling back to fresh session.`,
|
||||
),
|
||||
)
|
||||
// biome-ignore lint/suspicious/noConsole: intentional warning output
|
||||
console.warn(
|
||||
`Warning: Could not resume session ${resumeSessionId} — its environment has expired. Creating a fresh session instead.`,
|
||||
)
|
||||
@@ -2547,7 +2534,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
const { clearBridgePointer } = await import('./bridgePointer.js')
|
||||
await clearBridgePointer(resumePointerDir)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
console.error(
|
||||
isFatal
|
||||
? `Error: ${errorMessage(err)}`
|
||||
|
||||
@@ -104,7 +104,8 @@ export function isEligibleBridgeMessage(m: Message): boolean {
|
||||
export function extractTitleText(m: Message): string | undefined {
|
||||
if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary)
|
||||
return undefined
|
||||
if (m.origin && (m.origin as { kind?: string }).kind !== 'human') return undefined
|
||||
if (m.origin && (m.origin as { kind?: string }).kind !== 'human')
|
||||
return undefined
|
||||
const content = m.message!.content
|
||||
let raw: string | undefined
|
||||
if (typeof content === 'string') {
|
||||
@@ -266,7 +267,13 @@ export function handleServerControlRequest(
|
||||
// Outbound-only: reply error for mutable requests so claude.ai doesn't show
|
||||
// false success. initialize must still succeed (server kills the connection
|
||||
// if it doesn't — see comment above).
|
||||
const req = request.request as { subtype: string; model?: string; max_thinking_tokens?: number | null; mode?: string; [key: string]: unknown }
|
||||
const req = request.request as {
|
||||
subtype: string
|
||||
model?: string
|
||||
max_thinking_tokens?: number | null
|
||||
mode?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
if (outboundOnly && req.subtype !== 'initialize') {
|
||||
response = {
|
||||
type: 'control_response',
|
||||
@@ -389,8 +396,8 @@ export function handleServerControlRequest(
|
||||
void transport.write(event)
|
||||
rcLog(
|
||||
`control_response: subtype=${req.subtype}` +
|
||||
` request_id=${request.request_id}` +
|
||||
` result=${(response.response as { subtype?: string }).subtype}`,
|
||||
` request_id=${request.request_id}` +
|
||||
` result=${(response.response as { subtype?: string }).subtype}`,
|
||||
)
|
||||
logForDebugging(
|
||||
`[bridge:repl] Sent control_response for ${req.subtype} request_id=${request.request_id} result=${(response.response as { subtype?: string }).subtype}`,
|
||||
|
||||
@@ -24,7 +24,9 @@ export function extractInboundMessageFields(
|
||||
| { content: string | Array<ContentBlockParam>; uuid: UUID | undefined }
|
||||
| undefined {
|
||||
if (msg.type !== 'user') return undefined
|
||||
const content = (msg.message as { content?: string | Array<ContentBlockParam> } | undefined)?.content
|
||||
const content = (
|
||||
msg.message as { content?: string | Array<ContentBlockParam> } | undefined
|
||||
)?.content
|
||||
if (!content) return undefined
|
||||
if (Array.isArray(content) && content.length === 0) return undefined
|
||||
|
||||
|
||||
@@ -290,7 +290,9 @@ export async function initReplBridge(
|
||||
isSyntheticMessage(msg)
|
||||
)
|
||||
continue
|
||||
const rawContent = getContentText(msg.message!.content as string | ContentBlockParam[])
|
||||
const rawContent = getContentText(
|
||||
msg.message!.content as string | ContentBlockParam[],
|
||||
)
|
||||
if (!rawContent) continue
|
||||
const derived = deriveTitle(rawContent)
|
||||
if (!derived) continue
|
||||
|
||||
@@ -20,7 +20,10 @@ export function rcLog(msg: string): void {
|
||||
try {
|
||||
if (!headerWritten) {
|
||||
ensureLogDir()
|
||||
appendFileSync(LOG_PATH, `\n===== RC-DEBUG session ${new Date().toISOString()} =====\n`)
|
||||
appendFileSync(
|
||||
LOG_PATH,
|
||||
`\n===== RC-DEBUG session ${new Date().toISOString()} =====\n`,
|
||||
)
|
||||
headerWritten = true
|
||||
}
|
||||
const ts = new Date().toISOString().slice(11, 23) // HH:mm:ss.SSS
|
||||
|
||||
@@ -834,7 +834,10 @@ export async function initEnvLessBridgeCore(
|
||||
for (const msg of filtered) {
|
||||
if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string)
|
||||
}
|
||||
const events = filtered.map(m => ({ ...m, session_id: sessionId })) as StdoutMessage[]
|
||||
const events = filtered.map(m => ({
|
||||
...m,
|
||||
session_id: sessionId,
|
||||
})) as StdoutMessage[]
|
||||
void transport.writeBatch(events)
|
||||
},
|
||||
sendControlRequest(request: SDKControlRequest) {
|
||||
@@ -844,8 +847,14 @@ export async function initEnvLessBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event: TransportMessage = { ...request, session_id: sessionId } as TransportMessage
|
||||
if ((request as { request?: { subtype?: string } }).request?.subtype === 'can_use_tool') {
|
||||
const event: TransportMessage = {
|
||||
...request,
|
||||
session_id: sessionId,
|
||||
} as TransportMessage
|
||||
if (
|
||||
(request as { request?: { subtype?: string } }).request?.subtype ===
|
||||
'can_use_tool'
|
||||
) {
|
||||
transport.reportState('requires_action')
|
||||
}
|
||||
void transport.write(event as StdoutMessage)
|
||||
@@ -860,7 +869,10 @@ export async function initEnvLessBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event: TransportMessage = { ...response, session_id: sessionId } as TransportMessage
|
||||
const event: TransportMessage = {
|
||||
...response,
|
||||
session_id: sessionId,
|
||||
} as TransportMessage
|
||||
transport.reportState('running')
|
||||
void transport.write(event as StdoutMessage)
|
||||
logForDebugging('[remote-bridge] Sent control_response')
|
||||
|
||||
@@ -452,7 +452,6 @@ export async function initBridgeCore(
|
||||
// re-created after a connection loss.
|
||||
let currentSessionId: string
|
||||
|
||||
|
||||
if (reusedPriorSession && prior) {
|
||||
currentSessionId = prior.sessionId
|
||||
logForDebugging(
|
||||
@@ -632,9 +631,9 @@ export async function initBridgeCore(
|
||||
environmentRecreations++
|
||||
rcLog(
|
||||
`doReconnect: attempt=${environmentRecreations}/${MAX_ENVIRONMENT_RECREATIONS}` +
|
||||
` envId=${environmentId}` +
|
||||
` sessionId=${currentSessionId}` +
|
||||
` workId=${currentWorkId}`,
|
||||
` envId=${environmentId}` +
|
||||
` sessionId=${currentSessionId}` +
|
||||
` workId=${currentWorkId}`,
|
||||
)
|
||||
// Invalidate any in-flight v2 handshake — the environment is being
|
||||
// recreated, so a stale transport arriving post-reconnect would be
|
||||
@@ -846,7 +845,6 @@ export async function initBridgeCore(
|
||||
// UUIDs are scoped per-session on the server, so re-flushing is safe.
|
||||
previouslyFlushedUUIDs?.clear()
|
||||
|
||||
|
||||
// Reset the counter so independent reconnections hours apart don't
|
||||
// exhaust the limit — it guards against rapid consecutive failures,
|
||||
// not lifetime total.
|
||||
@@ -907,8 +905,8 @@ export async function initBridgeCore(
|
||||
function handleTransportPermanentClose(closeCode: number | undefined): void {
|
||||
rcLog(
|
||||
`handleTransportPermanentClose: code=${closeCode}` +
|
||||
` transport=${transport ? 'exists' : 'null'}` +
|
||||
` pollAborted=${pollController.signal.aborted}`,
|
||||
` transport=${transport ? 'exists' : 'null'}` +
|
||||
` pollAborted=${pollController.signal.aborted}`,
|
||||
)
|
||||
logForDebugging(
|
||||
`[bridge:repl] Transport permanently closed: code=${closeCode}`,
|
||||
@@ -1303,7 +1301,9 @@ export async function initBridgeCore(
|
||||
session_id: currentSessionId,
|
||||
})) as TransportMessage[]
|
||||
const dropsBefore = newTransport.droppedBatchCount
|
||||
void newTransport.writeBatch(events as StdoutMessage[]).then(() => {
|
||||
void newTransport
|
||||
.writeBatch(events as StdoutMessage[])
|
||||
.then(() => {
|
||||
// If any batch was dropped during this flush (SI down for
|
||||
// maxConsecutiveFailures attempts), flush() still resolved
|
||||
// normally but the events were NOT delivered. Don't mark
|
||||
@@ -1357,10 +1357,10 @@ export async function initBridgeCore(
|
||||
const parsed = JSON.parse(data)
|
||||
rcLog(
|
||||
`ingress: type=${parsed.type}` +
|
||||
`${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 === 'user' ? ` uuid=${parsed.uuid}` : ''}` +
|
||||
`${parsed.type === 'keep_alive' ? '' : ` len=${data.length}`}`,
|
||||
`${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 === 'user' ? ` uuid=${parsed.uuid}` : ''}` +
|
||||
`${parsed.type === 'keep_alive' ? '' : ` len=${data.length}`}`,
|
||||
)
|
||||
} catch {
|
||||
rcLog(`ingress (non-JSON): ${String(data).slice(0, 200)}`)
|
||||
@@ -1387,9 +1387,9 @@ export async function initBridgeCore(
|
||||
if (transport !== newTransport) return
|
||||
rcLog(
|
||||
`transport onClose: code=${closeCode}` +
|
||||
` connected=${newTransport.isConnectedStatus()}` +
|
||||
` state=${newTransport.getStateLabel()}` +
|
||||
` seq=${newTransport.getLastSequenceNum()}`,
|
||||
` connected=${newTransport.isConnectedStatus()}` +
|
||||
` state=${newTransport.getStateLabel()}` +
|
||||
` seq=${newTransport.getLastSequenceNum()}`,
|
||||
)
|
||||
handleTransportPermanentClose(closeCode)
|
||||
})
|
||||
@@ -1818,7 +1818,10 @@ export async function initBridgeCore(
|
||||
for (const msg of filtered) {
|
||||
if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string)
|
||||
}
|
||||
const events: TransportMessage[] = filtered.map(m => ({ ...m, session_id: currentSessionId })) as TransportMessage[]
|
||||
const events: TransportMessage[] = filtered.map(m => ({
|
||||
...m,
|
||||
session_id: currentSessionId,
|
||||
})) as TransportMessage[]
|
||||
void transport.writeBatch(events as StdoutMessage[])
|
||||
},
|
||||
sendControlRequest(request: SDKControlRequest) {
|
||||
@@ -1828,7 +1831,10 @@ export async function initBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event: TransportMessage = { ...request, session_id: currentSessionId } as TransportMessage
|
||||
const event: TransportMessage = {
|
||||
...request,
|
||||
session_id: currentSessionId,
|
||||
} as TransportMessage
|
||||
void transport.write(event as StdoutMessage)
|
||||
logForDebugging(
|
||||
`[bridge:repl] Sent control_request request_id=${request.request_id}`,
|
||||
@@ -1841,7 +1847,10 @@ export async function initBridgeCore(
|
||||
)
|
||||
return
|
||||
}
|
||||
const event: TransportMessage = { ...response, session_id: currentSessionId } as TransportMessage
|
||||
const event: TransportMessage = {
|
||||
...response,
|
||||
session_id: currentSessionId,
|
||||
} as TransportMessage
|
||||
void transport.write(event as StdoutMessage)
|
||||
logForDebugging('[bridge:repl] Sent control_response')
|
||||
},
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type StdoutMessage = any;
|
||||
export type StdoutMessage = any
|
||||
|
||||
@@ -11,21 +11,44 @@
|
||||
/** Patterns that match known secret/token formats. */
|
||||
const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
|
||||
// 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
|
||||
{ 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
|
||||
{ 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
|
||||
{ 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)
|
||||
{ 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")
|
||||
{ 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
|
||||
{ pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, replacement: '[REDACTED_NPM_TOKEN]' },
|
||||
// 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). */
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import figures from 'figures'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
import type { AppState } from '../state/AppStateStore.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { isFullscreenActive } from '../utils/fullscreen.js'
|
||||
import type { Theme } from '../utils/theme.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'
|
||||
import { RARITY_COLORS } from './types.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import figures from 'figures';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink';
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js';
|
||||
import type { AppState } from '../state/AppStateStore.js';
|
||||
import { getGlobalConfig } from '../utils/config.js';
|
||||
import { isFullscreenActive } from '../utils/fullscreen.js';
|
||||
import type { Theme } from '../utils/theme.js';
|
||||
import { getCompanion } from './companion.js';
|
||||
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js';
|
||||
import { RARITY_COLORS } from './types.js';
|
||||
|
||||
const TICK_MS = 500
|
||||
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 PET_BURST_MS = 2500 // how long hearts float after /buddy pet
|
||||
const TICK_MS = 500;
|
||||
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 PET_BURST_MS = 2500; // how long hearts float after /buddy pet
|
||||
|
||||
// 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".
|
||||
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.
|
||||
const H = figures.heart
|
||||
const H = figures.heart;
|
||||
const PET_HEARTS = [
|
||||
` ${H} ${H} `,
|
||||
` ${H} ${H} ${H} `,
|
||||
` ${H} ${H} ${H} `,
|
||||
`${H} ${H} ${H} `,
|
||||
'· · · ',
|
||||
]
|
||||
];
|
||||
|
||||
function wrap(text: string, width: number): string[] {
|
||||
const words = text.split(' ')
|
||||
const lines: string[] = []
|
||||
let cur = ''
|
||||
const words = text.split(' ');
|
||||
const lines: string[] = [];
|
||||
let cur = '';
|
||||
for (const w of words) {
|
||||
if (cur.length + w.length + 1 > width && cur) {
|
||||
lines.push(cur)
|
||||
cur = w
|
||||
lines.push(cur);
|
||||
cur = w;
|
||||
} else {
|
||||
cur = cur ? `${cur} ${w}` : w
|
||||
cur = cur ? `${cur} ${w}` : w;
|
||||
}
|
||||
}
|
||||
if (cur) lines.push(cur)
|
||||
return lines
|
||||
if (cur) lines.push(cur);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function SpeechBubble({
|
||||
@@ -53,40 +53,29 @@ function SpeechBubble({
|
||||
fading,
|
||||
tail,
|
||||
}: {
|
||||
text: string
|
||||
color: keyof Theme
|
||||
fading: boolean
|
||||
tail: 'down' | 'right'
|
||||
text: string;
|
||||
color: keyof Theme;
|
||||
fading: boolean;
|
||||
tail: 'down' | 'right';
|
||||
}): React.ReactNode {
|
||||
const lines = wrap(text, 30)
|
||||
const borderColor = fading ? 'inactive' : color
|
||||
const lines = wrap(text, 30);
|
||||
const borderColor = fading ? 'inactive' : color;
|
||||
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) => (
|
||||
<Text
|
||||
key={i}
|
||||
italic
|
||||
dimColor={!fading}
|
||||
color={fading ? 'inactive' : undefined}
|
||||
>
|
||||
<Text key={i} italic dimColor={!fading} color={fading ? 'inactive' : undefined}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
if (tail === 'right') {
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
{bubble}
|
||||
<Text color={borderColor}>─</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="flex-end" marginRight={1}>
|
||||
@@ -96,18 +85,18 @@ function SpeechBubble({
|
||||
<Text color={borderColor}>╲</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const MIN_COLS_FOR_FULL_SPRITE = 100
|
||||
const SPRITE_BODY_WIDTH = 12
|
||||
const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name `
|
||||
const SPRITE_PADDING_X = 2
|
||||
const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column
|
||||
const NARROW_QUIP_CAP = 24
|
||||
export const MIN_COLS_FOR_FULL_SPRITE = 100;
|
||||
const SPRITE_BODY_WIDTH = 12;
|
||||
const NAME_ROW_PAD = 2; // focused state wraps name in spaces: ` name `
|
||||
const SPRITE_PADDING_X = 2;
|
||||
const BUBBLE_WIDTH = 36; // SpeechBubble box (34) + tail column
|
||||
const NARROW_QUIP_CAP = 24;
|
||||
|
||||
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
|
||||
@@ -115,89 +104,73 @@ function spriteColWidth(nameWidth: number): number {
|
||||
// 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
|
||||
// (above input in fullscreen, below in scrollback), so no reservation.
|
||||
export function companionReservedColumns(
|
||||
terminalColumns: number,
|
||||
speaking: boolean,
|
||||
): number {
|
||||
if (!feature('BUDDY')) return 0
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return 0
|
||||
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 companionReservedColumns(terminalColumns: number, speaking: boolean): number {
|
||||
if (!feature('BUDDY')) return 0;
|
||||
const companion = getCompanion();
|
||||
if (!companion || getGlobalConfig().companionMuted) return 0;
|
||||
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 {
|
||||
const reaction = useAppState(s => s.companionReaction)
|
||||
const petAt = useAppState(s => s.companionPetAt)
|
||||
const focused = useAppState(s => s.footerSelection === 'companion')
|
||||
const setAppState = useSetAppState()
|
||||
const { columns } = useTerminalSize()
|
||||
const [tick, setTick] = useState(0)
|
||||
const lastSpokeTick = useRef(0)
|
||||
const reaction = useAppState(s => s.companionReaction);
|
||||
const petAt = useAppState(s => s.companionPetAt);
|
||||
const focused = useAppState(s => s.footerSelection === 'companion');
|
||||
const setAppState = useSetAppState();
|
||||
const { columns } = useTerminalSize();
|
||||
const [tick, setTick] = useState(0);
|
||||
const lastSpokeTick = useRef(0);
|
||||
// Sync-during-render (not useEffect) so the first post-pet render already
|
||||
// has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped.
|
||||
const [{ petStartTick, forPetAt }, setPetStart] = useState({
|
||||
petStartTick: 0,
|
||||
forPetAt: petAt,
|
||||
})
|
||||
});
|
||||
if (petAt !== forPetAt) {
|
||||
setPetStart({ petStartTick: tick, forPetAt: petAt })
|
||||
setPetStart({ petStartTick: tick, forPetAt: petAt });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(
|
||||
setT => setT((t: number) => t + 1),
|
||||
TICK_MS,
|
||||
setTick,
|
||||
)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reaction) return
|
||||
lastSpokeTick.current = tick
|
||||
if (!reaction) return;
|
||||
lastSpokeTick.current = tick;
|
||||
const timer = setTimeout(
|
||||
setA =>
|
||||
setA((prev: AppState) =>
|
||||
prev.companionReaction === undefined
|
||||
? prev
|
||||
: { ...prev, companionReaction: undefined },
|
||||
prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined },
|
||||
),
|
||||
BUBBLE_SHOW * TICK_MS,
|
||||
setAppState,
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
// 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
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return null
|
||||
if (!feature('BUDDY')) return null;
|
||||
const companion = getCompanion();
|
||||
if (!companion || getGlobalConfig().companionMuted) return null;
|
||||
|
||||
const color = RARITY_COLORS[companion.rarity]
|
||||
const colWidth = spriteColWidth(stringWidth(companion.name))
|
||||
const color = RARITY_COLORS[companion.rarity];
|
||||
const colWidth = spriteColWidth(stringWidth(companion.name));
|
||||
|
||||
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0
|
||||
const fading =
|
||||
reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW
|
||||
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0;
|
||||
const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW;
|
||||
|
||||
const petAge = petAt ? tick - petStartTick : Infinity
|
||||
const petting = petAge * TICK_MS < PET_BURST_MS
|
||||
const petAge = petAt ? tick - petStartTick : Infinity;
|
||||
const petting = petAge * TICK_MS < PET_BURST_MS;
|
||||
|
||||
// Narrow terminals: collapse to one-line face. When speaking, the quip
|
||||
// replaces the name beside the face (no room for a bubble).
|
||||
if (columns < MIN_COLS_FOR_FULL_SPRITE) {
|
||||
const quip =
|
||||
reaction && reaction.length > NARROW_QUIP_CAP
|
||||
? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…'
|
||||
: reaction
|
||||
const label = quip
|
||||
? `"${quip}"`
|
||||
: focused
|
||||
? ` ${companion.name} `
|
||||
: companion.name
|
||||
reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction;
|
||||
const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name;
|
||||
return (
|
||||
<Box paddingX={1} alignSelf="flex-end">
|
||||
<Text>
|
||||
@@ -210,44 +183,34 @@ export function CompanionSprite(): React.ReactNode {
|
||||
dimColor={!focused && !reaction}
|
||||
bold={focused}
|
||||
inverse={focused && !reaction}
|
||||
color={
|
||||
reaction
|
||||
? fading
|
||||
? 'inactive'
|
||||
: color
|
||||
: focused
|
||||
? color
|
||||
: undefined
|
||||
}
|
||||
color={reaction ? (fading ? 'inactive' : color) : focused ? color : undefined}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
const frameCount = spriteFrameCount(companion.species)
|
||||
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null
|
||||
const frameCount = spriteFrameCount(companion.species);
|
||||
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null;
|
||||
|
||||
let spriteFrame: number
|
||||
let blink = false
|
||||
let spriteFrame: number;
|
||||
let blink = false;
|
||||
if (reaction || petting) {
|
||||
// Excited: cycle all fidget frames fast
|
||||
spriteFrame = tick % frameCount
|
||||
spriteFrame = tick % frameCount;
|
||||
} else {
|
||||
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
|
||||
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!;
|
||||
if (step === -1) {
|
||||
spriteFrame = 0
|
||||
blink = true
|
||||
spriteFrame = 0;
|
||||
blink = true;
|
||||
} else {
|
||||
spriteFrame = step % frameCount
|
||||
spriteFrame = step % frameCount;
|
||||
}
|
||||
}
|
||||
|
||||
const body = renderSprite(companion, spriteFrame).map(line =>
|
||||
blink ? line.replaceAll(companion.eye, '-') : line,
|
||||
)
|
||||
const sprite = heartFrame ? [heartFrame, ...body] : body
|
||||
const body = renderSprite(companion, spriteFrame).map(line => (blink ? line.replaceAll(companion.eye, '-') : line));
|
||||
const sprite = heartFrame ? [heartFrame, ...body] : body;
|
||||
|
||||
// Name row doubles as hint row — unfocused shows dim name + ↓ discovery,
|
||||
// focused shows inverse name. The enter-to-open hint lives in
|
||||
@@ -255,31 +218,20 @@ export function CompanionSprite(): React.ReactNode {
|
||||
// sprite doesn't jump up when selected. flexShrink=0 stops the
|
||||
// inline-bubble row wrapper from squeezing the sprite to fit.
|
||||
const spriteColumn = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
alignItems="center"
|
||||
width={colWidth}
|
||||
>
|
||||
<Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
|
||||
{sprite.map((line, i) => (
|
||||
<Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
|
||||
{line}
|
||||
</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}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
if (!reaction) {
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||
}
|
||||
|
||||
// Fullscreen: bubble renders separately via CompanionFloatingBubble in
|
||||
@@ -288,19 +240,14 @@ export function CompanionSprite(): React.ReactNode {
|
||||
// Non-fullscreen: bubble sits inline beside the sprite (input shrinks)
|
||||
// because floating into Static scrollback can't be cleared.
|
||||
if (isFullscreenActive()) {
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||
}
|
||||
return (
|
||||
<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}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's
|
||||
@@ -308,33 +255,29 @@ export function CompanionSprite(): React.ReactNode {
|
||||
// the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this
|
||||
// just reads companionReaction and renders the fade.
|
||||
export function CompanionFloatingBubble(): React.ReactNode {
|
||||
const reaction = useAppState(s => s.companionReaction)
|
||||
const reaction = useAppState(s => s.companionReaction);
|
||||
const [{ tick, forReaction }, setTick] = useState({
|
||||
tick: 0,
|
||||
forReaction: reaction,
|
||||
})
|
||||
});
|
||||
|
||||
// Reset tick synchronously when reaction changes (not in useEffect, which
|
||||
// runs post-render and would show one stale-faded frame). Storing the
|
||||
// reaction the tick is counting FOR alongside the tick itself means the
|
||||
// fade computation never sees a tick from a previous reaction.
|
||||
if (reaction !== forReaction) {
|
||||
setTick({ tick: 0, forReaction: reaction })
|
||||
setTick({ tick: 0, forReaction: reaction });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!reaction) return
|
||||
const timer = setInterval(
|
||||
set => set(s => ({ ...s, tick: s.tick + 1 })),
|
||||
TICK_MS,
|
||||
setTick,
|
||||
)
|
||||
return () => clearInterval(timer)
|
||||
}, [reaction])
|
||||
if (!reaction) return;
|
||||
const timer = setInterval(set => set(s => ({ ...s, tick: s.tick + 1 })), TICK_MS, setTick);
|
||||
return () => clearInterval(timer);
|
||||
}, [reaction]);
|
||||
|
||||
if (!feature('BUDDY') || !reaction) return null
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return null
|
||||
if (!feature('BUDDY') || !reaction) return null;
|
||||
const companion = getCompanion();
|
||||
if (!companion || getGlobalConfig().companionMuted) return null;
|
||||
|
||||
return (
|
||||
<SpeechBubble
|
||||
@@ -343,5 +286,5 @@ export function CompanionFloatingBubble(): React.ReactNode {
|
||||
fading={tick >= BUBBLE_SHOW - FADE_WINDOW}
|
||||
tail="down"
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useNotifications } from '../context/notifications.js'
|
||||
import { Text } from '@anthropic/ink'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getRainbowColor } from '../utils/thinking.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNotifications } from '../context/notifications.js';
|
||||
import { Text } from '@anthropic/ink';
|
||||
import { getGlobalConfig } from '../utils/config.js';
|
||||
import { getRainbowColor } from '../utils/thinking.js';
|
||||
|
||||
// Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter
|
||||
// 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.
|
||||
export function isBuddyTeaserWindow(): boolean {
|
||||
if (process.env.USER_TYPE === 'ant') return true
|
||||
const d = new Date()
|
||||
return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7
|
||||
if (process.env.USER_TYPE === 'ant') return true;
|
||||
const d = new Date();
|
||||
return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7;
|
||||
}
|
||||
|
||||
export function isBuddyLive(): boolean {
|
||||
if (process.env.USER_TYPE === 'ant') return true
|
||||
const d = new Date()
|
||||
return (
|
||||
d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3)
|
||||
)
|
||||
if (process.env.USER_TYPE === 'ant') return true;
|
||||
const d = new Date();
|
||||
return d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3);
|
||||
}
|
||||
|
||||
function RainbowText({ text }: { text: string }): React.ReactNode {
|
||||
@@ -31,37 +29,35 @@ function RainbowText({ text }: { text: string }): React.ReactNode {
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Rainbow /buddy teaser shown on startup when no companion hatched yet.
|
||||
// Idle presence and reactions are handled by CompanionSprite directly.
|
||||
export function useBuddyNotification(): void {
|
||||
const { addNotification, removeNotification } = useNotifications()
|
||||
const { addNotification, removeNotification } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
if (!feature('BUDDY')) return
|
||||
const config = getGlobalConfig()
|
||||
if (config.companion || !isBuddyTeaserWindow()) return
|
||||
if (!feature('BUDDY')) return;
|
||||
const config = getGlobalConfig();
|
||||
if (config.companion || !isBuddyTeaserWindow()) return;
|
||||
addNotification({
|
||||
key: 'buddy-teaser',
|
||||
jsx: <RainbowText text="/buddy" />,
|
||||
priority: 'immediate',
|
||||
timeoutMs: 15_000,
|
||||
})
|
||||
return () => removeNotification('buddy-teaser')
|
||||
}, [addNotification, removeNotification])
|
||||
});
|
||||
return () => removeNotification('buddy-teaser');
|
||||
}, [addNotification, removeNotification]);
|
||||
}
|
||||
|
||||
export function findBuddyTriggerPositions(
|
||||
text: string,
|
||||
): Array<{ start: number; end: number }> {
|
||||
if (!feature('BUDDY')) return []
|
||||
const triggers: Array<{ start: number; end: number }> = []
|
||||
const re = /\/buddy\b/g
|
||||
let m: RegExpExecArray | null
|
||||
export function findBuddyTriggerPositions(text: string): Array<{ start: number; end: number }> {
|
||||
if (!feature('BUDDY')) return [];
|
||||
const triggers: Array<{ start: number; end: number }> = [];
|
||||
const re = /\/buddy\b/g;
|
||||
let m: RegExpExecArray | 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;
|
||||
}
|
||||
|
||||
348
src/cli/bg.ts
348
src/cli/bg.ts
@@ -1,7 +1,341 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const psHandler: (args: string[]) => Promise<void> = (async () => {}) as (args: string[]) => Promise<void>;
|
||||
export const logsHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
|
||||
export const attachHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
|
||||
export const killHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
|
||||
export const handleBgFlag: (args: string[]) => Promise<void> = (async () => {}) as (args: string[]) => Promise<void>;
|
||||
import { readdir, readFile, unlink } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
import { isProcessRunning } from '../utils/genericProcessUtils.js'
|
||||
import { jsonParse } from '../utils/slowOperations.js'
|
||||
import { selectEngine } from './bg/engines/index.js'
|
||||
import type { SessionEntry } from './bg/engine.js'
|
||||
|
||||
export type { SessionEntry } from './bg/engine.js'
|
||||
|
||||
function getSessionsDir(): string {
|
||||
return join(getClaudeConfigHomeDir(), 'sessions')
|
||||
}
|
||||
|
||||
export async function listLiveSessions(): Promise<SessionEntry[]> {
|
||||
const dir = getSessionsDir()
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(dir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const sessions: SessionEntry[] = []
|
||||
for (const file of files) {
|
||||
if (!/^\d+\.json$/.test(file)) continue
|
||||
const pid = parseInt(file.slice(0, -5), 10)
|
||||
|
||||
if (!isProcessRunning(pid)) {
|
||||
void unlink(join(dir, file)).catch(() => {})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readFile(join(dir, file), 'utf-8')
|
||||
const entry = jsonParse(raw) as SessionEntry
|
||||
sessions.push(entry)
|
||||
} catch {
|
||||
// Corrupt file — skip
|
||||
}
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
export function findSession(
|
||||
sessions: SessionEntry[],
|
||||
target: string,
|
||||
): SessionEntry | undefined {
|
||||
const asNum = parseInt(target, 10)
|
||||
return sessions.find(
|
||||
s =>
|
||||
s.sessionId === target ||
|
||||
s.pid === asNum ||
|
||||
(s.name && s.name === target),
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the engine type for an existing session.
|
||||
* Backward-compatible: sessions without an `engine` field are inferred
|
||||
* from the presence of `tmuxSessionName`.
|
||||
*/
|
||||
function resolveSessionEngine(session: SessionEntry): 'tmux' | 'detached' {
|
||||
if (session.engine) return session.engine
|
||||
return session.tmuxSessionName ? 'tmux' : 'detached'
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon status` / `claude ps` — list live sessions.
|
||||
*/
|
||||
export async function psHandler(_args: string[]): Promise<void> {
|
||||
const sessions = await listLiveSessions()
|
||||
|
||||
if (sessions.length === 0) {
|
||||
console.log('No active sessions.')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${sessions.length} active session${sessions.length > 1 ? 's' : ''}:\n`,
|
||||
)
|
||||
|
||||
for (const s of sessions) {
|
||||
const engineType = resolveSessionEngine(s)
|
||||
const parts: string[] = [
|
||||
` PID: ${s.pid}`,
|
||||
` Kind: ${s.kind}`,
|
||||
` Engine: ${engineType}`,
|
||||
` Session: ${s.sessionId}`,
|
||||
` CWD: ${s.cwd}`,
|
||||
]
|
||||
|
||||
if (s.name) parts.push(` Name: ${s.name}`)
|
||||
if (s.startedAt) parts.push(` Started: ${formatTime(s.startedAt)}`)
|
||||
if (s.status) parts.push(` Status: ${s.status}`)
|
||||
if (s.waitingFor) parts.push(` Waiting for: ${s.waitingFor}`)
|
||||
if (s.bridgeSessionId) parts.push(` Bridge: ${s.bridgeSessionId}`)
|
||||
if (s.tmuxSessionName) parts.push(` Tmux: ${s.tmuxSessionName}`)
|
||||
if (s.logPath) parts.push(` Log: ${s.logPath}`)
|
||||
|
||||
console.log(parts.join('\n'))
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon logs <target>` — show logs for a session.
|
||||
*/
|
||||
export async function logsHandler(target: string | undefined): Promise<void> {
|
||||
const sessions = await listLiveSessions()
|
||||
|
||||
if (!target) {
|
||||
if (sessions.length === 0) {
|
||||
console.log('No active sessions.')
|
||||
return
|
||||
}
|
||||
if (sessions.length === 1) {
|
||||
target = sessions[0]!.sessionId
|
||||
} else {
|
||||
console.log('Multiple sessions active. Specify one:')
|
||||
for (const s of sessions) {
|
||||
const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId
|
||||
console.log(` ${label} PID=${s.pid}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const session = findSession(sessions, target)
|
||||
if (!session) {
|
||||
console.error(`Session not found: ${target}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
if (!session.logPath) {
|
||||
console.log(`No log path recorded for session ${session.sessionId}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(session.logPath, 'utf-8')
|
||||
process.stdout.write(content)
|
||||
} catch (e) {
|
||||
console.error(`Failed to read log file: ${session.logPath}`)
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon attach <target>` — attach to a background session.
|
||||
*
|
||||
* Engine-aware: tmux sessions use tmux attach, detached sessions use log tail.
|
||||
*/
|
||||
export async function attachHandler(target: string | undefined): Promise<void> {
|
||||
const sessions = await listLiveSessions()
|
||||
|
||||
if (!target) {
|
||||
// Find bg sessions (tmux or detached)
|
||||
const bgSessions = sessions.filter(
|
||||
s => s.tmuxSessionName || s.engine === 'detached',
|
||||
)
|
||||
if (bgSessions.length === 0) {
|
||||
console.log(
|
||||
'No background sessions to attach to. Start one with `claude daemon bg`.',
|
||||
)
|
||||
return
|
||||
}
|
||||
if (bgSessions.length === 1) {
|
||||
target = bgSessions[0]!.sessionId
|
||||
} else {
|
||||
console.log('Multiple background sessions. Specify one:')
|
||||
for (const s of bgSessions) {
|
||||
const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId
|
||||
const engineType = resolveSessionEngine(s)
|
||||
console.log(` ${label} PID=${s.pid} engine=${engineType}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const session = findSession(sessions, target)
|
||||
if (!session) {
|
||||
console.error(`Session not found: ${target}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const engineType = resolveSessionEngine(session)
|
||||
|
||||
try {
|
||||
if (engineType === 'tmux') {
|
||||
const { TmuxEngine } = await import('./bg/engines/tmux.js')
|
||||
const tmux = new TmuxEngine()
|
||||
if (!(await tmux.available())) {
|
||||
console.error(
|
||||
'tmux is no longer available. Cannot attach to tmux session.',
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
await tmux.attach(session)
|
||||
} else {
|
||||
const { DetachedEngine } = await import('./bg/engines/detached.js')
|
||||
const detached = new DetachedEngine()
|
||||
await detached.attach(session)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon kill <target>` — kill a session.
|
||||
*/
|
||||
export async function killHandler(target: string | undefined): Promise<void> {
|
||||
const sessions = await listLiveSessions()
|
||||
|
||||
if (!target) {
|
||||
if (sessions.length === 0) {
|
||||
console.log('No active sessions to kill.')
|
||||
return
|
||||
}
|
||||
console.log('Specify a session to kill:')
|
||||
for (const s of sessions) {
|
||||
const label = s.name ? `${s.name} (${s.sessionId})` : s.sessionId
|
||||
console.log(` ${label} PID=${s.pid}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const session = findSession(sessions, target)
|
||||
if (!session) {
|
||||
console.error(`Session not found: ${target}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Killing session ${session.sessionId} (PID: ${session.pid})...`)
|
||||
|
||||
try {
|
||||
process.kill(session.pid, 'SIGTERM')
|
||||
} catch {
|
||||
console.log('Session already exited.')
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
if (isProcessRunning(session.pid)) {
|
||||
try {
|
||||
process.kill(session.pid, 'SIGKILL')
|
||||
console.log('Session force-killed.')
|
||||
} catch {
|
||||
console.log('Session exited during grace period.')
|
||||
}
|
||||
} else {
|
||||
console.log('Session stopped.')
|
||||
}
|
||||
|
||||
const pidFile = join(getSessionsDir(), `${session.pid}.json`)
|
||||
void unlink(pidFile).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* `claude daemon bg [args]` — start a background session.
|
||||
*
|
||||
* Cross-platform: uses TmuxEngine on macOS/Linux when tmux is available,
|
||||
* falls back to DetachedEngine on Windows or when tmux is absent.
|
||||
*/
|
||||
export async function handleBgStart(args: string[]): Promise<void> {
|
||||
const engine = await selectEngine()
|
||||
|
||||
// Strip --bg/--background from args (for backward-compat shortcut)
|
||||
const filteredArgs = args.filter(a => a !== '--bg' && a !== '--background')
|
||||
|
||||
// Engines without interactive TTY input (e.g. detached) require -p/--print
|
||||
// or piped input. Tmux provides a virtual terminal so it works without -p.
|
||||
if (
|
||||
!engine.supportsInteractiveInput &&
|
||||
!filteredArgs.some(a => a === '-p' || a === '--print' || a === '--pipe')
|
||||
) {
|
||||
console.error(
|
||||
'Error: Background sessions with detached engine require -p/--print flag.\n' +
|
||||
'The detached engine has no terminal for interactive input.\n\n' +
|
||||
'Usage:\n' +
|
||||
' claude daemon bg -p "your prompt here"\n' +
|
||||
' echo "prompt" | claude daemon bg --pipe',
|
||||
)
|
||||
if (process.platform !== 'win32') {
|
||||
console.error(
|
||||
'\nAlternatively, install tmux for interactive background sessions:\n' +
|
||||
` ${process.platform === 'darwin' ? 'brew install tmux' : 'sudo apt install tmux'}`,
|
||||
)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const sessionName = `claude-bg-${randomUUID().slice(0, 8)}`
|
||||
const logPath = join(
|
||||
getClaudeConfigHomeDir(),
|
||||
'sessions',
|
||||
'logs',
|
||||
`${sessionName}.log`,
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await engine.start({
|
||||
sessionName,
|
||||
args: filteredArgs,
|
||||
env: { ...process.env },
|
||||
logPath,
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
|
||||
console.log(`Background session started: ${result.sessionName}`)
|
||||
console.log(` Engine: ${result.engineUsed}`)
|
||||
console.log(` Log: ${result.logPath}`)
|
||||
console.log()
|
||||
console.log(
|
||||
`Use \`claude daemon attach ${result.sessionName}\` to reconnect.`,
|
||||
)
|
||||
console.log(`Use \`claude daemon status\` to check status.`)
|
||||
console.log(`Use \`claude daemon kill ${result.sessionName}\` to stop.`)
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : String(e))
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy export alias — kept for backward compatibility with cli.tsx
|
||||
export const handleBgFlag = handleBgStart
|
||||
|
||||
15
src/cli/bg/__tests__/detached.test.ts
Normal file
15
src/cli/bg/__tests__/detached.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
37
src/cli/bg/__tests__/engine.test.ts
Normal file
37
src/cli/bg/__tests__/engine.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
8
src/cli/bg/__tests__/tail.test.ts
Normal file
8
src/cli/bg/__tests__/tail.test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
49
src/cli/bg/engine.ts
Normal file
49
src/cli/bg/engine.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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>
|
||||
}
|
||||
59
src/cli/bg/engines/detached.ts
Normal file
59
src/cli/bg/engines/detached.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
22
src/cli/bg/engines/index.ts
Normal file
22
src/cli/bg/engines/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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()
|
||||
}
|
||||
80
src/cli/bg/engines/tmux.ts
Normal file
80
src/cli/bg/engines/tmux.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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)'
|
||||
}
|
||||
70
src/cli/bg/tail.ts
Normal file
70
src/cli/bg/tail.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
/** Write an error message to stderr (if given) and exit with code 1. */
|
||||
export function cliError(msg?: string): never {
|
||||
// biome-ignore lint/suspicious/noConsole: centralized CLI error output
|
||||
if (msg) console.error(msg)
|
||||
process.exit(1)
|
||||
return undefined as never
|
||||
|
||||
@@ -59,12 +59,9 @@ export async function agentsHandler(): Promise<void> {
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('No agents found.')
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${totalActive} active agents\n`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(lines.join('\n').trimEnd())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,216 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type { Command } from '@commander-js/extra-typings';
|
||||
import type { Command } from '@commander-js/extra-typings'
|
||||
import {
|
||||
createTask,
|
||||
getTask,
|
||||
updateTask,
|
||||
listTasks,
|
||||
getTasksDir,
|
||||
} from '../../utils/tasks.js'
|
||||
import { getRecentActivity } from '../../utils/logoV2Utils.js'
|
||||
import type { LogOption } from '../../types/logs.js'
|
||||
|
||||
export {};
|
||||
export const logHandler: (logId: string | number | undefined) => Promise<void> = (async () => {}) as (logId: string | number | undefined) => Promise<void>;
|
||||
export const errorHandler: (num: number | undefined) => Promise<void> = (async () => {}) as (num: number | undefined) => Promise<void>;
|
||||
export const exportHandler: (source: string, outputFile: string) => Promise<void> = (async () => {}) as (source: string, outputFile: string) => Promise<void>;
|
||||
export const taskCreateHandler: (subject: string, opts: { description?: string; list?: string }) => Promise<void> = (async () => {}) as (subject: string, opts: { description?: string; list?: string }) => Promise<void>;
|
||||
export const taskListHandler: (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise<void> = (async () => {}) as (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise<void>;
|
||||
export const taskGetHandler: (id: string, opts: { list?: string }) => Promise<void> = (async () => {}) as (id: string, opts: { list?: string }) => Promise<void>;
|
||||
export const taskUpdateHandler: (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise<void> = (async () => {}) as (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise<void>;
|
||||
export const taskDirHandler: (opts: { list?: string }) => Promise<void> = (async () => {}) as (opts: { list?: string }) => Promise<void>;
|
||||
export const completionHandler: (shell: string, opts: { output?: string }, program: Command) => Promise<void> = (async () => {}) as (shell: string, opts: { output?: string }, program: Command) => Promise<void>;
|
||||
const DEFAULT_LIST = 'default'
|
||||
|
||||
// ─── Group C: Task CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
export async function taskCreateHandler(
|
||||
subject: string,
|
||||
opts: { description?: string; list?: string },
|
||||
): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
const id = await createTask(listId, {
|
||||
subject,
|
||||
description: opts.description || '',
|
||||
status: 'pending',
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
})
|
||||
console.log(`Created task ${id}: ${subject}`)
|
||||
}
|
||||
|
||||
export async function taskListHandler(opts: {
|
||||
list?: string
|
||||
pending?: boolean
|
||||
json?: boolean
|
||||
}): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
let tasks = await listTasks(listId)
|
||||
|
||||
if (opts.pending) {
|
||||
tasks = tasks.filter(t => t.status === 'pending')
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(tasks, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log('No tasks found.')
|
||||
return
|
||||
}
|
||||
|
||||
for (const t of tasks) {
|
||||
console.log(` [${t.status}] ${t.id}: ${t.subject}`)
|
||||
if (t.description) console.log(` ${t.description}`)
|
||||
if (t.owner) console.log(` owner: ${t.owner}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function taskGetHandler(
|
||||
id: string,
|
||||
opts: { list?: string },
|
||||
): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
const task = await getTask(listId, id)
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${id}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(JSON.stringify(task, null, 2))
|
||||
}
|
||||
|
||||
export async function taskUpdateHandler(
|
||||
id: string,
|
||||
opts: {
|
||||
list?: string
|
||||
status?: string
|
||||
subject?: string
|
||||
description?: string
|
||||
owner?: string
|
||||
clearOwner?: boolean
|
||||
},
|
||||
): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
const updates: Record<string, unknown> = {}
|
||||
|
||||
if (opts.status) updates.status = opts.status
|
||||
if (opts.subject) updates.subject = opts.subject
|
||||
if (opts.description) updates.description = opts.description
|
||||
if (opts.owner) updates.owner = opts.owner
|
||||
if (opts.clearOwner) updates.owner = undefined
|
||||
|
||||
const task = await updateTask(listId, id, updates)
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${id}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(`Updated task ${id}: [${task.status}] ${task.subject}`)
|
||||
}
|
||||
|
||||
export async function taskDirHandler(opts: { list?: string }): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
console.log(getTasksDir(listId))
|
||||
}
|
||||
|
||||
// ─── Group B: Log / Error / Export ───────────────────────────────────────────
|
||||
|
||||
export async function logHandler(
|
||||
logId: string | number | undefined,
|
||||
): Promise<void> {
|
||||
const logs = await getRecentActivity()
|
||||
|
||||
if (logId === undefined) {
|
||||
if (logs.length === 0) {
|
||||
console.log('No recent sessions.')
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < Math.min(logs.length, 20); i++) {
|
||||
const log = logs[i]!
|
||||
const date = log.modified
|
||||
? new Date(log.modified).toLocaleString()
|
||||
: 'unknown'
|
||||
const title =
|
||||
(log as Record<string, unknown>).title || log.sessionId || 'untitled'
|
||||
console.log(` ${i}: ${title} (${date})`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const idx = typeof logId === 'string' ? parseInt(logId, 10) : logId
|
||||
const log =
|
||||
Number.isFinite(idx) && idx >= 0 && idx < logs.length
|
||||
? logs[idx]
|
||||
: logs.find(l => l.sessionId === String(logId))
|
||||
|
||||
if (!log) {
|
||||
console.error(`Session not found: ${logId}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(log, null, 2))
|
||||
}
|
||||
|
||||
export async function errorHandler(num: number | undefined): Promise<void> {
|
||||
// Error log viewing — shows recent session errors
|
||||
const logs = await getRecentActivity()
|
||||
const count = num ?? 5
|
||||
|
||||
console.log(`Last ${count} sessions:`)
|
||||
for (let i = 0; i < Math.min(count, logs.length); i++) {
|
||||
const log = logs[i]!
|
||||
const date = log.modified
|
||||
? new Date(log.modified).toLocaleString()
|
||||
: 'unknown'
|
||||
console.log(` ${i}: ${log.sessionId} (${date})`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportHandler(
|
||||
source: string,
|
||||
outputFile: string,
|
||||
): Promise<void> {
|
||||
const { writeFile, readFile } = await import('fs/promises')
|
||||
const logs = await getRecentActivity()
|
||||
|
||||
// Try as index first
|
||||
const idx = parseInt(source, 10)
|
||||
let log: LogOption | undefined
|
||||
if (Number.isFinite(idx) && idx >= 0 && idx < logs.length) {
|
||||
log = logs[idx]
|
||||
} else {
|
||||
log = logs.find(l => l.sessionId === source)
|
||||
}
|
||||
|
||||
if (!log) {
|
||||
// Try as file path
|
||||
try {
|
||||
const content = await readFile(source, 'utf-8')
|
||||
await writeFile(outputFile, content, 'utf-8')
|
||||
console.log(`Exported ${source} → ${outputFile}`)
|
||||
return
|
||||
} catch {
|
||||
console.error(`Source not found: ${source}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(outputFile, JSON.stringify(log, null, 2), 'utf-8')
|
||||
console.log(`Exported session ${log.sessionId} → ${outputFile}`)
|
||||
}
|
||||
|
||||
// ─── Group D: Completion ─────────────────────────────────────────────────────
|
||||
|
||||
export async function completionHandler(
|
||||
shell: string,
|
||||
opts: { output?: string },
|
||||
_program: Command,
|
||||
): Promise<void> {
|
||||
const { regenerateCompletionCache } = await import(
|
||||
'../../utils/completionCache.js'
|
||||
)
|
||||
|
||||
if (opts.output) {
|
||||
// Generate and write to file
|
||||
await regenerateCompletionCache()
|
||||
console.log(`Completion cache regenerated for ${shell}.`)
|
||||
} else {
|
||||
// Regenerate and output to stdout
|
||||
await regenerateCompletionCache()
|
||||
console.log(`Completion cache regenerated for ${shell}.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,9 @@ export async function authLogin({
|
||||
|
||||
const orgResult = await validateForceLoginOrg()
|
||||
if (!orgResult.valid) {
|
||||
process.stderr.write((orgResult as { valid: false; message: string }).message + '\n')
|
||||
process.stderr.write(
|
||||
(orgResult as { valid: false; message: string }).message + '\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -209,7 +211,9 @@ export async function authLogin({
|
||||
|
||||
const orgResult = await validateForceLoginOrg()
|
||||
if (!orgResult.valid) {
|
||||
process.stderr.write((orgResult as { valid: false; message: string }).message + '\n')
|
||||
process.stderr.write(
|
||||
(orgResult as { valid: false; message: string }).message + '\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,203 +3,163 @@
|
||||
* These are dynamically imported only when the corresponding `claude mcp *` command runs.
|
||||
*/
|
||||
|
||||
import { stat } from 'fs/promises'
|
||||
import pMap from 'p-map'
|
||||
import { cwd } from 'process'
|
||||
import React from 'react'
|
||||
import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js'
|
||||
import { wrappedRender as render } from '@anthropic/ink'
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
|
||||
import { stat } from 'fs/promises';
|
||||
import pMap from 'p-map';
|
||||
import { cwd } from 'process';
|
||||
import React from 'react';
|
||||
import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js';
|
||||
import { wrappedRender as render } from '@anthropic/ink';
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
} from '../../services/analytics/index.js';
|
||||
import {
|
||||
clearMcpClientConfig,
|
||||
clearServerTokensFromLocalStorage,
|
||||
getMcpClientConfig,
|
||||
readClientSecret,
|
||||
saveMcpClientSecret,
|
||||
} from '../../services/mcp/auth.js'
|
||||
import {
|
||||
connectToServer,
|
||||
getMcpServerConnectionBatchSize,
|
||||
} from '../../services/mcp/client.js'
|
||||
} from '../../services/mcp/auth.js';
|
||||
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js';
|
||||
import {
|
||||
addMcpConfig,
|
||||
getAllMcpConfigs,
|
||||
getMcpConfigByName,
|
||||
getMcpConfigsByScope,
|
||||
removeMcpConfig,
|
||||
} from '../../services/mcp/config.js'
|
||||
import type {
|
||||
ConfigScope,
|
||||
ScopedMcpServerConfig,
|
||||
} from '../../services/mcp/types.js'
|
||||
import {
|
||||
describeMcpConfigFilePath,
|
||||
ensureConfigScope,
|
||||
getScopeLabel,
|
||||
} 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'
|
||||
} from '../../services/mcp/config.js';
|
||||
import type { ConfigScope, ScopedMcpServerConfig } from '../../services/mcp/types.js';
|
||||
import { describeMcpConfigFilePath, ensureConfigScope, getScopeLabel } 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 {
|
||||
const result = await connectToServer(name, server)
|
||||
const result = await connectToServer(name, server);
|
||||
if (result.type === 'connected') {
|
||||
return '✓ Connected'
|
||||
return '✓ Connected';
|
||||
} else if (result.type === 'needs-auth') {
|
||||
return '! Needs authentication'
|
||||
return '! Needs authentication';
|
||||
} else {
|
||||
return '✗ Failed to connect'
|
||||
return '✗ Failed to connect';
|
||||
}
|
||||
} catch (_error) {
|
||||
return '✗ Connection error'
|
||||
return '✗ Connection error';
|
||||
}
|
||||
}
|
||||
|
||||
// mcp serve (lines 4512–4532)
|
||||
export async function mcpServeHandler({
|
||||
debug,
|
||||
verbose,
|
||||
}: {
|
||||
debug?: boolean
|
||||
verbose?: boolean
|
||||
}): Promise<void> {
|
||||
const providedCwd = cwd()
|
||||
logEvent('tengu_mcp_start', {})
|
||||
export async function mcpServeHandler({ debug, verbose }: { debug?: boolean; verbose?: boolean }): Promise<void> {
|
||||
const providedCwd = cwd();
|
||||
logEvent('tengu_mcp_start', {});
|
||||
|
||||
try {
|
||||
await stat(providedCwd)
|
||||
await stat(providedCwd);
|
||||
} catch (error) {
|
||||
if (isFsInaccessible(error)) {
|
||||
cliError(`Error: Directory ${providedCwd} does not exist`)
|
||||
cliError(`Error: Directory ${providedCwd} does not exist`);
|
||||
}
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const { setup } = await import('../../setup.js')
|
||||
await setup(providedCwd, 'default', false, false, undefined, false)
|
||||
const { startMCPServer } = await import('../../entrypoints/mcp.js')
|
||||
await startMCPServer(providedCwd, debug ?? false, verbose ?? false)
|
||||
const { setup } = await import('../../setup.js');
|
||||
await setup(providedCwd, 'default', false, false, undefined, false);
|
||||
const { startMCPServer } = await import('../../entrypoints/mcp.js');
|
||||
await startMCPServer(providedCwd, debug ?? false, verbose ?? false);
|
||||
} catch (error) {
|
||||
cliError(`Error: Failed to start MCP server: ${error}`)
|
||||
cliError(`Error: Failed to start MCP server: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// mcp remove (lines 4545–4635)
|
||||
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
|
||||
const serverBeforeRemoval = getMcpConfigByName(name)
|
||||
const serverBeforeRemoval = getMcpConfigByName(name);
|
||||
|
||||
const cleanupSecureStorage = () => {
|
||||
if (
|
||||
serverBeforeRemoval &&
|
||||
(serverBeforeRemoval.type === 'sse' ||
|
||||
serverBeforeRemoval.type === 'http')
|
||||
) {
|
||||
clearServerTokensFromLocalStorage(name, serverBeforeRemoval)
|
||||
clearMcpClientConfig(name, serverBeforeRemoval)
|
||||
if (serverBeforeRemoval && (serverBeforeRemoval.type === 'sse' || serverBeforeRemoval.type === 'http')) {
|
||||
clearServerTokensFromLocalStorage(name, serverBeforeRemoval);
|
||||
clearMcpClientConfig(name, serverBeforeRemoval);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (options.scope) {
|
||||
const scope = ensureConfigScope(options.scope)
|
||||
const scope = ensureConfigScope(options.scope);
|
||||
logEvent('tengu_mcp_delete', {
|
||||
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)
|
||||
cleanupSecureStorage()
|
||||
process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`)
|
||||
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`)
|
||||
await removeMcpConfig(name, scope);
|
||||
cleanupSecureStorage();
|
||||
process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`);
|
||||
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`);
|
||||
}
|
||||
|
||||
// If no scope specified, check where the server exists
|
||||
const projectConfig = getCurrentProjectConfig()
|
||||
const globalConfig = getGlobalConfig()
|
||||
const projectConfig = getCurrentProjectConfig();
|
||||
const globalConfig = getGlobalConfig();
|
||||
|
||||
// Check if server exists in project scope (.mcp.json)
|
||||
const { servers: projectServers } = getMcpConfigsByScope('project')
|
||||
const mcpJsonExists = !!projectServers[name]
|
||||
const { servers: projectServers } = getMcpConfigsByScope('project');
|
||||
const mcpJsonExists = !!projectServers[name];
|
||||
|
||||
// Count how many scopes contain this server
|
||||
const scopes: Array<Exclude<ConfigScope, 'dynamic'>> = []
|
||||
if (projectConfig.mcpServers?.[name]) scopes.push('local')
|
||||
if (mcpJsonExists) scopes.push('project')
|
||||
if (globalConfig.mcpServers?.[name]) scopes.push('user')
|
||||
const scopes: Array<Exclude<ConfigScope, 'dynamic'>> = [];
|
||||
if (projectConfig.mcpServers?.[name]) scopes.push('local');
|
||||
if (mcpJsonExists) scopes.push('project');
|
||||
if (globalConfig.mcpServers?.[name]) scopes.push('user');
|
||||
|
||||
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) {
|
||||
// Server exists in only one scope, remove it
|
||||
const scope = scopes[0]!
|
||||
const scope = scopes[0]!;
|
||||
logEvent('tengu_mcp_delete', {
|
||||
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)
|
||||
cleanupSecureStorage()
|
||||
process.stdout.write(
|
||||
`Removed MCP server "${name}" from ${scope} config\n`,
|
||||
)
|
||||
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`)
|
||||
await removeMcpConfig(name, scope);
|
||||
cleanupSecureStorage();
|
||||
process.stdout.write(`Removed MCP server "${name}" from ${scope} config\n`);
|
||||
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`);
|
||||
} else {
|
||||
// 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 => {
|
||||
process.stderr.write(
|
||||
` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`,
|
||||
)
|
||||
})
|
||||
process.stderr.write('\nTo remove from a specific scope, use:\n')
|
||||
process.stderr.write(` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`);
|
||||
});
|
||||
process.stderr.write('\nTo remove from a specific scope, use:\n');
|
||||
scopes.forEach(scope => {
|
||||
process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`)
|
||||
})
|
||||
cliError()
|
||||
process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`);
|
||||
});
|
||||
cliError();
|
||||
}
|
||||
} catch (error) {
|
||||
cliError((error as Error).message)
|
||||
cliError((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// mcp list (lines 4641–4688)
|
||||
export async function mcpListHandler(): Promise<void> {
|
||||
logEvent('tengu_mcp_list', {})
|
||||
const { servers: configs } = await getAllMcpConfigs()
|
||||
logEvent('tengu_mcp_list', {});
|
||||
const { servers: configs } = await getAllMcpConfigs();
|
||||
if (Object.keys(configs).length === 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
'No MCP servers configured. Use `claude mcp add` to add a server.',
|
||||
)
|
||||
console.log('No MCP servers configured. Use `claude mcp add` to add a server.');
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Checking MCP server health...\n')
|
||||
console.log('Checking MCP server health...\n');
|
||||
|
||||
// Check servers concurrently
|
||||
const entries = Object.entries(configs)
|
||||
const entries = Object.entries(configs);
|
||||
const results = await pMap(
|
||||
entries,
|
||||
async ([name, server]) => ({
|
||||
@@ -208,127 +168,100 @@ export async function mcpListHandler(): Promise<void> {
|
||||
status: await checkMcpServerHealth(name, server),
|
||||
}),
|
||||
{ concurrency: getMcpServerConnectionBatchSize() },
|
||||
)
|
||||
);
|
||||
|
||||
for (const { name, server, status } of results) {
|
||||
// Intentionally excluding sse-ide servers here since they're internal
|
||||
if (server.type === 'sse') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.url} (SSE) - ${status}`)
|
||||
console.log(`${name}: ${server.url} (SSE) - ${status}`);
|
||||
} else if (server.type === 'http') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.url} (HTTP) - ${status}`)
|
||||
console.log(`${name}: ${server.url} (HTTP) - ${status}`);
|
||||
} else if (server.type === 'claudeai-proxy') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${server.url} - ${status}`)
|
||||
console.log(`${name}: ${server.url} - ${status}`);
|
||||
} else if (!server.type || server.type === 'stdio') {
|
||||
const stdioServer = server as { command: string; args: string[]; type?: string }
|
||||
const args = Array.isArray(stdioServer.args) ? stdioServer.args : []
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`)
|
||||
const stdioServer = server as { command: string; args: string[]; type?: string };
|
||||
const args = Array.isArray(stdioServer.args) ? stdioServer.args : [];
|
||||
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use gracefulShutdown to properly clean up MCP server connections
|
||||
// (process.exit bypasses cleanup handlers, leaving child processes orphaned)
|
||||
await gracefulShutdown(0)
|
||||
await gracefulShutdown(0);
|
||||
}
|
||||
|
||||
// mcp get (lines 4694–4786)
|
||||
export async function mcpGetHandler(name: string): Promise<void> {
|
||||
logEvent('tengu_mcp_get', {
|
||||
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
const server = getMcpConfigByName(name)
|
||||
});
|
||||
const server = getMcpConfigByName(name);
|
||||
if (!server) {
|
||||
cliError(`No MCP server found with name: ${name}`)
|
||||
cliError(`No MCP server found with name: ${name}`);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${name}:`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Scope: ${getScopeLabel(server.scope)}`)
|
||||
console.log(`${name}:`);
|
||||
console.log(` Scope: ${getScopeLabel(server.scope)}`);
|
||||
|
||||
// Check server health
|
||||
const status = await checkMcpServerHealth(name, server)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Status: ${status}`)
|
||||
const status = await checkMcpServerHealth(name, server);
|
||||
console.log(` Status: ${status}`);
|
||||
|
||||
// Intentionally excluding sse-ide servers here since they're internal
|
||||
if (server.type === 'sse') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Type: sse`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` URL: ${server.url}`)
|
||||
console.log(` Type: sse`);
|
||||
console.log(` URL: ${server.url}`);
|
||||
if (server.headers) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(' Headers:')
|
||||
console.log(' Headers:');
|
||||
for (const [key, value] of Object.entries(server.headers)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${key}: ${value}`)
|
||||
console.log(` ${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
if (server.oauth?.clientId || server.oauth?.callbackPort) {
|
||||
const parts: string[] = []
|
||||
const parts: string[] = [];
|
||||
if (server.oauth.clientId) {
|
||||
parts.push('client_id configured')
|
||||
const clientConfig = getMcpClientConfig(name, server)
|
||||
if (clientConfig?.clientSecret) parts.push('client_secret configured')
|
||||
parts.push('client_id configured');
|
||||
const clientConfig = getMcpClientConfig(name, server);
|
||||
if (clientConfig?.clientSecret) parts.push('client_secret configured');
|
||||
}
|
||||
if (server.oauth.callbackPort)
|
||||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` OAuth: ${parts.join(', ')}`)
|
||||
if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`);
|
||||
console.log(` OAuth: ${parts.join(', ')}`);
|
||||
}
|
||||
} else if (server.type === 'http') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Type: http`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` URL: ${server.url}`)
|
||||
console.log(` Type: http`);
|
||||
console.log(` URL: ${server.url}`);
|
||||
if (server.headers) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(' Headers:')
|
||||
console.log(' Headers:');
|
||||
for (const [key, value] of Object.entries(server.headers)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${key}: ${value}`)
|
||||
console.log(` ${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
if (server.oauth?.clientId || server.oauth?.callbackPort) {
|
||||
const parts: string[] = []
|
||||
const parts: string[] = [];
|
||||
if (server.oauth.clientId) {
|
||||
parts.push('client_id configured')
|
||||
const clientConfig = getMcpClientConfig(name, server)
|
||||
if (clientConfig?.clientSecret) parts.push('client_secret configured')
|
||||
parts.push('client_id configured');
|
||||
const clientConfig = getMcpClientConfig(name, server);
|
||||
if (clientConfig?.clientSecret) parts.push('client_secret configured');
|
||||
}
|
||||
if (server.oauth.callbackPort)
|
||||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` OAuth: ${parts.join(', ')}`)
|
||||
if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`);
|
||||
console.log(` OAuth: ${parts.join(', ')}`);
|
||||
}
|
||||
} else if (server.type === 'stdio') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Type: stdio`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
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(' ')}`)
|
||||
console.log(` Type: stdio`);
|
||||
console.log(` Command: ${server.command}`);
|
||||
const args = Array.isArray(server.args) ? server.args : [];
|
||||
console.log(` Args: ${args.join(' ')}`);
|
||||
if (server.env) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(' Environment:')
|
||||
console.log(' Environment:');
|
||||
for (const [key, value] of Object.entries(server.env)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${key}=${value}`)
|
||||
console.log(` ${key}=${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`,
|
||||
)
|
||||
console.log(`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`);
|
||||
// Use gracefulShutdown to properly clean up MCP server connections
|
||||
// (process.exit bypasses cleanup handlers, leaving child processes orphaned)
|
||||
await gracefulShutdown(0)
|
||||
await gracefulShutdown(0);
|
||||
}
|
||||
|
||||
// mcp add-json (lines 4801–4870)
|
||||
@@ -338,8 +271,8 @@ export async function mcpAddJsonHandler(
|
||||
options: { scope?: string; clientSecret?: true },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const scope = ensureConfigScope(options.scope)
|
||||
const parsedJson = safeParseJSON(json)
|
||||
const scope = ensureConfigScope(options.scope);
|
||||
const parsedJson = safeParseJSON(json);
|
||||
|
||||
// Read secret before writing config so cancellation doesn't leave partial state
|
||||
const needsSecret =
|
||||
@@ -353,15 +286,15 @@ export async function mcpAddJsonHandler(
|
||||
'oauth' in parsedJson &&
|
||||
parsedJson.oauth &&
|
||||
typeof parsedJson.oauth === 'object' &&
|
||||
'clientId' in parsedJson.oauth
|
||||
const clientSecret = needsSecret ? await readClientSecret() : undefined
|
||||
'clientId' in parsedJson.oauth;
|
||||
const clientSecret = needsSecret ? await readClientSecret() : undefined;
|
||||
|
||||
await addMcpConfig(name, parsedJson, scope)
|
||||
await addMcpConfig(name, parsedJson, scope);
|
||||
|
||||
const transportType =
|
||||
parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson
|
||||
? String(parsedJson.type || 'stdio')
|
||||
: 'stdio'
|
||||
: 'stdio';
|
||||
|
||||
if (
|
||||
clientSecret &&
|
||||
@@ -372,53 +305,38 @@ export async function mcpAddJsonHandler(
|
||||
'url' in parsedJson &&
|
||||
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', {
|
||||
scope:
|
||||
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
source:
|
||||
'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
scope: 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,
|
||||
})
|
||||
});
|
||||
|
||||
cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`)
|
||||
cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`);
|
||||
} catch (error) {
|
||||
cliError((error as Error).message)
|
||||
cliError((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// mcp add-from-claude-desktop (lines 4881–4927)
|
||||
export async function mcpAddFromDesktopHandler(options: {
|
||||
scope?: string
|
||||
}): Promise<void> {
|
||||
export async function mcpAddFromDesktopHandler(options: { scope?: string }): Promise<void> {
|
||||
try {
|
||||
const scope = ensureConfigScope(options.scope)
|
||||
const platform = getPlatform()
|
||||
const scope = ensureConfigScope(options.scope);
|
||||
const platform = getPlatform();
|
||||
|
||||
logEvent('tengu_mcp_add', {
|
||||
scope:
|
||||
scope 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,
|
||||
})
|
||||
scope: scope 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 servers = await readClaudeDesktopMcpServers()
|
||||
const { readClaudeDesktopMcpServers } = await import('../../utils/claudeDesktop.js');
|
||||
const servers = await readClaudeDesktopMcpServers();
|
||||
|
||||
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(
|
||||
@@ -428,29 +346,29 @@ export async function mcpAddFromDesktopHandler(options: {
|
||||
servers={servers}
|
||||
scope={scope}
|
||||
onDone={() => {
|
||||
unmount()
|
||||
unmount();
|
||||
}}
|
||||
/>
|
||||
</KeybindingSetup>
|
||||
</AppStateProvider>,
|
||||
{ exitOnCtrlC: true },
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
cliError((error as Error).message)
|
||||
cliError((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// mcp reset-project-choices (lines 4935–4952)
|
||||
export async function mcpResetChoicesHandler(): Promise<void> {
|
||||
logEvent('tengu_mcp_reset_mcpjson_choices', {})
|
||||
logEvent('tengu_mcp_reset_mcpjson_choices', {});
|
||||
saveCurrentProjectConfig(current => ({
|
||||
...current,
|
||||
enabledMcpjsonServers: [],
|
||||
disabledMcpjsonServers: [],
|
||||
enableAllProjectMcpServers: false,
|
||||
}))
|
||||
}));
|
||||
cliOk(
|
||||
'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.',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,27 +72,21 @@ export function handleMarketplaceError(error: unknown, action: string): never {
|
||||
|
||||
function printValidationResult(result: ValidationResult): void {
|
||||
if (result.errors.length > 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
`${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n`,
|
||||
)
|
||||
result.errors.forEach(error => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${error.path}: ${error.message}`)
|
||||
})
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
}
|
||||
if (result.warnings.length > 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
`${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n`,
|
||||
)
|
||||
result.warnings.forEach(warning => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${warning.path}: ${warning.message}`)
|
||||
})
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
}
|
||||
}
|
||||
@@ -106,7 +100,6 @@ export async function pluginValidateHandler(
|
||||
try {
|
||||
const result = await validateManifest(manifestPath)
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`Validating ${result.fileType} manifest: ${result.filePath}\n`)
|
||||
printValidationResult(result)
|
||||
|
||||
@@ -120,7 +113,6 @@ export async function pluginValidateHandler(
|
||||
if (basename(manifestDir) === '.claude-plugin') {
|
||||
contentResults = await validatePluginContents(dirname(manifestDir))
|
||||
for (const r of contentResults) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`Validating ${r.fileType}: ${r.filePath}\n`)
|
||||
printValidationResult(r)
|
||||
}
|
||||
@@ -139,13 +131,11 @@ export async function pluginValidateHandler(
|
||||
: `${figures.tick} Validation passed`,
|
||||
)
|
||||
} else {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${figures.cross} Validation failed`)
|
||||
process.exit(1)
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`${figures.cross} Unexpected error during validation: ${errorMessage(error)}`,
|
||||
)
|
||||
@@ -358,7 +348,6 @@ export async function pluginListHandler(options: {
|
||||
}
|
||||
|
||||
if (pluginIds.length > 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Installed plugins:\n')
|
||||
}
|
||||
|
||||
@@ -383,25 +372,18 @@ export async function pluginListHandler(options: {
|
||||
const version = installation.version || 'unknown'
|
||||
const scope = installation.scope
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${pluginId}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Version: ${version}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Scope: ${scope}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Status: ${status}`)
|
||||
for (const error of pluginErrors) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Error: ${getPluginErrorMessage(error)}`)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
}
|
||||
}
|
||||
|
||||
if (inlinePlugins.length > 0 || inlineLoadErrors.length > 0) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Session-only plugins (--plugin-dir):\n')
|
||||
for (const p of inlinePlugins) {
|
||||
// Same dirName≠manifestName fallback as the JSON path above — error
|
||||
@@ -413,19 +395,13 @@ export async function pluginListHandler(options: {
|
||||
pErrors.length > 0
|
||||
? `${figures.cross} loaded with errors`
|
||||
: `${figures.tick} loaded`
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${p.source}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Version: ${p.manifest.version ?? 'unknown'}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Path: ${p.path}`)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Status: ${status}`)
|
||||
for (const e of pErrors) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Error: ${getPluginErrorMessage(e)}`)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
}
|
||||
// Path-level failures: no LoadedPlugin object exists. Show them so
|
||||
@@ -433,7 +409,6 @@ export async function pluginListHandler(options: {
|
||||
for (const e of inlineLoadErrors.filter(e =>
|
||||
e.source.startsWith('inline['),
|
||||
)) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(
|
||||
` ${figures.pointer} ${e.source}: ${figures.cross} ${getPluginErrorMessage(e)}\n`,
|
||||
)
|
||||
@@ -489,12 +464,10 @@ export async function marketplaceAddHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Adding marketplace...')
|
||||
|
||||
const { name, alreadyMaterialized, resolvedSource } =
|
||||
await addMarketplaceSource(marketplaceSource, message => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(message)
|
||||
})
|
||||
|
||||
@@ -555,33 +528,25 @@ export async function marketplaceListHandler(options: {
|
||||
cliOk('No marketplaces configured')
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('Configured marketplaces:\n')
|
||||
names.forEach(name => {
|
||||
const marketplace = config[name]
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` ${figures.pointer} ${name}`)
|
||||
|
||||
if (marketplace?.source) {
|
||||
const src = marketplace.source
|
||||
if (src.source === 'github') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: GitHub (${src.repo})`)
|
||||
} else if (src.source === 'git') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: Git (${src.url})`)
|
||||
} else if (src.source === 'url') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: URL (${src.url})`)
|
||||
} else if (src.source === 'directory') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: Directory (${src.path})`)
|
||||
} else if (src.source === 'file') {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(` Source: File (${src.path})`)
|
||||
}
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log('')
|
||||
})
|
||||
|
||||
@@ -620,11 +585,9 @@ export async function marketplaceUpdateHandler(
|
||||
if (options.cowork) setUseCoworkPlugins(true)
|
||||
try {
|
||||
if (name) {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`Updating marketplace: ${name}...`)
|
||||
|
||||
await refreshMarketplace(name, message => {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(message)
|
||||
})
|
||||
|
||||
@@ -644,7 +607,6 @@ export async function marketplaceUpdateHandler(
|
||||
cliOk('No marketplaces configured')
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`Updating ${marketplaceNames.length} marketplace(s)...`)
|
||||
|
||||
await refreshAllMarketplaces()
|
||||
|
||||
@@ -1,3 +1,158 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const templatesMain: (args: string[]) => Promise<void> = () => Promise.resolve();
|
||||
import { randomUUID } from 'crypto'
|
||||
import { listTemplates, loadTemplate } from '../../jobs/templates.js'
|
||||
import {
|
||||
createJob,
|
||||
readJobState,
|
||||
appendJobReply,
|
||||
getJobDir,
|
||||
} from '../../jobs/state.js'
|
||||
|
||||
/**
|
||||
* Entry point for template job commands: `new`, `list`, `reply`.
|
||||
* Called from cli.tsx fast-path.
|
||||
*/
|
||||
export async function templatesMain(args: string[]): Promise<void> {
|
||||
const subcommand = args[0]
|
||||
|
||||
switch (subcommand) {
|
||||
case 'list':
|
||||
handleList()
|
||||
break
|
||||
case 'new':
|
||||
handleNew(args.slice(1))
|
||||
break
|
||||
case 'reply':
|
||||
handleReply(args.slice(1))
|
||||
break
|
||||
case 'status':
|
||||
handleStatus(args.slice(1))
|
||||
break
|
||||
default:
|
||||
console.error(`Unknown template command: ${subcommand}`)
|
||||
printUsage()
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`
|
||||
Template Job Commands:
|
||||
|
||||
claude job list List available templates
|
||||
claude job new <template> [args] Create a new job from a template
|
||||
claude job reply <job-id> <text> Reply to an existing job
|
||||
claude job status <job-id> Show job status
|
||||
`)
|
||||
}
|
||||
|
||||
function handleStatus(args: string[]): void {
|
||||
const jobId = args[0]
|
||||
if (!jobId) {
|
||||
console.error('Usage: claude job status <job-id>')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const state = readJobState(jobId)
|
||||
if (!state) {
|
||||
console.error(`Job not found: ${jobId}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Job: ${state.jobId}`)
|
||||
console.log(` Template: ${state.templateName}`)
|
||||
console.log(` Status: ${state.status}`)
|
||||
console.log(` Created: ${state.createdAt}`)
|
||||
console.log(` Updated: ${state.updatedAt}`)
|
||||
console.log(` Args: ${state.args.join(' ') || '(none)'}`)
|
||||
}
|
||||
|
||||
function handleList(): void {
|
||||
const templates = listTemplates()
|
||||
|
||||
if (templates.length === 0) {
|
||||
console.log('No templates found.')
|
||||
console.log('Place .md files in .claude/templates/ or ~/.claude/templates/')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${templates.length} template${templates.length > 1 ? 's' : ''} found:\n`,
|
||||
)
|
||||
|
||||
for (const t of templates) {
|
||||
console.log(` ${t.name}`)
|
||||
console.log(` ${t.description}`)
|
||||
console.log(` Path: ${t.filePath}`)
|
||||
console.log()
|
||||
}
|
||||
}
|
||||
|
||||
function handleNew(args: string[]): void {
|
||||
const templateName = args[0]
|
||||
if (!templateName) {
|
||||
console.error('Usage: claude job new <template> [args...]')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const template = loadTemplate(templateName)
|
||||
if (!template) {
|
||||
console.error(`Template not found: ${templateName}`)
|
||||
console.log('\nAvailable templates:')
|
||||
for (const t of listTemplates()) {
|
||||
console.log(` ${t.name}`)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const jobId = randomUUID().slice(0, 8)
|
||||
const inputText = args.slice(1).join(' ')
|
||||
const rawContent = `---\n${Object.entries(template.frontmatter)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n')}\n---\n${template.content}`
|
||||
|
||||
const dir = createJob(
|
||||
jobId,
|
||||
templateName,
|
||||
rawContent,
|
||||
inputText,
|
||||
args.slice(1),
|
||||
)
|
||||
|
||||
console.log(`Job created: ${jobId}`)
|
||||
console.log(` Template: ${templateName}`)
|
||||
console.log(` Directory: ${dir}`)
|
||||
if (inputText) {
|
||||
console.log(` Input: ${inputText}`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReply(args: string[]): void {
|
||||
const jobId = args[0]
|
||||
const text = args.slice(1).join(' ')
|
||||
|
||||
if (!jobId || !text) {
|
||||
console.error('Usage: claude job reply <job-id> <text>')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const state = readJobState(jobId)
|
||||
if (!state) {
|
||||
console.error(`Job not found: ${jobId}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const ok = appendJobReply(jobId, text)
|
||||
if (ok) {
|
||||
console.log(`Reply added to job ${jobId}`)
|
||||
console.log(` Directory: ${getJobDir(jobId)}`)
|
||||
} else {
|
||||
console.error(`Failed to append reply to job ${jobId}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,24 @@
|
||||
*/
|
||||
/* eslint-disable custom-rules/no-process-exit -- CLI subcommand handlers intentionally exit */
|
||||
|
||||
import { cwd } from 'process'
|
||||
import React from 'react'
|
||||
import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js'
|
||||
import { useManagePlugins } from '../../hooks/useManagePlugins.js'
|
||||
import type { Root } from '@anthropic/ink'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js'
|
||||
import { AppStateProvider } from '../../state/AppState.js'
|
||||
import { onChangeAppState } from '../../state/onChangeAppState.js'
|
||||
import { isAnthropicAuthEnabled } from '../../utils/auth.js'
|
||||
import { cwd } from 'process';
|
||||
import React from 'react';
|
||||
import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js';
|
||||
import { useManagePlugins } from '../../hooks/useManagePlugins.js';
|
||||
import type { Root } from '@anthropic/ink';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js';
|
||||
import { AppStateProvider } from '../../state/AppState.js';
|
||||
import { onChangeAppState } from '../../state/onChangeAppState.js';
|
||||
import { isAnthropicAuthEnabled } from '../../utils/auth.js';
|
||||
|
||||
export async function setupTokenHandler(root: Root): Promise<void> {
|
||||
logEvent('tengu_setup_token_command', {})
|
||||
logEvent('tengu_setup_token_command', {});
|
||||
|
||||
const showAuthWarning = !isAnthropicAuthEnabled()
|
||||
const { ConsoleOAuthFlow } = await import(
|
||||
'../../components/ConsoleOAuthFlow.js'
|
||||
)
|
||||
const showAuthWarning = !isAnthropicAuthEnabled();
|
||||
const { ConsoleOAuthFlow } = await import('../../components/ConsoleOAuthFlow.js');
|
||||
await new Promise<void>(resolve => {
|
||||
root.render(
|
||||
<AppStateProvider onChangeAppState={onChangeAppState}>
|
||||
@@ -33,18 +31,16 @@ export async function setupTokenHandler(root: Root): Promise<void> {
|
||||
{showAuthWarning && (
|
||||
<Box flexDirection="column">
|
||||
<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 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>
|
||||
</Box>
|
||||
)}
|
||||
<ConsoleOAuthFlow
|
||||
onDone={() => {
|
||||
void resolve()
|
||||
void resolve();
|
||||
}}
|
||||
mode="setup-token"
|
||||
startingMessage="This will guide you through long-lived (1-year) auth token setup for your Claude account. Claude subscription required."
|
||||
@@ -52,75 +48,63 @@ export async function setupTokenHandler(root: Root): Promise<void> {
|
||||
</Box>
|
||||
</KeybindingSetup>
|
||||
</AppStateProvider>,
|
||||
)
|
||||
})
|
||||
root.unmount()
|
||||
process.exit(0)
|
||||
);
|
||||
});
|
||||
root.unmount();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
useManagePlugins()
|
||||
function DoctorWithPlugins({ onDone }: { onDone: () => void }): React.ReactNode {
|
||||
useManagePlugins();
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<DoctorLazy onDone={onDone} />
|
||||
</React.Suspense>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function doctorHandler(root: Root): Promise<void> {
|
||||
logEvent('tengu_doctor_command', {})
|
||||
logEvent('tengu_doctor_command', {});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
root.render(
|
||||
<AppStateProvider>
|
||||
<KeybindingSetup>
|
||||
<MCPConnectionManager
|
||||
dynamicMcpConfig={undefined}
|
||||
isStrictMcpConfig={false}
|
||||
>
|
||||
<MCPConnectionManager dynamicMcpConfig={undefined} isStrictMcpConfig={false}>
|
||||
<DoctorWithPlugins
|
||||
onDone={() => {
|
||||
void resolve()
|
||||
void resolve();
|
||||
}}
|
||||
/>
|
||||
</MCPConnectionManager>
|
||||
</KeybindingSetup>
|
||||
</AppStateProvider>,
|
||||
)
|
||||
})
|
||||
root.unmount()
|
||||
process.exit(0)
|
||||
);
|
||||
});
|
||||
root.unmount();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// install handler
|
||||
export async function installHandler(
|
||||
target: string | undefined,
|
||||
options: { force?: boolean },
|
||||
): Promise<void> {
|
||||
const { setup } = await import('../../setup.js')
|
||||
await setup(cwd(), 'default', false, false, undefined, false)
|
||||
const { install } = await import('../../commands/install.js')
|
||||
export async function installHandler(target: string | undefined, options: { force?: boolean }): 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 => {
|
||||
const args: string[] = []
|
||||
if (target) args.push(target)
|
||||
if (options.force) args.push('--force')
|
||||
const args: string[] = [];
|
||||
if (target) args.push(target);
|
||||
if (options.force) args.push('--force');
|
||||
|
||||
void install.call(
|
||||
result => {
|
||||
void resolve()
|
||||
process.exit(result.includes('failed') ? 1 : 0)
|
||||
void resolve();
|
||||
process.exit(result.includes('failed') ? 1 : 0);
|
||||
},
|
||||
{},
|
||||
args,
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
456
src/cli/print.ts
456
src/cli/print.ts
@@ -320,6 +320,17 @@ import {
|
||||
logQueryProfileReport,
|
||||
} from 'src/utils/queryProfiler.js'
|
||||
import { asSessionId } from 'src/types/ids.js'
|
||||
import {
|
||||
commitAutonomyQueuedPrompt,
|
||||
createAutonomyQueuedPrompt,
|
||||
createProactiveAutonomyCommands,
|
||||
finalizeAutonomyRunCompleted,
|
||||
finalizeAutonomyRunFailed,
|
||||
markAutonomyRunCompleted,
|
||||
markAutonomyRunFailed,
|
||||
markAutonomyRunRunning,
|
||||
} from 'src/utils/autonomyRuns.js'
|
||||
import { prepareAutonomyTurnPrompt } from 'src/utils/autonomyAuthority.js'
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
import { skillChangeDetector } from '../utils/skills/skillChangeDetector.js'
|
||||
import { getCommands, clearCommandsCache } from '../commands.js'
|
||||
@@ -362,9 +373,12 @@ const proactiveModule =
|
||||
feature('PROACTIVE') || feature('KAIROS')
|
||||
? (require('../proactive/index.js') as typeof import('../proactive/index.js'))
|
||||
: null
|
||||
const cronSchedulerModule = require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')
|
||||
const cronJitterConfigModule = 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 cronSchedulerModule =
|
||||
require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')
|
||||
const cronJitterConfigModule =
|
||||
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')
|
||||
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
|
||||
: null
|
||||
@@ -1180,7 +1194,9 @@ function runHeadlessStreaming(
|
||||
removeInterruptedMessage(mutableMessages, turnInterruptionState.message)
|
||||
enqueue({
|
||||
mode: 'prompt',
|
||||
value: turnInterruptionState.message.message!.content as string | ContentBlockParam[],
|
||||
value: turnInterruptionState.message.message!.content as
|
||||
| string
|
||||
| ContentBlockParam[],
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
}
|
||||
@@ -1642,7 +1658,10 @@ function runHeadlessStreaming(
|
||||
connection.config.type === 'stdio' ||
|
||||
connection.config.type === undefined
|
||||
) {
|
||||
const stdioConfig = connection.config as { command: string; args: string[] }
|
||||
const stdioConfig = connection.config as {
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
config = {
|
||||
type: 'stdio' as const,
|
||||
command: stdioConfig.command,
|
||||
@@ -1804,7 +1823,8 @@ function runHeadlessStreaming(
|
||||
}
|
||||
for (const [name, config] of Object.entries(sdkMcpConfigs)) {
|
||||
if (config.type === 'sdk' && !(name in supportedConfigs)) {
|
||||
supportedConfigs[name] = config as unknown as McpServerConfigForProcessTransport
|
||||
supportedConfigs[name] =
|
||||
config as unknown as McpServerConfigForProcessTransport
|
||||
}
|
||||
}
|
||||
const { response, sdkServersChanged } =
|
||||
@@ -1839,15 +1859,23 @@ function runHeadlessStreaming(
|
||||
) {
|
||||
return
|
||||
}
|
||||
const tickContent = `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`
|
||||
enqueue({
|
||||
mode: 'prompt' as const,
|
||||
value: tickContent,
|
||||
uuid: randomUUID(),
|
||||
priority: 'later',
|
||||
isMeta: true,
|
||||
})
|
||||
void run()
|
||||
void (async () => {
|
||||
const commands = await createProactiveAutonomyCommands({
|
||||
basePrompt: `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`,
|
||||
currentDir: cwd(),
|
||||
shouldCreate: () => !inputClosed,
|
||||
})
|
||||
for (const command of commands) {
|
||||
if (inputClosed) {
|
||||
return
|
||||
}
|
||||
enqueue({
|
||||
...command,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
}
|
||||
void run()
|
||||
})()
|
||||
}, 0)
|
||||
}
|
||||
: undefined
|
||||
@@ -2092,6 +2120,9 @@ function runHeadlessStreaming(
|
||||
}
|
||||
|
||||
const input = command.value
|
||||
const autonomyRunIds = batch
|
||||
.map(item => item.autonomy?.runId)
|
||||
.filter((runId): runId is string => Boolean(runId))
|
||||
|
||||
if (structuredIO instanceof RemoteIO && command.mode === 'prompt') {
|
||||
logEvent('tengu_bridge_message_received', {
|
||||
@@ -2141,107 +2172,151 @@ function runHeadlessStreaming(
|
||||
// const-capture: TS loses `while ((command = dequeue()))` narrowing
|
||||
// inside the closure.
|
||||
const cmd = command
|
||||
await runWithWorkload(cmd.workload ?? options.workload, async () => {
|
||||
for await (const message of ask({
|
||||
commands: uniqBy(
|
||||
[...currentCommands, ...appState.mcp.commands],
|
||||
'name',
|
||||
),
|
||||
prompt: input,
|
||||
promptUuid: cmd.uuid,
|
||||
isMeta: cmd.isMeta,
|
||||
cwd: cwd(),
|
||||
tools: allTools,
|
||||
verbose: options.verbose,
|
||||
mcpClients: allMcpClients,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
maxTurns: options.maxTurns,
|
||||
maxBudgetUsd: options.maxBudgetUsd,
|
||||
taskBudget: options.taskBudget,
|
||||
canUseTool,
|
||||
userSpecifiedModel: activeUserSpecifiedModel,
|
||||
fallbackModel: options.fallbackModel,
|
||||
jsonSchema: getInitJsonSchema() ?? options.jsonSchema,
|
||||
mutableMessages,
|
||||
getReadFileCache: () =>
|
||||
pendingSeeds.size === 0
|
||||
? readFileState
|
||||
: mergeFileStateCaches(readFileState, pendingSeeds),
|
||||
setReadFileCache: cache => {
|
||||
readFileState = cache
|
||||
for (const [path, seed] of pendingSeeds.entries()) {
|
||||
const existing = readFileState.get(path)
|
||||
if (!existing || seed.timestamp > existing.timestamp) {
|
||||
readFileState.set(path, seed)
|
||||
for (const runId of autonomyRunIds) {
|
||||
await markAutonomyRunRunning(runId)
|
||||
}
|
||||
let lastResultIsError = false
|
||||
try {
|
||||
await runWithWorkload(
|
||||
cmd.workload ?? options.workload,
|
||||
async () => {
|
||||
for await (const message of ask({
|
||||
commands: uniqBy(
|
||||
[...currentCommands, ...appState.mcp.commands],
|
||||
'name',
|
||||
),
|
||||
prompt: input,
|
||||
promptUuid: cmd.uuid,
|
||||
isMeta: cmd.isMeta,
|
||||
cwd: cwd(),
|
||||
tools: allTools,
|
||||
verbose: options.verbose,
|
||||
mcpClients: allMcpClients,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
maxTurns: options.maxTurns,
|
||||
maxBudgetUsd: options.maxBudgetUsd,
|
||||
taskBudget: options.taskBudget,
|
||||
canUseTool,
|
||||
userSpecifiedModel: activeUserSpecifiedModel,
|
||||
fallbackModel: options.fallbackModel,
|
||||
jsonSchema: getInitJsonSchema() ?? options.jsonSchema,
|
||||
mutableMessages,
|
||||
getReadFileCache: () =>
|
||||
pendingSeeds.size === 0
|
||||
? readFileState
|
||||
: mergeFileStateCaches(readFileState, pendingSeeds),
|
||||
setReadFileCache: cache => {
|
||||
readFileState = cache
|
||||
for (const [path, seed] of pendingSeeds.entries()) {
|
||||
const existing = readFileState.get(path)
|
||||
if (!existing || seed.timestamp > existing.timestamp) {
|
||||
readFileState.set(path, seed)
|
||||
}
|
||||
}
|
||||
pendingSeeds.clear()
|
||||
},
|
||||
customSystemPrompt: options.systemPrompt,
|
||||
appendSystemPrompt: options.appendSystemPrompt,
|
||||
getAppState,
|
||||
setAppState,
|
||||
abortController,
|
||||
replayUserMessages: options.replayUserMessages,
|
||||
includePartialMessages: options.includePartialMessages,
|
||||
handleElicitation: (serverName, params, elicitSignal) =>
|
||||
structuredIO.handleElicitation(
|
||||
serverName,
|
||||
params.message,
|
||||
undefined,
|
||||
elicitSignal,
|
||||
params.mode,
|
||||
params.url,
|
||||
'elicitationId' in params
|
||||
? params.elicitationId
|
||||
: undefined,
|
||||
),
|
||||
agents: currentAgents,
|
||||
orphanedPermission: cmd.orphanedPermission,
|
||||
setSDKStatus: status => {
|
||||
output.enqueue({
|
||||
type: 'system',
|
||||
subtype: 'status',
|
||||
status: status as 'compacting' | null,
|
||||
session_id: getSessionId(),
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
},
|
||||
})) {
|
||||
// Forward messages to bridge incrementally (mid-turn) so
|
||||
// claude.ai sees progress and the connection stays alive
|
||||
// while blocked on permission requests.
|
||||
forwardMessagesToBridge()
|
||||
|
||||
if (message.type === 'result') {
|
||||
lastResultIsError = !!(message as Record<string, unknown>)
|
||||
.is_error
|
||||
// Flush pending SDK events so they appear before result on the stream.
|
||||
for (const event of drainSdkEvents()) {
|
||||
output.enqueue(event)
|
||||
}
|
||||
|
||||
// Hold-back: don't emit result while background agents are running
|
||||
const currentState = getAppState()
|
||||
if (
|
||||
getRunningTasks(currentState).some(
|
||||
t =>
|
||||
(t.type === 'local_agent' ||
|
||||
t.type === 'local_workflow') &&
|
||||
isBackgroundTask(t),
|
||||
)
|
||||
) {
|
||||
heldBackResult = message as StdoutMessage
|
||||
} else {
|
||||
heldBackResult = null
|
||||
output.enqueue(message as StdoutMessage)
|
||||
}
|
||||
} else {
|
||||
// Flush SDK events (task_started, task_progress) so background
|
||||
// agent progress is streamed in real-time, not batched until result.
|
||||
for (const event of drainSdkEvents()) {
|
||||
output.enqueue(event)
|
||||
}
|
||||
output.enqueue(message as StdoutMessage)
|
||||
}
|
||||
}
|
||||
pendingSeeds.clear()
|
||||
},
|
||||
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(),
|
||||
) // end runWithWorkload
|
||||
if (lastResultIsError) {
|
||||
for (const runId of autonomyRunIds) {
|
||||
await finalizeAutonomyRunFailed({
|
||||
runId,
|
||||
error: 'ask() returned an error result',
|
||||
})
|
||||
},
|
||||
})) {
|
||||
// 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') {
|
||||
// Flush pending SDK events so they appear before result on the stream.
|
||||
for (const event of drainSdkEvents()) {
|
||||
output.enqueue(event)
|
||||
}
|
||||
} else {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
} catch (error) {
|
||||
for (const runId of autonomyRunIds) {
|
||||
await finalizeAutonomyRunFailed({
|
||||
runId,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
for (const uuid of batchUuids) {
|
||||
notifyCommandLifecycle(uuid, 'completed')
|
||||
@@ -2253,10 +2328,15 @@ function runHeadlessStreaming(
|
||||
|
||||
if (feature('FILE_PERSISTENCE') && turnStartTime !== undefined) {
|
||||
void executeFilePersistence(
|
||||
{ turnStartTime } as import('src/utils/filePersistence/types.js').TurnStartTime,
|
||||
{
|
||||
turnStartTime,
|
||||
} as import('src/utils/filePersistence/types.js').TurnStartTime,
|
||||
abortController.signal,
|
||||
result => {
|
||||
const filesResult = result as unknown as { persistedFiles: { filename: string; file_id: string }[]; failedFiles: { filename: string; error: string }[] }
|
||||
const filesResult = result as unknown as {
|
||||
persistedFiles: { filename: string; file_id: string }[]
|
||||
failedFiles: { filename: string; error: string }[]
|
||||
}
|
||||
output.enqueue({
|
||||
type: 'system' as const,
|
||||
subtype: 'files_persisted' as const,
|
||||
@@ -2700,28 +2780,73 @@ function runHeadlessStreaming(
|
||||
// the end of run() picks up the queued command.
|
||||
let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null =
|
||||
null
|
||||
if (
|
||||
cronGate.isKairosCronEnabled()
|
||||
) {
|
||||
if (cronGate.isKairosCronEnabled()) {
|
||||
cronScheduler = cronSchedulerModule.createCronScheduler({
|
||||
onFire: prompt => {
|
||||
if (inputClosed) return
|
||||
enqueue({
|
||||
mode: 'prompt',
|
||||
value: prompt,
|
||||
uuid: randomUUID(),
|
||||
priority: 'later',
|
||||
// System-generated — matches useScheduledTasks.ts REPL equivalent.
|
||||
// Without this, messages.ts metaProp eval is {} → prompt leaks
|
||||
// into visible transcript when cron fires mid-turn in -p mode.
|
||||
isMeta: true,
|
||||
// Threaded to cc_workload= in the billing-header attribution block
|
||||
// so the API can serve cron requests at lower QoS. drainCommandQueue
|
||||
// reads this per-iteration and hoists it into bootstrap state for
|
||||
// the ask() call.
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
void run()
|
||||
void (async () => {
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: cwd(),
|
||||
})
|
||||
if (inputClosed) return
|
||||
const command = await commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
currentDir: cwd(),
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
if (inputClosed) return
|
||||
enqueue({
|
||||
...command,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
void run()
|
||||
})()
|
||||
},
|
||||
onFireTask: task => {
|
||||
if (inputClosed) return
|
||||
void (async () => {
|
||||
if (task.agentId) {
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: task.prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: cwd(),
|
||||
})
|
||||
if (inputClosed) return
|
||||
const command = await commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
currentDir: cwd(),
|
||||
sourceId: task.id,
|
||||
sourceLabel: task.prompt,
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
await markAutonomyRunFailed(
|
||||
command.autonomy!.runId,
|
||||
`No teammate runtime available for scheduled task owner ${task.agentId} in headless mode.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: task.prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: cwd(),
|
||||
})
|
||||
if (inputClosed) return
|
||||
const command = await commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
currentDir: cwd(),
|
||||
sourceId: task.id,
|
||||
sourceLabel: task.prompt,
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
if (inputClosed) return
|
||||
enqueue({
|
||||
...command,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
void run()
|
||||
})()
|
||||
},
|
||||
isLoading: () => running || inputClosed,
|
||||
getJitterConfig: cronJitterConfigModule?.getCronJitterConfig,
|
||||
@@ -2996,7 +3121,9 @@ function runHeadlessStreaming(
|
||||
sdkClient.type === 'connected' &&
|
||||
sdkClient.client?.transport?.onmessage
|
||||
) {
|
||||
sdkClient.client.transport.onmessage(mcpRequest.message as import('@modelcontextprotocol/sdk/types.js').JSONRPCMessage)
|
||||
sdkClient.client.transport.onmessage(
|
||||
mcpRequest.message as import('@modelcontextprotocol/sdk/types.js').JSONRPCMessage,
|
||||
)
|
||||
}
|
||||
sendControlResponseSuccess(msg)
|
||||
} else if (msg.request.subtype === 'rewind_files') {
|
||||
@@ -3061,7 +3188,10 @@ function runHeadlessStreaming(
|
||||
sendControlResponseSuccess(msg)
|
||||
} else if (msg.request.subtype === 'mcp_set_servers') {
|
||||
const { response, sdkServersChanged } = await applyMcpServerChanges(
|
||||
msg.request.servers as Record<string, McpServerConfigForProcessTransport>,
|
||||
msg.request.servers as Record<
|
||||
string,
|
||||
McpServerConfigForProcessTransport
|
||||
>,
|
||||
)
|
||||
sendControlResponseSuccess(msg, response)
|
||||
|
||||
@@ -3131,7 +3261,8 @@ function runHeadlessStreaming(
|
||||
model: a.model === 'inherit' ? undefined : a.model,
|
||||
})),
|
||||
plugins,
|
||||
mcpServers: buildMcpServerStatuses() as SDKControlReloadPluginsResponse['mcpServers'],
|
||||
mcpServers:
|
||||
buildMcpServerStatuses() as SDKControlReloadPluginsResponse['mcpServers'],
|
||||
error_count: r.error_count,
|
||||
} satisfies SDKControlReloadPluginsResponse)
|
||||
} catch (error) {
|
||||
@@ -3406,7 +3537,7 @@ function runHeadlessStreaming(
|
||||
mcp: {
|
||||
...prev.mcp,
|
||||
clients: prev.mcp.clients.map(c =>
|
||||
c.name === serverName as string ? result.client : c,
|
||||
c.name === (serverName as string) ? result.client : c,
|
||||
),
|
||||
tools: [
|
||||
...reject(prev.mcp.tools, t =>
|
||||
@@ -3455,7 +3586,9 @@ function runHeadlessStreaming(
|
||||
})
|
||||
.finally(() => {
|
||||
// Clean up only if this is still the active flow
|
||||
if (activeOAuthFlows.get(serverName as string) === controller) {
|
||||
if (
|
||||
activeOAuthFlows.get(serverName as string) === controller
|
||||
) {
|
||||
activeOAuthFlows.delete(serverName as string)
|
||||
oauthCallbackSubmitters.delete(serverName as string)
|
||||
oauthManualCallbackUsed.delete(serverName as string)
|
||||
@@ -3570,7 +3703,9 @@ function runHeadlessStreaming(
|
||||
// next API call re-reads keychain/file and works. No respawn.
|
||||
await installOAuthTokens(tokens)
|
||||
logEvent('tengu_oauth_success', {
|
||||
loginWithClaudeAi: (loginWithClaudeAi ?? true) as boolean | number,
|
||||
loginWithClaudeAi: (loginWithClaudeAi ?? true) as
|
||||
| boolean
|
||||
| number,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -3618,10 +3753,7 @@ function runHeadlessStreaming(
|
||||
req.subtype === 'claude_oauth_wait_for_completion'
|
||||
) {
|
||||
if (!claudeOAuth) {
|
||||
sendControlResponseError(
|
||||
msg,
|
||||
'No active claude_authenticate flow',
|
||||
)
|
||||
sendControlResponseError(msg, 'No active claude_authenticate flow')
|
||||
} else {
|
||||
// Inject the manual code synchronously — must happen in stdin
|
||||
// message order so a subsequent claude_authenticate doesn't
|
||||
@@ -3681,7 +3813,7 @@ function runHeadlessStreaming(
|
||||
mcp: {
|
||||
...prev.mcp,
|
||||
clients: prev.mcp.clients.map(c =>
|
||||
c.name === serverName as string ? result.client : c,
|
||||
c.name === (serverName as string) ? result.client : c,
|
||||
),
|
||||
tools: [
|
||||
...reject(prev.mcp.tools, t => t.name?.startsWith(prefix)),
|
||||
@@ -4116,9 +4248,13 @@ function runHeadlessStreaming(
|
||||
mode: 'prompt' as const,
|
||||
// file_attachments rides the protobuf catchall from the web composer.
|
||||
// Same-ref no-op when absent (no 'file_attachments' key).
|
||||
value: await resolveAndPrepend(userMsg, (userMsg.message as { content: ContentBlockParam[] }).content),
|
||||
value: await resolveAndPrepend(
|
||||
userMsg,
|
||||
(userMsg.message as { content: ContentBlockParam[] }).content,
|
||||
),
|
||||
uuid: userMsg.uuid as `${string}-${string}-${string}-${string}-${string}`,
|
||||
priority: (userMsg as { priority?: string }).priority as import('src/types/textInputTypes.js').QueuePriority,
|
||||
priority: (userMsg as { priority?: string })
|
||||
.priority as import('src/types/textInputTypes.js').QueuePriority,
|
||||
})
|
||||
// Increment prompt count for attribution tracking and save snapshot
|
||||
// The snapshot persists promptCount so it survives compaction
|
||||
@@ -4447,7 +4583,10 @@ async function handleInitializeRequest(
|
||||
const accountInfo = getAccountInformation()
|
||||
if (request.hooks) {
|
||||
const hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>> = {}
|
||||
for (const [event, matchers] of Object.entries(request.hooks) as [string, Array<{ hookCallbackIds: string[]; timeout?: number; matcher?: string }>][]) {
|
||||
for (const [event, matchers] of Object.entries(request.hooks) as [
|
||||
string,
|
||||
Array<{ hookCallbackIds: string[]; timeout?: number; matcher?: string }>,
|
||||
][]) {
|
||||
hooks[event as HookEvent] = matchers.map(matcher => {
|
||||
const callbacks = matcher.hookCallbackIds.map(callbackId => {
|
||||
return structuredIO.createHookCallback(callbackId, matcher.timeout)
|
||||
@@ -4489,7 +4628,11 @@ async function handleInitializeRequest(
|
||||
// getAccountInformation() returns undefined under 3P providers, so the
|
||||
// other fields are all absent. apiProvider disambiguates "not logged
|
||||
// in" (firstParty + tokenSource:none) from "3P, login not applicable".
|
||||
apiProvider: getAPIProvider() as 'firstParty' | 'bedrock' | 'vertex' | 'foundry',
|
||||
apiProvider: getAPIProvider() as
|
||||
| 'firstParty'
|
||||
| 'bedrock'
|
||||
| 'vertex'
|
||||
| 'foundry',
|
||||
},
|
||||
pid: process.pid,
|
||||
}
|
||||
@@ -4537,7 +4680,11 @@ async function handleRewindFiles(
|
||||
dryRun: boolean,
|
||||
): Promise<RewindFilesResult> {
|
||||
if (!fileHistoryEnabled()) {
|
||||
return { canRewind: false, error: 'File rewinding is not enabled.', filesChanged: [] }
|
||||
return {
|
||||
canRewind: false,
|
||||
error: 'File rewinding is not enabled.',
|
||||
filesChanged: [],
|
||||
}
|
||||
}
|
||||
if (!fileHistoryCanRestore(appState.fileHistory, userMessageId)) {
|
||||
return {
|
||||
@@ -4842,7 +4989,10 @@ function reregisterChannelHandlerAfterReconnect(
|
||||
value: wrapChannelMessage(connection.name, content, meta),
|
||||
priority: 'next',
|
||||
isMeta: true,
|
||||
origin: { kind: 'channel', server: connection.name } as unknown as string,
|
||||
origin: {
|
||||
kind: 'channel',
|
||||
server: connection.name,
|
||||
} as unknown as string,
|
||||
skipSlashCommands: true,
|
||||
})
|
||||
},
|
||||
@@ -5266,13 +5416,21 @@ export async function handleOrphanedPermissionResponse({
|
||||
onEnqueued?: () => void
|
||||
handledToolUseIds: Set<string>
|
||||
}): Promise<boolean> {
|
||||
const responseInner = message.response as { subtype?: string; response?: Record<string, unknown>; request_id?: string } | undefined
|
||||
const responseInner = message.response as
|
||||
| {
|
||||
subtype?: string
|
||||
response?: Record<string, unknown>
|
||||
request_id?: string
|
||||
}
|
||||
| undefined
|
||||
if (
|
||||
responseInner?.subtype === 'success' &&
|
||||
responseInner.response?.toolUseID &&
|
||||
typeof responseInner.response.toolUseID === 'string'
|
||||
) {
|
||||
const permissionResult = responseInner.response as PermissionResult & { toolUseID?: string }
|
||||
const permissionResult = responseInner.response as PermissionResult & {
|
||||
toolUseID?: string
|
||||
}
|
||||
const toolUseID = permissionResult.toolUseID
|
||||
if (!toolUseID) {
|
||||
return false
|
||||
|
||||
@@ -1,2 +1,70 @@
|
||||
// Auto-generated stub
|
||||
export async function rollback(target?: string, options?: { list?: boolean; dryRun?: boolean; safe?: boolean }): Promise<void> {}
|
||||
/**
|
||||
* `claude rollback [target]` — roll back to a previous Claude Code version.
|
||||
*
|
||||
* ANT-only command (USER_TYPE === "ant").
|
||||
*
|
||||
* Options:
|
||||
* --list List recent published versions
|
||||
* --dry-run Show what would be installed without installing
|
||||
* --safe Roll back to the server-pinned safe version
|
||||
*/
|
||||
export async function rollback(
|
||||
target?: string,
|
||||
options?: { list?: boolean; dryRun?: boolean; safe?: boolean },
|
||||
): Promise<void> {
|
||||
if (options?.list) {
|
||||
console.log('Recent versions:')
|
||||
console.log(' (version listing requires access to the release registry)')
|
||||
console.log(' Use `claude update --list` for available versions.')
|
||||
return
|
||||
}
|
||||
|
||||
if (options?.safe) {
|
||||
console.log('Safe rollback: would install the server-pinned safe version.')
|
||||
if (options.dryRun) {
|
||||
console.log(' (dry run — no changes made)')
|
||||
return
|
||||
}
|
||||
console.log(' Safe version pinning requires access to the release API.')
|
||||
console.log(' Contact oncall for the current safe version.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
console.error(
|
||||
'Usage: claude rollback [target]\n\n' +
|
||||
'Options:\n' +
|
||||
' -l, --list List recent published versions\n' +
|
||||
' --dry-run Show what would be installed\n' +
|
||||
' --safe Roll back to server-pinned safe version\n\n' +
|
||||
'Examples:\n' +
|
||||
' claude rollback 2.1.880\n' +
|
||||
' claude rollback --list\n' +
|
||||
' claude rollback --safe',
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Rolling back to version ${target}...`)
|
||||
|
||||
if (options?.dryRun) {
|
||||
console.log(` (dry run — would install ${target})`)
|
||||
return
|
||||
}
|
||||
|
||||
// Version rollback via npm/bun
|
||||
const { spawnSync } = await import('child_process')
|
||||
const result = spawnSync(
|
||||
'npm',
|
||||
['install', '-g', `@anthropic-ai/claude-code@${target}`],
|
||||
{ stdio: 'inherit' },
|
||||
)
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.error(`Rollback failed with exit code ${result.status}`)
|
||||
process.exitCode = result.status ?? 1
|
||||
} else {
|
||||
console.log(`Rolled back to ${target} successfully.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ask = any;
|
||||
export type ask = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type installOAuthTokens = any;
|
||||
export type installOAuthTokens = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type RemoteIO = any;
|
||||
export type RemoteIO = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type StructuredIO = any;
|
||||
export type StructuredIO = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type collectContextData = any;
|
||||
export type collectContextData = any
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SDKStatus = any;
|
||||
export type ModelInfo = any;
|
||||
export type SDKMessage = any;
|
||||
export type SDKUserMessage = any;
|
||||
export type SDKUserMessageReplay = any;
|
||||
export type PermissionResult = any;
|
||||
export type McpServerConfigForProcessTransport = any;
|
||||
export type McpServerStatus = any;
|
||||
export type RewindFilesResult = any;
|
||||
export type HookEvent = any;
|
||||
export type HookInput = any;
|
||||
export type HookJSONOutput = any;
|
||||
export type PermissionUpdate = any;
|
||||
export type SDKStatus = any
|
||||
export type ModelInfo = any
|
||||
export type SDKMessage = any
|
||||
export type SDKUserMessage = any
|
||||
export type SDKUserMessageReplay = any
|
||||
export type PermissionResult = any
|
||||
export type McpServerConfigForProcessTransport = any
|
||||
export type McpServerStatus = any
|
||||
export type RewindFilesResult = any
|
||||
export type HookEvent = any
|
||||
export type HookInput = any
|
||||
export type HookJSONOutput = any
|
||||
export type PermissionUpdate = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SDKControlElicitationResponseSchema = any;
|
||||
export type SDKControlElicitationResponseSchema = any
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type StdoutMessage = any;
|
||||
export type SDKControlInitializeRequest = any;
|
||||
export type SDKControlInitializeResponse = any;
|
||||
export type SDKControlRequest = any;
|
||||
export type SDKControlResponse = any;
|
||||
export type SDKControlMcpSetServersResponse = any;
|
||||
export type SDKControlReloadPluginsResponse = any;
|
||||
export type StdinMessage = any;
|
||||
export type StdoutMessage = any
|
||||
export type SDKControlInitializeRequest = any
|
||||
export type SDKControlInitializeResponse = any
|
||||
export type SDKControlRequest = any
|
||||
export type SDKControlResponse = any
|
||||
export type SDKControlMcpSetServersResponse = any
|
||||
export type SDKControlReloadPluginsResponse = any
|
||||
export type StdinMessage = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type CanUseToolFn = any;
|
||||
export type CanUseToolFn = any
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type tryGenerateSuggestion = any;
|
||||
export type logSuggestionOutcome = any;
|
||||
export type logSuggestionSuppressed = any;
|
||||
export type PromptVariant = any;
|
||||
export type tryGenerateSuggestion = any
|
||||
export type logSuggestionOutcome = any
|
||||
export type logSuggestionSuppressed = any
|
||||
export type PromptVariant = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE = any;
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE = any
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logEvent = any;
|
||||
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any;
|
||||
export type logEvent = any
|
||||
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type isQualifiedForGrove = any;
|
||||
export type checkGroveForNonInteractive = any;
|
||||
export type isQualifiedForGrove = any
|
||||
export type checkGroveForNonInteractive = any
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type EMPTY_USAGE = any;
|
||||
export type EMPTY_USAGE = any
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type statusListeners = any;
|
||||
export type ClaudeAILimits = any;
|
||||
export type statusListeners = any
|
||||
export type ClaudeAILimits = any
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type performMCPOAuthFlow = any;
|
||||
export type revokeServerTokens = any;
|
||||
export type performMCPOAuthFlow = any
|
||||
export type revokeServerTokens = any
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type isChannelAllowlisted = any;
|
||||
export type isChannelsEnabled = any;
|
||||
export type isChannelAllowlisted = any
|
||||
export type isChannelsEnabled = any
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ChannelMessageNotificationSchema = any;
|
||||
export type gateChannelServer = any;
|
||||
export type wrapChannelMessage = any;
|
||||
export type findChannelEntry = any;
|
||||
export type ChannelMessageNotificationSchema = any
|
||||
export type gateChannelServer = any
|
||||
export type wrapChannelMessage = any
|
||||
export type findChannelEntry = any
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type setupSdkMcpClients = any;
|
||||
export type connectToServer = any;
|
||||
export type clearServerCache = any;
|
||||
export type fetchToolsForClient = any;
|
||||
export type areMcpConfigsEqual = any;
|
||||
export type reconnectMcpServerImpl = any;
|
||||
export type setupSdkMcpClients = any
|
||||
export type connectToServer = any
|
||||
export type clearServerCache = any
|
||||
export type fetchToolsForClient = any
|
||||
export type areMcpConfigsEqual = any
|
||||
export type reconnectMcpServerImpl = any
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type filterMcpServersByPolicy = any;
|
||||
export type getMcpConfigByName = any;
|
||||
export type isMcpServerDisabled = any;
|
||||
export type setMcpServerEnabled = any;
|
||||
export type getAllMcpConfigs = any;
|
||||
export type filterMcpServersByPolicy = any
|
||||
export type getMcpConfigByName = any
|
||||
export type isMcpServerDisabled = any
|
||||
export type setMcpServerEnabled = any
|
||||
export type getAllMcpConfigs = any
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type runElicitationHooks = any;
|
||||
export type runElicitationResultHooks = any;
|
||||
export type runElicitationHooks = any
|
||||
export type runElicitationResultHooks = any
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user