Feat/integrate lint preview (#285)

* feat: 适配 zed acp 协议

* docs: 完善 acp 文档

* feat: integrate feature branches + daemon/job 命令层级化 + 跨平台后台引擎

Cherry-picked from origin/lint/preview (637c908), excluding lint-only changes.

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

* fix: correct detectMimeFromBase64 to decode raw bytes from base64

Cherry-picked from origin/lint/preview (ee36954).

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

* fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构

Cherry-picked from origin/lint/preview (c5f52cd), excluding lint-only formatting changes.

- 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层
- 修复 --daemon-worker=kind 等号格式解析
- 修复 daemon/bg fast path 缺少 setShellIfWindows()
- 修复 checkPathExists 用 existsSync 替代 execSync('dir')
- 7 个 spawn 站点迁移到 CliLaunchSpec

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

* fix: merge tsconfig.base.json into tsconfig.json with full compiler options

The cherry-pick from 637c908 dropped jsx/strict/etc settings when removing
tsconfig.base.json. This commit restores them in a single tsconfig.json.

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

* fix: merge tsconfig.base.json into tsconfig.json with full compiler options

The cherry-pick from 637c908 dropped jsx/strict/etc settings when removing
tsconfig.base.json. This commit restores them in a single tsconfig.json.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-16 20:59:29 +08:00
committed by GitHub
parent a02dc0bded
commit c8d08d235b
137 changed files with 13267 additions and 837 deletions

2
.gitignore vendored
View File

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

View File

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

View 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 是唯一跨终端方案。

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

View File

@@ -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不处理多实例并发
## 依赖
无外部依赖,可独立实施。

View 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 状态管理可复用模式,但非硬性依赖)

View 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",范围会明显膨胀
## 依赖
无硬性依赖,可独立实施。

View 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` (无参数) — 返回明确提示: 当前版本需要显式 sessionIddiscovery 尚未启用
**验证:**
- [ ] `claude assistant <sessionId>` 能进入 remote viewer
- [ ] 历史懒加载工作正常
- [ ] 无参数模式给出明确提示
### Phase 4B: session discovery
**修改文件:**
| 文件 | 改动 |
|------|------|
| `src/assistant/sessionDiscovery.ts` | 恢复 `discoverAssistantSessions()` |
**行为:**
- 数据来源优先复用现有 sessions / bridge / teleport API不新增协议
- `claude assistant` 无参数时能拿到候选 session 列表
**验证:**
- [ ] 无参数调用能列出可用 sessions
- [ ] 数据来源复用现有通道
### Phase 4C: session chooser
**修改文件:**
| 文件 | 改动 |
|------|------|
| `src/assistant/AssistantSessionChooser.ts` | 恢复交互式选择器 |
**行为:**
- 多 session 时可交互选择
**验证:**
- [ ] 多个 session 时弹出选择器
- [ ] 选择后正确 attach
### Phase 4D: install wizard
**修改文件:**
| 文件 | 改动 |
|------|------|
| `src/commands/assistant/assistant.ts` | 恢复 install wizard 辅助函数 |
**行为:**
- 没有 session 时如何引导用户
**验证:**
- [ ] 无可用 session 时引导用户创建/连接
## 为什么拆分
- attach 渲染层与远端消息通道大部分已在
- 真正缺的是"如何发现目标 session"和"如何交互选择"
- 如果把 `src/assistant/index.ts` 的整套 KAIROS 正常模式也一起拉进来,范围会失控
## 风险
- 这是四项里范围最大的
- 一旦把 KAIROS 正常模式整体拉入,会从"viewer attach"膨胀成"完整 assistant mode 恢复"
## 依赖
- Task 002 的 session registry 模式可复用

View File

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

View 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 通过

View 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 通过

View 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 上验证关键路径

View 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.

View File

@@ -1,5 +1,5 @@
{ {
"extends": "../../../tsconfig.base.json", "extends": "../../../tsconfig.json",
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -1,5 +1,5 @@
{ {
"extends": "../../../tsconfig.base.json", "extends": "../../../tsconfig.json",
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

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

View File

@@ -1,5 +1,5 @@
{ {
"extends": "../../../tsconfig.base.json", "extends": "../../../tsconfig.json",
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -1,5 +1,5 @@
{ {
"extends": "../../../tsconfig.base.json", "extends": "../../../tsconfig.json",
"include": ["src/**/*.ts", "src/**/*.tsx"], "include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -1,5 +1,5 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.json",
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -1,5 +1,5 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.json",
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -1,7 +1,9 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4' import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js' import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js' import { buildTool } from 'src/Tool.js'
import { lazySchema } from 'src/utils/lazySchema.js' import { lazySchema } from 'src/utils/lazySchema.js'
import { logForDebugging } from 'src/utils/debug.js'
const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification' const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification'
@@ -74,14 +76,58 @@ Requires Remote Control to be configured. Respects user notification settings (t
} }
}, },
async call(_input: PushInput) { async call(input: PushInput, context) {
// Push delivery is handled by the Remote Control / KAIROS transport layer. const appState = context.getAppState()
// Without the KAIROS runtime, this tool is not available.
return { // Try bridge delivery first (for remote/mobile viewers)
data: { if (appState.replBridgeEnabled) {
sent: false, if (feature('BRIDGE_MODE')) {
error: 'PushNotification requires the KAIROS transport layer.', 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.' } }
}, },
}) })

View File

@@ -70,14 +70,51 @@ Guidelines:
} }
}, },
async call(_input: SendUserFileInput) { async call(input: SendUserFileInput, context) {
// File transfer is handled by the KAIROS assistant transport layer. const { file_path } = input
// Without the KAIROS runtime, this tool is not available. 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 { return {
data: { data: {
sent: false, sent: delivered,
file_path: _input.file_path, file_path,
error: 'SendUserFile requires the KAIROS assistant transport layer.', size: fileSize,
...(fileUuid ? { file_uuid: fileUuid } : {}),
...(!delivered ? { error: 'Bridge upload failed. File available at local path.' } : {}),
}, },
} }
}, },

View File

@@ -1,5 +1,5 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.json",
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -1,5 +1,5 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.json",
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -1,5 +1,5 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.json",
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -1,5 +1,5 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.json",
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View 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);
}

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { log, error as logError } from "../../logger";
import { Hono } from "hono"; import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware"; import { uuidAuth } from "../../auth/middleware";
import { getSession, isSessionClosedStatus, resolveOwnedWebSessionId, updateSessionStatus } from "../../services/session"; import { getSession, isSessionClosedStatus, resolveOwnedWebSessionId, updateSessionStatus } from "../../services/session";
@@ -44,9 +45,9 @@ app.post("/sessions/:id/events", uuidAuth, async (c) => {
const body = await c.req.json(); const body = await c.req.json();
const eventType = body.type || "user"; const eventType = body.type || "user";
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"); 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); return c.json({ status: "ok", event }, 200);
}); });

View File

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

View File

@@ -1,3 +1,4 @@
import { log, error as logError } from "../logger";
import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store"; import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store";
import { storeListSessions } from "../store"; import { storeListSessions } from "../store";
import { config } from "../config"; import { config } from "../config";
@@ -10,7 +11,7 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
const envs = storeListActiveEnvironments(); const envs = storeListActiveEnvironments();
for (const env of envs) { for (const env of envs) {
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) { if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
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" }); storeUpdateEnvironment(env.id, { status: "disconnected" });
} }
} }
@@ -21,7 +22,7 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
if (session.status === "running" || session.status === "idle") { if (session.status === "running" || session.status === "idle") {
const elapsed = now - session.updatedAt.getTime(); const elapsed = now - session.updatedAt.getTime();
if (elapsed > timeoutMs * 2) { if (elapsed > timeoutMs * 2) {
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)`);
updateSessionStatus(session.id, "inactive"); updateSessionStatus(session.id, "inactive");
} }
} }

View File

@@ -1,3 +1,4 @@
import { log, error as logError } from "../logger";
import { import {
storeCreateWorkItem, storeCreateWorkItem,
storeGetWorkItem, storeGetWorkItem,
@@ -35,7 +36,7 @@ export async function createWorkItem(environmentId: string, sessionId: string):
const secret = encodeWorkSecret(); const secret = encodeWorkSecret();
const record = storeCreateWorkItem({ environmentId, sessionId, secret }); const record = storeCreateWorkItem({ environmentId, sessionId, secret });
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; return record.id;
} }

View File

@@ -1,3 +1,5 @@
import { log, error as logError } from "../logger";
export interface SessionEvent { export interface SessionEvent {
id: string; id: string;
sessionId: string; sessionId: string;
@@ -33,12 +35,12 @@ export class EventBus {
createdAt: Date.now(), createdAt: Date.now(),
}; };
this.events.push(full); 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) { for (const cb of this.subscribers) {
try { try {
cb(full); cb(full);
} catch (err) { } catch (err) {
console.error(`[RC-DEBUG] bus subscriber error:`, err); logError(`[RC-DEBUG] bus subscriber error:`, err);
} }
} }
return full; return full;

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.json",
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "web"] "exclude": ["node_modules", "dist", "web"]
} }

View File

@@ -1,5 +1,5 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.json",
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

@@ -1,9 +1,64 @@
// Auto-generated stub — replace with real implementation import { readFileSync } from 'fs'
export {} import { join } from 'path'
export const isAssistantMode: () => boolean = () => false import { getKairosActive } from '../bootstrap/state.js'
export const initializeAssistantTeam: () => Promise<void> = async () => {} import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
export const markAssistantForced: () => void = () => {}
export const isAssistantForced: () => boolean = () => false let _assistantForced = false
export const getAssistantSystemPromptAddendum: () => string = () => ''
export const getAssistantActivationPath: () => string | undefined = () => /**
undefined * 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'
}

View File

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

View File

@@ -12,6 +12,7 @@ import {
logEventAsync, logEventAsync,
} from '../services/analytics/index.js' } from '../services/analytics/index.js'
import { isInBundledMode } from '../utils/bundledMode.js' import { isInBundledMode } from '../utils/bundledMode.js'
import { getBootstrapArgs, getScriptPath } from '../utils/cliLaunch.js'
import { logForDebugging } from '../utils/debug.js' import { logForDebugging } from '../utils/debug.js'
import { rcLog } from './rcDebugLog.js' import { rcLog } from './rcDebugLog.js'
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js' import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
@@ -111,17 +112,15 @@ function pollSleepDetectionThresholdMs(backoff: BackoffConfig): number {
/** /**
* Returns the args that must precede CLI flags when spawning a child claude * Returns the args that must precede CLI flags when spawning a child claude
* process. In compiled binaries, process.execPath is the claude binary itself * process. Delegates to the centralized cliLaunch module which handles
* and args go directly to it. In npm installs (node running cli.js), * bundled-vs-script mode, execArgv sanitization, and the Bun execArgv leak
* process.execPath is the node runtime — the child spawn must pass the script * quirk. See anthropics/claude-code#28334.
* path as the first arg, otherwise node interprets --sdk-url as a node option
* and exits with "bad option: --sdk-url". See anthropics/claude-code#28334.
*/ */
function spawnScriptArgs(): string[] { function spawnScriptArgs(): string[] {
if (isInBundledMode() || !process.argv[1]) { const bootstrap = [...getBootstrapArgs()]
return [] const script = getScriptPath()
} if (script) bootstrap.push(script)
return [process.argv[1]] return bootstrap
} }
/** Attempt to spawn a session; returns error string if spawn throws. */ /** Attempt to spawn a session; returns error string if spawn throws. */

View File

@@ -1,7 +1,337 @@
// Auto-generated stub — replace with real implementation import { readdir, readFile, unlink } from 'fs/promises'
export {}; import { join } from 'path'
export const psHandler: (args: string[]) => Promise<void> = (async () => {}) as (args: string[]) => Promise<void>; import { randomUUID } from 'crypto'
export const logsHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>; import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
export const attachHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>; import { isProcessRunning } from '../utils/genericProcessUtils.js'
export const killHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>; import { jsonParse } from '../utils/slowOperations.js'
export const handleBgFlag: (args: string[]) => Promise<void> = (async () => {}) as (args: string[]) => Promise<void>; 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

View 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)
})
})

View 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')
})
})

View 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
View 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>
}

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
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
View 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
}
})
})
}

View File

@@ -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 {}; const DEFAULT_LIST = 'default'
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>; // ─── Group C: Task CRUD ──────────────────────────────────────────────────────
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 async function taskCreateHandler(
export const taskListHandler: (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise<void> = (async () => {}) as (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise<void>; subject: string,
export const taskGetHandler: (id: string, opts: { list?: string }) => Promise<void> = (async () => {}) as (id: string, opts: { list?: string }) => Promise<void>; opts: { description?: string; list?: string },
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>; ): Promise<void> {
export const taskDirHandler: (opts: { list?: string }) => Promise<void> = (async () => {}) as (opts: { list?: string }) => Promise<void>; const listId = opts.list || DEFAULT_LIST
export const completionHandler: (shell: string, opts: { output?: string }, program: Command) => Promise<void> = (async () => {}) as (shell: string, opts: { output?: string }, program: Command) => Promise<void>; 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}.`)
}
}

View File

@@ -1,3 +1,158 @@
// Auto-generated stub — replace with real implementation import { randomUUID } from 'crypto'
export {}; import { listTemplates, loadTemplate } from '../../jobs/templates.js'
export const templatesMain: (args: string[]) => Promise<void> = () => Promise.resolve(); 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
}
}

View File

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

View File

@@ -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.`)
}
}

View File

@@ -1,2 +1,95 @@
// Auto-generated stub import { readFileSync } from 'fs'
export async function up(): Promise<void> {} import { join } from 'path'
import { spawnSync } from 'child_process'
import { findGitRoot } from '../utils/git.js'
/**
* `claude up` — run the "# claude up" section from the nearest CLAUDE.md.
*
* Walks up from CWD looking for CLAUDE.md files, extracts the section
* under the `# claude up` heading, and executes it as a shell script.
*
* ANT-only command (USER_TYPE === "ant").
*/
export async function up(): Promise<void> {
const cwd = process.cwd()
const gitRoot = findGitRoot(cwd)
const searchDirs = gitRoot ? [gitRoot, cwd] : [cwd]
let upSection: string | null = null
for (const dir of searchDirs) {
const claudeMdPath = join(dir, 'CLAUDE.md')
try {
const content = readFileSync(claudeMdPath, 'utf-8')
upSection = extractUpSection(content)
if (upSection) {
console.log(`Found "# claude up" in ${claudeMdPath}`)
break
}
} catch {
// File not found — continue searching
}
}
if (!upSection) {
console.log(
'No "# claude up" section found in CLAUDE.md.\n' +
'Add a section like:\n\n' +
' # claude up\n' +
' ```bash\n' +
' npm install\n' +
' npm run build\n' +
' ```',
)
return
}
console.log('Running:\n')
console.log(upSection)
console.log()
const result = spawnSync('bash', ['-c', upSection], {
cwd,
stdio: 'inherit',
})
if (result.status !== 0) {
console.error(`\nclaude up failed with exit code ${result.status}`)
process.exitCode = result.status ?? 1
} else {
console.log('\nclaude up completed successfully.')
}
}
/**
* Extract the content under "# claude up" heading from markdown.
* Returns the text between `# claude up` and the next `#` heading (or EOF).
* Strips fenced code block markers if present.
*/
function extractUpSection(markdown: string): string | null {
const lines = markdown.split('\n')
let inSection = false
const sectionLines: string[] = []
for (const line of lines) {
if (/^#\s+claude\s+up\b/i.test(line)) {
inSection = true
continue
}
if (inSection && /^#\s/.test(line)) {
break
}
if (inSection) {
sectionLines.push(line)
}
}
if (sectionLines.length === 0) return null
// Strip fenced code block markers
let text = sectionLines.join('\n').trim()
text = text.replace(/^```\w*\n?/, '').replace(/\n?```\s*$/, '')
return text.trim() || null
}

View File

@@ -25,6 +25,7 @@ import ide from './commands/ide/index.js'
import init from './commands/init.js' import init from './commands/init.js'
import initVerifiers from './commands/init-verifiers.js' import initVerifiers from './commands/init-verifiers.js'
import keybindings from './commands/keybindings/index.js' import keybindings from './commands/keybindings/index.js'
import lang from './commands/lang/index.js'
import login from './commands/login/index.js' import login from './commands/login/index.js'
import logout from './commands/logout/index.js' import logout from './commands/logout/index.js'
import installGitHubApp from './commands/install-github-app/index.js' import installGitHubApp from './commands/install-github-app/index.js'
@@ -111,6 +112,13 @@ const ultraplan = feature('ULTRAPLAN')
? require('./commands/ultraplan.js').default ? require('./commands/ultraplan.js').default
: null : null
const torch = feature('TORCH') ? require('./commands/torch.js').default : null const torch = feature('TORCH') ? require('./commands/torch.js').default : null
const daemonCmd =
feature('DAEMON') || feature('BG_SESSIONS')
? require('./commands/daemon/index.js').default
: null
const jobCmd = feature('TEMPLATES')
? require('./commands/job/index.js').default
: null
const peersCmd = feature('UDS_INBOX') const peersCmd = feature('UDS_INBOX')
? ( ? (
require('./commands/peers/index.js') as typeof import('./commands/peers/index.js') require('./commands/peers/index.js') as typeof import('./commands/peers/index.js')
@@ -182,6 +190,7 @@ import sandboxToggle from './commands/sandbox-toggle/index.js'
import chrome from './commands/chrome/index.js' import chrome from './commands/chrome/index.js'
import stickers from './commands/stickers/index.js' import stickers from './commands/stickers/index.js'
import advisor from './commands/advisor.js' import advisor from './commands/advisor.js'
import autonomy from './commands/autonomy.js'
import provider from './commands/provider.js' import provider from './commands/provider.js'
import { logError } from './utils/log.js' import { logError } from './utils/log.js'
import { toError } from './utils/errors.js' import { toError } from './utils/errors.js'
@@ -290,6 +299,7 @@ export const INTERNAL_ONLY_COMMANDS = [
const COMMANDS = memoize((): Command[] => [ const COMMANDS = memoize((): Command[] => [
addDir, addDir,
advisor, advisor,
autonomy,
provider, provider,
agents, agents,
branch, branch,
@@ -315,6 +325,7 @@ const COMMANDS = memoize((): Command[] => [
ide, ide,
init, init,
keybindings, keybindings,
lang,
installGitHubApp, installGitHubApp,
installSlackApp, installSlackApp,
mcp, mcp,
@@ -384,6 +395,8 @@ const COMMANDS = memoize((): Command[] => [
...(workflowsCmd ? [workflowsCmd] : []), ...(workflowsCmd ? [workflowsCmd] : []),
...(ultraplan ? [ultraplan] : []), ...(ultraplan ? [ultraplan] : []),
...(torch ? [torch] : []), ...(torch ? [torch] : []),
...(daemonCmd ? [daemonCmd] : []),
...(jobCmd ? [jobCmd] : []),
...(process.env.USER_TYPE === 'ant' && !process.env.IS_DEMO ...(process.env.USER_TYPE === 'ant' && !process.env.IS_DEMO
? INTERNAL_ONLY_COMMANDS ? INTERNAL_ONLY_COMMANDS
: []), : []),

View File

@@ -0,0 +1,246 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import autonomyCommand from '../autonomy'
import type { LocalCommandResult } from '../../types/command'
import {
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state'
function expectTextResult(
result: LocalCommandResult,
): asserts result is Extract<LocalCommandResult, { type: 'text' }> {
if (result.type !== 'text')
throw new Error(`Expected text result, got ${result.type}`)
}
import { listAutonomyFlows } from '../../utils/autonomyFlows'
import {
createAutonomyQueuedPrompt,
markAutonomyRunCompleted,
startManagedAutonomyFlowFromHeartbeatTask,
} from '../../utils/autonomyRuns'
import {
enqueuePendingNotification,
getCommandQueueSnapshot,
resetCommandQueue,
} from '../../utils/messageQueueManager'
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
let tempDir = ''
beforeEach(async () => {
tempDir = await createTempDir('autonomy-command-')
resetStateForTests()
resetCommandQueue()
setOriginalCwd(tempDir)
setProjectRoot(tempDir)
})
afterEach(async () => {
resetStateForTests()
resetCommandQueue()
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('/autonomy', () => {
test('status reports autonomy runs and managed flows separately', async () => {
const plainRun = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceLabel: 'nightly',
})
expect(plainRun).not.toBeNull()
await markAutonomyRunCompleted(plainRun!.autonomy!.runId, tempDir)
await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
},
{
name: 'draft',
prompt: 'Draft the weekly report',
},
],
},
rootDir: tempDir,
currentDir: tempDir,
})
const mod = await autonomyCommand.load()
const result = await mod.call('', {} as any)
expectTextResult(result)
expect(result.value).toContain('Autonomy runs: 2')
expect(result.value).toContain('Autonomy flows: 1')
expect(result.value).toContain('Completed: 1')
expect(result.value).toContain('Queued: 1')
})
test('runs subcommand lists recent autonomy runs', async () => {
const queued = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
})
const mod = await autonomyCommand.load()
const result = await mod.call('runs 5', {} as any)
expectTextResult(result)
expect(result.value).toContain(queued!.autonomy!.runId)
expect(result.value).toContain('proactive-tick')
})
test('flows subcommand lists managed flows and flow subcommand shows detail', async () => {
await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
},
{
name: 'draft',
prompt: 'Draft the weekly report',
},
],
},
rootDir: tempDir,
currentDir: tempDir,
})
const [flow] = await listAutonomyFlows(tempDir)
const mod = await autonomyCommand.load()
const flowsResult = await mod.call('flows 5', {} as any)
expectTextResult(flowsResult)
expect(flowsResult.value).toContain(flow!.flowId)
expect(flowsResult.value).toContain('managed')
const flowResult = await mod.call(`flow ${flow!.flowId}`, {} as any)
expectTextResult(flowResult)
expect(flowResult.value).toContain(`Flow: ${flow!.flowId}`)
expect(flowResult.value).toContain('Mode: managed')
expect(flowResult.value).toContain('Current step: gather')
})
test('flow resume queues the next waiting step', async () => {
const waitingStart = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
waitFor: 'manual',
},
{
name: 'draft',
prompt: 'Draft the weekly report',
},
],
},
rootDir: tempDir,
currentDir: tempDir,
})
expect(waitingStart).toBeNull()
const [flow] = await listAutonomyFlows(tempDir)
const mod = await autonomyCommand.load()
const result = await mod.call(`flow resume ${flow!.flowId}`, {} as any)
expectTextResult(result)
expect(result.value).toContain('Queued the next managed step')
expect(getCommandQueueSnapshot()).toHaveLength(1)
expect(getCommandQueueSnapshot()[0]!.autonomy?.flowId).toBe(flow!.flowId)
})
test('flow cancel removes queued managed steps and marks the flow cancelled', async () => {
const queued = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
},
{
name: 'draft',
prompt: 'Draft the weekly report',
},
],
},
rootDir: tempDir,
currentDir: tempDir,
})
expect(queued).not.toBeNull()
enqueuePendingNotification(queued!)
expect(getCommandQueueSnapshot()).toHaveLength(1)
const [flow] = await listAutonomyFlows(tempDir)
const mod = await autonomyCommand.load()
const result = await mod.call(`flow cancel ${flow!.flowId}`, {} as any)
const [cancelledFlow] = await listAutonomyFlows(tempDir)
expectTextResult(result)
expect(result.value).toContain('Cancelled flow')
expect(cancelledFlow!.status).toBe('cancelled')
expect(getCommandQueueSnapshot()).toHaveLength(0)
})
test('flow cancel refuses to rewrite a terminal managed flow', async () => {
const queued = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
},
],
},
rootDir: tempDir,
currentDir: tempDir,
})
await markAutonomyRunCompleted(queued!.autonomy!.runId, tempDir)
const [flow] = await listAutonomyFlows(tempDir)
const mod = await autonomyCommand.load()
const result = await mod.call(`flow cancel ${flow!.flowId}`, {} as any)
const [terminalFlow] = await listAutonomyFlows(tempDir)
expectTextResult(result)
expect(result.value).toContain('already terminal')
expect(terminalFlow!.status).toBe('succeeded')
})
test('invalid subcommands return usage text', async () => {
const mod = await autonomyCommand.load()
const result = await mod.call('unknown', {} as any)
expectTextResult(result)
expect(result.value).toContain('Usage: /autonomy')
})
})

View File

@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import proactiveCommand from '../proactive'
import {
activateProactive,
deactivateProactive,
isProactiveActive,
} from '../../proactive/index'
beforeEach(() => {
deactivateProactive()
})
describe('/proactive baseline', () => {
test('invoking the command enables proactive mode and emits a system reminder', async () => {
const mod = await proactiveCommand.load()
let resultText: string | undefined
let options: Parameters<Parameters<typeof mod.call>[0]>[1] | undefined
await mod.call((result, opts) => {
resultText = result
options = opts
}, {} as any)
expect(isProactiveActive()).toBe(true)
expect(resultText).toContain('Proactive mode enabled')
expect(options?.display).toBe('system')
expect(options?.metaMessages?.[0]).toContain(
'Proactive mode is now enabled',
)
})
test('invoking the command again disables proactive mode', async () => {
const mod = await proactiveCommand.load()
activateProactive('test')
let resultText: string | undefined
let options: Parameters<Parameters<typeof mod.call>[0]>[1] | undefined
await mod.call((result, opts) => {
resultText = result
options = opts
}, {} as any)
expect(isProactiveActive()).toBe(false)
expect(resultText).toBe('Proactive mode disabled')
expect(options?.display).toBe('system')
})
})

View File

@@ -1,53 +0,0 @@
import * as React from 'react'
import type { LocalJSXCommandContext } from '../../commands.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import type { AppState } from '../../state/AppState.js'
/** Stub — install wizard is not yet restored. */
export async function computeDefaultInstallDir(): Promise<string> {
return ''
}
/** Stub — install wizard is not yet restored. */
export function NewInstallWizard(_props: {
defaultDir: string
onInstalled: (dir: string) => void
onCancel: () => void
onError: (message: string) => void
}): React.ReactNode {
return null
}
/**
* /assistant command implementation.
*
* Opens the Kairos assistant panel. In the current build the panel is
* rendered by the REPL layer when kairosActive is true; the slash command
* simply toggles visibility and prints a confirmation line.
*/
export async function call(
onDone: LocalJSXCommandOnDone,
context: LocalJSXCommandContext,
_args: string,
): Promise<React.ReactNode> {
const { setAppState, getAppState } = context
const current = getAppState()
const isVisible = (current as Record<string, unknown>).assistantPanelVisible
if (isVisible) {
setAppState((prev: AppState) => ({
...prev,
assistantPanelVisible: false,
} as AppState))
onDone('Assistant panel hidden.', { display: 'system' })
} else {
setAppState((prev: AppState) => ({
...prev,
assistantPanelVisible: true,
} as AppState))
onDone('Assistant panel opened.', { display: 'system' })
}
return null
}

View File

@@ -0,0 +1,175 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import { resolve } from 'path';
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 { findGitRoot } from '../../utils/git.js';
import { buildCliLaunch, spawnCli } from '../../utils/cliLaunch.js';
import { getKairosActive, setKairosActive } from '../../bootstrap/state.js';
import type { LocalJSXCommandContext } from '../../commands.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import type { AppState } from '../../state/AppState.js';
/**
* Compute the default directory for assistant daemon installation.
* Prefers git root of cwd; falls back to cwd itself.
*/
export async function computeDefaultInstallDir(): Promise<string> {
const cwd = process.cwd();
const gitRoot = findGitRoot(cwd);
return gitRoot || resolve(cwd);
}
interface WizardProps {
defaultDir: string;
onInstalled: (dir: string) => void;
onCancel: () => void;
onError: (message: string) => void;
}
/**
* Install wizard for assistant mode. Shown when `claude assistant` finds
* zero CCR sessions. Guides the user to start a daemon that registers
* a bridge → CCR cloud session.
*
* After installation, main.tsx tells the user to run `claude assistant`
* again in a few seconds (daemon needs time to register the bridge session).
*/
export function NewInstallWizard({ defaultDir, onInstalled, onCancel, onError }: WizardProps): React.ReactNode {
useRegisterOverlay('assistant-install-wizard');
const [focusIndex, setFocusIndex] = useState(0);
const [starting, setStarting] = useState(false);
useKeybindings(
{
'select:next': () => setFocusIndex(i => (i + 1) % 2),
'select:previous': () => setFocusIndex(i => (i - 1 + 2) % 2),
'select:accept': () => {
if (focusIndex === 0) {
startDaemon();
} else {
onCancel();
}
},
},
{ context: 'Select' },
);
function startDaemon(): void {
if (starting) return;
setStarting(true);
const dir = defaultDir || resolve('.');
try {
const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]);
const child = spawnCli(launch, {
cwd: dir,
stdio: 'ignore',
detached: true,
});
child.unref();
child.on('error', err => {
onError(`Failed to start daemon: ${err.message}`);
});
// Give the daemon a moment to initialize, then report success.
// The daemon still needs several more seconds to register the bridge
// and create a CCR session — main.tsx will tell the user to reconnect.
setTimeout(() => {
onInstalled(dir);
}, 1500);
} catch (err) {
onError(`Failed to start daemon: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (starting) {
return (
<Dialog title="Assistant Setup" onCancel={onCancel} hideInputGuide>
<Text>Starting daemon in {defaultDir}...</Text>
</Dialog>
);
}
return (
<Dialog title="Assistant Setup" onCancel={onCancel} hideInputGuide>
<Box flexDirection="column" gap={1}>
<Text>No active assistant sessions found.</Text>
<Text>
Start a daemon in <Text bold>{defaultDir || '.'}</Text> to create a cloud session?
</Text>
<Box flexDirection="column">
<ListItem isFocused={focusIndex === 0}>
<Text>Start assistant daemon</Text>
</ListItem>
<ListItem isFocused={focusIndex === 1}>
<Text>Cancel</Text>
</ListItem>
</Box>
<Text dimColor>Enter to select · Esc to cancel</Text>
</Box>
</Dialog>
);
}
/**
* /assistant command implementation.
*
* First invocation activates KAIROS (sets kairosActive, enables brief
* and proactive tools). Subsequent invocations toggle the assistant panel.
*/
export async function call(
onDone: LocalJSXCommandOnDone,
context: LocalJSXCommandContext,
_args: string,
): Promise<React.ReactNode> {
const { setAppState, getAppState } = context;
// First invocation: activate KAIROS
if (!getKairosActive()) {
setKairosActive(true);
setAppState(
(prev: AppState) =>
({
...prev,
kairosEnabled: true,
assistantPanelVisible: true,
}) as AppState,
);
onDone('KAIROS assistant mode activated.', { display: 'system' });
return null;
}
// Subsequent invocations: toggle panel visibility
const current = getAppState();
const isVisible = (current as Record<string, unknown>).assistantPanelVisible;
if (isVisible) {
setAppState(
(prev: AppState) =>
({
...prev,
assistantPanelVisible: false,
}) as AppState,
);
onDone('Assistant panel hidden.', { display: 'system' });
} else {
setAppState(
(prev: AppState) =>
({
...prev,
assistantPanelVisible: true,
}) as AppState,
);
onDone('Assistant panel opened.', { display: 'system' });
}
return null;
}

View File

@@ -1,25 +1,21 @@
import { feature } from 'bun:bundle' import { feature } from 'bun:bundle'
import { getKairosActive } from '../../bootstrap/state.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
/** /**
* Runtime gate for the /assistant command. * Runtime gate for the /assistant command visibility.
* *
* Build-time: feature('KAIROS') must be on (checked in commands.ts before * Build-time: feature('KAIROS') must be on.
* the module is even required). * Runtime: tengu_kairos_assistant GrowthBook flag (remote kill switch).
* *
* Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill * Does NOT require kairosActive — the /assistant command is visible
* switch, and kairosActive state must be true (set during bootstrap when * before activation so users can invoke it to activate KAIROS.
* the session qualifies for KAIROS features).
*/ */
export function isAssistantEnabled(): boolean { export function isAssistantEnabled(): boolean {
if (!feature('KAIROS')) { if (!feature('KAIROS')) {
return false return false
} }
if ( if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)) {
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)
) {
return false return false
} }
return getKairosActive() return true
} }

125
src/commands/autonomy.ts Normal file
View File

@@ -0,0 +1,125 @@
import type { Command, LocalCommandCall } from '../types/command.js'
import {
formatAutonomyFlowDetail,
formatAutonomyFlowsList,
formatAutonomyFlowsStatus,
getAutonomyFlowById,
listAutonomyFlows,
requestManagedAutonomyFlowCancel,
} from '../utils/autonomyFlows.js'
import {
formatAutonomyRunsList,
formatAutonomyRunsStatus,
listAutonomyRuns,
markAutonomyRunCancelled,
resumeManagedAutonomyFlowPrompt,
} from '../utils/autonomyRuns.js'
import {
enqueuePendingNotification,
removeByFilter,
} from '../utils/messageQueueManager.js'
function parseRunsLimit(raw?: string): number {
const parsed = Number.parseInt(raw ?? '', 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
return 10
}
return Math.min(parsed, 50)
}
const call: LocalCommandCall = async (args: string) => {
const [subcommand = 'status', arg1, arg2] = args.trim().split(/\s+/, 3)
const runs = await listAutonomyRuns()
const flows = await listAutonomyFlows()
if (subcommand === 'runs') {
return {
type: 'text',
value: formatAutonomyRunsList(runs, parseRunsLimit(arg1)),
}
}
if (subcommand === 'flows') {
return {
type: 'text',
value: formatAutonomyFlowsList(flows, parseRunsLimit(arg1)),
}
}
if (subcommand === 'flow') {
if (arg1 === 'cancel') {
const flowId = arg2 ?? ''
const cancelled = await requestManagedAutonomyFlowCancel({ flowId })
if (!cancelled) {
return {
type: 'text',
value: 'Autonomy flow not found.',
}
}
if (!cancelled.accepted) {
return {
type: 'text',
value: `Autonomy flow ${flowId} is already terminal (${cancelled.flow.status}).`,
}
}
const removed = removeByFilter(cmd => cmd.autonomy?.flowId === flowId)
for (const command of removed) {
if (command.autonomy?.runId) {
await markAutonomyRunCancelled(command.autonomy.runId)
}
}
return {
type: 'text',
value:
cancelled.flow.status === 'running'
? `Cancellation requested for flow ${flowId}. The current step is still running, and no new steps will be started.`
: `Cancelled flow ${flowId}. Removed ${removed.length} queued step(s).`,
}
}
if (arg1 === 'resume') {
const flowId = arg2 ?? ''
const command = await resumeManagedAutonomyFlowPrompt({ flowId })
if (!command) {
return {
type: 'text',
value: 'Autonomy flow is not waiting or was not found.',
}
}
enqueuePendingNotification(command)
return {
type: 'text',
value: `Queued the next managed step for flow ${flowId}.`,
}
}
return {
type: 'text',
value: formatAutonomyFlowDetail(await getAutonomyFlowById(arg1 ?? '')),
}
}
if (subcommand !== 'status' && subcommand !== '') {
return {
type: 'text',
value:
'Usage: /autonomy [status|runs [limit]|flows [limit]|flow <id>|flow cancel <id>|flow resume <id>]',
}
}
return {
type: 'text',
value: [formatAutonomyRunsStatus(runs), formatAutonomyFlowsStatus(flows)].join('\n'),
}
}
const autonomy = {
type: 'local',
name: 'autonomy',
description:
'Inspect automatic autonomy runs recorded for proactive ticks and scheduled tasks',
supportsNonInteractive: true,
load: () => Promise.resolve({ call }),
} satisfies Command
export default autonomy

View File

@@ -0,0 +1,24 @@
import { describe, test, expect } from 'bun:test'
describe('/daemon command', () => {
test('index exports a valid Command', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.name).toBe('daemon')
expect(cmd.type).toBe('local-jsx')
expect(typeof cmd.load).toBe('function')
expect(cmd.description).toContain('daemon')
})
test('daemon module exports call function', async () => {
const mod = await import('../daemon.js')
expect(typeof mod.call).toBe('function')
})
test('argumentHint lists subcommands', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.argumentHint).toContain('status')
expect(cmd.argumentHint).toContain('bg')
})
})

View File

@@ -0,0 +1,57 @@
import type {
LocalJSXCommandOnDone,
LocalJSXCommandContext,
} from '../../types/command.js'
/**
* /daemon slash command — manages daemon and background sessions from the REPL.
*
* Subcommands: status | start | stop | bg | attach | logs | kill
* Default (no args): status
*/
export async function call(
onDone: LocalJSXCommandOnDone,
_context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const parts = args ? args.trim().split(/\s+/) : []
const sub = parts[0] || 'status'
// attach is interactive/blocking — not available inside the REPL
if (sub === 'attach') {
onDone(
'Use `claude daemon attach` from the CLI. Attach is not available inside the REPL.',
{ display: 'system' },
)
return null
}
// For all other subcommands, capture console output and return via onDone
const lines = await captureConsole(async () => {
if (sub === 'bg') {
const bg = await import('../../cli/bg.js')
await bg.handleBgStart(parts.slice(1))
} else {
const { daemonMain } = await import('../../daemon/main.js')
await daemonMain([sub, ...parts.slice(1)])
}
})
onDone(lines.join('\n') || 'Done.', { display: 'system' })
return null
}
async function captureConsole(fn: () => Promise<void>): Promise<string[]> {
const lines: string[] = []
const origLog = console.log
const origError = console.error
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '))
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '))
try {
await fn()
} finally {
console.log = origLog
console.error = origError
}
return lines
}

View File

@@ -0,0 +1,17 @@
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

View File

@@ -1,6 +1,7 @@
import { feature } from 'bun:bundle' import { feature } from 'bun:bundle'
import type { Command } from '../commands.js' import type { Command } from '../commands.js'
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js' import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'
import { AUTONOMY_AGENTS_PATH_POSIX } from '../utils/autonomyAuthority.js'
import { isEnvTruthy } from '../utils/envUtils.js' import { isEnvTruthy } from '../utils/envUtils.js'
const OLD_INIT_PROMPT = `Please analyze this codebase and create a CLAUDE.md file, which will be given to future instances of Claude Code to operate in this repository. const OLD_INIT_PROMPT = `Please analyze this codebase and create a CLAUDE.md file, which will be given to future instances of Claude Code to operate in this repository.
@@ -43,7 +44,7 @@ Use AskUserQuestion to find out what the user wants:
## Phase 2: Explore the codebase ## Phase 2: Explore the codebase
Launch a subagent to survey the codebase, and ask it to read key files to understand the project: manifest files (package.json, Cargo.toml, pyproject.toml, go.mod, pom.xml, etc.), README, Makefile/build configs, CI config, existing CLAUDE.md, .claude/rules/, AGENTS.md, .cursor/rules or .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules, .mcp.json. Launch a subagent to survey the codebase, and ask it to read key files to understand the project: manifest files (package.json, Cargo.toml, pyproject.toml, go.mod, pom.xml, etc.), README, Makefile/build configs, CI config, existing CLAUDE.md, .claude/rules/, ${AUTONOMY_AGENTS_PATH_POSIX}, .cursor/rules or .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules, .mcp.json.
Detect: Detect:
- Build, test, and lint commands (especially non-standard ones) - Build, test, and lint commands (especially non-standard ones)
@@ -105,7 +106,7 @@ Include:
- Repo etiquette (branch naming, PR conventions, commit style) - Repo etiquette (branch naming, PR conventions, commit style)
- Required env vars or setup steps - Required env vars or setup steps
- Non-obvious gotchas or architectural decisions - Non-obvious gotchas or architectural decisions
- Important parts from existing AI coding tool configs if they exist (AGENTS.md, .cursor/rules, .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules) - Important parts from existing AI coding tool configs if they exist (${AUTONOMY_AGENTS_PATH_POSIX}, .cursor/rules, .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules)
Exclude: Exclude:
- File-by-file structure or component lists (Claude can discover these by reading the codebase) - File-by-file structure or component lists (Claude can discover these by reading the codebase)

View File

@@ -0,0 +1,25 @@
import { describe, test, expect } from 'bun:test'
describe('/job command', () => {
test('index exports a valid Command', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.name).toBe('job')
expect(cmd.type).toBe('local-jsx')
expect(typeof cmd.load).toBe('function')
expect(cmd.description).toContain('job')
})
test('job module exports call function', async () => {
const mod = await import('../job.js')
expect(typeof mod.call).toBe('function')
})
test('argumentHint lists subcommands', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.argumentHint).toContain('list')
expect(cmd.argumentHint).toContain('new')
expect(cmd.argumentHint).toContain('status')
})
})

16
src/commands/job/index.ts Normal file
View File

@@ -0,0 +1,16 @@
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

34
src/commands/job/job.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js'
/**
* /job slash command — manages template jobs from inside the REPL.
*
* Subcommands: list | new <template> [args] | reply <id> <text> | status <id>
* Default (no args): list
*/
export async function call(
onDone: LocalJSXCommandOnDone,
_context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const parts = args ? args.trim().split(/\s+/) : []
const sub = parts[0] || 'list'
// Capture console output so we can return it as onDone text
const lines: string[] = []
const origLog = console.log
const origError = console.error
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '))
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '))
try {
const { templatesMain } = await import('../../cli/handlers/templateJobs.js')
await templatesMain([sub, ...parts.slice(1)])
} finally {
console.log = origLog
console.error = origError
}
onDone(lines.join('\n') || 'Done.', { display: 'system' })
return null
}

View File

@@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'
const lang = {
type: 'local-jsx',
name: 'lang',
description: 'Set display language (en/zh/auto)',
immediate: true,
argumentHint: '<en|zh|auto>',
load: () => import('./lang.js'),
} satisfies Command
export default lang

49
src/commands/lang/lang.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { ToolUseContext } from '../../Tool.js'
import type {
LocalJSXCommandContext,
LocalJSXCommandOnDone,
} from '../../types/command.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import {
type PreferredLanguage,
getLanguageDisplayName,
getResolvedLanguage,
} from '../../utils/language.js'
const VALID_LANGS: readonly PreferredLanguage[] = ['en', 'zh', 'auto']
export async function call(
onDone: LocalJSXCommandOnDone,
_context: ToolUseContext & LocalJSXCommandContext,
args: string,
): Promise<null> {
const arg = args.trim().toLowerCase()
if (!arg) {
const pref = getGlobalConfig().preferredLanguage ?? 'auto'
const resolved = getResolvedLanguage()
const suffix =
pref === 'auto' ? `${getLanguageDisplayName(resolved)}` : ''
onDone(`Language: ${getLanguageDisplayName(pref)}${suffix}`, {
display: 'system',
})
return null
}
if (!VALID_LANGS.includes(arg as PreferredLanguage)) {
onDone(`Invalid language "${arg}". Use: en, zh, or auto`, {
display: 'system',
})
return null
}
const lang = arg as PreferredLanguage
saveGlobalConfig(current => ({ ...current, preferredLanguage: lang }))
const resolved = getResolvedLanguage()
const suffix = lang === 'auto' ? `${getLanguageDisplayName(resolved)}` : ''
onDone(`Language set to ${getLanguageDisplayName(lang)}${suffix}`, {
display: 'system',
})
return null
}

View File

@@ -1,4 +1,4 @@
import { spawn, type ChildProcess } from 'child_process'; import { type ChildProcess } from 'child_process';
import { resolve } from 'path'; import { resolve } from 'path';
import * as React from 'react'; import * as React from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -10,6 +10,7 @@ import { ListItem } from '../../components/design-system/ListItem.js';
import { useRegisterOverlay } from '../../context/overlayContext.js'; import { useRegisterOverlay } from '../../context/overlayContext.js';
import { Box, Text } from '@anthropic/ink'; import { Box, Text } from '@anthropic/ink';
import { useKeybindings } from '../../keybindings/useKeybinding.js'; import { useKeybindings } from '../../keybindings/useKeybinding.js';
import { buildCliLaunch, spawnCli } from '../../utils/cliLaunch.js';
import type { ToolUseContext } from '../../Tool.js'; import type { ToolUseContext } from '../../Tool.js';
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
import { errorMessage } from '../../utils/errors.js'; import { errorMessage } from '../../utils/errors.js';
@@ -202,9 +203,9 @@ async function checkPrerequisites(): Promise<string | null> {
function startDaemon(): void { function startDaemon(): void {
const dir = resolve('.'); const dir = resolve('.');
const execArgs = [...process.execArgv, process.argv[1]!, 'daemon', 'start', `--dir=${dir}`]; const launch = buildCliLaunch(['daemon', 'start', `--dir=${dir}`]);
const child = spawn(process.execPath, execArgs, { const child = spawnCli(launch, {
cwd: dir, cwd: dir,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
detached: false, detached: false,

View File

@@ -1,6 +1,11 @@
import type { LocalCommandCall } from '../../types/command.js' import type { LocalCommandCall } from '../../types/command.js'
import { getSlaveClient } from '../../hooks/useMasterMonitor.js' import { getSlaveClient } from '../../hooks/useMasterMonitor.js'
import { getPipeIpc } from '../../utils/pipeTransport.js' import { getPipeIpc } from '../../utils/pipeTransport.js'
import {
addSendOverride,
removeSendOverride,
removeMasterPipeMute,
} from '../../utils/pipeMuteState.js'
export const call: LocalCommandCall = async (args, context) => { export const call: LocalCommandCall = async (args, context) => {
const currentState = context.getAppState() const currentState = context.getAppState()
@@ -48,6 +53,12 @@ export const call: LocalCommandCall = async (args, context) => {
} }
try { try {
// Temporarily override mute for this slave so its response is visible.
// Override lasts until the slave emits 'done' or 'error' (cleared by
// useMasterMonitor's attachPipeEntryEmitter handler).
addSendOverride(targetName)
removeMasterPipeMute(targetName)
client.send({ type: 'relay_unmute' })
client.send({ client.send({
type: 'prompt', type: 'prompt',
data: message, data: message,
@@ -89,6 +100,8 @@ export const call: LocalCommandCall = async (args, context) => {
value: `Sent to "${targetName}": ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`, value: `Sent to "${targetName}": ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`,
} }
} catch (err) { } catch (err) {
// Roll back override on send failure to prevent permanent unmute
removeSendOverride(targetName)
return { return {
type: 'text', type: 'text',
value: `Failed to send to "${targetName}": ${err instanceof Error ? err.message : String(err)}`, value: `Failed to send to "${targetName}": ${err instanceof Error ? err.message : String(err)}`,

View File

@@ -1 +1,19 @@
export default null import type { Command, LocalJSXCommandOnDone } from '../types/command.js'
import type { ReactNode } from 'react'
const call = async (onDone: LocalJSXCommandOnDone): Promise<ReactNode> => {
onDone(
'torch: Reserved internal debug command. No implementation is available in this build.',
{ display: 'system' },
)
return null
}
export default {
type: 'local-jsx',
name: 'torch',
description: '[INTERNAL] Development debug command (reserved)',
isEnabled: () => true,
isHidden: true,
load: () => Promise.resolve({ call }),
} satisfies Command

View File

@@ -0,0 +1,61 @@
/**
* Tests for daemon/main.ts subcommand routing.
*
* The `status` and `bg` subcommands trigger dynamic imports of `cli/bg.ts`
* which depends on `envUtils.ts` → `lodash-es/memoize.js` (unavailable in
* raw test context without `bun run dev`'s define flags). We test only the
* self-contained subcommands: help and unknown.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
describe('daemonMain subcommand routing', () => {
const origLog = console.log
const origError = console.error
let logLines: string[]
beforeEach(() => {
logLines = []
console.log = (...a: unknown[]) => logLines.push(a.map(String).join(' '))
console.error = (...a: unknown[]) => logLines.push(a.map(String).join(' '))
})
afterEach(() => {
console.log = origLog
console.error = origError
process.exitCode = 0
})
test('unknown subcommand sets exitCode to 1', async () => {
const { daemonMain } = await import('../main.js')
await daemonMain(['unknown-command-xyz'])
expect(process.exitCode).toBe(1)
})
test('help subcommand prints usage', async () => {
const { daemonMain } = await import('../main.js')
await daemonMain(['help'])
const output = logLines.join('\n')
expect(output).toContain('SUBCOMMANDS')
expect(output).toContain('status')
expect(output).toContain('start')
expect(output).toContain('stop')
expect(output).toContain('bg')
expect(output).toContain('attach')
expect(output).toContain('logs')
expect(output).toContain('kill')
})
test('--help is alias for help', async () => {
const { daemonMain } = await import('../main.js')
await daemonMain(['--help'])
const output = logLines.join('\n')
expect(output).toContain('SUBCOMMANDS')
})
test('-h is alias for help', async () => {
const { daemonMain } = await import('../main.js')
await daemonMain(['-h'])
const output = logLines.join('\n')
expect(output).toContain('SUBCOMMANDS')
})
})

View File

@@ -0,0 +1,185 @@
/**
* Tests for src/daemon/state.ts
*
* Uses real temp directories and CLAUDE_CONFIG_DIR env var
* instead of mocking fs/envUtils, to avoid cross-test mock pollution.
*/
import { describe, expect, test, beforeEach, afterAll } from 'bun:test'
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
// ─── setup: real temp dir via env var ──────────────────────────────────────
const tempBase = mkdtempSync(join(tmpdir(), 'daemon-state-test-'))
beforeEach(() => {
// Clear lodash memoize cache so CLAUDE_CONFIG_DIR env var takes effect
if (
typeof getClaudeConfigHomeDir === 'function' &&
'cache' in getClaudeConfigHomeDir
) {
;(getClaudeConfigHomeDir as any).cache.clear?.()
}
const tempHome = mkdtempSync(join(tempBase, 'home-'))
process.env.CLAUDE_CONFIG_DIR = tempHome
})
afterAll(() => {
delete process.env.CLAUDE_CONFIG_DIR
// Clear memoize cache after all tests so other files see fresh state
if (
typeof getClaudeConfigHomeDir === 'function' &&
'cache' in getClaudeConfigHomeDir
) {
;(getClaudeConfigHomeDir as any).cache.clear?.()
}
try {
rmSync(tempBase, { recursive: true, force: true })
} catch {
// best-effort cleanup
}
})
// ─── import ─────────────────────────────────────────────────────────────────
const {
getDaemonStateFilePath,
writeDaemonState,
readDaemonState,
removeDaemonState,
queryDaemonStatus,
} = await import('../state.js')
// ─── tests ─────────────────────────────────────────────────────────────────
describe('getDaemonStateFilePath', () => {
test('returns default path with remote-control name', () => {
const p = getDaemonStateFilePath()
expect(p).toContain('daemon')
expect(p).toContain('remote-control.json')
})
test('returns path with custom name', () => {
const p = getDaemonStateFilePath('my-daemon')
expect(p).toContain('my-daemon.json')
})
})
describe('writeDaemonState', () => {
test('writes state JSON to disk', () => {
const state = {
pid: 1234,
cwd: '/test',
startedAt: '2026-01-01T00:00:00Z',
workerKinds: ['rcs'],
lastStatus: 'running' as const,
}
writeDaemonState(state, 'test')
const filePath = getDaemonStateFilePath('test')
expect(existsSync(filePath)).toBe(true)
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'))
expect(parsed.pid).toBe(1234)
expect(parsed.cwd).toBe('/test')
})
test('creates directory recursively', () => {
writeDaemonState(
{
pid: 1,
cwd: '/',
startedAt: '',
workerKinds: [],
lastStatus: 'running',
},
'dir-test',
)
const filePath = getDaemonStateFilePath('dir-test')
expect(existsSync(filePath)).toBe(true)
})
})
describe('readDaemonState', () => {
test('returns null when no state file', () => {
expect(readDaemonState('nonexistent')).toBeNull()
})
test('returns parsed state when file exists', () => {
const state = {
pid: 42,
cwd: '/x',
startedAt: '',
workerKinds: [],
lastStatus: 'running' as const,
}
writeDaemonState(state, 'read-test')
const result = readDaemonState('read-test')
expect(result).not.toBeNull()
expect(result!.pid).toBe(42)
})
})
describe('removeDaemonState', () => {
test('removes existing state file', () => {
writeDaemonState(
{
pid: 1,
cwd: '/',
startedAt: '',
workerKinds: [],
lastStatus: 'running',
},
'rm-test',
)
const filePath = getDaemonStateFilePath('rm-test')
expect(existsSync(filePath)).toBe(true)
removeDaemonState('rm-test')
expect(existsSync(filePath)).toBe(false)
})
test('does not throw when file does not exist', () => {
expect(() => removeDaemonState('no-file')).not.toThrow()
})
})
describe('queryDaemonStatus', () => {
test('returns stopped when no state file', () => {
const result = queryDaemonStatus('empty')
expect(result.status).toBe('stopped')
expect(result.state).toBeUndefined()
})
test('returns running when PID is alive (current process)', () => {
writeDaemonState(
{
pid: process.pid,
cwd: process.cwd(),
startedAt: new Date().toISOString(),
workerKinds: ['test'],
lastStatus: 'running',
},
'alive-test',
)
const result = queryDaemonStatus('alive-test')
expect(result.status).toBe('running')
expect(result.state).toBeDefined()
expect(result.state!.pid).toBe(process.pid)
})
test('returns stale when PID is dead and cleans up', () => {
writeDaemonState(
{
pid: 999999,
cwd: '/',
startedAt: '',
workerKinds: [],
lastStatus: 'running',
},
'stale-test',
)
const result = queryDaemonStatus('stale-test')
expect(result.status).toBe('stale')
expect(existsSync(getDaemonStateFilePath('stale-test'))).toBe(false)
})
})

View File

@@ -1,6 +1,13 @@
import { spawn, type ChildProcess } from 'child_process' import { type ChildProcess } from 'child_process'
import { resolve } from 'path' import { resolve } from 'path'
import { buildCliLaunch, spawnCli } from '../utils/cliLaunch.js'
import { errorMessage } from '../utils/errors.js' import { errorMessage } from '../utils/errors.js'
import {
writeDaemonState,
removeDaemonState,
queryDaemonStatus,
stopDaemonByPid,
} from './state.js'
/** /**
* Exit code used by workers for permanent (non-retryable) failures. * Exit code used by workers for permanent (non-retryable) failures.
@@ -29,30 +36,62 @@ interface WorkerState {
* Daemon supervisor entry point. Called from `cli.tsx` via: * Daemon supervisor entry point. Called from `cli.tsx` via:
* `claude daemon [subcommand]` * `claude daemon [subcommand]`
* *
* Starts and supervises long-running workers. Currently spawns one * Manages the daemon supervisor AND background sessions under one namespace.
* `remoteControl` worker that runs the headless bridge server.
* *
* Subcommands: * Subcommands:
* (none) — start the supervisor with default workers * (none) — unified status (supervisor + sessions)
* start — same as no subcommand * start — start the supervisor with default workers
* status — print worker status (TODO: IPC) * stop — send SIGTERM to supervisor
* stop send SIGTERM to supervisor (TODO: PID file) * statusunified status (supervisor + sessions)
* ps — alias for status
* bg — start a background session
* attach — attach to a background session
* logs — show session logs
* kill — kill a session
*/ */
export async function daemonMain(args: string[]): Promise<void> { export async function daemonMain(args: string[]): Promise<void> {
const subcommand = args[0] || 'start' const subcommand = args[0] || 'status'
switch (subcommand) { switch (subcommand) {
// --- Supervisor management ---
case 'start': case 'start':
await runSupervisor(args.slice(1)) await runSupervisor(args.slice(1))
break break
case 'status':
console.log('daemon status: not yet implemented (requires IPC)')
break
case 'stop': case 'stop':
console.log('daemon stop: not yet implemented (requires PID file)') await handleDaemonStop()
break break
// --- Unified status ---
case 'status':
case 'ps':
await showUnifiedStatus()
break
// --- Session management (delegates to bg.ts) ---
case 'bg': {
const bg = await import('../cli/bg.js')
await bg.handleBgStart(args.slice(1))
break
}
case 'attach': {
const bg = await import('../cli/bg.js')
await bg.attachHandler(args[1])
break
}
case 'logs': {
const bg = await import('../cli/bg.js')
await bg.logsHandler(args[1])
break
}
case 'kill': {
const bg = await import('../cli/bg.js')
await bg.killHandler(args[1])
break
}
case '--help': case '--help':
case '-h': case '-h':
case 'help':
printHelp() printHelp()
break break
default: default:
@@ -64,17 +103,25 @@ export async function daemonMain(args: string[]): Promise<void> {
function printHelp(): void { function printHelp(): void {
console.log(` console.log(`
Claude Code Daemon — persistent background supervisor Claude Code Daemon — background process management
USAGE USAGE
claude daemon [subcommand] [options] claude daemon [subcommand]
SUBCOMMANDS SUBCOMMANDS
start Start the daemon supervisor (default) status Show daemon and session status (default)
status Show worker status start Start the daemon supervisor
stop Stop the daemon 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
OPTIONS REPL
/daemon [subcommand] Same commands available in interactive mode
OPTIONS (for start)
--dir <path> Working directory (default: current) --dir <path> Working directory (default: current)
--spawn-mode <mode> Worker spawn mode: same-dir | worktree (default: same-dir) --spawn-mode <mode> Worker spawn mode: same-dir | worktree (default: same-dir)
--capacity <N> Max concurrent sessions per worker (default: 4) --capacity <N> Max concurrent sessions per worker (default: 4)
@@ -85,6 +132,63 @@ OPTIONS
`) `)
} }
/**
* Show unified status: daemon supervisor + background sessions.
*/
async function showUnifiedStatus(): Promise<void> {
// 1. Daemon supervisor status
const result = queryDaemonStatus()
console.log('=== Daemon Supervisor ===')
switch (result.status) {
case 'running': {
const s = result.state!
console.log(` Status: running`)
console.log(` PID: ${s.pid}`)
console.log(` CWD: ${s.cwd}`)
console.log(` Started: ${s.startedAt}`)
console.log(` Workers: ${s.workerKinds.join(', ')}`)
break
}
case 'stopped':
console.log(' Status: stopped')
break
case 'stale':
console.log(' Status: stale (cleaned up)')
break
}
// 2. Background sessions
console.log('\n=== Background Sessions ===')
const bg = await import('../cli/bg.js')
await bg.psHandler([])
}
/**
* Stop a running daemon from another CLI process.
*/
async function handleDaemonStop(): Promise<void> {
const result = queryDaemonStatus()
if (result.status === 'stopped') {
console.log('daemon is not running')
return
}
if (result.status === 'stale') {
console.log('daemon was stale (cleaned up)')
return
}
console.log(`stopping daemon (PID: ${result.state!.pid})...`)
const stopped = await stopDaemonByPid()
if (stopped) {
console.log('daemon stopped')
} else {
console.log('daemon could not be stopped (may have already exited)')
}
}
/** /**
* Parse supervisor arguments from CLI. * Parse supervisor arguments from CLI.
*/ */
@@ -140,12 +244,22 @@ async function runSupervisor(args: string[]): Promise<void> {
}, },
] ]
// Write daemon state file so other CLI processes can query/stop us
writeDaemonState({
pid: process.pid,
cwd: dir,
startedAt: new Date().toISOString(),
workerKinds: workers.map(w => w.kind),
lastStatus: 'running',
})
const controller = new AbortController() const controller = new AbortController()
// Graceful shutdown // Graceful shutdown
const shutdown = () => { const shutdown = () => {
console.log('[daemon] supervisor shutting down...') console.log('[daemon] supervisor shutting down...')
controller.abort() controller.abort()
removeDaemonState()
for (const w of workers) { for (const w of workers) {
if (w.process && !w.process.killed) { if (w.process && !w.process.killed) {
w.process.kill('SIGTERM') w.process.kill('SIGTERM')
@@ -222,17 +336,11 @@ function spawnWorker(
CLAUDE_CODE_SESSION_KIND: 'daemon-worker', CLAUDE_CODE_SESSION_KIND: 'daemon-worker',
} }
// Build the worker command: reuse the same entrypoint with --daemon-worker flag
const execArgs = [
...process.execArgv,
process.argv[1]!,
`--daemon-worker=${worker.kind}`,
]
console.log(`[daemon] spawning worker '${worker.kind}'`) console.log(`[daemon] spawning worker '${worker.kind}'`)
const child = spawn(process.execPath, execArgs, { const launch = buildCliLaunch([`--daemon-worker=${worker.kind}`], { env })
env,
const child = spawnCli(launch, {
cwd: dir, cwd: dir,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}) })

157
src/daemon/state.ts Normal file
View File

@@ -0,0 +1,157 @@
import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs'
import { join, dirname } from 'path'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
/**
* Daemon state persisted to disk so that `status` / `stop` can work
* from a different CLI process than the one that started the daemon.
*/
export interface DaemonStateData {
pid: number
cwd: string
startedAt: string
workerKinds: string[]
lastStatus: 'running' | 'stopped' | 'error'
}
export type DaemonStatus = 'running' | 'stopped' | 'stale'
/**
* Returns the path to the daemon state file for a given daemon name.
*/
export function getDaemonStateFilePath(name = 'remote-control'): string {
return join(getClaudeConfigHomeDir(), 'daemon', `${name}.json`)
}
/**
* Write daemon state to disk. Called by the supervisor on startup.
*/
export function writeDaemonState(
state: DaemonStateData,
name = 'remote-control',
): void {
const filePath = getDaemonStateFilePath(name)
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, JSON.stringify(state, null, 2), 'utf-8')
}
/**
* Read daemon state from disk. Returns null if no state file exists.
*/
export function readDaemonState(
name = 'remote-control',
): DaemonStateData | null {
const filePath = getDaemonStateFilePath(name)
try {
const raw = readFileSync(filePath, 'utf-8')
return JSON.parse(raw) as DaemonStateData
} catch {
return null
}
}
/**
* Remove the daemon state file.
*/
export function removeDaemonState(name = 'remote-control'): void {
const filePath = getDaemonStateFilePath(name)
try {
unlinkSync(filePath)
} catch {
// File may not exist — that's fine
}
}
/**
* Check if a process with the given PID is alive.
*/
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
/**
* Query the daemon status by reading the state file and probing the PID.
*
* Returns:
* - { status: 'running', state } — PID is alive
* - { status: 'stopped' } — no state file
* - { status: 'stale' } — state file exists but PID is dead (auto-cleaned)
*/
export function queryDaemonStatus(name = 'remote-control'): {
status: DaemonStatus
state?: DaemonStateData
} {
const state = readDaemonState(name)
if (!state) {
return { status: 'stopped' }
}
if (isProcessAlive(state.pid)) {
return { status: 'running', state }
}
// Stale — process is dead but state file remains
removeDaemonState(name)
return { status: 'stale' }
}
/**
* Stop a running daemon by sending SIGTERM, waiting, then SIGKILL if needed.
* Cleans up the state file afterward.
*
* @returns true if the daemon was stopped, false if it wasn't running
*/
export async function stopDaemonByPid(
name = 'remote-control',
timeoutMs = 10_000,
): Promise<boolean> {
const state = readDaemonState(name)
if (!state) {
return false
}
const { pid } = state
if (!isProcessAlive(pid)) {
removeDaemonState(name)
return false
}
// Send SIGTERM
try {
process.kill(pid, 'SIGTERM')
} catch {
removeDaemonState(name)
return false
}
// Wait for exit with timeout
const deadline = Date.now() + timeoutMs
const pollInterval = 200
while (Date.now() < deadline) {
if (!isProcessAlive(pid)) {
removeDaemonState(name)
return true
}
await new Promise(resolve => setTimeout(resolve, pollInterval))
}
// Force kill
try {
process.kill(pid, 'SIGKILL')
} catch {
// Already dead
}
// Brief wait for SIGKILL to take effect
await new Promise(resolve => setTimeout(resolve, 500))
removeDaemonState(name)
return true
}

View File

@@ -145,9 +145,10 @@ async function main(): Promise<void> {
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer — // perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
// workers are lean. If a worker kind needs configs/auth (assistant will), // workers are lean. If a worker kind needs configs/auth (assistant will),
// it calls them inside its run() fn. // it calls them inside its run() fn.
if (feature('DAEMON') && args[0] === '--daemon-worker') { if (feature('DAEMON') && (args[0] === '--daemon-worker' || args[0]?.startsWith('--daemon-worker='))) {
const kind = args[0] === '--daemon-worker' ? args[1] : args[0].split('=')[1]
const { runDaemonWorker } = await import('../daemon/workerRegistry.js') const { runDaemonWorker } = await import('../daemon/workerRegistry.js')
await runDaemonWorker(args[1]) await runDaemonWorker(kind)
return return
} }
@@ -207,11 +208,18 @@ async function main(): Promise<void> {
return return
} }
// Fast-path for `claude daemon [subcommand]`: long-running supervisor. // Fast-path for `claude daemon [subcommand]`: unified daemon + session management.
if (feature('DAEMON') && args[0] === 'daemon') { // Handles both supervisor (start/stop) and background session (bg/attach/logs/kill)
// subcommands under one namespace.
if (
(feature('DAEMON') || feature('BG_SESSIONS')) &&
args[0] === 'daemon'
) {
profileCheckpoint('cli_daemon_path') profileCheckpoint('cli_daemon_path')
const { enableConfigs } = await import('../utils/config.js') const { enableConfigs } = await import('../utils/config.js')
enableConfigs() enableConfigs()
const { setShellIfWindows } = await import('../utils/windowsPaths.js')
setShellIfWindows()
const { initSinks } = await import('../utils/sinks.js') const { initSinks } = await import('../utils/sinks.js')
initSinks() initSinks()
const { daemonMain } = await import('../daemon/main.js') const { daemonMain } = await import('../daemon/main.js')
@@ -219,51 +227,69 @@ async function main(): Promise<void> {
return return
} }
// Fast-path for `claude ps|logs|attach|kill` and `--bg`/`--background`. // Fast-path for `--bg`/`--background` shortcut → daemon bg.
// Session management against the ~/.claude/sessions/ registry. Flag if (
// literals are inlined so bg.js only loads when actually dispatching. feature('BG_SESSIONS') &&
(args.includes('--bg') || args.includes('--background'))
) {
profileCheckpoint('cli_daemon_path')
const { enableConfigs } = await import('../utils/config.js')
enableConfigs()
const { setShellIfWindows } = await import('../utils/windowsPaths.js')
setShellIfWindows()
const bg = await import('../cli/bg.js')
await bg.handleBgStart(
args.filter(a => a !== '--bg' && a !== '--background'),
)
return
}
// Backward-compat: ps/logs/attach/kill → daemon <sub> (deprecated)
if ( if (
feature('BG_SESSIONS') && feature('BG_SESSIONS') &&
(args[0] === 'ps' || (args[0] === 'ps' ||
args[0] === 'logs' || args[0] === 'logs' ||
args[0] === 'attach' || args[0] === 'attach' ||
args[0] === 'kill' || args[0] === 'kill')
args.includes('--bg') ||
args.includes('--background'))
) { ) {
profileCheckpoint('cli_bg_path') const mapped = args[0] === 'ps' ? 'status' : args[0]
console.error(
`[deprecated] Use: claude daemon ${mapped}${args[1] ? ' ' + args[1] : ''}`,
)
profileCheckpoint('cli_daemon_path')
const { enableConfigs } = await import('../utils/config.js') const { enableConfigs } = await import('../utils/config.js')
enableConfigs() enableConfigs()
const bg = await import('../cli/bg.js') const { setShellIfWindows } = await import('../utils/windowsPaths.js')
switch (args[0]) { setShellIfWindows()
case 'ps': const { initSinks } = await import('../utils/sinks.js')
await bg.psHandler(args.slice(1)) initSinks()
break const { daemonMain } = await import('../daemon/main.js')
case 'logs': await daemonMain([args[0] === 'ps' ? 'status' : args[0]!, ...args.slice(1)])
await bg.logsHandler(args[1])
break
case 'attach':
await bg.attachHandler(args[1])
break
case 'kill':
await bg.killHandler(args[1])
break
default:
await bg.handleBgFlag(args)
}
return return
} }
// Fast-path for template job commands. // Fast-path for `claude job <subcommand>`: template jobs.
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 (not return) — mountFleetView's Ink TUI can leave event
// loop handles that prevent natural exit.
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(0)
}
// Backward-compat: new/list/reply → job <sub> (deprecated)
if ( if (
feature('TEMPLATES') && feature('TEMPLATES') &&
(args[0] === 'new' || args[0] === 'list' || args[0] === 'reply') (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') profileCheckpoint('cli_templates_path')
const { templatesMain } = await import('../cli/handlers/templateJobs.js') const { templatesMain } = await import('../cli/handlers/templateJobs.js')
await templatesMain(args) await templatesMain(args)
// process.exit (not return) — mountFleetView's Ink TUI can leave event
// loop handles that prevent natural exit.
// eslint-disable-next-line custom-rules/no-process-exit // eslint-disable-next-line custom-rules/no-process-exit
process.exit(0) process.exit(0)
} }

View File

@@ -48,7 +48,6 @@ export function useAwaySummary(
'tengu_sedge_lantern', 'tengu_sedge_lantern',
false, false,
) )
useEffect(() => { useEffect(() => {
if (!feature('AWAY_SUMMARY')) return if (!feature('AWAY_SUMMARY')) return
if (!gbEnabled) return if (!gbEnabled) return

View File

@@ -18,6 +18,11 @@ import {
type PipeIpcSlaveState, type PipeIpcSlaveState,
} from '../utils/pipeTransport.js' } from '../utils/pipeTransport.js'
import { logForDebugging } from '../utils/debug.js' import { logForDebugging } from '../utils/debug.js'
import {
isMasterPipeMuted,
hasSendOverride,
removeSendOverride,
} from '../utils/pipeMuteState.js'
/** Session history entry for pipe IPC monitoring. */ /** Session history entry for pipe IPC monitoring. */
export type SessionEntry = { export type SessionEntry = {
@@ -113,6 +118,28 @@ function isMonitoredPipeEntryType(type: string): boolean {
return MONITORED_PIPE_ENTRY_TYPES.includes(type) return MONITORED_PIPE_ENTRY_TYPES.includes(type)
} }
/** Business message types that should be dropped when a slave is muted. */
const MUTED_DROPPABLE_TYPES = new Set([
'prompt_ack',
'stream',
'tool_start',
'tool_result',
'done',
'error',
'permission_request',
'permission_cancel',
])
/**
* Centralized mute check used by both attachPipeEntryEmitter and
* useMasterMonitor's inline handler — keeps the two gates in sync.
*/
export function shouldDropMutedMessage(slaveName: string, msgType: string): boolean {
if (hasSendOverride(slaveName)) return false
if (!isMasterPipeMuted(slaveName)) return false
return MUTED_DROPPABLE_TYPES.has(msgType)
}
function pipeMessageToSessionEntry( function pipeMessageToSessionEntry(
slaveName: string, slaveName: string,
msg: PipeMessage, msg: PipeMessage,
@@ -153,6 +180,35 @@ function attachPipeEntryEmitter(name: string, client: PipeClient): void {
if (typeof client.on !== 'function') return if (typeof client.on !== 'function') return
const handler = (msg: PipeMessage) => { const handler = (msg: PipeMessage) => {
if (!isMonitoredPipeEntryType(msg.type)) return if (!isMonitoredPipeEntryType(msg.type)) return
// Mute gate: drop business messages from muted slaves
if (shouldDropMutedMessage(name, msg.type)) {
// Auto-deny permission_request to prevent slave deadlock
if (msg.type === 'permission_request') {
try {
const payload = JSON.parse(msg.data ?? '{}')
if (payload.requestId) {
client.send({
type: 'permission_response',
data: JSON.stringify({
requestId: payload.requestId,
behavior: 'deny',
feedback: 'Permission auto-denied: pipe is logically disconnected.',
}),
})
}
} catch {
// Malformed payload — safe to ignore
}
}
return
}
// Clear /send override when slave turn completes
if ((msg.type === 'done' || msg.type === 'error') && hasSendOverride(name)) {
removeSendOverride(name)
}
emitPipeEntry(name, pipeMessageToSessionEntry(name, msg)) emitPipeEntry(name, pipeMessageToSessionEntry(name, msg))
} }
_pipeEntryHandlers.set(name, handler) _pipeEntryHandlers.set(name, handler)
@@ -166,14 +222,14 @@ function emitSlaveClientRegistryChanged(): void {
} }
} }
function subscribeToSlaveClientRegistry(listener: () => void): () => void { export function subscribeToSlaveClientRegistry(listener: () => void): () => void {
_slaveClientRegistryListeners.add(listener) _slaveClientRegistryListeners.add(listener)
return () => { return () => {
_slaveClientRegistryListeners.delete(listener) _slaveClientRegistryListeners.delete(listener)
} }
} }
function getSlaveClientRegistryVersion(): number { export function getSlaveClientRegistryVersion(): number {
return _slaveClientRegistryVersion return _slaveClientRegistryVersion
} }
@@ -248,13 +304,23 @@ export function useMasterMonitor(): void {
for (const [slaveName, client] of _slaveClients.entries()) { for (const [slaveName, client] of _slaveClients.entries()) {
const handler = (msg: PipeMessage) => { const handler = (msg: PipeMessage) => {
const entry = pipeMessageToSessionEntry(slaveName, msg)
// Only record relevant message types // Only record relevant message types
if (!isMonitoredPipeEntryType(msg.type)) { if (!isMonitoredPipeEntryType(msg.type)) {
return return
} }
// Mute gate (second gate, same helper as attachPipeEntryEmitter)
if (shouldDropMutedMessage(slaveName, msg.type)) {
return
}
// Clear /send override when slave turn completes
if ((msg.type === 'done' || msg.type === 'error') && hasSendOverride(slaveName)) {
removeSendOverride(slaveName)
}
const entry = pipeMessageToSessionEntry(slaveName, msg)
setAppState(prev => { setAppState(prev => {
const slave = getPipeIpc(prev).slaves[slaveName] const slave = getPipeIpc(prev).slaves[slaveName]
if (!slave) return prev if (!slave) return prev
@@ -294,6 +360,8 @@ export function useMasterMonitor(): void {
// Handle slave disconnect // Handle slave disconnect
const onDisconnect = () => { const onDisconnect = () => {
logForDebugging(`[MasterMonitor] Slave "${slaveName}" disconnected`) logForDebugging(`[MasterMonitor] Slave "${slaveName}" disconnected`)
// Clear any lingering /send override before removing client
removeSendOverride(slaveName)
removeSlaveClient(slaveName) removeSlaveClient(slaveName)
setAppState(prev => { setAppState(prev => {
const { [slaveName]: _removed, ...remainingSlaves } = const { [slaveName]: _removed, ...remainingSlaves } =

View File

@@ -246,6 +246,15 @@ function registerMessageHandlers(
} }
}) })
// Handle relay mute/unmute from master
server.onMessage((msg: PipeMessage, _reply) => {
if (msg.type === 'relay_mute') {
pp().setRelayMuted(true)
} else if (msg.type === 'relay_unmute') {
pp().setRelayMuted(false)
}
})
// Handle detach // Handle detach
server.onMessage((msg: PipeMessage, _reply) => { server.onMessage((msg: PipeMessage, _reply) => {
if (msg.type !== 'detach') return if (msg.type !== 'detach') return

View File

@@ -0,0 +1,141 @@
/**
* usePipeMuteSync — Sync master's UI selection state to slave relay mute flags.
*
* Watches routeMode, selectedPipes, slave client registry, and send-override
* changes. When a slave is deselected or routeMode switches to 'local', sends
* relay_mute. When re-selected, sends relay_unmute. Also maintains the
* master-side muted set for in-flight message filtering.
*
* Feature-gated by UDS_INBOX (conditional import in REPL.tsx).
*/
import { useEffect, useRef, useSyncExternalStore } from 'react'
import { useAppState } from '../state/AppState.js'
import { getPipeIpc } from '../utils/pipeTransport.js'
import {
setMasterMutedPipes,
clearMasterMutedPipes,
hasSendOverride,
clearSendOverrides,
subscribeSendOverride,
getSendOverrideVersion,
} from '../utils/pipeMuteState.js'
import {
getAllSlaveClients,
subscribeToSlaveClientRegistry,
getSlaveClientRegistryVersion,
} from './useMasterMonitor.js'
type UsePipeMuteSyncDeps = {
setToolUseConfirmQueue: (action: React.SetStateAction<Record<string, unknown>[]>) => void
}
export function usePipeMuteSync({
setToolUseConfirmQueue,
}: UsePipeMuteSyncDeps): void {
// Subscribe to individual scalars to avoid object-selector re-render churn
// (AppState.tsx warns against object-returning selectors)
const routeMode = useAppState(
s => (getPipeIpc(s).routeMode as 'selected' | 'local') ?? 'selected',
)
const selectedPipes: string[] = useAppState(
s => (getPipeIpc(s).selectedPipes as string[]) ?? [],
)
// Subscribe to slave client registry changes
const registryVersion = useSyncExternalStore(
subscribeToSlaveClientRegistry,
getSlaveClientRegistryVersion,
getSlaveClientRegistryVersion,
)
// Subscribe to send-override changes so mute recalculates after /send completes
const sendOverrideVersion = useSyncExternalStore(
subscribeSendOverride,
getSendOverrideVersion,
getSendOverrideVersion,
)
const prevMutedRef = useRef<Set<string>>(new Set())
useEffect(() => {
const slaves = getAllSlaveClients()
// Compute which slaves should be muted now
const nextMuted = new Set<string>()
if (routeMode === 'local') {
// All connected slaves muted
for (const name of slaves.keys()) {
if (!hasSendOverride(name)) {
nextMuted.add(name)
}
}
} else {
// routeMode === 'selected': mute slaves NOT in selectedPipes
const selectedSet = new Set(selectedPipes)
for (const name of slaves.keys()) {
if (!selectedSet.has(name) && !hasSendOverride(name)) {
nextMuted.add(name)
}
}
}
// Step 1: Update master-side muted set FIRST (before sending control packets)
setMasterMutedPipes(nextMuted)
const prevMuted = prevMutedRef.current
// Step 2: For newly muted slaves — abort pending permissions, then send relay_mute
for (const name of nextMuted) {
if (!prevMuted.has(name)) {
// Abort pending permission prompts for this slave
setToolUseConfirmQueue((queue: Record<string, unknown>[]) => {
const toAbort = queue.filter(
(item: Record<string, unknown>) => item.pipeName === name,
)
for (const item of toAbort) {
try {
;(item.onAbort as (() => void) | undefined)?.()
} catch {
// onAbort may throw if client disconnected — safe to ignore
}
}
return queue.filter((item: Record<string, unknown>) => item.pipeName !== name)
})
// Send relay_mute to slave
const client = slaves.get(name)
if (client?.connected) {
try {
client.send({ type: 'relay_mute' })
} catch {
// send may fail if socket is closing — non-fatal
}
}
}
}
// Step 3: For newly unmuted slaves — send relay_unmute
for (const name of prevMuted) {
if (!nextMuted.has(name)) {
const client = slaves.get(name)
if (client?.connected) {
try {
client.send({ type: 'relay_unmute' })
} catch {
// non-fatal
}
}
}
}
prevMutedRef.current = nextMuted
}, [routeMode, selectedPipes, registryVersion, sendOverrideVersion, setToolUseConfirmQueue])
// Cleanup on unmount: clear all master-side mute state
useEffect(() => {
return () => {
clearMasterMutedPipes()
clearSendOverrides()
}
}, [])
}

View File

@@ -90,6 +90,7 @@ export function usePipePermissionForward({
input: payload.input, input: payload.input,
toolUseContext, toolUseContext,
toolUseID: `pipe:${payload.requestId}`, toolUseID: `pipe:${payload.requestId}`,
pipeName,
permissionResult: payload.permissionResult, permissionResult: payload.permissionResult,
permissionPromptStartTimeMs: permissionPromptStartTimeMs:
payload.permissionPromptStartTimeMs, payload.permissionPromptStartTimeMs,

View File

@@ -6,7 +6,7 @@
* `getPipeRelay()` singleton set by usePipeIpc's attach handler. * `getPipeRelay()` singleton set by usePipeIpc's attach handler.
*/ */
import { useRef, useCallback } from 'react' import { useRef, useCallback } from 'react'
import { getPipeRelay } from '../utils/pipePermissionRelay.js' import { getPipeRelay, isRelayMuted } from '../utils/pipePermissionRelay.js'
import type { PipeMessage } from '../utils/pipeTransport.js' import type { PipeMessage } from '../utils/pipeTransport.js'
export type PipeRelayHandle = { export type PipeRelayHandle = {
@@ -29,6 +29,9 @@ export function usePipeRelay(): PipeRelayHandle {
if (typeof relay !== 'function') { if (typeof relay !== 'function') {
return false return false
} }
if (isRelayMuted()) {
return false
}
relay(message) relay(message)
return true return true
}, },

View File

@@ -7,9 +7,12 @@ import {
} from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js' } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
import { isKairosCronEnabled } from '@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js' import { isKairosCronEnabled } from '@claude-code-best/builtin-tools/tools/ScheduleCronTool/prompt.js'
import type { Message } from '../types/message.js' import type { Message } from '../types/message.js'
import { getCwd } from '../utils/cwd.js'
import { getCronJitterConfig } from '../utils/cronJitterConfig.js' import { getCronJitterConfig } from '../utils/cronJitterConfig.js'
import { createCronScheduler } from '../utils/cronScheduler.js' import { createCronScheduler } from '../utils/cronScheduler.js'
import { removeCronTasks } from '../utils/cronTasks.js' import { removeCronTasks } from '../utils/cronTasks.js'
import { createAutonomyQueuedPrompt } from '../utils/autonomyRuns.js'
import { markAutonomyRunFailed } from '../utils/autonomyRuns.js'
import { logForDebugging } from '../utils/debug.js' import { logForDebugging } from '../utils/debug.js'
import { enqueuePendingNotification } from '../utils/messageQueueManager.js' import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
import { createScheduledTaskFireMessage } from '../utils/messages.js' import { createScheduledTaskFireMessage } from '../utils/messages.js'
@@ -68,50 +71,92 @@ export function useScheduledTasks({
// forward isMeta, so their messages remain visible in the // forward isMeta, so their messages remain visible in the
// transcript. This is acceptable since normal mode is not the // transcript. This is acceptable since normal mode is not the
// primary use case for scheduled tasks. // primary use case for scheduled tasks.
const enqueueForLead = (prompt: string) => const enqueueForLead = async (prompt: string) => {
enqueuePendingNotification({ const command = await createAutonomyQueuedPrompt({
value: prompt, basePrompt: prompt,
mode: 'prompt', trigger: 'scheduled-task',
priority: 'later', currentDir: getCwd(),
isMeta: true,
// Threaded through to cc_workload= in the billing-header
// attribution block so the API can serve cron-initiated requests
// at lower QoS when capacity is tight. No human is actively
// waiting on this response.
workload: WORKLOAD_CRON, workload: WORKLOAD_CRON,
}) })
if (!command) {
return
}
enqueuePendingNotification(command)
}
const scheduler = createCronScheduler({ const scheduler = createCronScheduler({
// Missed-task surfacing (onFire fallback). Teammate crons are always // Missed-task surfacing (onFire fallback). Teammate crons are always
// session-only (durable:false) so they never appear in the missed list, // session-only (durable:false) so they never appear in the missed list,
// which is populated from disk at scheduler startup — this path only // which is populated from disk at scheduler startup — this path only
// handles team-lead durable crons. // handles team-lead durable crons.
onFire: enqueueForLead, onFire: prompt => {
void enqueueForLead(prompt)
},
// Normal fires receive the full CronTask so we can route by agentId. // Normal fires receive the full CronTask so we can route by agentId.
onFireTask: task => { onFireTask: task => {
if (task.agentId) { void (async () => {
const teammate = findTeammateTaskByAgentId( if (task.agentId) {
task.agentId, const teammate = findTeammateTaskByAgentId(
store.getState().tasks, task.agentId,
) store.getState().tasks,
if (teammate && !isTerminalTaskStatus(teammate.status)) { )
injectUserMessageToTeammate(teammate.id, task.prompt, setAppState) if (teammate && !isTerminalTaskStatus(teammate.status)) {
const command = await createAutonomyQueuedPrompt({
basePrompt: task.prompt,
trigger: 'scheduled-task',
currentDir: getCwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
})
if (!command) {
return
}
const injected = injectUserMessageToTeammate(
teammate.id,
command.value as string,
{
autonomyRunId: command.autonomy?.runId,
origin: command.origin,
},
setAppState,
)
if (!injected && command.autonomy?.runId) {
await markAutonomyRunFailed(
command.autonomy.runId,
`Teammate ${task.agentId} exited before the scheduled message could be delivered.`,
)
}
return
}
// Teammate is gone — clean up the orphaned cron so it doesn't keep
// firing into nowhere every tick. One-shots would auto-delete on
// fire anyway, but recurring crons would loop until auto-expiry.
logForDebugging(
`[ScheduledTasks] teammate ${task.agentId} gone, removing orphaned cron ${task.id}`,
)
void removeCronTasks([task.id])
return return
} }
// Teammate is gone — clean up the orphaned cron so it doesn't keep
// firing into nowhere every tick. One-shots would auto-delete on const command = await createAutonomyQueuedPrompt({
// fire anyway, but recurring crons would loop until auto-expiry. basePrompt: task.prompt,
logForDebugging( trigger: 'scheduled-task',
`[ScheduledTasks] teammate ${task.agentId} gone, removing orphaned cron ${task.id}`, currentDir: getCwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
})
if (!command) {
return
}
const msg = createScheduledTaskFireMessage(
`Running scheduled task (${formatCronFireTime(new Date())})`,
) )
void removeCronTasks([task.id]) setMessages(prev => [...prev, msg])
return enqueuePendingNotification(command)
} })()
const msg = createScheduledTaskFireMessage(
`Running scheduled task (${formatCronFireTime(new Date())})`,
)
setMessages(prev => [...prev, msg])
enqueueForLead(task.prompt)
}, },
isLoading: () => isLoadingRef.current, isLoading: () => isLoadingRef.current,
assistantMode, assistantMode,

View File

@@ -0,0 +1,140 @@
/**
* Tests for src/jobs/classifier.ts
*
* Uses real temp directories instead of mocking fs to avoid
* cross-test mock pollution in bun test.
*
* classifier.ts takes jobDir as a parameter, so no envUtils mock needed.
*/
import { describe, expect, test, beforeEach, afterAll } from 'bun:test'
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import type { AssistantMessage } from '../../types/message.js'
import { classifyAndWriteState } from '../classifier.js'
// ─── setup: real temp dir ──────────────────────────────────────────────────
let tempBase: string
let jobDir: string
let stateFile: string
tempBase = mkdtempSync(join(tmpdir(), 'classifier-test-'))
function freshJobDir(): void {
jobDir = mkdtempSync(join(tempBase, 'job-'))
stateFile = join(jobDir, 'state.json')
}
// ─── helpers ────────────────────────────────────────────────────────────────
function makeAssistantMessage(
content: any[],
extra: Record<string, any> = {},
): AssistantMessage {
return {
type: 'assistant',
uuid: '00000000-0000-0000-0000-000000000000' as any,
message: {
role: 'assistant',
content,
...extra,
},
} as any
}
// ─── lifecycle ─────────────────────────────────────────────────────────────
beforeEach(() => {
freshJobDir()
})
afterAll(() => {
try {
rmSync(tempBase, { recursive: true, force: true })
} catch {
// best-effort cleanup
}
})
// ─── tests ──────────────────────────────────────────────────────────────────
describe('classifyAndWriteState', () => {
test('does nothing when state.json is missing', async () => {
await classifyAndWriteState(jobDir, [])
// stateFile should still not exist
let exists = false
try {
readFileSync(stateFile, 'utf-8')
exists = true
} catch {
// expected
}
expect(exists).toBe(false)
})
test('sets status to running when last message has tool_use block', async () => {
writeFileSync(
stateFile,
JSON.stringify({ status: 'created', updatedAt: '2026-01-01' }),
'utf-8',
)
const msg = makeAssistantMessage([
{ type: 'text', text: 'Let me check...' },
{ type: 'tool_use', id: 'toolu_1', name: 'bash', input: {} },
])
await classifyAndWriteState(jobDir, [msg])
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
expect(state.status).toBe('running')
})
test('sets status to completed when stop_reason is end_turn', async () => {
writeFileSync(
stateFile,
JSON.stringify({ status: 'running', updatedAt: '2026-01-01' }),
'utf-8',
)
const msg = makeAssistantMessage([{ type: 'text', text: 'All done.' }], {
stop_reason: 'end_turn',
})
await classifyAndWriteState(jobDir, [msg])
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
expect(state.status).toBe('completed')
})
test('sets status to running for empty messages (state exists)', async () => {
writeFileSync(
stateFile,
JSON.stringify({ status: 'created', updatedAt: '2026-01-01' }),
'utf-8',
)
await classifyAndWriteState(jobDir, [])
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
expect(state.status).toBe('running')
})
test('sets status to running when stop_reason is max_tokens', async () => {
writeFileSync(
stateFile,
JSON.stringify({ status: 'running', updatedAt: '2026-01-01' }),
'utf-8',
)
const msg = makeAssistantMessage([{ type: 'text', text: 'I need more' }], {
stop_reason: 'max_tokens',
})
await classifyAndWriteState(jobDir, [msg])
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
expect(state.status).toBe('running')
})
})

View File

@@ -0,0 +1,91 @@
/**
* Tests for src/jobs/state.ts
*
* Uses real temp directories and CLAUDE_CONFIG_DIR env var
* instead of mocking fs, to avoid cross-test mock pollution.
*/
import { describe, expect, test, beforeEach, afterAll } from 'bun:test'
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
// ─── setup: real temp dir via env var ──────────────────────────────────────
const tempBase = mkdtempSync(join(tmpdir(), 'jobs-state-test-'))
beforeEach(() => {
// Each test gets a fresh config dir
const tempHome = mkdtempSync(join(tempBase, 'home-'))
process.env.CLAUDE_CONFIG_DIR = tempHome
})
afterAll(() => {
delete process.env.CLAUDE_CONFIG_DIR
try {
rmSync(tempBase, { recursive: true, force: true })
} catch {
// best-effort cleanup
}
})
// ─── import ─────────────────────────────────────────────────────────────────
const { createJob, readJobState, appendJobReply, getJobDir } = await import(
'../state.js'
)
// ─── tests ──────────────────────────────────────────────────────────────────
describe('createJob', () => {
test('creates job directory and writes state, template, and input files', () => {
const dir = createJob('job-1', 'my-template', '# Template', 'hello', [
'--flag',
])
expect(dir).toContain('job-1')
expect(existsSync(dir)).toBe(true)
const stateFile = join(dir, 'state.json')
expect(existsSync(stateFile)).toBe(true)
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
expect(state.jobId).toBe('job-1')
expect(state.templateName).toBe('my-template')
expect(state.status).toBe('created')
expect(state.args).toEqual(['--flag'])
expect(readFileSync(join(dir, 'template.md'), 'utf-8')).toBe('# Template')
expect(readFileSync(join(dir, 'input.txt'), 'utf-8')).toBe('hello')
})
})
describe('readJobState', () => {
test('returns null when job does not exist', () => {
expect(readJobState('nonexistent')).toBeNull()
})
test('returns parsed state when job exists', () => {
createJob('job-2', 'tpl', 'content', 'input', [])
const result = readJobState('job-2')
expect(result).not.toBeNull()
expect(result!.jobId).toBe('job-2')
expect(result!.status).toBe('created')
})
})
describe('appendJobReply', () => {
test('returns false when job does not exist', () => {
expect(appendJobReply('no-job', 'hello')).toBe(false)
})
test('appends reply and updates state', () => {
createJob('job-3', 'tpl', 'content', 'input', [])
const result = appendJobReply('job-3', 'my reply')
expect(result).toBe(true)
const dir = getJobDir('job-3')
const repliesPath = join(dir, 'replies.jsonl')
expect(existsSync(repliesPath)).toBe(true)
const replyLine = JSON.parse(readFileSync(repliesPath, 'utf-8').trim())
expect(replyLine.text).toBe('my reply')
})
})

View File

@@ -0,0 +1,87 @@
/**
* Tests for src/jobs/templates.ts
*
* Uses real temp directories and CLAUDE_CONFIG_DIR env var
* instead of mocking fs, to avoid cross-test mock pollution.
*/
import { describe, expect, test, beforeEach, afterAll } from 'bun:test'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
// ─── setup: real temp dir via env var ──────────────────────────────────────
const tempBase = mkdtempSync(join(tmpdir(), 'jobs-templates-test-'))
beforeEach(() => {
const tempHome = mkdtempSync(join(tempBase, 'home-'))
process.env.CLAUDE_CONFIG_DIR = tempHome
})
afterAll(() => {
delete process.env.CLAUDE_CONFIG_DIR
try {
rmSync(tempBase, { recursive: true, force: true })
} catch {
// best-effort cleanup
}
})
// ─── import ─────────────────────────────────────────────────────────────────
const { listTemplates, loadTemplate } = await import('../templates.js')
// ─── tests ──────────────────────────────────────────────────────────────────
describe('listTemplates', () => {
test('returns empty array when no template dirs exist', () => {
const result = listTemplates()
expect(result).toEqual([])
})
test('discovers templates from user-level dir', () => {
const userDir = join(process.env.CLAUDE_CONFIG_DIR!, 'templates')
mkdirSync(userDir, { recursive: true })
writeFileSync(
join(userDir, 'greeting.md'),
'---\ndescription: A greeting template\n---\nHello {{name}}',
'utf-8',
)
const result = listTemplates()
expect(result.length).toBe(1)
expect(result[0]!.name).toBe('greeting')
expect(result[0]!.description).toBe('A greeting template')
expect(result[0]!.content).toBe('Hello {{name}}')
})
test('skips non-md files', () => {
const userDir = join(process.env.CLAUDE_CONFIG_DIR!, 'templates')
mkdirSync(userDir, { recursive: true })
writeFileSync(join(userDir, 'notes.txt'), 'not a template', 'utf-8')
writeFileSync(join(userDir, 'data.json'), '{}', 'utf-8')
const result = listTemplates()
expect(result).toEqual([])
})
})
describe('loadTemplate', () => {
test('returns null when template not found', () => {
expect(loadTemplate('nonexistent')).toBeNull()
})
test('returns template by name', () => {
const userDir = join(process.env.CLAUDE_CONFIG_DIR!, 'templates')
mkdirSync(userDir, { recursive: true })
writeFileSync(
join(userDir, 'deploy.md'),
'---\ndescription: Deploy script\n---\nrun deploy',
'utf-8',
)
const result = loadTemplate('deploy')
expect(result).not.toBeNull()
expect(result!.name).toBe('deploy')
})
})

View File

@@ -1,3 +1,67 @@
// Auto-generated stub — replace with real implementation import { readFileSync, writeFileSync } from 'fs'
export {}; import { join } from 'path'
export const classifyAndWriteState: (...args: unknown[]) => Promise<void> = () => Promise.resolve(); import type { AssistantMessage } from '../types/message.js'
/**
* Classify the job status from the turn's assistant messages and update state.json.
*
* Called by stopHooks.ts after each repl_main_thread turn when CLAUDE_JOB_DIR is set.
* Only the main thread calls this (not subagents).
*
* @param jobDir - Path to the job directory (from CLAUDE_JOB_DIR env)
* @param assistantMessages - Assistant messages from this turn
*/
export async function classifyAndWriteState(
jobDir: string,
assistantMessages: AssistantMessage[],
): Promise<void> {
const stateFile = join(jobDir, 'state.json')
let state: Record<string, unknown>
try {
state = JSON.parse(readFileSync(stateFile, 'utf-8'))
} catch {
// No state file or corrupt — not a valid job directory
return
}
const newStatus = classifyStatus(assistantMessages)
state.status = newStatus
state.updatedAt = new Date().toISOString()
writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8')
}
/**
* Determine job status from assistant messages.
*
* - Has tool_use blocks → still running (tools executing)
* - stop_reason === 'end_turn' → completed (model finished)
* - Otherwise → running
*/
function classifyStatus(messages: AssistantMessage[]): string {
if (messages.length === 0) return 'running'
const lastMessage = messages[messages.length - 1]!
const content = lastMessage.message?.content
// Check if the last message has tool_use blocks (still executing)
if (Array.isArray(content)) {
const hasToolUse = content.some(
block =>
typeof block === 'object' &&
block !== null &&
'type' in block &&
block.type === 'tool_use',
)
if (hasToolUse) return 'running'
}
// Check stop_reason via index signature
const stopReason = (lastMessage.message as Record<string, unknown>)
?.stop_reason
if (stopReason === 'end_turn') return 'completed'
if (stopReason === 'max_tokens') return 'running'
return 'running'
}

102
src/jobs/state.ts Normal file
View File

@@ -0,0 +1,102 @@
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
export interface JobState {
jobId: string
templateName: string
createdAt: string
updatedAt: string
status: 'created' | 'running' | 'completed' | 'failed'
args: string[]
}
function getJobsDir(): string {
return join(getClaudeConfigHomeDir(), 'jobs')
}
export function getJobDir(jobId: string): string {
return join(getJobsDir(), jobId)
}
/**
* Create a new job directory with initial state.
*/
export function createJob(
jobId: string,
templateName: string,
templateContent: string,
inputText: string,
args: string[],
): string {
const dir = getJobDir(jobId)
mkdirSync(dir, { recursive: true })
const now = new Date().toISOString()
const state: JobState = {
jobId,
templateName,
createdAt: now,
updatedAt: now,
status: 'created',
args,
}
writeFileSync(
join(dir, 'state.json'),
JSON.stringify(state, null, 2),
'utf-8',
)
writeFileSync(join(dir, 'template.md'), templateContent, 'utf-8')
writeFileSync(join(dir, 'input.txt'), inputText, 'utf-8')
return dir
}
/**
* Read job state from disk.
*/
export function readJobState(jobId: string): JobState | null {
try {
const raw = readFileSync(join(getJobDir(jobId), 'state.json'), 'utf-8')
const parsed: unknown = JSON.parse(raw)
if (typeof parsed !== 'object' || parsed === null) return null
const obj = parsed as Record<string, unknown>
if (typeof obj.jobId !== 'string' || typeof obj.status !== 'string') {
return null
}
return obj as unknown as JobState
} catch {
return null
}
}
/**
* Append a reply to a job.
*/
export function appendJobReply(jobId: string, text: string): boolean {
const dir = getJobDir(jobId)
const state = readJobState(jobId)
if (!state) return false
const repliesPath = join(dir, 'replies.jsonl')
const entry = JSON.stringify({
text,
timestamp: new Date().toISOString(),
})
try {
appendFileSync(repliesPath, entry + '\n', 'utf-8')
} catch {
writeFileSync(repliesPath, entry + '\n', 'utf-8')
}
const updated = { ...state, updatedAt: new Date().toISOString() }
writeFileSync(
join(dir, 'state.json'),
JSON.stringify(updated, null, 2),
'utf-8',
)
return true
}

86
src/jobs/templates.ts Normal file
View File

@@ -0,0 +1,86 @@
import { readdirSync, readFileSync } from 'fs'
import { join, basename } from 'path'
import { parseFrontmatter } from '../utils/frontmatterParser.js'
import type { FrontmatterData } from '../utils/frontmatterParser.js'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
import {
getProjectDirsUpToHome,
extractDescriptionFromMarkdown,
type ClaudeConfigDirectory,
} from '../utils/markdownConfigLoader.js'
export interface TemplateInfo {
name: string
description: string
filePath: string
frontmatter: FrontmatterData
content: string
}
/**
* Discover .claude/templates directories from CWD up to git root,
* plus the user-level ~/.claude/templates.
*/
function getTemplatesDirs(): string[] {
const projectDirs = getProjectDirsUpToHome(
'templates' as ClaudeConfigDirectory,
process.cwd(),
)
// User-level dir (getProjectDirsUpToHome stops before home)
const userDir = join(getClaudeConfigHomeDir(), 'templates')
try {
readdirSync(userDir)
return [...projectDirs, userDir]
} catch {
return projectDirs
}
}
/**
* List all available templates.
*/
export function listTemplates(): TemplateInfo[] {
const templates: TemplateInfo[] = []
const seenNames = new Set<string>()
for (const dir of getTemplatesDirs()) {
let files: string[]
try {
files = readdirSync(dir)
} catch {
continue
}
for (const file of files) {
if (!file.endsWith('.md')) continue
const name = basename(file, '.md')
if (seenNames.has(name)) continue
seenNames.add(name)
const filePath = join(dir, file)
try {
const raw = readFileSync(filePath, 'utf-8')
const { frontmatter, content } = parseFrontmatter(raw, filePath)
const description =
(typeof frontmatter.description === 'string'
? frontmatter.description
: '') || extractDescriptionFromMarkdown(content, 'No description')
templates.push({ name, description, filePath, frontmatter, content })
} catch {
// Skip unreadable files
}
}
}
return templates
}
/**
* Load a specific template by name.
*/
export function loadTemplate(name: string): TemplateInfo | null {
const all = listTemplates()
return all.find(t => t.name === name) ?? null
}

View File

@@ -1802,9 +1802,11 @@ async function run(): Promise<CommanderCommand> {
} }
if ( if (
feature("KAIROS") && feature("KAIROS") &&
assistantModule?.isAssistantMode() && assistantModule &&
(assistantModule.isAssistantForced() ||
(options as Record<string, unknown>).assistant === true) &&
// Spawned teammates share the leader's cwd + settings.json, so // Spawned teammates share the leader's cwd + settings.json, so
// isAssistantMode() is true for them too. --agent-id being set // the flag is true for them too. --agent-id being set
// means we ARE a spawned teammate (extractTeammateOptions runs // means we ARE a spawned teammate (extractTeammateOptions runs
// ~170 lines later so check the raw commander option) — don't // ~170 lines later so check the raw commander option) — don't
// re-init the team or override teammateMode/proactive/brief. // re-init the team or override teammateMode/proactive/brief.

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import {
activateProactive,
deactivateProactive,
getActivationSource,
getNextTickAt,
isContextBlocked,
isProactiveActive,
isProactivePaused,
pauseProactive,
resumeProactive,
setContextBlocked,
setNextTickAt,
shouldTick,
subscribeToProactiveChanges,
} from '../index'
function resetProactiveState() {
activateProactive('reset')
setContextBlocked(false)
setNextTickAt(null)
deactivateProactive()
}
beforeEach(() => {
resetProactiveState()
})
describe('proactive state baseline', () => {
test('activateProactive enables proactive mode and records the source', () => {
activateProactive('baseline_test')
expect(isProactiveActive()).toBe(true)
expect(isProactivePaused()).toBe(false)
expect(isContextBlocked()).toBe(false)
expect(getActivationSource()).toBe('baseline_test')
expect(shouldTick()).toBe(true)
})
test('pauseProactive suppresses ticking and clears nextTickAt', () => {
activateProactive('pause_case')
setNextTickAt(Date.now() + 30_000)
pauseProactive()
expect(isProactivePaused()).toBe(true)
expect(getNextTickAt()).toBeNull()
expect(shouldTick()).toBe(false)
resumeProactive()
expect(isProactivePaused()).toBe(false)
expect(shouldTick()).toBe(true)
})
test('setContextBlocked clears nextTickAt and blocks ticking', () => {
activateProactive('blocked_case')
setNextTickAt(Date.now() + 5_000)
setContextBlocked(true)
expect(isContextBlocked()).toBe(true)
expect(getNextTickAt()).toBeNull()
expect(shouldTick()).toBe(false)
})
test('subscribers are notified on state changes', () => {
let notifications = 0
const unsubscribe = subscribeToProactiveChanges(() => {
notifications += 1
})
activateProactive('subscriber_case')
setNextTickAt(Date.now() + 1_000)
setContextBlocked(true)
deactivateProactive()
unsubscribe()
expect(notifications).toBeGreaterThanOrEqual(3)
})
})

View File

@@ -6,7 +6,10 @@
* proactive mode is active and not blocked. * proactive mode is active and not blocked.
*/ */
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import type { QueuedCommand } from '../types/textInputTypes.js'
import { TICK_TAG } from '../constants/xml.js' import { TICK_TAG } from '../constants/xml.js'
import { getCwd } from '../utils/cwd.js'
import { createProactiveAutonomyCommands } from '../utils/autonomyRuns.js'
import { import {
isProactiveActive, isProactiveActive,
isProactivePaused, isProactivePaused,
@@ -24,8 +27,7 @@ type UseProactiveOpts = {
queuedCommandsLength: number queuedCommandsLength: number
hasActiveLocalJsxUI: boolean hasActiveLocalJsxUI: boolean
isInPlanMode: boolean isInPlanMode: boolean
onSubmitTick: (prompt: string) => void onQueueTick: (command: QueuedCommand) => void
onQueueTick: (prompt: string) => void
} }
export function useProactive(opts: UseProactiveOpts): void { export function useProactive(opts: UseProactiveOpts): void {
@@ -70,14 +72,19 @@ export function useProactive(opts: UseProactiveOpts): void {
return return
} }
const tickContent = `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>` void (async () => {
const commands = await createProactiveAutonomyCommands({
// If nothing is in the queue, submit directly; otherwise queue basePrompt: `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`,
if (queuedCommandsLength === 0) { currentDir: getCwd(),
optsRef.current.onSubmitTick(tickContent) })
} else { for (const command of commands) {
optsRef.current.onQueueTick(tickContent) // Always queue proactive turns. This avoids races where the prompt
} // is built asynchronously, a user turn starts meanwhile, and a
// direct-submit path would silently drop the autonomy turn after
// consuming its heartbeat due-state.
optsRef.current.onQueueTick(command)
}
})()
// Schedule next tick // Schedule next tick
scheduleTick() scheduleTick()

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