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
167 changed files with 9944 additions and 7856 deletions

View File

@@ -27,4 +27,4 @@ jobs:
run: bun test run: bun test
- name: Build - name: Build
run: bun run build:vite run: bun run build

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

@@ -55,8 +55,6 @@ bun run health
# Check unused exports # Check unused exports
bun run check:unused bun run check:unused
bun run typecheck
# Remote Control Server # Remote Control Server
bun run rcs bun run rcs

View File

@@ -17,14 +17,13 @@
| 特性 | 说明 | 文档 | | 特性 | 说明 | 文档 |
|------|------|------| |------|------|------|
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) | | **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/pipes-and-lan) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
| ACP 协议一等一支持 | 支持接入 Zed、Cursor 等 IDE支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
| Remote Control 私有部署 | Docker 自托管 RCS + Web UI | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) | | Remote Control 私有部署 | Docker 自托管 RCS + Web UI | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) | | /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
| Web Search | 内置网页搜索工具 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) | | Web Search | 内置网页搜索工具 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
| 自定义模型供应商 | 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

@@ -30,8 +30,6 @@ const DEFAULT_BUILD_FEATURES = [
'ULTRAPLAN', 'ULTRAPLAN',
// P2: daemon + remote control server // P2: daemon + remote control server
'DAEMON', 'DAEMON',
// ACP (Agent Client Protocol) agent mode
'ACP',
// PR-package restored features // PR-package restored features
'WORKFLOW_SCRIPTS', 'WORKFLOW_SCRIPTS',
'HISTORY_SNIP', 'HISTORY_SNIP',
@@ -42,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',
@@ -90,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)
@@ -140,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')

106
bun.lock
View File

@@ -5,9 +5,7 @@
"": { "": {
"name": "claude-code-best", "name": "claude-code-best",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.19.0",
"@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",
@@ -59,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",
@@ -119,7 +116,6 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-compiler-runtime": "^1.0.0", "react-compiler-runtime": "^1.0.0",
"react-reconciler": "^0.33.0", "react-reconciler": "^0.33.0",
"rollup": "^4.60.1",
"semver": "^7.7.4", "semver": "^7.7.4",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"shell-quote": "^1.8.3", "shell-quote": "^1.8.3",
@@ -134,11 +130,11 @@
"undici": "^7.24.6", "undici": "^7.24.6",
"url-handler-napi": "workspace:*", "url-handler-napi": "workspace:*",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"vite": "^8.0.8",
"vscode-jsonrpc": "^8.2.1", "vscode-jsonrpc": "^8.2.1",
"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",
@@ -257,8 +253,6 @@
}, },
}, },
"packages": { "packages": {
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.19.0", "https://registry.npmmirror.com/@agentclientprotocol/sdk/-/sdk-0.19.0.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-U9I8ws9WTOk6jCBAWpXefGSDgVXn14/kV6HFzwWGcstQ02mOQgClMAROHmoIn9GqZbDBDEOkdIbP4P4TEMQdug=="],
"@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "https://registry.npmmirror.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "https://registry.npmmirror.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="],
"@ant/claude-for-chrome-mcp": ["@ant/claude-for-chrome-mcp@workspace:packages/@ant/claude-for-chrome-mcp"], "@ant/claude-for-chrome-mcp": ["@ant/claude-for-chrome-mcp@workspace:packages/@ant/claude-for-chrome-mcp"],
@@ -449,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"],
@@ -513,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=="],
@@ -847,36 +841,6 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.0.tgz", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.0.tgz", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
@@ -1135,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=="],
@@ -1169,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=="],
@@ -1283,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=="],
@@ -1341,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=="],
@@ -1357,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=="],
@@ -1377,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=="],
@@ -1519,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=="],
@@ -1541,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=="],
@@ -1781,15 +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=="],
"rolldown": ["rolldown@1.0.0-rc.15", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
"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=="],
@@ -1801,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=="],
@@ -1809,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=="],
@@ -1819,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=="],
@@ -1889,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=="],
@@ -1931,7 +1893,7 @@
"vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vite": ["vite@8.0.8", "https://registry.npmmirror.com/vite/-/vite-8.0.8.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], "vite": ["vite@6.4.2", "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
@@ -1999,8 +1961,6 @@
"@anthropic/remote-control-server/typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "@anthropic/remote-control-server/typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@anthropic/remote-control-server/vite": ["vite@6.4.2", "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
"@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "https://registry.npmmirror.com/@aws-crypto/util/-/util-5.2.0.tgz", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], "@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "https://registry.npmmirror.com/@aws-crypto/util/-/util-5.2.0.tgz", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-crypto/sha256-browser/@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "https://registry.npmmirror.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], "@aws-crypto/sha256-browser/@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "https://registry.npmmirror.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
@@ -2167,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=="],
@@ -2335,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=="],
@@ -2353,10 +2313,6 @@
"qrcode/yargs": ["yargs@15.4.1", "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], "qrcode/yargs": ["yargs@15.4.1", "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.124.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.124.0.tgz", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
"xss/commander": ["commander@2.20.3", "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "xss/commander": ["commander@2.20.3", "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1,189 +0,0 @@
# ACP (Agent Client Protocol) — Zed / IDE 集成
> Feature Flag: `FEATURE_ACP=1`build 和 dev 模式默认启用)
> 实现状态:可用(支持 Zed、Cursor 等 ACP 客户端)
> 源码目录:`src/services/acp/`
## 一、功能概述
ACP (Agent Client Protocol) 是一种标准化的 stdio 协议,允许 IDE 和编辑器通过 stdin/stdout 的 NDJSON 流驱动 AI Agent。CCB 实现了完整的 ACP agent 端,可以被 Zed、Cursor 等支持 ACP 的客户端直接调用。
### 核心特性
- **会话管理**:新建 / 恢复 / 加载 / 分叉 / 关闭会话
- **历史回放**:恢复会话时自动加载并回放对话历史
- **权限桥接**ACP 客户端的权限决策映射到 CCB 的工具权限系统
- **斜杠命令 & Skills**:加载真实命令列表,支持 `/commit``/review` 等 prompt 型 skill
- **Context Window 跟踪**:精确的 usage_update含 model prefix matching
- **Prompt 排队**:支持连续发送多条 prompt自动排队处理
- **模式切换**auto / default / acceptEdits / plan / dontAsk / bypassPermissions
- **模型切换**:运行时切换 AI 模型
## 二、架构
```
┌──────────────┐ NDJSON/stdio ┌──────────────────┐
│ Zed / IDE │ ◄────────────────► │ CCB ACP Agent │
│ (Client) │ stdin / stdout │ (Agent) │
└──────────────┘ │ │
│ entry.ts │ ← stdio → NDJSON stream
│ agent.ts │ ← ACP protocol handler
│ bridge.ts │ ← SDKMessage → ACP SessionUpdate
│ permissions.ts │ ← 权限桥接
│ utils.ts │ ← 通用工具
│ │
│ QueryEngine │ ← 内部查询引擎
└──────────────────┘
```
### 文件职责
| 文件 | 职责 |
|------|------|
| `entry.ts` | 入口,创建 stdio → NDJSON stream启动 `AgentSideConnection` |
| `agent.ts` | 实现 ACP `Agent` 接口:会话 CRUD、prompt、cancel、模式/模型切换 |
| `bridge.ts` | `SDKMessage` → ACP `SessionUpdate` 转换:文本/思考/工具/用量/编辑 diff |
| `permissions.ts` | ACP `requestPermission()` → CCB `CanUseToolFn` 桥接 |
| `utils.ts` | Pushable、流转换、权限模式解析、session fingerprint、路径显示 |
## 三、配置 Zed 编辑器
### 3.1 Zed settings.json 配置
打开 Zed 的 `settings.json``Cmd+,` → Open Settings添加 `agent_servers` 配置:
```json
{
"agent_servers": {
"ccb": {
"type": "custom",
"command": "ccb",
"args": ["--acp"]
}
}
}
```
### 3.3 API 认证配置
CCB 的 ACP agent 在启动时会自动加载 `settings.json` 中的环境变量(`ANTHROPIC_BASE_URL``ANTHROPIC_AUTH_TOKEN` 等)。确保已通过 `/login` 配置好 API 供应商。
也可通过环境变量传入:
```json
{
"agent_servers": {
"claude-code": {
"command": "ccb",
"args": ["--acp"],
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com/v1",
"ANTHROPIC_AUTH_TOKEN": "sk-xxx"
}
}
}
}
```
### 3.4 在 Zed 中使用
1. 配置完成后重启 Zed
2. 打开任意项目目录
3.`Cmd+'`macOS`Ctrl+'`Linux打开 Agent Panel
4. 在 Agent Panel 顶部的下拉菜单中选择 **claude-code**
5. 开始对话
### 3.5 功能说明
| 功能 | 操作 |
|------|------|
| 对话 | 在 Agent Panel 中直接输入消息 |
| 斜杠命令 | 输入 `/` 查看可用 skills 列表(如 `/commit``/review` |
| 工具权限 | 弹出权限请求时选择 Allow / Reject / Always Allow |
| 模式切换 | 通过 Agent Panel 的设置菜单切换 auto/default/plan 等模式 |
| 模型切换 | 通过 Agent Panel 的设置菜单切换 AI 模型 |
| 会话恢复 | 关闭重开 Zed 后,之前的会话可自动恢复(含历史消息) |
## 四、配置其他 ACP 客户端
ACP 是开放协议,任何支持 ACP 的客户端都可以连接 CCB。通用配置模式
```
命令: ccb --acp
参数: ["--acp"]
通信: stdin/stdout NDJSON
协议版本: ACP v1
```
### 4.1 Cursor
在 Cursor 的设置中配置 MCP / Agent Server使用同样的 `ccb --acp` 命令。
### 4.2 自定义客户端
使用 `@agentclientprotocol/sdk` 可以快速构建 ACP 客户端:
```typescript
import { ClientSideConnection, ndJsonStream } from '@agentclientprotocol/sdk'
// 创建连接(将 ccb --acp 作为子进程启动)
const child = spawn('ccb', ['--acp'])
const stream = ndJsonStream(
Writable.toWeb(child.stdin),
Readable.toWeb(child.stdout),
)
const client = new ClientSideConnection(stream)
// 初始化
await client.initialize({ clientCapabilities: {} })
// 创建会话
const { sessionId } = await client.newSession({
cwd: '/path/to/project',
})
// 发送 prompt
const response = await client.prompt({
sessionId,
prompt: [{ type: 'text', text: 'Hello, explain this project' }],
})
// 监听 session 更新
client.on('sessionUpdate', (update) => {
console.log('Update:', update)
})
```
## 五、ACP 协议支持矩阵
| 方法 | 状态 | 说明 |
|------|------|------|
| `initialize` | ✅ | 返回 agent 信息和能力 |
| `authenticate` | ✅ | 无需认证(自托管) |
| `newSession` | ✅ | 创建新会话 |
| `resumeSession` | ✅ | 恢复已有会话(含历史回放) |
| `loadSession` | ✅ | 加载指定会话(含历史回放) |
| `listSessions` | ✅ | 列出可用会话 |
| `forkSession` | ✅ | 分叉会话 |
| `closeSession` | ✅ | 关闭会话 |
| `prompt` | ✅ | 发送消息,支持排队 |
| `cancel` | ✅ | 取消当前/排队的 prompt |
| `setSessionMode` | ✅ | 切换权限模式 |
| `setSessionModel` | ✅ | 切换 AI 模型 |
| `setSessionConfigOption` | ✅ | 动态修改配置 |
### SessionUpdate 类型
| 类型 | 状态 | 说明 |
|------|------|------|
| `agent_message_chunk` | ✅ | 助手文本消息 |
| `agent_thought_chunk` | ✅ | 思考/推理内容 |
| `user_message_chunk` | ✅ | 用户消息(历史回放) |
| `tool_call` | ✅ | 工具调用开始 |
| `tool_call_update` | ✅ | 工具调用结果/状态更新 |
| `usage_update` | ✅ | token 用量 + context window |
| `plan` | ✅ | TodoWrite → plan entries |
| `available_commands_update` | ✅ | 斜杠命令 & skills 列表 |
| `current_mode_update` | ✅ | 模式切换通知 |
| `config_option_update` | ✅ | 配置更新通知 |

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

@@ -138,19 +138,13 @@ bun run dist/cli.js
/remote-control /remote-control
``` ```
环境型 Remote Control例如 `claude remote-control` 子命令)会向 RCS 注册环境,注册成功后在终端显示连接 URL CLI 会向 RCS 注册环境,注册成功后在终端显示连接 URL
``` ```
https://rcs.example.com/code?bridge=<environmentId> https://rcs.example.com/code?bridge=<environmentId>
``` ```
交互式 REPL 方式(`--remote-control``/remote-control`)在某些桥接模式下也可能直接给出会话 URL 同时支持 QR 码扫码打开。该 URL 即 Web UI 控制面板入口,在浏览器中打开即可远程操控当前会话。
```
https://rcs.example.com/code/session_<id>
```
两种 URL 都可以直接在浏览器打开并远程操控当前会话;只有 environment 模式才会出现在 Web UI 的环境列表中。
若已连接,再次执行 `/remote-control` 会显示对话框,包含以下选项: 若已连接,再次执行 `/remote-control` 会显示对话框,包含以下选项:
- **Disconnect this session** — 断开远程连接 - **Disconnect this session** — 断开远程连接
@@ -171,7 +165,7 @@ claude bridge
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。功能: 通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。功能:
- 查看已注册的运行环境environment 模式) - 查看已注册的运行环境
- 创建和管理会话 - 创建和管理会话
- 实时查看对话消息和工具调用 - 实时查看对话消息和工具调用
- 审批 Claude Code 的工具权限请求 - 审批 Claude Code 的工具权限请求
@@ -281,3 +275,4 @@ curl https://rcs.example.com/health
| 依赖 | claude.ai 订阅 + OAuth | 仅需 API Key | | 依赖 | claude.ai 订阅 + OAuth | 仅需 API Key |
自托管模式的核心优势是:设置 `CLAUDE_BRIDGE_BASE_URL` 后,代码自动调用 `isSelfHostedBridge()` 返回 `true`,跳过所有 GrowthBook 和订阅检查,无需 claude.ai 账户即可使用。 自托管模式的核心优势是:设置 `CLAUDE_BRIDGE_BASE_URL` 后,代码自动调用 `isSelfHostedBridge()` 返回 `true`,跳过所有 GrowthBook 和订阅检查,无需 claude.ai 账户即可使用。

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.7", "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>",
@@ -40,9 +40,6 @@
], ],
"scripts": { "scripts": {
"build": "bun run build.ts", "build": "bun run build.ts",
"build:vite": "vite build && bun run scripts/post-build.ts",
"build:vite:only": "vite build",
"build:bun": "bun run build.ts",
"dev": "bun run scripts/dev.ts", "dev": "bun run scripts/dev.ts",
"dev:inspect": "bun run scripts/dev-debug.ts", "dev:inspect": "bun run scripts/dev-debug.ts",
"prepublishOnly": "bun run build", "prepublishOnly": "bun run build",
@@ -55,15 +52,16 @@
"health": "bun run scripts/health-check.ts", "health": "bun run scripts/health-check.ts",
"postinstall": "node scripts/postinstall.cjs && node scripts/setup-chrome-mcp.mjs", "postinstall": "node scripts/postinstall.cjs && node scripts/setup-chrome-mcp.mjs",
"docs:dev": "npx mintlify dev", "docs:dev": "npx mintlify dev",
"typecheck": "tsc --noEmit",
"rcs": "bun run scripts/rcs.ts" "rcs": "bun run scripts/rcs.ts"
}, },
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.19.0", "@claude-code-best/mcp-chrome-bridge": "^2.0.7"
"@claude-code-best/mcp-chrome-bridge": "^2.0.7",
"ws": "^8.20.0"
}, },
"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:*",
@@ -77,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",
@@ -84,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",
@@ -113,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",
@@ -173,7 +166,6 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-compiler-runtime": "^1.0.0", "react-compiler-runtime": "^1.0.0",
"react-reconciler": "^0.33.0", "react-reconciler": "^0.33.0",
"rollup": "^4.60.1",
"semver": "^7.7.4", "semver": "^7.7.4",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"shell-quote": "^1.8.3", "shell-quote": "^1.8.3",
@@ -188,11 +180,11 @@
"undici": "^7.24.6", "undici": "^7.24.6",
"url-handler-napi": "workspace:*", "url-handler-napi": "workspace:*",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"vite": "^8.0.8",
"vscode-jsonrpc": "^8.2.1", "vscode-jsonrpc": "^8.2.1",
"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

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

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

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

View File

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

View File

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

View File

@@ -274,9 +274,4 @@ export const screenshot: ScreenshotAPI = {
if (displayId !== undefined) args.push('-D', String(displayId)) if (displayId !== undefined) args.push('-D', String(displayId))
return captureScreenToBase64(args) return captureScreenToBase64(args)
}, },
captureWindowTarget(_titleOrHwnd: string | number): ScreenshotResult | null {
// Window capture not supported on macOS via this backend
return null
},
} }

View File

@@ -275,9 +275,4 @@ export const screenshot: ScreenshotAPI = {
return { base64: '', width: 0, height: 0 } return { base64: '', width: 0, height: 0 }
} }
}, },
captureWindowTarget(_titleOrHwnd: string | number): ScreenshotResult | null {
// Window capture not supported on Linux via this backend
return null
},
} }

View File

@@ -76,7 +76,6 @@ export interface ScreenshotAPI {
x: number, y: number, w: number, h: number, x: number, y: number, w: number, h: number,
outW: number, outH: number, quality: number, displayId?: number, outW: number, outH: number, quality: number, displayId?: number,
): Promise<ScreenshotResult> ): Promise<ScreenshotResult>
captureWindowTarget(titleOrHwnd: string | number): ScreenshotResult | null
} }
export interface SwiftBackend { export interface SwiftBackend {

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import type { CoreTool, Tool, Tools, AnyObject, ToolResult, ValidationResult, PermissionResult } from '@claude-code-best/agent-tools' import type { CoreTool, Tool, Tools, AnyObject, ToolResult, ValidationResult, PermissionResult } from '@claude-code-best/agent-tools'
import type { Tool as HostTool } from '../../../../src/Tool.js' import type { Tool as HostTool } from '../../src/Tool.js'
describe('agent-tools compatibility', () => { describe('agent-tools compatibility', () => {
test('CoreTool structural compatibility with host Tool', () => { test('CoreTool structural compatibility with host Tool', () => {
@@ -27,7 +27,7 @@ describe('agent-tools compatibility', () => {
} }
// This assignment should work if HostTool structurally extends CoreTool // This assignment should work if HostTool structurally extends CoreTool
const coreTool: CoreTool = mockHostTool as unknown as CoreTool const coreTool: CoreTool = mockHostTool as CoreTool
expect(coreTool.name).toBe('test') expect(coreTool.name).toBe('test')
expect(coreTool.isEnabled()).toBe(true) expect(coreTool.isEnabled()).toBe(true)
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -72,18 +72,18 @@ describe("detectColorMode", () => {
describe("detectLanguage", () => { describe("detectLanguage", () => {
test("detects language from file extension", () => { test("detects language from file extension", () => {
expect(detectLanguage("index.ts", null)).toBe("ts"); expect(detectLanguage("index.ts")).toBe("ts");
expect(detectLanguage("main.py", null)).toBe("py"); expect(detectLanguage("main.py")).toBe("py");
expect(detectLanguage("style.css", null)).toBe("css"); expect(detectLanguage("style.css")).toBe("css");
}); });
test("detects language from known filenames", () => { test("detects language from known filenames", () => {
expect(detectLanguage("Makefile", null)).toBe("makefile"); expect(detectLanguage("Makefile")).toBe("makefile");
expect(detectLanguage("Dockerfile", null)).toBe("dockerfile"); expect(detectLanguage("Dockerfile")).toBe("dockerfile");
}); });
test("returns null for unknown extensions", () => { test("returns null for unknown extensions", () => {
expect(detectLanguage("file.xyz123", null)).toBeNull(); expect(detectLanguage("file.xyz123")).toBeNull();
}); });
}); });

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ describe('InProcessTransport', () => {
let received: JSONRPCMessage | null = null let received: JSONRPCMessage | null = null
client.onmessage = (msg) => { received = msg } client.onmessage = (msg) => { received = msg }
await server.send({ jsonrpc: '2.0', result: 42, id: 1 } as any) await server.send({ jsonrpc: '2.0', result: 42, id: 1 })
await new Promise(resolve => setTimeout(resolve, 10)) await new Promise(resolve => setTimeout(resolve, 10))

View File

@@ -57,9 +57,9 @@ describe('discoverTools', () => {
expect(tool.name).toBe('mcp__my-server__search') expect(tool.name).toBe('mcp__my-server__search')
expect(tool.mcpInfo).toEqual({ serverName: 'my-server', toolName: 'search' }) expect(tool.mcpInfo).toEqual({ serverName: 'my-server', toolName: 'search' })
expect(tool.isMcp).toBe(true) expect(tool.isMcp).toBe(true)
expect(tool.isReadOnly({} as any)).toBe(true) expect(tool.isReadOnly()).toBe(true)
expect(tool.userFacingName(undefined)).toBe('Search Items') expect(tool.userFacingName()).toBe('Search Items')
expect(await tool.description({} as any, { isNonInteractiveSession: false, toolPermissionContext: {}, tools: [] })).toBe('Search for items') expect(await tool.description()).toBe('Search for items')
}) })
test('respects skipPrefix option', async () => { test('respects skipPrefix option', async () => {

View File

@@ -65,7 +65,7 @@ describe('createMcpManager', () => {
const result = await manager.connect('test-server', { command: 'npx', args: [] }) const result = await manager.connect('test-server', { command: 'npx', args: [] })
expect(result.type).toBe('connected') expect(result.type).toBe('connected')
expect(connectedEvent as unknown as string).toBe('test-server') expect(connectedEvent).toBe('test-server')
}) })
test('disconnect calls cleanup and emits disconnected', async () => { test('disconnect calls cleanup and emits disconnected', async () => {

View File

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

View File

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

View File

@@ -25,18 +25,17 @@ import {
storeUpdateSession, storeUpdateSession,
storeGetEnvironment, storeGetEnvironment,
storeGetSession, storeGetSession,
storeListActiveEnvironments,
} from "../store"; } from "../store";
import { getEventBus, getAllEventBuses, removeEventBus } from "../transport/event-bus";
import { runDisconnectMonitorSweep } from "../services/disconnect-monitor";
describe("Disconnect Monitor Logic", () => { describe("Disconnect Monitor Logic", () => {
beforeEach(() => { beforeEach(() => {
storeReset(); storeReset();
for (const [key] of getAllEventBuses()) {
removeEventBus(key);
}
}); });
// Test the logic directly rather than the interval-based monitor
// to avoid long-running tests with timers
test("environment times out when lastPollAt is too old", () => { test("environment times out when lastPollAt is too old", () => {
const env = storeCreateEnvironment({ secret: "s" }); const env = storeCreateEnvironment({ secret: "s" });
const timeoutMs = 300 * 1000; // 5 minutes const timeoutMs = 300 * 1000; // 5 minutes
@@ -45,7 +44,14 @@ describe("Disconnect Monitor Logic", () => {
const oldDate = new Date(Date.now() - timeoutMs - 60000); const oldDate = new Date(Date.now() - timeoutMs - 60000);
storeUpdateEnvironment(env.id, { lastPollAt: oldDate }); storeUpdateEnvironment(env.id, { lastPollAt: oldDate });
runDisconnectMonitorSweep(); // Check the timeout logic (same as in disconnect-monitor.ts)
const now = Date.now();
const envs = storeListActiveEnvironments();
for (const e of envs) {
if (e.lastPollAt && now - e.lastPollAt.getTime() > timeoutMs) {
storeUpdateEnvironment(e.id, { status: "disconnected" });
}
}
const updated = storeGetEnvironment(env.id); const updated = storeGetEnvironment(env.id);
expect(updated?.status).toBe("disconnected"); expect(updated?.status).toBe("disconnected");
@@ -53,56 +59,43 @@ describe("Disconnect Monitor Logic", () => {
test("environment stays active when lastPollAt is recent", () => { test("environment stays active when lastPollAt is recent", () => {
const env = storeCreateEnvironment({ secret: "s" }); const env = storeCreateEnvironment({ secret: "s" });
runDisconnectMonitorSweep(); const timeoutMs = 300 * 1000;
// lastPollAt is recent (just created)
const now = Date.now();
const envs = storeListActiveEnvironments();
for (const e of envs) {
if (e.lastPollAt && now - e.lastPollAt.getTime() > timeoutMs) {
storeUpdateEnvironment(e.id, { status: "disconnected" });
}
}
const updated = storeGetEnvironment(env.id); const updated = storeGetEnvironment(env.id);
expect(updated?.status).toBe("active"); expect(updated?.status).toBe("active");
}); });
test("session becomes inactive when updatedAt is too old", () => { test("session becomes inactive when updatedAt is too old", () => {
const session = storeCreateSession({}); const session = storeCreateSession({ status: "idle" });
storeUpdateSession(session.id, { status: "running" }); storeUpdateSession(session.id, { status: "running" });
const timeoutMs = 300 * 1000 * 2; // 2x disconnect timeout
// Simulate updatedAt being older than 2x timeout
// We can't directly set updatedAt, but we can verify the logic
// by checking that recently updated sessions are not marked inactive
const now = Date.now();
const rec = storeGetSession(session.id); const rec = storeGetSession(session.id);
expect(rec).toBeTruthy(); // Session was just updated, should not be inactive
if (!rec) return; expect(rec?.status).toBe("running");
expect(now - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000);
runDisconnectMonitorSweep();
const updated = storeGetSession(session.id);
expect(updated?.status).toBe("inactive");
}); });
test("session stays running when recently updated", () => { test("session stays running when recently updated", () => {
const session = storeCreateSession({}); const session = storeCreateSession({});
storeUpdateSession(session.id, { status: "running" }); storeUpdateSession(session.id, { status: "running" });
runDisconnectMonitorSweep(); const timeoutMs = 300 * 1000 * 2;
const updated = storeGetSession(session.id);
expect(updated?.status).toBe("running");
});
test("session timeout publishes an inactive session_status event", () => {
const session = storeCreateSession({});
storeUpdateSession(session.id, { status: "idle" });
const rec = storeGetSession(session.id); const rec = storeGetSession(session.id);
expect(rec).toBeTruthy(); expect(rec?.status).toBe("running");
if (!rec) return; expect(Date.now() - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000);
const bus = getEventBus(session.id);
const events: Array<{ type: string; payload: { status?: string } }> = [];
bus.subscribe((event) => {
events.push({ type: event.type, payload: event.payload as { status?: string } });
});
runDisconnectMonitorSweep();
expect(events).toContainEqual({
type: "session_status",
payload: { status: "inactive" },
});
}); });
}); });

View File

@@ -19,18 +19,16 @@ mock.module("../config", () => ({
import { Hono } from "hono"; import { Hono } from "hono";
import { storeReset, storeCreateSession, storeCreateEnvironment, storeBindSession } from "../store"; import { storeReset, storeCreateSession, storeCreateEnvironment, storeBindSession } from "../store";
import { removeEventBus, getAllEventBuses, getEventBus } from "../transport/event-bus"; import { removeEventBus, getAllEventBuses } from "../transport/event-bus";
import { issueToken } from "../auth/token"; import { issueToken } from "../auth/token";
import { publishSessionEvent } from "../services/transport";
// Import route modules // Import route modules
import v1Sessions from "../routes/v1/sessions"; import v1Sessions from "../routes/v1/sessions";
import v1Environments from "../routes/v1/environments"; import v1Environments from "../routes/v1/environments";
import v1EnvironmentsWork from "../routes/v1/environments.work"; import v1EnvironmentsWork from "../routes/v1/environments.work";
import v1SessionIngress, { websocket as sessionIngressWebsocket } from "../routes/v1/session-ingress"; import v1SessionIngress from "../routes/v1/session-ingress";
import v2CodeSessions from "../routes/v2/code-sessions"; import v2CodeSessions from "../routes/v2/code-sessions";
import v2Worker from "../routes/v2/worker"; import v2Worker from "../routes/v2/worker";
import v2WorkerEventsStream from "../routes/v2/worker-events-stream";
import v2WorkerEvents from "../routes/v2/worker-events"; import v2WorkerEvents from "../routes/v2/worker-events";
import webAuth from "../routes/web/auth"; import webAuth from "../routes/web/auth";
import webSessions from "../routes/web/sessions"; import webSessions from "../routes/web/sessions";
@@ -45,7 +43,6 @@ function createApp() {
app.route("/v2/session_ingress", v1SessionIngress); app.route("/v2/session_ingress", v1SessionIngress);
app.route("/v1/code/sessions", v2CodeSessions); app.route("/v1/code/sessions", v2CodeSessions);
app.route("/v1/code/sessions", v2Worker); app.route("/v1/code/sessions", v2Worker);
app.route("/v1/code/sessions", v2WorkerEventsStream);
app.route("/v1/code/sessions", v2WorkerEvents); app.route("/v1/code/sessions", v2WorkerEvents);
app.route("/web", webAuth); app.route("/web", webAuth);
app.route("/web", webSessions); app.route("/web", webSessions);
@@ -56,11 +53,6 @@ function createApp() {
const AUTH_HEADERS = { Authorization: "Bearer test-api-key", "X-Username": "testuser" }; const AUTH_HEADERS = { Authorization: "Bearer test-api-key", "X-Username": "testuser" };
function toWebSessionId(sessionId: string): string {
if (!sessionId.startsWith("cse_")) return sessionId;
return `session_${sessionId.slice("cse_".length)}`;
}
describe("V1 Session Routes", () => { describe("V1 Session Routes", () => {
let app: Hono; let app: Hono;
@@ -117,24 +109,6 @@ describe("V1 Session Routes", () => {
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
test("GET /v1/sessions/:id — resolves compat code session IDs", async () => {
const createRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const {
session: { id },
} = await createRes.json();
const getRes = await app.request(`/v1/sessions/${toWebSessionId(id)}`, {
headers: AUTH_HEADERS,
});
expect(getRes.status).toBe(200);
const body = await getRes.json();
expect(body.id).toBe(id);
});
test("PATCH /v1/sessions/:id — updates title", async () => { test("PATCH /v1/sessions/:id — updates title", async () => {
const createRes = await app.request("/v1/sessions", { const createRes = await app.request("/v1/sessions", {
method: "POST", method: "POST",
@@ -168,32 +142,6 @@ describe("V1 Session Routes", () => {
expect(archiveRes.status).toBe(200); expect(archiveRes.status).toBe(200);
}); });
test("POST /v1/sessions/:id/archive — archives compat code session IDs", async () => {
const createRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const {
session: { id },
} = await createRes.json();
const compatId = toWebSessionId(id);
const archiveRes = await app.request(`/v1/sessions/${compatId}/archive`, {
method: "POST",
headers: AUTH_HEADERS,
});
expect(archiveRes.status).toBe(200);
const getRes = await app.request(`/v1/sessions/${compatId}`, {
headers: AUTH_HEADERS,
});
expect(getRes.status).toBe(200);
const body = await getRes.json();
expect(body.id).toBe(id);
expect(body.status).toBe("archived");
});
test("POST /v1/sessions/:id/events — publishes events", async () => { test("POST /v1/sessions/:id/events — publishes events", async () => {
const createRes = await app.request("/v1/sessions", { const createRes = await app.request("/v1/sessions", {
method: "POST", method: "POST",
@@ -212,30 +160,6 @@ describe("V1 Session Routes", () => {
expect(body.events).toBe(1); expect(body.events).toBe(1);
}); });
test("POST /v1/sessions/:id/events — resolves compat code session IDs", async () => {
const createRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const {
session: { id },
} = await createRes.json();
const compatId = toWebSessionId(id);
const eventsRes = await app.request(`/v1/sessions/${compatId}/events`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ events: [{ type: "user", content: "hello from compat" }] }),
});
expect(eventsRes.status).toBe(200);
const events = getEventBus(id).getEventsSince(0);
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("user");
expect((events[0]?.payload as { content?: string }).content).toBe("hello from compat");
});
test("POST /v1/sessions with environment_id creates work item", async () => { test("POST /v1/sessions with environment_id creates work item", async () => {
// First register an environment // First register an environment
const envRes = await app.request("/v1/environments/bridge", { const envRes = await app.request("/v1/environments/bridge", {
@@ -519,26 +443,6 @@ describe("Web Auth Routes", () => {
expect(body.ok).toBe(true); expect(body.ok).toBe(true);
}); });
test("POST /web/bind — binds compat code session ID to UUID", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body = await sessRes.json();
const compatId = toWebSessionId(body.session.id);
const bindRes = await app.request("/web/bind?uuid=test-uuid", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: compatId }),
});
expect(bindRes.status).toBe(200);
const bindBody = await bindRes.json();
expect(bindBody.ok).toBe(true);
expect(bindBody.sessionId).toBe(compatId);
});
test("POST /web/bind — 404 for unknown session", async () => { test("POST /web/bind — 404 for unknown session", async () => {
const res = await app.request("/web/bind?uuid=test-uuid", { const res = await app.request("/web/bind?uuid=test-uuid", {
method: "POST", method: "POST",
@@ -597,24 +501,6 @@ describe("Web Session Routes", () => {
expect(sessions[0].id).toBe(id); expect(sessions[0].id).toBe(id);
}); });
test("GET /web/sessions and /all — serialize owned code sessions as compat IDs", async () => {
const codeSession = storeCreateSession({ idPrefix: "cse_" });
storeBindSession(codeSession.id, "user-1");
const compatId = toWebSessionId(codeSession.id);
const listRes = await app.request("/web/sessions?uuid=user-1");
expect(listRes.status).toBe(200);
const sessions = await listRes.json();
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe(compatId);
const allRes = await app.request("/web/sessions/all?uuid=user-1");
expect(allRes.status).toBe(200);
const summaries = await allRes.json();
expect(summaries).toHaveLength(1);
expect(summaries[0].id).toBe(compatId);
});
test("GET /web/sessions — requires UUID", async () => { test("GET /web/sessions — requires UUID", async () => {
const res = await app.request("/web/sessions"); const res = await app.request("/web/sessions");
expect(res.status).toBe(401); expect(res.status).toBe(401);
@@ -639,33 +525,6 @@ describe("Web Session Routes", () => {
expect(sessions).toHaveLength(1); // only user-1's session, not user-2's expect(sessions).toHaveLength(1); // only user-1's session, not user-2's
}); });
test("GET /web/sessions and /all — hides archived and inactive sessions", async () => {
const archived = storeCreateSession({});
const inactive = storeCreateSession({});
const open = storeCreateSession({});
storeBindSession(archived.id, "user-1");
storeBindSession(inactive.id, "user-1");
storeBindSession(open.id, "user-1");
await app.request(`/v1/sessions/${archived.id}/archive`, {
method: "POST",
headers: AUTH_HEADERS,
});
const { storeUpdateSession } = await import("../store");
storeUpdateSession(inactive.id, { status: "inactive" });
const listRes = await app.request("/web/sessions?uuid=user-1");
expect(listRes.status).toBe(200);
const sessions = await listRes.json();
expect(sessions.map((session: { id: string }) => session.id)).toEqual([open.id]);
const allRes = await app.request("/web/sessions/all?uuid=user-1");
expect(allRes.status).toBe(200);
const summaries = await allRes.json();
expect(summaries.map((session: { id: string }) => session.id)).toEqual([open.id]);
});
test("GET /web/sessions/:id — returns owned session", async () => { test("GET /web/sessions/:id — returns owned session", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", { const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST", method: "POST",
@@ -704,22 +563,6 @@ describe("Web Session Routes", () => {
expect(body.events).toEqual([]); expect(body.events).toEqual([]);
}); });
test("GET /web/sessions/:id and history — supports compat code session IDs", async () => {
const codeSession = storeCreateSession({ idPrefix: "cse_" });
storeBindSession(codeSession.id, "user-1");
const compatId = toWebSessionId(codeSession.id);
const getRes = await app.request(`/web/sessions/${compatId}?uuid=user-1`);
expect(getRes.status).toBe(200);
const session = await getRes.json();
expect(session.id).toBe(compatId);
const histRes = await app.request(`/web/sessions/${compatId}/history?uuid=user-1`);
expect(histRes.status).toBe(200);
const history = await histRes.json();
expect(history.events).toEqual([]);
});
test("GET /web/sessions/:id/history — 403 for non-owner", async () => { test("GET /web/sessions/:id/history — 403 for non-owner", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", { const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST", method: "POST",
@@ -804,24 +647,6 @@ describe("Web Session Routes", () => {
} }
}); });
test("GET /web/sessions/:id/events — supports compat code session IDs", async () => {
const codeSession = storeCreateSession({ idPrefix: "cse_" });
storeBindSession(codeSession.id, "user-1");
const compatId = toWebSessionId(codeSession.id);
const eventsRes = await app.request(`/web/sessions/${compatId}/events?uuid=user-1`);
expect(eventsRes.status).toBe(200);
expect(eventsRes.headers.get("Content-Type")).toBe("text/event-stream");
const reader = eventsRes.body?.getReader();
if (reader) {
const { value } = await reader.read();
const text = new TextDecoder().decode(value!);
expect(text).toContain(": keepalive");
reader.cancel();
}
});
test("GET /web/sessions/:id/events — 403 for non-owner", async () => { test("GET /web/sessions/:id/events — 403 for non-owner", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", { const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST", method: "POST",
@@ -833,25 +658,6 @@ describe("Web Session Routes", () => {
const eventsRes = await app.request(`/web/sessions/${id}/events?uuid=user-2`); const eventsRes = await app.request(`/web/sessions/${id}/events?uuid=user-2`);
expect(eventsRes.status).toBe(403); expect(eventsRes.status).toBe(403);
}); });
test("GET /web/sessions/:id/events — 409 for archived session", async () => {
const createRes = await app.request("/web/sessions?uuid=user-1", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { id } = await createRes.json();
await app.request(`/v1/sessions/${id}/archive`, {
method: "POST",
headers: AUTH_HEADERS,
});
const res = await app.request(`/web/sessions/${id}/events?uuid=user-1`);
expect(res.status).toBe(409);
const body = await res.json();
expect(body.error.type).toBe("session_closed");
});
}); });
describe("Web Control Routes", () => { describe("Web Control Routes", () => {
@@ -886,32 +692,6 @@ describe("Web Control Routes", () => {
expect(body.event).toBeTruthy(); expect(body.event).toBeTruthy();
}); });
test("POST /web/sessions/:id/events/control/interrupt — supports compat code session IDs", async () => {
const rawSessionId = storeCreateSession({ idPrefix: "cse_" }).id;
storeBindSession(rawSessionId, "user-1");
const compatId = toWebSessionId(rawSessionId);
const eventsRes = await app.request(`/web/sessions/${compatId}/events?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "user", content: "hello" }),
});
expect(eventsRes.status).toBe(200);
const controlRes = await app.request(`/web/sessions/${compatId}/control?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "permission_response", approved: true, request_id: "r1" }),
});
expect(controlRes.status).toBe(200);
const interruptRes = await app.request(`/web/sessions/${compatId}/interrupt?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(interruptRes.status).toBe(200);
});
test("POST /web/sessions/:id/events — 403 for non-owner", async () => { test("POST /web/sessions/:id/events — 403 for non-owner", async () => {
const res = await app.request(`/web/sessions/${sessionId}/events?uuid=user-2`, { const res = await app.request(`/web/sessions/${sessionId}/events?uuid=user-2`, {
method: "POST", method: "POST",
@@ -963,33 +743,6 @@ describe("Web Control Routes", () => {
}); });
expect(res.status).toBe(403); expect(res.status).toBe(403);
}); });
test("POST /web/sessions/:id/events/control/interrupt — 409 for archived session", async () => {
await app.request(`/v1/sessions/${sessionId}/archive`, {
method: "POST",
headers: AUTH_HEADERS,
});
const eventsRes = await app.request(`/web/sessions/${sessionId}/events?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "user", content: "hello" }),
});
expect(eventsRes.status).toBe(409);
const controlRes = await app.request(`/web/sessions/${sessionId}/control?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "permission_response", approved: true, request_id: "r1" }),
});
expect(controlRes.status).toBe(409);
const interruptRes = await app.request(`/web/sessions/${sessionId}/interrupt?uuid=user-1`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
expect(interruptRes.status).toBe(409);
});
}); });
describe("Web Environment Routes", () => { describe("Web Environment Routes", () => {
@@ -1069,81 +822,6 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
test("POST /v2/session_ingress/session/:sessionId/events — resolves compat code session IDs", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const {
session: { id },
} = await sessRes.json();
const compatId = toWebSessionId(id);
const res = await app.request(`/v2/session_ingress/session/${compatId}/events`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ events: [{ type: "assistant", message: { role: "assistant", content: "compat ok" } }] }),
});
expect(res.status).toBe(200);
const events = getEventBus(id).getEventsSince(0);
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("assistant");
});
test("GET /v2/session_ingress/ws/:sessionId — resolves compat code session IDs", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const {
session: { id },
} = await sessRes.json();
const compatId = toWebSessionId(id);
publishSessionEvent(id, "user", { content: "compat ws replay" }, "outbound");
const server = Bun.serve({
port: 0,
fetch: app.fetch,
websocket: {
...sessionIngressWebsocket,
idleTimeout: 30,
},
});
try {
const message = await new Promise<string>((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/v2/session_ingress/ws/${compatId}?token=test-api-key`);
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timed out waiting for compat WebSocket replay"));
}, 2000);
ws.onmessage = (event) => {
const data = typeof event.data === "string" ? event.data : String(event.data);
if (data.includes("\"type\":\"user\"")) {
clearTimeout(timeout);
ws.close();
resolve(data);
}
};
ws.onerror = () => {
clearTimeout(timeout);
reject(new Error("Compat WebSocket connection failed"));
};
});
expect(message).toContain("\"type\":\"user\"");
expect(message).toContain(`\"session_id\":\"${id}\"`);
expect(message).toContain("compat ws replay");
} finally {
await server.stop(true);
}
});
}); });
describe("V2 Worker Events Routes", () => { describe("V2 Worker Events Routes", () => {
@@ -1178,112 +856,6 @@ describe("V2 Worker Events Routes", () => {
expect(body.count).toBe(1); expect(body.count).toBe(1);
}); });
test("POST /v1/code/sessions/:id/worker/events — unwraps CCR batch payloads", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { session: { id } } = await sessRes.json();
const res = await app.request(`/v1/code/sessions/${id}/worker/events`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({
worker_epoch: 1,
events: [{ payload: { type: "assistant", content: "response" } }],
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.count).toBe(1);
const events = getEventBus(id).getEventsSince(0);
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("assistant");
expect((events[0]?.payload as { content?: string }).content).toBe("response");
});
test("GET/PUT /v1/code/sessions/:id/worker — stores worker state", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { session: { id } } = await sessRes.json();
const putRes = await app.request(`/v1/code/sessions/${id}/worker`, {
method: "PUT",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({
worker_epoch: 1,
worker_status: "running",
external_metadata: { permission_mode: "default" },
}),
});
expect(putRes.status).toBe(200);
const getRes = await app.request(`/v1/code/sessions/${id}/worker`, {
headers: AUTH_HEADERS,
});
expect(getRes.status).toBe(200);
const body = await getRes.json();
expect(body.worker.worker_status).toBe("running");
expect(body.worker.external_metadata.permission_mode).toBe("default");
});
test("POST /v1/code/sessions/:id/worker/heartbeat — updates heartbeat", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { session: { id } } = await sessRes.json();
const heartbeatRes = await app.request(`/v1/code/sessions/${id}/worker/heartbeat`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ worker_epoch: 1 }),
});
expect(heartbeatRes.status).toBe(200);
const getRes = await app.request(`/v1/code/sessions/${id}/worker`, {
headers: AUTH_HEADERS,
});
const body = await getRes.json();
expect(body.worker.last_heartbeat_at).toBeTruthy();
});
test("GET /v1/code/sessions/:id/worker/events/stream — emits CCR client_event frames", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { session: { id } } = await sessRes.json();
const streamRes = await app.request(`/v1/code/sessions/${id}/worker/events/stream`, {
headers: AUTH_HEADERS,
});
expect(streamRes.status).toBe(200);
const reader = streamRes.body?.getReader();
expect(reader).toBeTruthy();
if (!reader) return;
const firstChunk = await reader.read();
const keepalive = new TextDecoder().decode(firstChunk.value!);
expect(keepalive).toContain(": keepalive");
publishSessionEvent(id, "user", { type: "user", content: "hello" }, "outbound");
const secondChunk = await reader.read();
const frame = new TextDecoder().decode(secondChunk.value!);
expect(frame).toContain("event: client_event");
expect(frame).toContain("\"payload\":{\"type\":\"user\",\"content\":\"hello\",\"message\":{\"content\":\"hello\"}}");
reader.cancel();
});
test("PUT /v1/code/sessions/:id/worker/state — updates session status", async () => { test("PUT /v1/code/sessions/:id/worker/state — updates session status", async () => {
const sessRes = await app.request("/v1/sessions", { const sessRes = await app.request("/v1/sessions", {
method: "POST", method: "POST",
@@ -1331,20 +903,4 @@ describe("V2 Worker Events Routes", () => {
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
}); });
test("POST /v1/code/sessions/:id/worker/events/delivery — batch no-op", async () => {
const sessRes = await app.request("/v1/code/sessions", {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { session: { id } } = await sessRes.json();
const res = await app.request(`/v1/code/sessions/${id}/worker/events/delivery`, {
method: "POST",
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ worker_epoch: 1, updates: [{ event_id: "evt123", status: "received" }] }),
});
expect(res.status).toBe(200);
});
}); });

View File

@@ -345,14 +345,6 @@ describe("Transport Service", () => {
expect(result.message).toEqual(msg); expect(result.message).toEqual(msg);
}); });
test("preserves uuid field", () => {
const result = normalizePayload("user", {
uuid: "msg_123",
content: "hi",
});
expect(result.uuid).toBe("msg_123");
});
test("uses name as tool_name fallback", () => { test("uses name as tool_name fallback", () => {
const result = normalizePayload("tool", { name: "Read" }); const result = normalizePayload("tool", { name: "Read" });
expect(result.tool_name).toBe("Read"); expect(result.tool_name).toBe("Read");

View File

@@ -336,26 +336,6 @@ describe("ws-handler", () => {
expect(lastMsg.message.content).toBe("hello world"); expect(lastMsg.message.content).toBe("hello world");
}); });
test("preserves payload uuid for outbound user events", () => {
const bus = getEventBus("um2");
const ws = createMockWs();
handleWebSocketOpen(ws, "um2");
bus.publish({
id: "internal-event-id",
sessionId: "um2",
type: "user",
payload: { uuid: "web-message-uuid", content: "hello from web" },
direction: "outbound",
});
const sent = ws.getSentData();
const lastMsg = JSON.parse(sent[sent.length - 1]);
expect(lastMsg.type).toBe("user");
expect(lastMsg.uuid).toBe("web-message-uuid");
expect(lastMsg.message.content).toBe("hello from web");
});
test("converts generic event type", () => { test("converts generic event type", () => {
const bus = getEventBus("gen1"); const bus = getEventBus("gen1");
const ws = createMockWs(); const ws = createMockWs();

View File

@@ -14,14 +14,14 @@ app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
/** DELETE /v1/environments/bridge/:id — Deregister */ /** DELETE /v1/environments/bridge/:id — Deregister */
app.delete("/bridge/:id", acceptCliHeaders, apiKeyAuth, async (c) => { app.delete("/bridge/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
const envId = c.req.param("id")!; const envId = c.req.param("id");
deregisterEnvironment(envId); deregisterEnvironment(envId);
return c.json({ status: "ok" }, 200); return c.json({ status: "ok" }, 200);
}); });
/** POST /v1/environments/:id/bridge/reconnect — Reconnect */ /** POST /v1/environments/:id/bridge/reconnect — Reconnect */
app.post("/:id/bridge/reconnect", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/bridge/reconnect", acceptCliHeaders, apiKeyAuth, async (c) => {
const envId = c.req.param("id")!; const envId = c.req.param("id");
reconnectEnvironment(envId); reconnectEnvironment(envId);
const { reconnectWorkForEnvironment } = await import("../../services/work-dispatch"); const { reconnectWorkForEnvironment } = await import("../../services/work-dispatch");
await reconnectWorkForEnvironment(envId); await reconnectWorkForEnvironment(envId);

View File

@@ -7,7 +7,7 @@ const app = new Hono();
/** GET /v1/environments/:id/work/poll — Long-poll for work */ /** GET /v1/environments/:id/work/poll — Long-poll for work */
app.get("/:id/work/poll", acceptCliHeaders, apiKeyAuth, async (c) => { app.get("/:id/work/poll", acceptCliHeaders, apiKeyAuth, async (c) => {
const envId = c.req.param("id")!; const envId = c.req.param("id");
updatePollTime(envId); updatePollTime(envId);
const result = await pollWork(envId); const result = await pollWork(envId);
if (!result) { if (!result) {
@@ -19,21 +19,21 @@ app.get("/:id/work/poll", acceptCliHeaders, apiKeyAuth, async (c) => {
/** POST /v1/environments/:id/work/:workId/ack — Acknowledge work */ /** POST /v1/environments/:id/work/:workId/ack — Acknowledge work */
app.post("/:id/work/:workId/ack", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/work/:workId/ack", acceptCliHeaders, apiKeyAuth, async (c) => {
const workId = c.req.param("workId")!; const workId = c.req.param("workId");
ackWork(workId); ackWork(workId);
return c.json({ status: "ok" }, 200); return c.json({ status: "ok" }, 200);
}); });
/** POST /v1/environments/:id/work/:workId/stop — Stop work */ /** POST /v1/environments/:id/work/:workId/stop — Stop work */
app.post("/:id/work/:workId/stop", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/work/:workId/stop", acceptCliHeaders, apiKeyAuth, async (c) => {
const workId = c.req.param("workId")!; const workId = c.req.param("workId");
stopWork(workId); stopWork(workId);
return c.json({ status: "ok" }, 200); return c.json({ status: "ok" }, 200);
}); });
/** POST /v1/environments/:id/work/:workId/heartbeat — Heartbeat */ /** POST /v1/environments/:id/work/:workId/heartbeat — Heartbeat */
app.post("/:id/work/:workId/heartbeat", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/work/:workId/heartbeat", acceptCliHeaders, apiKeyAuth, async (c) => {
const workId = c.req.param("workId")!; const workId = c.req.param("workId");
const result = heartbeatWork(workId); const result = heartbeatWork(workId);
return c.json(result, 200); return c.json(result, 200);
}); });

View File

@@ -8,7 +8,7 @@ import {
handleWebSocketClose, handleWebSocketClose,
ingestBridgeMessage, ingestBridgeMessage,
} from "../../transport/ws-handler"; } from "../../transport/ws-handler";
import { getSession, resolveExistingSessionId } from "../../services/session"; import { getSession } from "../../services/session";
const { upgradeWebSocket, websocket } = createBunWebSocket(); const { upgradeWebSocket, websocket } = createBunWebSocket();
@@ -43,8 +43,7 @@ function authenticateRequest(c: any, label: string, expectedSessionId?: string):
/** POST /v2/session_ingress/session/:sessionId/events — HTTP POST (HybridTransport writes) */ /** POST /v2/session_ingress/session/:sessionId/events — HTTP POST (HybridTransport writes) */
app.post("/session/:sessionId/events", async (c) => { app.post("/session/:sessionId/events", async (c) => {
const requestedSessionId = c.req.param("sessionId")!; const sessionId = c.req.param("sessionId")!;
const sessionId = resolveExistingSessionId(requestedSessionId) ?? requestedSessionId;
if (!authenticateRequest(c, `POST session/${sessionId}`, sessionId)) { if (!authenticateRequest(c, `POST session/${sessionId}`, sessionId)) {
return c.json({ error: { type: "unauthorized", message: "Invalid auth" } }, 401); return c.json({ error: { type: "unauthorized", message: "Invalid auth" } }, 401);
@@ -72,8 +71,7 @@ app.post("/session/:sessionId/events", async (c) => {
app.get( app.get(
"/ws/:sessionId", "/ws/:sessionId",
upgradeWebSocket(async (c) => { upgradeWebSocket(async (c) => {
const requestedSessionId = c.req.param("sessionId")!; const sessionId = c.req.param("sessionId")!;
const sessionId = resolveExistingSessionId(requestedSessionId) ?? requestedSessionId;
if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) { if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) {
return { return {

View File

@@ -4,7 +4,6 @@ import {
getSession, getSession,
updateSessionTitle, updateSessionTitle,
archiveSession, archiveSession,
resolveExistingSessionId,
} from "../../services/session"; } from "../../services/session";
import { createWorkItem } from "../../services/work-dispatch"; import { createWorkItem } from "../../services/work-dispatch";
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware"; import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
@@ -39,8 +38,7 @@ app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
/** GET /v1/sessions/:id — Get session */ /** GET /v1/sessions/:id — Get session */
app.get("/:id", acceptCliHeaders, apiKeyAuth, async (c) => { app.get("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!; const session = getSession(c.req.param("id"));
const session = getSession(sessionId);
if (!session) { if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404); return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
} }
@@ -49,43 +47,27 @@ app.get("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
/** PATCH /v1/sessions/:id — Update session title */ /** PATCH /v1/sessions/:id — Update session title */
app.patch("/:id", acceptCliHeaders, apiKeyAuth, async (c) => { app.patch("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
const existing = getSession(sessionId);
if (!existing) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const body = await c.req.json(); const body = await c.req.json();
if (body.title) { if (body.title) {
updateSessionTitle(sessionId, body.title); updateSessionTitle(c.req.param("id"), body.title);
} }
const session = getSession(sessionId); const session = getSession(c.req.param("id"));
return c.json(session, 200); return c.json(session, 200);
}); });
/** POST /v1/sessions/:id/archive — Archive session */ /** POST /v1/sessions/:id/archive — Archive session */
app.post("/:id/archive", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/archive", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
try { try {
archiveSession(sessionId); archiveSession(c.req.param("id"));
} catch { } catch {
return c.json({ status: "ok" }, 409); return c.json({ status: "ok" }, 409);
} }
return c.json({ status: "ok" }, 200); return c.json({ status: "ok" }, 200);
}); });
/** POST /v1/sessions/:id/events — Send event to session */ /** POST /v1/sessions/:id/events — Send event to session */
app.post("/:id/events", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/events", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!; const sessionId = c.req.param("id");
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const body = await c.req.json(); const body = await c.req.json();
const events = body.events const events = body.events

View File

@@ -15,7 +15,7 @@ app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
/** POST /v1/code/sessions/:id/bridge — Get connection info + worker JWT */ /** POST /v1/code/sessions/:id/bridge — Get connection info + worker JWT */
app.post("/:id/bridge", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = c.req.param("id")!; const sessionId = c.req.param("id");
const session = getSession(sessionId); const session = getSession(sessionId);
if (!session) { if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404); return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);

View File

@@ -1,13 +1,13 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware"; import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
import { createWorkerEventStream } from "../../transport/sse-writer"; import { createSSEStream } from "../../transport/sse-writer";
import { getSession } from "../../services/session"; import { getSession } from "../../services/session";
const app = new Hono(); const app = new Hono();
/** SSE /v1/code/sessions/:id/worker/events/stream — SSE event stream */ /** SSE /v1/code/sessions/:id/worker/events/stream — SSE event stream */
app.get("/:id/worker/events/stream", acceptCliHeaders, sessionIngressAuth, async (c) => { app.get("/:id/worker/events/stream", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!; const sessionId = c.req.param("id");
const session = getSession(sessionId); const session = getSession(sessionId);
if (!session) { if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404); return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
@@ -18,7 +18,7 @@ app.get("/:id/worker/events/stream", acceptCliHeaders, sessionIngressAuth, async
const fromSeq = c.req.query("from_sequence_num"); const fromSeq = c.req.query("from_sequence_num");
const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0; const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
return createWorkerEventStream(c, sessionId, fromSeqNum); return createSSEStream(c, sessionId, fromSeqNum);
}); });
export default app; export default app;

View File

@@ -1,66 +1,32 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware"; import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
import { publishSessionEvent } from "../../services/transport"; import { publishSessionEvent } from "../../services/transport";
import { getSession, touchSession, updateSessionStatus } from "../../services/session"; import { getSession, updateSessionStatus } from "../../services/session";
const app = new Hono(); const app = new Hono();
function extractWorkerEvents(body: unknown): Array<Record<string, unknown>> {
if (!body || typeof body !== "object") {
return [];
}
const payload = body as Record<string, unknown>;
const rawEvents = Array.isArray(payload.events)
? payload.events
: Array.isArray(body)
? body
: [body];
return rawEvents
.filter((evt): evt is Record<string, unknown> => !!evt && typeof evt === "object")
.map((evt) => {
const wrappedPayload = evt.payload;
if (wrappedPayload && typeof wrappedPayload === "object" && !Array.isArray(wrappedPayload)) {
return wrappedPayload as Record<string, unknown>;
}
return evt;
});
}
/** POST /v1/code/sessions/:id/worker/events — Write events */ /** POST /v1/code/sessions/:id/worker/events — Write events */
app.post("/:id/worker/events", acceptCliHeaders, sessionIngressAuth, async (c) => { app.post("/:id/worker/events", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!; const sessionId = c.req.param("id");
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const body = await c.req.json(); const body = await c.req.json();
const events = extractWorkerEvents(body); const events = Array.isArray(body) ? body : [body];
const published = []; const published = [];
for (const evt of events) { for (const evt of events) {
const eventType = typeof evt.type === "string" ? evt.type : "message"; const result = publishSessionEvent(sessionId, evt.type || "message", evt, "inbound");
const result = publishSessionEvent(sessionId, eventType, evt, "inbound");
published.push(result); published.push(result);
} }
touchSession(sessionId);
return c.json({ status: "ok", count: published.length }, 200); return c.json({ status: "ok", count: published.length }, 200);
}); });
/** PUT /v1/code/sessions/:id/worker/state — Report worker state */ /** PUT /v1/code/sessions/:id/worker/state — Report worker state */
app.put("/:id/worker/state", acceptCliHeaders, sessionIngressAuth, async (c) => { app.put("/:id/worker/state", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!; const sessionId = c.req.param("id");
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const body = await c.req.json(); const body = await c.req.json();
if (body.status) { if (body.status) {
updateSessionStatus(sessionId, body.status); updateSessionStatus(sessionId, body.status);
} else {
touchSession(sessionId);
} }
return c.json({ status: "ok" }, 200); return c.json({ status: "ok" }, 200);
@@ -68,29 +34,12 @@ app.put("/:id/worker/state", acceptCliHeaders, sessionIngressAuth, async (c) =>
/** PUT /v1/code/sessions/:id/worker/external_metadata — Report worker metadata (no-op) */ /** PUT /v1/code/sessions/:id/worker/external_metadata — Report worker metadata (no-op) */
app.put("/:id/worker/external_metadata", acceptCliHeaders, sessionIngressAuth, async (c) => { app.put("/:id/worker/external_metadata", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
// TUI's CCRClient calls this for metadata reporting. Accept and discard. // TUI's CCRClient calls this for metadata reporting. Accept and discard.
return c.json({ status: "ok" }, 200); return c.json({ status: "ok" }, 200);
}); });
/** POST /v1/code/sessions/:id/worker/events/delivery — Batch delivery tracking (no-op) */
app.post("/:id/worker/events/delivery", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
return c.json({ status: "ok" }, 200);
});
/** POST /v1/code/sessions/:id/worker/events/:eventId/delivery — Delivery tracking (no-op) */ /** POST /v1/code/sessions/:id/worker/events/:eventId/delivery — Delivery tracking (no-op) */
app.post("/:id/worker/events/:eventId/delivery", acceptCliHeaders, sessionIngressAuth, async (c) => { app.post("/:id/worker/events/:eventId/delivery", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
if (!getSession(sessionId)) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
// TUI's CCRClient reports event delivery status (received/processing/processed). // TUI's CCRClient reports event delivery status (received/processing/processed).
// Accept and discard — event bus doesn't track per-event delivery. // Accept and discard — event bus doesn't track per-event delivery.
return c.json({ status: "ok" }, 200); return c.json({ status: "ok" }, 200);

View File

@@ -1,78 +1,12 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session"; import { getSession, incrementEpoch } from "../../services/session";
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware"; import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
const app = new Hono(); const app = new Hono();
/** GET /v1/code/sessions/:id/worker — Read worker state */
app.get("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const worker = storeGetSessionWorker(sessionId);
return c.json({
worker: {
worker_status: worker?.workerStatus ?? session.status,
external_metadata: worker?.externalMetadata ?? null,
requires_action_details: worker?.requiresActionDetails ?? null,
last_heartbeat_at: worker?.lastHeartbeatAt?.toISOString() ?? null,
},
}, 200);
});
/** PUT /v1/code/sessions/:id/worker — Update worker state */
app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const body = await c.req.json();
if (body.worker_status) {
updateSessionStatus(sessionId, body.worker_status);
} else {
touchSession(sessionId);
}
const worker = storeUpsertSessionWorker(sessionId, {
workerStatus: body.worker_status,
externalMetadata: body.external_metadata,
requiresActionDetails: body.requires_action_details,
});
return c.json({
status: "ok",
worker: {
worker_status: worker.workerStatus ?? session.status,
external_metadata: worker.externalMetadata,
requires_action_details: worker.requiresActionDetails,
last_heartbeat_at: worker.lastHeartbeatAt?.toISOString() ?? null,
},
}, 200);
});
/** POST /v1/code/sessions/:id/worker/heartbeat — Keep worker alive */
app.post("/:id/worker/heartbeat", acceptCliHeaders, sessionIngressAuth, async (c) => {
const sessionId = c.req.param("id")!;
const session = getSession(sessionId);
if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
}
const now = new Date();
storeUpsertSessionWorker(sessionId, { lastHeartbeatAt: now });
touchSession(sessionId);
return c.json({ status: "ok", last_heartbeat_at: now.toISOString() }, 200);
});
/** POST /v1/code/sessions/:id/worker/register — Register worker */ /** POST /v1/code/sessions/:id/worker/register — Register worker */
app.post("/:id/worker/register", acceptCliHeaders, apiKeyAuth, async (c) => { app.post("/:id/worker/register", acceptCliHeaders, apiKeyAuth, async (c) => {
const sessionId = c.req.param("id")!; const sessionId = c.req.param("id");
const session = getSession(sessionId); const session = getSession(sessionId);
if (!session) { if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404); return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);

View File

@@ -1,6 +1,5 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { storeBindSession } from "../../store"; import { storeGetSession, storeBindSession } from "../../store";
import { resolveExistingWebSessionId, toWebSessionId } from "../../services/session";
const app = new Hono(); const app = new Hono();
@@ -15,13 +14,13 @@ app.post("/bind", async (c) => {
return c.json({ error: "sessionId and uuid are required" }, 400); return c.json({ error: "sessionId and uuid are required" }, 400);
} }
const resolvedSessionId = resolveExistingWebSessionId(sessionId); const session = storeGetSession(sessionId);
if (!resolvedSessionId) { if (!session) {
return c.json({ error: "Session not found" }, 404); return c.json({ error: "Session not found" }, 404);
} }
storeBindSession(resolvedSessionId, uuid); storeBindSession(sessionId, uuid);
return c.json({ ok: true, sessionId: toWebSessionId(resolvedSessionId) }); return c.json({ ok: true, sessionId });
}); });
export default app; export default app;

View File

@@ -1,46 +1,31 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware"; import { uuidAuth } from "../../auth/middleware";
import { getSession, isSessionClosedStatus, resolveOwnedWebSessionId, updateSessionStatus } from "../../services/session"; import { getSession, updateSessionStatus } from "../../services/session";
import { publishSessionEvent } from "../../services/transport"; import { publishSessionEvent } from "../../services/transport";
import { getEventBus } from "../../transport/event-bus"; import { getEventBus } from "../../transport/event-bus";
import { storeIsSessionOwner } from "../../store";
const app = new Hono(); const app = new Hono();
type OwnershipCheckResult = function checkOwnership(c: { get: (key: string) => string | undefined }, sessionId: string) {
| { error: true } const uuid = c.get("uuid");
| { error: true; reason: string } if (!storeIsSessionOwner(sessionId, uuid)) {
| { error: false; session: NonNullable<ReturnType<typeof getSession>>; sessionId: string }; return { error: true, session: null };
function checkOwnership(c: { get: (key: string) => string | undefined }, sessionId: string): OwnershipCheckResult {
const uuid = c.get("uuid")!;
const resolvedSessionId = resolveOwnedWebSessionId(sessionId, uuid);
if (!resolvedSessionId) {
return { error: true };
} }
const session = getSession(resolvedSessionId); const session = getSession(sessionId);
if (!session) { if (!session) {
return { error: true }; return { error: true, session: null };
} }
if (isSessionClosedStatus(session.status)) { return { error: false, session };
return { error: true, reason: `Session is ${session.status}` };
}
return { error: false, session, sessionId: resolvedSessionId };
}
function closedSessionResponse(message: string) {
return { error: { type: "session_closed", message } };
} }
/** POST /web/sessions/:id/events — Send user message to session */ /** POST /web/sessions/:id/events — Send user message to session */
app.post("/sessions/:id/events", uuidAuth, async (c) => { app.post("/sessions/:id/events", uuidAuth, async (c) => {
const requestedSessionId = c.req.param("id")!; const sessionId = c.req.param("id")!;
const ownership = checkOwnership(c, requestedSessionId); const { error } = checkOwnership(c, sessionId);
if (ownership.error) { if (error) {
const message = "reason" in ownership ? ownership.reason : "Not your session"; return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
const status = "reason" in ownership ? 409 : 403;
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
} }
const { sessionId } = ownership;
const body = await c.req.json(); const body = await c.req.json();
const eventType = body.type || "user"; const eventType = body.type || "user";
@@ -52,14 +37,11 @@ app.post("/sessions/:id/events", uuidAuth, async (c) => {
/** POST /web/sessions/:id/control — Send control request (permission approval etc) */ /** POST /web/sessions/:id/control — Send control request (permission approval etc) */
app.post("/sessions/:id/control", uuidAuth, async (c) => { app.post("/sessions/:id/control", uuidAuth, async (c) => {
const requestedSessionId = c.req.param("id")!; const sessionId = c.req.param("id")!;
const ownership = checkOwnership(c, requestedSessionId); const { error } = checkOwnership(c, sessionId);
if (ownership.error) { if (error) {
const message = "reason" in ownership ? ownership.reason : "Not your session"; return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
const status = "reason" in ownership ? 409 : 403;
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
} }
const { sessionId } = ownership;
const body = await c.req.json(); const body = await c.req.json();
const event = publishSessionEvent(sessionId, body.type || "control_request", body, "outbound"); const event = publishSessionEvent(sessionId, body.type || "control_request", body, "outbound");
@@ -68,14 +50,11 @@ app.post("/sessions/:id/control", uuidAuth, async (c) => {
/** POST /web/sessions/:id/interrupt — Interrupt session */ /** POST /web/sessions/:id/interrupt — Interrupt session */
app.post("/sessions/:id/interrupt", uuidAuth, async (c) => { app.post("/sessions/:id/interrupt", uuidAuth, async (c) => {
const requestedSessionId = c.req.param("id")!; const sessionId = c.req.param("id")!;
const ownership = checkOwnership(c, requestedSessionId); const { error } = checkOwnership(c, sessionId);
if (ownership.error) { if (error) {
const message = "reason" in ownership ? ownership.reason : "Not your session"; return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
const status = "reason" in ownership ? 409 : 403;
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
} }
const { sessionId } = ownership;
publishSessionEvent(sessionId, "interrupt", { action: "interrupt" }, "outbound"); publishSessionEvent(sessionId, "interrupt", { action: "interrupt" }, "outbound");
updateSessionStatus(sessionId, "idle"); updateSessionStatus(sessionId, "idle");

View File

@@ -1,16 +1,9 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { uuidAuth } from "../../auth/middleware"; import { uuidAuth } from "../../auth/middleware";
import { import { getSession, createSession } from "../../services/session";
createSession, import { storeListSessionsByOwnerUuid, storeIsSessionOwner, storeBindSession } from "../../store";
getSession,
isSessionClosedStatus,
listWebSessionSummariesByOwnerUuid,
listWebSessionsByOwnerUuid,
resolveOwnedWebSessionId,
toWebSessionResponse,
} from "../../services/session";
import { storeBindSession } from "../../store";
import { createWorkItem } from "../../services/work-dispatch"; import { createWorkItem } from "../../services/work-dispatch";
import { listSessionSummariesByOwnerUuid } from "../../services/session";
import { createSSEStream } from "../../transport/sse-writer"; import { createSSEStream } from "../../transport/sse-writer";
import { getEventBus } from "../../transport/event-bus"; import { getEventBus } from "../../transport/event-bus";
@@ -18,7 +11,7 @@ const app = new Hono();
/** POST /web/sessions — Create a session from web UI */ /** POST /web/sessions — Create a session from web UI */
app.post("/sessions", uuidAuth, async (c) => { app.post("/sessions", uuidAuth, async (c) => {
const uuid = c.get("uuid")!; const uuid = c.get("uuid");
const body = await c.req.json(); const body = await c.req.json();
const session = createSession({ const session = createSession({
environment_id: body.environment_id || null, environment_id: body.environment_id || null,
@@ -44,37 +37,37 @@ app.post("/sessions", uuidAuth, async (c) => {
/** GET /web/sessions — List sessions owned by the requesting UUID */ /** GET /web/sessions — List sessions owned by the requesting UUID */
app.get("/sessions", uuidAuth, async (c) => { app.get("/sessions", uuidAuth, async (c) => {
const uuid = c.get("uuid")!; const uuid = c.get("uuid");
const sessions = listWebSessionsByOwnerUuid(uuid); const sessions = storeListSessionsByOwnerUuid(uuid);
return c.json(sessions, 200); return c.json(sessions, 200);
}); });
/** GET /web/sessions/all — List sessions owned by the requesting UUID (unowned sessions excluded) */ /** GET /web/sessions/all — List sessions owned by the requesting UUID (unowned sessions excluded) */
app.get("/sessions/all", uuidAuth, async (c) => { app.get("/sessions/all", uuidAuth, async (c) => {
const uuid = c.get("uuid")!; const uuid = c.get("uuid");
const sessions = listWebSessionSummariesByOwnerUuid(uuid); const sessions = listSessionSummariesByOwnerUuid(uuid);
return c.json(sessions, 200); return c.json(sessions, 200);
}); });
/** GET /web/sessions/:id — Session detail */ /** GET /web/sessions/:id — Session detail */
app.get("/sessions/:id", uuidAuth, async (c) => { app.get("/sessions/:id", uuidAuth, async (c) => {
const uuid = c.get("uuid")!; const uuid = c.get("uuid");
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid); const sessionId = c.req.param("id")!;
if (!sessionId) { if (!storeIsSessionOwner(sessionId, uuid)) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403); return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
} }
const session = getSession(sessionId); const session = getSession(sessionId);
if (!session) { if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404); return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
} }
return c.json(toWebSessionResponse(session), 200); return c.json(session, 200);
}); });
/** GET /web/sessions/:id/history — Historical events for session */ /** GET /web/sessions/:id/history — Historical events for session */
app.get("/sessions/:id/history", uuidAuth, async (c) => { app.get("/sessions/:id/history", uuidAuth, async (c) => {
const uuid = c.get("uuid")!; const uuid = c.get("uuid");
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid); const sessionId = c.req.param("id")!;
if (!sessionId) { if (!storeIsSessionOwner(sessionId, uuid)) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403); return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
} }
const session = getSession(sessionId); const session = getSession(sessionId);
@@ -89,18 +82,15 @@ app.get("/sessions/:id/history", uuidAuth, async (c) => {
/** SSE /web/sessions/:id/events — Real-time event stream */ /** SSE /web/sessions/:id/events — Real-time event stream */
app.get("/sessions/:id/events", uuidAuth, async (c) => { app.get("/sessions/:id/events", uuidAuth, async (c) => {
const uuid = c.get("uuid")!; const uuid = c.get("uuid");
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid); const sessionId = c.req.param("id")!;
if (!sessionId) { if (!storeIsSessionOwner(sessionId, uuid)) {
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403); return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
} }
const session = getSession(sessionId); const session = getSession(sessionId);
if (!session) { if (!session) {
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404); return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
} }
if (isSessionClosedStatus(session.status)) {
return c.json({ error: { type: "session_closed", message: `Session is ${session.status}` } }, 409);
}
const lastEventId = c.req.header("Last-Event-ID"); const lastEventId = c.req.header("Last-Event-ID");
const fromSeqNum = lastEventId ? parseInt(lastEventId) : 0; const fromSeqNum = lastEventId ? parseInt(lastEventId) : 0;

View File

@@ -1,35 +1,32 @@
import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store"; import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store";
import { storeListSessions } from "../store"; import { storeListSessions, storeUpdateSession } from "../store";
import { config } from "../config"; import { config } from "../config";
import { updateSessionStatus } from "./session";
export function runDisconnectMonitorSweep(now = Date.now()) {
const timeoutMs = config.disconnectTimeout * 1000;
// Check environment heartbeat timeout
const envs = storeListActiveEnvironments();
for (const env of envs) {
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
console.log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
storeUpdateEnvironment(env.id, { status: "disconnected" });
}
}
// Check session timeout (2x disconnect timeout with no update)
const sessions = storeListSessions();
for (const session of sessions) {
if (session.status === "running" || session.status === "idle") {
const elapsed = now - session.updatedAt.getTime();
if (elapsed > timeoutMs * 2) {
console.log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`);
updateSessionStatus(session.id, "inactive");
}
}
}
}
export function startDisconnectMonitor() { export function startDisconnectMonitor() {
const timeoutMs = config.disconnectTimeout * 1000;
setInterval(() => { setInterval(() => {
runDisconnectMonitorSweep(); const now = Date.now();
// Check environment heartbeat timeout
const envs = storeListActiveEnvironments();
for (const env of envs) {
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
console.log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
storeUpdateEnvironment(env.id, { status: "disconnected" });
}
}
// Check session timeout (2x disconnect timeout with no update)
const sessions = storeListSessions();
for (const session of sessions) {
if (session.status === "running" || session.status === "idle") {
const elapsed = now - session.updatedAt.getTime();
if (elapsed > timeoutMs * 2) {
console.log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`);
storeUpdateSession(session.id, { status: "inactive" });
}
}
}
}, 60_000); // Check every minute }, 60_000); // Check every minute
} }

View File

@@ -1,20 +1,14 @@
import { import {
storeCreateSession, storeCreateSession,
storeGetSession, storeGetSession,
storeIsSessionOwner,
storeUpdateSession, storeUpdateSession,
storeListSessions, storeListSessions,
storeListSessionsByUsername, storeListSessionsByUsername,
storeListSessionsByEnvironment, storeListSessionsByEnvironment,
storeListSessionsByOwnerUuid, storeListSessionsByOwnerUuid,
} from "../store"; } from "../store";
import { getAllEventBuses, removeEventBus } from "../transport/event-bus"; import { removeEventBus } from "../transport/event-bus";
import type { CreateSessionRequest, CreateCodeSessionRequest, SessionResponse, SessionSummaryResponse } from "../types/api"; import type { CreateSessionRequest, CreateCodeSessionRequest, SessionResponse, SessionSummaryResponse } from "../types/api";
import { v4 as uuid } from "uuid";
const CODE_SESSION_PREFIX = "cse_";
const WEB_SESSION_PREFIX = "session_";
const CLOSED_SESSION_STATUSES = new Set(["archived", "inactive"]);
function toResponse(row: { id: string; environmentId: string | null; title: string | null; status: string; source: string; permissionMode: string | null; workerEpoch: number; username: string | null; createdAt: Date; updatedAt: Date }): SessionResponse { function toResponse(row: { id: string; environmentId: string | null; title: string | null; status: string; source: string; permissionMode: string | null; workerEpoch: number; username: string | null; createdAt: Date; updatedAt: Date }): SessionResponse {
return { return {
@@ -31,24 +25,6 @@ function toResponse(row: { id: string; environmentId: string | null; title: stri
}; };
} }
export function toWebSessionId(sessionId: string): string {
if (!sessionId.startsWith(CODE_SESSION_PREFIX)) return sessionId;
return `${WEB_SESSION_PREFIX}${sessionId.slice(CODE_SESSION_PREFIX.length)}`;
}
function toCompatibleCodeSessionId(sessionId: string): string | null {
if (!sessionId.startsWith(WEB_SESSION_PREFIX)) return null;
return `${CODE_SESSION_PREFIX}${sessionId.slice(WEB_SESSION_PREFIX.length)}`;
}
export function toWebSessionResponse(session: SessionResponse): SessionResponse {
return { ...session, id: toWebSessionId(session.id) };
}
function toWebSessionSummaryResponse(session: SessionSummaryResponse): SessionSummaryResponse {
return { ...session, id: toWebSessionId(session.id) };
}
export function createSession(req: CreateSessionRequest & { username?: string }): SessionResponse { export function createSession(req: CreateSessionRequest & { username?: string }): SessionResponse {
const record = storeCreateSession({ const record = storeCreateSession({
environmentId: req.environment_id, environmentId: req.environment_id,
@@ -75,78 +51,16 @@ export function getSession(sessionId: string): SessionResponse | null {
return record ? toResponse(record) : null; return record ? toResponse(record) : null;
} }
export function isSessionClosedStatus(status: string | null | undefined): boolean {
return !!status && CLOSED_SESSION_STATUSES.has(status);
}
export function resolveExistingSessionId(sessionId: string): string | null {
if (storeGetSession(sessionId)) {
return sessionId;
}
const compatibleCodeSessionId = toCompatibleCodeSessionId(sessionId);
if (compatibleCodeSessionId && storeGetSession(compatibleCodeSessionId)) {
return compatibleCodeSessionId;
}
return null;
}
export function resolveExistingWebSessionId(sessionId: string): string | null {
return resolveExistingSessionId(sessionId);
}
export function resolveOwnedWebSessionId(sessionId: string, uuid: string): string | null {
if (storeIsSessionOwner(sessionId, uuid)) {
return sessionId;
}
const compatibleCodeSessionId = toCompatibleCodeSessionId(sessionId);
if (compatibleCodeSessionId && storeIsSessionOwner(compatibleCodeSessionId, uuid)) {
return compatibleCodeSessionId;
}
return null;
}
export function listWebSessionsByOwnerUuid(uuid: string): SessionResponse[] {
return storeListSessionsByOwnerUuid(uuid)
.filter((session) => !isSessionClosedStatus(session.status))
.map(toResponse)
.map(toWebSessionResponse);
}
export function listWebSessionSummariesByOwnerUuid(uuid: string): SessionSummaryResponse[] {
return storeListSessionsByOwnerUuid(uuid)
.filter((session) => !isSessionClosedStatus(session.status))
.map(toSummaryResponse)
.map(toWebSessionSummaryResponse);
}
export function updateSessionTitle(sessionId: string, title: string) { export function updateSessionTitle(sessionId: string, title: string) {
storeUpdateSession(sessionId, { title }); storeUpdateSession(sessionId, { title });
} }
export function updateSessionStatus(sessionId: string, status: string) { export function updateSessionStatus(sessionId: string, status: string) {
storeUpdateSession(sessionId, { status }); storeUpdateSession(sessionId, { status });
const bus = getAllEventBuses().get(sessionId);
if (!bus) return;
bus.publish({
id: uuid(),
sessionId,
type: "session_status",
payload: { status },
direction: "inbound",
});
}
export function touchSession(sessionId: string) {
storeUpdateSession(sessionId, {});
} }
export function archiveSession(sessionId: string) { export function archiveSession(sessionId: string) {
updateSessionStatus(sessionId, "archived"); storeUpdateSession(sessionId, { status: "archived" });
removeEventBus(sessionId); removeEventBus(sessionId);
} }

View File

@@ -51,8 +51,6 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
raw: payload, raw: payload,
}; };
if (typeof p.uuid === "string" && p.uuid) normalized.uuid = p.uuid;
// Preserve tool fields // Preserve tool fields
if (p.tool_name) normalized.tool_name = p.tool_name; if (p.tool_name) normalized.tool_name = p.tool_name;
if (p.name) normalized.tool_name = p.name; if (p.name) normalized.tool_name = p.name;

View File

@@ -47,16 +47,6 @@ export interface WorkItemRecord {
updatedAt: Date; updatedAt: Date;
} }
export interface SessionWorkerRecord {
sessionId: string;
workerStatus: string | null;
externalMetadata: Record<string, unknown> | null;
requiresActionDetails: Record<string, unknown> | null;
lastHeartbeatAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
// ---------- Stores (in-memory Maps) ---------- // ---------- Stores (in-memory Maps) ----------
const users = new Map<string, UserRecord>(); const users = new Map<string, UserRecord>();
@@ -64,7 +54,6 @@ const tokenToUser = new Map<string, { username: string; createdAt: Date }>();
const environments = new Map<string, EnvironmentRecord>(); const environments = new Map<string, EnvironmentRecord>();
const sessions = new Map<string, SessionRecord>(); const sessions = new Map<string, SessionRecord>();
const workItems = new Map<string, WorkItemRecord>(); const workItems = new Map<string, WorkItemRecord>();
const sessionWorkers = new Map<string, SessionWorkerRecord>();
// UUID → session ownership: sessionId → Set of UUIDs // UUID → session ownership: sessionId → Set of UUIDs
const sessionOwners = new Map<string, Set<string>>(); const sessionOwners = new Map<string, Set<string>>();
@@ -201,59 +190,9 @@ export function storeListSessionsByEnvironment(envId: string): SessionRecord[] {
} }
export function storeDeleteSession(id: string): boolean { export function storeDeleteSession(id: string): boolean {
sessionWorkers.delete(id);
return sessions.delete(id); return sessions.delete(id);
} }
// ---------- Session Worker ----------
export function storeGetSessionWorker(sessionId: string): SessionWorkerRecord | undefined {
return sessionWorkers.get(sessionId);
}
export function storeUpsertSessionWorker(sessionId: string, patch: {
workerStatus?: string | null;
externalMetadata?: Record<string, unknown> | null;
requiresActionDetails?: Record<string, unknown> | null;
lastHeartbeatAt?: Date | null;
}): SessionWorkerRecord {
const now = new Date();
const existing = sessionWorkers.get(sessionId);
const record: SessionWorkerRecord = existing ?? {
sessionId,
workerStatus: null,
externalMetadata: null,
requiresActionDetails: null,
lastHeartbeatAt: null,
createdAt: now,
updatedAt: now,
};
if (patch.workerStatus !== undefined) {
record.workerStatus = patch.workerStatus;
}
if (patch.externalMetadata !== undefined) {
if (patch.externalMetadata === null) {
record.externalMetadata = null;
} else {
record.externalMetadata = {
...(record.externalMetadata ?? {}),
...patch.externalMetadata,
};
}
}
if (patch.requiresActionDetails !== undefined) {
record.requiresActionDetails = patch.requiresActionDetails;
}
if (patch.lastHeartbeatAt !== undefined) {
record.lastHeartbeatAt = patch.lastHeartbeatAt;
}
record.updatedAt = now;
sessionWorkers.set(sessionId, record);
return record;
}
// ---------- Work Items ---------- // ---------- Work Items ----------
// ---------- Session Ownership (UUID-based) ---------- // ---------- Session Ownership (UUID-based) ----------
@@ -333,6 +272,5 @@ export function storeReset() {
environments.clear(); environments.clear();
sessions.clear(); sessions.clear();
workItems.clear(); workItems.clear();
sessionWorkers.clear();
sessionOwners.clear(); sessionOwners.clear();
} }

View File

@@ -115,109 +115,3 @@ export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
}, },
}); });
} }
function toWorkerClientPayload(event: SessionEvent): Record<string, unknown> {
const normalized =
event.payload && typeof event.payload === "object"
? (event.payload as Record<string, unknown>)
: undefined;
const raw =
normalized?.raw && typeof normalized.raw === "object" && !Array.isArray(normalized.raw)
? (normalized.raw as Record<string, unknown>)
: undefined;
const payload: Record<string, unknown> = {
...(raw ?? normalized ?? {}),
type: event.type,
};
if (event.type === "user") {
const message = payload.message;
if (!message || typeof message !== "object" || !("content" in message)) {
const content =
typeof normalized?.content === "string"
? normalized.content
: typeof payload.content === "string"
? payload.content
: typeof event.payload === "string"
? event.payload
: "";
payload.content = content;
payload.message = { content };
}
}
return payload;
}
function toWorkerClientFrame(event: SessionEvent): string {
const data = JSON.stringify({
event_id: event.id,
sequence_num: event.seqNum,
event_type: event.type,
source: "client",
payload: toWorkerClientPayload(event),
created_at: new Date(event.createdAt).toISOString(),
});
return `id: ${event.seqNum}\nevent: client_event\ndata: ${data}\n\n`;
}
/** Create CCR worker SSE stream (client_event frames, outbound events only). */
export function createWorkerEventStream(c: Context, sessionId: string, fromSeqNum = 0) {
const bus = getEventBus(sessionId);
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
if (fromSeqNum > 0) {
const missed = bus
.getEventsSince(fromSeqNum)
.filter((event) => event.direction === "outbound");
for (const event of missed) {
controller.enqueue(encoder.encode(toWorkerClientFrame(event)));
}
}
controller.enqueue(encoder.encode(": keepalive\n\n"));
const unsub = bus.subscribe((event) => {
if (event.direction !== "outbound") {
return;
}
try {
controller.enqueue(encoder.encode(toWorkerClientFrame(event)));
} catch {
unsub();
}
});
const keepalive = setInterval(() => {
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
} catch {
clearInterval(keepalive);
unsub();
}
}, 15000);
c.req.raw.signal.addEventListener("abort", () => {
unsub();
clearInterval(keepalive);
try {
controller.close();
} catch {
// already closed
}
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

View File

@@ -24,14 +24,13 @@ const SERVER_KEEPALIVE_INTERVAL_MS = 60_000;
*/ */
function toSDKMessage(event: SessionEvent): string { function toSDKMessage(event: SessionEvent): string {
const payload = event.payload as Record<string, unknown> | null; const payload = event.payload as Record<string, unknown> | null;
const messageUuid = typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
let msg: Record<string, unknown>; let msg: Record<string, unknown>;
if (event.type === "user" || event.type === "user_message") { if (event.type === "user" || event.type === "user_message") {
msg = { msg = {
type: "user", type: "user",
uuid: messageUuid, uuid: event.id,
session_id: event.sessionId, session_id: event.sessionId,
message: { message: {
role: "user", role: "user",
@@ -83,7 +82,7 @@ function toSDKMessage(event: SessionEvent): string {
} else { } else {
msg = { msg = {
type: event.type, type: event.type,
uuid: messageUuid, uuid: event.id,
session_id: event.sessionId, session_id: event.sessionId,
message: payload, message: payload,
}; };

View File

@@ -1,5 +1,17 @@
{ {
"extends": "../../tsconfig.base.json", "compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": ".",
"declaration": true,
"resolveJsonModule": true,
"types": ["bun-types"]
},
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "web"] "exclude": ["node_modules", "dist", "web"]
} }

View File

@@ -4,26 +4,18 @@
*/ */
import { getUuid, setUuid, apiBind, apiFetchSessions, apiFetchAllSessions, apiFetchEnvironments, apiFetchSession, apiFetchSessionHistory, apiSendEvent, apiSendControl, apiInterrupt, apiCreateSession } from "./api.js"; import { getUuid, setUuid, apiBind, apiFetchSessions, apiFetchAllSessions, apiFetchEnvironments, apiFetchSession, apiFetchSessionHistory, apiSendEvent, apiSendControl, apiInterrupt, apiCreateSession } from "./api.js";
import { connectSSE, disconnectSSE } from "./sse.js"; import { connectSSE, disconnectSSE } from "./sse.js";
import { appendEvent, showLoading, isLoading, removeLoading, resetReplayState, renderReplayPendingRequests } from "./render.js"; import { appendEvent, renderPermissionRequest, showLoading, isLoading, resetReplayState, renderReplayPendingRequests } from "./render.js";
import { initTaskPanel, toggleTaskPanel, resetTaskState } from "./task-panel.js"; import { initTaskPanel, toggleTaskPanel, resetTaskState } from "./task-panel.js";
import { esc, formatTime, statusClass, isClosedSessionStatus } from "./utils.js"; import { esc, formatTime, statusClass } from "./utils.js";
// ============================================================ // ============================================================
// State // State
// ============================================================ // ============================================================
let currentSessionId = null; let currentSessionId = null;
let currentSessionStatus = null;
let dashboardInterval = null; let dashboardInterval = null;
let cachedEnvs = []; let cachedEnvs = [];
function generateMessageUuid() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
// ============================================================ // ============================================================
// Router // Router
// ============================================================ // ============================================================
@@ -51,69 +43,6 @@ function navigate(path) {
} }
window.navigate = navigate; window.navigate = navigate;
function applySessionStatus(status) {
currentSessionStatus = status || null;
const badge = document.getElementById("session-status");
if (badge) {
badge.textContent = status || "";
badge.className = `status-badge status-${statusClass(status)}`;
}
const closed = isClosedSessionStatus(status);
const input = document.getElementById("msg-input");
if (input) {
input.disabled = closed;
input.placeholder = closed ? "Session is closed" : "Type a message...";
}
const actionBtn = document.getElementById("action-btn");
if (actionBtn) {
actionBtn.disabled = closed;
actionBtn.title = closed ? "Session is closed" : "";
}
if (closed) {
removeLoading();
window.__updateActionBtn?.(false);
}
}
function handleSessionEvent(event) {
if (event?.type === "session_status" && typeof event.payload?.status === "string") {
applySessionStatus(event.payload.status);
if (isClosedSessionStatus(event.payload.status)) {
disconnectSSE();
}
}
appendEvent(event);
}
async function syncClosedSessionState(err, actionLabel) {
if (!(err instanceof Error)) {
alert(`${actionLabel}: unknown error`);
return;
}
if (!currentSessionId || !/session is /i.test(err.message)) {
alert(`${actionLabel}: ${err.message}`);
return;
}
try {
const session = await apiFetchSession(currentSessionId);
applySessionStatus(session.status);
if (isClosedSessionStatus(session.status)) {
appendEvent({ type: "session_status", payload: { status: session.status } });
return;
}
} catch {
// Fall back to the original error if the refresh also fails.
}
alert(`${actionLabel}: ${err.message}`);
}
async function handleRoute() { async function handleRoute() {
// Ensure we have a UUID // Ensure we have a UUID
getUuid(); getUuid();
@@ -157,8 +86,6 @@ async function handleRoute() {
} }
// Default: /code → dashboard // Default: /code → dashboard
currentSessionId = null;
currentSessionStatus = null;
showPage("dashboard"); showPage("dashboard");
disconnectSSE(); disconnectSSE();
renderDashboard(); renderDashboard();
@@ -245,7 +172,9 @@ async function renderSessionDetail(id) {
document.getElementById("session-id").textContent = session.id; document.getElementById("session-id").textContent = session.id;
document.getElementById("session-env").textContent = session.environment_id || ""; document.getElementById("session-env").textContent = session.environment_id || "";
document.getElementById("session-time").textContent = formatTime(session.created_at); document.getElementById("session-time").textContent = formatTime(session.created_at);
applySessionStatus(session.status); const badge = document.getElementById("session-status");
badge.textContent = session.status;
badge.className = `status-badge status-${statusClass(session.status)}`;
} catch (err) { } catch (err) {
alert("Failed to load session: " + err.message); alert("Failed to load session: " + err.message);
navigate("/code/"); navigate("/code/");
@@ -272,13 +201,7 @@ async function renderSessionDetail(id) {
// Re-render any still-unresolved permission prompts from history // Re-render any still-unresolved permission prompts from history
renderReplayPendingRequests(); renderReplayPendingRequests();
if (isClosedSessionStatus(currentSessionStatus)) { connectSSE(id, appendEvent, lastSeqNum);
appendEvent({ type: "session_status", payload: { status: currentSessionStatus } });
disconnectSSE();
return;
}
connectSSE(id, handleSessionEvent, lastSeqNum);
} }
// ============================================================ // ============================================================
@@ -314,35 +237,28 @@ function setupControlBar() {
} }
async function doInterrupt() { async function doInterrupt() {
if (!currentSessionId || isClosedSessionStatus(currentSessionStatus)) return; if (!currentSessionId) return;
const btn = document.getElementById("action-btn"); const btn = document.getElementById("action-btn");
btn.disabled = true; btn.disabled = true;
try { try {
await apiInterrupt(currentSessionId); await apiInterrupt(currentSessionId);
appendEvent({ type: "interrupt", payload: { message: "Session interrupted" } }); appendEvent({ type: "interrupt", payload: { message: "Session interrupted" } });
} catch (err) { } catch (err) {
await syncClosedSessionState(err, "Interrupt failed"); alert("Interrupt failed: " + err.message);
} finally { } finally {
btn.disabled = isClosedSessionStatus(currentSessionStatus); btn.disabled = false;
} }
} }
async function sendMessage() { async function sendMessage() {
const input = document.getElementById("msg-input"); const input = document.getElementById("msg-input");
const text = input.value.trim(); const text = input.value.trim();
if (!text || !currentSessionId || isClosedSessionStatus(currentSessionStatus)) return; if (!text || !currentSessionId) return;
input.value = ""; input.value = "";
const uuid = generateMessageUuid();
try { try {
await apiSendEvent(currentSessionId, { await apiSendEvent(currentSessionId, { type: "user", content: text });
type: "user",
uuid,
content: text,
message: { content: text },
});
} catch (err) { } catch (err) {
input.value = text; alert("Failed to send: " + err.message);
await syncClosedSessionState(err, "Failed to send");
} }
} }

View File

@@ -150,7 +150,6 @@ nav {
.status-active, .status-running { background: var(--green-bg); color: var(--green); } .status-active, .status-running { background: var(--green-bg); color: var(--green); }
.status-idle { background: var(--yellow-bg); color: var(--yellow); } .status-idle { background: var(--yellow-bg); color: var(--yellow); }
.status-inactive { background: #F0ECE7; color: var(--text-secondary); }
.status-requires_action { background: var(--orange-bg); color: var(--orange); } .status-requires_action { background: var(--orange-bg); color: var(--orange); }
.status-archived { background: #F0ECE7; color: var(--text-secondary); } .status-archived { background: #F0ECE7; color: var(--text-secondary); }
.status-error { background: var(--red-bg); color: var(--red); } .status-error { background: var(--red-bg); color: var(--red); }

View File

@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=Figtree:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700&family=Figtree:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap" />
<link rel="stylesheet" href="/code/style.css" /> <link rel="stylesheet" href="./style.css" />
</head> </head>
<body> <body>
<!-- Nav Bar --> <!-- Nav Bar -->
@@ -146,6 +146,6 @@
<!-- QR Libraries --> <!-- QR Libraries -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js"></script> <script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js"></script>
<script type="module" src="/code/app.js"></script> <script type="module" src="./app.js"></script>
</body> </body>
</html> </html>

View File

@@ -13,13 +13,11 @@ import { processAssistantEvent } from "./task-panel.js";
const replayPendingRequests = new Map(); // request_id → event data (unresolved) const replayPendingRequests = new Map(); // request_id → event data (unresolved)
const replayRespondedRequests = new Set(); // request_ids that have a response const replayRespondedRequests = new Set(); // request_ids that have a response
const renderedUserUuids = new Set();
/** Clear replay tracking state (call before each history load) */ /** Clear replay tracking state (call before each history load) */
export function resetReplayState() { export function resetReplayState() {
replayPendingRequests.clear(); replayPendingRequests.clear();
replayRespondedRequests.clear(); replayRespondedRequests.clear();
renderedUserUuids.clear();
} }
/** After replay finishes, render any still-unresolved permission prompts */ /** After replay finishes, render any still-unresolved permission prompts */
@@ -86,59 +84,6 @@ function formatAssistantContent(content) {
return html; return html;
} }
function getUserUuid(payload) {
if (!payload || typeof payload !== "object") return null;
if (typeof payload.uuid === "string" && payload.uuid) return payload.uuid;
if (payload.raw && typeof payload.raw === "object" && typeof payload.raw.uuid === "string" && payload.raw.uuid) {
return payload.raw.uuid;
}
return null;
}
function shouldRenderUserEvent(payload, direction, replay) {
const uuid = getUserUuid(payload);
if (uuid) {
if (renderedUserUuids.has(uuid)) return false;
renderedUserUuids.add(uuid);
return true;
}
// Legacy fallback with no uuid: keep the previous no-duplicate behavior.
// Live inbound user events without a uuid are most likely echoes of a web-
// sent message; replay keeps the prior "outbound only" rule as well.
return direction === "outbound";
}
function getMessageContentBlocks(payload) {
if (!payload || typeof payload !== "object") return [];
const msg = payload.message;
if (!msg || typeof msg !== "object" || !Array.isArray(msg.content)) return [];
return msg.content.filter((block) => block && typeof block === "object");
}
function renderEmbeddedToolUseBlocks(payload) {
return getMessageContentBlocks(payload)
.filter((block) => block.type === "tool_use")
.map((block) =>
renderToolUse({
tool_name: block.name || "tool",
tool_input: block.input || {},
}),
);
}
function renderEmbeddedToolResultBlocks(payload) {
return getMessageContentBlocks(payload)
.filter((block) => block.type === "tool_result")
.map((block) =>
renderToolResult({
content: block.content || "",
output: block.content || "",
is_error: !!block.is_error,
}),
);
}
// ============================================================ // ============================================================
// Event Router // Event Router
// ============================================================ // ============================================================
@@ -158,42 +103,26 @@ export function appendEvent(data, { replay = false } = {}) {
// During history replay, only render messages & tools — skip interactive/stateful events // During history replay, only render messages & tools — skip interactive/stateful events
// Exception: unresolved permission/control requests are re-shown as pending prompts. // Exception: unresolved permission/control requests are re-shown as pending prompts.
if (replay) { if (replay) {
const histEls = []; let histEl;
switch (type) { switch (type) {
case "user": case "user":
{ if (direction === "outbound") histEl = renderUserMessage(payload, direction);
const toolResultEls = renderEmbeddedToolResultBlocks(payload);
if (toolResultEls.length > 0) {
histEls.push(...toolResultEls);
break;
}
if (shouldRenderUserEvent(payload, direction, true)) {
histEls.push(renderUserMessage(payload, direction));
}
}
break; break;
case "assistant": case "assistant":
{ {
const toolUseEls = renderEmbeddedToolUseBlocks(payload);
const text = extractText(payload); const text = extractText(payload);
if (text && text.trim()) histEls.push(renderAssistantMessage(payload)); if (text && text.trim()) histEl = renderAssistantMessage(payload);
if (toolUseEls.length > 0) histEls.push(...toolUseEls);
processAssistantEvent(payload); processAssistantEvent(payload);
} }
break; break;
case "tool_use": case "tool_use":
histEls.push(renderToolUse(payload)); histEl = renderToolUse(payload);
break; break;
case "tool_result": case "tool_result":
histEls.push(renderToolResult(payload)); histEl = renderToolResult(payload);
break; break;
case "error": case "error":
histEls.push(renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`)); histEl = renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`);
break;
case "session_status":
if (payload.status === "archived" || payload.status === "inactive") {
histEls.push(renderSystemMessage(`Session ${payload.status}`));
}
break; break;
case "control_request": case "control_request":
case "permission_request": case "permission_request":
@@ -220,42 +149,32 @@ export function appendEvent(data, { replay = false } = {}) {
default: default:
return; return;
} }
for (const histEl of histEls) { if (histEl) {
stream.appendChild(histEl); stream.appendChild(histEl);
stream.scrollTop = stream.scrollHeight; stream.scrollTop = stream.scrollHeight;
} }
return; return;
} }
const els = []; let el;
let needLoading = false; let needLoading = false;
switch (type) { switch (type) {
case "user": case "user":
{ // Skip inbound user messages — they're echoes of what we already sent
const toolResultEls = renderEmbeddedToolResultBlocks(payload); if (direction === "inbound") return;
if (toolResultEls.length > 0) { el = renderUserMessage(payload, direction);
els.push(...toolResultEls); needLoading = true;
break;
}
if (!shouldRenderUserEvent(payload, direction, false)) return;
els.push(renderUserMessage(payload, direction));
needLoading = true;
}
break; break;
case "partial_assistant": case "partial_assistant":
// Skip partial assistant — wait for the final "assistant" event // Skip partial assistant — wait for the final "assistant" event
// to avoid blank/duplicate messages during streaming // to avoid blank/duplicate messages during streaming
return; return;
case "assistant": case "assistant":
removeLoading();
{ {
const toolUseEls = renderEmbeddedToolUseBlocks(payload);
const text = extractText(payload); const text = extractText(payload);
if (text && text.trim()) { if (text && text.trim()) el = renderAssistantMessage(payload);
removeLoading();
els.push(renderAssistantMessage(payload));
}
if (toolUseEls.length > 0) els.push(...toolUseEls);
processAssistantEvent(payload); processAssistantEvent(payload);
} }
break; break;
@@ -265,10 +184,10 @@ export function appendEvent(data, { replay = false } = {}) {
// Skip result — it just repeats the assistant message content // Skip result — it just repeats the assistant message content
return; return;
case "tool_use": case "tool_use":
els.push(renderToolUse(payload)); el = renderToolUse(payload);
break; break;
case "tool_result": case "tool_result":
els.push(renderToolResult(payload)); el = renderToolResult(payload);
break; break;
case "control_request": case "control_request":
case "permission_request": case "permission_request":
@@ -276,27 +195,27 @@ export function appendEvent(data, { replay = false } = {}) {
const toolName = payload.request.tool_name || "unknown"; const toolName = payload.request.tool_name || "unknown";
const toolInput = payload.request.input || payload.request.tool_input || {}; const toolInput = payload.request.input || payload.request.tool_input || {};
if (toolName === "AskUserQuestion") { if (toolName === "AskUserQuestion") {
els.push(renderAskUserQuestion({ el = renderAskUserQuestion({
request_id: payload.request_id || data.id, request_id: payload.request_id || data.id,
tool_input: toolInput, tool_input: toolInput,
description: payload.request.description || "", description: payload.request.description || "",
})); });
} else if (toolName === "ExitPlanMode") { } else if (toolName === "ExitPlanMode") {
els.push(renderExitPlanMode({ el = renderExitPlanMode({
request_id: payload.request_id || data.id, request_id: payload.request_id || data.id,
tool_input: toolInput, tool_input: toolInput,
description: payload.request.description || "", description: payload.request.description || "",
})); });
} else { } else {
els.push(renderPermissionRequest({ el = renderPermissionRequest({
request_id: payload.request_id || data.id, request_id: payload.request_id || data.id,
tool_name: toolName, tool_name: toolName,
tool_input: toolInput, tool_input: toolInput,
description: payload.request.description || "", description: payload.request.description || "",
})); });
} }
} else { } else {
els.push(renderSystemMessage(`Control: ${payload.request?.subtype || "unknown"}`)); el = renderSystemMessage(`Control: ${payload.request?.subtype || "unknown"}`);
} }
break; break;
case "control_response": case "control_response":
@@ -310,22 +229,16 @@ export function appendEvent(data, { replay = false } = {}) {
const fullText = typeof payload === "string" ? payload : JSON.stringify(payload); const fullText = typeof payload === "string" ? payload : JSON.stringify(payload);
if (/connecting|waiting|initializing|Remote Control/i.test(msg + " " + fullText)) return; if (/connecting|waiting|initializing|Remote Control/i.test(msg + " " + fullText)) return;
if (!msg.trim()) return; if (!msg.trim()) return;
els.push(renderSystemMessage(msg)); el = renderSystemMessage(msg);
} }
break; break;
case "error": case "error":
removeLoading(); removeLoading();
els.push(renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`)); el = renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`);
break;
case "session_status":
if (payload.status === "archived" || payload.status === "inactive") {
removeLoading();
els.push(renderSystemMessage(`Session ${payload.status}`));
}
break; break;
case "interrupt": case "interrupt":
removeLoading(); removeLoading();
els.push(renderSystemMessage("Session interrupted")); el = renderSystemMessage("Session interrupted");
break; break;
case "system": case "system":
// Skip raw system/init messages — they're noise // Skip raw system/init messages — they're noise
@@ -334,11 +247,11 @@ export function appendEvent(data, { replay = false } = {}) {
// Skip noise from bridge init // Skip noise from bridge init
const raw = JSON.stringify(payload); const raw = JSON.stringify(payload);
if (/Remote Control connecting/i.test(raw)) return; if (/Remote Control connecting/i.test(raw)) return;
els.push(renderSystemMessage(`${type}: ${truncate(raw, 200)}`)); el = renderSystemMessage(`${type}: ${truncate(raw, 200)}`);
} }
} }
for (const el of els) { if (el) {
stream.appendChild(el); stream.appendChild(el);
stream.scrollTop = stream.scrollHeight; stream.scrollTop = stream.scrollHeight;
} }

View File

@@ -19,14 +19,9 @@ export function statusClass(status) {
active: "active", active: "active",
running: "running", running: "running",
idle: "idle", idle: "idle",
inactive: "inactive",
requires_action: "requires_action", requires_action: "requires_action",
archived: "archived", archived: "archived",
error: "error", error: "error",
}; };
return map[status] || "default"; return map[status] || "default";
} }
export function isClosedSessionStatus(status) {
return status === "archived" || status === "inactive";
}

View File

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

View File

@@ -37,8 +37,6 @@ const DEFAULT_FEATURES = [
"KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN", "KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN",
// P2: daemon + remote control server // P2: daemon + remote control server
"DAEMON", "DAEMON",
// ACP (Agent Client Protocol) agent mode
"ACP",
// PR-package restored features // PR-package restored features
"WORKFLOW_SCRIPTS", "WORKFLOW_SCRIPTS",
"HISTORY_SNIP", "HISTORY_SNIP",
@@ -49,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

@@ -1,90 +0,0 @@
#!/usr/bin/env bun
/**
* Post-build processing for Vite build output.
*
* 1. Patch globalThis.Bun destructuring in third-party deps for Node.js compat
* 2. Copy native addon files
* 3. Bundle standalone scripts (download-ripgrep)
* 4. Generate dual entry points (cli-bun.js, cli-node.js)
*/
import { readdir, readFile, writeFile, cp } from "node:fs/promises";
import { chmodSync } from "node:fs";
import { join } from "node:path";
import { execSync } from "node:child_process";
const outdir = "dist";
async function postBuild() {
// Step 1: Patch globalThis.Bun destructuring from third-party deps
const files = await readdir(outdir, { recursive: true });
const BUN_DESTRUCTURE = /var \{([^}]+)\} = globalThis\.Bun;?/g;
const BUN_DESTRUCTURE_SAFE =
'var {$1} = typeof globalThis.Bun !== "undefined" ? globalThis.Bun : {};';
let bunPatched = 0;
for (const file of files) {
const filePath = join(outdir, file);
if (typeof file !== "string" || !file.endsWith(".js")) continue;
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;
}
// Step 2: Copy native addon files
const vendorDir = join(outdir, "vendor", "audio-capture");
await cp("vendor/audio-capture", vendorDir, { recursive: true } as never);
console.log(`Copied vendor/audio-capture/ → ${vendorDir}/`);
// Step 3: Bundle standalone scripts via Bun.build (kept for simplicity)
try {
const { default: Bun } = await import("bun");
const rgScript = await Bun.build({
entrypoints: ["scripts/download-ripgrep.ts"],
outdir,
target: "node",
});
if (rgScript.success) {
console.log(`Bundled download-ripgrep script to ${outdir}/`);
} else {
console.warn("Failed to bundle download-ripgrep script (non-fatal)");
}
} catch {
// Bun not available — try esbuild fallback
try {
execSync(
`npx esbuild scripts/download-ripgrep.ts --bundle --platform=node --outfile=${outdir}/download-ripgrep.js --format=esm`,
{ stdio: "inherit" },
);
console.log(`Bundled download-ripgrep script via esbuild to ${outdir}/`);
} catch {
console.warn(
"Failed to bundle download-ripgrep script — skipping (non-fatal)",
);
}
}
// Step 4: Generate dual entry points
const cliBun = join(outdir, "cli-bun.js");
const cliNode = join(outdir, "cli-node.js");
await writeFile(cliBun, '#!/usr/bin/env bun\nimport "./cli.js"\n');
await writeFile(cliNode, '#!/usr/bin/env node\nimport "./cli.js"\n');
chmodSync(cliBun, 0o755);
chmodSync(cliNode, 0o755);
console.log(
`Post-build complete: patched ${bunPatched} Bun destructure, generated entry points`,
);
}
postBuild().catch((err) => {
console.error("Post-build failed:", err);
process.exit(1);
});

View File

@@ -1,118 +0,0 @@
import type { Plugin } from "rollup";
/**
* Default features that match the official CLI build.
* Additional features can be enabled via FEATURE_<NAME>=1 env vars.
*/
const DEFAULT_BUILD_FEATURES = [
"AGENT_TRIGGERS_REMOTE",
"CHICAGO_MCP",
"VOICE_MODE",
"SHOT_STATS",
"PROMPT_CACHE_BREAK_DETECTION",
"TOKEN_BUDGET",
// P0: local features
"AGENT_TRIGGERS",
"ULTRATHINK",
"BUILTIN_EXPLORE_PLAN_AGENTS",
"LODESTONE",
// P1: API-dependent features
"EXTRACT_MEMORIES",
"VERIFICATION_AGENT",
"KAIROS_BRIEF",
"AWAY_SUMMARY",
"ULTRAPLAN",
// P2: daemon + remote control server
"DAEMON",
// PR-package restored features
"WORKFLOW_SCRIPTS",
"HISTORY_SNIP",
"CONTEXT_COLLAPSE",
"MONITOR_TOOL",
"FORK_SUBAGENT",
"KAIROS",
"COORDINATOR_MODE",
"LAN_PIPES",
// P3: poor mode
"POOR",
];
/**
* Collect enabled feature flags from defaults + env vars.
*/
export function getEnabledFeatures(): Set<string> {
const envFeatures = Object.keys(process.env)
.filter((k) => k.startsWith("FEATURE_"))
.map((k) => k.replace("FEATURE_", ""));
return new Set([...DEFAULT_BUILD_FEATURES, ...envFeatures]);
}
// Regex to match feature('FLAG_NAME') calls with string literal arguments
const FEATURE_CALL_RE = /feature\s*\(\s*['"]([\w]+)['"]\s*\)/g;
/**
* Vite/Rollup plugin that replaces `feature('X')` calls with boolean literals
* at the transform stage, BEFORE the bundler resolves imports.
*
* This approach is necessary because some feature-gated code blocks contain
* require() calls to files that don't exist (e.g. hunter.js inside
* feature('REVIEW_ARTIFACT')). The bundler must see these as dead code
* (`if (false) { ... }`) before attempting import resolution.
*
* Also resolves `import { feature } from 'bun:bundle'` as a virtual module
* to prevent "module not found" errors.
*/
export default function featureFlagsPlugin(): Plugin {
const features = getEnabledFeatures();
const virtualModuleId = "bun:bundle";
const resolvedVirtualModuleId = "\0" + virtualModuleId;
return {
name: "feature-flags",
// Resolve bun:bundle as a virtual module (prevents "module not found")
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
},
// Provide a stub export for bun:bundle (unused at runtime after transform)
load(id) {
if (id === resolvedVirtualModuleId) {
return "export function feature(name) { return false; }";
}
},
// Replace feature('X') calls with true/false literals at transform time,
// and transpile `using` declarations for Node.js compatibility.
transform(code, id) {
// Skip node_modules
if (id.includes("node_modules")) return null;
let modified = false;
// 1. Replace feature('X') calls with boolean literals
let matchCount = 0;
let transformed = code.replace(FEATURE_CALL_RE, (match, flagName) => {
matchCount++;
return features.has(flagName) ? "true" : "false";
});
if (matchCount > 0) modified = true;
// 2. Transpile `using _ = expr;` to `const _ = expr;` for Node.js compat.
// Node.js v22 does not support `using` declarations (Explicit Resource Management).
// Safe because: SLOW_OPERATION_LOGGING is not enabled, so slowLogging returns
// a no-op disposable whose [Symbol.dispose]() is empty.
if (transformed.includes("using _")) {
transformed = transformed.replace(/\busing\s+(_\w*)\s*=/g, "const $1 =");
modified = true;
}
if (!modified) return null;
return { code: transformed, map: null };
},
};
}

View File

@@ -1,25 +0,0 @@
import type { Plugin } from "rollup";
/**
* Rollup plugin that replaces `var __require = import.meta.require;`
* with a Node.js compatible version that falls back to createRequire
* when import.meta.require is not available (e.g. in Node.js runtime).
*
* This replicates the post-processing done in the original build.ts.
*/
export default function importMetaRequirePlugin(): Plugin {
return {
name: "import-meta-require",
renderChunk(code) {
const pattern = "var __require = import.meta.require;";
const replacement =
'var __require = typeof import.meta.require === "function" ? import.meta.require : (await import("module")).createRequire(import.meta.url);';
if (code.includes(pattern)) {
return code.replace(pattern, replacement);
}
return null;
},
};
}

View File

@@ -1184,17 +1184,6 @@ export class QueryEngine {
this.abortController.abort() this.abortController.abort()
} }
/** Reset the abort controller so the next submitMessage() call can start
* with a fresh, non-aborted signal. Must be called after interrupt(). */
resetAbortController(): void {
this.abortController = createAbortController()
}
/** Expose the current abort signal for external consumers (e.g. ACP bridge). */
getAbortSignal(): AbortSignal {
return this.abortController.signal
}
getMessages(): readonly Message[] { getMessages(): readonly Message[] {
return this.mutableMessages return this.mutableMessages
} }

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

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