mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 14:55:50 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bddffa216a |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -27,4 +27,4 @@ jobs:
|
||||
run: bun test
|
||||
|
||||
- name: Build
|
||||
run: bun run build:vite
|
||||
run: bun run build
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,7 +15,7 @@ src/utils/vendor/
|
||||
.claude/
|
||||
.codex/
|
||||
.omx/
|
||||
|
||||
.docs/task/
|
||||
# Binary / screenshot files (root only)
|
||||
/*.png
|
||||
*.bmp
|
||||
|
||||
204
02-kairos (1).md
Normal file
204
02-kairos (1).md
Normal file
@@ -0,0 +1,204 @@
|
||||
# KAIROS — 永不关机的 Claude
|
||||
|
||||
> 源码位置:`src/assistant/`、`src/proactive/`、`src/services/autoDream/`
|
||||
> 编译开关:`feature('KAIROS')`、`feature('KAIROS_BRIEF')`、`feature('KAIROS_CHANNELS')`
|
||||
> 远程开关:GrowthBook `tengu_kairos`
|
||||
|
||||
关掉终端 Claude 还在运行的持久助手模式。KAIROS 是 Claude Code 中最复杂的隐藏功能之一。
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
KAIROS 让 Claude 从"一次性对话工具"变成"持久运行的 AI 助手":
|
||||
|
||||
- 关闭终端后 Claude 仍在后台运行
|
||||
- 每天自动写日志
|
||||
- 晚上自动"做梦"整理记忆
|
||||
- 没人说话时自己找活干
|
||||
- 命令超 15 秒自动丢后台
|
||||
|
||||
---
|
||||
|
||||
## 激活流程
|
||||
|
||||
定义在 `src/main.tsx`(约第 1054-1092 行),需要通过五层检查:
|
||||
|
||||
```
|
||||
1. feature('KAIROS') ← 编译时 flag
|
||||
2. settings.assistant: true ← .claude/settings.json
|
||||
3. 目录信任状态检查 ← 防恶意仓库劫持
|
||||
4. tengu_kairos ← GrowthBook 远程开关
|
||||
5. setKairosActive(true) ← 全局状态激活
|
||||
```
|
||||
|
||||
`--assistant` CLI 参数可跳过远程开关检查(用于 Agent SDK daemon 模式)。
|
||||
|
||||
全局状态存储在 `src/bootstrap/state.ts`:
|
||||
- `kairosActive: boolean`(默认 `false`)
|
||||
- `getKairosActive()` / `setKairosActive(true)`
|
||||
|
||||
---
|
||||
|
||||
## 跨会话持久运行
|
||||
|
||||
### 会话恢复
|
||||
|
||||
`src/utils/conversationRecovery.ts` 中使用 `feature('KAIROS')` 条件导入 `BriefTool` 和 `SendUserFileTool`。在反序列化会话时识别这些工具的结果为"终端工具结果",判断 turn 是正常完成还是被中断。
|
||||
|
||||
### 持久 Cron 任务
|
||||
|
||||
关键在 `.claude/scheduled_tasks.json`。标记为 `permanent: true` 的任务不受 7 天自动过期限制:
|
||||
|
||||
- `catch-up`:恢复中断的工作
|
||||
- `morning-checkin`:每日早间签到
|
||||
- `dream`:记忆整合
|
||||
|
||||
### 会话历史 API
|
||||
|
||||
`src/assistant/sessionHistory.ts` 通过 OAuth API 加载远程会话历史,使用 `v1/sessions/{sessionId}/events` 端点,支持分页拉取。
|
||||
|
||||
---
|
||||
|
||||
## 做梦机制(Dream)
|
||||
|
||||
KAIROS 最精巧的子系统——后台运行的子代理,将分散的会话记忆整合为持久的结构化知识。
|
||||
|
||||
### 触发条件(三层门控,由廉到贵)
|
||||
|
||||
定义在 `src/services/autoDream/autoDream.ts`:
|
||||
|
||||
```
|
||||
1. 时间门控:距上次整合超过 24 小时(minHours)
|
||||
2. 会话门控:至少 5 个新会话(minSessions)
|
||||
3. 锁门控:没有其他进程正在整合
|
||||
```
|
||||
|
||||
阈值通过 GrowthBook `tengu_onyx_plover` 远程配置动态控制。
|
||||
|
||||
### 四阶段整合流程
|
||||
|
||||
定义在 `src/services/autoDream/consolidationPrompt.ts`:
|
||||
|
||||
| 阶段 | 动作 |
|
||||
|------|------|
|
||||
| **Orient** | 列出记忆目录、读取 `MEMORY.md` 索引、浏览已有主题文件 |
|
||||
| **Gather** | 从每日日志、已有记忆、JSONL transcript 中搜集新信号 |
|
||||
| **Consolidate** | 合并新信号到主题文件,转换相对日期为绝对日期,删除过时事实 |
|
||||
| **Prune** | 更新 `MEMORY.md` 索引,保持在行数和大小限制内 |
|
||||
|
||||
### 锁机制
|
||||
|
||||
`src/services/autoDream/consolidationLock.ts`:
|
||||
|
||||
- 使用 `.consolidate-lock` 文件
|
||||
- 文件 mtime = `lastConsolidatedAt`
|
||||
- 文件内容 = 持有者 PID
|
||||
- 支持 PID 存活检查(1 小时超时)
|
||||
- double-write 后 re-read 验证防竞争
|
||||
|
||||
### 每日日志
|
||||
|
||||
路径由 `src/memdir/paths.ts` 的 `getAutoMemDailyLogPath()` 计算:
|
||||
|
||||
```
|
||||
<autoMemPath>/logs/YYYY/MM/YYYY-MM-DD.md
|
||||
```
|
||||
|
||||
### UI 呈现
|
||||
|
||||
- Footer pill 标签显示 **"dreaming"**
|
||||
- `src/components/tasks/DreamDetailDialog.tsx` 提供专门的详情对话框
|
||||
- 支持查看实时进度和手动中止
|
||||
- `Shift+Down` 打开后台任务对话框
|
||||
|
||||
---
|
||||
|
||||
## 主动模式(Proactive Mode)
|
||||
|
||||
没人说话时 Claude 自己找活干。
|
||||
|
||||
### 核心状态
|
||||
|
||||
`src/proactive/index.ts` 维护三个状态:
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| `active` | 是否激活 |
|
||||
| `paused` | 是否暂停(用户按 Esc 取消时暂停,下次输入恢复) |
|
||||
| `contextBlocked` | API 错误时阻塞 tick,防止 tick-error-tick 死循环 |
|
||||
|
||||
### 激活方式
|
||||
|
||||
- `--proactive` CLI 参数
|
||||
- `CLAUDE_CODE_PROACTIVE` 环境变量
|
||||
- 受 `feature('PROACTIVE') || feature('KAIROS')` 保护
|
||||
|
||||
### 系统提示
|
||||
|
||||
激活后追加:
|
||||
|
||||
```
|
||||
# Proactive Mode
|
||||
|
||||
You are in proactive mode. Take initiative -- explore, act, and make progress
|
||||
without waiting for instructions.
|
||||
|
||||
Start by briefly greeting the user.
|
||||
|
||||
You will receive periodic <tick> prompts. These are check-ins. Do whatever
|
||||
seems most useful, or call Sleep if there's nothing to do.
|
||||
```
|
||||
|
||||
### SleepTool 集成
|
||||
|
||||
设置中的 `minSleepDurationMs` 和 `maxSleepDurationMs` 控制 Sleep 持续时间范围,节流 proactive tick 频率。没活干就 Sleep 等着。
|
||||
|
||||
---
|
||||
|
||||
## 后台任务管理
|
||||
|
||||
### Cron 调度器
|
||||
|
||||
`src/utils/cronScheduler.ts`:
|
||||
|
||||
- 每 1 秒 tick 一次(`CHECK_INTERVAL_MS = 1000`)
|
||||
- 使用 chokidar 监视 `.claude/scheduled_tasks.json`
|
||||
- 支持调度器锁(`src/utils/cronTasksLock.ts`),防止多实例重复触发
|
||||
- 锁探测间隔 5 秒,持有者崩溃时自动接管
|
||||
|
||||
### 任务类型
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| 一次性(`recurring: false`) | 触发后自动删除,支持错过任务检测 |
|
||||
| 循环(`recurring: true`) | 触发后重新调度,默认 7 天过期 |
|
||||
| 永久(`permanent: true`) | 不受过期限制(KAIROS 专用) |
|
||||
| 会话级(`durable: false`) | 仅内存中,进程退出即消失 |
|
||||
|
||||
### Jitter 防雷群机制
|
||||
|
||||
`src/utils/cronJitterConfig.ts`:
|
||||
|
||||
- 循环任务:基于 taskId 的确定性延迟(interval 的 10%,上限 15 分钟)
|
||||
- 一次性任务:在 :00 和 :30 施加最多 90 秒提前量
|
||||
- 运维可在事故期间推送配置变更,60 秒内全客户端生效
|
||||
|
||||
---
|
||||
|
||||
## 关键源码文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/bootstrap/state.ts` | KAIROS 全局状态 |
|
||||
| `src/assistant/index.ts` | 助手模式入口 |
|
||||
| `src/assistant/sessionHistory.ts` | 远程会话历史 API |
|
||||
| `src/proactive/index.ts` | 主动模式状态管理 |
|
||||
| `src/services/autoDream/autoDream.ts` | Auto-Dream 引擎 |
|
||||
| `src/services/autoDream/consolidationPrompt.ts` | 整合提示(四阶段) |
|
||||
| `src/services/autoDream/consolidationLock.ts` | 整合锁 |
|
||||
| `src/services/autoDream/config.ts` | Dream 配置 |
|
||||
| `src/tasks/DreamTask/DreamTask.ts` | Dream 任务定义 |
|
||||
| `src/utils/cronScheduler.ts` | Cron 调度器 |
|
||||
| `src/utils/cronTasks.ts` | Cron 任务持久化 |
|
||||
| `src/skills/bundled/dream.ts` | `/dream` Skill(存根) |
|
||||
@@ -55,8 +55,6 @@ bun run health
|
||||
# Check unused exports
|
||||
bun run check:unused
|
||||
|
||||
bun run typecheck
|
||||
|
||||
# Remote Control Server
|
||||
bun run rcs
|
||||
|
||||
|
||||
@@ -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) |
|
||||
| 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) |
|
||||
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
|
||||
| Langfuse 监控 | LLM 调用/工具执行/多 Agent 全链路追踪 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
||||
|
||||
66
build.ts
66
build.ts
@@ -30,8 +30,6 @@ const DEFAULT_BUILD_FEATURES = [
|
||||
'ULTRAPLAN',
|
||||
// P2: daemon + remote control server
|
||||
'DAEMON',
|
||||
// ACP (Agent Client Protocol) agent mode
|
||||
'ACP',
|
||||
// PR-package restored features
|
||||
'WORKFLOW_SCRIPTS',
|
||||
'HISTORY_SNIP',
|
||||
@@ -42,6 +40,8 @@ const DEFAULT_BUILD_FEATURES = [
|
||||
'KAIROS',
|
||||
'COORDINATOR_MODE',
|
||||
'LAN_PIPES',
|
||||
'BG_SESSIONS',
|
||||
'TEMPLATES',
|
||||
// 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性
|
||||
// P3: poor mode (disable extract_memories + prompt_suggestion)
|
||||
'POOR',
|
||||
@@ -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(
|
||||
`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)
|
||||
@@ -140,7 +121,46 @@ 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')
|
||||
// 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
|
||||
const { chmodSync } = await import('fs')
|
||||
|
||||
106
bun.lock
106
bun.lock
@@ -5,9 +5,7 @@
|
||||
"": {
|
||||
"name": "claude-code-best",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.19.0",
|
||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.7",
|
||||
"ws": "^8.20.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.3.0",
|
||||
@@ -59,11 +57,10 @@
|
||||
"@sentry/node": "^10.47.0",
|
||||
"@smithy/core": "^3.23.13",
|
||||
"@smithy/node-http-handler": "^4.5.1",
|
||||
"@types/bun": "^1.3.12",
|
||||
"@types/bun": "^1.3.11",
|
||||
"@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/plist": "^3.0.5",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
@@ -119,7 +116,6 @@
|
||||
"react": "^19.2.4",
|
||||
"react-compiler-runtime": "^1.0.0",
|
||||
"react-reconciler": "^0.33.0",
|
||||
"rollup": "^4.60.1",
|
||||
"semver": "^7.7.4",
|
||||
"sharp": "^0.34.5",
|
||||
"shell-quote": "^1.8.3",
|
||||
@@ -134,11 +130,11 @@
|
||||
"undici": "^7.24.6",
|
||||
"url-handler-napi": "workspace:*",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.8",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"vscode-languageserver-protocol": "^3.17.5",
|
||||
"vscode-languageserver-types": "^3.17.5",
|
||||
"wrap-ansi": "^10.0.0",
|
||||
"ws": "^8.20.0",
|
||||
"xss": "^1.0.15",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6",
|
||||
@@ -257,8 +253,6 @@
|
||||
},
|
||||
},
|
||||
"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=="],
|
||||
|
||||
"@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/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"],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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-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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rollup": ["rollup@4.60.1", "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
||||
|
||||
@@ -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-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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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-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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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/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/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/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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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 |
@@ -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` | ✅ | 配置更新通知 |
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
### 第一步:安装 Chrome 扩展
|
||||
|
||||
1. 下载扩展:https://github.com/hangwin/mcp-chrome/releases
|
||||
1. 下载扩展:https://github.com/hangwin/mcp-chrome/releases(下载最新 zip)
|
||||
2. 解压 zip 文件
|
||||
3. 打开 Chrome 访问 `chrome://extensions/`
|
||||
4. 开启右上角「开发者模式」
|
||||
|
||||
@@ -138,19 +138,13 @@ bun run dist/cli.js
|
||||
/remote-control
|
||||
```
|
||||
|
||||
环境型 Remote Control(例如 `claude remote-control` 子命令)会向 RCS 注册环境,注册成功后在终端显示连接 URL:
|
||||
CLI 会向 RCS 注册环境,注册成功后在终端显示连接 URL:
|
||||
|
||||
```
|
||||
https://rcs.example.com/code?bridge=<environmentId>
|
||||
```
|
||||
|
||||
交互式 REPL 方式(`--remote-control` 或 `/remote-control`)在某些桥接模式下也可能直接给出会话 URL:
|
||||
|
||||
```
|
||||
https://rcs.example.com/code/session_<id>
|
||||
```
|
||||
|
||||
两种 URL 都可以直接在浏览器打开并远程操控当前会话;只有 environment 模式才会出现在 Web UI 的环境列表中。
|
||||
同时支持 QR 码扫码打开。该 URL 即 Web UI 控制面板入口,在浏览器中打开即可远程操控当前会话。
|
||||
|
||||
若已连接,再次执行 `/remote-control` 会显示对话框,包含以下选项:
|
||||
- **Disconnect this session** — 断开远程连接
|
||||
@@ -171,7 +165,7 @@ claude bridge
|
||||
|
||||
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。功能:
|
||||
|
||||
- 查看已注册的运行环境(environment 模式)
|
||||
- 查看已注册的运行环境
|
||||
- 创建和管理会话
|
||||
- 实时查看对话消息和工具调用
|
||||
- 审批 Claude Code 的工具权限请求
|
||||
@@ -281,3 +275,4 @@ curl https://rcs.example.com/health
|
||||
| 依赖 | claude.ai 订阅 + OAuth | 仅需 API Key |
|
||||
|
||||
自托管模式的核心优势是:设置 `CLAUDE_BRIDGE_BASE_URL` 后,代码自动调用 `isSelfHostedBridge()` 返回 `true`,跳过所有 GrowthBook 和订阅检查,无需 claude.ai 账户即可使用。
|
||||
|
||||
|
||||
310
docs/features/stub-recovery-design-1-4.md
Normal file
310
docs/features/stub-recovery-design-1-4.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Stub 恢复设计 1-4
|
||||
|
||||
> 日期:2026-04-12
|
||||
> 目标:基于当前代码边界,为下一阶段 4 个 stub/半 stub 命令面给出可实施的设计方案。
|
||||
> 排序原则:按建议实施顺序排序,不按问题严重性排序。
|
||||
|
||||
## 设计原则
|
||||
|
||||
- 先做能独立闭环、收益明确、改动边界清晰的项。
|
||||
- 大项拆成 `MVP` 和 `Phase 2+`,避免一次性掉进大范围恢复。
|
||||
- 优先复用已有状态、传输层、日志与配置能力,不重造协议。
|
||||
- 设计以当前仓库实际代码为准,不以旧文档的理想状态为准。
|
||||
|
||||
## 1. `claude daemon status` / `claude daemon stop`
|
||||
|
||||
### 现状
|
||||
|
||||
- `start` 路径已有完整 supervisor + worker 生命周期:
|
||||
[src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:1>)
|
||||
[src/daemon/workerRegistry.ts](</e:/Source_code/Claude-code-bast/src/daemon/workerRegistry.ts:1>)
|
||||
- `status` / `stop` 目前只是占位输出:
|
||||
[src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:49>)
|
||||
- `/remote-control-server` 有自己的命令内 UI 状态,但只维护当前进程内的 `daemonProcess`,并不适合作为跨进程 CLI 管理基础:
|
||||
[src/commands/remoteControlServer/remoteControlServer.tsx](</e:/Source_code/Claude-code-bast/src/commands/remoteControlServer/remoteControlServer.tsx:32>)
|
||||
|
||||
### 目标
|
||||
|
||||
- 让 `claude daemon status` 和 `claude daemon stop` 在另一个 CLI 进程中也能正确工作。
|
||||
- 不依赖 TUI 内存态,不要求当前命令进程就是启动 daemon 的那个进程。
|
||||
|
||||
### MVP 方案
|
||||
|
||||
- 新增 daemon 状态文件,例如:
|
||||
`~/.claude/daemon/remote-control.json`
|
||||
- `start` 时写入:
|
||||
- supervisor pid
|
||||
- cwd
|
||||
- startedAt
|
||||
- worker kinds
|
||||
- 最近状态
|
||||
- `status`:
|
||||
- 读取状态文件
|
||||
- 用现有进程探测能力验证 pid 是否存活
|
||||
- 输出 `running / stopped / stale`
|
||||
- stale 时自动清理状态文件
|
||||
- `stop`:
|
||||
- 读取 pid
|
||||
- 发送 `SIGTERM`
|
||||
- 等待退出
|
||||
- 超时后 `SIGKILL`
|
||||
- 清理状态文件
|
||||
|
||||
### 代码范围
|
||||
|
||||
- 新增 `src/daemon/state.ts`
|
||||
- 修改 [src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:1>)
|
||||
- 轻量修改 [src/commands/remoteControlServer/remoteControlServer.tsx](</e:/Source_code/Claude-code-bast/src/commands/remoteControlServer/remoteControlServer.tsx:32>),让 UI 尽量读取同一份状态文件
|
||||
|
||||
### 验证
|
||||
|
||||
1. `claude daemon start`
|
||||
2. 新开终端执行 `claude daemon status`
|
||||
3. 执行 `claude daemon stop`
|
||||
4. 再次执行 `claude daemon status`,确认返回 `stopped` 或清晰的 `stale cleaned`
|
||||
|
||||
### 风险
|
||||
|
||||
- Windows 信号模型和 Unix 不同,`stop` 需要超时兜底。
|
||||
- 当前设计默认单 supervisor,不处理多实例并发。
|
||||
|
||||
### 工作量判断
|
||||
|
||||
- 小
|
||||
- 适合作为下一步的首选实现项
|
||||
|
||||
## 2. `BG_SESSIONS`
|
||||
|
||||
### 现状
|
||||
|
||||
- fast-path 已接好:
|
||||
[src/entrypoints/cli.tsx](</e:/Source_code/Claude-code-bast/src/entrypoints/cli.tsx:218>)
|
||||
- session registry 已有真实实现:
|
||||
[src/utils/concurrentSessions.ts](</e:/Source_code/Claude-code-bast/src/utils/concurrentSessions.ts:1>)
|
||||
- `exit` 在 bg session 内已会 `tmux detach-client`:
|
||||
[src/commands/exit/exit.tsx](</e:/Source_code/Claude-code-bast/src/commands/exit/exit.tsx:20>)
|
||||
- 但 CLI handler 仍全空:
|
||||
[src/cli/bg.ts](</e:/Source_code/Claude-code-bast/src/cli/bg.ts:1>)
|
||||
- task summary 仍然是 stub:
|
||||
[src/utils/taskSummary.ts](</e:/Source_code/Claude-code-bast/src/utils/taskSummary.ts:1>)
|
||||
|
||||
### 目标
|
||||
|
||||
- 先把 `ps` / `logs` / `kill` 做成真正有用的 session 管理命令。
|
||||
- 不在第一阶段就强行补完 `attach` / `--bg`。
|
||||
|
||||
### Phase 2A:MVP
|
||||
|
||||
- 实现 `ps`
|
||||
- 从 registry 读取 live sessions
|
||||
- 展示 pid、kind、sessionId、cwd、name、startedAt、bridgeSessionId
|
||||
- 如果有 activity/status,则一并展示
|
||||
- 实现 `logs`
|
||||
- 支持按 `sessionId / pid / name` 查找
|
||||
- 优先复用本地 transcript/log 读取能力
|
||||
- 如果 registry 里存在 `logPath`,支持 tail 文件
|
||||
- 实现 `kill`
|
||||
- 解析目标 session
|
||||
- 发退出信号
|
||||
- 清理 stale registry
|
||||
|
||||
### Phase 2B:后续
|
||||
|
||||
- 实现 `attach`
|
||||
- 实现 `--bg`
|
||||
- 实现 `taskSummary` 的中途状态更新
|
||||
|
||||
### 为什么要拆
|
||||
|
||||
- 现有 registry 记录了 `pid / sessionId / name / logPath`
|
||||
- 但没有可靠的 tmux attach target
|
||||
- 所以 `attach` 和 `--bg` 不是简单补 handler,而是需要补启动/附着元数据设计
|
||||
|
||||
### 代码范围
|
||||
|
||||
- 修改 [src/cli/bg.ts](</e:/Source_code/Claude-code-bast/src/cli/bg.ts:1>)
|
||||
- 修改 [src/utils/concurrentSessions.ts](</e:/Source_code/Claude-code-bast/src/utils/concurrentSessions.ts:1>) 以便后续 attach/--bg 扩展
|
||||
- 修改 [src/utils/taskSummary.ts](</e:/Source_code/Claude-code-bast/src/utils/taskSummary.ts:1>)
|
||||
- 复用:
|
||||
[src/utils/sessionStorage.ts](</e:/Source_code/Claude-code-bast/src/utils/sessionStorage.ts:3870>)
|
||||
[src/utils/udsClient.ts](</e:/Source_code/Claude-code-bast/src/utils/udsClient.ts:1>)
|
||||
|
||||
### 验证
|
||||
|
||||
1. `ps` 能列出 live sessions
|
||||
2. `logs <sessionId|pid|name>` 能输出对应日志
|
||||
3. `kill <sessionId|pid|name>` 能结束目标 session
|
||||
|
||||
### 风险
|
||||
|
||||
- `attach` / `--bg` 第二阶段需要 tmux 元数据设计
|
||||
- Windows 下 tmux 路径需要明确降级策略
|
||||
|
||||
### 工作量判断
|
||||
|
||||
- `ps/logs/kill` 中等
|
||||
- `attach/--bg` 明显更大,应分阶段
|
||||
|
||||
## 3. `TEMPLATES`
|
||||
|
||||
### 现状
|
||||
|
||||
- 命令入口只有 fast-path:
|
||||
[src/entrypoints/cli.tsx](</e:/Source_code/Claude-code-bast/src/entrypoints/cli.tsx:249>)
|
||||
- handler 是空的:
|
||||
[src/cli/handlers/templateJobs.ts](</e:/Source_code/Claude-code-bast/src/cli/handlers/templateJobs.ts:1>)
|
||||
- `markdownConfigLoader` 已把 `templates` 纳入配置目录:
|
||||
[src/utils/markdownConfigLoader.ts](</e:/Source_code/Claude-code-bast/src/utils/markdownConfigLoader.ts:29>)
|
||||
- `query / stopHooks` 已预留 job classifier 链路:
|
||||
[src/query/stopHooks.ts](</e:/Source_code/Claude-code-bast/src/query/stopHooks.ts:103>)
|
||||
- `jobs/classifier.ts` 仍是 stub:
|
||||
[src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
|
||||
|
||||
### 目标
|
||||
|
||||
- 把 `new / list / reply` 做成可用的模板任务系统。
|
||||
- 第一阶段不碰复杂的自动分类与自动执行。
|
||||
|
||||
### MVP 方案
|
||||
|
||||
- 模板来源:
|
||||
`.claude/templates/*.md`
|
||||
- 模板格式:
|
||||
复用现有 markdown + frontmatter 解析,不另外设计 DSL
|
||||
- `list`
|
||||
- 列出所有模板
|
||||
- 显示模板名、description、路径
|
||||
- `new <template> [args...]`
|
||||
- 解析模板
|
||||
- 在 `~/.claude/jobs/<job-id>/` 下创建 job 目录
|
||||
- 写入 `template.md`、`input.txt`、`state.json`
|
||||
- 返回 job id 与目录
|
||||
- `reply <job-id> <text>`
|
||||
- 将回复写入 `replies.jsonl` 或 `input.txt`
|
||||
- 更新 `state.json`
|
||||
|
||||
### Phase 2
|
||||
|
||||
- 恢复 [src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
|
||||
- 让带 `CLAUDE_JOB_DIR` 的 job session 在 turn 完成后自动更新 `state.json`
|
||||
- 再决定是否补自动 job runner
|
||||
|
||||
### 为什么要拆
|
||||
|
||||
- 当前证据表明这是“template job commands”,不是单纯模板列表
|
||||
- 但自动 job 运行链路没有足够现成实现,先做文件系统 job lifecycle 更稳
|
||||
|
||||
### 代码范围
|
||||
|
||||
- 修改 [src/cli/handlers/templateJobs.ts](</e:/Source_code/Claude-code-bast/src/cli/handlers/templateJobs.ts:1>)
|
||||
- 新增 `src/jobs/state.ts`
|
||||
- 新增 `src/jobs/templates.ts`
|
||||
- Phase 2 再改 [src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
|
||||
|
||||
### 验证
|
||||
|
||||
1. `list` 能列出 `.claude/templates`
|
||||
2. `new` 能创建 job 目录和状态文件
|
||||
3. `reply` 能更新 job 内容和状态
|
||||
4. Phase 2 再验证 classifier 写状态
|
||||
|
||||
### 风险
|
||||
|
||||
- frontmatter schema 需要先定义最小字段集
|
||||
- 一旦扩展到“自动运行 job”,范围会明显膨胀
|
||||
|
||||
### 工作量判断
|
||||
|
||||
- MVP 中等
|
||||
- 完整 job 系统偏大
|
||||
|
||||
## 4. `assistant [sessionId]`
|
||||
|
||||
### 现状
|
||||
|
||||
- attach 主流程其实已经存在:
|
||||
[src/main.tsx](</e:/Source_code/Claude-code-bast/src/main.tsx:4708>)
|
||||
- 远端 viewer 所需基础模块已存在:
|
||||
[src/remote/RemoteSessionManager.ts](</e:/Source_code/Claude-code-bast/src/remote/RemoteSessionManager.ts:1>)
|
||||
[src/hooks/useAssistantHistory.ts](</e:/Source_code/Claude-code-bast/src/hooks/useAssistantHistory.ts:1>)
|
||||
[src/assistant/sessionHistory.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionHistory.ts:1>)
|
||||
- 真正 stub 的主要是:
|
||||
[src/assistant/sessionDiscovery.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionDiscovery.ts:1>)
|
||||
[src/assistant/AssistantSessionChooser.ts](</e:/Source_code/Claude-code-bast/src/assistant/AssistantSessionChooser.ts:1>)
|
||||
[src/commands/assistant/assistant.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/assistant.ts:7>)
|
||||
[src/assistant/index.ts](</e:/Source_code/Claude-code-bast/src/assistant/index.ts:1>)
|
||||
|
||||
### 目标
|
||||
|
||||
- 不一次性恢复整个 KAIROS 助手系统。
|
||||
- 先做“明确 sessionId 的 viewer attach 可用”,再逐步补 discovery / chooser / install。
|
||||
|
||||
### Phase 4A:MVP
|
||||
|
||||
- 只支持 `claude assistant <sessionId>`
|
||||
- 对 `claude assistant` 无参数模式,先返回明确提示:
|
||||
- 当前版本需要显式 `sessionId`
|
||||
- discovery 尚未启用
|
||||
- 这样可以直接复用现有 attach 分支,不必先恢复 chooser/install wizard
|
||||
|
||||
### Phase 4B
|
||||
|
||||
- 恢复 `discoverAssistantSessions()`
|
||||
- 数据来源优先复用现有 sessions / bridge / teleport API,而不是新协议
|
||||
- 让 `claude assistant` 无参数时能拿到候选 session 列表
|
||||
|
||||
### Phase 4C
|
||||
|
||||
- 恢复 `AssistantSessionChooser`
|
||||
- 多 session 时可交互选择
|
||||
|
||||
### Phase 4D
|
||||
|
||||
- 最后考虑 install wizard 辅助函数
|
||||
- 这部分属于“没有 session 时如何引导”,不是 attach 核心路径
|
||||
|
||||
### 为什么要拆
|
||||
|
||||
- attach 渲染层与远端消息通道大部分已经在
|
||||
- 真正缺的是“如何发现目标 session”和“如何交互选择”
|
||||
- 如果把 `src/assistant/index.ts` 的整套 KAIROS 正常模式也一起拉进来,范围会失控
|
||||
|
||||
### 代码范围
|
||||
|
||||
- Phase 4A:
|
||||
- [src/main.tsx](</e:/Source_code/Claude-code-bast/src/main.tsx:4708>)
|
||||
- [src/commands/assistant/index.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/index.ts:1>)
|
||||
- Phase 4B:
|
||||
- [src/assistant/sessionDiscovery.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionDiscovery.ts:1>)
|
||||
- Phase 4C:
|
||||
- [src/assistant/AssistantSessionChooser.ts](</e:/Source_code/Claude-code-bast/src/assistant/AssistantSessionChooser.ts:1>)
|
||||
- Phase 4D:
|
||||
- [src/commands/assistant/assistant.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/assistant.ts:7>)
|
||||
|
||||
### 验证
|
||||
|
||||
1. `claude assistant <sessionId>` 能进入 remote viewer
|
||||
2. 历史懒加载工作正常
|
||||
3. 无参数模式先给出明确提示
|
||||
4. 后续阶段再分别验证 discovery / chooser / install
|
||||
|
||||
### 风险
|
||||
|
||||
- 这是四项里范围最大的
|
||||
- 一旦把 KAIROS 正常模式整体拉入,会从“viewer attach”膨胀成“完整 assistant mode 恢复”
|
||||
|
||||
### 工作量判断
|
||||
|
||||
- Phase 4A 中等
|
||||
- 4A-4D 全做完很大
|
||||
|
||||
## 建议执行顺序
|
||||
|
||||
1. `claude daemon status` / `claude daemon stop`
|
||||
2. `BG_SESSIONS` 先做 `ps/logs/kill`
|
||||
3. `TEMPLATES` 先做 job 文件系统 MVP
|
||||
4. `assistant [sessionId]` 先做显式 sessionId attach,再补 discovery/chooser/install
|
||||
|
||||
## 简短结论
|
||||
|
||||
这四项里,最适合立刻实现的是 `daemon status/stop`。`BG_SESSIONS` 和 `TEMPLATES` 适合按 MVP 先补 handler 与文件系统闭环。`assistant [sessionId]` 不能整块硬上,应该按“attach → discovery → chooser → install”拆开恢复。
|
||||
77
docs/task/task-001-daemon-status-stop.md
Normal file
77
docs/task/task-001-daemon-status-stop.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Task 001: daemon status / stop
|
||||
|
||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 1 项
|
||||
> 优先级: P0 (首选实现项)
|
||||
> 工作量: 小
|
||||
> 状态: DONE
|
||||
|
||||
## 目标
|
||||
|
||||
让 `claude daemon status` 和 `claude daemon stop` 在任意 CLI 进程中都能正确工作,不依赖 TUI 内存态。
|
||||
|
||||
## 背景
|
||||
|
||||
- `start` 路径已有完整 supervisor + worker 生命周期 (`src/daemon/main.ts`, `src/daemon/workerRegistry.ts`)
|
||||
- `status` / `stop` 目前只是占位输出 (`src/daemon/main.ts:49`)
|
||||
- `/remote-control-server` 有自己的命令内 UI 状态,但只维护当前进程内的 `daemonProcess`,不适合跨进程管理
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/daemon/state.ts` | daemon 状态文件读写模块 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/daemon/main.ts` | `start` 写入状态文件;`status`/`stop` 调用 state 模块 |
|
||||
| `src/commands/remoteControlServer/remoteControlServer.tsx` | 读取同一份状态文件(轻量改动) |
|
||||
|
||||
### 状态文件
|
||||
|
||||
路径: `~/.claude/daemon/remote-control.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"pid": 12345,
|
||||
"cwd": "/path/to/project",
|
||||
"startedAt": "2026-04-12T10:00:00Z",
|
||||
"workerKinds": ["bridge", "rcs"],
|
||||
"lastStatus": "running"
|
||||
}
|
||||
```
|
||||
|
||||
### status 逻辑
|
||||
|
||||
1. 读取状态文件
|
||||
2. 用进程探测验证 pid 是否存活
|
||||
3. 输出 `running` / `stopped` / `stale`
|
||||
4. stale 时自动清理状态文件
|
||||
|
||||
### stop 逻辑
|
||||
|
||||
1. 读取 pid
|
||||
2. 发送 `SIGTERM`
|
||||
3. 等待退出(超时兜底)
|
||||
4. 超时后 `SIGKILL`
|
||||
5. 清理状态文件
|
||||
|
||||
## 验证步骤
|
||||
|
||||
- [ ] `claude daemon start` 正常启动并写入状态文件
|
||||
- [ ] 新开终端执行 `claude daemon status`,显示 `running`
|
||||
- [ ] 执行 `claude daemon stop`,daemon 正常退出
|
||||
- [ ] 再次执行 `claude daemon status`,返回 `stopped` 或 `stale cleaned`
|
||||
- [ ] Windows 下 stop 超时兜底正常工作
|
||||
|
||||
## 风险
|
||||
|
||||
- Windows 信号模型和 Unix 不同,`stop` 需要超时兜底
|
||||
- 当前设计默认单 supervisor,不处理多实例并发
|
||||
|
||||
## 依赖
|
||||
|
||||
无外部依赖,可独立实施。
|
||||
80
docs/task/task-002-bg-sessions-ps-logs-kill.md
Normal file
80
docs/task/task-002-bg-sessions-ps-logs-kill.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Task 002: BG_SESSIONS — ps / logs / kill
|
||||
|
||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 2 项
|
||||
> 优先级: P1
|
||||
> 工作量: 中等
|
||||
> 状态: DONE
|
||||
> 阶段: Phase 2A (MVP)
|
||||
|
||||
## 目标
|
||||
|
||||
把 `ps` / `logs` / `kill` 做成真正有用的 session 管理命令。不在第一阶段补完 `attach` / `--bg`。
|
||||
|
||||
## 背景
|
||||
|
||||
- fast-path 已接好 (`src/entrypoints/cli.tsx:218`)
|
||||
- session registry 已有真实实现 (`src/utils/concurrentSessions.ts`)
|
||||
- `exit` 在 bg session 内已会 `tmux detach-client` (`src/commands/exit/exit.tsx:20`)
|
||||
- CLI handler 仍全空 (`src/cli/bg.ts`)
|
||||
- task summary 仍然是 stub (`src/utils/taskSummary.ts`)
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/cli/bg.ts` | 实现 `ps` / `logs` / `kill` handler |
|
||||
| `src/utils/concurrentSessions.ts` | 扩展以便后续 attach/--bg 使用 |
|
||||
| `src/utils/taskSummary.ts` | 补充基础实现 |
|
||||
|
||||
### 复用模块
|
||||
|
||||
- `src/utils/sessionStorage.ts` — session 存储
|
||||
- `src/utils/udsClient.ts` — UDS 通信
|
||||
|
||||
### ps 命令
|
||||
|
||||
- 从 registry 读取 live sessions
|
||||
- 展示: pid, kind, sessionId, cwd, name, startedAt, bridgeSessionId
|
||||
- 如果有 activity/status,一并展示
|
||||
|
||||
### logs 命令
|
||||
|
||||
- 支持按 `sessionId` / `pid` / `name` 查找
|
||||
- 优先复用本地 transcript/log 读取能力
|
||||
- 如果 registry 里存在 `logPath`,支持 tail 文件
|
||||
|
||||
### kill 命令
|
||||
|
||||
- 解析目标 session
|
||||
- 发退出信号
|
||||
- 清理 stale registry
|
||||
|
||||
## 验证步骤
|
||||
|
||||
- [ ] `ps` 能列出当前 live sessions
|
||||
- [ ] `logs <sessionId|pid|name>` 能输出对应日志
|
||||
- [ ] `kill <sessionId|pid|name>` 能结束目标 session 并清理 registry
|
||||
- [ ] 无 live session 时各命令有明确提示
|
||||
|
||||
## Phase 2B (后续)
|
||||
|
||||
- [ ] 实现 `attach`
|
||||
- [ ] 实现 `--bg`
|
||||
- [ ] 实现 `taskSummary` 的中途状态更新
|
||||
|
||||
### 为什么拆分
|
||||
|
||||
- 现有 registry 记录了 `pid / sessionId / name / logPath`
|
||||
- 但没有可靠的 tmux attach target
|
||||
- `attach` 和 `--bg` 需要补启动/附着元数据设计,不是简单补 handler
|
||||
|
||||
## 风险
|
||||
|
||||
- `attach` / `--bg` 第二阶段需要 tmux 元数据设计
|
||||
- Windows 下 tmux 路径需要明确降级策略
|
||||
|
||||
## 依赖
|
||||
|
||||
- Task 001 (daemon 状态管理可复用模式,但非硬性依赖)
|
||||
87
docs/task/task-003-templates-job-mvp.md
Normal file
87
docs/task/task-003-templates-job-mvp.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Task 003: TEMPLATES — job 文件系统 MVP
|
||||
|
||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 3 项
|
||||
> 优先级: P2
|
||||
> 工作量: 中等
|
||||
> 状态: DONE
|
||||
> 阶段: MVP
|
||||
|
||||
## 目标
|
||||
|
||||
把 `new` / `list` / `reply` 做成可用的模板任务系统。第一阶段不碰复杂的自动分类与自动执行。
|
||||
|
||||
## 背景
|
||||
|
||||
- 命令入口只有 fast-path (`src/entrypoints/cli.tsx:249`)
|
||||
- handler 是空的 (`src/cli/handlers/templateJobs.ts`)
|
||||
- `markdownConfigLoader` 已把 `templates` 纳入配置目录 (`src/utils/markdownConfigLoader.ts:29`)
|
||||
- `query/stopHooks` 已预留 job classifier 链路 (`src/query/stopHooks.ts:103`)
|
||||
- `jobs/classifier.ts` 仍是 stub (`src/jobs/classifier.ts`)
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/jobs/state.ts` | job 状态管理 |
|
||||
| `src/jobs/templates.ts` | 模板解析与列表 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/cli/handlers/templateJobs.ts` | 实现 `new` / `list` / `reply` handler |
|
||||
|
||||
### 模板来源
|
||||
|
||||
`.claude/templates/*.md`
|
||||
|
||||
### 模板格式
|
||||
|
||||
复用现有 markdown + frontmatter 解析,不另外设计 DSL。
|
||||
|
||||
### list 命令
|
||||
|
||||
- 列出所有模板
|
||||
- 显示: 模板名, description, 路径
|
||||
|
||||
### new 命令
|
||||
|
||||
- 解析模板
|
||||
- 在 `~/.claude/jobs/<job-id>/` 下创建 job 目录
|
||||
- 写入 `template.md`, `input.txt`, `state.json`
|
||||
- 返回 job id 与目录路径
|
||||
|
||||
### reply 命令
|
||||
|
||||
- 将回复写入 `replies.jsonl` 或 `input.txt`
|
||||
- 更新 `state.json`
|
||||
|
||||
## 验证步骤
|
||||
|
||||
- [ ] `list` 能列出 `.claude/templates` 下的所有模板
|
||||
- [ ] `new <template> [args...]` 能创建 job 目录和状态文件
|
||||
- [ ] `reply <job-id> <text>` 能更新 job 内容和状态
|
||||
- [ ] frontmatter schema 最小字段集已定义
|
||||
|
||||
## Phase 2 (后续)
|
||||
|
||||
- [ ] 恢复 `src/jobs/classifier.ts`
|
||||
- [ ] 让带 `CLAUDE_JOB_DIR` 的 job session 在 turn 完成后自动更新 `state.json`
|
||||
- [ ] 再决定是否补自动 job runner
|
||||
|
||||
### 为什么拆分
|
||||
|
||||
- 当前是 "template job commands",不是单纯模板列表
|
||||
- 自动 job 运行链路没有足够现成实现
|
||||
- 先做文件系统 job lifecycle 更稳
|
||||
|
||||
## 风险
|
||||
|
||||
- frontmatter schema 需要先定义最小字段集
|
||||
- 一旦扩展到"自动运行 job",范围会明显膨胀
|
||||
|
||||
## 依赖
|
||||
|
||||
无硬性依赖,可独立实施。
|
||||
103
docs/task/task-004-assistant-session-attach.md
Normal file
103
docs/task/task-004-assistant-session-attach.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Task 004: assistant [sessionId] — 分阶段恢复
|
||||
|
||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 4 项
|
||||
> 优先级: P3
|
||||
> 工作量: Phase 4A 中等,4A-4D 全做完很大
|
||||
> 状态: Phase 4A DONE, 4B-4D TODO
|
||||
|
||||
## 目标
|
||||
|
||||
不一次性恢复整个 KAIROS 助手系统。先做"明确 sessionId 的 viewer attach 可用",再逐步补 discovery / chooser / install。
|
||||
|
||||
## 背景
|
||||
|
||||
- attach 主流程已存在 (`src/main.tsx:4708`)
|
||||
- 远端 viewer 所需基础模块已存在:
|
||||
- `src/remote/RemoteSessionManager.ts`
|
||||
- `src/hooks/useAssistantHistory.ts`
|
||||
- `src/assistant/sessionHistory.ts`
|
||||
- 真正 stub 的主要是:
|
||||
- `src/assistant/sessionDiscovery.ts`
|
||||
- `src/assistant/AssistantSessionChooser.ts`
|
||||
- `src/commands/assistant/assistant.ts:7`
|
||||
- `src/assistant/index.ts`
|
||||
|
||||
## 分阶段实现
|
||||
|
||||
### Phase 4A: MVP — 显式 sessionId attach
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/main.tsx` | 确保 attach 分支可用 |
|
||||
| `src/commands/assistant/index.ts` | 实现显式 sessionId 参数入口 |
|
||||
|
||||
**行为:**
|
||||
- `claude assistant <sessionId>` — 进入 remote viewer
|
||||
- `claude assistant` (无参数) — 返回明确提示: 当前版本需要显式 sessionId,discovery 尚未启用
|
||||
|
||||
**验证:**
|
||||
- [ ] `claude assistant <sessionId>` 能进入 remote viewer
|
||||
- [ ] 历史懒加载工作正常
|
||||
- [ ] 无参数模式给出明确提示
|
||||
|
||||
### Phase 4B: session discovery
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/assistant/sessionDiscovery.ts` | 恢复 `discoverAssistantSessions()` |
|
||||
|
||||
**行为:**
|
||||
- 数据来源优先复用现有 sessions / bridge / teleport API,不新增协议
|
||||
- `claude assistant` 无参数时能拿到候选 session 列表
|
||||
|
||||
**验证:**
|
||||
- [ ] 无参数调用能列出可用 sessions
|
||||
- [ ] 数据来源复用现有通道
|
||||
|
||||
### Phase 4C: session chooser
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/assistant/AssistantSessionChooser.ts` | 恢复交互式选择器 |
|
||||
|
||||
**行为:**
|
||||
- 多 session 时可交互选择
|
||||
|
||||
**验证:**
|
||||
- [ ] 多个 session 时弹出选择器
|
||||
- [ ] 选择后正确 attach
|
||||
|
||||
### Phase 4D: install wizard
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/commands/assistant/assistant.ts` | 恢复 install wizard 辅助函数 |
|
||||
|
||||
**行为:**
|
||||
- 没有 session 时如何引导用户
|
||||
|
||||
**验证:**
|
||||
- [ ] 无可用 session 时引导用户创建/连接
|
||||
|
||||
## 为什么拆分
|
||||
|
||||
- attach 渲染层与远端消息通道大部分已在
|
||||
- 真正缺的是"如何发现目标 session"和"如何交互选择"
|
||||
- 如果把 `src/assistant/index.ts` 的整套 KAIROS 正常模式也一起拉进来,范围会失控
|
||||
|
||||
## 风险
|
||||
|
||||
- 这是四项里范围最大的
|
||||
- 一旦把 KAIROS 正常模式整体拉入,会从"viewer attach"膨胀成"完整 assistant mode 恢复"
|
||||
|
||||
## 依赖
|
||||
|
||||
- Task 002 的 session registry 模式可复用
|
||||
88
docs/test-plans/openclaw-autonomy-baseline.md
Normal file
88
docs/test-plans/openclaw-autonomy-baseline.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# OpenClaw Autonomy Baseline Test Spec
|
||||
|
||||
## Purpose
|
||||
|
||||
This test spec locks the current behavior of the existing trigger and context layers before any formal autonomy-subsystem implementation begins.
|
||||
|
||||
At this stage, production code is read-only. Only test files, fixtures, and planning documents may change.
|
||||
|
||||
## Goal
|
||||
|
||||
Establish a stable baseline around the parts of `Claude-code-bast` that later autonomy work is most likely to touch:
|
||||
|
||||
- proactive state handling
|
||||
- cron task storage semantics
|
||||
- cron scheduler helper semantics
|
||||
- user-context cache and `CLAUDE.md` injection behavior
|
||||
|
||||
## Out of Scope for This Baseline Round
|
||||
|
||||
- New authority behavior (`AGENTS.md` / `HEARTBEAT.md`)
|
||||
- New detached-run ledger behavior
|
||||
- New flow behavior
|
||||
- UI redesign
|
||||
|
||||
## Files Under Baseline Protection
|
||||
|
||||
- `src/proactive/index.ts`
|
||||
- `src/utils/cronTasks.ts`
|
||||
- `src/utils/cronScheduler.ts`
|
||||
- `src/context.ts`
|
||||
|
||||
## Test Files Added In This Round
|
||||
|
||||
- `src/proactive/__tests__/state.baseline.test.ts`
|
||||
- `src/commands/__tests__/proactive.baseline.test.ts`
|
||||
- `src/utils/__tests__/cronTasks.baseline.test.ts`
|
||||
- `src/utils/__tests__/cronScheduler.baseline.test.ts`
|
||||
- `src/__tests__/context.baseline.test.ts`
|
||||
|
||||
## Baseline Assertions
|
||||
|
||||
### Proactive state
|
||||
|
||||
1. Activating proactive mode sets active state and activation source.
|
||||
2. Pausing proactive mode suppresses `shouldTick()` and clears `nextTickAt`.
|
||||
3. Blocking context suppresses `shouldTick()` and clears `nextTickAt`.
|
||||
4. Subscribers are notified on state transitions.
|
||||
5. The `/proactive` command enables proactive mode and emits the expected hidden reminder.
|
||||
6. The `/proactive` command disables proactive mode on the second invocation.
|
||||
|
||||
### Cron task storage
|
||||
|
||||
1. Session-only cron tasks remain in memory only.
|
||||
2. Durable cron tasks are persisted to `.claude/scheduled_tasks.json`.
|
||||
3. Daemon-style `dir`-scoped reads exclude session-only cron tasks.
|
||||
4. `removeCronTasks()` without `dir` can remove session-only tasks.
|
||||
5. `removeCronTasks()` with `dir` does not mutate session-only task storage.
|
||||
|
||||
### Cron scheduler helpers
|
||||
|
||||
1. `isRecurringTaskAged()` preserves current aging semantics.
|
||||
2. `buildMissedTaskNotification()` preserves the current AskUserQuestion safety wording.
|
||||
3. `buildMissedTaskNotification()` preserves code-fence hardening for prompt bodies that contain backticks.
|
||||
|
||||
### User context caching
|
||||
|
||||
1. `getUserContext()` includes `currentDate`.
|
||||
2. `getUserContext()` includes mocked `claudeMd` content when memory loading is enabled.
|
||||
3. `CLAUDE_CODE_DISABLE_CLAUDE_MDS` suppresses `claudeMd`.
|
||||
4. `setSystemPromptInjection()` clears the memoized user-context cache.
|
||||
5. `getSystemContext()` reflects the injection after cache invalidation.
|
||||
|
||||
## Remaining Baseline Gaps
|
||||
|
||||
The following areas are intentionally deferred because they require higher-cost harnessing and should still avoid production-code changes:
|
||||
|
||||
1. `useScheduledTasks.ts` hook-level runtime behavior
|
||||
2. `src/cli/print.ts` full headless scheduler loop behavior
|
||||
3. `useProactive.ts` hook timer behavior
|
||||
4. end-to-end queue interaction between proactive ticks and `SleepTool`
|
||||
|
||||
## Acceptance
|
||||
|
||||
This baseline round is complete when:
|
||||
|
||||
1. The four new test files pass.
|
||||
2. No production source files are modified.
|
||||
3. The tests are stable enough to serve as a pre-implementation guardrail.
|
||||
30
package.json
30
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
@@ -40,9 +40,6 @@
|
||||
],
|
||||
"scripts": {
|
||||
"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:inspect": "bun run scripts/dev-debug.ts",
|
||||
"prepublishOnly": "bun run build",
|
||||
@@ -55,15 +52,16 @@
|
||||
"health": "bun run scripts/health-check.ts",
|
||||
"postinstall": "node scripts/postinstall.cjs && node scripts/setup-chrome-mcp.mjs",
|
||||
"docs:dev": "npx mintlify dev",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"rcs": "bun run scripts/rcs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.19.0",
|
||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.7",
|
||||
"ws": "^8.20.0"
|
||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.7"
|
||||
},
|
||||
"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",
|
||||
"@ant/claude-for-chrome-mcp": "workspace:*",
|
||||
"@ant/computer-use-input": "workspace:*",
|
||||
@@ -77,6 +75,9 @@
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||
"@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-runtime": "^3.1020.0",
|
||||
"@aws-sdk/client-sts": "^3.1020.0",
|
||||
@@ -84,13 +85,8 @@
|
||||
"@aws-sdk/credential-providers": "^3.1020.0",
|
||||
"@azure/identity": "^4.13.1",
|
||||
"@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",
|
||||
"@growthbook/growthbook": "^1.6.5",
|
||||
"@langfuse/otel": "^5.1.0",
|
||||
"@langfuse/tracing": "^5.1.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/api-logs": "^0.214.0",
|
||||
@@ -113,11 +109,8 @@
|
||||
"@sentry/node": "^10.47.0",
|
||||
"@smithy/core": "^3.23.13",
|
||||
"@smithy/node-http-handler": "^4.5.1",
|
||||
"@types/bun": "^1.3.12",
|
||||
"@types/bun": "^1.3.11",
|
||||
"@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/plist": "^3.0.5",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
@@ -173,7 +166,6 @@
|
||||
"react": "^19.2.4",
|
||||
"react-compiler-runtime": "^1.0.0",
|
||||
"react-reconciler": "^0.33.0",
|
||||
"rollup": "^4.60.1",
|
||||
"semver": "^7.7.4",
|
||||
"sharp": "^0.34.5",
|
||||
"shell-quote": "^1.8.3",
|
||||
@@ -188,11 +180,11 @@
|
||||
"undici": "^7.24.6",
|
||||
"url-handler-napi": "workspace:*",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.8",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"vscode-languageserver-protocol": "^3.17.5",
|
||||
"vscode-languageserver-types": "^3.17.5",
|
||||
"wrap-ansi": "^10.0.0",
|
||||
"ws": "^8.20.0",
|
||||
"xss": "^1.0.15",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -5,12 +5,9 @@
|
||||
* mouse and keyboard via CoreGraphics events and System Events.
|
||||
*/
|
||||
|
||||
import { execFile, execFileSync } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { $ } from 'bun'
|
||||
import type { FrontmostAppInfo, InputBackend } from '../types.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const KEY_MAP: Record<string, number> = {
|
||||
return: 36, enter: 36, tab: 48, space: 49, delete: 51, backspace: 51,
|
||||
escape: 53, esc: 53,
|
||||
@@ -28,17 +25,13 @@ const MODIFIER_MAP: Record<string, string> = {
|
||||
}
|
||||
|
||||
async function osascript(script: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync('osascript', ['-e', script], {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
return stdout.trim()
|
||||
const result = await $`osascript -e ${script}`.quiet().nothrow().text()
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
async function jxa(script: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync('osascript', ['-l', 'JavaScript', '-e', script], {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
return stdout.trim()
|
||||
const result = await $`osascript -l JavaScript -e ${script}`.quiet().nothrow().text()
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
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'] = () => {
|
||||
try {
|
||||
const output = execFileSync('osascript', ['-e', `
|
||||
tell application "System Events"
|
||||
set frontApp to first application process whose frontmost is true
|
||||
set appName to name of frontApp
|
||||
set bundleId to bundle identifier of frontApp
|
||||
return bundleId & "|" & appName
|
||||
end tell
|
||||
`], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim()
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['osascript', '-e', `
|
||||
tell application "System Events"
|
||||
set frontApp to first application process whose frontmost is true
|
||||
set appName to name of frontApp
|
||||
set bundleId to bundle identifier of frontApp
|
||||
return bundleId & "|" & appName
|
||||
end tell
|
||||
`],
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const output = new TextDecoder().decode(result.stdout).trim()
|
||||
if (!output || !output.includes('|')) return null
|
||||
const [bundleId, appName] = output.split('|', 2)
|
||||
return { bundleId: bundleId!, appName: appName! }
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -37,16 +37,21 @@
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
/** Detect actual image MIME type from base64 data using magic bytes. */
|
||||
/** Detect actual image MIME type from base64 data by decoding the magic bytes. */
|
||||
function detectMimeFromBase64(b64: string): string {
|
||||
// First byte is enough to distinguish PNG (0x89) from JPEG (0xFF)
|
||||
const c = b64.charCodeAt(0);
|
||||
if (c === 0x89) return "image/png";
|
||||
if (c === 0xFF) return "image/jpeg";
|
||||
// RIFF = WebP
|
||||
if (c === 0x52) return "image/webp";
|
||||
// GIF
|
||||
if (c === 0x47) return "image/gif";
|
||||
// Decode first 12 raw bytes (16 base64 chars is enough) and check standard magic bytes.
|
||||
// PNG: 89 50 4E 47
|
||||
// JPEG: FF D8 FF
|
||||
// RIFF+WEBP: "RIFF" at 0..3 + "WEBP" at 8..11
|
||||
// GIF: "GIF" at 0..2
|
||||
const raw = Buffer.from(b64.slice(0, 16), "base64");
|
||||
if (raw[0] === 0x89 && raw[1] === 0x50 && raw[2] === 0x4e && raw[3] === 0x47) return "image/png";
|
||||
if (raw[0] === 0xff && raw[1] === 0xd8 && raw[2] === 0xff) return "image/jpeg";
|
||||
if (
|
||||
raw[0] === 0x52 && raw[1] === 0x49 && raw[2] === 0x46 && raw[3] === 0x46 && // RIFF
|
||||
raw[8] === 0x57 && raw[9] === 0x45 && raw[10] === 0x42 && raw[11] === 0x50 // WEBP
|
||||
) return "image/webp";
|
||||
if (raw[0] === 0x47 && raw[1] === 0x49 && raw[2] === 0x46) return "image/gif";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -274,9 +274,4 @@ export const screenshot: ScreenshotAPI = {
|
||||
if (displayId !== undefined) args.push('-D', String(displayId))
|
||||
return captureScreenToBase64(args)
|
||||
},
|
||||
|
||||
captureWindowTarget(_titleOrHwnd: string | number): ScreenshotResult | null {
|
||||
// Window capture not supported on macOS via this backend
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -275,9 +275,4 @@ export const screenshot: ScreenshotAPI = {
|
||||
return { base64: '', width: 0, height: 0 }
|
||||
}
|
||||
},
|
||||
|
||||
captureWindowTarget(_titleOrHwnd: string | number): ScreenshotResult | null {
|
||||
// Window capture not supported on Linux via this backend
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ export interface ScreenshotAPI {
|
||||
x: number, y: number, w: number, h: number,
|
||||
outW: number, outH: number, quality: number, displayId?: number,
|
||||
): Promise<ScreenshotResult>
|
||||
captureWindowTarget(titleOrHwnd: string | number): ScreenshotResult | null
|
||||
}
|
||||
|
||||
export interface SwiftBackend {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
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', () => {
|
||||
test('CoreTool structural compatibility with host Tool', () => {
|
||||
@@ -27,7 +27,7 @@ describe('agent-tools compatibility', () => {
|
||||
}
|
||||
|
||||
// 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.isEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { z } from 'zod/v4'
|
||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
|
||||
const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification'
|
||||
|
||||
@@ -74,14 +76,58 @@ Requires Remote Control to be configured. Respects user notification settings (t
|
||||
}
|
||||
},
|
||||
|
||||
async call(_input: PushInput) {
|
||||
// Push delivery is handled by the Remote Control / KAIROS transport layer.
|
||||
// Without the KAIROS runtime, this tool is not available.
|
||||
return {
|
||||
data: {
|
||||
sent: false,
|
||||
error: 'PushNotification requires the KAIROS transport layer.',
|
||||
},
|
||||
async call(input: PushInput, context) {
|
||||
const appState = context.getAppState()
|
||||
|
||||
// Try bridge delivery first (for remote/mobile viewers)
|
||||
if (appState.replBridgeEnabled) {
|
||||
if (feature('BRIDGE_MODE')) {
|
||||
try {
|
||||
const { getBridgeAccessToken, getBridgeBaseUrl } = await import(
|
||||
'src/bridge/bridgeConfig.js'
|
||||
)
|
||||
const { getSessionId } = await import('src/bootstrap/state.js')
|
||||
const token = getBridgeAccessToken()
|
||||
const sessionId = getSessionId()
|
||||
if (token && sessionId) {
|
||||
const baseUrl = getBridgeBaseUrl()
|
||||
const axios = (await import('axios')).default
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/v1/sessions/${sessionId}/events`,
|
||||
{
|
||||
events: [
|
||||
{
|
||||
type: 'push_notification',
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
priority: input.priority ?? 'normal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
timeout: 10_000,
|
||||
validateStatus: (s: number) => s < 500,
|
||||
},
|
||||
)
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
logForDebugging(`[PushNotification] delivered via bridge session=${sessionId}`)
|
||||
return { data: { sent: true } }
|
||||
}
|
||||
logForDebugging(`[PushNotification] bridge delivery failed: status=${response.status}`)
|
||||
}
|
||||
} catch (e) {
|
||||
logForDebugging(`[PushNotification] bridge delivery error: ${e}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no bridge available, push was not delivered to a remote device.
|
||||
logForDebugging(`[PushNotification] no bridge available, not delivered: ${input.title}`)
|
||||
return { data: { sent: false, error: 'No Remote Control bridge configured. Notification not delivered.' } }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -70,14 +70,51 @@ Guidelines:
|
||||
}
|
||||
},
|
||||
|
||||
async call(_input: SendUserFileInput) {
|
||||
// File transfer is handled by the KAIROS assistant transport layer.
|
||||
// Without the KAIROS runtime, this tool is not available.
|
||||
async call(input: SendUserFileInput, context) {
|
||||
const { file_path } = input
|
||||
const { stat } = await import('fs/promises')
|
||||
|
||||
// Verify file exists and is readable
|
||||
let fileSize: number
|
||||
try {
|
||||
const fileStat = await stat(file_path)
|
||||
if (!fileStat.isFile()) {
|
||||
return {
|
||||
data: { sent: false, file_path, error: 'Path is not a file.' },
|
||||
}
|
||||
}
|
||||
fileSize = fileStat.size
|
||||
} catch {
|
||||
return {
|
||||
data: { sent: false, file_path, error: 'File does not exist or is not readable.' },
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt bridge upload if available (so web viewers can download)
|
||||
const appState = context.getAppState()
|
||||
let fileUuid: string | undefined
|
||||
if (appState.replBridgeEnabled) {
|
||||
try {
|
||||
const { uploadBriefAttachment } = await import(
|
||||
'@claude-code-best/builtin-tools/tools/BriefTool/upload.js'
|
||||
)
|
||||
fileUuid = await uploadBriefAttachment(file_path, fileSize, {
|
||||
replBridgeEnabled: true,
|
||||
signal: context.abortController.signal,
|
||||
})
|
||||
} catch {
|
||||
// Best-effort upload — local path is always available
|
||||
}
|
||||
}
|
||||
|
||||
const delivered = !appState.replBridgeEnabled || Boolean(fileUuid)
|
||||
return {
|
||||
data: {
|
||||
sent: false,
|
||||
file_path: _input.file_path,
|
||||
error: 'SendUserFile requires the KAIROS assistant transport layer.',
|
||||
sent: delivered,
|
||||
file_path,
|
||||
size: fileSize,
|
||||
...(fileUuid ? { file_uuid: fileUuid } : {}),
|
||||
...(!delivered ? { error: 'Bridge upload failed. File available at local path.' } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -72,18 +72,18 @@ describe("detectColorMode", () => {
|
||||
|
||||
describe("detectLanguage", () => {
|
||||
test("detects language from file extension", () => {
|
||||
expect(detectLanguage("index.ts", null)).toBe("ts");
|
||||
expect(detectLanguage("main.py", null)).toBe("py");
|
||||
expect(detectLanguage("style.css", null)).toBe("css");
|
||||
expect(detectLanguage("index.ts")).toBe("ts");
|
||||
expect(detectLanguage("main.py")).toBe("py");
|
||||
expect(detectLanguage("style.css")).toBe("css");
|
||||
});
|
||||
|
||||
test("detects language from known filenames", () => {
|
||||
expect(detectLanguage("Makefile", null)).toBe("makefile");
|
||||
expect(detectLanguage("Dockerfile", null)).toBe("dockerfile");
|
||||
expect(detectLanguage("Makefile")).toBe("makefile");
|
||||
expect(detectLanguage("Dockerfile")).toBe("dockerfile");
|
||||
});
|
||||
|
||||
test("returns null for unknown extensions", () => {
|
||||
expect(detectLanguage("file.xyz123", null)).toBeNull();
|
||||
expect(detectLanguage("file.xyz123")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -38,7 +38,7 @@ describe('InProcessTransport', () => {
|
||||
let received: JSONRPCMessage | null = null
|
||||
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))
|
||||
|
||||
|
||||
@@ -57,9 +57,9 @@ describe('discoverTools', () => {
|
||||
expect(tool.name).toBe('mcp__my-server__search')
|
||||
expect(tool.mcpInfo).toEqual({ serverName: 'my-server', toolName: 'search' })
|
||||
expect(tool.isMcp).toBe(true)
|
||||
expect(tool.isReadOnly({} as any)).toBe(true)
|
||||
expect(tool.userFacingName(undefined)).toBe('Search Items')
|
||||
expect(await tool.description({} as any, { isNonInteractiveSession: false, toolPermissionContext: {}, tools: [] })).toBe('Search for items')
|
||||
expect(tool.isReadOnly()).toBe(true)
|
||||
expect(tool.userFacingName()).toBe('Search Items')
|
||||
expect(await tool.description()).toBe('Search for items')
|
||||
})
|
||||
|
||||
test('respects skipPrefix option', async () => {
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('createMcpManager', () => {
|
||||
|
||||
const result = await manager.connect('test-server', { command: 'npx', args: [] })
|
||||
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 () => {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -25,18 +25,17 @@ import {
|
||||
storeUpdateSession,
|
||||
storeGetEnvironment,
|
||||
storeGetSession,
|
||||
storeListActiveEnvironments,
|
||||
} from "../store";
|
||||
import { getEventBus, getAllEventBuses, removeEventBus } from "../transport/event-bus";
|
||||
import { runDisconnectMonitorSweep } from "../services/disconnect-monitor";
|
||||
|
||||
describe("Disconnect Monitor Logic", () => {
|
||||
beforeEach(() => {
|
||||
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", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
const timeoutMs = 300 * 1000; // 5 minutes
|
||||
@@ -45,7 +44,14 @@ describe("Disconnect Monitor Logic", () => {
|
||||
const oldDate = new Date(Date.now() - timeoutMs - 60000);
|
||||
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);
|
||||
expect(updated?.status).toBe("disconnected");
|
||||
@@ -53,56 +59,43 @@ describe("Disconnect Monitor Logic", () => {
|
||||
|
||||
test("environment stays active when lastPollAt is recent", () => {
|
||||
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);
|
||||
expect(updated?.status).toBe("active");
|
||||
});
|
||||
|
||||
test("session becomes inactive when updatedAt is too old", () => {
|
||||
const session = storeCreateSession({});
|
||||
const session = storeCreateSession({ status: "idle" });
|
||||
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);
|
||||
expect(rec).toBeTruthy();
|
||||
if (!rec) return;
|
||||
|
||||
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000);
|
||||
|
||||
runDisconnectMonitorSweep();
|
||||
|
||||
const updated = storeGetSession(session.id);
|
||||
expect(updated?.status).toBe("inactive");
|
||||
// Session was just updated, should not be inactive
|
||||
expect(rec?.status).toBe("running");
|
||||
expect(now - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
|
||||
});
|
||||
|
||||
test("session stays running when recently updated", () => {
|
||||
const session = storeCreateSession({});
|
||||
storeUpdateSession(session.id, { status: "running" });
|
||||
|
||||
runDisconnectMonitorSweep();
|
||||
|
||||
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 timeoutMs = 300 * 1000 * 2;
|
||||
const rec = storeGetSession(session.id);
|
||||
expect(rec).toBeTruthy();
|
||||
if (!rec) return;
|
||||
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" },
|
||||
});
|
||||
expect(rec?.status).toBe("running");
|
||||
expect(Date.now() - rec!.updatedAt.getTime()).toBeLessThan(timeoutMs);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,18 +19,16 @@ mock.module("../config", () => ({
|
||||
|
||||
import { Hono } from "hono";
|
||||
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 { publishSessionEvent } from "../services/transport";
|
||||
|
||||
// Import route modules
|
||||
import v1Sessions from "../routes/v1/sessions";
|
||||
import v1Environments from "../routes/v1/environments";
|
||||
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 v2Worker from "../routes/v2/worker";
|
||||
import v2WorkerEventsStream from "../routes/v2/worker-events-stream";
|
||||
import v2WorkerEvents from "../routes/v2/worker-events";
|
||||
import webAuth from "../routes/web/auth";
|
||||
import webSessions from "../routes/web/sessions";
|
||||
@@ -45,7 +43,6 @@ function createApp() {
|
||||
app.route("/v2/session_ingress", v1SessionIngress);
|
||||
app.route("/v1/code/sessions", v2CodeSessions);
|
||||
app.route("/v1/code/sessions", v2Worker);
|
||||
app.route("/v1/code/sessions", v2WorkerEventsStream);
|
||||
app.route("/v1/code/sessions", v2WorkerEvents);
|
||||
app.route("/web", webAuth);
|
||||
app.route("/web", webSessions);
|
||||
@@ -56,11 +53,6 @@ function createApp() {
|
||||
|
||||
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", () => {
|
||||
let app: Hono;
|
||||
|
||||
@@ -117,24 +109,6 @@ describe("V1 Session Routes", () => {
|
||||
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 () => {
|
||||
const createRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
@@ -168,32 +142,6 @@ describe("V1 Session Routes", () => {
|
||||
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 () => {
|
||||
const createRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
@@ -212,30 +160,6 @@ describe("V1 Session Routes", () => {
|
||||
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 () => {
|
||||
// First register an environment
|
||||
const envRes = await app.request("/v1/environments/bridge", {
|
||||
@@ -519,26 +443,6 @@ describe("Web Auth Routes", () => {
|
||||
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 () => {
|
||||
const res = await app.request("/web/bind?uuid=test-uuid", {
|
||||
method: "POST",
|
||||
@@ -597,24 +501,6 @@ describe("Web Session Routes", () => {
|
||||
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 () => {
|
||||
const res = await app.request("/web/sessions");
|
||||
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
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
@@ -704,22 +563,6 @@ describe("Web Session Routes", () => {
|
||||
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 () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
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 () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
@@ -833,25 +658,6 @@ describe("Web Session Routes", () => {
|
||||
const eventsRes = await app.request(`/web/sessions/${id}/events?uuid=user-2`);
|
||||
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", () => {
|
||||
@@ -886,32 +692,6 @@ describe("Web Control Routes", () => {
|
||||
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 () => {
|
||||
const res = await app.request(`/web/sessions/${sessionId}/events?uuid=user-2`, {
|
||||
method: "POST",
|
||||
@@ -963,33 +743,6 @@ describe("Web Control Routes", () => {
|
||||
});
|
||||
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", () => {
|
||||
@@ -1069,81 +822,6 @@ describe("V1 Session Ingress Routes (HTTP)", () => {
|
||||
});
|
||||
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", () => {
|
||||
@@ -1178,112 +856,6 @@ describe("V2 Worker Events Routes", () => {
|
||||
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 () => {
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
@@ -1331,20 +903,4 @@ describe("V2 Worker Events Routes", () => {
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,14 +345,6 @@ describe("Transport Service", () => {
|
||||
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", () => {
|
||||
const result = normalizePayload("tool", { name: "Read" });
|
||||
expect(result.tool_name).toBe("Read");
|
||||
|
||||
@@ -336,26 +336,6 @@ describe("ws-handler", () => {
|
||||
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", () => {
|
||||
const bus = getEventBus("gen1");
|
||||
const ws = createMockWs();
|
||||
|
||||
@@ -14,14 +14,14 @@ app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
|
||||
/** DELETE /v1/environments/bridge/:id — Deregister */
|
||||
app.delete("/bridge/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const envId = c.req.param("id")!;
|
||||
const envId = c.req.param("id");
|
||||
deregisterEnvironment(envId);
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
|
||||
/** POST /v1/environments/:id/bridge/reconnect — Reconnect */
|
||||
app.post("/:id/bridge/reconnect", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const envId = c.req.param("id")!;
|
||||
const envId = c.req.param("id");
|
||||
reconnectEnvironment(envId);
|
||||
const { reconnectWorkForEnvironment } = await import("../../services/work-dispatch");
|
||||
await reconnectWorkForEnvironment(envId);
|
||||
|
||||
@@ -7,7 +7,7 @@ const app = new Hono();
|
||||
|
||||
/** GET /v1/environments/:id/work/poll — Long-poll for work */
|
||||
app.get("/:id/work/poll", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const envId = c.req.param("id")!;
|
||||
const envId = c.req.param("id");
|
||||
updatePollTime(envId);
|
||||
const result = await pollWork(envId);
|
||||
if (!result) {
|
||||
@@ -19,21 +19,21 @@ app.get("/:id/work/poll", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
|
||||
/** POST /v1/environments/:id/work/:workId/ack — Acknowledge work */
|
||||
app.post("/:id/work/:workId/ack", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const workId = c.req.param("workId")!;
|
||||
const workId = c.req.param("workId");
|
||||
ackWork(workId);
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
|
||||
/** POST /v1/environments/:id/work/:workId/stop — Stop work */
|
||||
app.post("/:id/work/:workId/stop", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const workId = c.req.param("workId")!;
|
||||
const workId = c.req.param("workId");
|
||||
stopWork(workId);
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
|
||||
/** POST /v1/environments/:id/work/:workId/heartbeat — Heartbeat */
|
||||
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);
|
||||
return c.json(result, 200);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
handleWebSocketClose,
|
||||
ingestBridgeMessage,
|
||||
} from "../../transport/ws-handler";
|
||||
import { getSession, resolveExistingSessionId } from "../../services/session";
|
||||
import { getSession } from "../../services/session";
|
||||
|
||||
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) */
|
||||
app.post("/session/:sessionId/events", async (c) => {
|
||||
const requestedSessionId = c.req.param("sessionId")!;
|
||||
const sessionId = resolveExistingSessionId(requestedSessionId) ?? requestedSessionId;
|
||||
const sessionId = c.req.param("sessionId")!;
|
||||
|
||||
if (!authenticateRequest(c, `POST session/${sessionId}`, sessionId)) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Invalid auth" } }, 401);
|
||||
@@ -72,8 +71,7 @@ app.post("/session/:sessionId/events", async (c) => {
|
||||
app.get(
|
||||
"/ws/:sessionId",
|
||||
upgradeWebSocket(async (c) => {
|
||||
const requestedSessionId = c.req.param("sessionId")!;
|
||||
const sessionId = resolveExistingSessionId(requestedSessionId) ?? requestedSessionId;
|
||||
const sessionId = c.req.param("sessionId")!;
|
||||
|
||||
if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) {
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
getSession,
|
||||
updateSessionTitle,
|
||||
archiveSession,
|
||||
resolveExistingSessionId,
|
||||
} from "../../services/session";
|
||||
import { createWorkItem } from "../../services/work-dispatch";
|
||||
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||
@@ -39,8 +38,7 @@ app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
|
||||
/** GET /v1/sessions/:id — Get session */
|
||||
app.get("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
|
||||
const session = getSession(sessionId);
|
||||
const session = getSession(c.req.param("id"));
|
||||
if (!session) {
|
||||
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 */
|
||||
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();
|
||||
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);
|
||||
});
|
||||
|
||||
/** POST /v1/sessions/:id/archive — Archive session */
|
||||
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 {
|
||||
archiveSession(sessionId);
|
||||
archiveSession(c.req.param("id"));
|
||||
} catch {
|
||||
return c.json({ status: "ok" }, 409);
|
||||
}
|
||||
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
|
||||
/** POST /v1/sessions/:id/events — Send event to session */
|
||||
app.post("/:id/events", 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);
|
||||
}
|
||||
const sessionId = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
|
||||
const events = body.events
|
||||
|
||||
@@ -15,7 +15,7 @@ app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
|
||||
/** POST /v1/code/sessions/:id/bridge — Get connection info + worker JWT */
|
||||
app.post("/:id/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const sessionId = 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);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||
import { createWorkerEventStream } from "../../transport/sse-writer";
|
||||
import { createSSEStream } from "../../transport/sse-writer";
|
||||
import { getSession } from "../../services/session";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
/** SSE /v1/code/sessions/:id/worker/events/stream — SSE event stream */
|
||||
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);
|
||||
if (!session) {
|
||||
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 fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
|
||||
|
||||
return createWorkerEventStream(c, sessionId, fromSeqNum);
|
||||
return createSSEStream(c, sessionId, fromSeqNum);
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -1,66 +1,32 @@
|
||||
import { Hono } from "hono";
|
||||
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||
import { publishSessionEvent } from "../../services/transport";
|
||||
import { getSession, touchSession, updateSessionStatus } from "../../services/session";
|
||||
import { getSession, updateSessionStatus } from "../../services/session";
|
||||
|
||||
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 */
|
||||
app.post("/:id/worker/events", 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);
|
||||
}
|
||||
const sessionId = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
|
||||
const events = extractWorkerEvents(body);
|
||||
const events = Array.isArray(body) ? body : [body];
|
||||
const published = [];
|
||||
for (const evt of events) {
|
||||
const eventType = typeof evt.type === "string" ? evt.type : "message";
|
||||
const result = publishSessionEvent(sessionId, eventType, evt, "inbound");
|
||||
const result = publishSessionEvent(sessionId, evt.type || "message", evt, "inbound");
|
||||
published.push(result);
|
||||
}
|
||||
|
||||
touchSession(sessionId);
|
||||
|
||||
return c.json({ status: "ok", count: published.length }, 200);
|
||||
});
|
||||
|
||||
/** PUT /v1/code/sessions/:id/worker/state — Report worker state */
|
||||
app.put("/:id/worker/state", 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);
|
||||
}
|
||||
const sessionId = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
|
||||
if (body.status) {
|
||||
updateSessionStatus(sessionId, body.status);
|
||||
} else {
|
||||
touchSession(sessionId);
|
||||
}
|
||||
|
||||
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) */
|
||||
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.
|
||||
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) */
|
||||
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).
|
||||
// Accept and discard — event bus doesn't track per-event delivery.
|
||||
return c.json({ status: "ok" }, 200);
|
||||
|
||||
@@ -1,78 +1,12 @@
|
||||
import { Hono } from "hono";
|
||||
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
|
||||
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
|
||||
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
|
||||
import { getSession, incrementEpoch } from "../../services/session";
|
||||
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||
|
||||
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 */
|
||||
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);
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { storeBindSession } from "../../store";
|
||||
import { resolveExistingWebSessionId, toWebSessionId } from "../../services/session";
|
||||
import { storeGetSession, storeBindSession } from "../../store";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -15,13 +14,13 @@ app.post("/bind", async (c) => {
|
||||
return c.json({ error: "sessionId and uuid are required" }, 400);
|
||||
}
|
||||
|
||||
const resolvedSessionId = resolveExistingWebSessionId(sessionId);
|
||||
if (!resolvedSessionId) {
|
||||
const session = storeGetSession(sessionId);
|
||||
if (!session) {
|
||||
return c.json({ error: "Session not found" }, 404);
|
||||
}
|
||||
|
||||
storeBindSession(resolvedSessionId, uuid);
|
||||
return c.json({ ok: true, sessionId: toWebSessionId(resolvedSessionId) });
|
||||
storeBindSession(sessionId, uuid);
|
||||
return c.json({ ok: true, sessionId });
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -1,46 +1,31 @@
|
||||
import { Hono } from "hono";
|
||||
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 { getEventBus } from "../../transport/event-bus";
|
||||
import { storeIsSessionOwner } from "../../store";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
type OwnershipCheckResult =
|
||||
| { error: true }
|
||||
| { error: true; reason: string }
|
||||
| { error: false; session: NonNullable<ReturnType<typeof getSession>>; sessionId: string };
|
||||
|
||||
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 };
|
||||
function checkOwnership(c: { get: (key: string) => string | undefined }, sessionId: string) {
|
||||
const uuid = c.get("uuid");
|
||||
if (!storeIsSessionOwner(sessionId, uuid)) {
|
||||
return { error: true, session: null };
|
||||
}
|
||||
const session = getSession(resolvedSessionId);
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
return { error: true };
|
||||
return { error: true, session: null };
|
||||
}
|
||||
if (isSessionClosedStatus(session.status)) {
|
||||
return { error: true, reason: `Session is ${session.status}` };
|
||||
}
|
||||
return { error: false, session, sessionId: resolvedSessionId };
|
||||
}
|
||||
|
||||
function closedSessionResponse(message: string) {
|
||||
return { error: { type: "session_closed", message } };
|
||||
return { error: false, session };
|
||||
}
|
||||
|
||||
/** POST /web/sessions/:id/events — Send user message to session */
|
||||
app.post("/sessions/:id/events", uuidAuth, async (c) => {
|
||||
const requestedSessionId = c.req.param("id")!;
|
||||
const ownership = checkOwnership(c, requestedSessionId);
|
||||
if (ownership.error) {
|
||||
const message = "reason" in ownership ? ownership.reason : "Not your session";
|
||||
const status = "reason" in ownership ? 409 : 403;
|
||||
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
|
||||
const sessionId = c.req.param("id")!;
|
||||
const { error } = checkOwnership(c, sessionId);
|
||||
if (error) {
|
||||
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||
}
|
||||
const { sessionId } = ownership;
|
||||
|
||||
const body = await c.req.json();
|
||||
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) */
|
||||
app.post("/sessions/:id/control", uuidAuth, async (c) => {
|
||||
const requestedSessionId = c.req.param("id")!;
|
||||
const ownership = checkOwnership(c, requestedSessionId);
|
||||
if (ownership.error) {
|
||||
const message = "reason" in ownership ? ownership.reason : "Not your session";
|
||||
const status = "reason" in ownership ? 409 : 403;
|
||||
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
|
||||
const sessionId = c.req.param("id")!;
|
||||
const { error } = checkOwnership(c, sessionId);
|
||||
if (error) {
|
||||
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||
}
|
||||
const { sessionId } = ownership;
|
||||
|
||||
const body = await c.req.json();
|
||||
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 */
|
||||
app.post("/sessions/:id/interrupt", uuidAuth, async (c) => {
|
||||
const requestedSessionId = c.req.param("id")!;
|
||||
const ownership = checkOwnership(c, requestedSessionId);
|
||||
if (ownership.error) {
|
||||
const message = "reason" in ownership ? ownership.reason : "Not your session";
|
||||
const status = "reason" in ownership ? 409 : 403;
|
||||
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
|
||||
const sessionId = c.req.param("id")!;
|
||||
const { error } = checkOwnership(c, sessionId);
|
||||
if (error) {
|
||||
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||
}
|
||||
const { sessionId } = ownership;
|
||||
|
||||
publishSessionEvent(sessionId, "interrupt", { action: "interrupt" }, "outbound");
|
||||
updateSessionStatus(sessionId, "idle");
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import { uuidAuth } from "../../auth/middleware";
|
||||
import {
|
||||
createSession,
|
||||
getSession,
|
||||
isSessionClosedStatus,
|
||||
listWebSessionSummariesByOwnerUuid,
|
||||
listWebSessionsByOwnerUuid,
|
||||
resolveOwnedWebSessionId,
|
||||
toWebSessionResponse,
|
||||
} from "../../services/session";
|
||||
import { storeBindSession } from "../../store";
|
||||
import { getSession, createSession } from "../../services/session";
|
||||
import { storeListSessionsByOwnerUuid, storeIsSessionOwner, storeBindSession } from "../../store";
|
||||
import { createWorkItem } from "../../services/work-dispatch";
|
||||
import { listSessionSummariesByOwnerUuid } from "../../services/session";
|
||||
import { createSSEStream } from "../../transport/sse-writer";
|
||||
import { getEventBus } from "../../transport/event-bus";
|
||||
|
||||
@@ -18,7 +11,7 @@ const app = new Hono();
|
||||
|
||||
/** POST /web/sessions — Create a session from web UI */
|
||||
app.post("/sessions", uuidAuth, async (c) => {
|
||||
const uuid = c.get("uuid")!;
|
||||
const uuid = c.get("uuid");
|
||||
const body = await c.req.json();
|
||||
const session = createSession({
|
||||
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 */
|
||||
app.get("/sessions", uuidAuth, async (c) => {
|
||||
const uuid = c.get("uuid")!;
|
||||
const sessions = listWebSessionsByOwnerUuid(uuid);
|
||||
const uuid = c.get("uuid");
|
||||
const sessions = storeListSessionsByOwnerUuid(uuid);
|
||||
return c.json(sessions, 200);
|
||||
});
|
||||
|
||||
/** GET /web/sessions/all — List sessions owned by the requesting UUID (unowned sessions excluded) */
|
||||
app.get("/sessions/all", uuidAuth, async (c) => {
|
||||
const uuid = c.get("uuid")!;
|
||||
const sessions = listWebSessionSummariesByOwnerUuid(uuid);
|
||||
const uuid = c.get("uuid");
|
||||
const sessions = listSessionSummariesByOwnerUuid(uuid);
|
||||
return c.json(sessions, 200);
|
||||
});
|
||||
|
||||
/** GET /web/sessions/:id — Session detail */
|
||||
app.get("/sessions/:id", uuidAuth, async (c) => {
|
||||
const uuid = c.get("uuid")!;
|
||||
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid);
|
||||
if (!sessionId) {
|
||||
const uuid = c.get("uuid");
|
||||
const sessionId = c.req.param("id")!;
|
||||
if (!storeIsSessionOwner(sessionId, uuid)) {
|
||||
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||
}
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
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 */
|
||||
app.get("/sessions/:id/history", uuidAuth, async (c) => {
|
||||
const uuid = c.get("uuid")!;
|
||||
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid);
|
||||
if (!sessionId) {
|
||||
const uuid = c.get("uuid");
|
||||
const sessionId = c.req.param("id")!;
|
||||
if (!storeIsSessionOwner(sessionId, uuid)) {
|
||||
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||
}
|
||||
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 */
|
||||
app.get("/sessions/:id/events", uuidAuth, async (c) => {
|
||||
const uuid = c.get("uuid")!;
|
||||
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid);
|
||||
if (!sessionId) {
|
||||
const uuid = c.get("uuid");
|
||||
const sessionId = c.req.param("id")!;
|
||||
if (!storeIsSessionOwner(sessionId, uuid)) {
|
||||
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||
}
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
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 fromSeqNum = lastEventId ? parseInt(lastEventId) : 0;
|
||||
|
||||
@@ -1,35 +1,32 @@
|
||||
import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store";
|
||||
import { storeListSessions } from "../store";
|
||||
import { storeListSessions, storeUpdateSession } from "../store";
|
||||
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() {
|
||||
const timeoutMs = config.disconnectTimeout * 1000;
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import {
|
||||
storeCreateSession,
|
||||
storeGetSession,
|
||||
storeIsSessionOwner,
|
||||
storeUpdateSession,
|
||||
storeListSessions,
|
||||
storeListSessionsByUsername,
|
||||
storeListSessionsByEnvironment,
|
||||
storeListSessionsByOwnerUuid,
|
||||
} 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 { 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 {
|
||||
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 {
|
||||
const record = storeCreateSession({
|
||||
environmentId: req.environment_id,
|
||||
@@ -75,78 +51,16 @@ export function getSession(sessionId: string): SessionResponse | 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) {
|
||||
storeUpdateSession(sessionId, { title });
|
||||
}
|
||||
|
||||
export function updateSessionStatus(sessionId: string, status: string) {
|
||||
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) {
|
||||
updateSessionStatus(sessionId, "archived");
|
||||
storeUpdateSession(sessionId, { status: "archived" });
|
||||
removeEventBus(sessionId);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,8 +51,6 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
|
||||
raw: payload,
|
||||
};
|
||||
|
||||
if (typeof p.uuid === "string" && p.uuid) normalized.uuid = p.uuid;
|
||||
|
||||
// Preserve tool fields
|
||||
if (p.tool_name) normalized.tool_name = p.tool_name;
|
||||
if (p.name) normalized.tool_name = p.name;
|
||||
|
||||
@@ -47,16 +47,6 @@ export interface WorkItemRecord {
|
||||
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) ----------
|
||||
|
||||
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 sessions = new Map<string, SessionRecord>();
|
||||
const workItems = new Map<string, WorkItemRecord>();
|
||||
const sessionWorkers = new Map<string, SessionWorkerRecord>();
|
||||
|
||||
// UUID → session ownership: sessionId → Set of UUIDs
|
||||
const sessionOwners = new Map<string, Set<string>>();
|
||||
@@ -201,59 +190,9 @@ export function storeListSessionsByEnvironment(envId: string): SessionRecord[] {
|
||||
}
|
||||
|
||||
export function storeDeleteSession(id: string): boolean {
|
||||
sessionWorkers.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 ----------
|
||||
|
||||
// ---------- Session Ownership (UUID-based) ----------
|
||||
@@ -333,6 +272,5 @@ export function storeReset() {
|
||||
environments.clear();
|
||||
sessions.clear();
|
||||
workItems.clear();
|
||||
sessionWorkers.clear();
|
||||
sessionOwners.clear();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,14 +24,13 @@ const SERVER_KEEPALIVE_INTERVAL_MS = 60_000;
|
||||
*/
|
||||
function toSDKMessage(event: SessionEvent): string {
|
||||
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>;
|
||||
|
||||
if (event.type === "user" || event.type === "user_message") {
|
||||
msg = {
|
||||
type: "user",
|
||||
uuid: messageUuid,
|
||||
uuid: event.id,
|
||||
session_id: event.sessionId,
|
||||
message: {
|
||||
role: "user",
|
||||
@@ -83,7 +82,7 @@ function toSDKMessage(event: SessionEvent): string {
|
||||
} else {
|
||||
msg = {
|
||||
type: event.type,
|
||||
uuid: messageUuid,
|
||||
uuid: event.id,
|
||||
session_id: event.sessionId,
|
||||
message: payload,
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
"exclude": ["node_modules", "dist", "web"]
|
||||
}
|
||||
|
||||
@@ -4,26 +4,18 @@
|
||||
*/
|
||||
import { getUuid, setUuid, apiBind, apiFetchSessions, apiFetchAllSessions, apiFetchEnvironments, apiFetchSession, apiFetchSessionHistory, apiSendEvent, apiSendControl, apiInterrupt, apiCreateSession } from "./api.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 { esc, formatTime, statusClass, isClosedSessionStatus } from "./utils.js";
|
||||
import { esc, formatTime, statusClass } from "./utils.js";
|
||||
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
|
||||
let currentSessionId = null;
|
||||
let currentSessionStatus = null;
|
||||
let dashboardInterval = null;
|
||||
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
|
||||
// ============================================================
|
||||
@@ -51,69 +43,6 @@ function navigate(path) {
|
||||
}
|
||||
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() {
|
||||
// Ensure we have a UUID
|
||||
getUuid();
|
||||
@@ -157,8 +86,6 @@ async function handleRoute() {
|
||||
}
|
||||
|
||||
// Default: /code → dashboard
|
||||
currentSessionId = null;
|
||||
currentSessionStatus = null;
|
||||
showPage("dashboard");
|
||||
disconnectSSE();
|
||||
renderDashboard();
|
||||
@@ -245,7 +172,9 @@ async function renderSessionDetail(id) {
|
||||
document.getElementById("session-id").textContent = session.id;
|
||||
document.getElementById("session-env").textContent = session.environment_id || "";
|
||||
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) {
|
||||
alert("Failed to load session: " + err.message);
|
||||
navigate("/code/");
|
||||
@@ -272,13 +201,7 @@ async function renderSessionDetail(id) {
|
||||
// Re-render any still-unresolved permission prompts from history
|
||||
renderReplayPendingRequests();
|
||||
|
||||
if (isClosedSessionStatus(currentSessionStatus)) {
|
||||
appendEvent({ type: "session_status", payload: { status: currentSessionStatus } });
|
||||
disconnectSSE();
|
||||
return;
|
||||
}
|
||||
|
||||
connectSSE(id, handleSessionEvent, lastSeqNum);
|
||||
connectSSE(id, appendEvent, lastSeqNum);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -314,35 +237,28 @@ function setupControlBar() {
|
||||
}
|
||||
|
||||
async function doInterrupt() {
|
||||
if (!currentSessionId || isClosedSessionStatus(currentSessionStatus)) return;
|
||||
if (!currentSessionId) return;
|
||||
const btn = document.getElementById("action-btn");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await apiInterrupt(currentSessionId);
|
||||
appendEvent({ type: "interrupt", payload: { message: "Session interrupted" } });
|
||||
} catch (err) {
|
||||
await syncClosedSessionState(err, "Interrupt failed");
|
||||
alert("Interrupt failed: " + err.message);
|
||||
} finally {
|
||||
btn.disabled = isClosedSessionStatus(currentSessionStatus);
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById("msg-input");
|
||||
const text = input.value.trim();
|
||||
if (!text || !currentSessionId || isClosedSessionStatus(currentSessionStatus)) return;
|
||||
if (!text || !currentSessionId) return;
|
||||
input.value = "";
|
||||
const uuid = generateMessageUuid();
|
||||
try {
|
||||
await apiSendEvent(currentSessionId, {
|
||||
type: "user",
|
||||
uuid,
|
||||
content: text,
|
||||
message: { content: text },
|
||||
});
|
||||
await apiSendEvent(currentSessionId, { type: "user", content: text });
|
||||
} catch (err) {
|
||||
input.value = text;
|
||||
await syncClosedSessionState(err, "Failed to send");
|
||||
alert("Failed to send: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +150,6 @@ nav {
|
||||
|
||||
.status-active, .status-running { background: var(--green-bg); color: var(--green); }
|
||||
.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-archived { background: #F0ECE7; color: var(--text-secondary); }
|
||||
.status-error { background: var(--red-bg); color: var(--red); }
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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="/code/style.css" />
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Nav Bar -->
|
||||
@@ -146,6 +146,6 @@
|
||||
<!-- QR Libraries -->
|
||||
<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 type="module" src="/code/app.js"></script>
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -13,13 +13,11 @@ import { processAssistantEvent } from "./task-panel.js";
|
||||
|
||||
const replayPendingRequests = new Map(); // request_id → event data (unresolved)
|
||||
const replayRespondedRequests = new Set(); // request_ids that have a response
|
||||
const renderedUserUuids = new Set();
|
||||
|
||||
/** Clear replay tracking state (call before each history load) */
|
||||
export function resetReplayState() {
|
||||
replayPendingRequests.clear();
|
||||
replayRespondedRequests.clear();
|
||||
renderedUserUuids.clear();
|
||||
}
|
||||
|
||||
/** After replay finishes, render any still-unresolved permission prompts */
|
||||
@@ -86,59 +84,6 @@ function formatAssistantContent(content) {
|
||||
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
|
||||
// ============================================================
|
||||
@@ -158,42 +103,26 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
// During history replay, only render messages & tools — skip interactive/stateful events
|
||||
// Exception: unresolved permission/control requests are re-shown as pending prompts.
|
||||
if (replay) {
|
||||
const histEls = [];
|
||||
let histEl;
|
||||
switch (type) {
|
||||
case "user":
|
||||
{
|
||||
const toolResultEls = renderEmbeddedToolResultBlocks(payload);
|
||||
if (toolResultEls.length > 0) {
|
||||
histEls.push(...toolResultEls);
|
||||
break;
|
||||
}
|
||||
if (shouldRenderUserEvent(payload, direction, true)) {
|
||||
histEls.push(renderUserMessage(payload, direction));
|
||||
}
|
||||
}
|
||||
if (direction === "outbound") histEl = renderUserMessage(payload, direction);
|
||||
break;
|
||||
case "assistant":
|
||||
{
|
||||
const toolUseEls = renderEmbeddedToolUseBlocks(payload);
|
||||
const text = extractText(payload);
|
||||
if (text && text.trim()) histEls.push(renderAssistantMessage(payload));
|
||||
if (toolUseEls.length > 0) histEls.push(...toolUseEls);
|
||||
if (text && text.trim()) histEl = renderAssistantMessage(payload);
|
||||
processAssistantEvent(payload);
|
||||
}
|
||||
break;
|
||||
case "tool_use":
|
||||
histEls.push(renderToolUse(payload));
|
||||
histEl = renderToolUse(payload);
|
||||
break;
|
||||
case "tool_result":
|
||||
histEls.push(renderToolResult(payload));
|
||||
histEl = renderToolResult(payload);
|
||||
break;
|
||||
case "error":
|
||||
histEls.push(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}`));
|
||||
}
|
||||
histEl = renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`);
|
||||
break;
|
||||
case "control_request":
|
||||
case "permission_request":
|
||||
@@ -220,42 +149,32 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
default:
|
||||
return;
|
||||
}
|
||||
for (const histEl of histEls) {
|
||||
if (histEl) {
|
||||
stream.appendChild(histEl);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const els = [];
|
||||
let el;
|
||||
let needLoading = false;
|
||||
|
||||
switch (type) {
|
||||
case "user":
|
||||
{
|
||||
const toolResultEls = renderEmbeddedToolResultBlocks(payload);
|
||||
if (toolResultEls.length > 0) {
|
||||
els.push(...toolResultEls);
|
||||
break;
|
||||
}
|
||||
if (!shouldRenderUserEvent(payload, direction, false)) return;
|
||||
els.push(renderUserMessage(payload, direction));
|
||||
needLoading = true;
|
||||
}
|
||||
// Skip inbound user messages — they're echoes of what we already sent
|
||||
if (direction === "inbound") return;
|
||||
el = renderUserMessage(payload, direction);
|
||||
needLoading = true;
|
||||
break;
|
||||
case "partial_assistant":
|
||||
// Skip partial assistant — wait for the final "assistant" event
|
||||
// to avoid blank/duplicate messages during streaming
|
||||
return;
|
||||
case "assistant":
|
||||
removeLoading();
|
||||
{
|
||||
const toolUseEls = renderEmbeddedToolUseBlocks(payload);
|
||||
const text = extractText(payload);
|
||||
if (text && text.trim()) {
|
||||
removeLoading();
|
||||
els.push(renderAssistantMessage(payload));
|
||||
}
|
||||
if (toolUseEls.length > 0) els.push(...toolUseEls);
|
||||
if (text && text.trim()) el = renderAssistantMessage(payload);
|
||||
processAssistantEvent(payload);
|
||||
}
|
||||
break;
|
||||
@@ -265,10 +184,10 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
// Skip result — it just repeats the assistant message content
|
||||
return;
|
||||
case "tool_use":
|
||||
els.push(renderToolUse(payload));
|
||||
el = renderToolUse(payload);
|
||||
break;
|
||||
case "tool_result":
|
||||
els.push(renderToolResult(payload));
|
||||
el = renderToolResult(payload);
|
||||
break;
|
||||
case "control_request":
|
||||
case "permission_request":
|
||||
@@ -276,27 +195,27 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
const toolName = payload.request.tool_name || "unknown";
|
||||
const toolInput = payload.request.input || payload.request.tool_input || {};
|
||||
if (toolName === "AskUserQuestion") {
|
||||
els.push(renderAskUserQuestion({
|
||||
el = renderAskUserQuestion({
|
||||
request_id: payload.request_id || data.id,
|
||||
tool_input: toolInput,
|
||||
description: payload.request.description || "",
|
||||
}));
|
||||
});
|
||||
} else if (toolName === "ExitPlanMode") {
|
||||
els.push(renderExitPlanMode({
|
||||
el = renderExitPlanMode({
|
||||
request_id: payload.request_id || data.id,
|
||||
tool_input: toolInput,
|
||||
description: payload.request.description || "",
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
els.push(renderPermissionRequest({
|
||||
el = renderPermissionRequest({
|
||||
request_id: payload.request_id || data.id,
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
description: payload.request.description || "",
|
||||
}));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
els.push(renderSystemMessage(`Control: ${payload.request?.subtype || "unknown"}`));
|
||||
el = renderSystemMessage(`Control: ${payload.request?.subtype || "unknown"}`);
|
||||
}
|
||||
break;
|
||||
case "control_response":
|
||||
@@ -310,22 +229,16 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
const fullText = typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
if (/connecting|waiting|initializing|Remote Control/i.test(msg + " " + fullText)) return;
|
||||
if (!msg.trim()) return;
|
||||
els.push(renderSystemMessage(msg));
|
||||
el = renderSystemMessage(msg);
|
||||
}
|
||||
break;
|
||||
case "error":
|
||||
removeLoading();
|
||||
els.push(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}`));
|
||||
}
|
||||
el = renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`);
|
||||
break;
|
||||
case "interrupt":
|
||||
removeLoading();
|
||||
els.push(renderSystemMessage("Session interrupted"));
|
||||
el = renderSystemMessage("Session interrupted");
|
||||
break;
|
||||
case "system":
|
||||
// Skip raw system/init messages — they're noise
|
||||
@@ -334,11 +247,11 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
// Skip noise from bridge init
|
||||
const raw = JSON.stringify(payload);
|
||||
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.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
|
||||
@@ -19,14 +19,9 @@ export function statusClass(status) {
|
||||
active: "active",
|
||||
running: "running",
|
||||
idle: "idle",
|
||||
inactive: "inactive",
|
||||
requires_action: "requires_action",
|
||||
archived: "archived",
|
||||
error: "error",
|
||||
};
|
||||
return map[status] || "default";
|
||||
}
|
||||
|
||||
export function isClosedSessionStatus(status) {
|
||||
return status === "archived" || status === "inactive";
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -37,8 +37,6 @@ const DEFAULT_FEATURES = [
|
||||
"KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN",
|
||||
// P2: daemon + remote control server
|
||||
"DAEMON",
|
||||
// ACP (Agent Client Protocol) agent mode
|
||||
"ACP",
|
||||
// PR-package restored features
|
||||
"WORKFLOW_SCRIPTS",
|
||||
"HISTORY_SNIP",
|
||||
@@ -49,6 +47,8 @@ const DEFAULT_FEATURES = [
|
||||
"KAIROS",
|
||||
"COORDINATOR_MODE",
|
||||
"LAN_PIPES",
|
||||
"BG_SESSIONS",
|
||||
"TEMPLATES",
|
||||
// "REVIEW_ARTIFACT", // API 请求无响应,需进一步排查 schema 兼容性
|
||||
// P3: poor mode (disable extract_memories + prompt_suggestion)
|
||||
"POOR",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1184,17 +1184,6 @@ export class QueryEngine {
|
||||
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[] {
|
||||
return this.mutableMessages
|
||||
}
|
||||
|
||||
91
src/__tests__/context.baseline.test.ts
Normal file
91
src/__tests__/context.baseline.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const AssistantSessionChooser: (props: Record<string, unknown>) => null = () => null;
|
||||
54
src/assistant/AssistantSessionChooser.tsx
Normal file
54
src/assistant/AssistantSessionChooser.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { Dialog } from '../components/design-system/Dialog.js';
|
||||
import { ListItem } from '../components/design-system/ListItem.js';
|
||||
import { useRegisterOverlay } from '../context/overlayContext.js';
|
||||
import { useKeybindings } from '../keybindings/useKeybinding.js';
|
||||
import type { AssistantSession } from './sessionDiscovery.js';
|
||||
|
||||
interface Props {
|
||||
sessions: AssistantSession[];
|
||||
onSelect: (id: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive session chooser for `claude assistant` when multiple
|
||||
* CCR sessions are discovered. Renders a Dialog with up/down navigation.
|
||||
*
|
||||
* Session IDs are in `session_*` compat format — passed directly to
|
||||
* createRemoteSessionConfig() for viewer attach.
|
||||
*/
|
||||
export function AssistantSessionChooser({ sessions, onSelect, onCancel }: Props): React.ReactNode {
|
||||
useRegisterOverlay('assistant-session-chooser');
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
|
||||
useKeybindings(
|
||||
{
|
||||
'select:next': () => setFocusIndex(i => (i + 1) % sessions.length),
|
||||
'select:previous': () => setFocusIndex(i => (i - 1 + sessions.length) % sessions.length),
|
||||
'select:accept': () => onSelect(sessions[focusIndex]!.id),
|
||||
},
|
||||
{ context: 'Select' },
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog title="Select Assistant Session" onCancel={onCancel} hideInputGuide>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>Multiple sessions found. Select one to attach:</Text>
|
||||
<Box flexDirection="column">
|
||||
{sessions.map((s, i) => (
|
||||
<ListItem key={s.id} isFocused={focusIndex === i}>
|
||||
<Box>
|
||||
<Text>{s.title || s.id.slice(0, 20)}</Text>
|
||||
<Text dimColor> [{s.status}]</Text>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</Box>
|
||||
<Text dimColor>↑↓ navigate · Enter select · Esc cancel</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,21 +5,20 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growt
|
||||
/**
|
||||
* Runtime gate for KAIROS features.
|
||||
*
|
||||
* Build-time: feature('KAIROS') must be on (checked by caller before
|
||||
* this module is required).
|
||||
* Two-layer gate:
|
||||
* 1. Build-time: feature('KAIROS') must be on
|
||||
* 2. Runtime: tengu_kairos_assistant GrowthBook flag (remote kill switch)
|
||||
*
|
||||
* Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill
|
||||
* switch, and kairosActive state must be true (set during bootstrap when
|
||||
* the session qualifies for KAIROS features).
|
||||
* Called by main.tsx BEFORE setKairosActive(true) — must NOT check
|
||||
* kairosActive (that would deadlock: gate needs active, active needs gate).
|
||||
* The caller (main.tsx L1826-1832) sets kairosActive after this returns true.
|
||||
*/
|
||||
export async function isKairosEnabled(): Promise<boolean> {
|
||||
if (!feature('KAIROS')) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)
|
||||
) {
|
||||
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)) {
|
||||
return false
|
||||
}
|
||||
return getKairosActive()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,9 +1,64 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {}
|
||||
export const isAssistantMode: () => boolean = () => false
|
||||
export const initializeAssistantTeam: () => Promise<void> = async () => {}
|
||||
export const markAssistantForced: () => void = () => {}
|
||||
export const isAssistantForced: () => boolean = () => false
|
||||
export const getAssistantSystemPromptAddendum: () => string = () => ''
|
||||
export const getAssistantActivationPath: () => string | undefined = () =>
|
||||
undefined
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { getKairosActive } from '../bootstrap/state.js'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
|
||||
let _assistantForced = false
|
||||
|
||||
/**
|
||||
* Whether the current session is in assistant (KAIROS) daemon mode.
|
||||
* Wraps the bootstrap kairosActive state set by main.tsx after gate check.
|
||||
*/
|
||||
export function isAssistantMode(): boolean {
|
||||
return getKairosActive()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this session as forced assistant mode (--assistant flag).
|
||||
* Skips the GrowthBook gate check — daemon is pre-entitled.
|
||||
*/
|
||||
export function markAssistantForced(): void {
|
||||
_assistantForced = true
|
||||
}
|
||||
|
||||
export function isAssistantForced(): boolean {
|
||||
return _assistantForced
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-create an in-process team so Agent(name) can spawn teammates
|
||||
* without TeamCreate.
|
||||
*
|
||||
* Phase 1: returns undefined so main.tsx's `assistantTeamContext ?? computeInitialTeamContext()`
|
||||
* correctly falls back. Returning {} would bypass the ?? operator since {} is truthy.
|
||||
*
|
||||
* Phase 2: should return a full team context object matching AppState.teamContext shape.
|
||||
*/
|
||||
export async function initializeAssistantTeam(): Promise<undefined> {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Assistant-specific system prompt addendum loaded from ~/.claude/agents/assistant.md.
|
||||
* Returns empty string if the file doesn't exist.
|
||||
*/
|
||||
export function getAssistantSystemPromptAddendum(): string {
|
||||
try {
|
||||
return readFileSync(
|
||||
join(getClaudeConfigHomeDir(), 'agents', 'assistant.md'),
|
||||
'utf-8',
|
||||
)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How assistant mode was activated. Used for diagnostics/analytics.
|
||||
* - 'daemon': via --assistant flag (Agent SDK daemon)
|
||||
* - 'gate': via GrowthBook gate check
|
||||
*/
|
||||
export function getAssistantActivationPath(): string | undefined {
|
||||
if (!isAssistantMode()) return undefined
|
||||
return _assistantForced ? 'daemon' : 'gate'
|
||||
}
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type AssistantSession = { id: string; [key: string]: unknown };
|
||||
export const discoverAssistantSessions: () => Promise<AssistantSession[]> = () => Promise.resolve([]);
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
|
||||
/**
|
||||
* Minimal session type for assistant discovery.
|
||||
* Only `id` is consumed by main.tsx (L4757); other fields are for chooser display.
|
||||
* ID format is `session_*` (compat prefix) — viewer endpoints use /v1/sessions/*.
|
||||
*/
|
||||
export type AssistantSession = {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover assistant sessions on Anthropic CCR.
|
||||
*
|
||||
* Reuses the existing fetchCodeSessionsFromSessionsAPI() which calls
|
||||
* GET /v1/sessions with proper OAuth + anthropic-beta headers.
|
||||
*
|
||||
* Throws on failure — main.tsx L4720-4725 catch displays the error.
|
||||
* Does NOT return [] on error (that would silently redirect to install wizard).
|
||||
*/
|
||||
export async function discoverAssistantSessions(): Promise<AssistantSession[]> {
|
||||
const { fetchCodeSessionsFromSessionsAPI } = await import(
|
||||
'../utils/teleport/api.js'
|
||||
)
|
||||
|
||||
let allSessions
|
||||
try {
|
||||
allSessions = await fetchCodeSessionsFromSessionsAPI()
|
||||
} catch (err) {
|
||||
logForDebugging(
|
||||
`[assistant:discovery] fetchCodeSessionsFromSessionsAPI failed: ${err}`,
|
||||
)
|
||||
throw err
|
||||
}
|
||||
|
||||
// Filter to active/working sessions only — completed/archived are not attachable
|
||||
return allSessions
|
||||
.filter(
|
||||
s =>
|
||||
s.status === 'idle' || s.status === 'working' || s.status === 'waiting',
|
||||
)
|
||||
.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title || 'Untitled',
|
||||
status: s.status,
|
||||
created_at: s.created_at ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
355
src/cli/bg.ts
355
src/cli/bg.ts
@@ -1,7 +1,348 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const psHandler: (args: string[]) => Promise<void> = (async () => {}) as (args: string[]) => Promise<void>;
|
||||
export const logsHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
|
||||
export const attachHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
|
||||
export const killHandler: (sessionId: string | undefined) => Promise<void> = (async () => {}) as (sessionId: string | undefined) => Promise<void>;
|
||||
export const handleBgFlag: (args: string[]) => Promise<void> = (async () => {}) as (args: string[]) => Promise<void>;
|
||||
import { readdir, readFile, unlink } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { spawnSync } from 'child_process'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
import { isProcessRunning } from '../utils/genericProcessUtils.js'
|
||||
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)'
|
||||
}
|
||||
|
||||
@@ -1,13 +1,216 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type { Command } from '@commander-js/extra-typings';
|
||||
import type { Command } from '@commander-js/extra-typings'
|
||||
import {
|
||||
createTask,
|
||||
getTask,
|
||||
updateTask,
|
||||
listTasks,
|
||||
getTasksDir,
|
||||
} from '../../utils/tasks.js'
|
||||
import { getRecentActivity } from '../../utils/logoV2Utils.js'
|
||||
import type { LogOption } from '../../types/logs.js'
|
||||
|
||||
export {};
|
||||
export const logHandler: (logId: string | number | undefined) => Promise<void> = (async () => {}) as (logId: string | number | undefined) => Promise<void>;
|
||||
export const errorHandler: (num: number | undefined) => Promise<void> = (async () => {}) as (num: number | undefined) => Promise<void>;
|
||||
export const exportHandler: (source: string, outputFile: string) => Promise<void> = (async () => {}) as (source: string, outputFile: string) => Promise<void>;
|
||||
export const taskCreateHandler: (subject: string, opts: { description?: string; list?: string }) => Promise<void> = (async () => {}) as (subject: string, opts: { description?: string; list?: string }) => Promise<void>;
|
||||
export const taskListHandler: (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise<void> = (async () => {}) as (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise<void>;
|
||||
export const taskGetHandler: (id: string, opts: { list?: string }) => Promise<void> = (async () => {}) as (id: string, opts: { list?: string }) => Promise<void>;
|
||||
export const taskUpdateHandler: (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise<void> = (async () => {}) as (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise<void>;
|
||||
export const taskDirHandler: (opts: { list?: string }) => Promise<void> = (async () => {}) as (opts: { list?: string }) => Promise<void>;
|
||||
export const completionHandler: (shell: string, opts: { output?: string }, program: Command) => Promise<void> = (async () => {}) as (shell: string, opts: { output?: string }, program: Command) => Promise<void>;
|
||||
const DEFAULT_LIST = 'default'
|
||||
|
||||
// ─── Group C: Task CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
export async function taskCreateHandler(
|
||||
subject: string,
|
||||
opts: { description?: string; list?: string },
|
||||
): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
const id = await createTask(listId, {
|
||||
subject,
|
||||
description: opts.description || '',
|
||||
status: 'pending',
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
})
|
||||
console.log(`Created task ${id}: ${subject}`)
|
||||
}
|
||||
|
||||
export async function taskListHandler(opts: {
|
||||
list?: string
|
||||
pending?: boolean
|
||||
json?: boolean
|
||||
}): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
let tasks = await listTasks(listId)
|
||||
|
||||
if (opts.pending) {
|
||||
tasks = tasks.filter(t => t.status === 'pending')
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(tasks, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.log('No tasks found.')
|
||||
return
|
||||
}
|
||||
|
||||
for (const t of tasks) {
|
||||
console.log(` [${t.status}] ${t.id}: ${t.subject}`)
|
||||
if (t.description) console.log(` ${t.description}`)
|
||||
if (t.owner) console.log(` owner: ${t.owner}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function taskGetHandler(
|
||||
id: string,
|
||||
opts: { list?: string },
|
||||
): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
const task = await getTask(listId, id)
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${id}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(JSON.stringify(task, null, 2))
|
||||
}
|
||||
|
||||
export async function taskUpdateHandler(
|
||||
id: string,
|
||||
opts: {
|
||||
list?: string
|
||||
status?: string
|
||||
subject?: string
|
||||
description?: string
|
||||
owner?: string
|
||||
clearOwner?: boolean
|
||||
},
|
||||
): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
const updates: Record<string, unknown> = {}
|
||||
|
||||
if (opts.status) updates.status = opts.status
|
||||
if (opts.subject) updates.subject = opts.subject
|
||||
if (opts.description) updates.description = opts.description
|
||||
if (opts.owner) updates.owner = opts.owner
|
||||
if (opts.clearOwner) updates.owner = undefined
|
||||
|
||||
const task = await updateTask(listId, id, updates)
|
||||
if (!task) {
|
||||
console.error(`Task not found: ${id}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(`Updated task ${id}: [${task.status}] ${task.subject}`)
|
||||
}
|
||||
|
||||
export async function taskDirHandler(opts: { list?: string }): Promise<void> {
|
||||
const listId = opts.list || DEFAULT_LIST
|
||||
console.log(getTasksDir(listId))
|
||||
}
|
||||
|
||||
// ─── Group B: Log / Error / Export ───────────────────────────────────────────
|
||||
|
||||
export async function logHandler(
|
||||
logId: string | number | undefined,
|
||||
): Promise<void> {
|
||||
const logs = await getRecentActivity()
|
||||
|
||||
if (logId === undefined) {
|
||||
if (logs.length === 0) {
|
||||
console.log('No recent sessions.')
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < Math.min(logs.length, 20); i++) {
|
||||
const log = logs[i]!
|
||||
const date = log.modified
|
||||
? new Date(log.modified).toLocaleString()
|
||||
: 'unknown'
|
||||
const title =
|
||||
(log as Record<string, unknown>).title || log.sessionId || 'untitled'
|
||||
console.log(` ${i}: ${title} (${date})`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const idx = typeof logId === 'string' ? parseInt(logId, 10) : logId
|
||||
const log =
|
||||
Number.isFinite(idx) && idx >= 0 && idx < logs.length
|
||||
? logs[idx]
|
||||
: logs.find(l => l.sessionId === String(logId))
|
||||
|
||||
if (!log) {
|
||||
console.error(`Session not found: ${logId}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(log, null, 2))
|
||||
}
|
||||
|
||||
export async function errorHandler(num: number | undefined): Promise<void> {
|
||||
// Error log viewing — shows recent session errors
|
||||
const logs = await getRecentActivity()
|
||||
const count = num ?? 5
|
||||
|
||||
console.log(`Last ${count} sessions:`)
|
||||
for (let i = 0; i < Math.min(count, logs.length); i++) {
|
||||
const log = logs[i]!
|
||||
const date = log.modified
|
||||
? new Date(log.modified).toLocaleString()
|
||||
: 'unknown'
|
||||
console.log(` ${i}: ${log.sessionId} (${date})`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportHandler(
|
||||
source: string,
|
||||
outputFile: string,
|
||||
): Promise<void> {
|
||||
const { writeFile, readFile } = await import('fs/promises')
|
||||
const logs = await getRecentActivity()
|
||||
|
||||
// Try as index first
|
||||
const idx = parseInt(source, 10)
|
||||
let log: LogOption | undefined
|
||||
if (Number.isFinite(idx) && idx >= 0 && idx < logs.length) {
|
||||
log = logs[idx]
|
||||
} else {
|
||||
log = logs.find(l => l.sessionId === source)
|
||||
}
|
||||
|
||||
if (!log) {
|
||||
// Try as file path
|
||||
try {
|
||||
const content = await readFile(source, 'utf-8')
|
||||
await writeFile(outputFile, content, 'utf-8')
|
||||
console.log(`Exported ${source} → ${outputFile}`)
|
||||
return
|
||||
} catch {
|
||||
console.error(`Source not found: ${source}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(outputFile, JSON.stringify(log, null, 2), 'utf-8')
|
||||
console.log(`Exported session ${log.sessionId} → ${outputFile}`)
|
||||
}
|
||||
|
||||
// ─── Group D: Completion ─────────────────────────────────────────────────────
|
||||
|
||||
export async function completionHandler(
|
||||
shell: string,
|
||||
opts: { output?: string },
|
||||
_program: Command,
|
||||
): Promise<void> {
|
||||
const { regenerateCompletionCache } = await import(
|
||||
'../../utils/completionCache.js'
|
||||
)
|
||||
|
||||
if (opts.output) {
|
||||
// Generate and write to file
|
||||
await regenerateCompletionCache()
|
||||
console.log(`Completion cache regenerated for ${shell}.`)
|
||||
} else {
|
||||
// Regenerate and output to stdout
|
||||
await regenerateCompletionCache()
|
||||
console.log(`Completion cache regenerated for ${shell}.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,131 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const templatesMain: (args: string[]) => Promise<void> = () => Promise.resolve();
|
||||
import { randomUUID } from 'crypto'
|
||||
import { listTemplates, loadTemplate } from '../../jobs/templates.js'
|
||||
import {
|
||||
createJob,
|
||||
readJobState,
|
||||
appendJobReply,
|
||||
getJobDir,
|
||||
} from '../../jobs/state.js'
|
||||
|
||||
/**
|
||||
* Entry point for template job commands: `new`, `list`, `reply`.
|
||||
* Called from cli.tsx fast-path.
|
||||
*/
|
||||
export async function templatesMain(args: string[]): Promise<void> {
|
||||
const subcommand = args[0]
|
||||
|
||||
switch (subcommand) {
|
||||
case 'list':
|
||||
handleList()
|
||||
break
|
||||
case 'new':
|
||||
handleNew(args.slice(1))
|
||||
break
|
||||
case 'reply':
|
||||
handleReply(args.slice(1))
|
||||
break
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
152
src/cli/print.ts
152
src/cli/print.ts
@@ -320,6 +320,17 @@ import {
|
||||
logQueryProfileReport,
|
||||
} from 'src/utils/queryProfiler.js'
|
||||
import { asSessionId } from 'src/types/ids.js'
|
||||
import {
|
||||
commitAutonomyQueuedPrompt,
|
||||
createAutonomyQueuedPrompt,
|
||||
createProactiveAutonomyCommands,
|
||||
finalizeAutonomyRunCompleted,
|
||||
finalizeAutonomyRunFailed,
|
||||
markAutonomyRunCompleted,
|
||||
markAutonomyRunFailed,
|
||||
markAutonomyRunRunning,
|
||||
} from 'src/utils/autonomyRuns.js'
|
||||
import { prepareAutonomyTurnPrompt } from 'src/utils/autonomyAuthority.js'
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
import { skillChangeDetector } from '../utils/skills/skillChangeDetector.js'
|
||||
import { getCommands, clearCommandsCache } from '../commands.js'
|
||||
@@ -1839,15 +1850,23 @@ function runHeadlessStreaming(
|
||||
) {
|
||||
return
|
||||
}
|
||||
const tickContent = `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`
|
||||
enqueue({
|
||||
mode: 'prompt' as const,
|
||||
value: tickContent,
|
||||
uuid: randomUUID(),
|
||||
priority: 'later',
|
||||
isMeta: true,
|
||||
})
|
||||
void run()
|
||||
void (async () => {
|
||||
const commands = await createProactiveAutonomyCommands({
|
||||
basePrompt: `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`,
|
||||
currentDir: cwd(),
|
||||
shouldCreate: () => !inputClosed,
|
||||
})
|
||||
for (const command of commands) {
|
||||
if (inputClosed) {
|
||||
return
|
||||
}
|
||||
enqueue({
|
||||
...command,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
}
|
||||
void run()
|
||||
})()
|
||||
}, 0)
|
||||
}
|
||||
: undefined
|
||||
@@ -2092,6 +2111,9 @@ function runHeadlessStreaming(
|
||||
}
|
||||
|
||||
const input = command.value
|
||||
const autonomyRunIds = batch
|
||||
.map(item => item.autonomy?.runId)
|
||||
.filter((runId): runId is string => Boolean(runId))
|
||||
|
||||
if (structuredIO instanceof RemoteIO && command.mode === 'prompt') {
|
||||
logEvent('tengu_bridge_message_received', {
|
||||
@@ -2141,8 +2163,12 @@ function runHeadlessStreaming(
|
||||
// const-capture: TS loses `while ((command = dequeue()))` narrowing
|
||||
// inside the closure.
|
||||
const cmd = command
|
||||
await runWithWorkload(cmd.workload ?? options.workload, async () => {
|
||||
for await (const message of ask({
|
||||
for (const runId of autonomyRunIds) {
|
||||
await markAutonomyRunRunning(runId)
|
||||
}
|
||||
try {
|
||||
await runWithWorkload(cmd.workload ?? options.workload, async () => {
|
||||
for await (const message of ask({
|
||||
commands: uniqBy(
|
||||
[...currentCommands, ...appState.mcp.commands],
|
||||
'name',
|
||||
@@ -2241,7 +2267,30 @@ function runHeadlessStreaming(
|
||||
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) {
|
||||
notifyCommandLifecycle(uuid, 'completed')
|
||||
@@ -2706,22 +2755,69 @@ function runHeadlessStreaming(
|
||||
cronScheduler = cronSchedulerModule.createCronScheduler({
|
||||
onFire: prompt => {
|
||||
if (inputClosed) return
|
||||
enqueue({
|
||||
mode: 'prompt',
|
||||
value: prompt,
|
||||
uuid: randomUUID(),
|
||||
priority: 'later',
|
||||
// System-generated — matches useScheduledTasks.ts REPL equivalent.
|
||||
// Without this, messages.ts metaProp eval is {} → prompt leaks
|
||||
// into visible transcript when cron fires mid-turn in -p mode.
|
||||
isMeta: true,
|
||||
// Threaded to cc_workload= in the billing-header attribution block
|
||||
// so the API can serve cron requests at lower QoS. drainCommandQueue
|
||||
// reads this per-iteration and hoists it into bootstrap state for
|
||||
// the ask() call.
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
void run()
|
||||
void (async () => {
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: cwd(),
|
||||
})
|
||||
if (inputClosed) return
|
||||
const command = await commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
currentDir: cwd(),
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
if (inputClosed) return
|
||||
enqueue({
|
||||
...command,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
void run()
|
||||
})()
|
||||
},
|
||||
onFireTask: task => {
|
||||
if (inputClosed) return
|
||||
void (async () => {
|
||||
if (task.agentId) {
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: task.prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: cwd(),
|
||||
})
|
||||
if (inputClosed) return
|
||||
const command = await commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
currentDir: cwd(),
|
||||
sourceId: task.id,
|
||||
sourceLabel: task.prompt,
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
await markAutonomyRunFailed(
|
||||
command.autonomy!.runId,
|
||||
`No teammate runtime available for scheduled task owner ${task.agentId} in headless mode.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const prepared = await prepareAutonomyTurnPrompt({
|
||||
basePrompt: task.prompt,
|
||||
trigger: 'scheduled-task',
|
||||
currentDir: cwd(),
|
||||
})
|
||||
if (inputClosed) return
|
||||
const command = await commitAutonomyQueuedPrompt({
|
||||
prepared,
|
||||
currentDir: cwd(),
|
||||
sourceId: task.id,
|
||||
sourceLabel: task.prompt,
|
||||
workload: WORKLOAD_CRON,
|
||||
})
|
||||
if (inputClosed) return
|
||||
enqueue({
|
||||
...command,
|
||||
uuid: randomUUID(),
|
||||
})
|
||||
void run()
|
||||
})()
|
||||
},
|
||||
isLoading: () => running || inputClosed,
|
||||
getJitterConfig: cronJitterConfigModule?.getCronJitterConfig,
|
||||
|
||||
@@ -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.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,95 @@
|
||||
// Auto-generated stub
|
||||
export async function up(): Promise<void> {}
|
||||
import { readFileSync } from 'fs'
|
||||
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
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import ide from './commands/ide/index.js'
|
||||
import init from './commands/init.js'
|
||||
import initVerifiers from './commands/init-verifiers.js'
|
||||
import keybindings from './commands/keybindings/index.js'
|
||||
import lang from './commands/lang/index.js'
|
||||
import login from './commands/login/index.js'
|
||||
import logout from './commands/logout/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 stickers from './commands/stickers/index.js'
|
||||
import advisor from './commands/advisor.js'
|
||||
import autonomy from './commands/autonomy.js'
|
||||
import provider from './commands/provider.js'
|
||||
import { logError } from './utils/log.js'
|
||||
import { toError } from './utils/errors.js'
|
||||
@@ -290,6 +292,7 @@ export const INTERNAL_ONLY_COMMANDS = [
|
||||
const COMMANDS = memoize((): Command[] => [
|
||||
addDir,
|
||||
advisor,
|
||||
autonomy,
|
||||
provider,
|
||||
agents,
|
||||
branch,
|
||||
@@ -315,6 +318,7 @@ const COMMANDS = memoize((): Command[] => [
|
||||
ide,
|
||||
init,
|
||||
keybindings,
|
||||
lang,
|
||||
installGitHubApp,
|
||||
installSlackApp,
|
||||
mcp,
|
||||
|
||||
237
src/commands/__tests__/autonomy.test.ts
Normal file
237
src/commands/__tests__/autonomy.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
54
src/commands/__tests__/proactive.baseline.test.ts
Normal file
54
src/commands/__tests__/proactive.baseline.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
175
src/commands/assistant/assistant.tsx
Normal file
175
src/commands/assistant/assistant.tsx
Normal 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;
|
||||
}
|
||||
@@ -1,25 +1,21 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { getKairosActive } from '../../bootstrap/state.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
|
||||
* the module is even required).
|
||||
* Build-time: feature('KAIROS') must be on.
|
||||
* Runtime: tengu_kairos_assistant GrowthBook flag (remote kill switch).
|
||||
*
|
||||
* Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill
|
||||
* switch, and kairosActive state must be true (set during bootstrap when
|
||||
* the session qualifies for KAIROS features).
|
||||
* Does NOT require kairosActive — the /assistant command is visible
|
||||
* before activation so users can invoke it to activate KAIROS.
|
||||
*/
|
||||
export function isAssistantEnabled(): boolean {
|
||||
if (!feature('KAIROS')) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)
|
||||
) {
|
||||
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)) {
|
||||
return false
|
||||
}
|
||||
return getKairosActive()
|
||||
return true
|
||||
}
|
||||
|
||||
125
src/commands/autonomy.ts
Normal file
125
src/commands/autonomy.ts
Normal 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
|
||||
@@ -1,6 +1,7 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { Command } from '../commands.js'
|
||||
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'
|
||||
import { AUTONOMY_AGENTS_PATH_POSIX } from '../utils/autonomyAuthority.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.
|
||||
@@ -43,7 +44,7 @@ Use AskUserQuestion to find out what the user wants:
|
||||
|
||||
## 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:
|
||||
- Build, test, and lint commands (especially non-standard ones)
|
||||
@@ -105,7 +106,7 @@ Include:
|
||||
- Repo etiquette (branch naming, PR conventions, commit style)
|
||||
- Required env vars or setup steps
|
||||
- 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:
|
||||
- File-by-file structure or component lists (Claude can discover these by reading the codebase)
|
||||
|
||||
12
src/commands/lang/index.ts
Normal file
12
src/commands/lang/index.ts
Normal 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
49
src/commands/lang/lang.ts
Normal 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
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import { getSlaveClient } from '../../hooks/useMasterMonitor.js'
|
||||
import { getPipeIpc } from '../../utils/pipeTransport.js'
|
||||
import { addSendOverride, removeMasterPipeMute } from '../../utils/pipeMuteState.js'
|
||||
|
||||
export const call: LocalCommandCall = async (args, context) => {
|
||||
const currentState = context.getAppState()
|
||||
@@ -48,6 +49,12 @@ export const call: LocalCommandCall = async (args, context) => {
|
||||
}
|
||||
|
||||
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({
|
||||
type: 'prompt',
|
||||
data: message,
|
||||
@@ -89,6 +96,8 @@ export const call: LocalCommandCall = async (args, context) => {
|
||||
value: `Sent to "${targetName}": ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`,
|
||||
}
|
||||
} catch (err) {
|
||||
// Roll back override on send failure to prevent permanent unmute
|
||||
removeSendOverride(targetName)
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Failed to send to "${targetName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
185
src/daemon/__tests__/state.test.ts
Normal file
185
src/daemon/__tests__/state.test.ts
Normal 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
Reference in New Issue
Block a user