Compare commits

..

1 Commits

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

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

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

MIME detection fix:
- Screenshot detectMimeFromBase64(): decode raw bytes from base64
  instead of broken charCodeAt comparison
- Fixes API 400 on Windows (JPEG) and macOS (PNG) screenshots
2026-04-14 18:32:19 +08:00
95 changed files with 9710 additions and 1479 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

204
02-kairos (1).md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

@@ -47,6 +47,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

@@ -1,7 +1,348 @@
// 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 { spawnSync } from 'child_process'
export const attachHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>; import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
export const killHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>; import { isProcessRunning } from '../utils/genericProcessUtils.js'
export const handleBgFlag: (args: string[]) => Promise<void> = (async () => {}) as (args: string[]) => Promise<void>; import { jsonParse } from '../utils/slowOperations.js'
import { execFileNoThrow } from '../utils/execFileNoThrow.js'
import { quote } from '../utils/bash/shellQuote.js'
interface SessionEntry {
pid: number
sessionId: string
cwd: string
startedAt: number
kind: string
name?: string
logPath?: string
entrypoint?: string
status?: string
waitingFor?: string
updatedAt?: number
bridgeSessionId?: string
agent?: string
tmuxSessionName?: string
}
function getSessionsDir(): string {
return join(getClaudeConfigHomeDir(), 'sessions')
}
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
}
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()
}
/**
* `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 parts: string[] = [
` PID: ${s.pid}`,
` Kind: ${s.kind}`,
` 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}`)
console.log(parts.join('\n'))
console.log()
}
}
/**
* `claude 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 attach <target>` — attach to a background tmux session.
*/
export async function attachHandler(target: string | undefined): Promise<void> {
// Check tmux availability
const { code: tmuxCode } = await execFileNoThrow('tmux', ['-V'])
if (tmuxCode !== 0) {
console.error(
'tmux is required for attach. Install tmux to use background sessions.',
)
console.error(getTmuxHint())
process.exitCode = 1
return
}
const sessions = await listLiveSessions()
if (!target) {
// Find bg sessions with tmux metadata
const bgSessions = sessions.filter(s => s.tmuxSessionName)
if (bgSessions.length === 0) {
console.log(
'No background sessions to attach to. Start one with `claude --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
console.log(` ${label} PID=${s.pid} tmux=${s.tmuxSessionName}`)
}
return
}
}
const session = findSession(sessions, target)
if (!session) {
console.error(`Session not found: ${target}`)
process.exitCode = 1
return
}
if (!session.tmuxSessionName) {
console.error(
`Session ${session.sessionId} was not started with --bg (no tmux session).`,
)
process.exitCode = 1
return
}
// tmux attach is a blocking call — replaces this process's terminal
const result = spawnSync(
'tmux',
['attach-session', '-t', session.tmuxSessionName],
{
stdio: 'inherit',
},
)
if (result.status !== 0) {
console.error(
`Failed to attach to tmux session '${session.tmuxSessionName}'.`,
)
process.exitCode = 1
}
}
/**
* `claude 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 --bg [args]` — start a session in a background tmux pane.
*/
export async function handleBgFlag(args: string[]): Promise<void> {
// Check tmux availability
const { code: tmuxCode } = await execFileNoThrow('tmux', ['-V'])
if (tmuxCode !== 0) {
console.error(
'tmux is required for --bg. Install tmux to use background sessions.',
)
console.error(getTmuxHint())
process.exitCode = 1
return
}
const sessionName = `claude-bg-${randomUUID().slice(0, 8)}`
const logPath = join(
getClaudeConfigHomeDir(),
'sessions',
'logs',
`${sessionName}.log`,
)
// Strip --bg/--background from args
const filteredArgs = args.filter(a => a !== '--bg' && a !== '--background')
// Build the command to run inside tmux — use array form to avoid shell injection
const entrypoint = process.argv[1]!
const tmuxEnv = {
...process.env,
CLAUDE_CODE_SESSION_KIND: 'bg',
CLAUDE_CODE_SESSION_NAME: sessionName,
CLAUDE_CODE_SESSION_LOG: logPath,
CLAUDE_CODE_TMUX_SESSION: sessionName,
}
const cmd = quote([process.execPath, entrypoint, ...filteredArgs])
const result = spawnSync(
'tmux',
['new-session', '-d', '-s', sessionName, cmd],
{ stdio: 'inherit', env: tmuxEnv },
)
if (result.status !== 0) {
console.error('Failed to create tmux session.')
process.exitCode = 1
return
}
console.log(`Background session started: ${sessionName}`)
console.log(` tmux session: ${sessionName}`)
console.log(` log: ${logPath}`)
console.log()
console.log(`Use \`claude attach ${sessionName}\` to reconnect.`)
console.log(`Use \`claude ps\` to check status.`)
console.log(`Use \`claude kill ${sessionName}\` to stop.`)
}
function getTmuxHint(): string {
if (process.platform === 'darwin') {
return 'Install with: brew install tmux'
}
if (process.platform === 'win32') {
return 'tmux is not natively available on Windows. Consider using WSL.'
}
return 'Install with: sudo apt install tmux (or your package manager)'
}

View File

@@ -1,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,131 @@
// 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
default:
console.error(`Unknown template command: ${subcommand}`)
printUsage()
process.exitCode = 1
}
}
function printUsage(): void {
console.log(`
Template Job Commands:
claude list List available templates
claude new <template> [args] Create a new job from a template
claude reply <job-id> <text> Reply to an existing job
`)
}
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 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 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'
@@ -1839,15 +1850,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 +2111,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,8 +2163,12 @@ 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)
}
try {
await runWithWorkload(cmd.workload ?? options.workload, async () => {
for await (const message of ask({
commands: uniqBy( commands: uniqBy(
[...currentCommands, ...appState.mcp.commands], [...currentCommands, ...appState.mcp.commands],
'name', 'name',
@@ -2241,7 +2267,30 @@ function runHeadlessStreaming(
output.enqueue(message as StdoutMessage) output.enqueue(message as StdoutMessage)
} }
} }
}) // end runWithWorkload }) // end runWithWorkload
for (const runId of autonomyRunIds) {
const nextCommands = await finalizeAutonomyRunCompleted({
runId,
currentDir: cwd(),
priority: 'later',
workload: cmd.workload ?? options.workload,
})
for (const nextCommand of nextCommands) {
enqueue({
...nextCommand,
uuid: randomUUID(),
})
}
}
} catch (error) {
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')
@@ -2706,22 +2755,69 @@ function runHeadlessStreaming(
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,

View File

@@ -1,2 +1,69 @@
// 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.log(
'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',
)
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'
@@ -182,6 +183,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 +292,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 +318,7 @@ const COMMANDS = memoize((): Command[] => [
ide, ide,
init, init,
keybindings, keybindings,
lang,
installGitHubApp, installGitHubApp,
installSlackApp, installSlackApp,
mcp, mcp,

View File

@@ -0,0 +1,237 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import autonomyCommand from '../autonomy'
import {
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state'
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',
})
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)
expect(result.type).toBe('text')
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)
expect(result.type).toBe('text')
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)
expect(flowsResult.type).toBe('text')
expect(flowsResult.value).toContain(flow!.flowId)
expect(flowsResult.value).toContain('managed')
const flowResult = await mod.call(`flow ${flow!.flowId}`, {} as any)
expect(flowResult.type).toBe('text')
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)
expect(result.type).toBe('text')
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)
expect(result.type).toBe('text')
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)
expect(result.type).toBe('text')
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)
expect(result.type).toBe('text')
expect(result.value).toContain('Usage: /autonomy')
})
})

View File

@@ -0,0 +1,54 @@
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 { spawn } from 'child_process';
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 { 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 execArgs = [...process.execArgv, process.argv[1]!, 'daemon', 'start', `--dir=${dir}`];
const child = spawn(process.execPath, execArgs, {
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

@@ -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,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,6 +1,7 @@
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, 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 +49,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 +96,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,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,12 @@
import { spawn, type ChildProcess } from 'child_process' import { spawn, type ChildProcess } from 'child_process'
import { resolve } from 'path' import { resolve } from 'path'
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.
@@ -46,10 +52,10 @@ export async function daemonMain(args: string[]): Promise<void> {
await runSupervisor(args.slice(1)) await runSupervisor(args.slice(1))
break break
case 'status': case 'status':
console.log('daemon status: not yet implemented (requires IPC)') showDaemonStatus()
break break
case 'stop': case 'stop':
console.log('daemon stop: not yet implemented (requires PID file)') await handleDaemonStop()
break break
case '--help': case '--help':
case '-h': case '-h':
@@ -85,6 +91,57 @@ OPTIONS
`) `)
} }
/**
* Show daemon status by reading the state file and probing the PID.
*/
function showDaemonStatus(): void {
const result = queryDaemonStatus()
switch (result.status) {
case 'running': {
const s = result.state!
console.log(`daemon 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('daemon status: stopped')
break
case 'stale':
console.log('daemon status: stale (cleaned up)')
break
}
}
/**
* 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 +197,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')

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

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

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

@@ -0,0 +1,96 @@
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 state: JobState = {
jobId,
templateName,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
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')
return JSON.parse(raw) 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')
}
// Update state
state.updatedAt = new Date().toISOString()
writeFileSync(
join(dir, 'state.json'),
JSON.stringify(state, null, 2),
'utf-8',
)
return true
}

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

@@ -0,0 +1,105 @@
import { readdirSync, readFileSync, statSync } 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'
export interface TemplateInfo {
name: string
description: string
filePath: string
frontmatter: FrontmatterData
content: string
}
/**
* Discover .claude/templates directories from CWD up to root,
* plus the user-level ~/.claude/templates.
*/
function getTemplatesDirs(): string[] {
const dirs: string[] = []
// Project-level: walk up from CWD
let dir = process.cwd()
const seen = new Set<string>()
while (true) {
const candidate = join(dir, '.claude', 'templates')
if (!seen.has(candidate)) {
seen.add(candidate)
try {
if (statSync(candidate).isDirectory()) {
dirs.push(candidate)
}
} catch {
// Not found — keep walking
}
}
const parent = join(dir, '..')
if (parent === dir) break
dir = parent
}
// User-level
const userDir = join(getClaudeConfigHomeDir(), 'templates')
try {
if (statSync(userDir).isDirectory()) {
dirs.push(userDir)
}
} catch {
// Not found
}
return dirs
}
/**
* 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 =
(frontmatter.description as string) ||
content
.split('\n')
.find(l => l.trim().length > 0)
?.trim() ||
'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

@@ -130,6 +130,8 @@ export const DEFAULT_BINDINGS: KeybindingBlock[] = [
{ {
context: 'Confirmation', context: 'Confirmation',
bindings: { bindings: {
y: 'confirm:yes',
n: 'confirm:no',
enter: 'confirm:yes', enter: 'confirm:yes',
escape: 'confirm:no', escape: 'confirm:no',
// Navigation for dialogs with lists // Navigation for dialogs with lists

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()

View File

@@ -72,6 +72,11 @@ import { QueryGuard } from '../utils/QueryGuard.js';
import { isEnvTruthy } from '../utils/envUtils.js'; import { isEnvTruthy } from '../utils/envUtils.js';
import { formatTokens, truncateToWidth } from '../utils/format.js'; import { formatTokens, truncateToWidth } from '../utils/format.js';
import { consumeEarlyInput } from '../utils/earlyInput.js'; import { consumeEarlyInput } from '../utils/earlyInput.js';
import {
finalizeAutonomyRunCompleted,
finalizeAutonomyRunFailed,
markAutonomyRunRunning,
} from '../utils/autonomyRuns.js';
import { setMemberActive } from '../utils/swarm/teamHelpers.js'; import { setMemberActive } from '../utils/swarm/teamHelpers.js';
import { import {
@@ -346,6 +351,9 @@ const usePipeRelay = feature('UDS_INBOX')
const usePipePermissionForward = feature('UDS_INBOX') const usePipePermissionForward = feature('UDS_INBOX')
? require('../hooks/usePipePermissionForward.js').usePipePermissionForward ? require('../hooks/usePipePermissionForward.js').usePipePermissionForward
: () => undefined; : () => undefined;
const usePipeMuteSync = feature('UDS_INBOX')
? require('../hooks/usePipeMuteSync.js').usePipeMuteSync
: () => undefined;
const usePipeRouter = feature('UDS_INBOX') const usePipeRouter = feature('UDS_INBOX')
? require('../hooks/usePipeRouter.js').usePipeRouter ? require('../hooks/usePipeRouter.js').usePipeRouter
: () => ({ routeToSelectedPipes: () => false }); : () => ({ routeToSelectedPipes: () => false });
@@ -4299,7 +4307,7 @@ export function REPL({
}); });
} }
} else { } else {
injectUserMessageToTeammate(task.id, input, setAppState); injectUserMessageToTeammate(task.id, input, undefined, setAppState);
} }
setInputValue(''); setInputValue('');
helpers.setCursorOffset(0); helpers.setCursorOffset(0);
@@ -4804,7 +4812,10 @@ export function REPL({
// Submits incoming prompts from teammate messages or tasks mode as new turns // Submits incoming prompts from teammate messages or tasks mode as new turns
// Returns true if submission succeeded, false if a query is already running // Returns true if submission succeeded, false if a query is already running
const handleIncomingPrompt = useCallback( const handleIncomingPrompt = useCallback(
(content: string, options?: { isMeta?: boolean }): boolean => { (
input: string | QueuedCommand,
options?: { isMeta?: boolean },
): boolean => {
if (queryGuard.isActive) return false; if (queryGuard.isActive) return false;
// Defer to user-queued commands — user input always takes priority // Defer to user-queued commands — user input always takes priority
@@ -4816,16 +4827,53 @@ export function REPL({
return false; return false;
} }
const queuedCommand =
typeof input === 'string'
? ({
value: input,
mode: 'prompt',
isMeta: options?.isMeta ? true : undefined,
} satisfies QueuedCommand)
: input
const newAbortController = createAbortController(); const newAbortController = createAbortController();
setAbortController(newAbortController); setAbortController(newAbortController);
// Create a user message with the formatted content (includes XML wrapper) // Create a user message with the formatted content (includes XML wrapper)
const userMessage = createUserMessage({ const userMessage = createUserMessage({
content, content: queuedCommand.value as string,
isMeta: options?.isMeta ? true : undefined, isMeta: queuedCommand.isMeta ? true : undefined,
origin: queuedCommand.origin,
}); });
void onQuery([userMessage], newAbortController, true, [], mainLoopModel); const autonomyRunId = queuedCommand.autonomy?.runId
if (autonomyRunId) {
void markAutonomyRunRunning(autonomyRunId)
}
void onQuery([userMessage], newAbortController, true, [], mainLoopModel)
.then(() => {
if (autonomyRunId) {
void finalizeAutonomyRunCompleted({
runId: autonomyRunId,
currentDir: getCwd(),
priority: 'later',
}).then(nextCommands => {
for (const command of nextCommands) {
enqueue(command);
}
})
}
})
.catch((error: unknown) => {
if (autonomyRunId) {
void finalizeAutonomyRunFailed({
runId: autonomyRunId,
error: String(error),
})
}
logError(toError(error))
})
return true; return true;
}, },
[onQuery, mainLoopModel, store], [onQuery, mainLoopModel, store],
@@ -4856,6 +4904,7 @@ export function REPL({
const pipeIpcState = useAppState(s => getPipeIpc(s as any)); const pipeIpcState = useAppState(s => getPipeIpc(s as any));
usePipePermissionForward({ store, tools, setMessages, setToolUseConfirmQueue, getToolUseContext, mainLoopModel }); usePipePermissionForward({ store, tools, setMessages, setToolUseConfirmQueue, getToolUseContext, mainLoopModel });
usePipeMuteSync({ setToolUseConfirmQueue });
// Pipe IPC lifecycle — extracted to usePipeIpc hook // Pipe IPC lifecycle — extracted to usePipeIpc hook
usePipeIpc({ store, handleIncomingPrompt }); usePipeIpc({ store, handleIncomingPrompt });
@@ -4898,8 +4947,7 @@ export function REPL({
queuedCommandsLength: queuedCommands.length, queuedCommandsLength: queuedCommands.length,
hasActiveLocalJsxUI: isShowingLocalJSXCommand, hasActiveLocalJsxUI: isShowingLocalJSXCommand,
isInPlanMode: toolPermissionContext.mode === 'plan', isInPlanMode: toolPermissionContext.mode === 'plan',
onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, { isMeta: true }), onQueueTick: (command: QueuedCommand) => enqueue(command),
onQueueTick: (prompt: string) => enqueue({ mode: 'prompt', value: prompt, isMeta: true }),
}); });
// Abort the current operation when a 'now' priority message arrives // Abort the current operation when a 'now' priority message arrives

View File

@@ -466,6 +466,7 @@ const LOCAL_GATE_DEFAULTS: Record<string, unknown> = {
tengu_birch_trellis: true, // Tree-sitter bash security analysis tengu_birch_trellis: true, // Tree-sitter bash security analysis
tengu_collage_kaleidoscope: true, // macOS clipboard image reading tengu_collage_kaleidoscope: true, // macOS clipboard image reading
tengu_compact_cache_prefix: true, // Reuse prompt cache during compaction tengu_compact_cache_prefix: true, // Reuse prompt cache during compaction
tengu_kairos_assistant: true, // KAIROS assistant mode activation
tengu_kairos_cron_durable: true, // Persistent cron tasks tengu_kairos_cron_durable: true, // Persistent cron tasks
tengu_attribution_header: true, // API request attribution header tengu_attribution_header: true, // API request attribution header
tengu_slate_prism: true, // Agent progress summaries tengu_slate_prism: true, // Agent progress summaries
@@ -830,6 +831,16 @@ export function getFeatureValue_CACHED_MAY_BE_STALE<T>(
return localDefault !== undefined ? (localDefault as T) : defaultValue return localDefault !== undefined ? (localDefault as T) : defaultValue
} }
// LOCAL_GATE_DEFAULTS take priority over remote values and disk cache.
// In fork/self-hosted deployments, the GrowthBook server may push false
// for gates we intentionally enable. Local defaults represent the
// project's intentional configuration and override everything except
// env/config overrides (which are explicit user intent).
const localDefault = getLocalGateDefault(feature)
if (localDefault !== undefined) {
return localDefault as T
}
// Log experiment exposure if data is available, otherwise defer until after init // Log experiment exposure if data is available, otherwise defer until after init
if (experimentDataByFeature.has(feature)) { if (experimentDataByFeature.has(feature)) {
logExposureForFeature(feature) logExposureForFeature(feature)
@@ -838,10 +849,6 @@ export function getFeatureValue_CACHED_MAY_BE_STALE<T>(
} }
// In-memory payload is authoritative once processRemoteEvalPayload has run. // In-memory payload is authoritative once processRemoteEvalPayload has run.
// Disk is also fresh by then (syncRemoteEvalToDisk runs synchronously inside
// init), so this is correctness-equivalent to the disk read below — but it
// skips the config JSON parse and is what onGrowthBookRefresh subscribers
// depend on to read fresh values the instant they're notified.
if (remoteEvalFeatureValues.has(feature)) { if (remoteEvalFeatureValues.has(feature)) {
return remoteEvalFeatureValues.get(feature) as T return remoteEvalFeatureValues.get(feature) as T
} }
@@ -853,14 +860,9 @@ export function getFeatureValue_CACHED_MAY_BE_STALE<T>(
return cached as T return cached as T
} }
} catch { } catch {
// Config not yet initialized — fall through to local gate defaults // Config not yet initialized — fall through to defaultValue
} }
// Disk cache miss (or config not initialized) — use local gate defaults return defaultValue
// before falling back to the caller's defaultValue. This covers:
// 1. GrowthBook "enabled" but never connected (caches empty)
// 2. Config not yet initialized (early in startup)
const localDefault = getLocalGateDefault(feature)
return localDefault !== undefined ? (localDefault as T) : defaultValue
} }
/** /**

View File

@@ -0,0 +1,487 @@
/**
* Tests for queryModelOpenAI in index.ts.
*
* Focused on the two bugs fixed:
* 1. stop_reason was always null in the assembled AssistantMessage because
* partialMessage (from message_start) has stop_reason: null, and the
* stop_reason captured from message_delta was never applied.
* 2. partialMessage was not reset to null after message_stop, so the safety
* fallback at the end of the loop would yield a second identical
* AssistantMessage (causing doubled content in the next API request).
*
* Strategy: mock getOpenAIClient + adaptOpenAIStreamToAnthropic so we can
* feed pre-built Anthropic events directly into queryModelOpenAI and inspect
* what it emits — without any real HTTP calls.
*/
import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test'
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type { AssistantMessage, StreamEvent } from '../../../../types/message.js'
// ─── helpers ─────────────────────────────────────────────────────────────────
/** Build a minimal message_start event */
function makeMessageStart(overrides: Record<string, any> = {}): BetaRawMessageStreamEvent {
return {
type: 'message_start',
message: {
id: 'msg_test',
type: 'message',
role: 'assistant',
content: [],
model: 'test-model',
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
...overrides,
},
} as any
}
/** Build a content_block_start event for the given block type */
function makeContentBlockStart(index: number, type: 'text' | 'tool_use' | 'thinking', extra: Record<string, any> = {}): BetaRawMessageStreamEvent {
const block =
type === 'text'
? { type: 'text', text: '' }
: type === 'tool_use'
? { type: 'tool_use', id: 'toolu_test', name: 'bash', input: {} }
: { type: 'thinking', thinking: '', signature: '' }
return { type: 'content_block_start', index, content_block: { ...block, ...extra } } as any
}
/** Build a text_delta content_block_delta event */
function makeTextDelta(index: number, text: string): BetaRawMessageStreamEvent {
return { type: 'content_block_delta', index, delta: { type: 'text_delta', text } } as any
}
/** Build an input_json_delta content_block_delta event */
function makeInputJsonDelta(index: number, json: string): BetaRawMessageStreamEvent {
return { type: 'content_block_delta', index, delta: { type: 'input_json_delta', partial_json: json } } as any
}
/** Build a thinking_delta content_block_delta event */
function makeThinkingDelta(index: number, thinking: string): BetaRawMessageStreamEvent {
return { type: 'content_block_delta', index, delta: { type: 'thinking_delta', thinking } } as any
}
/** Build a content_block_stop event */
function makeContentBlockStop(index: number): BetaRawMessageStreamEvent {
return { type: 'content_block_stop', index } as any
}
/** Build a message_delta event with stop_reason and output_tokens */
function makeMessageDelta(stopReason: string, outputTokens: number): BetaRawMessageStreamEvent {
return {
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage: { output_tokens: outputTokens },
} as any
}
/** Build a message_stop event */
function makeMessageStop(): BetaRawMessageStreamEvent {
return { type: 'message_stop' } as any
}
/** Async generator from a fixed array of events */
async function* eventStream(events: BetaRawMessageStreamEvent[]) {
for (const e of events) yield e
}
/** Collect all outputs from queryModelOpenAI into typed buckets */
async function runQueryModel(
events: BetaRawMessageStreamEvent[],
envOverrides: Record<string, string | undefined> = {},
) {
// Wire events into the mocked stream adapter
_nextEvents = events
// Save + apply env overrides
const saved: Record<string, string | undefined> = {}
for (const [k, v] of Object.entries(envOverrides)) {
saved[k] = process.env[k]
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
try {
// We inline mock.module inside the try block.
// Bun resolves mock.module at the call site synchronously (hoisted),
// so we register once per test file, then re-import each time.
const { queryModelOpenAI } = await import('../index.js')
const assistantMessages: AssistantMessage[] = []
const streamEvents: StreamEvent[] = []
const otherOutputs: any[] = []
const minimalOptions: any = {
model: 'test-model',
tools: [],
agents: [],
querySource: 'main_loop',
getToolPermissionContext: async () => ({
alwaysAllow: [],
alwaysDeny: [],
needsPermission: [],
mode: 'default',
isBypassingPermissions: false,
}),
}
for await (const item of queryModelOpenAI(
[],
{ type: 'text', text: '' } as any,
[],
new AbortController().signal,
minimalOptions,
)) {
if (item.type === 'assistant') {
assistantMessages.push(item as AssistantMessage)
} else if (item.type === 'stream_event') {
streamEvents.push(item as StreamEvent)
} else {
otherOutputs.push(item)
}
}
return { assistantMessages, streamEvents, otherOutputs }
} finally {
// Restore env
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
}
}
// ─── mock setup ──────────────────────────────────────────────────────────────
// We mock at module level. Bun's mock.module replaces the module for the
// entire file, so we configure the stream per-test via a shared variable.
let _nextEvents: BetaRawMessageStreamEvent[] = []
/** Captured arguments from the last chat.completions.create() call */
let _lastCreateArgs: Record<string, any> | null = null
mock.module('../client.js', () => ({
getOpenAIClient: () => ({
chat: {
completions: {
create: async (args: Record<string, any>) => {
_lastCreateArgs = args
return { [Symbol.asyncIterator]: async function* () {} }
},
},
},
}),
}))
mock.module('../streamAdapter.js', () => ({
adaptOpenAIStreamToAnthropic: (_stream: any, _model: string) => eventStream(_nextEvents),
}))
mock.module('../modelMapping.js', () => ({
resolveOpenAIModel: (m: string) => m,
}))
mock.module('../convertMessages.js', () => ({
anthropicMessagesToOpenAI: () => [],
}))
mock.module('../convertTools.js', () => ({
anthropicToolsToOpenAI: () => [],
anthropicToolChoiceToOpenAI: () => undefined,
}))
mock.module('../../../../utils/context.js', () => ({
MODEL_CONTEXT_WINDOW_DEFAULT: 200_000,
COMPACT_MAX_OUTPUT_TOKENS: 20_000,
CAPPED_DEFAULT_MAX_TOKENS: 8_000,
ESCALATED_MAX_TOKENS: 64_000,
is1mContextDisabled: () => false,
has1mContext: () => false,
modelSupports1M: () => false,
getModelMaxOutputTokens: () => ({ upperLimit: 8192, default: 8192 }),
getContextWindowForModel: () => 200_000,
getSonnet1mExpTreatmentEnabled: () => false,
calculateContextPercentages: () => ({ usedPercent: 0, remainingPercent: 100 }),
getMaxThinkingTokensForModel: () => 0,
}))
mock.module('../../../../utils/messages.js', () => ({
normalizeMessagesForAPI: (msgs: any) => msgs,
normalizeContentFromAPI: (blocks: any[]) => blocks,
createAssistantAPIErrorMessage: (opts: any) => ({
type: 'assistant',
message: { content: [{ type: 'text', text: opts.content }], apiError: opts.apiError },
uuid: 'error-uuid',
timestamp: new Date().toISOString(),
}),
}))
mock.module('../../../../utils/api.js', () => ({
toolToAPISchema: async (t: any) => t,
}))
mock.module('../../../../utils/toolSearch.js', () => ({
isToolSearchEnabled: async () => false,
extractDiscoveredToolNames: () => new Set(),
}))
mock.module('../../../../tools/ToolSearchTool/prompt.js', () => ({
isDeferredTool: () => false,
TOOL_SEARCH_TOOL_NAME: '__tool_search__',
}))
mock.module('../../../../cost-tracker.js', () => ({
addToTotalSessionCost: () => {},
}))
mock.module('../../../../utils/modelCost.js', () => ({
COST_TIER_3_15: {},
COST_TIER_15_75: {},
COST_TIER_5_25: {},
COST_TIER_30_150: {},
COST_HAIKU_35: {},
COST_HAIKU_45: {},
getOpus46CostTier: () => ({}),
MODEL_COSTS: {},
getModelCosts: () => ({}),
calculateUSDCost: () => 0,
calculateCostFromTokens: () => 0,
formatModelPricing: () => '',
getModelPricingString: () => undefined,
}))
mock.module('../../../../utils/debug.js', () => ({
logForDebugging: () => {},
logAntError: () => {},
isDebugMode: () => false,
isDebugToStdErr: () => false,
getDebugFilePath: () => null,
getDebugLogPath: () => '',
getDebugFilter: () => null,
getMinDebugLogLevel: () => 'debug',
enableDebugLogging: () => false,
setHasFormattedOutput: () => {},
getHasFormattedOutput: () => false,
flushDebugLogs: async () => {},
}))
// ─── tests ───────────────────────────────────────────────────────────────────
describe('queryModelOpenAI — stop_reason propagation', () => {
test('assembled AssistantMessage has stop_reason end_turn (not null)', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'Hello'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 10),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]!.message.stop_reason).toBe('end_turn')
})
test('assembled AssistantMessage has stop_reason tool_use', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'tool_use'),
makeInputJsonDelta(0, '{"cmd":"ls"}'),
makeContentBlockStop(0),
makeMessageDelta('tool_use', 20),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]!.message.stop_reason).toBe('tool_use')
})
test('assembled AssistantMessage has stop_reason max_tokens', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'truncated'),
makeContentBlockStop(0),
makeMessageDelta('max_tokens', 8192),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
// Two assistant-typed items: the content message + the max_output_tokens error signal.
// The error signal is emitted as a synthetic assistant message by createAssistantAPIErrorMessage.
expect(assistantMessages).toHaveLength(2)
const contentMsg = assistantMessages[0]!
expect(contentMsg.message.stop_reason).toBe('max_tokens')
// Second item is the error signal (has apiError set)
const errorMsg = assistantMessages[1]!.message as any
expect(errorMsg.apiError).toBe('max_output_tokens')
})
test('stop_reason is null when no message_delta was received (safety fallback path)', async () => {
// Stream ends without message_stop — triggers the safety fallback branch.
// stop_reason stays null since no message_delta was ever seen.
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'partial'),
makeContentBlockStop(0),
// No message_delta / message_stop
]
const { assistantMessages } = await runQueryModel(_nextEvents)
// Safety fallback should yield the partial content
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]!.message.stop_reason).toBeNull()
})
})
describe('queryModelOpenAI — usage accumulation', () => {
test('usage in assembled message reflects all four fields from message_delta', async () => {
// message_start has all fields=0 (trailing-chunk pattern: usage not yet available).
// message_delta carries the real values after stream ends.
// The spread in the message_delta handler must override all zeros from message_start,
// including cache_read_input_tokens which was previously missing from message_delta.
_nextEvents = [
makeMessageStart({ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } }),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'response'),
makeContentBlockStop(0),
// message_delta carries all four Anthropic usage fields (as emitted by the fixed streamAdapter)
{
type: 'message_delta',
delta: { stop_reason: 'end_turn', stop_sequence: null },
usage: { input_tokens: 30011, output_tokens: 190, cache_read_input_tokens: 19904, cache_creation_input_tokens: 0 },
} as any,
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
const usage = assistantMessages[0]!.message.usage as any
expect(usage.input_tokens).toBe(30011)
expect(usage.output_tokens).toBe(190)
// cache_read_input_tokens from message_delta overrides the 0 from message_start
expect(usage.cache_read_input_tokens).toBe(19904)
expect(usage.cache_creation_input_tokens).toBe(0)
})
test('usage is zero when no usage events arrive (prevents false autocompact)', async () => {
// If usage stays 0, tokenCountWithEstimation will undercount — so at least
// verify the field exists and is numeric (to detect regressions).
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'hi'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 0),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
const usage = assistantMessages[0]!.message.usage as any
expect(typeof usage.input_tokens).toBe('number')
expect(typeof usage.output_tokens).toBe('number')
})
})
describe('queryModelOpenAI — no duplicate AssistantMessage (partialMessage reset)', () => {
test('yields exactly one AssistantMessage per message_stop when content is present', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'only once'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 5),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
// Before the fix, partialMessage was not reset to null, so the safety
// fallback at the end of the loop would yield a second message with the
// same message.id — causing mergeAssistantMessages to concatenate content.
expect(assistantMessages).toHaveLength(1)
})
test('thinking + text response yields exactly one AssistantMessage', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'thinking'),
makeThinkingDelta(0, 'let me think'),
makeContentBlockStop(0),
makeContentBlockStart(1, 'text'),
makeTextDelta(1, 'answer'),
makeContentBlockStop(1),
makeMessageDelta('end_turn', 30),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
})
test('safety fallback path still yields message when stream ends without message_stop', async () => {
// Simulates a stream that cuts off without the normal termination sequence.
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'abrupt end'),
// No content_block_stop, no message_delta, no message_stop
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
})
})
describe('queryModelOpenAI — stream_events forwarded', () => {
test('every adapted event is also yielded as stream_event for real-time display', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'hello'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 5),
makeMessageStop(),
]
const { streamEvents } = await runQueryModel(_nextEvents)
const eventTypes = streamEvents.map(e => (e as any).event?.type)
expect(eventTypes).toContain('message_start')
expect(eventTypes).toContain('content_block_start')
expect(eventTypes).toContain('content_block_delta')
expect(eventTypes).toContain('content_block_stop')
expect(eventTypes).toContain('message_delta')
expect(eventTypes).toContain('message_stop')
})
})
describe('queryModelOpenAI — max_tokens forwarded to request', () => {
test('buildOpenAIRequestBody includes max_tokens in the request payload', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'hi'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 5),
makeMessageStop(),
]
await runQueryModel(_nextEvents)
expect(_lastCreateArgs).not.toBeNull()
expect(_lastCreateArgs!.max_tokens).toBe(8192)
})
})

View File

@@ -1,454 +1,40 @@
/** import { describe, expect, test } from 'bun:test'
* Tests for queryModelOpenAI in index.ts. import { fileURLToPath } from 'node:url'
*
* Focused on the two bugs fixed:
* 1. stop_reason was always null in the assembled AssistantMessage because
* partialMessage (from message_start) has stop_reason: null, and the
* stop_reason captured from message_delta was never applied.
* 2. partialMessage was not reset to null after message_stop, so the safety
* fallback at the end of the loop would yield a second identical
* AssistantMessage (causing doubled content in the next API request).
*
* Strategy: mock getOpenAIClient + adaptOpenAIStreamToAnthropic so we can
* feed pre-built Anthropic events directly into queryModelOpenAI and inspect
* what it emits — without any real HTTP calls.
*/
import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test'
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type { AssistantMessage, StreamEvent } from '../../../../types/message.js'
// ─── helpers ───────────────────────────────────────────────────────────────── const isolatedPath = fileURLToPath(
new URL('./queryModelOpenAI.isolated.ts', import.meta.url),
)
/** Build a minimal message_start event */ describe('queryModelOpenAI', () => {
function makeMessageStart(overrides: Record<string, any> = {}): BetaRawMessageStreamEvent { test('passes in isolated subprocess', async () => {
return { const proc = Bun.spawn({
type: 'message_start', cmd: [process.execPath, 'test', isolatedPath],
message: { cwd: process.cwd(),
id: 'msg_test', stdout: 'pipe',
type: 'message', stderr: 'pipe',
role: 'assistant', env: process.env,
content: [], })
model: 'test-model',
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
...overrides,
},
} as any
}
/** Build a content_block_start event for the given block type */ const [stdout, stderr, exitCode] = await Promise.all([
function makeContentBlockStart(index: number, type: 'text' | 'tool_use' | 'thinking', extra: Record<string, any> = {}): BetaRawMessageStreamEvent { new Response(proc.stdout).text(),
const block = new Response(proc.stderr).text(),
type === 'text' proc.exited,
? { type: 'text', text: '' } ])
: type === 'tool_use'
? { type: 'tool_use', id: 'toolu_test', name: 'bash', input: {} }
: { type: 'thinking', thinking: '', signature: '' }
return { type: 'content_block_start', index, content_block: { ...block, ...extra } } as any
}
/** Build a text_delta content_block_delta event */ if (exitCode !== 0) {
function makeTextDelta(index: number, text: string): BetaRawMessageStreamEvent { throw new Error(
return { type: 'content_block_delta', index, delta: { type: 'text_delta', text } } as any [
} `isolated queryModelOpenAI test failed with exit code ${exitCode}`,
'',
/** Build an input_json_delta content_block_delta event */ 'STDOUT:',
function makeInputJsonDelta(index: number, json: string): BetaRawMessageStreamEvent { stdout,
return { type: 'content_block_delta', index, delta: { type: 'input_json_delta', partial_json: json } } as any '',
} 'STDERR:',
stderr,
/** Build a thinking_delta content_block_delta event */ ].join('\n'),
function makeThinkingDelta(index: number, thinking: string): BetaRawMessageStreamEvent { )
return { type: 'content_block_delta', index, delta: { type: 'thinking_delta', thinking } } as any
}
/** Build a content_block_stop event */
function makeContentBlockStop(index: number): BetaRawMessageStreamEvent {
return { type: 'content_block_stop', index } as any
}
/** Build a message_delta event with stop_reason and output_tokens */
function makeMessageDelta(stopReason: string, outputTokens: number): BetaRawMessageStreamEvent {
return {
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage: { output_tokens: outputTokens },
} as any
}
/** Build a message_stop event */
function makeMessageStop(): BetaRawMessageStreamEvent {
return { type: 'message_stop' } as any
}
/** Async generator from a fixed array of events */
async function* eventStream(events: BetaRawMessageStreamEvent[]) {
for (const e of events) yield e
}
/** Collect all outputs from queryModelOpenAI into typed buckets */
async function runQueryModel(
events: BetaRawMessageStreamEvent[],
envOverrides: Record<string, string | undefined> = {},
) {
// Wire events into the mocked stream adapter
_nextEvents = events
// Save + apply env overrides
const saved: Record<string, string | undefined> = {}
for (const [k, v] of Object.entries(envOverrides)) {
saved[k] = process.env[k]
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
try {
// We inline mock.module inside the try block.
// Bun resolves mock.module at the call site synchronously (hoisted),
// so we register once per test file, then re-import each time.
const { queryModelOpenAI } = await import('../index.js')
const assistantMessages: AssistantMessage[] = []
const streamEvents: StreamEvent[] = []
const otherOutputs: any[] = []
const minimalOptions: any = {
model: 'test-model',
tools: [],
agents: [],
querySource: 'main_loop',
getToolPermissionContext: async () => ({
alwaysAllow: [],
alwaysDeny: [],
needsPermission: [],
mode: 'default',
isBypassingPermissions: false,
}),
} }
for await (const item of queryModelOpenAI( expect(exitCode).toBe(0)
[],
{ type: 'text', text: '' } as any,
[],
new AbortController().signal,
minimalOptions,
)) {
if (item.type === 'assistant') {
assistantMessages.push(item as AssistantMessage)
} else if (item.type === 'stream_event') {
streamEvents.push(item as StreamEvent)
} else {
otherOutputs.push(item)
}
}
return { assistantMessages, streamEvents, otherOutputs }
} finally {
// Restore env
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
}
}
// ─── mock setup ──────────────────────────────────────────────────────────────
// We mock at module level. Bun's mock.module replaces the module for the
// entire file, so we configure the stream per-test via a shared variable.
let _nextEvents: BetaRawMessageStreamEvent[] = []
/** Captured arguments from the last chat.completions.create() call */
let _lastCreateArgs: Record<string, any> | null = null
mock.module('../client.js', () => ({
getOpenAIClient: () => ({
chat: {
completions: {
create: async (args: Record<string, any>) => {
_lastCreateArgs = args
return { [Symbol.asyncIterator]: async function* () {} }
},
},
},
}),
}))
mock.module('../streamAdapter.js', () => ({
adaptOpenAIStreamToAnthropic: (_stream: any, _model: string) => eventStream(_nextEvents),
}))
mock.module('../modelMapping.js', () => ({
resolveOpenAIModel: (m: string) => m,
}))
mock.module('../convertMessages.js', () => ({
anthropicMessagesToOpenAI: () => [],
}))
mock.module('../convertTools.js', () => ({
anthropicToolsToOpenAI: () => [],
anthropicToolChoiceToOpenAI: () => undefined,
}))
mock.module('../../../../utils/context.js', () => ({
getModelMaxOutputTokens: () => ({ upperLimit: 8192, default: 8192 }),
getContextWindowForModel: () => 200_000,
}))
mock.module('../../../../utils/messages.js', () => ({
normalizeMessagesForAPI: (msgs: any) => msgs,
normalizeContentFromAPI: (blocks: any[]) => blocks,
createAssistantAPIErrorMessage: (opts: any) => ({
type: 'assistant',
message: { content: [{ type: 'text', text: opts.content }], apiError: opts.apiError },
uuid: 'error-uuid',
timestamp: new Date().toISOString(),
}),
}))
mock.module('../../../../utils/api.js', () => ({
toolToAPISchema: async (t: any) => t,
}))
mock.module('../../../../utils/toolSearch.js', () => ({
isToolSearchEnabled: async () => false,
extractDiscoveredToolNames: () => new Set(),
}))
mock.module('../../../../tools/ToolSearchTool/prompt.js', () => ({
isDeferredTool: () => false,
TOOL_SEARCH_TOOL_NAME: '__tool_search__',
}))
mock.module('../../../../cost-tracker.js', () => ({
addToTotalSessionCost: () => {},
}))
mock.module('../../../../utils/modelCost.js', () => ({
calculateUSDCost: () => 0,
}))
mock.module('../../../../utils/debug.js', () => ({
logForDebugging: () => {},
}))
// ─── tests ───────────────────────────────────────────────────────────────────
describe('queryModelOpenAI — stop_reason propagation', () => {
test('assembled AssistantMessage has stop_reason end_turn (not null)', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'Hello'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 10),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]!.message.stop_reason).toBe('end_turn')
})
test('assembled AssistantMessage has stop_reason tool_use', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'tool_use'),
makeInputJsonDelta(0, '{"cmd":"ls"}'),
makeContentBlockStop(0),
makeMessageDelta('tool_use', 20),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]!.message.stop_reason).toBe('tool_use')
})
test('assembled AssistantMessage has stop_reason max_tokens', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'truncated'),
makeContentBlockStop(0),
makeMessageDelta('max_tokens', 8192),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
// Two assistant-typed items: the content message + the max_output_tokens error signal.
// The error signal is emitted as a synthetic assistant message by createAssistantAPIErrorMessage.
expect(assistantMessages).toHaveLength(2)
const contentMsg = assistantMessages[0]!
expect(contentMsg.message.stop_reason).toBe('max_tokens')
// Second item is the error signal (has apiError set)
const errorMsg = assistantMessages[1]!.message as any
expect(errorMsg.apiError).toBe('max_output_tokens')
})
test('stop_reason is null when no message_delta was received (safety fallback path)', async () => {
// Stream ends without message_stop — triggers the safety fallback branch.
// stop_reason stays null since no message_delta was ever seen.
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'partial'),
makeContentBlockStop(0),
// No message_delta / message_stop
]
const { assistantMessages } = await runQueryModel(_nextEvents)
// Safety fallback should yield the partial content
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]!.message.stop_reason).toBeNull()
})
})
describe('queryModelOpenAI — usage accumulation', () => {
test('usage in assembled message reflects all four fields from message_delta', async () => {
// message_start has all fields=0 (trailing-chunk pattern: usage not yet available).
// message_delta carries the real values after stream ends.
// The spread in the message_delta handler must override all zeros from message_start,
// including cache_read_input_tokens which was previously missing from message_delta.
_nextEvents = [
makeMessageStart({ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } }),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'response'),
makeContentBlockStop(0),
// message_delta carries all four Anthropic usage fields (as emitted by the fixed streamAdapter)
{
type: 'message_delta',
delta: { stop_reason: 'end_turn', stop_sequence: null },
usage: { input_tokens: 30011, output_tokens: 190, cache_read_input_tokens: 19904, cache_creation_input_tokens: 0 },
} as any,
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
const usage = assistantMessages[0]!.message.usage as any
expect(usage.input_tokens).toBe(30011)
expect(usage.output_tokens).toBe(190)
// cache_read_input_tokens from message_delta overrides the 0 from message_start
expect(usage.cache_read_input_tokens).toBe(19904)
expect(usage.cache_creation_input_tokens).toBe(0)
})
test('usage is zero when no usage events arrive (prevents false autocompact)', async () => {
// If usage stays 0, tokenCountWithEstimation will undercount — so at least
// verify the field exists and is numeric (to detect regressions).
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'hi'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 0),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
const usage = assistantMessages[0]!.message.usage as any
expect(typeof usage.input_tokens).toBe('number')
expect(typeof usage.output_tokens).toBe('number')
})
})
describe('queryModelOpenAI — no duplicate AssistantMessage (partialMessage reset)', () => {
test('yields exactly one AssistantMessage per message_stop when content is present', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'only once'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 5),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
// Before the fix, partialMessage was not reset to null, so the safety
// fallback at the end of the loop would yield a second message with the
// same message.id — causing mergeAssistantMessages to concatenate content.
expect(assistantMessages).toHaveLength(1)
})
test('thinking + text response yields exactly one AssistantMessage', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'thinking'),
makeThinkingDelta(0, 'let me think'),
makeContentBlockStop(0),
makeContentBlockStart(1, 'text'),
makeTextDelta(1, 'answer'),
makeContentBlockStop(1),
makeMessageDelta('end_turn', 30),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
})
test('safety fallback path still yields message when stream ends without message_stop', async () => {
// Simulates a stream that cuts off without the normal termination sequence.
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'abrupt end'),
// No content_block_stop, no message_delta, no message_stop
]
const { assistantMessages } = await runQueryModel(_nextEvents)
expect(assistantMessages).toHaveLength(1)
})
})
describe('queryModelOpenAI — stream_events forwarded', () => {
test('every adapted event is also yielded as stream_event for real-time display', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'hello'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 5),
makeMessageStop(),
]
const { streamEvents } = await runQueryModel(_nextEvents)
const eventTypes = streamEvents.map(e => (e as any).event?.type)
expect(eventTypes).toContain('message_start')
expect(eventTypes).toContain('content_block_start')
expect(eventTypes).toContain('content_block_delta')
expect(eventTypes).toContain('content_block_stop')
expect(eventTypes).toContain('message_delta')
expect(eventTypes).toContain('message_stop')
})
})
describe('queryModelOpenAI — max_tokens forwarded to request', () => {
test('buildOpenAIRequestBody includes max_tokens in the request payload', async () => {
_nextEvents = [
makeMessageStart(),
makeContentBlockStart(0, 'text'),
makeTextDelta(0, 'hi'),
makeContentBlockStop(0),
makeMessageDelta('end_turn', 5),
makeMessageStop(),
]
await runQueryModel(_nextEvents)
expect(_lastCreateArgs).not.toBeNull()
expect(_lastCreateArgs!.max_tokens).toBe(8192)
}) })
}) })

View File

@@ -1,6 +1,21 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { adaptOpenAIStreamToAnthropic } from '../streamAdapter.js'
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs' import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { readFileSync, writeFileSync, mkdirSync } from 'fs'
import { tmpdir } from 'os'
// Guard against mock pollution from queryModelOpenAI.test.ts which replaces
// ../streamAdapter.js process-wide via mock.module (bun has no un-mock API).
// We copy the source to a unique temp path so the import bypasses bun's
// module mock cache completely.
const _testDir = dirname(fileURLToPath(import.meta.url))
const _realSource = readFileSync(join(_testDir, '..', 'streamAdapter.ts'), 'utf-8')
const _tempDir = join(tmpdir(), `stream-adapter-test-${Date.now()}`)
mkdirSync(_tempDir, { recursive: true })
const _tempFile = join(_tempDir, 'streamAdapter.ts')
writeFileSync(_tempFile, _realSource, 'utf-8')
const { adaptOpenAIStreamToAnthropic } = await import(_tempFile)
/** Helper to create a mock async iterable from chunk array */ /** Helper to create a mock async iterable from chunk array */
function mockStream(chunks: ChatCompletionChunk[]): AsyncIterable<ChatCompletionChunk> { function mockStream(chunks: ChatCompletionChunk[]): AsyncIterable<ChatCompletionChunk> {
@@ -31,6 +46,11 @@ function makeChunk(overrides: Partial<ChatCompletionChunk> & any = {}): ChatComp
/** Collect all emitted Anthropic events from the stream adapter for assertion */ /** Collect all emitted Anthropic events from the stream adapter for assertion */
async function collectEvents(chunks: ChatCompletionChunk[]) { async function collectEvents(chunks: ChatCompletionChunk[]) {
const realModuleUrl = new URL(
`../streamAdapter.js?real=${Date.now()}-${Math.random().toString(36).slice(2)}`,
import.meta.url,
).href
const { adaptOpenAIStreamToAnthropic } = await import(realModuleUrl)
const events: any[] = [] const events: any[] = []
for await (const event of adaptOpenAIStreamToAnthropic(mockStream(chunks), 'gpt-4o')) { for await (const event of adaptOpenAIStreamToAnthropic(mockStream(chunks), 'gpt-4o')) {
events.push(event) events.push(event)

View File

@@ -8,6 +8,7 @@ import {
} from '../utils/messages.js' } from '../utils/messages.js'
import { getSmallFastModel } from '../utils/model/model.js' import { getSmallFastModel } from '../utils/model/model.js'
import { asSystemPrompt } from '../utils/systemPromptType.js' import { asSystemPrompt } from '../utils/systemPromptType.js'
import { getResolvedLanguage } from '../utils/language.js'
import { queryModelWithoutStreaming } from './api/claude.js' import { queryModelWithoutStreaming } from './api/claude.js'
import { getSessionMemoryContent } from './SessionMemory/sessionMemoryUtils.js' import { getSessionMemoryContent } from './SessionMemory/sessionMemoryUtils.js'
@@ -15,11 +16,18 @@ import { getSessionMemoryContent } from './SessionMemory/sessionMemoryUtils.js'
// large sessions. 30 messages ≈ ~15 exchanges, plenty for "where we left off." // large sessions. 30 messages ≈ ~15 exchanges, plenty for "where we left off."
const RECENT_MESSAGE_WINDOW = 30 const RECENT_MESSAGE_WINDOW = 30
const PROMPT_EN =
'The user stepped away and is coming back. Write exactly 1-3 short sentences. Start by stating the high-level task — what they are building or debugging, not implementation details. Next: the concrete next step. Skip status reports and commit recaps.'
const PROMPT_ZH =
'用户离开后回来了。用中文写 1-3 句话。先说明用户在做什么(高层目标,不是实现细节),然后说明下一步具体操作。不要写状态报告或提交总结。'
function buildAwaySummaryPrompt(memory: string | null): string { function buildAwaySummaryPrompt(memory: string | null): string {
const memoryBlock = memory const memoryBlock = memory
? `Session memory (broader context):\n${memory}\n\n` ? `Session memory (broader context):\n${memory}\n\n`
: '' : ''
return `${memoryBlock}The user stepped away and is coming back. Write exactly 1-3 short sentences. Start by stating the high-level task — what they are building or debugging, not implementation details. Next: the concrete next step. Skip status reports and commit recaps.` const prompt = getResolvedLanguage() === 'zh' ? PROMPT_ZH : PROMPT_EN
return `${memoryBlock}${prompt}`
} }
/** /**

View File

@@ -0,0 +1,569 @@
import { mock, describe, test, expect, beforeEach } from 'bun:test'
// Mock @langfuse/otel before any imports
const mockForceFlush = mock(() => Promise.resolve())
const mockShutdown = mock(() => Promise.resolve())
mock.module('@langfuse/otel', () => ({
LangfuseSpanProcessor: class MockLangfuseSpanProcessor {
forceFlush = mockForceFlush
shutdown = mockShutdown
onStart = mock(() => {})
onEnd = mock(() => {})
},
}))
// Mock @opentelemetry/sdk-trace-base
mock.module('@opentelemetry/sdk-trace-base', () => ({
BasicTracerProvider: class MockBasicTracerProvider {
constructor(_opts?: unknown) {}
},
}))
// Mock @langfuse/tracing
const mockChildUpdate = mock(() => {})
const mockChildEnd = mock(() => {})
const mockRootUpdate = mock(() => {})
const mockRootEnd = mock(() => {})
// Mock LangfuseOtelSpanAttributes (re-exported from @langfuse/core)
const mockLangfuseOtelSpanAttributes: Record<string, string> = {
TRACE_SESSION_ID: 'session.id',
OBSERVATION_TYPE: 'observation.type',
OBSERVATION_INPUT: 'observation.input',
OBSERVATION_OUTPUT: 'observation.output',
OBSERVATION_MODEL: 'observation.model',
OBSERVATION_COMPLETION_START_TIME: 'observation.completionStartTime',
OBSERVATION_USAGE_DETAILS: 'observation.usageDetails',
}
const mockSpanContext = { traceId: 'test-trace-id', spanId: 'test-span-id', traceFlags: 1 }
const mockSetAttribute = mock(() => {})
// Child observation mock (returned by rootSpan.startObservation for tools)
const mockChildStartObservation = mock(() => ({
id: 'child-id',
update: mockChildUpdate,
end: mockChildEnd,
}))
const mockStartObservation = mock(() => ({
id: 'test-span-id',
traceId: 'test-trace-id',
type: 'span',
otelSpan: {
spanContext: () => mockSpanContext,
setAttribute: mockSetAttribute,
},
update: mockRootUpdate,
end: mockRootEnd,
// Instance method — used by recordToolObservation
startObservation: mockChildStartObservation,
}))
const mockSetLangfuseTracerProvider = mock(() => {})
mock.module('@langfuse/tracing', () => ({
startObservation: mockStartObservation,
LangfuseOtelSpanAttributes: mockLangfuseOtelSpanAttributes,
propagateAttributes: mock((_params: unknown, fn?: () => void) => fn?.()),
setLangfuseTracerProvider: mockSetLangfuseTracerProvider,
}))
// Mock debug logger
mock.module('src/utils/debug.js', () => ({
logForDebugging: mock(() => {}),
}))
describe('Langfuse integration', () => {
beforeEach(() => {
// Reset env
process.env.HOME = '/Users/testuser'
delete process.env.LANGFUSE_PUBLIC_KEY
delete process.env.LANGFUSE_SECRET_KEY
delete process.env.LANGFUSE_BASE_URL
mockStartObservation.mockClear()
mockChildStartObservation.mockClear()
mockChildUpdate.mockClear()
mockChildEnd.mockClear()
mockRootUpdate.mockClear()
mockRootEnd.mockClear()
mockForceFlush.mockClear()
mockShutdown.mockClear()
mockSetAttribute.mockClear()
})
// ── sanitize tests ──────────────────────────────────────────────────────────
describe('sanitizeToolInput', () => {
test('replaces home dir in file_path', async () => {
const { sanitizeToolInput } = await import('../sanitize.js')
const home = process.env.HOME ?? '/Users/testuser'
const result = sanitizeToolInput('FileReadTool', { file_path: `${home}/project/file.ts` }) as Record<string, string>
expect(result.file_path).toBe('~/project/file.ts')
})
test('redacts sensitive keys', async () => {
const { sanitizeToolInput } = await import('../sanitize.js')
const result = sanitizeToolInput('MCPTool', { api_key: 'secret123', token: 'abc' }) as Record<string, string>
expect(result.api_key).toBe('[REDACTED]')
expect(result.token).toBe('[REDACTED]')
})
test('returns non-object input unchanged', async () => {
const { sanitizeToolInput } = await import('../sanitize.js')
expect(sanitizeToolInput('BashTool', 'raw string')).toBe('raw string')
expect(sanitizeToolInput('BashTool', null)).toBe(null)
})
})
describe('sanitizeToolOutput', () => {
test('redacts FileReadTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('FileReadTool', 'file content here')
expect(result).toBe('[file content redacted, 17 chars]')
})
test('redacts FileWriteTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('FileWriteTool', 'written content')
expect(result).toBe('[file content redacted, 15 chars]')
})
test('truncates BashTool output over 500 chars', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const longOutput = 'x'.repeat(600)
const result = sanitizeToolOutput('BashTool', longOutput)
expect(result).toContain('[truncated]')
expect(result.length).toBeLessThan(600)
})
test('does not truncate BashTool output under 500 chars', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const shortOutput = 'hello world'
expect(sanitizeToolOutput('BashTool', shortOutput)).toBe('hello world')
})
test('redacts ConfigTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('ConfigTool', 'config data')
expect(result).toBe('[ConfigTool output redacted, 11 chars]')
})
test('redacts MCPTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('MCPTool', 'mcp data')
expect(result).toBe('[MCPTool output redacted, 8 chars]')
})
})
describe('sanitizeGlobal', () => {
test('replaces home dir in strings', async () => {
const { sanitizeGlobal } = await import('../sanitize.js')
const home = process.env.HOME ?? '/Users/testuser'
expect(sanitizeGlobal(`path: ${home}/file`)).toBe('path: ~/file')
})
test('recursively sanitizes nested objects', async () => {
const { sanitizeGlobal } = await import('../sanitize.js')
const result = sanitizeGlobal({ nested: { api_key: 'secret', name: 'test' } }) as Record<string, Record<string, string>>
expect(result.nested.api_key).toBe('[REDACTED]')
expect(result.nested.name).toBe('test')
})
test('returns non-string/object values unchanged', async () => {
const { sanitizeGlobal } = await import('../sanitize.js')
expect(sanitizeGlobal(42)).toBe(42)
expect(sanitizeGlobal(true)).toBe(true)
})
})
// ── client tests ────────────────────────────────────────────────────────────
describe('isLangfuseEnabled', () => {
test('returns false when keys not configured', async () => {
const { isLangfuseEnabled } = await import('../client.js')
expect(isLangfuseEnabled()).toBe(false)
})
test('returns true when both keys are set', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { isLangfuseEnabled } = await import('../client.js')
expect(isLangfuseEnabled()).toBe(true)
})
})
describe('initLangfuse', () => {
test('returns false when keys not configured', async () => {
const { initLangfuse } = await import('../client.js')
expect(initLangfuse()).toBe(false)
})
test('returns true when keys are configured', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
// client.js is a singleton — test via isLangfuseEnabled which reads env directly
const { isLangfuseEnabled } = await import('../client.js')
expect(isLangfuseEnabled()).toBe(true)
})
test('is idempotent — multiple calls do not re-initialize', async () => {
// client.js singleton: once processor is set, initLangfuse returns true immediately
// We verify this by checking that calling it multiple times doesn't throw
const { initLangfuse } = await import('../client.js')
expect(() => { initLangfuse(); initLangfuse() }).not.toThrow()
})
})
describe('shutdownLangfuse', () => {
test('calls forceFlush and shutdown on processor', async () => {
// Verify shutdown is callable without error even when no processor is set
const { shutdownLangfuse } = await import('../client.js')
await expect(shutdownLangfuse()).resolves.toBeUndefined()
})
})
// ── tracing tests ───────────────────────────────────────────────────────────
describe('createTrace', () => {
test('returns null when langfuse not enabled', async () => {
const { createTrace } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
expect(span).toBeNull()
})
test('creates root span when enabled', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty', input: [] })
expect(span).not.toBeNull()
expect(mockStartObservation).toHaveBeenCalledWith('agent-run', expect.objectContaining({
metadata: expect.objectContaining({ provider: 'firstParty', model: 'claude-3' }),
}), { asType: 'agent' })
})
})
describe('recordLLMObservation', () => {
test('no-ops when rootSpan is null', async () => {
const { recordLLMObservation } = await import('../tracing.js')
recordLLMObservation(null, { model: 'm', provider: 'firstParty', input: [], output: [], usage: { input_tokens: 10, output_tokens: 5 } })
expect(mockStartObservation).toHaveBeenCalledTimes(0)
})
test('records generation child observation via global startObservation', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordLLMObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockStartObservation.mockClear()
recordLLMObservation(span, {
model: 'claude-3',
provider: 'firstParty',
input: [{ role: 'user', content: 'hello' }],
output: [{ role: 'assistant', content: 'hi' }],
usage: { input_tokens: 10, output_tokens: 5 },
})
// Should call the global startObservation with asType: 'generation' and parentSpanContext
expect(mockStartObservation).toHaveBeenCalledWith('ChatAnthropic', expect.objectContaining({
model: 'claude-3',
}), expect.objectContaining({
asType: 'generation',
parentSpanContext: mockSpanContext,
}))
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({
usageDetails: { input: 10, output: 5 },
}))
expect(mockRootEnd).toHaveBeenCalled()
})
})
describe('recordToolObservation', () => {
test('no-ops when rootSpan is null', async () => {
const { recordToolObservation } = await import('../tracing.js')
recordToolObservation(null, { toolName: 'BashTool', toolUseId: 'id1', input: {}, output: 'out' })
// startObservation should not be called beyond the initial trace creation (none here)
})
test('records tool child observation via global startObservation', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockStartObservation.mockClear()
mockRootUpdate.mockClear()
mockRootEnd.mockClear()
recordToolObservation(span, {
toolName: 'BashTool',
toolUseId: 'tu-1',
input: { command: 'ls' },
output: 'file.ts',
})
// Should call the global startObservation with asType: 'tool' and parentSpanContext
expect(mockStartObservation).toHaveBeenCalledWith('BashTool', expect.objectContaining({
input: expect.any(Object),
}), expect.objectContaining({
asType: 'tool',
parentSpanContext: mockSpanContext,
}))
expect(mockRootUpdate).toHaveBeenCalled()
expect(mockRootEnd).toHaveBeenCalled()
})
test('passes startTime to global startObservation', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockStartObservation.mockClear()
const startTime = new Date('2026-01-01T00:00:00Z')
recordToolObservation(span, {
toolName: 'BashTool',
toolUseId: 'tu-2',
input: {},
output: 'out',
startTime,
})
expect(mockStartObservation).toHaveBeenCalledWith('BashTool', expect.any(Object), expect.objectContaining({
startTime,
parentSpanContext: mockSpanContext,
}))
})
test('sanitizes FileReadTool output', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockRootUpdate.mockClear()
recordToolObservation(span, {
toolName: 'FileReadTool',
toolUseId: 'tu-2',
input: { file_path: '/tmp/file.ts' },
output: 'file content here',
})
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({
output: '[file content redacted, 17 chars]',
}))
})
test('sets ERROR level for error observations', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockRootUpdate.mockClear()
recordToolObservation(span, {
toolName: 'BashTool',
toolUseId: 'tu-3',
input: {},
output: 'error occurred',
isError: true,
})
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({ level: 'ERROR' }))
})
})
describe('endTrace', () => {
test('no-ops when rootSpan is null', async () => {
const { endTrace } = await import('../tracing.js')
endTrace(null)
expect(mockRootEnd).not.toHaveBeenCalled()
})
test('calls span.end()', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, endTrace } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
endTrace(span)
expect(mockRootEnd).toHaveBeenCalled()
})
test('calls span.update() with output when provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, endTrace } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
endTrace(span, 'final output')
expect(mockRootUpdate).toHaveBeenCalledWith({ output: 'final output' })
expect(mockRootEnd).toHaveBeenCalled()
})
})
describe('createSubagentTrace', () => {
test('returns null when langfuse not enabled', async () => {
const { createSubagentTrace } = await import('../tracing.js')
const span = createSubagentTrace({
sessionId: 's1',
agentType: 'Explore',
agentId: 'agent-1',
model: 'claude-3',
provider: 'firstParty',
})
expect(span).toBeNull()
})
test('creates trace with agentType and agentId metadata', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createSubagentTrace } = await import('../tracing.js')
const span = createSubagentTrace({
sessionId: 's1',
agentType: 'Explore',
agentId: 'agent-1',
model: 'claude-3',
provider: 'firstParty',
input: [{ role: 'user', content: 'search for X' }],
})
expect(span).not.toBeNull()
expect(mockStartObservation).toHaveBeenCalledWith('agent:Explore', expect.objectContaining({
metadata: expect.objectContaining({
agentType: 'Explore',
agentId: 'agent-1',
provider: 'firstParty',
model: 'claude-3',
}),
}), { asType: 'agent' })
// Verify session.id attribute is set
expect(mockSetAttribute).toHaveBeenCalledWith('session.id', 's1')
})
test('returns null on SDK error', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockImplementationOnce(() => { throw new Error('SDK error') })
const { createSubagentTrace } = await import('../tracing.js')
const span = createSubagentTrace({
sessionId: 's1',
agentType: 'Plan',
agentId: 'agent-2',
model: 'claude-3',
provider: 'firstParty',
})
expect(span).toBeNull()
})
})
describe('createTrace with querySource', () => {
test('includes querySource in metadata', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
querySource: 'user',
})
expect(span).not.toBeNull()
expect(mockStartObservation).toHaveBeenCalledWith('agent-run:user', expect.objectContaining({
metadata: expect.objectContaining({
agentType: 'main',
querySource: 'user',
}),
}), { asType: 'agent' })
})
test('omits querySource when not provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockClear()
const { createTrace } = await import('../tracing.js')
createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
const calls = mockStartObservation.mock.calls as unknown[][]
const secondArg = calls[0]?.[1] as Record<string, unknown> | undefined
const metadata = (secondArg?.metadata ?? {}) as Record<string, unknown>
expect(metadata).not.toHaveProperty('querySource')
})
})
describe('nested agent scenario', () => {
test('sub-agent trace shares sessionId with parent', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, createSubagentTrace } = await import('../tracing.js')
mockSetAttribute.mockClear()
// Create parent trace
const parentSpan = createTrace({
sessionId: 'shared-session',
model: 'claude-3',
provider: 'firstParty',
})
// Create sub-agent trace with same sessionId
const subSpan = createSubagentTrace({
sessionId: 'shared-session',
agentType: 'Explore',
agentId: 'agent-explore-1',
model: 'claude-3',
provider: 'firstParty',
})
expect(parentSpan).not.toBeNull()
expect(subSpan).not.toBeNull()
// Both should have set session.id attribute
const sessionAttributeCalls = mockSetAttribute.mock.calls.filter(
(call: unknown[]) => Array.isArray(call) && call[0] === 'session.id' && call[1] === 'shared-session',
)
expect(sessionAttributeCalls.length).toBeGreaterThanOrEqual(2)
})
test('query reuses passed langfuseTrace instead of creating new one', async () => {
// This validates the pattern used in query.ts:
// const ownsTrace = !params.toolUseContext.langfuseTrace
// const langfuseTrace = params.toolUseContext.langfuseTrace ?? createTrace(...)
// When langfuseTrace is already set, createTrace should NOT be called
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createSubagentTrace } = await import('../tracing.js')
// Simulate what runAgent does: create subTrace, then pass it as langfuseTrace
const subTrace = createSubagentTrace({
sessionId: 's1',
agentType: 'Explore',
agentId: 'agent-1',
model: 'claude-3',
provider: 'firstParty',
})
expect(subTrace).not.toBeNull()
// Simulate query.ts logic: if langfuseTrace already set, don't create new one
const ownsTrace = false // Would be: !params.toolUseContext.langfuseTrace
const langfuseTrace = subTrace // Would be: params.toolUseContext.langfuseTrace ?? createTrace(...)
expect(ownsTrace).toBe(false)
expect(langfuseTrace).toBe(subTrace)
})
})
describe('SDK exceptions do not affect main flow', () => {
test('createTrace returns null on SDK error', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockImplementationOnce(() => { throw new Error('SDK error') })
const { createTrace } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
expect(span).toBeNull()
})
test('recordLLMObservation silently fails on SDK error', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockImplementationOnce(() => { throw new Error('SDK error') })
const { createTrace, recordLLMObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
// The second call to startObservation (for the generation) will throw
mockStartObservation.mockImplementationOnce(() => { throw new Error('SDK error') })
expect(() => recordLLMObservation(span, {
model: 'm',
provider: 'firstParty',
input: [],
output: [],
usage: { input_tokens: 1, output_tokens: 1 },
})).not.toThrow()
})
})
})

View File

@@ -1,683 +1,40 @@
import { mock, describe, test, expect, beforeEach } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { fileURLToPath } from 'node:url'
// Mock @langfuse/otel before any imports const isolatedPath = fileURLToPath(
const mockForceFlush = mock(() => Promise.resolve()) new URL('./langfuse.isolated.ts', import.meta.url),
const mockShutdown = mock(() => Promise.resolve()) )
mock.module('@langfuse/otel', () => ({
LangfuseSpanProcessor: class MockLangfuseSpanProcessor {
forceFlush = mockForceFlush
shutdown = mockShutdown
onStart = mock(() => {})
onEnd = mock(() => {})
},
}))
// Mock @opentelemetry/sdk-trace-base
mock.module('@opentelemetry/sdk-trace-base', () => ({
BasicTracerProvider: class MockBasicTracerProvider {
constructor(_opts?: unknown) {}
},
}))
// Mock @langfuse/tracing
const mockChildUpdate = mock(() => {})
const mockChildEnd = mock(() => {})
const mockRootUpdate = mock(() => {})
const mockRootEnd = mock(() => {})
// Mock LangfuseOtelSpanAttributes (re-exported from @langfuse/core)
const mockLangfuseOtelSpanAttributes: Record<string, string> = {
TRACE_SESSION_ID: 'session.id',
TRACE_USER_ID: 'user.id',
OBSERVATION_TYPE: 'observation.type',
OBSERVATION_INPUT: 'observation.input',
OBSERVATION_OUTPUT: 'observation.output',
OBSERVATION_MODEL: 'observation.model',
OBSERVATION_COMPLETION_START_TIME: 'observation.completionStartTime',
OBSERVATION_USAGE_DETAILS: 'observation.usageDetails',
}
const mockSpanContext = { traceId: 'test-trace-id', spanId: 'test-span-id', traceFlags: 1 }
const mockSetAttribute = mock(() => {})
// Child observation mock (returned by rootSpan.startObservation for tools)
const mockChildStartObservation = mock(() => ({
id: 'child-id',
update: mockChildUpdate,
end: mockChildEnd,
}))
const mockStartObservation = mock(() => ({
id: 'test-span-id',
traceId: 'test-trace-id',
type: 'span',
otelSpan: {
spanContext: () => mockSpanContext,
setAttribute: mockSetAttribute,
},
update: mockRootUpdate,
end: mockRootEnd,
// Instance method — used by recordToolObservation
startObservation: mockChildStartObservation,
}))
const mockSetLangfuseTracerProvider = mock(() => {})
mock.module('@langfuse/tracing', () => ({
startObservation: mockStartObservation,
LangfuseOtelSpanAttributes: mockLangfuseOtelSpanAttributes,
propagateAttributes: mock((_params: unknown, fn?: () => void) => fn?.()),
setLangfuseTracerProvider: mockSetLangfuseTracerProvider,
}))
// Mock debug logger
mock.module('src/utils/debug.js', () => ({
logForDebugging: mock(() => {}),
}))
// Mock user data — resolveLangfuseUserId uses getCoreUserData().email and .deviceId
mock.module('src/utils/user.js', () => ({
getCoreUserData: mock(() => ({
email: 'test-device-id',
deviceId: 'test-device-id',
})),
}))
describe('Langfuse integration', () => { describe('Langfuse integration', () => {
beforeEach(() => { test('passes in isolated subprocess', async () => {
// Reset env const proc = Bun.spawn({
delete process.env.LANGFUSE_PUBLIC_KEY cmd: [process.execPath, 'test', isolatedPath],
delete process.env.LANGFUSE_SECRET_KEY cwd: process.cwd(),
delete process.env.LANGFUSE_BASE_URL stdout: 'pipe',
mockStartObservation.mockClear() stderr: 'pipe',
mockChildStartObservation.mockClear() env: process.env,
mockChildUpdate.mockClear()
mockChildEnd.mockClear()
mockRootUpdate.mockClear()
mockRootEnd.mockClear()
mockForceFlush.mockClear()
mockShutdown.mockClear()
mockSetAttribute.mockClear()
})
// ── sanitize tests ──────────────────────────────────────────────────────────
describe('sanitizeToolInput', () => {
test('replaces home dir in file_path', async () => {
const { sanitizeToolInput } = await import('../sanitize.js')
const home = process.env.HOME ?? '/Users/testuser'
const result = sanitizeToolInput('FileReadTool', { file_path: `${home}/project/file.ts` }) as Record<string, string>
expect(result.file_path).toBe('~/project/file.ts')
}) })
test('redacts sensitive keys', async () => { const [stdout, stderr, exitCode] = await Promise.all([
const { sanitizeToolInput } = await import('../sanitize.js') new Response(proc.stdout).text(),
const result = sanitizeToolInput('MCPTool', { api_key: 'secret123', token: 'abc' }) as Record<string, string> new Response(proc.stderr).text(),
expect(result.api_key).toBe('[REDACTED]') proc.exited,
expect(result.token).toBe('[REDACTED]') ])
})
test('returns non-object input unchanged', async () => { if (exitCode !== 0) {
const { sanitizeToolInput } = await import('../sanitize.js') throw new Error(
expect(sanitizeToolInput('BashTool', 'raw string')).toBe('raw string') [
expect(sanitizeToolInput('BashTool', null)).toBe(null) `isolated langfuse test failed with exit code ${exitCode}`,
}) '',
}) 'STDOUT:',
stdout,
describe('sanitizeToolOutput', () => { '',
test('redacts FileReadTool output', async () => { 'STDERR:',
const { sanitizeToolOutput } = await import('../sanitize.js') stderr,
const result = sanitizeToolOutput('FileReadTool', 'file content here') ].join('\n'),
expect(result).toBe('[file content redacted, 17 chars]')
})
test('redacts FileWriteTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('FileWriteTool', 'written content')
expect(result).toBe('[file content redacted, 15 chars]')
})
test('truncates BashTool output over 500 chars', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const longOutput = 'x'.repeat(600)
const result = sanitizeToolOutput('BashTool', longOutput)
expect(result).toContain('[truncated]')
expect(result.length).toBeLessThan(600)
})
test('does not truncate BashTool output under 500 chars', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const shortOutput = 'hello world'
expect(sanitizeToolOutput('BashTool', shortOutput)).toBe('hello world')
})
test('redacts ConfigTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('ConfigTool', 'config data')
expect(result).toBe('[ConfigTool output redacted, 11 chars]')
})
test('redacts MCPTool output', async () => {
const { sanitizeToolOutput } = await import('../sanitize.js')
const result = sanitizeToolOutput('MCPTool', 'mcp data')
expect(result).toBe('[MCPTool output redacted, 8 chars]')
})
})
describe('sanitizeGlobal', () => {
test('replaces home dir in strings', async () => {
const { sanitizeGlobal } = await import('../sanitize.js')
const home = process.env.HOME ?? '/Users/testuser'
expect(sanitizeGlobal(`path: ${home}/file`)).toBe('path: ~/file')
})
test('recursively sanitizes nested objects', async () => {
const { sanitizeGlobal } = await import('../sanitize.js')
const result = sanitizeGlobal({ nested: { api_key: 'secret', name: 'test' } }) as Record<string, Record<string, string>>
expect(result.nested.api_key).toBe('[REDACTED]')
expect(result.nested.name).toBe('test')
})
test('returns non-string/object values unchanged', async () => {
const { sanitizeGlobal } = await import('../sanitize.js')
expect(sanitizeGlobal(42)).toBe(42)
expect(sanitizeGlobal(true)).toBe(true)
})
})
// ── client tests ────────────────────────────────────────────────────────────
describe('isLangfuseEnabled', () => {
test('returns false when keys not configured', async () => {
const { isLangfuseEnabled } = await import('../client.js')
expect(isLangfuseEnabled()).toBe(false)
})
test('returns true when both keys are set', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { isLangfuseEnabled } = await import('../client.js')
expect(isLangfuseEnabled()).toBe(true)
})
})
describe('initLangfuse', () => {
test('returns false when keys not configured', async () => {
const { initLangfuse } = await import('../client.js')
expect(initLangfuse()).toBe(false)
})
test('returns true when keys are configured', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
// client.js is a singleton — test via isLangfuseEnabled which reads env directly
const { isLangfuseEnabled } = await import('../client.js')
expect(isLangfuseEnabled()).toBe(true)
})
test('is idempotent — multiple calls do not re-initialize', async () => {
// client.js singleton: once processor is set, initLangfuse returns true immediately
// We verify this by checking that calling it multiple times doesn't throw
const { initLangfuse } = await import('../client.js')
expect(() => { initLangfuse(); initLangfuse() }).not.toThrow()
})
})
describe('shutdownLangfuse', () => {
test('calls forceFlush and shutdown on processor', async () => {
// Verify shutdown is callable without error even when no processor is set
const { shutdownLangfuse } = await import('../client.js')
await expect(shutdownLangfuse()).resolves.toBeUndefined()
})
})
// ── tracing tests ───────────────────────────────────────────────────────────
describe('createTrace', () => {
test('returns null when langfuse not enabled', async () => {
const { createTrace } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
expect(span).toBeNull()
})
test('creates root span when enabled', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty', input: [] })
expect(span).not.toBeNull()
expect(mockStartObservation).toHaveBeenCalledWith('agent-run', expect.objectContaining({
metadata: expect.objectContaining({ provider: 'firstParty', model: 'claude-3' }),
}), { asType: 'agent' })
})
})
describe('recordLLMObservation', () => {
test('no-ops when rootSpan is null', async () => {
const { recordLLMObservation } = await import('../tracing.js')
recordLLMObservation(null, { model: 'm', provider: 'firstParty', input: [], output: [], usage: { input_tokens: 10, output_tokens: 5 } })
expect(mockStartObservation).toHaveBeenCalledTimes(0)
})
test('records generation child observation via global startObservation', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordLLMObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockStartObservation.mockClear()
recordLLMObservation(span, {
model: 'claude-3',
provider: 'firstParty',
input: [{ role: 'user', content: 'hello' }],
output: [{ role: 'assistant', content: 'hi' }],
usage: { input_tokens: 10, output_tokens: 5 },
})
// Should call the global startObservation with asType: 'generation' and parentSpanContext
expect(mockStartObservation).toHaveBeenCalledWith('ChatAnthropic', expect.objectContaining({
model: 'claude-3',
}), expect.objectContaining({
asType: 'generation',
parentSpanContext: mockSpanContext,
}))
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({
usageDetails: { input: 10, output: 5 },
}))
expect(mockRootEnd).toHaveBeenCalled()
})
test('includes cache tokens in usageDetails when provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordLLMObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockStartObservation.mockClear()
mockRootUpdate.mockClear()
recordLLMObservation(span, {
model: 'claude-3',
provider: 'firstParty',
input: [],
output: [],
usage: { input_tokens: 10000, output_tokens: 50, cache_creation_input_tokens: 2000, cache_read_input_tokens: 7000 },
})
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({
usageDetails: {
input: 19000, // 10000 + 2000 + 7000
output: 50,
cache_read: 7000,
cache_creation: 2000,
},
}))
})
test('omits cache fields when not provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordLLMObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockRootUpdate.mockClear()
recordLLMObservation(span, {
model: 'claude-3',
provider: 'firstParty',
input: [],
output: [],
usage: { input_tokens: 100, output_tokens: 20 },
})
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({
usageDetails: { input: 100, output: 20 },
}))
})
})
describe('recordToolObservation', () => {
test('no-ops when rootSpan is null', async () => {
const { recordToolObservation } = await import('../tracing.js')
recordToolObservation(null, { toolName: 'BashTool', toolUseId: 'id1', input: {}, output: 'out' })
// startObservation should not be called beyond the initial trace creation (none here)
})
test('records tool child observation via global startObservation', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockStartObservation.mockClear()
mockRootUpdate.mockClear()
mockRootEnd.mockClear()
recordToolObservation(span, {
toolName: 'BashTool',
toolUseId: 'tu-1',
input: { command: 'ls' },
output: 'file.ts',
})
// Should call the global startObservation with asType: 'tool' and parentSpanContext
expect(mockStartObservation).toHaveBeenCalledWith('BashTool', expect.objectContaining({
input: expect.any(Object),
}), expect.objectContaining({
asType: 'tool',
parentSpanContext: mockSpanContext,
}))
expect(mockRootUpdate).toHaveBeenCalled()
expect(mockRootEnd).toHaveBeenCalled()
})
test('passes startTime to global startObservation', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockStartObservation.mockClear()
const startTime = new Date('2026-01-01T00:00:00Z')
recordToolObservation(span, {
toolName: 'BashTool',
toolUseId: 'tu-2',
input: {},
output: 'out',
startTime,
})
expect(mockStartObservation).toHaveBeenCalledWith('BashTool', expect.any(Object), expect.objectContaining({
startTime,
parentSpanContext: mockSpanContext,
}))
})
test('sanitizes FileReadTool output', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockRootUpdate.mockClear()
recordToolObservation(span, {
toolName: 'FileReadTool',
toolUseId: 'tu-2',
input: { file_path: '/tmp/file.ts' },
output: 'file content here',
})
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({
output: '[file content redacted, 17 chars]',
}))
})
test('sets ERROR level for error observations', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, recordToolObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
mockRootUpdate.mockClear()
recordToolObservation(span, {
toolName: 'BashTool',
toolUseId: 'tu-3',
input: {},
output: 'error occurred',
isError: true,
})
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({ level: 'ERROR' }))
})
})
describe('endTrace', () => {
test('no-ops when rootSpan is null', async () => {
const { endTrace } = await import('../tracing.js')
endTrace(null)
expect(mockRootEnd).not.toHaveBeenCalled()
})
test('calls span.end()', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, endTrace } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
endTrace(span)
expect(mockRootEnd).toHaveBeenCalled()
})
test('calls span.update() with output when provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, endTrace } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
endTrace(span, 'final output')
expect(mockRootUpdate).toHaveBeenCalledWith({ output: 'final output' })
expect(mockRootEnd).toHaveBeenCalled()
})
})
describe('createSubagentTrace', () => {
test('returns null when langfuse not enabled', async () => {
const { createSubagentTrace } = await import('../tracing.js')
const span = createSubagentTrace({
sessionId: 's1',
agentType: 'Explore',
agentId: 'agent-1',
model: 'claude-3',
provider: 'firstParty',
})
expect(span).toBeNull()
})
test('creates trace with agentType and agentId metadata', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createSubagentTrace } = await import('../tracing.js')
const span = createSubagentTrace({
sessionId: 's1',
agentType: 'Explore',
agentId: 'agent-1',
model: 'claude-3',
provider: 'firstParty',
input: [{ role: 'user', content: 'search for X' }],
})
expect(span).not.toBeNull()
expect(mockStartObservation).toHaveBeenCalledWith('agent:Explore', expect.objectContaining({
metadata: expect.objectContaining({
agentType: 'Explore',
agentId: 'agent-1',
provider: 'firstParty',
model: 'claude-3',
}),
}), { asType: 'agent' })
// Verify session.id attribute is set
expect(mockSetAttribute).toHaveBeenCalledWith('session.id', 's1')
})
test('returns null on SDK error', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockImplementationOnce(() => { throw new Error('SDK error') })
const { createSubagentTrace } = await import('../tracing.js')
const span = createSubagentTrace({
sessionId: 's1',
agentType: 'Plan',
agentId: 'agent-2',
model: 'claude-3',
provider: 'firstParty',
})
expect(span).toBeNull()
})
})
describe('createTrace with querySource', () => {
test('includes querySource in metadata', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
querySource: 'user',
})
expect(span).not.toBeNull()
expect(mockStartObservation).toHaveBeenCalledWith('agent-run:user', expect.objectContaining({
metadata: expect.objectContaining({
agentType: 'main',
querySource: 'user',
}),
}), { asType: 'agent' })
})
test('omits querySource when not provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockClear()
const { createTrace } = await import('../tracing.js')
createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
const calls = mockStartObservation.mock.calls as unknown[][]
const secondArg = calls[0]?.[1] as Record<string, unknown> | undefined
const metadata = (secondArg?.metadata ?? {}) as Record<string, unknown>
expect(metadata).not.toHaveProperty('querySource')
})
})
describe('createTrace with username', () => {
test('sets user.id attribute when username is provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockSetAttribute.mockClear()
const { createTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
username: 'user@example.com',
})
expect(span).not.toBeNull()
expect(mockSetAttribute).toHaveBeenCalledWith('user.id', 'user@example.com')
})
test('falls back to LANGFUSE_USER_ID env when username not provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
process.env.LANGFUSE_USER_ID = 'env-user@test.com'
mockSetAttribute.mockClear()
const { createTrace } = await import('../tracing.js')
const span = createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
})
expect(span).not.toBeNull()
expect(mockSetAttribute).toHaveBeenCalledWith('user.id', 'env-user@test.com')
delete process.env.LANGFUSE_USER_ID
})
test('falls back to deviceId when neither username nor env is provided', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
delete process.env.LANGFUSE_USER_ID
mockSetAttribute.mockClear()
const { createTrace } = await import('../tracing.js')
createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
// Falls back to getCoreUserData().deviceId (mocked as 'test-device-id')
expect(mockSetAttribute).toHaveBeenCalledWith('user.id', 'test-device-id')
})
test('username takes precedence over LANGFUSE_USER_ID env', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
process.env.LANGFUSE_USER_ID = 'env-user@test.com'
mockSetAttribute.mockClear()
const { createTrace } = await import('../tracing.js')
createTrace({
sessionId: 's1',
model: 'claude-3',
provider: 'firstParty',
username: 'param-user@test.com',
})
const userIdCalls = mockSetAttribute.mock.calls.filter(
(call: unknown[]) => Array.isArray(call) && call[0] === 'user.id',
) )
expect(userIdCalls.length).toBe(1) }
expect((userIdCalls[0] as unknown[])[1]).toBe('param-user@test.com')
delete process.env.LANGFUSE_USER_ID
})
})
describe('nested agent scenario', () => { expect(exitCode).toBe(0)
test('sub-agent trace shares sessionId with parent', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createTrace, createSubagentTrace } = await import('../tracing.js')
mockSetAttribute.mockClear()
// Create parent trace
const parentSpan = createTrace({
sessionId: 'shared-session',
model: 'claude-3',
provider: 'firstParty',
})
// Create sub-agent trace with same sessionId
const subSpan = createSubagentTrace({
sessionId: 'shared-session',
agentType: 'Explore',
agentId: 'agent-explore-1',
model: 'claude-3',
provider: 'firstParty',
})
expect(parentSpan).not.toBeNull()
expect(subSpan).not.toBeNull()
// Both should have set session.id attribute
const sessionAttributeCalls = mockSetAttribute.mock.calls.filter(
(call: unknown[]) => Array.isArray(call) && call[0] === 'session.id' && call[1] === 'shared-session',
)
expect(sessionAttributeCalls.length).toBeGreaterThanOrEqual(2)
})
test('query reuses passed langfuseTrace instead of creating new one', async () => {
// This validates the pattern used in query.ts:
// const ownsTrace = !params.toolUseContext.langfuseTrace
// const langfuseTrace = params.toolUseContext.langfuseTrace ?? createTrace(...)
// When langfuseTrace is already set, createTrace should NOT be called
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
const { createSubagentTrace } = await import('../tracing.js')
// Simulate what runAgent does: create subTrace, then pass it as langfuseTrace
const subTrace = createSubagentTrace({
sessionId: 's1',
agentType: 'Explore',
agentId: 'agent-1',
model: 'claude-3',
provider: 'firstParty',
})
expect(subTrace).not.toBeNull()
// Simulate query.ts logic: if langfuseTrace already set, don't create new one
const ownsTrace = false // Would be: !params.toolUseContext.langfuseTrace
const langfuseTrace = subTrace // Would be: params.toolUseContext.langfuseTrace ?? createTrace(...)
expect(ownsTrace).toBe(false)
expect(langfuseTrace).toBe(subTrace)
})
})
describe('SDK exceptions do not affect main flow', () => {
test('createTrace returns null on SDK error', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockImplementationOnce(() => { throw new Error('SDK error') })
const { createTrace } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
expect(span).toBeNull()
})
test('recordLLMObservation silently fails on SDK error', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
mockStartObservation.mockImplementationOnce(() => { throw new Error('SDK error') })
const { createTrace, recordLLMObservation } = await import('../tracing.js')
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
// The second call to startObservation (for the generation) will throw
mockStartObservation.mockImplementationOnce(() => { throw new Error('SDK error') })
expect(() => recordLLMObservation(span, {
model: 'm',
provider: 'firstParty',
input: [],
output: [],
usage: { input_tokens: 1, output_tokens: 1 },
})).not.toThrow()
})
}) })
}) })

View File

@@ -82,8 +82,13 @@ export function appendTeammateMessage(
export function injectUserMessageToTeammate( export function injectUserMessageToTeammate(
taskId: string, taskId: string,
message: string, message: string,
options: {
autonomyRunId?: string
origin?: Message['origin']
} | undefined,
setAppState: SetAppState, setAppState: SetAppState,
): void { ): boolean {
let injected = false
updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => { updateTaskState<InProcessTeammateTaskState>(taskId, setAppState, task => {
// Allow message injection when teammate is running or idle (waiting for input) // Allow message injection when teammate is running or idle (waiting for input)
// Only reject if teammate is in a terminal state // Only reject if teammate is in a terminal state
@@ -94,15 +99,29 @@ export function injectUserMessageToTeammate(
return task return task
} }
injected = true
return { return {
...task, ...task,
pendingUserMessages: [...task.pendingUserMessages, message], pendingUserMessages: [
...task.pendingUserMessages,
{
message,
...(options?.autonomyRunId
? { autonomyRunId: options.autonomyRunId }
: {}),
...(options?.origin ? { origin: options.origin } : {}),
},
],
messages: appendCappedMessage( messages: appendCappedMessage(
task.messages, task.messages,
createUserMessage({ content: message }), createUserMessage({
content: message,
...(options?.origin ? { origin: options.origin } : {}),
}),
), ),
} }
}) })
return injected
} }
/** /**

View File

@@ -1,7 +1,7 @@
import type { TaskStateBase } from '../../Task.js' import type { TaskStateBase } from '../../Task.js'
import type { AgentToolResult } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js' import type { AgentToolResult } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js'
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
import type { Message } from '../../types/message.js' import type { Message, MessageOrigin } from '../../types/message.js'
import type { PermissionMode } from '../../utils/permissions/PermissionMode.js' import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'
import type { AgentProgress } from '../LocalAgentTask/LocalAgentTask.js' import type { AgentProgress } from '../LocalAgentTask/LocalAgentTask.js'
@@ -19,6 +19,12 @@ export type TeammateIdentity = {
parentSessionId: string // Leader's session ID parentSessionId: string // Leader's session ID
} }
export type PendingTeammateUserMessage = {
message: string
autonomyRunId?: string
origin?: MessageOrigin
}
export type InProcessTeammateTaskState = TaskStateBase & { export type InProcessTeammateTaskState = TaskStateBase & {
type: 'in_process_teammate' type: 'in_process_teammate'
@@ -56,7 +62,7 @@ export type InProcessTeammateTaskState = TaskStateBase & {
inProgressToolUseIDs?: Set<string> inProgressToolUseIDs?: Set<string>
// Queue of user messages to deliver when viewing teammate transcript // Queue of user messages to deliver when viewing teammate transcript
pendingUserMessages: string[] pendingUserMessages: PendingTeammateUserMessage[]
// UI: random spinner verbs (stable across re-renders, shared between components) // UI: random spinner verbs (stable across re-renders, shared between components)
spinnerVerb?: string spinnerVerb?: string

View File

@@ -355,6 +355,19 @@ export type QueuedCommand = {
* unified the queue but lost the isolation the dual-queue accidentally had). * unified the queue but lost the isolation the dual-queue accidentally had).
*/ */
agentId?: AgentId agentId?: AgentId
/**
* Autonomy-run provenance for system-generated automatic turns.
* Used by the autonomy ledger to track queue → execution lifecycle.
*/
autonomy?: {
runId: string
trigger: 'scheduled-task' | 'proactive-tick' | 'managed-flow-step'
sourceId?: string
sourceLabel?: string
flowId?: string
flowStepId?: string
flowStepName?: string
}
} }
/** /**

View File

@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { join } from 'node:path'
import {
AUTONOMY_AGENTS_PATH_POSIX,
AUTONOMY_DIR,
buildAutonomyTurnPrompt,
loadAutonomyAuthority,
resetAutonomyAuthorityForTests,
} from '../autonomyAuthority'
import {
cleanupTempDir,
createTempDir,
createTempSubdir,
writeTempFile,
} from '../../../tests/mocks/file-system'
const AGENTS_REL = join(AUTONOMY_DIR, 'AGENTS.md')
const HEARTBEAT_REL = join(AUTONOMY_DIR, 'HEARTBEAT.md')
let tempDir = ''
beforeEach(async () => {
tempDir = await createTempDir('autonomy-authority-')
})
afterEach(async () => {
resetAutonomyAuthorityForTests()
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('autonomyAuthority', () => {
test('loadAutonomyAuthority merges AGENTS.md files from root to current directory', async () => {
const nestedDir = await createTempSubdir(tempDir, 'packages/app')
await writeTempFile(tempDir, AGENTS_REL, 'root authority')
await writeTempFile(nestedDir, AGENTS_REL, 'nested authority')
await writeTempFile(
tempDir,
HEARTBEAT_REL,
[
'# Heartbeat',
'tasks:',
' - name: inbox',
' interval: 30m',
' prompt: "Check inbox"',
].join('\n'),
)
const snapshot = await loadAutonomyAuthority({
rootDir: tempDir,
currentDir: nestedDir,
})
expect(snapshot.agentsFiles.map(file => file.relativePath)).toEqual([
AUTONOMY_AGENTS_PATH_POSIX,
`packages/app/${AUTONOMY_AGENTS_PATH_POSIX}`,
])
expect(snapshot.agentsContent).toContain('root authority')
expect(snapshot.agentsContent).toContain('nested authority')
expect(snapshot.heartbeatContent).toContain('# Heartbeat')
expect(snapshot.heartbeatTasks).toEqual([
{
name: 'inbox',
interval: '30m',
prompt: 'Check inbox',
steps: [],
},
])
})
test('loadAutonomyAuthority reads HEARTBEAT.md only from the workspace root', async () => {
const nestedDir = await createTempSubdir(tempDir, 'child')
await writeTempFile(
tempDir,
HEARTBEAT_REL,
'# Root heartbeat\nRemember the root task',
)
await writeTempFile(
nestedDir,
HEARTBEAT_REL,
'# Nested heartbeat\nThis should not be used',
)
const snapshot = await loadAutonomyAuthority({
rootDir: tempDir,
currentDir: nestedDir,
})
expect(snapshot.heartbeatFile?.path).toBe(join(tempDir, HEARTBEAT_REL))
expect(snapshot.heartbeatContent).toContain('Root heartbeat')
expect(snapshot.heartbeatContent).not.toContain('Nested heartbeat')
})
test('buildAutonomyTurnPrompt returns the original prompt when no authority files exist', async () => {
const prompt = await buildAutonomyTurnPrompt({
basePrompt: 'Run the scheduled task.',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
})
expect(prompt).toBe('Run the scheduled task.')
})
test('buildAutonomyTurnPrompt injects AGENTS.md and HEARTBEAT.md for automated turns', async () => {
const nestedDir = await createTempSubdir(tempDir, 'nested')
await writeTempFile(tempDir, AGENTS_REL, 'root rules')
await writeTempFile(nestedDir, AGENTS_REL, 'nested rules')
await writeTempFile(tempDir, HEARTBEAT_REL, 'Check heartbeat directives')
const scheduledPrompt = await buildAutonomyTurnPrompt({
basePrompt: 'Review the nightly report.',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: nestedDir,
})
const tickPrompt = await buildAutonomyTurnPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: nestedDir,
})
expect(scheduledPrompt).toContain(
'This prompt was generated automatically. Follow the workspace authority below before acting.',
)
expect(scheduledPrompt).toContain('<autonomy_authority>')
expect(scheduledPrompt).toContain('root rules')
expect(scheduledPrompt).toContain('nested rules')
expect(scheduledPrompt).toContain('Check heartbeat directives')
expect(scheduledPrompt).toContain('Review the nightly report.')
expect(tickPrompt).toContain(
'This is an autonomous proactive turn. Follow the workspace authority below before acting.',
)
expect(tickPrompt).toContain('<tick>12:00:00</tick>')
})
test('proactive prompts surface due HEARTBEAT.md tasks only when their interval elapses', async () => {
await writeTempFile(
tempDir,
HEARTBEAT_REL,
[
'tasks:',
' - name: inbox',
' interval: 30m',
' prompt: "Check inbox"',
].join('\n'),
)
const first = await buildAutonomyTurnPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
nowMs: 0,
})
const second = await buildAutonomyTurnPrompt({
basePrompt: '<tick>12:10:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
nowMs: 10 * 60_000,
})
const third = await buildAutonomyTurnPrompt({
basePrompt: '<tick>12:31:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
nowMs: 31 * 60_000,
})
expect(first).toContain('Due HEARTBEAT.md tasks:')
expect(first).toContain('- inbox (30m): Check inbox')
expect(second).not.toContain('Due HEARTBEAT.md tasks:')
expect(third).toContain('Due HEARTBEAT.md tasks:')
})
test('managed HEARTBEAT.md tasks parse nested steps and are not duplicated into the inline due-task section', async () => {
await writeTempFile(
tempDir,
HEARTBEAT_REL,
[
'tasks:',
' - name: inbox',
' interval: 30m',
' prompt: "Check inbox"',
' - name: weekly-report',
' interval: 7d',
' prompt: "Ship the weekly report"',
' steps:',
' - name: gather',
' prompt: "Gather weekly inputs"',
' - name: draft',
' prompt: "Draft the weekly report"',
' wait_for: manual',
].join('\n'),
)
const snapshot = await loadAutonomyAuthority({
rootDir: tempDir,
currentDir: tempDir,
})
const prompt = await buildAutonomyTurnPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
nowMs: 0,
})
expect(snapshot.heartbeatTasks).toEqual([
{
name: 'inbox',
interval: '30m',
prompt: 'Check inbox',
steps: [],
},
{
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
},
{
name: 'draft',
prompt: 'Draft the weekly report',
waitFor: 'manual',
},
],
},
])
expect(prompt).toContain('- inbox (30m): Check inbox')
expect(prompt).not.toContain('- weekly-report (7d): Ship the weekly report')
expect(prompt).not.toContain('- gather (')
})
})

View File

@@ -0,0 +1,411 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdir, writeFile } from 'fs/promises'
import { join } from 'path'
import {
resetStateForTests,
setCwdState,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state'
import {
formatAutonomyRunsList,
formatAutonomyRunsStatus,
listAutonomyRuns,
createAutonomyQueuedPrompt,
createProactiveAutonomyCommands,
finalizeAutonomyRunCompleted,
markAutonomyRunCompleted,
markAutonomyRunFailed,
markAutonomyRunRunning,
recoverManagedAutonomyFlowPrompt,
resolveAutonomyRunsPath,
startManagedAutonomyFlowFromHeartbeatTask,
} from '../autonomyRuns'
import {
formatAutonomyFlowsList,
getAutonomyFlowById,
listAutonomyFlows,
} from '../autonomyFlows'
import {
AUTONOMY_DIR,
resetAutonomyAuthorityForTests,
} from '../autonomyAuthority'
import { resetCommandQueue } from '../messageQueueManager'
import {
cleanupTempDir,
createTempDir,
createTempSubdir,
writeTempFile,
} from '../../../tests/mocks/file-system'
const AGENTS_REL = join(AUTONOMY_DIR, 'AGENTS.md')
const HEARTBEAT_REL = join(AUTONOMY_DIR, 'HEARTBEAT.md')
let tempDir = ''
beforeEach(async () => {
tempDir = await createTempDir('autonomy-runs-')
resetStateForTests()
resetAutonomyAuthorityForTests()
resetCommandQueue()
setOriginalCwd(tempDir)
setProjectRoot(tempDir)
})
afterEach(async () => {
resetStateForTests()
resetAutonomyAuthorityForTests()
resetCommandQueue()
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('autonomyRuns', () => {
test('createAutonomyQueuedPrompt records a queued automatic run and returns a prompt command', async () => {
const currentDir = await createTempSubdir(tempDir, 'nested')
await writeTempFile(tempDir, AGENTS_REL, 'root authority')
const command = await createAutonomyQueuedPrompt({
basePrompt: 'Review nightly report',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir,
sourceId: 'cron-1',
sourceLabel: 'nightly-report',
workload: 'cron',
})
const runs = await listAutonomyRuns(tempDir)
const flows = await listAutonomyFlows(tempDir)
expect(command.mode).toBe('prompt')
expect(command.isMeta).toBe(true)
expect(command.autonomy?.trigger).toBe('scheduled-task')
expect(command.autonomy?.sourceId).toBe('cron-1')
expect(command.origin).toBeDefined()
expect(command.value).toContain('root authority')
expect(runs).toHaveLength(1)
expect(runs[0]).toMatchObject({
runId: command.autonomy?.runId,
runtime: 'automatic',
trigger: 'scheduled-task',
status: 'queued',
ownerKey: 'main-thread',
sourceId: 'cron-1',
sourceLabel: 'nightly-report',
})
expect(flows).toHaveLength(0)
expect(resolveAutonomyRunsPath(tempDir)).toContain('.claude')
})
test('createAutonomyQueuedPrompt defaults currentDir to the active cwd for nested authority', async () => {
const nestedDir = await createTempSubdir(tempDir, 'nested')
await writeTempFile(tempDir, AGENTS_REL, 'root authority')
await writeTempFile(nestedDir, AGENTS_REL, 'nested authority')
setOriginalCwd(nestedDir)
setCwdState(nestedDir)
const command = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
})
expect(command.value).toContain('root authority')
expect(command.value).toContain('nested authority')
})
test('markAutonomyRunRunning/completed/failed update persisted lifecycle state for plain runs', async () => {
const command = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
})
const runId = command.autonomy!.runId
await markAutonomyRunRunning(runId, tempDir, 100)
let runs = await listAutonomyRuns(tempDir)
expect(runs[0]).toMatchObject({
runId,
status: 'running',
startedAt: 100,
})
await markAutonomyRunCompleted(runId, tempDir, 200)
runs = await listAutonomyRuns(tempDir)
expect(runs[0]).toMatchObject({
runId,
status: 'completed',
endedAt: 200,
})
await markAutonomyRunFailed(runId, 'boom', tempDir, 300)
runs = await listAutonomyRuns(tempDir)
expect(runs[0]).toMatchObject({
runId,
status: 'failed',
endedAt: 300,
error: 'boom',
})
})
test('formatters produce readable status and run listings', async () => {
const first = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
sourceLabel: 'nightly',
})
const second = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
})
await markAutonomyRunRunning(first.autonomy!.runId, tempDir, 100)
await markAutonomyRunCompleted(first.autonomy!.runId, tempDir, 200)
await markAutonomyRunFailed(second.autonomy!.runId, 'stopped', tempDir, 300)
const runs = await listAutonomyRuns(tempDir)
const status = formatAutonomyRunsStatus(runs)
const list = formatAutonomyRunsList(runs, 5)
const flows = await listAutonomyFlows(tempDir)
const flowList = formatAutonomyFlowsList(flows, 5)
expect(status).toContain('Autonomy runs: 2')
expect(status).toContain('Completed: 1')
expect(status).toContain('Failed: 1')
expect(list).toContain(first.autonomy!.runId)
expect(list).toContain(second.autonomy!.runId)
expect(list).toContain('nightly')
expect(list).toContain('stopped')
expect(flowList).toBe('No autonomy flows recorded.')
})
test('same-process concurrent run creation does not lose updates', async () => {
await Promise.all([
createAutonomyQueuedPrompt({
basePrompt: 'scheduled one',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
}),
createAutonomyQueuedPrompt({
basePrompt: 'scheduled two',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-2',
}),
])
const runs = await listAutonomyRuns(tempDir)
expect(runs).toHaveLength(2)
expect(new Set(runs.map(run => run.sourceId))).toEqual(
new Set(['cron-1', 'cron-2']),
)
})
test('listAutonomyRuns keeps older persisted records by normalizing missing runtime and owner metadata', async () => {
const runsPath = resolveAutonomyRunsPath(tempDir)
await mkdir(join(tempDir, '.claude', 'autonomy'), { recursive: true })
await writeFile(
runsPath,
`${JSON.stringify(
{
runs: [
{
runId: 'legacy-run',
trigger: 'scheduled-task',
status: 'completed',
rootDir: tempDir,
promptPreview: 'legacy prompt',
createdAt: 123,
},
],
},
null,
2,
)}\n`,
'utf-8',
)
const [legacy] = await listAutonomyRuns(tempDir)
expect(legacy).toMatchObject({
runId: 'legacy-run',
runtime: 'automatic',
ownerKey: 'main-thread',
currentDir: tempDir,
status: 'completed',
})
})
test('createAutonomyQueuedPrompt does not consume heartbeat tasks or create runs when shouldCreate rejects commit', async () => {
await writeTempFile(
tempDir,
HEARTBEAT_REL,
[
'tasks:',
' - name: inbox',
' interval: 30m',
' prompt: "Check inbox"',
].join('\n'),
)
const skipped = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
shouldCreate: () => false,
})
const committed = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:01:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
})
const runs = await listAutonomyRuns(tempDir)
expect(skipped).toBeNull()
expect(committed).not.toBeNull()
expect(committed!.value).toContain('Due HEARTBEAT.md tasks:')
expect(runs).toHaveLength(1)
})
test('createProactiveAutonomyCommands queues one managed flow step command per due HEARTBEAT flow', async () => {
await writeTempFile(
tempDir,
HEARTBEAT_REL,
[
'tasks:',
' - name: inbox',
' interval: 30m',
' prompt: "Check inbox"',
' - name: weekly-report',
' interval: 7d',
' prompt: "Ship the weekly report"',
' steps:',
' - name: gather',
' prompt: "Gather weekly inputs"',
' - name: draft',
' prompt: "Draft the weekly report"',
].join('\n'),
)
const commands = await createProactiveAutonomyCommands({
basePrompt: '<tick>12:00:00</tick>',
rootDir: tempDir,
currentDir: tempDir,
})
const runs = await listAutonomyRuns(tempDir)
const flows = await listAutonomyFlows(tempDir)
expect(commands).toHaveLength(2)
expect(commands[0]!.autonomy?.trigger).toBe('proactive-tick')
expect(commands[0]!.value).toContain('- inbox (30m): Check inbox')
expect(commands[1]!.autonomy?.trigger).toBe('managed-flow-step')
expect(commands[1]!.value).toContain(
'This is step 1/2 of the managed autonomy flow',
)
expect(runs).toHaveLength(2)
expect(flows).toHaveLength(1)
expect(flows[0]).toMatchObject({
status: 'queued',
currentStep: 'gather',
goal: 'Ship the weekly report',
})
})
test('finalizeAutonomyRunCompleted advances managed flows to the next queued step', async () => {
const command = 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(command).not.toBeNull()
await markAutonomyRunRunning(command!.autonomy!.runId, tempDir, 100)
const nextCommands = await finalizeAutonomyRunCompleted({
runId: command!.autonomy!.runId,
rootDir: tempDir,
currentDir: tempDir,
})
const runs = await listAutonomyRuns(tempDir)
const [flow] = await listAutonomyFlows(tempDir)
const detail = await getAutonomyFlowById(flow!.flowId, tempDir)
expect(nextCommands).toHaveLength(1)
expect(nextCommands[0]!.autonomy?.trigger).toBe('managed-flow-step')
expect(nextCommands[0]!.value).toContain('Current step: draft')
expect(runs).toHaveLength(2)
expect(flow).toMatchObject({
status: 'queued',
currentStep: 'draft',
runCount: 2,
})
expect(detail?.stateJson?.steps.map(step => step.status)).toEqual([
'completed',
'queued',
])
})
test('recoverManagedAutonomyFlowPrompt rehydrates a queued managed step with the same run id', async () => {
const command = 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 recovered = await recoverManagedAutonomyFlowPrompt({
flowId: flow!.flowId,
rootDir: tempDir,
currentDir: tempDir,
})
expect(recovered).not.toBeNull()
expect(recovered!.autonomy?.runId).toBe(command!.autonomy?.runId)
expect(recovered!.autonomy?.flowId).toBe(flow!.flowId)
})
})

View File

@@ -0,0 +1,79 @@
import { describe, expect, test } from 'bun:test'
import {
buildMissedTaskNotification,
isRecurringTaskAged,
} from '../cronScheduler'
describe('cronScheduler baseline helpers', () => {
test('isRecurringTaskAged returns false when maxAgeMs is zero', () => {
expect(
isRecurringTaskAged(
{ id: 'a', cron: '* * * * *', prompt: 'x', createdAt: 0, recurring: true },
10_000,
0,
),
).toBe(false)
})
test('isRecurringTaskAged only ages recurring non-permanent tasks', () => {
expect(
isRecurringTaskAged(
{ id: 'a', cron: '* * * * *', prompt: 'x', createdAt: 0 },
10_000,
100,
),
).toBe(false)
expect(
isRecurringTaskAged(
{
id: 'b',
cron: '* * * * *',
prompt: 'x',
createdAt: 0,
recurring: true,
permanent: true,
},
10_000,
100,
),
).toBe(false)
expect(
isRecurringTaskAged(
{ id: 'c', cron: '* * * * *', prompt: 'x', createdAt: 0, recurring: true },
10_000,
100,
),
).toBe(true)
})
test('buildMissedTaskNotification preserves AskUserQuestion safety instruction', () => {
const msg = buildMissedTaskNotification([
{
id: 'a1b2c3d4',
cron: '* * * * *',
prompt: 'check deployment',
createdAt: new Date('2026-04-12T10:00:00Z').getTime(),
},
])
expect(msg).toContain('AskUserQuestion')
expect(msg).toContain('Do NOT execute this prompt yet')
expect(msg).toContain('check deployment')
})
test('buildMissedTaskNotification widens the code fence when the prompt contains backticks', () => {
const msg = buildMissedTaskNotification([
{
id: 'z9y8x7w6',
cron: '* * * * *',
prompt: 'run ```dangerous``` only if approved',
createdAt: new Date('2026-04-12T10:00:00Z').getTime(),
},
])
expect(msg).toContain('````')
expect(msg).toContain('run ```dangerous``` only if approved')
})
})

View File

@@ -0,0 +1,203 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
getSessionCronTasks,
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state'
import {
addCronTask,
findMissedTasks,
getCronFilePath,
hasCronTasksSync,
listAllCronTasks,
markCronTasksFired,
nextCronRunMs,
oneShotJitteredNextCronRunMs,
readCronTasks,
removeCronTasks,
writeCronTasks,
} from '../cronTasks'
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
let tempDir = ''
beforeEach(async () => {
tempDir = await createTempDir('cron-baseline-')
resetStateForTests()
setOriginalCwd(tempDir)
setProjectRoot(tempDir)
})
afterEach(async () => {
resetStateForTests()
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('cronTasks baseline', () => {
test('session-only cron tasks remain in memory and do not create the cron file', async () => {
const id = await addCronTask('* * * * *', 'session-only prompt', true, false)
const tasks = await listAllCronTasks()
expect(id).toHaveLength(8)
expect(getSessionCronTasks()).toHaveLength(1)
expect(tasks).toHaveLength(1)
expect(tasks[0]).toMatchObject({
id,
prompt: 'session-only prompt',
durable: false,
recurring: true,
})
expect(existsSync(getCronFilePath())).toBe(false)
})
test('durable cron tasks are written to .claude/scheduled_tasks.json', async () => {
const id = await addCronTask('* * * * *', 'durable prompt', true, true)
const filePath = getCronFilePath()
const fileTasks = await readCronTasks()
expect(existsSync(filePath)).toBe(true)
expect(filePath).toBe(join(tempDir, '.claude', 'scheduled_tasks.json'))
expect(fileTasks).toHaveLength(1)
expect(fileTasks[0]).toMatchObject({
id,
prompt: 'durable prompt',
recurring: true,
})
expect(fileTasks[0].durable).toBeUndefined()
})
test('writeCronTasks strips runtime-only durable flags from disk', async () => {
await writeCronTasks([
{
id: 'abc12345',
cron: '* * * * *',
prompt: 'strip durable',
createdAt: 123,
recurring: true,
durable: false,
},
])
const raw = await readFile(getCronFilePath(), 'utf-8')
expect(raw).not.toContain('"durable"')
})
test('hasCronTasksSync reflects whether the durable cron file has entries', async () => {
expect(hasCronTasksSync()).toBe(false)
await writeCronTasks([
{
id: 'sync0001',
cron: '* * * * *',
prompt: 'present',
createdAt: 1,
},
])
expect(hasCronTasksSync()).toBe(true)
})
test('daemon-style listAllCronTasks(dir) excludes session-only tasks', async () => {
await addCronTask('* * * * *', 'session prompt', true, false)
const durableId = await addCronTask('* * * * *', 'durable prompt', true, true)
const sessionView = await listAllCronTasks()
const daemonView = await listAllCronTasks(tempDir)
expect(sessionView).toHaveLength(2)
expect(daemonView).toHaveLength(1)
expect(daemonView[0]).toMatchObject({
id: durableId,
prompt: 'durable prompt',
})
})
test('removeCronTasks without dir removes session-only tasks from memory', async () => {
const sessionId = await addCronTask('* * * * *', 'remove me', true, false)
await removeCronTasks([sessionId])
expect(getSessionCronTasks()).toHaveLength(0)
expect(await listAllCronTasks()).toHaveLength(0)
})
test('removeCronTasks with dir does not mutate session-only task storage', async () => {
const sessionId = await addCronTask('* * * * *', 'keep session task', true, false)
await addCronTask('* * * * *', 'durable prompt', true, true)
await removeCronTasks([sessionId], tempDir)
expect(getSessionCronTasks()).toHaveLength(1)
expect(getSessionCronTasks()[0]?.id).toBe(sessionId)
})
test('markCronTasksFired persists lastFiredAt for durable tasks', async () => {
await writeCronTasks([
{
id: 'fire0001',
cron: '* * * * *',
prompt: 'persist fired',
createdAt: 100,
recurring: true,
},
])
await markCronTasksFired(['fire0001'], 123456789)
const tasks = await readCronTasks()
expect(tasks[0]?.lastFiredAt).toBe(123456789)
})
test('findMissedTasks returns tasks whose first scheduled run is in the past', () => {
const nowMs = new Date('2026-04-12T10:10:00').getTime()
const tasks = findMissedTasks(
[
{
id: 'missed01',
cron: '* * * * *',
prompt: 'old task',
createdAt: new Date('2026-04-12T10:00:00').getTime(),
},
{
id: 'future01',
cron: '59 23 31 12 *',
prompt: 'far future',
createdAt: nowMs,
},
],
nowMs,
)
expect(tasks.map(t => t.id)).toEqual(['missed01'])
})
test('nextCronRunMs returns null for invalid cron expressions', () => {
expect(nextCronRunMs('invalid cron', Date.now())).toBeNull()
})
test('oneShotJitteredNextCronRunMs never returns a time earlier than fromMs', () => {
const fromMs = new Date('2026-04-12T10:59:50').getTime()
const next = oneShotJitteredNextCronRunMs('0 11 * * *', fromMs, '00000000')
expect(next).not.toBeNull()
expect(next!).toBeGreaterThanOrEqual(fromMs)
})
test('jitteredNextCronRunMs returns the exact next fire time when no second match exists in range', () => {
const fromMs = new Date('2026-04-12T10:00:00').getTime()
const exact = nextCronRunMs('0 0 29 2 *', fromMs)
const jittered = oneShotJitteredNextCronRunMs('0 0 29 2 *', fromMs, '89abcdef')
expect(exact).not.toBeNull()
expect(jittered).not.toBeNull()
expect(jittered!).toBeGreaterThanOrEqual(fromMs)
})
})

View File

@@ -0,0 +1,82 @@
import { describe, test, expect, mock } from 'bun:test'
// Mock dependencies before importing the module under test
let mockPreferredLanguage: string | undefined
let mockSystemLocale: string | undefined
mock.module('src/utils/config.js', () => ({
getGlobalConfig: () => ({
preferredLanguage: mockPreferredLanguage,
}),
}))
mock.module('src/utils/intl.js', () => ({
getSystemLocaleLanguage: () => mockSystemLocale,
}))
const { getResolvedLanguage, getLanguageDisplayName } = await import(
'src/utils/language.js'
)
describe('getResolvedLanguage', () => {
test('returns en when config is explicitly en', () => {
mockPreferredLanguage = 'en'
mockSystemLocale = 'zh'
expect(getResolvedLanguage()).toBe('en')
})
test('returns zh when config is explicitly zh', () => {
mockPreferredLanguage = 'zh'
mockSystemLocale = 'en'
expect(getResolvedLanguage()).toBe('zh')
})
test('falls back to system locale zh when config is auto', () => {
mockPreferredLanguage = 'auto'
mockSystemLocale = 'zh'
expect(getResolvedLanguage()).toBe('zh')
})
test('falls back to en when config is auto and system locale is not zh', () => {
mockPreferredLanguage = 'auto'
mockSystemLocale = 'en'
expect(getResolvedLanguage()).toBe('en')
})
test('falls back to en when config is auto and system locale is undefined', () => {
mockPreferredLanguage = 'auto'
mockSystemLocale = undefined
expect(getResolvedLanguage()).toBe('en')
})
test('falls back to auto behavior when config preferredLanguage is undefined', () => {
mockPreferredLanguage = undefined
mockSystemLocale = 'zh'
expect(getResolvedLanguage()).toBe('zh')
})
test('defaults to en when both config and locale are undefined', () => {
mockPreferredLanguage = undefined
mockSystemLocale = undefined
expect(getResolvedLanguage()).toBe('en')
})
})
describe('getLanguageDisplayName', () => {
test('returns Auto (follow system) for auto', () => {
expect(getLanguageDisplayName('auto')).toBe('Auto (follow system)')
})
test('returns English for en', () => {
expect(getLanguageDisplayName('en')).toBe('English')
})
test('returns 中文 for zh', () => {
expect(getLanguageDisplayName('zh')).toBe('中文')
})
test('returns the input string for unknown language codes', () => {
expect(getLanguageDisplayName('fr')).toBe('fr')
expect(getLanguageDisplayName('unknown')).toBe('unknown')
})
})

View File

@@ -0,0 +1,124 @@
import { describe, test, expect, beforeEach } from 'bun:test'
import {
setMasterMutedPipes,
isMasterPipeMuted,
removeMasterPipeMute,
clearMasterMutedPipes,
addSendOverride,
removeSendOverride,
hasSendOverride,
clearSendOverrides,
} from '../pipeMuteState.js'
describe('setMasterMutedPipes', () => {
beforeEach(() => {
clearMasterMutedPipes()
clearSendOverrides()
})
test('sets muted pipes from iterable', () => {
setMasterMutedPipes(['pipe-a', 'pipe-b'])
expect(isMasterPipeMuted('pipe-a')).toBe(true)
expect(isMasterPipeMuted('pipe-b')).toBe(true)
expect(isMasterPipeMuted('pipe-c')).toBe(false)
})
test('replaces previous muted set', () => {
setMasterMutedPipes(['pipe-a'])
setMasterMutedPipes(['pipe-b'])
expect(isMasterPipeMuted('pipe-a')).toBe(false)
expect(isMasterPipeMuted('pipe-b')).toBe(true)
})
})
describe('isMasterPipeMuted', () => {
beforeEach(() => {
clearMasterMutedPipes()
})
test('returns false for unknown pipe', () => {
expect(isMasterPipeMuted('unknown')).toBe(false)
})
})
describe('removeMasterPipeMute', () => {
beforeEach(() => {
clearMasterMutedPipes()
})
test('removes a single muted pipe', () => {
setMasterMutedPipes(['pipe-a', 'pipe-b'])
removeMasterPipeMute('pipe-a')
expect(isMasterPipeMuted('pipe-a')).toBe(false)
expect(isMasterPipeMuted('pipe-b')).toBe(true)
})
test('no-ops for non-existent pipe', () => {
removeMasterPipeMute('nonexistent')
expect(isMasterPipeMuted('nonexistent')).toBe(false)
})
})
describe('clearMasterMutedPipes', () => {
test('clears all muted pipes', () => {
setMasterMutedPipes(['pipe-a', 'pipe-b', 'pipe-c'])
clearMasterMutedPipes()
expect(isMasterPipeMuted('pipe-a')).toBe(false)
expect(isMasterPipeMuted('pipe-b')).toBe(false)
expect(isMasterPipeMuted('pipe-c')).toBe(false)
})
})
describe('addSendOverride', () => {
beforeEach(() => {
clearSendOverrides()
})
test('adds a send override', () => {
addSendOverride('pipe-x')
expect(hasSendOverride('pipe-x')).toBe(true)
})
test('adding same override twice is idempotent', () => {
addSendOverride('pipe-x')
addSendOverride('pipe-x')
expect(hasSendOverride('pipe-x')).toBe(true)
})
})
describe('removeSendOverride', () => {
beforeEach(() => {
clearSendOverrides()
})
test('removes a send override', () => {
addSendOverride('pipe-x')
removeSendOverride('pipe-x')
expect(hasSendOverride('pipe-x')).toBe(false)
})
test('no-ops for non-existent override', () => {
removeSendOverride('nonexistent')
expect(hasSendOverride('nonexistent')).toBe(false)
})
})
describe('hasSendOverride', () => {
beforeEach(() => {
clearSendOverrides()
})
test('returns false when no overrides set', () => {
expect(hasSendOverride('pipe-x')).toBe(false)
})
})
describe('clearSendOverrides', () => {
test('clears all send overrides', () => {
addSendOverride('pipe-a')
addSendOverride('pipe-b')
clearSendOverrides()
expect(hasSendOverride('pipe-a')).toBe(false)
expect(hasSendOverride('pipe-b')).toBe(false)
})
})

View File

@@ -0,0 +1,93 @@
/**
* Tests for src/utils/taskSummary.ts
*
* Covers: shouldGenerateTaskSummary, maybeGenerateTaskSummary
*
* Note: bun:bundle's feature() is a compile-time construct and cannot be
* trivially mocked at test time. We test maybeGenerateTaskSummary (which
* is called unconditionally) and the rate-limit behavior indirectly.
*/
import { describe, expect, test, mock, beforeEach } from 'bun:test'
// ─── mocks ──────────────────────────────────────────────────────────────────
let _updateCalls: any[] = []
mock.module('bun:bundle', () => ({
feature: (_name: string) => false,
}))
mock.module('../concurrentSessions.js', () => ({
isBgSession: () => false,
updateSessionActivity: async (data: any) => {
_updateCalls.push(data)
},
}))
mock.module('../debug.js', () => ({
logForDebugging: () => {},
}))
// ─── import after mocks ─────────────────────────────────────────────────────
const { shouldGenerateTaskSummary, maybeGenerateTaskSummary } = await import(
'../taskSummary.js'
)
// ─── tests ──────────────────────────────────────────────────────────────────
beforeEach(() => {
_updateCalls = []
})
describe('shouldGenerateTaskSummary', () => {
test('returns false when feature is disabled', () => {
// bun:bundle feature mock returns false
expect(shouldGenerateTaskSummary()).toBe(false)
})
})
describe('maybeGenerateTaskSummary', () => {
test('does not throw with empty messages', () => {
expect(() =>
maybeGenerateTaskSummary({ forkContextMessages: [] }),
).not.toThrow()
})
test('does not throw with undefined messages', () => {
expect(() => maybeGenerateTaskSummary({})).not.toThrow()
})
test('does not throw with assistant message containing tool_use', () => {
expect(() =>
maybeGenerateTaskSummary({
forkContextMessages: [
{
type: 'assistant',
message: {
content: [
{ type: 'text', text: 'Let me check' },
{ type: 'tool_use', name: 'bash' },
],
},
},
],
}),
).not.toThrow()
})
test('does not throw with non-array content', () => {
expect(() =>
maybeGenerateTaskSummary({
forkContextMessages: [
{
type: 'assistant',
message: {
content: 'plain text response',
},
},
],
}),
).not.toThrow()
})
})

View File

@@ -0,0 +1,522 @@
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
} from 'node:path'
import { getProjectRoot } from '../bootstrap/state.js'
import { getCwd } from './cwd.js'
import { getFsImplementation } from './fsOperations.js'
import { normalizePathForConfigKey } from './path.js'
export const AUTONOMY_DIR = join('.claude', 'autonomy')
export const AUTONOMY_DIR_POSIX = '.claude/autonomy'
export const AUTONOMY_AGENTS_FILENAME = 'AGENTS.md'
export const AUTONOMY_HEARTBEAT_FILENAME = 'HEARTBEAT.md'
export const AUTONOMY_AGENTS_PATH_POSIX = `${AUTONOMY_DIR_POSIX}/${AUTONOMY_AGENTS_FILENAME}`
export const AUTONOMY_HEARTBEAT_PATH_POSIX = `${AUTONOMY_DIR_POSIX}/${AUTONOMY_HEARTBEAT_FILENAME}`
export type HeartbeatAuthorityTask = {
name: string
interval: string
prompt: string
steps: HeartbeatAuthorityTaskStep[]
}
export type HeartbeatAuthorityTaskStep = {
name: string
prompt: string
waitFor?: string
}
export type AutonomyAuthorityFile = {
path: string
relativePath: string
content: string
}
export type AutonomyAuthoritySnapshot = {
rootDir: string
currentDir: string
agentsFiles: AutonomyAuthorityFile[]
agentsContent: string | null
heartbeatFile: AutonomyAuthorityFile | null
heartbeatContent: string | null
heartbeatTasks: HeartbeatAuthorityTask[]
}
type AutonomyAuthorityParams = {
rootDir?: string
currentDir?: string
}
export type AutonomyTriggerKind =
| 'proactive-tick'
| 'scheduled-task'
| 'managed-flow-step'
export type PreparedAutonomyTurn = {
rootDir: string
currentDir: string
trigger: AutonomyTriggerKind
prompt: string
dueHeartbeatTasks: HeartbeatAuthorityTask[]
nowMs: number
}
const heartbeatTaskLastRunByKey = new Map<string, number>()
function isPathWithinRoot(rootDir: string, currentDir: string): boolean {
const delta = relative(rootDir, currentDir)
return delta === '' || (!delta.startsWith('..') && !isAbsolute(delta))
}
function listAuthorityDirectories(
rootDir: string,
currentDir: string,
): string[] {
const resolvedRoot = resolve(rootDir)
const resolvedCurrent = resolve(currentDir)
if (!isPathWithinRoot(resolvedRoot, resolvedCurrent)) {
return [resolvedRoot]
}
const dirs: string[] = []
let cursor = resolvedCurrent
for (;;) {
dirs.push(cursor)
if (cursor === resolvedRoot) {
break
}
const parent = dirname(cursor)
if (parent === cursor) {
break
}
cursor = parent
}
return dirs.reverse()
}
async function readAuthorityFile(
filePath: string,
rootDir: string,
): Promise<AutonomyAuthorityFile | null> {
try {
const content = (await getFsImplementation().readFile(filePath, {
encoding: 'utf-8',
})) as string
const trimmed = content.trim()
if (!trimmed) {
return null
}
return {
path: filePath,
relativePath:
normalizePathForConfigKey(relative(rootDir, filePath)) ||
basename(filePath),
content: trimmed,
}
} catch {
return null
}
}
function mergeAgentsAuthority(files: AutonomyAuthorityFile[]): string | null {
if (files.length === 0) {
return null
}
return files
.map(file => `## ${file.relativePath}\n${file.content}`)
.join('\n\n')
}
export function parseHeartbeatAuthorityTasks(
content: string,
): HeartbeatAuthorityTask[] {
const tasks: HeartbeatAuthorityTask[] = []
const lines = content.split('\n')
const getIndent = (line: string): number =>
line.length - line.trimStart().length
const parseScalar = (line: string, key: string): string =>
line
.replace(key, '')
.trim()
.replace(/^["']|["']$/g, '')
function parseSteps(
startIndex: number,
stepsIndent: number,
): { steps: HeartbeatAuthorityTaskStep[]; nextIndex: number } {
const steps: HeartbeatAuthorityTaskStep[] = []
let index = startIndex
while (index < lines.length) {
const line = lines[index]!
const trimmed = line.trim()
const indent = getIndent(line)
if (!trimmed) {
index += 1
continue
}
if (indent <= stepsIndent) {
break
}
if (!trimmed.startsWith('- name:')) {
index += 1
continue
}
const stepIndent = indent
const name = parseScalar(trimmed, '- name:')
let prompt = ''
let waitFor: string | undefined
index += 1
while (index < lines.length) {
const nextLine = lines[index]!
const nextTrimmed = nextLine.trim()
const nextIndent = getIndent(nextLine)
if (!nextTrimmed) {
index += 1
continue
}
if (nextIndent <= stepIndent) {
break
}
if (nextTrimmed.startsWith('prompt:')) {
prompt = parseScalar(nextTrimmed, 'prompt:')
} else if (nextTrimmed.startsWith('wait_for:')) {
waitFor = parseScalar(nextTrimmed, 'wait_for:')
}
index += 1
}
if (name && prompt) {
steps.push({
name,
prompt,
...(waitFor ? { waitFor } : {}),
})
}
}
return { steps, nextIndex: index }
}
const tasksLineIndex = lines.findIndex(line => line.trim() === 'tasks:')
if (tasksLineIndex === -1) {
return tasks
}
const tasksIndent = getIndent(lines[tasksLineIndex]!)
let index = tasksLineIndex + 1
while (index < lines.length) {
const line = lines[index]!
const trimmed = line.trim()
const indent = getIndent(line)
if (!trimmed) {
index += 1
continue
}
if (indent <= tasksIndent) {
break
}
if (!trimmed.startsWith('- name:')) {
index += 1
continue
}
const taskIndent = indent
const name = parseScalar(trimmed, '- name:')
let interval = ''
let prompt = ''
let steps: HeartbeatAuthorityTaskStep[] = []
index += 1
while (index < lines.length) {
const nextLine = lines[index]!
const nextTrimmed = nextLine.trim()
const nextIndent = getIndent(nextLine)
if (!nextTrimmed) {
index += 1
continue
}
if (nextIndent <= tasksIndent) {
break
}
if (nextIndent === taskIndent && nextTrimmed.startsWith('- name:')) {
break
}
if (nextIndent <= taskIndent) {
break
}
if (nextTrimmed.startsWith('interval:')) {
interval = parseScalar(nextTrimmed, 'interval:')
index += 1
continue
}
if (nextTrimmed.startsWith('prompt:')) {
prompt = parseScalar(nextTrimmed, 'prompt:')
index += 1
continue
}
if (nextTrimmed === 'steps:') {
const parsed = parseSteps(index + 1, nextIndent)
steps = parsed.steps
index = parsed.nextIndex
continue
}
index += 1
}
if (name && interval && prompt) {
tasks.push({
name,
interval,
prompt,
steps,
})
}
}
return tasks
}
function parseHeartbeatIntervalMs(interval: string): number | null {
const match = interval.trim().match(/^(\d+)\s*(ms|s|m|h|d)?$/i)
if (!match) {
return null
}
const value = Number.parseInt(match[1]!, 10)
const unit = (match[2] ?? 'm').toLowerCase()
switch (unit) {
case 'ms':
return value
case 's':
return value * 1_000
case 'm':
return value * 60_000
case 'h':
return value * 60 * 60_000
case 'd':
return value * 24 * 60 * 60_000
default:
return null
}
}
function heartbeatTaskKey(
rootDir: string,
task: HeartbeatAuthorityTask,
): string {
return `${rootDir}::${task.name}::${task.interval}::${task.prompt}`
}
function collectDueHeartbeatTasks(
snapshot: AutonomyAuthoritySnapshot,
nowMs: number,
): HeartbeatAuthorityTask[] {
const due: HeartbeatAuthorityTask[] = []
for (const task of snapshot.heartbeatTasks) {
const intervalMs = parseHeartbeatIntervalMs(task.interval)
if (intervalMs == null) {
continue
}
const key = heartbeatTaskKey(snapshot.rootDir, task)
const lastRunMs = heartbeatTaskLastRunByKey.get(key)
if (lastRunMs !== undefined && nowMs - lastRunMs < intervalMs) {
continue
}
due.push(task)
}
return due
}
function markHeartbeatTasksConsumed(
snapshot: AutonomyAuthoritySnapshot,
tasks: HeartbeatAuthorityTask[],
nowMs: number,
): void {
for (const task of tasks) {
heartbeatTaskLastRunByKey.set(
heartbeatTaskKey(snapshot.rootDir, task),
nowMs,
)
}
}
export function resetAutonomyAuthorityForTests(): void {
heartbeatTaskLastRunByKey.clear()
}
export async function loadAutonomyAuthority(
params: AutonomyAuthorityParams = {},
): Promise<AutonomyAuthoritySnapshot> {
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const currentDir = resolve(params.currentDir ?? getCwd())
const authorityDirs = listAuthorityDirectories(rootDir, currentDir)
const [agentsResults, heartbeatFile] = await Promise.all([
Promise.all(
authorityDirs.map(async dir =>
readAuthorityFile(
join(dir, AUTONOMY_DIR, AUTONOMY_AGENTS_FILENAME),
rootDir,
),
),
),
readAuthorityFile(
join(rootDir, AUTONOMY_DIR, AUTONOMY_HEARTBEAT_FILENAME),
rootDir,
),
])
const agentsFiles = agentsResults.filter(
(file): file is AutonomyAuthorityFile => file !== null,
)
return {
rootDir,
currentDir,
agentsFiles,
agentsContent: mergeAgentsAuthority(agentsFiles),
heartbeatFile,
heartbeatContent: heartbeatFile?.content ?? null,
heartbeatTasks: heartbeatFile
? parseHeartbeatAuthorityTasks(heartbeatFile.content)
: [],
}
}
export async function buildAutonomyTurnPrompt(params: {
basePrompt: string
trigger: AutonomyTriggerKind
rootDir?: string
currentDir?: string
nowMs?: number
}): Promise<string> {
const prepared = await prepareAutonomyTurnPrompt(params)
commitPreparedAutonomyTurn(prepared)
return prepared.prompt
}
export async function prepareAutonomyTurnPrompt(params: {
basePrompt: string
trigger: AutonomyTriggerKind
rootDir?: string
currentDir?: string
nowMs?: number
}): Promise<PreparedAutonomyTurn> {
const snapshot = await loadAutonomyAuthority({
rootDir: params.rootDir,
currentDir: params.currentDir,
})
const nowMs = params.nowMs ?? Date.now()
const dueHeartbeatTasks =
params.trigger === 'proactive-tick'
? collectDueHeartbeatTasks(snapshot, nowMs)
: []
const duePromptTasks = dueHeartbeatTasks.filter(
task => task.steps.length === 0,
)
const sections: string[] = []
if (snapshot.agentsContent) {
sections.push(
`Workspace authority from ${AUTONOMY_AGENTS_FILENAME}:\n${snapshot.agentsContent}`,
)
}
if (snapshot.heartbeatContent) {
sections.push(
`Workspace heartbeat guidance from ${AUTONOMY_HEARTBEAT_FILENAME}:\n${snapshot.heartbeatContent}`,
)
}
if (duePromptTasks.length > 0) {
sections.push(
[
`Due ${AUTONOMY_HEARTBEAT_FILENAME} tasks:`,
...duePromptTasks.map(
task => `- ${task.name} (${task.interval}): ${task.prompt}`,
),
].join('\n'),
)
}
if (sections.length === 0) {
return {
rootDir: snapshot.rootDir,
currentDir: snapshot.currentDir,
trigger: params.trigger,
prompt: params.basePrompt,
dueHeartbeatTasks,
nowMs,
}
}
const prelude =
params.trigger === 'proactive-tick'
? 'This is an autonomous proactive turn. Follow the workspace authority below before acting.'
: 'This prompt was generated automatically. Follow the workspace authority below before acting.'
return {
rootDir: snapshot.rootDir,
currentDir: snapshot.currentDir,
trigger: params.trigger,
prompt: [
prelude,
'<autonomy_authority>',
...sections,
'</autonomy_authority>',
params.basePrompt,
].join('\n\n'),
dueHeartbeatTasks,
nowMs,
}
}
export function commitPreparedAutonomyTurn(
prepared: PreparedAutonomyTurn,
): void {
if (
prepared.trigger !== 'proactive-tick' ||
prepared.dueHeartbeatTasks.length === 0
) {
return
}
const snapshot: AutonomyAuthoritySnapshot = {
rootDir: prepared.rootDir,
currentDir: prepared.currentDir,
agentsFiles: [],
agentsContent: null,
heartbeatFile: null,
heartbeatContent: null,
heartbeatTasks: prepared.dueHeartbeatTasks,
}
markHeartbeatTasksConsumed(
snapshot,
prepared.dueHeartbeatTasks,
prepared.nowMs,
)
}

1057
src/utils/autonomyFlows.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
import { mkdir, writeFile } from 'fs/promises'
import { join, resolve } from 'path'
import { lock } from './lockfile.js'
const persistenceLocks = new Map<string, Promise<void>>()
export async function withAutonomyPersistenceLock<T>(
rootDir: string,
fn: () => Promise<T>,
): Promise<T> {
const key = resolve(rootDir)
const lockPath = join(key, '.claude', 'autonomy', '.lock')
const previous = persistenceLocks.get(key) ?? Promise.resolve()
let release!: () => void
const current = new Promise<void>(resolve => {
release = resolve
})
persistenceLocks.set(
key,
previous.then(() => current),
)
await previous
try {
await mkdir(join(key, '.claude', 'autonomy'), { recursive: true })
await writeFile(lockPath, '', { flag: 'a' })
const unlock = await lock(lockPath, {
lockfilePath: `${lockPath}.lock`,
retries: {
retries: 10,
factor: 1.2,
minTimeout: 10,
maxTimeout: 100,
},
})
try {
return await fn()
} finally {
await unlock().catch(() => {})
}
} finally {
release()
if (persistenceLocks.get(key) === current) {
persistenceLocks.delete(key)
}
}
}

782
src/utils/autonomyRuns.ts Normal file
View File

@@ -0,0 +1,782 @@
import { randomUUID } from 'crypto'
import { mkdir, writeFile } from 'fs/promises'
import { dirname, join, resolve } from 'path'
import { getProjectRoot } from '../bootstrap/state.js'
import type { MessageOrigin } from '../types/message.js'
import type { QueuedCommand } from '../types/textInputTypes.js'
import {
AUTONOMY_DIR,
buildAutonomyTurnPrompt,
commitPreparedAutonomyTurn,
prepareAutonomyTurnPrompt,
type AutonomyTriggerKind,
type HeartbeatAuthorityTask,
} from './autonomyAuthority.js'
import { getCwd } from './cwd.js'
import {
DEFAULT_AUTONOMY_OWNER_KEY,
getAutonomyFlowById,
markManagedAutonomyFlowStepCancelled,
markManagedAutonomyFlowStepCompleted,
markManagedAutonomyFlowStepFailed,
markManagedAutonomyFlowStepRunning,
queueManagedAutonomyFlowStepRun,
resumeManagedAutonomyFlow,
startManagedAutonomyFlow,
type AutonomyFlowRecord,
type AutonomyFlowSyncMode,
type ManagedAutonomyFlowStepDefinition,
} from './autonomyFlows.js'
import { withAutonomyPersistenceLock } from './autonomyPersistence.js'
import { getFsImplementation } from './fsOperations.js'
const AUTONOMY_RUNS_MAX = 200
const AUTONOMY_RUNS_RELATIVE_PATH = join(AUTONOMY_DIR, 'runs.json')
export type AutonomyRunStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed'
| 'cancelled'
export type AutonomyRunRuntime = 'automatic' | 'flow_step'
export type AutonomyRunRecord = {
runId: string
runtime: AutonomyRunRuntime
trigger: AutonomyTriggerKind
status: AutonomyRunStatus
rootDir: string
currentDir: string
ownerKey: string
sourceId?: string
sourceLabel?: string
parentFlowId?: string
parentFlowKey?: string
parentFlowSyncMode?: AutonomyFlowSyncMode
flowStepId?: string
flowStepName?: string
promptPreview: string
createdAt: number
startedAt?: number
endedAt?: number
error?: string
}
type AutonomyRunsFile = {
runs: AutonomyRunRecord[]
}
type AutonomyRunFlowRef = {
flowId: string
flowKey: string
syncMode: AutonomyFlowSyncMode
ownerKey: string
stepId: string
stepName: string
}
function truncatePromptPreview(prompt: string): string {
const singleLine = prompt.replace(/\s+/g, ' ').trim()
return singleLine.length <= 240
? singleLine
: `${singleLine.slice(0, 237)}...`
}
function cloneRunRecord(run: AutonomyRunRecord): AutonomyRunRecord {
return { ...run }
}
export function resolveAutonomyRunsPath(
rootDir: string = getProjectRoot(),
): string {
return join(resolve(rootDir), AUTONOMY_RUNS_RELATIVE_PATH)
}
export async function listAutonomyRuns(
rootDir: string = getProjectRoot(),
): Promise<AutonomyRunRecord[]> {
try {
const raw = (await getFsImplementation().readFile(
resolveAutonomyRunsPath(rootDir),
{
encoding: 'utf-8',
},
)) as string
const parsed = JSON.parse(raw) as Partial<AutonomyRunsFile>
if (!Array.isArray(parsed.runs)) {
return []
}
return parsed.runs
.filter((run): run is AutonomyRunRecord => {
return Boolean(
run &&
typeof run.runId === 'string' &&
typeof run.trigger === 'string' &&
typeof run.status === 'string' &&
typeof run.rootDir === 'string' &&
typeof run.promptPreview === 'string' &&
typeof run.createdAt === 'number',
)
})
.map(run => ({
...cloneRunRecord(run),
runtime: run.runtime === 'flow_step' ? 'flow_step' : 'automatic',
currentDir: run.currentDir || run.rootDir,
ownerKey: run.ownerKey || DEFAULT_AUTONOMY_OWNER_KEY,
}))
.sort((left, right) => right.createdAt - left.createdAt)
} catch {
return []
}
}
async function writeAutonomyRuns(
runs: AutonomyRunRecord[],
rootDir: string = getProjectRoot(),
): Promise<void> {
const path = resolveAutonomyRunsPath(rootDir)
await mkdir(dirname(path), { recursive: true })
await writeFile(
path,
`${JSON.stringify(
{
runs: runs
.slice()
.map(cloneRunRecord)
.sort((left, right) => right.createdAt - left.createdAt)
.slice(0, AUTONOMY_RUNS_MAX),
} satisfies AutonomyRunsFile,
null,
2,
)}\n`,
'utf-8',
)
}
async function updateAutonomyRun(
runId: string,
updater: (current: AutonomyRunRecord) => AutonomyRunRecord,
rootDir: string = getProjectRoot(),
): Promise<AutonomyRunRecord | null> {
return withAutonomyPersistenceLock(rootDir, async () => {
const runs = await listAutonomyRuns(rootDir)
const index = runs.findIndex(run => run.runId === runId)
if (index === -1) {
return null
}
const updated = cloneRunRecord(updater(cloneRunRecord(runs[index]!)))
runs[index] = updated
await writeAutonomyRuns(runs, rootDir)
return updated
})
}
export async function getAutonomyRunById(
runId: string,
rootDir: string = getProjectRoot(),
): Promise<AutonomyRunRecord | null> {
const runs = await listAutonomyRuns(rootDir)
return runs.find(run => run.runId === runId) ?? null
}
export async function createAutonomyRun(params: {
trigger: AutonomyTriggerKind
prompt: string
rootDir?: string
currentDir?: string
sourceId?: string
sourceLabel?: string
runtime?: AutonomyRunRuntime
ownerKey?: string
flow?: AutonomyRunFlowRef
nowMs?: number
}): Promise<AutonomyRunRecord> {
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const currentDir = resolve(params.currentDir ?? rootDir)
const record: AutonomyRunRecord = {
runId: randomUUID(),
runtime: params.runtime ?? (params.flow ? 'flow_step' : 'automatic'),
trigger: params.trigger,
status: 'queued',
rootDir,
currentDir,
ownerKey:
params.flow?.ownerKey ?? params.ownerKey ?? DEFAULT_AUTONOMY_OWNER_KEY,
...(params.sourceId ? { sourceId: params.sourceId } : {}),
...(params.sourceLabel ? { sourceLabel: params.sourceLabel } : {}),
...(params.flow
? {
parentFlowId: params.flow.flowId,
parentFlowKey: params.flow.flowKey,
parentFlowSyncMode: params.flow.syncMode,
flowStepId: params.flow.stepId,
flowStepName: params.flow.stepName,
}
: {}),
promptPreview: truncatePromptPreview(params.prompt),
createdAt: params.nowMs ?? Date.now(),
}
await withAutonomyPersistenceLock(rootDir, async () => {
const runs = await listAutonomyRuns(rootDir)
runs.unshift(record)
await writeAutonomyRuns(runs, rootDir)
})
if (
record.parentFlowId &&
record.flowStepId &&
record.parentFlowSyncMode === 'managed'
) {
const stepIndex =
(
await getAutonomyFlowById(record.parentFlowId, rootDir)
)?.stateJson?.steps.findIndex(
step => step.stepId === record.flowStepId,
) ?? 0
await queueManagedAutonomyFlowStepRun({
flowId: record.parentFlowId,
stepId: record.flowStepId,
stepIndex: stepIndex >= 0 ? stepIndex : 0,
runId: record.runId,
rootDir,
nowMs: record.createdAt,
})
}
return record
}
function buildManagedFlowStepPrompt(
flow: AutonomyFlowRecord,
stepIndex: number,
): string {
const state = flow.stateJson
const step = state?.steps[stepIndex]
if (!state || !step) {
return flow.goal
}
const completed = state.steps
.slice(0, stepIndex)
.filter(candidate => candidate.status === 'completed')
.map(candidate => `- ${candidate.name}`)
const remaining = state.steps
.slice(stepIndex + 1)
.map(candidate => `- ${candidate.name}`)
return [
`This is step ${stepIndex + 1}/${state.steps.length} of the managed autonomy flow "${flow.goal}".`,
'<autonomy_flow>',
`Flow ID: ${flow.flowId}`,
`Flow source: ${flow.sourceLabel ?? flow.sourceId ?? 'automatic'}`,
`Current step: ${step.name}`,
completed.length > 0
? ['Completed steps:', ...completed].join('\n')
: 'Completed steps: none',
remaining.length > 0
? ['Remaining steps after this one:', ...remaining].join('\n')
: 'Remaining steps after this one: none',
'</autonomy_flow>',
step.prompt,
].join('\n\n')
}
async function createOrRecoverManagedFlowStepCommand(params: {
flowId: string
rootDir?: string
currentDir?: string
priority?: 'now' | 'next' | 'later'
workload?: string
}): Promise<QueuedCommand | null> {
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const flow = await getAutonomyFlowById(params.flowId, rootDir)
if (!flow || flow.status !== 'queued' || !flow.stateJson) {
return null
}
const stepIndex = flow.stateJson.currentStepIndex
const step = flow.stateJson.steps[stepIndex]
if (!step) {
return null
}
if (step.status === 'queued' && step.runId) {
const run = await getAutonomyRunById(step.runId, rootDir)
if (run && run.status === 'queued' && !run.startedAt && !run.endedAt) {
const value = await buildAutonomyTurnPrompt({
basePrompt: buildManagedFlowStepPrompt(flow, stepIndex),
trigger: 'managed-flow-step',
rootDir,
currentDir: params.currentDir ?? flow.currentDir,
})
const origin = {
kind: 'autonomy',
trigger: 'managed-flow-step',
runId: run.runId,
...(run.sourceId ? { sourceId: run.sourceId } : {}),
} as unknown as MessageOrigin
return {
value,
mode: 'prompt',
priority: params.priority ?? 'later',
isMeta: true,
origin,
workload: params.workload,
autonomy: {
runId: run.runId,
trigger: 'managed-flow-step',
sourceId: run.sourceId,
sourceLabel: run.sourceLabel,
...(run.parentFlowId ? { flowId: run.parentFlowId } : {}),
...(run.flowStepId ? { flowStepId: run.flowStepId } : {}),
...(run.flowStepName ? { flowStepName: run.flowStepName } : {}),
},
}
}
return null
}
if (step.status !== 'pending' || step.runId) {
return null
}
return createAutonomyQueuedPrompt({
basePrompt: buildManagedFlowStepPrompt(flow, stepIndex),
trigger: 'managed-flow-step',
rootDir,
currentDir: params.currentDir ?? flow.currentDir,
sourceId: flow.sourceId ?? flow.flowId,
sourceLabel: flow.sourceLabel ?? flow.goal,
workload: params.workload,
priority: params.priority,
flow: {
flowId: flow.flowId,
flowKey: flow.flowKey,
syncMode: 'managed',
ownerKey: flow.ownerKey,
stepId: step.stepId,
stepName: step.name,
},
})
}
async function queueCurrentManagedFlowStepCommand(params: {
flowId: string
rootDir?: string
currentDir?: string
priority?: 'now' | 'next' | 'later'
workload?: string
}): Promise<QueuedCommand | null> {
return createOrRecoverManagedFlowStepCommand(params)
}
export async function startManagedAutonomyFlowFromHeartbeatTask(params: {
task: HeartbeatAuthorityTask
rootDir?: string
currentDir?: string
ownerKey?: string
priority?: 'now' | 'next' | 'later'
workload?: string
}): Promise<QueuedCommand | null> {
if (params.task.steps.length === 0) {
return null
}
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const currentDir = resolve(params.currentDir ?? getCwd())
const started = await startManagedAutonomyFlow({
trigger: 'proactive-tick',
goal: params.task.prompt,
steps: params.task.steps.map<ManagedAutonomyFlowStepDefinition>(step => ({
name: step.name,
prompt: step.prompt,
...(step.waitFor ? { waitFor: step.waitFor } : {}),
})),
rootDir,
currentDir,
ownerKey: params.ownerKey,
sourceId: `heartbeat:${params.task.name}`,
sourceLabel: params.task.name,
})
if (!started) {
return null
}
return createOrRecoverManagedFlowStepCommand({
flowId: started.flow.flowId,
rootDir,
currentDir,
priority: params.priority,
workload: params.workload,
})
}
export async function markAutonomyRunRunning(
runId: string,
rootDir?: string,
nowMs?: number,
): Promise<AutonomyRunRecord | null> {
const updated = await updateAutonomyRun(
runId,
current => ({
...current,
status: 'running',
startedAt: nowMs ?? Date.now(),
}),
rootDir,
)
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
await markManagedAutonomyFlowStepRunning({
flowId: updated.parentFlowId,
runId: updated.runId,
rootDir,
nowMs: updated.startedAt,
})
}
return updated
}
export async function markAutonomyRunCompleted(
runId: string,
rootDir?: string,
nowMs?: number,
): Promise<AutonomyRunRecord | null> {
const updated = await updateAutonomyRun(
runId,
current => ({
...current,
status: 'completed',
endedAt: nowMs ?? Date.now(),
error: undefined,
}),
rootDir,
)
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
await markManagedAutonomyFlowStepCompleted({
flowId: updated.parentFlowId,
runId: updated.runId,
rootDir,
nowMs: updated.endedAt,
})
}
return updated
}
export async function markAutonomyRunFailed(
runId: string,
error: string,
rootDir?: string,
nowMs?: number,
): Promise<AutonomyRunRecord | null> {
const updated = await updateAutonomyRun(
runId,
current => ({
...current,
status: 'failed',
endedAt: nowMs ?? Date.now(),
error,
}),
rootDir,
)
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
await markManagedAutonomyFlowStepFailed({
flowId: updated.parentFlowId,
runId: updated.runId,
error,
rootDir,
nowMs: updated.endedAt,
})
}
return updated
}
export async function markAutonomyRunCancelled(
runId: string,
rootDir?: string,
nowMs?: number,
): Promise<AutonomyRunRecord | null> {
const updated = await updateAutonomyRun(
runId,
current => ({
...current,
status: 'cancelled',
endedAt: nowMs ?? Date.now(),
error: undefined,
}),
rootDir,
)
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
await markManagedAutonomyFlowStepCancelled({
flowId: updated.parentFlowId,
runId: updated.runId,
rootDir,
nowMs: updated.endedAt,
})
}
return updated
}
export async function finalizeAutonomyRunCompleted(params: {
runId: string
rootDir?: string
currentDir?: string
priority?: 'now' | 'next' | 'later'
workload?: string
nowMs?: number
}): Promise<QueuedCommand[]> {
const updated = await markAutonomyRunCompleted(
params.runId,
params.rootDir,
params.nowMs,
)
if (!updated?.parentFlowId || updated.parentFlowSyncMode !== 'managed') {
return []
}
const next = await queueCurrentManagedFlowStepCommand({
flowId: updated.parentFlowId,
rootDir: params.rootDir,
currentDir: params.currentDir ?? updated.currentDir,
priority: params.priority,
workload: params.workload,
})
return next ? [next] : []
}
export async function finalizeAutonomyRunFailed(params: {
runId: string
error: string
rootDir?: string
nowMs?: number
}): Promise<void> {
await markAutonomyRunFailed(
params.runId,
params.error,
params.rootDir,
params.nowMs,
)
}
export async function recoverManagedAutonomyFlowPrompt(params: {
flowId: string
rootDir?: string
currentDir?: string
priority?: 'now' | 'next' | 'later'
workload?: string
}): Promise<QueuedCommand | null> {
return createOrRecoverManagedFlowStepCommand(params)
}
export async function resumeManagedAutonomyFlowPrompt(params: {
flowId: string
rootDir?: string
currentDir?: string
priority?: 'now' | 'next' | 'later'
workload?: string
nowMs?: number
}): Promise<QueuedCommand | null> {
const resumed = await resumeManagedAutonomyFlow({
flowId: params.flowId,
rootDir: params.rootDir,
nowMs: params.nowMs,
})
if (!resumed) {
return recoverManagedAutonomyFlowPrompt({
flowId: params.flowId,
rootDir: params.rootDir,
currentDir: params.currentDir,
priority: params.priority,
workload: params.workload,
})
}
return createOrRecoverManagedFlowStepCommand({
flowId: resumed.flow.flowId,
rootDir: params.rootDir,
currentDir: params.currentDir ?? resumed.flow.currentDir,
priority: params.priority,
workload: params.workload,
})
}
export async function createAutonomyQueuedPrompt(params: {
trigger: AutonomyTriggerKind
basePrompt: string
rootDir?: string
currentDir?: string
sourceId?: string
sourceLabel?: string
workload?: string
priority?: 'now' | 'next' | 'later'
shouldCreate?: () => boolean
flow?: AutonomyRunFlowRef
}): Promise<QueuedCommand | null> {
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const currentDir = resolve(params.currentDir ?? getCwd())
const prepared = await prepareAutonomyTurnPrompt({
basePrompt: params.basePrompt,
trigger: params.trigger,
rootDir,
currentDir,
})
if (params.shouldCreate && !params.shouldCreate()) {
return null
}
return commitAutonomyQueuedPrompt({
prepared,
rootDir,
currentDir,
sourceId: params.sourceId,
sourceLabel: params.sourceLabel,
workload: params.workload,
priority: params.priority,
flow: params.flow,
})
}
export async function commitAutonomyQueuedPrompt(params: {
prepared: Awaited<ReturnType<typeof prepareAutonomyTurnPrompt>>
rootDir?: string
currentDir?: string
sourceId?: string
sourceLabel?: string
workload?: string
priority?: 'now' | 'next' | 'later'
flow?: AutonomyRunFlowRef
}): Promise<QueuedCommand> {
const rootDir = resolve(
params.rootDir ?? params.prepared.rootDir ?? getProjectRoot(),
)
const currentDir = resolve(
params.currentDir ?? params.prepared.currentDir ?? getCwd(),
)
commitPreparedAutonomyTurn(params.prepared)
const value = params.prepared.prompt
const run = await createAutonomyRun({
trigger: params.prepared.trigger,
prompt: value,
rootDir,
currentDir,
sourceId: params.sourceId,
sourceLabel: params.sourceLabel,
flow: params.flow,
})
const origin = {
kind: 'autonomy',
trigger: params.prepared.trigger,
runId: run.runId,
...(params.sourceId ? { sourceId: params.sourceId } : {}),
} as unknown as MessageOrigin
return {
value,
mode: 'prompt',
priority: params.priority ?? 'later',
isMeta: true,
origin,
workload: params.workload,
autonomy: {
runId: run.runId,
trigger: params.prepared.trigger,
sourceId: params.sourceId,
sourceLabel: params.sourceLabel,
...(run.parentFlowId ? { flowId: run.parentFlowId } : {}),
...(run.flowStepId ? { flowStepId: run.flowStepId } : {}),
...(run.flowStepName ? { flowStepName: run.flowStepName } : {}),
},
}
}
export async function createProactiveAutonomyCommands(params: {
basePrompt: string
rootDir?: string
currentDir?: string
workload?: string
priority?: 'now' | 'next' | 'later'
shouldCreate?: () => boolean
}): Promise<QueuedCommand[]> {
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const currentDir = resolve(params.currentDir ?? getCwd())
const prepared = await prepareAutonomyTurnPrompt({
basePrompt: params.basePrompt,
trigger: 'proactive-tick',
rootDir,
currentDir,
})
if (params.shouldCreate && !params.shouldCreate()) {
return []
}
const commands: QueuedCommand[] = [
await commitAutonomyQueuedPrompt({
prepared,
rootDir,
currentDir,
workload: params.workload,
priority: params.priority,
}),
]
for (const task of prepared.dueHeartbeatTasks) {
if (task.steps.length === 0) {
continue
}
if (params.shouldCreate && !params.shouldCreate()) {
break
}
const flowCommand = await startManagedAutonomyFlowFromHeartbeatTask({
task,
rootDir,
currentDir,
priority: params.priority,
workload: params.workload,
})
if (flowCommand) {
commands.push(flowCommand)
}
}
return commands
}
export function formatAutonomyRunsStatus(runs: AutonomyRunRecord[]): string {
const counts = {
queued: 0,
running: 0,
completed: 0,
failed: 0,
cancelled: 0,
}
for (const run of runs) {
counts[run.status] += 1
}
const latest = runs[0]
const latestLine = latest
? `Latest: ${latest.trigger} ${latest.status} (${new Date(latest.createdAt).toLocaleString()})`
: 'Latest: none'
return [
`Autonomy runs: ${runs.length}`,
`Queued: ${counts.queued}`,
`Running: ${counts.running}`,
`Completed: ${counts.completed}`,
`Failed: ${counts.failed}`,
`Cancelled: ${counts.cancelled}`,
latestLine,
].join('\n')
}
export function formatAutonomyRunsList(
runs: AutonomyRunRecord[],
limit = 10,
): string {
const slice = runs.slice(0, limit)
if (slice.length === 0) {
return 'No autonomy runs recorded.'
}
return slice
.map(run => {
const source = run.sourceLabel ?? run.sourceId ?? 'auto'
const flow =
run.parentFlowId && run.flowStepName
? ` | flow=${run.parentFlowId} step=${run.flowStepName}`
: ''
const ended =
run.endedAt != null
? ` -> ${new Date(run.endedAt).toLocaleTimeString()}`
: ''
const error = run.error ? ` | ${run.error}` : ''
return `${run.runId} | ${run.runtime} | ${run.trigger} | ${run.status} | ${source}${flow} | ${new Date(run.createdAt).toLocaleTimeString()}${ended}\n ${run.promptPreview}${error}`
})
.join('\n')
}

View File

@@ -334,6 +334,9 @@ export type GlobalConfig = {
overageCreditUpsellSeenCount?: number // Number of times the overage credit upsell has been shown overageCreditUpsellSeenCount?: number // Number of times the overage credit upsell has been shown
hasVisitedExtraUsage?: boolean // Whether the user has visited /extra-usage — hides credit upsells hasVisitedExtraUsage?: boolean // Whether the user has visited /extra-usage — hides credit upsells
// Display language preference
preferredLanguage?: 'auto' | 'en' | 'zh' // auto = follow system locale, en = English, zh = 中文
// Voice mode notice tracking // Voice mode notice tracking
voiceNoticeSeenCount?: number // Number of times the voice-mode-available notice has been shown voiceNoticeSeenCount?: number // Number of times the voice-mode-available notice has been shown
voiceLangHintShownCount?: number // Number of times the /voice dictation-language hint has been shown voiceLangHintShownCount?: number // Number of times the /voice dictation-language hint has been shown
@@ -556,7 +559,6 @@ export type GlobalConfig = {
// Speculation configuration (ant-only) // Speculation configuration (ant-only)
speculationEnabled?: boolean // Whether speculation is enabled (default: true) speculationEnabled?: boolean // Whether speculation is enabled (default: true)
// Client data for server-side experiments (fetched during bootstrap). // Client data for server-side experiments (fetched during bootstrap).
clientDataCache?: Record<string, unknown> | null clientDataCache?: Record<string, unknown> | null

View File

@@ -26,6 +26,12 @@ import { fileHistoryEnabled, fileHistoryMakeSnapshot } from './fileHistory.js'
import { gracefulShutdownSync } from './gracefulShutdown.js' import { gracefulShutdownSync } from './gracefulShutdown.js'
import { enqueue } from './messageQueueManager.js' import { enqueue } from './messageQueueManager.js'
import { resolveSkillModelOverride } from './model/model.js' import { resolveSkillModelOverride } from './model/model.js'
import {
finalizeAutonomyRunCompleted,
finalizeAutonomyRunFailed,
markAutonomyRunFailed,
markAutonomyRunRunning,
} from './autonomyRuns.js'
import type { ProcessUserInputContext } from './processUserInput/processUserInput.js' import type { ProcessUserInputContext } from './processUserInput/processUserInput.js'
import { processUserInput } from './processUserInput/processUserInput.js' import { processUserInput } from './processUserInput/processUserInput.js'
import type { QueryGuard } from './QueryGuard.js' import type { QueryGuard } from './QueryGuard.js'
@@ -460,6 +466,7 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
commands.every(c => c.workload === firstWorkload) commands.every(c => c.workload === firstWorkload)
? firstWorkload ? firstWorkload
: undefined : undefined
const autonomyRunIds = new Set<string>()
// Wrap the entire turn (processUserInput loop + onQuery) in an // Wrap the entire turn (processUserInput loop + onQuery) in an
// AsyncLocalStorage context. This is the ONLY way to correctly // AsyncLocalStorage context. This is the ONLY way to correctly
@@ -469,10 +476,15 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
// context — isolated from the parent's continuation. A process-global // context — isolated from the parent's continuation. A process-global
// mutable slot would be clobbered at the detached closure's first // mutable slot would be clobbered at the detached closure's first
// await by this function's synchronous return path. See state.ts. // await by this function's synchronous return path. See state.ts.
await runWithWorkload(turnWorkload, async () => { try {
await runWithWorkload(turnWorkload, async () => {
for (let i = 0; i < commands.length; i++) { for (let i = 0; i < commands.length; i++) {
const cmd = commands[i]! const cmd = commands[i]!
const isFirst = i === 0 const isFirst = i === 0
if (cmd.autonomy?.runId) {
autonomyRunIds.add(cmd.autonomy.runId)
await markAutonomyRunRunning(cmd.autonomy.runId)
}
const result = await processUserInput({ const result = await processUserInput({
input: cmd.value, input: cmd.value,
preExpansionInput: cmd.preExpansionValue, preExpansionInput: cmd.preExpansionValue,
@@ -593,7 +605,26 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
params.onInputChange(nextInput) params.onInputChange(nextInput)
} }
} }
}) // end runWithWorkload — ALS context naturally scoped, no finally needed }) // end runWithWorkload — ALS context naturally scoped, no finally needed
for (const runId of autonomyRunIds) {
const nextCommands = await finalizeAutonomyRunCompleted({
runId,
priority: 'later',
workload: turnWorkload,
})
for (const nextCommand of nextCommands) {
enqueue(nextCommand)
}
}
} catch (error) {
for (const runId of autonomyRunIds) {
await finalizeAutonomyRunFailed({
runId,
error: String(error),
})
}
throw error
}
} finally { } finally {
// Safety net: release the guard reservation if processUserInput threw // Safety net: release the guard reservation if processUserInput threw
// or onQuery was skipped. No-op if onQuery already ran (guard is idle // or onQuery was skipped. No-op if onQuery already ran (guard is idle

26
src/utils/language.ts Normal file
View File

@@ -0,0 +1,26 @@
import { getGlobalConfig } from './config.js'
import { getSystemLocaleLanguage } from './intl.js'
export type PreferredLanguage = 'auto' | 'en' | 'zh'
export type ResolvedLanguage = 'en' | 'zh'
/**
* Resolve the effective display language.
* Priority: GlobalConfig.preferredLanguage → system locale → default 'en'.
*/
export function getResolvedLanguage(): ResolvedLanguage {
const pref = getGlobalConfig().preferredLanguage ?? 'auto'
if (pref === 'en' || pref === 'zh') return pref
const sysLang = getSystemLocaleLanguage()
return sysLang === 'zh' ? 'zh' : 'en'
}
const DISPLAY_NAMES: Record<string, string> = {
auto: 'Auto (follow system)',
en: 'English',
zh: '中文',
}
export function getLanguageDisplayName(lang: string): string {
return DISPLAY_NAMES[lang] ?? lang
}

View File

@@ -0,0 +1,78 @@
/**
* pipeMuteState — Master-side logical disconnect state.
*
* Tracks which slave pipes are currently "muted" (logically disconnected)
* and which have a temporary `/send` override active.
*
* This is local master state only — not part of the socket protocol.
*/
// ---------------------------------------------------------------------------
// Muted set: slaves whose business messages should be dropped by master
// ---------------------------------------------------------------------------
const _mutedPipes = new Set<string>()
export function setMasterMutedPipes(names: Iterable<string>): void {
_mutedPipes.clear()
for (const n of names) _mutedPipes.add(n)
}
export function isMasterPipeMuted(name: string): boolean {
return _mutedPipes.has(name)
}
export function removeMasterPipeMute(name: string): void {
_mutedPipes.delete(name)
}
export function clearMasterMutedPipes(): void {
_mutedPipes.clear()
}
// ---------------------------------------------------------------------------
// Send override set: slaves temporarily unmuted by explicit `/send` command.
// Override lasts until the slave emits `done` or `error`.
// ---------------------------------------------------------------------------
const _sendOverrides = new Set<string>()
let _sendOverrideVersion = 0
const _sendOverrideListeners = new Set<() => void>()
function emitSendOverrideChanged(): void {
_sendOverrideVersion += 1
for (const listener of _sendOverrideListeners) {
listener()
}
}
export function addSendOverride(name: string): void {
_sendOverrides.add(name)
emitSendOverrideChanged()
}
export function removeSendOverride(name: string): void {
if (_sendOverrides.delete(name)) {
emitSendOverrideChanged()
}
}
export function hasSendOverride(name: string): boolean {
return _sendOverrides.has(name)
}
export function clearSendOverrides(): void {
if (_sendOverrides.size > 0) {
_sendOverrides.clear()
emitSendOverrideChanged()
}
}
export function subscribeSendOverride(listener: () => void): () => void {
_sendOverrideListeners.add(listener)
return () => { _sendOverrideListeners.delete(listener) }
}
export function getSendOverrideVersion(): number {
return _sendOverrideVersion
}

View File

@@ -19,8 +19,22 @@ const pendingPipePermissions = new Map<string, PendingPipePermission>()
type PipeRelayFn = (message: PipeMessage) => void type PipeRelayFn = (message: PipeMessage) => void
let _pipeRelay: PipeRelayFn | null = null let _pipeRelay: PipeRelayFn | null = null
// Slave-side mute flag: when true, relayPipeMessage() and permission
// relay functions will short-circuit. Set by relay_mute / relay_unmute
// control messages from master.
let _relayMuted = false
export function setRelayMuted(muted: boolean): void {
_relayMuted = muted
}
export function isRelayMuted(): boolean {
return _relayMuted
}
export function setPipeRelay(fn: PipeRelayFn | null): void { export function setPipeRelay(fn: PipeRelayFn | null): void {
_pipeRelay = fn _pipeRelay = fn
if (!fn) _relayMuted = false // reset on disconnect
} }
export function getPipeRelay(): PipeRelayFn | null { export function getPipeRelay(): PipeRelayFn | null {
@@ -37,6 +51,7 @@ export function tryRelayPipePermissionRequest(
toolUseConfirm: ToolUseConfirm, toolUseConfirm: ToolUseConfirm,
onResponse: (payload: PipePermissionResponsePayload) => void, onResponse: (payload: PipePermissionResponsePayload) => void,
): string | null { ): string | null {
if (_relayMuted) return null
const send = getPipeSender() const send = getPipeSender()
if (!send) return null if (!send) return null
@@ -93,6 +108,7 @@ export function notifyPipePermissionCancel(
reason?: string, reason?: string,
): void { ): void {
if (!requestId) return if (!requestId) return
if (_relayMuted) return
const send = getPipeSender() const send = getPipeSender()
if (!send) return if (!send) return
send({ send({

View File

@@ -31,7 +31,8 @@ import { attachNdjsonFramer } from './ndjsonFramer.js'
* Message types exchanged over the pipe. * Message types exchanged over the pipe.
* *
* Basic: ping, pong * Basic: ping, pong
* Control: attach_request, attach_accept, attach_reject, detach * Control: attach_request, attach_accept, attach_reject, detach,
* relay_mute, relay_unmute
* Data (M→S): prompt — master sends user input to slave * Data (M→S): prompt — master sends user input to slave
* Data (S→M): stream — slave streams AI output fragments * Data (S→M): stream — slave streams AI output fragments
* tool_start — slave notifies tool execution start * tool_start — slave notifies tool execution start
@@ -49,6 +50,9 @@ export type PipeMessageType =
| 'attach_accept' | 'attach_accept'
| 'attach_reject' | 'attach_reject'
| 'detach' | 'detach'
// Mute control (master → slave): logical disconnect without dropping transport
| 'relay_mute'
| 'relay_unmute'
// Data flow (master → slave) // Data flow (master → slave)
| 'prompt' | 'prompt'
// Data flow (slave → master) // Data flow (slave → master)

View File

@@ -66,6 +66,11 @@ import { evictTerminalTask } from '../../utils/task/framework.js'
import { tokenCountWithEstimation } from '../../utils/tokens.js' import { tokenCountWithEstimation } from '../../utils/tokens.js'
import { createAbortController } from '../abortController.js' import { createAbortController } from '../abortController.js'
import { type AgentContext, runWithAgentContext } from '../agentContext.js' import { type AgentContext, runWithAgentContext } from '../agentContext.js'
import {
markAutonomyRunCompleted,
markAutonomyRunFailed,
markAutonomyRunRunning,
} from '../autonomyRuns.js'
import { count } from '../array.js' import { count } from '../array.js'
import { logForDebugging } from '../debug.js' import { logForDebugging } from '../debug.js'
import { cloneFileStateCache } from '../fileStateCache.js' import { cloneFileStateCache } from '../fileStateCache.js'
@@ -668,6 +673,7 @@ type WaitResult =
| { | {
type: 'new_message' type: 'new_message'
message: string message: string
autonomyRunId?: string
from: string from: string
color?: string color?: string
summary?: string summary?: string
@@ -710,7 +716,7 @@ async function waitForNextPromptOrShutdown(
task.type === 'in_process_teammate' && task.type === 'in_process_teammate' &&
task.pendingUserMessages.length > 0 task.pendingUserMessages.length > 0
) { ) {
const message = task.pendingUserMessages[0]! // Safe: checked length > 0 const pending = task.pendingUserMessages[0]! // Safe: checked length > 0
// Pop the message from the queue // Pop the message from the queue
setAppState(prev => { setAppState(prev => {
const prevTask = prev.tasks[taskId] const prevTask = prev.tasks[taskId]
@@ -731,9 +737,13 @@ async function waitForNextPromptOrShutdown(
logForDebugging( logForDebugging(
`[inProcessRunner] ${identity.agentName} found pending user message (poll #${pollCount})`, `[inProcessRunner] ${identity.agentName} found pending user message (poll #${pollCount})`,
) )
if (pending.autonomyRunId) {
await markAutonomyRunRunning(pending.autonomyRunId)
}
return { return {
type: 'new_message', type: 'new_message',
message, message: pending.message,
autonomyRunId: pending.autonomyRunId,
from: 'user', from: 'user',
} }
} }
@@ -1010,6 +1020,7 @@ export async function runInProcessTeammate(
description, description,
) )
let currentPrompt = wrappedInitialPrompt let currentPrompt = wrappedInitialPrompt
let currentAutonomyRunId: string | undefined
let shouldExit = false let shouldExit = false
// Try to claim an available task immediately so the UI can show activity // Try to claim an available task immediately so the UI can show activity
@@ -1306,6 +1317,13 @@ export async function runInProcessTeammate(
}), }),
setAppState, setAppState,
) )
if (currentAutonomyRunId) {
await markAutonomyRunFailed(currentAutonomyRunId, ERROR_MESSAGE_USER_ABORT)
currentAutonomyRunId = undefined
}
} else if (currentAutonomyRunId) {
await markAutonomyRunCompleted(currentAutonomyRunId)
currentAutonomyRunId = undefined
} }
// Check if already idle before updating (to skip duplicate notification) // Check if already idle before updating (to skip duplicate notification)
@@ -1378,6 +1396,7 @@ export async function runInProcessTeammate(
createUserMessage({ content: currentPrompt }), createUserMessage({ content: currentPrompt }),
setAppState, setAppState,
) )
currentAutonomyRunId = undefined
break break
case 'new_message': case 'new_message':
@@ -1389,6 +1408,7 @@ export async function runInProcessTeammate(
// Messages from other teammates get XML wrapper for identification // Messages from other teammates get XML wrapper for identification
if (waitResult.from === 'user') { if (waitResult.from === 'user') {
currentPrompt = waitResult.message currentPrompt = waitResult.message
currentAutonomyRunId = waitResult.autonomyRunId
} else { } else {
currentPrompt = formatAsTeammateMessage( currentPrompt = formatAsTeammateMessage(
waitResult.from, waitResult.from,
@@ -1404,6 +1424,7 @@ export async function runInProcessTeammate(
createUserMessage({ content: currentPrompt }), createUserMessage({ content: currentPrompt }),
setAppState, setAppState,
) )
currentAutonomyRunId = undefined
} }
break break
@@ -1459,7 +1480,6 @@ export async function runInProcessTeammate(
summary: identity.agentId, summary: identity.agentId,
}) })
} }
unregisterPerfettoAgent(identity.agentId) unregisterPerfettoAgent(identity.agentId)
return { success: true, messages: allMessages } return { success: true, messages: allMessages }
} catch (error) { } catch (error) {
@@ -1511,6 +1531,9 @@ export async function runInProcessTeammate(
summary: identity.agentId, summary: identity.agentId,
}) })
} }
if (currentAutonomyRunId) {
await markAutonomyRunFailed(currentAutonomyRunId, errorMessage)
}
// Send idle notification with failure via file-based mailbox // Send idle notification with failure via file-based mailbox
await sendIdleNotification( await sendIdleNotification(

View File

@@ -24,6 +24,7 @@ import type {
TeammateIdentity, TeammateIdentity,
} from '../../tasks/InProcessTeammateTask/types.js' } from '../../tasks/InProcessTeammateTask/types.js'
import { createAbortController } from '../abortController.js' import { createAbortController } from '../abortController.js'
import { markAutonomyRunFailed } from '../autonomyRuns.js'
import { formatAgentId } from '../agentId.js' import { formatAgentId } from '../agentId.js'
import { registerCleanup } from '../cleanupRegistry.js' import { registerCleanup } from '../cleanupRegistry.js'
import { logForDebugging } from '../debug.js' import { logForDebugging } from '../debug.js'
@@ -304,6 +305,15 @@ export function killInProcessTeammate(
} }
if (killed) { if (killed) {
const pendingAutonomyRunIds = teammateTask.pendingUserMessages
.map(message => message.autonomyRunId)
.filter((runId): runId is string => Boolean(runId))
for (const runId of pendingAutonomyRunIds) {
void markAutonomyRunFailed(
runId,
`Teammate ${agentId ?? taskId} was stopped before it could consume the queued autonomy prompt.`,
)
}
void evictTaskOutput(taskId) void evictTaskOutput(taskId)
// notified:true was pre-set so no XML notification fires; close the SDK // notified:true was pre-set so no XML notification fires; close the SDK
// task_started bookend directly. The in-process runner's own // task_started bookend directly. The in-process runner's own

View File

@@ -1,3 +1,78 @@
// Auto-generated stub — replace with real implementation import { feature } from 'bun:bundle'
export const shouldGenerateTaskSummary: () => boolean = () => false; import { isBgSession, updateSessionActivity } from './concurrentSessions.js'
export const maybeGenerateTaskSummary: (options: Record<string, unknown>) => void = () => {}; import { logForDebugging } from './debug.js'
/**
* Minimum interval between task summary generations (ms).
* Prevents excessive updates during rapid tool-call loops.
*/
const SUMMARY_INTERVAL_MS = 30_000
let lastSummaryTime = 0
/**
* Whether a task summary should be generated this turn.
* Only generates in bg sessions, and rate-limits to avoid churn.
*/
export function shouldGenerateTaskSummary(): boolean {
if (!feature('BG_SESSIONS')) return false
if (!isBgSession()) return false
const now = Date.now()
return now - lastSummaryTime >= SUMMARY_INTERVAL_MS
}
/**
* Generate a task summary from the current turn's messages and push it
* to the session registry so `claude ps` can display live status.
*
* Fire-and-forget from query.ts — errors are logged, never thrown.
*/
export function maybeGenerateTaskSummary(
options: Record<string, unknown>,
): void {
lastSummaryTime = Date.now()
try {
const messages = options.forkContextMessages as
| Array<{
type: string
message?: { content?: unknown }
}>
| undefined
if (!messages || messages.length === 0) return
// Extract a short status from the most recent assistant message
const lastAssistant = [...messages]
.reverse()
.find(m => m.type === 'assistant')
let status = 'working'
let waitingFor: string | undefined
if (lastAssistant?.message?.content) {
const content = lastAssistant.message.content
// Check if last block is tool_use
if (Array.isArray(content)) {
const lastBlock = content[content.length - 1] as
| Record<string, unknown>
| undefined
if (lastBlock?.type === 'tool_use') {
status = 'busy'
waitingFor = `tool: ${lastBlock.name || 'unknown'}`
}
}
}
// Fire-and-forget update to session registry
void updateSessionActivity({
status: status as 'busy' | 'idle',
waitingFor,
}).catch(err => {
logForDebugging(`[taskSummary] updateSessionActivity failed: ${err}`)
})
} catch (err) {
logForDebugging(`[taskSummary] error: ${err}`)
}
}

View File

@@ -1,15 +1,13 @@
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'
import { tmpdir } from "node:os"; import { tmpdir } from 'node:os'
import { join } from "node:path"; import { dirname, join } from 'node:path'
export async function createTempDir( export async function createTempDir(prefix = 'claude-test-'): Promise<string> {
prefix = "claude-test-", return mkdtemp(join(tmpdir(), prefix))
): Promise<string> {
return mkdtemp(join(tmpdir(), prefix));
} }
export async function cleanupTempDir(dir: string): Promise<void> { export async function cleanupTempDir(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true })
} }
export async function writeTempFile( export async function writeTempFile(
@@ -17,16 +15,18 @@ export async function writeTempFile(
name: string, name: string,
content: string, content: string,
): Promise<string> { ): Promise<string> {
const path = join(dir, name); const path = join(dir, name)
await writeFile(path, content, "utf-8"); const parentDir = dirname(path)
return path; await mkdir(parentDir, { recursive: true })
await writeFile(path, content, 'utf-8')
return path
} }
export async function createTempSubdir( export async function createTempSubdir(
dir: string, dir: string,
name: string, name: string,
): Promise<string> { ): Promise<string> {
const path = join(dir, name); const path = join(dir, name)
await mkdir(path, { recursive: true }); await mkdir(path, { recursive: true })
return path; return path
} }

View File

@@ -14,9 +14,13 @@
"paths": { "paths": {
"src/*": ["./src/*"], "src/*": ["./src/*"],
"@claude-code-best/builtin-tools/*": ["./packages/builtin-tools/src/*"], "@claude-code-best/builtin-tools/*": ["./packages/builtin-tools/src/*"],
"@claude-code-best/builtin-tools": ["./packages/builtin-tools/src/index.ts"] "@claude-code-best/builtin-tools": ["./packages/builtin-tools/src/index.ts"],
"@claude-code-best/mcp-client/*": ["./packages/mcp-client/src/*"],
"@claude-code-best/mcp-client": ["./packages/mcp-client/src/index.ts"],
"@claude-code-best/agent-tools/*": ["./packages/agent-tools/src/*"],
"@claude-code-best/agent-tools": ["./packages/agent-tools/src/index.ts"]
} }
}, },
"include": ["src/**/*.ts", "src/**/*.tsx", "packages/builtin-tools/src/**/*.ts", "packages/builtin-tools/src/**/*.tsx"], "include": ["src/**/*.ts", "src/**/*.tsx", "packages/builtin-tools/src/**/*.ts", "packages/builtin-tools/src/**/*.tsx", "packages/mcp-client/src/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules", "packages/mcp-client/src/__tests__"]
} }