mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2b66d9d2c | ||
|
|
d70e7f7f05 | ||
|
|
72a2093cd6 | ||
|
|
b5c299f5d2 | ||
|
|
ac42ce2d67 | ||
|
|
c659912517 | ||
|
|
a14b7f352b | ||
|
|
c5ab83a3fc | ||
|
|
03b7f9b453 | ||
|
|
bddd146f25 | ||
|
|
c8d08d235b | ||
|
|
a02dc0bded |
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: macos-latest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -23,8 +23,16 @@ jobs:
|
||||
- name: Type check
|
||||
run: bunx tsc --noEmit
|
||||
|
||||
- name: Test
|
||||
run: bun test
|
||||
- name: Test with Coverage
|
||||
run: |
|
||||
set -o pipefail
|
||||
bun test --coverage --coverage-reporter=lcov 2>&1 | grep -vE '^\s*(\(pass\)|\(skip\))' | sed '/^.*\/__tests__\/.*:$/d' | cat -s
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
run: bun run build:vite
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -13,9 +13,8 @@ src/utils/vendor/
|
||||
# AI tool runtime directories
|
||||
.agents/
|
||||
.claude/
|
||||
.codex/
|
||||
.omx/
|
||||
|
||||
.docs/task/
|
||||
# Binary / screenshot files (root only)
|
||||
/*.png
|
||||
*.bmp
|
||||
@@ -30,3 +29,12 @@ __pycache__/
|
||||
logs
|
||||
|
||||
data
|
||||
.omc
|
||||
.codex/*
|
||||
!.codex/agents/
|
||||
!.codex/agents/**
|
||||
!.codex/skills/
|
||||
!.codex/skills/**
|
||||
.codex/skills/.system/**
|
||||
!.codex/prompts/
|
||||
!.codex/prompts/**
|
||||
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -247,14 +247,23 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
## Testing
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **当前状态**: 2472 tests / 138 files / 0 fail
|
||||
- **当前状态**: 2992 tests / 188 files / 0 fail
|
||||
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
||||
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
||||
- **命名**: `describe("functionName")` + `test("behavior description")`,英文
|
||||
- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入)
|
||||
- **包测试**: `packages/` 下各包也有独立测试(如 `color-diff-napi` 11 tests)
|
||||
|
||||
### Mock 使用规范
|
||||
|
||||
**只 mock 有副作用的依赖链,不 mock 纯函数/纯数据模块。**
|
||||
|
||||
被迫 mock 的根源:`log.ts` / `debug.ts` → `bootstrap/state.ts`(模块级 `realpathSync` / `randomUUID` 副作用)。必须 mock 的模块:`log.ts`、`debug.ts`、`bun:bundle`、`settings/settings.js`、`config.ts`、`auth.ts`、第三方网络库。
|
||||
|
||||
不要 mock:纯函数模块(`errors.ts`、`stringUtils.js`)、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。
|
||||
|
||||
路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。
|
||||
|
||||
### 类型检查
|
||||
|
||||
项目使用 TypeScript strict 模式,**tsc 必须零错误**。每次修改后运行:
|
||||
|
||||
19
README.md
19
README.md
@@ -10,28 +10,25 @@
|
||||
|
||||
> Which Claude do you like? The open source one is the best.
|
||||
|
||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)...
|
||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
|
||||
|
||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/qZU6zS7Q)
|
||||
|
||||
| 特性 | 说明 | 文档 |
|
||||
|------|------|------|
|
||||
| **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) |
|
||||
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
|
||||
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |
|
||||
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
||||
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
||||
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
|
||||
| **自定义模型供应商** | 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) |
|
||||
| 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) |
|
||||
| Poor Mode | 穷鬼模式,关闭记忆提取和键入建议 | /poor 可以开关 |
|
||||
|
||||
|
||||
- 🔮 [ ] V6 — 大规模重构石山代码,全面模块分包(全新分支,main 封存为历史版本)
|
||||
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
|
||||
|
||||
- 🚀 [想要启动项目](#快速开始源码版)
|
||||
- 🐛 [想要调试项目](#vs-code-调试)
|
||||
|
||||
2
build.ts
2
build.ts
@@ -42,6 +42,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',
|
||||
|
||||
15
bun.lock
15
bun.lock
@@ -6,7 +6,7 @@
|
||||
"name": "claude-code-best",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.19.0",
|
||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.7",
|
||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
|
||||
"ws": "^8.20.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -15,6 +15,7 @@
|
||||
"@ant/computer-use-input": "workspace:*",
|
||||
"@ant/computer-use-mcp": "workspace:*",
|
||||
"@ant/computer-use-swift": "workspace:*",
|
||||
"@ant/model-provider": "workspace:*",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
|
||||
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
||||
@@ -183,6 +184,14 @@
|
||||
"wrap-ansi": "^10.0.0",
|
||||
},
|
||||
},
|
||||
"packages/@ant/model-provider": {
|
||||
"name": "@ant/model-provider",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"openai": "^6.33.0",
|
||||
},
|
||||
},
|
||||
"packages/agent-tools": {
|
||||
"name": "@claude-code-best/agent-tools",
|
||||
"version": "1.0.0",
|
||||
@@ -269,6 +278,8 @@
|
||||
|
||||
"@ant/computer-use-swift": ["@ant/computer-use-swift@workspace:packages/@ant/computer-use-swift"],
|
||||
|
||||
"@ant/model-provider": ["@ant/model-provider@workspace:packages/@ant/model-provider"],
|
||||
|
||||
"@anthropic-ai/bedrock-sdk": ["@anthropic-ai/bedrock-sdk@0.26.4", "https://registry.npmmirror.com/@anthropic-ai/bedrock-sdk/-/bedrock-sdk-0.26.4.tgz", { "dependencies": { "@anthropic-ai/sdk": ">=0.50.3 <1", "@aws-crypto/sha256-js": "^4.0.0", "@aws-sdk/client-bedrock-runtime": "^3.797.0", "@aws-sdk/credential-providers": "^3.796.0", "@smithy/eventstream-serde-node": "^2.0.10", "@smithy/fetch-http-handler": "^5.0.4", "@smithy/protocol-http": "^3.0.6", "@smithy/signature-v4": "^3.1.1", "@smithy/smithy-client": "^2.1.9", "@smithy/types": "^2.3.4", "@smithy/util-base64": "^2.0.0" } }, "sha512-0Z2NY3T2wnzT9esRit6BiWpQXvL+F2b3z3Z9in3mXh7MDf122rVi2bcPowQHmo9ITXAPJmv/3H3t0V1z3Fugfw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.104", "https://registry.npmmirror.com/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.104.tgz", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lVm+nS79r6WWlDnv5AgRzTtAlbP8O6M6kkWmDZAWE3nt9agmngxls9frJFvH55uzws2+6l0yyup/JYspfijkzw=="],
|
||||
@@ -449,7 +460,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.8", "https://registry.npmmirror.com/@claude-code-best/mcp-chrome-bridge/-/mcp-chrome-bridge-2.0.8.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-f7J1e4PQ6qxXzdHwL7QRrMZ4lPfD/L1MWxWDbyHmHY7jaW2GL6WcArKpk/fApg3V/q0racqUWzXHQdpE/HJZqg=="],
|
||||
|
||||
"@claude-code-best/mcp-client": ["@claude-code-best/mcp-client@workspace:packages/mcp-client"],
|
||||
|
||||
|
||||
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 |
17
docs/diagrams/agent-loop-simple.mmd
Normal file
17
docs/diagrams/agent-loop-simple.mmd
Normal file
@@ -0,0 +1,17 @@
|
||||
flowchart TB
|
||||
START((输入)) --> CTX["Context 管理"]
|
||||
CTX --> LLM["LLM 流式输出"]
|
||||
LLM --> TC{tool_use?}
|
||||
|
||||
TC --> |是| EXEC["执行工具"]
|
||||
EXEC --> CTX
|
||||
|
||||
TC --> |否| DONE((完成))
|
||||
|
||||
classDef proc fill:#eef,stroke:#66c,color:#224
|
||||
classDef decision fill:#fee,stroke:#c66,color:#422
|
||||
classDef io fill:#eff,stroke:#6cc,color:#244
|
||||
|
||||
class CTX,LLM,EXEC proc
|
||||
class TC decision
|
||||
class START,DONE io
|
||||
40
docs/diagrams/agent-loop.mmd
Normal file
40
docs/diagrams/agent-loop.mmd
Normal file
@@ -0,0 +1,40 @@
|
||||
flowchart TB
|
||||
START((输入)) --> CTX["Context 管理"]
|
||||
CTX --> PRE["Pre-sampling Hook"]
|
||||
PRE --> LLM["LLM 流式输出"]
|
||||
LLM --> TC{tool_use?}
|
||||
|
||||
TC --> |是| PERM{需权限?}
|
||||
PERM --> |是| USER["👤 用户审批"]
|
||||
USER --> |allow| TOOL_PRE
|
||||
USER --> |deny| DENIED["拒绝"]
|
||||
PERM --> |否| TOOL_PRE["Pre-tool Hook"]
|
||||
TOOL_PRE --> EXEC["并发执行工具"]
|
||||
EXEC --> TOOL_POST["Post-tool Hook"]
|
||||
TOOL_POST --> CTX
|
||||
DENIED --> CTX
|
||||
|
||||
TC --> |否| POST["Post-sampling Hook"]
|
||||
POST --> STOP{"Stop Hook"}
|
||||
STOP --> |不通过| CTX
|
||||
STOP --> |通过| BUDGET{"Token Budget"}
|
||||
BUDGET --> |继续| CTX
|
||||
BUDGET --> |完成| DONE((完成))
|
||||
|
||||
subgraph SUB["子 Agent"]
|
||||
FORK["AgentTool"] --> RECURSE["递归调用"]
|
||||
end
|
||||
|
||||
EXEC -.-> FORK
|
||||
|
||||
classDef proc fill:#eef,stroke:#66c,color:#224
|
||||
classDef decision fill:#fee,stroke:#c66,color:#422
|
||||
classDef hook fill:#ffe,stroke:#cc6,color:#442
|
||||
classDef io fill:#eff,stroke:#6cc,color:#244
|
||||
classDef sub fill:#efe,stroke:#6a6,color:#242
|
||||
|
||||
class CTX,LLM,EXEC proc
|
||||
class TC,PERM,STOP,BUDGET decision
|
||||
class PRE,TOOL_PRE,TOOL_POST,POST hook
|
||||
class START,DONE,USER,DENIED io
|
||||
class FORK,RECURSE sub
|
||||
318
docs/features/daemon-restructure-design.md
Normal file
318
docs/features/daemon-restructure-design.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# Daemon 重构设计方案
|
||||
|
||||
> 分支: `feat/integrate-5-branches`
|
||||
> 基于: `f41745cb` (= main `11bb3f62` 内容)
|
||||
> 日期: 2026-04-13
|
||||
|
||||
## 一、问题概述
|
||||
|
||||
### 1.1 命令结构散乱
|
||||
|
||||
当前后台进程相关的命令分布在三个不同的位置,没有统一的命名空间:
|
||||
|
||||
| 命令 | 注册位置 | 入口 |
|
||||
|------|---------|------|
|
||||
| `claude daemon start/status/stop` | `cli.tsx` 快速路径 L203 | `daemon/main.ts` |
|
||||
| `claude ps` | `cli.tsx` 快速路径 L220 | `cli/bg.ts` |
|
||||
| `claude logs <x>` | `cli.tsx` 快速路径 L232 | `cli/bg.ts` |
|
||||
| `claude attach <x>` | `cli.tsx` 快速路径 L236 | `cli/bg.ts` |
|
||||
| `claude kill <x>` | `cli.tsx` 快速路径 L238 | `cli/bg.ts` |
|
||||
| `claude --bg` | `cli.tsx` 快速路径 L244 | `cli/bg.ts` |
|
||||
| `claude new/list/reply` | `cli.tsx` 快速路径 L250 | `cli/handlers/templateJobs.ts` |
|
||||
| `claude rollback` | `main.tsx` Commander.js L6525 | `cli/rollback.ts` |
|
||||
| `claude up` | `main.tsx` Commander.js L6511 | `cli/up.ts` |
|
||||
|
||||
**问题**:
|
||||
- `ps/logs/attach/kill` 与 `daemon` 逻辑上都是后台进程管理,但互不关联
|
||||
- 这些命令都**只有 CLI 入口**,REPL 里输入 `/daemon` 或 `/ps` 不存在
|
||||
- `new/list/reply` 是模板任务系统的顶级命令,容易与其他命令冲突(特别是 `list`)
|
||||
|
||||
### 1.2 Windows 不支持
|
||||
|
||||
`--bg` 和 `attach` 硬依赖 tmux:
|
||||
- `bg.ts:handleBgFlag()` 第一步就检查 tmux,不可用直接报错退出
|
||||
- `bg.ts:attachHandler()` 用 `tmux attach-session`,无 tmux 替代方案
|
||||
- Windows (包括 VS Code 终端) 完全无法使用后台会话功能
|
||||
|
||||
### 1.3 无 REPL 入口
|
||||
|
||||
对比 `/mcp` 的双注册模式:
|
||||
- **CLI**: `claude mcp serve/add/remove/list` (Commander.js, `main.tsx:5760`)
|
||||
- **REPL**: `/mcp enable/disable/reconnect` (slash command, `commands/mcp/index.ts`)
|
||||
|
||||
`daemon`/`bg`/`job` 系列只有 CLI 快速路径,REPL 中完全不可用。
|
||||
|
||||
## 二、目标
|
||||
|
||||
1. **层级化命令结构**: 参照 `/mcp` 模式,将后台管理收归 `/daemon`,模板任务收归 `/job`
|
||||
2. **跨平台后台会话**: Windows / macOS / Linux 都能启动、附着、终止后台会话
|
||||
3. **双注册**: CLI (`claude daemon ...`) + REPL (`/daemon ...`) 同时可用
|
||||
4. **向后兼容**: 旧命令保留但输出 deprecation 提示
|
||||
|
||||
## 三、命令结构设计
|
||||
|
||||
### 3.1 `/daemon` — 后台进程管理
|
||||
|
||||
合并 daemon supervisor + bg sessions 为统一命名空间:
|
||||
|
||||
```
|
||||
claude daemon <subcommand> ← CLI 入口 (cli.tsx 快速路径)
|
||||
/daemon <subcommand> ← REPL 入口 (slash command, local-jsx)
|
||||
|
||||
子命令:
|
||||
status 综合状态面板 (daemon + 所有会话)
|
||||
start [--dir <path>] 启动 daemon supervisor
|
||||
stop 停止 daemon
|
||||
bg [args...] 启动后台会话
|
||||
attach [target] 附着到后台会话
|
||||
logs [target] 查看会话日志
|
||||
kill [target] 终止会话
|
||||
(无参数) 等同于 status
|
||||
```
|
||||
|
||||
**CLI 快速路径路由** (`cli.tsx`):
|
||||
```typescript
|
||||
// 新: 统一入口
|
||||
if (feature('DAEMON') && args[0] === 'daemon') {
|
||||
const sub = args[1] || 'status'
|
||||
switch (sub) {
|
||||
case 'start': case 'stop': case 'status':
|
||||
await daemonMain([sub, ...args.slice(2)])
|
||||
break
|
||||
case 'bg':
|
||||
await bg.handleBgStart(args.slice(2))
|
||||
break
|
||||
case 'attach': case 'logs': case 'kill':
|
||||
await bg[`${sub}Handler`](args[2])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 向后兼容 (deprecated)
|
||||
if (feature('BG_SESSIONS') && ['ps','logs','attach','kill'].includes(args[0])) {
|
||||
console.warn(`[deprecated] Use: claude daemon ${args[0] === 'ps' ? 'status' : args[0]}`)
|
||||
// ... delegate to daemon subcommand
|
||||
}
|
||||
```
|
||||
|
||||
**REPL 斜杠命令** (`commands/daemon/index.ts`):
|
||||
```typescript
|
||||
const daemon = {
|
||||
type: 'local-jsx',
|
||||
name: 'daemon',
|
||||
description: 'Manage background sessions and daemon',
|
||||
argumentHint: '[status|start|stop|bg|attach|logs|kill]',
|
||||
isEnabled: () => feature('DAEMON') || feature('BG_SESSIONS'),
|
||||
load: () => import('./daemon.js'),
|
||||
} satisfies Command
|
||||
```
|
||||
|
||||
### 3.2 `/job` — 模板任务管理
|
||||
|
||||
```
|
||||
claude job <subcommand> ← CLI 入口
|
||||
/job <subcommand> ← REPL 入口
|
||||
|
||||
子命令:
|
||||
list 列出模板和活跃任务
|
||||
new <template> [args] 从模板创建任务
|
||||
reply <id> <text> 回复任务
|
||||
status <id> 查看任务状态
|
||||
(无参数) 等同于 list
|
||||
```
|
||||
|
||||
### 3.3 独立命令 (不变)
|
||||
|
||||
```
|
||||
claude up 保持顶级 (简短的 bootstrap 命令)
|
||||
claude rollback [target] 保持顶级 (低频运维命令)
|
||||
```
|
||||
|
||||
## 四、跨平台后台引擎
|
||||
|
||||
### 4.1 引擎抽象
|
||||
|
||||
```typescript
|
||||
// src/cli/bg/engine.ts
|
||||
export interface BgEngine {
|
||||
readonly name: string
|
||||
|
||||
/** 当前平台是否可用 */
|
||||
available(): Promise<boolean>
|
||||
|
||||
/** 启动后台会话 */
|
||||
start(opts: BgStartOptions): Promise<BgStartResult>
|
||||
|
||||
/** 附着到后台会话(blocking) */
|
||||
attach(session: SessionEntry): Promise<void>
|
||||
}
|
||||
|
||||
export interface BgStartOptions {
|
||||
sessionName: string
|
||||
args: string[]
|
||||
env: Record<string, string | undefined>
|
||||
logPath: string
|
||||
cwd: string
|
||||
}
|
||||
|
||||
export interface BgStartResult {
|
||||
pid: number
|
||||
sessionName: string
|
||||
logPath: string
|
||||
engineUsed: string
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 三种引擎实现
|
||||
|
||||
| 引擎 | 平台 | 启动方式 | attach 方式 |
|
||||
|------|------|---------|------------|
|
||||
| TmuxEngine | macOS/Linux (有 tmux) | `tmux new-session -d` | `tmux attach-session` |
|
||||
| DetachedEngine | Windows / 无 tmux 的 macOS/Linux | `spawn({ detached, stdio→logFile })` | `tail -f` 日志文件 |
|
||||
|
||||
#### DetachedEngine 详细设计
|
||||
|
||||
**启动 (`start`)**:
|
||||
```typescript
|
||||
// 1. 打开日志文件 fd
|
||||
const logFd = fs.openSync(logPath, 'a')
|
||||
// 2. detached spawn, stdout/stderr 重定向到日志
|
||||
const child = spawn(process.execPath, execArgs, {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
env,
|
||||
cwd,
|
||||
})
|
||||
child.unref()
|
||||
fs.closeSync(logFd)
|
||||
// 3. 写 sessions/<PID>.json
|
||||
```
|
||||
|
||||
**附着 (`attach`)**:
|
||||
```typescript
|
||||
// 跨平台 tail -f 实现
|
||||
// 1. 读取已有日志内容输出到 stdout
|
||||
// 2. fs.watch(logPath) 监听变化
|
||||
// 3. 每次变化读取新增内容
|
||||
// 4. Ctrl+C 退出 tail(不杀后台进程)
|
||||
```
|
||||
|
||||
#### 引擎选择逻辑
|
||||
|
||||
```typescript
|
||||
// src/cli/bg/engines/index.ts
|
||||
export async function selectEngine(): Promise<BgEngine> {
|
||||
if (process.platform === 'win32') {
|
||||
return new DetachedEngine()
|
||||
}
|
||||
|
||||
const tmux = new TmuxEngine()
|
||||
if (await tmux.available()) {
|
||||
return tmux
|
||||
}
|
||||
|
||||
return new DetachedEngine()
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 SessionEntry 扩展
|
||||
|
||||
```typescript
|
||||
interface SessionEntry {
|
||||
// ... 现有字段
|
||||
engine: 'tmux' | 'detached' // 新增: 记录使用的引擎
|
||||
tmuxSessionName?: string // tmux 引擎才有
|
||||
logPath?: string // 两种引擎都有
|
||||
}
|
||||
```
|
||||
|
||||
`attach` 时根据 `session.engine` 选择对应的 attach 策略。
|
||||
|
||||
## 五、文件变更清单
|
||||
|
||||
### 新增文件 (10 个)
|
||||
|
||||
```
|
||||
src/cli/bg/engine.ts BgEngine 接口定义
|
||||
src/cli/bg/engines/tmux.ts TmuxEngine (从 bg.ts 提取)
|
||||
src/cli/bg/engines/detached.ts DetachedEngine (新实现)
|
||||
src/cli/bg/engines/index.ts 引擎选择 + re-export
|
||||
src/cli/bg/tail.ts 跨平台日志 tail (用于 detached attach)
|
||||
src/commands/daemon/index.ts /daemon REPL 斜杠命令注册
|
||||
src/commands/daemon/daemon.tsx /daemon 子命令路由 + status UI
|
||||
src/commands/job/index.ts /job REPL 斜杠命令注册
|
||||
src/commands/job/job.tsx /job 子命令路由 + UI
|
||||
docs/features/daemon-restructure-design.md 本设计文档
|
||||
```
|
||||
|
||||
### 修改文件 (6 个)
|
||||
|
||||
```
|
||||
src/cli/bg.ts 重构: handler 函数改为调用 BgEngine
|
||||
src/entrypoints/cli.tsx 快速路径: daemon 统一入口 + 向后兼容
|
||||
src/commands.ts 注册 /daemon 和 /job 斜杠命令
|
||||
src/daemon/main.ts daemonMain() 增加 bg/ps/logs 子命令分发
|
||||
src/main.tsx Commander.js: 可选注册 daemon/job 子命令
|
||||
src/cli/handlers/templateJobs.ts 适配 /job 入口 (可能不需改)
|
||||
```
|
||||
|
||||
### 不动的文件
|
||||
|
||||
```
|
||||
src/daemon/state.ts daemon PID 状态管理 (无需改)
|
||||
src/jobs/state.ts job 状态管理 (无需改)
|
||||
src/jobs/templates.ts 模板发现 (无需改)
|
||||
src/jobs/classifier.ts 任务分类器 (无需改)
|
||||
src/cli/rollback.ts 保持顶级命令 (无需改)
|
||||
src/cli/up.ts 保持顶级命令 (无需改)
|
||||
```
|
||||
|
||||
## 六、可行性分析
|
||||
|
||||
### 6.1 风险评估
|
||||
|
||||
| 风险 | 级别 | 缓解措施 |
|
||||
|------|------|---------|
|
||||
| cli.tsx 快速路径修改影响启动性能 | 低 | 仅改路由逻辑,import 仍然 lazy |
|
||||
| DetachedEngine 的 attach 在 Windows 上 fs.watch 不可靠 | 中 | 使用轮询 fallback (setInterval + fs.stat) |
|
||||
| 向后兼容的 deprecation 可能破坏脚本 | 低 | 旧命令保持可用,仅输出 stderr 警告 |
|
||||
| REPL 中 /daemon bg 需要 spawn 子进程 | 中 | 参考 /assistant 的 NewInstallWizard (已有 spawn 先例) |
|
||||
| tsc 类型兼容 | 低 | 接口定义清晰,不引入 any |
|
||||
|
||||
### 6.2 工作量估计
|
||||
|
||||
| Task | 文件数 | 复杂度 |
|
||||
|------|--------|--------|
|
||||
| Task 013: BgEngine 抽象 + 引擎实现 | 5 新增 + 1 修改 | 中 |
|
||||
| Task 014: /daemon 命令层级化 | 3 新增 + 3 修改 | 中 |
|
||||
| Task 015: /job 命令层级化 | 2 新增 + 2 修改 | 低 |
|
||||
| Task 016: 向后兼容 + 测试 | 0 新增 + 2 修改 | 低 |
|
||||
|
||||
### 6.3 依赖关系
|
||||
|
||||
```
|
||||
Task 013 (BgEngine) ← 无依赖,可独立开发
|
||||
Task 014 (/daemon) ← 依赖 Task 013 (引擎选择)
|
||||
Task 015 (/job) ← 无依赖,可与 013 并行
|
||||
Task 016 (兼容) ← 依赖 Task 014 + 015
|
||||
```
|
||||
|
||||
## 七、设计决策记录
|
||||
|
||||
### D1: 为什么 daemon + bg sessions 合为一个命名空间?
|
||||
|
||||
用户视角:都是"后台运行的东西"。分开会导致 `claude daemon status` 看 supervisor + `claude ps` 看会话,割裂感强。合并后 `claude daemon status` 一次性展示 supervisor 状态 + 所有会话列表。
|
||||
|
||||
### D2: 为什么 rollback/up 不收入 daemon?
|
||||
|
||||
它们本质是**版本管理/环境初始化**,不是后台进程管理。`claude up` 是同步阻塞的 setup 脚本,不涉及 daemon 或后台会话。保持顶级更直观。
|
||||
|
||||
### D3: 为什么 DetachedEngine 的 attach 用 tail 而不是 IPC?
|
||||
|
||||
1. 日志文件是最简单的跨平台方案,无需额外依赖
|
||||
2. UDS Pipe IPC 系统 (usePipeIpc) 设计用于实例间通信,不是终端附着
|
||||
3. tmux attach 的体验(完整 PTY)无法在纯 detached 模式下复制,tail 是最诚实的替代
|
||||
|
||||
### D4: 为什么不用 Windows Terminal 的 tab/pane API?
|
||||
|
||||
Windows Terminal 的 `wt.exe` 新窗口/标签功能不够通用——用户可能在 VS Code、ConEmu、cmder 等终端中。detached + log 是唯一跨终端方案。
|
||||
@@ -1,7 +1,7 @@
|
||||
# KAIROS — 常驻助手模式
|
||||
|
||||
> Feature Flag: `FEATURE_KAIROS=1`(及子 Feature)
|
||||
> 实现状态:核心框架完整,部分子模块为 stub
|
||||
> 实现状态:核心框架完整,部分子模块为 stub;proactive/sleep 节奏控制已可用
|
||||
> 引用数:154(全库最大)
|
||||
|
||||
## 一、功能概述
|
||||
@@ -74,8 +74,9 @@ KAIROS 在系统提示中注入两大段落:
|
||||
|
||||
SleepTool 是 KAIROS/Proactive 的节奏控制核心。工具描述让模型理解"休眠"概念:
|
||||
- 工具名:`Sleep`
|
||||
- 功能:等待指定时间后响应 tick prompt
|
||||
- 功能:等待指定时间后响应 tick prompt;若队列出现新工作或 proactive 被关闭,会提前唤醒
|
||||
- 与 `<tick_tag>` 配合实现心跳式自主工作
|
||||
- 远程控制 surfaces 可通过 `automation_state` 看到 `standby` / `sleeping` 两种状态
|
||||
|
||||
### 3.3 Bridge 集成
|
||||
|
||||
@@ -172,8 +173,10 @@ FEATURE_KAIROS=1 FEATURE_TOKEN_BUDGET=1 bun run dev
|
||||
| `src/assistant/AssistantSessionChooser.ts` | — | Session 选择 UI(stub) |
|
||||
| `src/tools/BriefTool/` | — | BriefTool 实现(stub) |
|
||||
| `src/tools/SleepTool/prompt.ts` | ~30 | SleepTool 工具提示 |
|
||||
| `src/tools/SleepTool/SleepTool.ts` | ~200 | 休眠/唤醒与 automation metadata |
|
||||
| `src/services/mcp/channelNotification.ts` | 5 | 频道消息接入(stub) |
|
||||
| `src/memdir/memdir.ts` | — | 记忆目录管理(stub) |
|
||||
| `src/constants/prompts.ts:552-554,843-914` | 72 | 系统提示注入 |
|
||||
| `src/components/tasks/src/tasks/DreamTask/` | 3 | Dream 任务(stub) |
|
||||
| `src/proactive/index.ts` | — | Proactive 核心(stub,KAIROS 共享) |
|
||||
| `src/proactive/index.ts` | — | Proactive 核心(KAIROS 共享) |
|
||||
| `src/utils/sessionState.ts` | — | 向 bridge/CCR 暴露 automation 状态 |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# PROACTIVE — 主动模式
|
||||
|
||||
> Feature Flag: `FEATURE_PROACTIVE=1`(与 `FEATURE_KAIROS=1` 共享功能)
|
||||
> 实现状态:核心模块全部 Stub,布线完整
|
||||
> 实现状态:核心循环与 SleepTool 已落地,部分外围文档仍在补齐
|
||||
> 引用数:37
|
||||
|
||||
## 一、功能概述
|
||||
@@ -21,13 +21,13 @@ PROACTIVE 实现 Tick 驱动的自主代理。CLI 在用户不输入时也能持
|
||||
|
||||
| 模块 | 文件 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 核心逻辑 | `src/proactive/index.ts` | **Stub** | `activateProactive()`、`deactivateProactive()`、`isProactiveActive() => false` |
|
||||
| 核心逻辑 | `src/proactive/index.ts` | **已实现** | `activateProactive()`、`deactivateProactive()`、`pause/resume`、`nextTickAt` 调度状态 |
|
||||
| SleepTool 提示 | `src/tools/SleepTool/prompt.ts` | **完整** | 工具提示定义(工具名:`Sleep`) |
|
||||
| 命令注册 | `src/commands.ts:62-65` | **布线** | 动态加载 `./commands/proactive.js` |
|
||||
| 工具注册 | `src/tools.ts:26-28` | **布线** | SleepTool 动态加载 |
|
||||
| REPL 集成 | `src/screens/REPL.tsx` | **布线** | tick 驱动逻辑、占位符、页脚 UI |
|
||||
| REPL 集成 | `src/screens/REPL.tsx` | **已实现** | tick 驱动、standby/sleeping 状态、页脚与 bridge automation metadata 上报 |
|
||||
| 系统提示 | `src/constants/prompts.ts:860-914` | **完整** | 自主工作行为指令(~55 行详细 prompt) |
|
||||
| 会话存储 | `src/utils/sessionStorage.ts:4892-4912` | **布线** | tick 消息注入对话流 |
|
||||
| 远控状态镜像 | `src/utils/sessionState.ts` | **已实现** | 向 remote-control/CCR 暴露 `automation_state` 元数据 |
|
||||
|
||||
### 2.2 系统提示内容
|
||||
|
||||
@@ -46,7 +46,7 @@ PROACTIVE 实现 Tick 驱动的自主代理。CLI 在用户不输入时也能持
|
||||
### 2.3 数据流
|
||||
|
||||
```
|
||||
activateProactive() [需要实现]
|
||||
activateProactive()
|
||||
│
|
||||
▼
|
||||
Tick 调度器启动
|
||||
@@ -62,20 +62,22 @@ Tick 调度器启动
|
||||
└── 无事可做 → 必须调用 SleepTool
|
||||
│
|
||||
▼
|
||||
SleepTool 等待 [需要实现]
|
||||
SleepTool 等待
|
||||
│
|
||||
├── 用户插入新工作 / 队列中有命令 → 立即唤醒
|
||||
├── proactive 被关闭 → 立即中断
|
||||
└── 进入休眠时向远端 surfaces 上报 `automation_state = sleeping`
|
||||
│
|
||||
▼
|
||||
下一个 tick 到达
|
||||
```
|
||||
|
||||
## 三、需要补全的内容
|
||||
## 三、当前行为补充
|
||||
|
||||
| 优先级 | 模块 | 工作量 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| 1 | `src/proactive/index.ts` | 中 | Tick 调度器、activate/deactivate 状态机、pause/resume |
|
||||
| 2 | `src/tools/SleepTool/SleepTool.ts` | 小 | 工具执行(等待指定时间后触发 tick) |
|
||||
| 3 | `src/commands/proactive.js` | 小 | `/proactive` 斜杠命令处理器 |
|
||||
| 4 | `src/hooks/useProactive.ts` | 中 | React hook(REPL 引用但不存在) |
|
||||
- `standby`:proactive 已开启,当前没有执行中的 turn,且已调度下一个 tick。
|
||||
- `sleeping`:模型显式调用 `SleepTool` 进入等待窗口。
|
||||
- remote-control/CCR 通过 `external_metadata.automation_state` 接收这两个状态,用于 Web UI 的 Autopilot 状态显示。
|
||||
- `SleepTool` 现在不是纯定时器;它会在共享命令队列出现新工作时提前醒来。
|
||||
|
||||
## 四、关键设计决策
|
||||
|
||||
@@ -101,9 +103,11 @@ FEATURE_PROACTIVE=1 FEATURE_KAIROS=1 FEATURE_KAIROS_BRIEF=1 bun run dev
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/proactive/index.ts` | 核心逻辑(stub) |
|
||||
| `src/proactive/index.ts` | 核心逻辑与 next-tick 状态 |
|
||||
| `src/tools/SleepTool/prompt.ts` | SleepTool 工具提示 |
|
||||
| `src/tools/SleepTool/SleepTool.ts` | 休眠/唤醒执行逻辑 |
|
||||
| `src/constants/prompts.ts:860-914` | 自主工作系统提示 |
|
||||
| `src/screens/REPL.tsx` | REPL tick 集成 |
|
||||
| `src/screens/REPL.tsx` | REPL tick 集成与 automation 状态上报 |
|
||||
| `src/utils/sessionStorage.ts:4892-4912` | Tick 消息注入 |
|
||||
| `src/utils/sessionState.ts` | bridge/CCR metadata 镜像 |
|
||||
| `src/components/PromptInput/PromptInputFooterLeftSide.tsx` | 页脚 UI 状态 |
|
||||
|
||||
@@ -174,6 +174,8 @@ claude bridge
|
||||
- 查看已注册的运行环境(environment 模式)
|
||||
- 创建和管理会话
|
||||
- 实时查看对话消息和工具调用
|
||||
- 查看 Autopilot 状态(`standby` / `sleeping`)和自动运行指示
|
||||
- 查看 authoritative task snapshots 驱动的 Tasks 面板
|
||||
- 审批 Claude Code 的工具权限请求
|
||||
|
||||
Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境。
|
||||
@@ -215,6 +217,7 @@ Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境
|
||||
9. 双向通信
|
||||
CLI ──消息/工具调用结果──► RCS ──► Browser
|
||||
CLI ◄──权限审批/指令───── RCS ◄──── Browser
|
||||
CLI ──automation_state / task_state──► RCS ──► Browser
|
||||
|
||||
10. 心跳保活(每 20 秒)
|
||||
CLI ──POST /v1/environments/:id/work/:workId/heartbeat──► RCS
|
||||
@@ -224,6 +227,13 @@ Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境
|
||||
|
||||
## 故障排查
|
||||
|
||||
### Web UI 看不到当前 Autopilot 状态
|
||||
|
||||
- `standby`:proactive 已开启,正在等待下一个 tick
|
||||
- `sleeping`:模型正在 `SleepTool` 等待窗口中
|
||||
|
||||
这两个状态通过 worker `external_metadata.automation_state` 上报。如果页面只显示普通 working spinner,优先检查 CLI 和 RCS 之间的 worker metadata PUT 是否成功。
|
||||
|
||||
### CLI 无法连接
|
||||
|
||||
```
|
||||
|
||||
310
docs/features/stub-recovery-design-1-4.md
Normal file
310
docs/features/stub-recovery-design-1-4.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Stub 恢复设计 1-4
|
||||
|
||||
> 日期:2026-04-12
|
||||
> 目标:基于当前代码边界,为下一阶段 4 个 stub/半 stub 命令面给出可实施的设计方案。
|
||||
> 排序原则:按建议实施顺序排序,不按问题严重性排序。
|
||||
|
||||
## 设计原则
|
||||
|
||||
- 先做能独立闭环、收益明确、改动边界清晰的项。
|
||||
- 大项拆成 `MVP` 和 `Phase 2+`,避免一次性掉进大范围恢复。
|
||||
- 优先复用已有状态、传输层、日志与配置能力,不重造协议。
|
||||
- 设计以当前仓库实际代码为准,不以旧文档的理想状态为准。
|
||||
|
||||
## 1. `claude daemon status` / `claude daemon stop`
|
||||
|
||||
### 现状
|
||||
|
||||
- `start` 路径已有完整 supervisor + worker 生命周期:
|
||||
[src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:1>)
|
||||
[src/daemon/workerRegistry.ts](</e:/Source_code/Claude-code-bast/src/daemon/workerRegistry.ts:1>)
|
||||
- `status` / `stop` 目前只是占位输出:
|
||||
[src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:49>)
|
||||
- `/remote-control-server` 有自己的命令内 UI 状态,但只维护当前进程内的 `daemonProcess`,并不适合作为跨进程 CLI 管理基础:
|
||||
[src/commands/remoteControlServer/remoteControlServer.tsx](</e:/Source_code/Claude-code-bast/src/commands/remoteControlServer/remoteControlServer.tsx:32>)
|
||||
|
||||
### 目标
|
||||
|
||||
- 让 `claude daemon status` 和 `claude daemon stop` 在另一个 CLI 进程中也能正确工作。
|
||||
- 不依赖 TUI 内存态,不要求当前命令进程就是启动 daemon 的那个进程。
|
||||
|
||||
### MVP 方案
|
||||
|
||||
- 新增 daemon 状态文件,例如:
|
||||
`~/.claude/daemon/remote-control.json`
|
||||
- `start` 时写入:
|
||||
- supervisor pid
|
||||
- cwd
|
||||
- startedAt
|
||||
- worker kinds
|
||||
- 最近状态
|
||||
- `status`:
|
||||
- 读取状态文件
|
||||
- 用现有进程探测能力验证 pid 是否存活
|
||||
- 输出 `running / stopped / stale`
|
||||
- stale 时自动清理状态文件
|
||||
- `stop`:
|
||||
- 读取 pid
|
||||
- 发送 `SIGTERM`
|
||||
- 等待退出
|
||||
- 超时后 `SIGKILL`
|
||||
- 清理状态文件
|
||||
|
||||
### 代码范围
|
||||
|
||||
- 新增 `src/daemon/state.ts`
|
||||
- 修改 [src/daemon/main.ts](</e:/Source_code/Claude-code-bast/src/daemon/main.ts:1>)
|
||||
- 轻量修改 [src/commands/remoteControlServer/remoteControlServer.tsx](</e:/Source_code/Claude-code-bast/src/commands/remoteControlServer/remoteControlServer.tsx:32>),让 UI 尽量读取同一份状态文件
|
||||
|
||||
### 验证
|
||||
|
||||
1. `claude daemon start`
|
||||
2. 新开终端执行 `claude daemon status`
|
||||
3. 执行 `claude daemon stop`
|
||||
4. 再次执行 `claude daemon status`,确认返回 `stopped` 或清晰的 `stale cleaned`
|
||||
|
||||
### 风险
|
||||
|
||||
- Windows 信号模型和 Unix 不同,`stop` 需要超时兜底。
|
||||
- 当前设计默认单 supervisor,不处理多实例并发。
|
||||
|
||||
### 工作量判断
|
||||
|
||||
- 小
|
||||
- 适合作为下一步的首选实现项
|
||||
|
||||
## 2. `BG_SESSIONS`
|
||||
|
||||
### 现状
|
||||
|
||||
- fast-path 已接好:
|
||||
[src/entrypoints/cli.tsx](</e:/Source_code/Claude-code-bast/src/entrypoints/cli.tsx:218>)
|
||||
- session registry 已有真实实现:
|
||||
[src/utils/concurrentSessions.ts](</e:/Source_code/Claude-code-bast/src/utils/concurrentSessions.ts:1>)
|
||||
- `exit` 在 bg session 内已会 `tmux detach-client`:
|
||||
[src/commands/exit/exit.tsx](</e:/Source_code/Claude-code-bast/src/commands/exit/exit.tsx:20>)
|
||||
- 但 CLI handler 仍全空:
|
||||
[src/cli/bg.ts](</e:/Source_code/Claude-code-bast/src/cli/bg.ts:1>)
|
||||
- task summary 仍然是 stub:
|
||||
[src/utils/taskSummary.ts](</e:/Source_code/Claude-code-bast/src/utils/taskSummary.ts:1>)
|
||||
|
||||
### 目标
|
||||
|
||||
- 先把 `ps` / `logs` / `kill` 做成真正有用的 session 管理命令。
|
||||
- 不在第一阶段就强行补完 `attach` / `--bg`。
|
||||
|
||||
### Phase 2A:MVP
|
||||
|
||||
- 实现 `ps`
|
||||
- 从 registry 读取 live sessions
|
||||
- 展示 pid、kind、sessionId、cwd、name、startedAt、bridgeSessionId
|
||||
- 如果有 activity/status,则一并展示
|
||||
- 实现 `logs`
|
||||
- 支持按 `sessionId / pid / name` 查找
|
||||
- 优先复用本地 transcript/log 读取能力
|
||||
- 如果 registry 里存在 `logPath`,支持 tail 文件
|
||||
- 实现 `kill`
|
||||
- 解析目标 session
|
||||
- 发退出信号
|
||||
- 清理 stale registry
|
||||
|
||||
### Phase 2B:后续
|
||||
|
||||
- 实现 `attach`
|
||||
- 实现 `--bg`
|
||||
- 实现 `taskSummary` 的中途状态更新
|
||||
|
||||
### 为什么要拆
|
||||
|
||||
- 现有 registry 记录了 `pid / sessionId / name / logPath`
|
||||
- 但没有可靠的 tmux attach target
|
||||
- 所以 `attach` 和 `--bg` 不是简单补 handler,而是需要补启动/附着元数据设计
|
||||
|
||||
### 代码范围
|
||||
|
||||
- 修改 [src/cli/bg.ts](</e:/Source_code/Claude-code-bast/src/cli/bg.ts:1>)
|
||||
- 修改 [src/utils/concurrentSessions.ts](</e:/Source_code/Claude-code-bast/src/utils/concurrentSessions.ts:1>) 以便后续 attach/--bg 扩展
|
||||
- 修改 [src/utils/taskSummary.ts](</e:/Source_code/Claude-code-bast/src/utils/taskSummary.ts:1>)
|
||||
- 复用:
|
||||
[src/utils/sessionStorage.ts](</e:/Source_code/Claude-code-bast/src/utils/sessionStorage.ts:3870>)
|
||||
[src/utils/udsClient.ts](</e:/Source_code/Claude-code-bast/src/utils/udsClient.ts:1>)
|
||||
|
||||
### 验证
|
||||
|
||||
1. `ps` 能列出 live sessions
|
||||
2. `logs <sessionId|pid|name>` 能输出对应日志
|
||||
3. `kill <sessionId|pid|name>` 能结束目标 session
|
||||
|
||||
### 风险
|
||||
|
||||
- `attach` / `--bg` 第二阶段需要 tmux 元数据设计
|
||||
- Windows 下 tmux 路径需要明确降级策略
|
||||
|
||||
### 工作量判断
|
||||
|
||||
- `ps/logs/kill` 中等
|
||||
- `attach/--bg` 明显更大,应分阶段
|
||||
|
||||
## 3. `TEMPLATES`
|
||||
|
||||
### 现状
|
||||
|
||||
- 命令入口只有 fast-path:
|
||||
[src/entrypoints/cli.tsx](</e:/Source_code/Claude-code-bast/src/entrypoints/cli.tsx:249>)
|
||||
- handler 是空的:
|
||||
[src/cli/handlers/templateJobs.ts](</e:/Source_code/Claude-code-bast/src/cli/handlers/templateJobs.ts:1>)
|
||||
- `markdownConfigLoader` 已把 `templates` 纳入配置目录:
|
||||
[src/utils/markdownConfigLoader.ts](</e:/Source_code/Claude-code-bast/src/utils/markdownConfigLoader.ts:29>)
|
||||
- `query / stopHooks` 已预留 job classifier 链路:
|
||||
[src/query/stopHooks.ts](</e:/Source_code/Claude-code-bast/src/query/stopHooks.ts:103>)
|
||||
- `jobs/classifier.ts` 仍是 stub:
|
||||
[src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
|
||||
|
||||
### 目标
|
||||
|
||||
- 把 `new / list / reply` 做成可用的模板任务系统。
|
||||
- 第一阶段不碰复杂的自动分类与自动执行。
|
||||
|
||||
### MVP 方案
|
||||
|
||||
- 模板来源:
|
||||
`.claude/templates/*.md`
|
||||
- 模板格式:
|
||||
复用现有 markdown + frontmatter 解析,不另外设计 DSL
|
||||
- `list`
|
||||
- 列出所有模板
|
||||
- 显示模板名、description、路径
|
||||
- `new <template> [args...]`
|
||||
- 解析模板
|
||||
- 在 `~/.claude/jobs/<job-id>/` 下创建 job 目录
|
||||
- 写入 `template.md`、`input.txt`、`state.json`
|
||||
- 返回 job id 与目录
|
||||
- `reply <job-id> <text>`
|
||||
- 将回复写入 `replies.jsonl` 或 `input.txt`
|
||||
- 更新 `state.json`
|
||||
|
||||
### Phase 2
|
||||
|
||||
- 恢复 [src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
|
||||
- 让带 `CLAUDE_JOB_DIR` 的 job session 在 turn 完成后自动更新 `state.json`
|
||||
- 再决定是否补自动 job runner
|
||||
|
||||
### 为什么要拆
|
||||
|
||||
- 当前证据表明这是“template job commands”,不是单纯模板列表
|
||||
- 但自动 job 运行链路没有足够现成实现,先做文件系统 job lifecycle 更稳
|
||||
|
||||
### 代码范围
|
||||
|
||||
- 修改 [src/cli/handlers/templateJobs.ts](</e:/Source_code/Claude-code-bast/src/cli/handlers/templateJobs.ts:1>)
|
||||
- 新增 `src/jobs/state.ts`
|
||||
- 新增 `src/jobs/templates.ts`
|
||||
- Phase 2 再改 [src/jobs/classifier.ts](</e:/Source_code/Claude-code-bast/src/jobs/classifier.ts:1>)
|
||||
|
||||
### 验证
|
||||
|
||||
1. `list` 能列出 `.claude/templates`
|
||||
2. `new` 能创建 job 目录和状态文件
|
||||
3. `reply` 能更新 job 内容和状态
|
||||
4. Phase 2 再验证 classifier 写状态
|
||||
|
||||
### 风险
|
||||
|
||||
- frontmatter schema 需要先定义最小字段集
|
||||
- 一旦扩展到“自动运行 job”,范围会明显膨胀
|
||||
|
||||
### 工作量判断
|
||||
|
||||
- MVP 中等
|
||||
- 完整 job 系统偏大
|
||||
|
||||
## 4. `assistant [sessionId]`
|
||||
|
||||
### 现状
|
||||
|
||||
- attach 主流程其实已经存在:
|
||||
[src/main.tsx](</e:/Source_code/Claude-code-bast/src/main.tsx:4708>)
|
||||
- 远端 viewer 所需基础模块已存在:
|
||||
[src/remote/RemoteSessionManager.ts](</e:/Source_code/Claude-code-bast/src/remote/RemoteSessionManager.ts:1>)
|
||||
[src/hooks/useAssistantHistory.ts](</e:/Source_code/Claude-code-bast/src/hooks/useAssistantHistory.ts:1>)
|
||||
[src/assistant/sessionHistory.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionHistory.ts:1>)
|
||||
- 真正 stub 的主要是:
|
||||
[src/assistant/sessionDiscovery.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionDiscovery.ts:1>)
|
||||
[src/assistant/AssistantSessionChooser.ts](</e:/Source_code/Claude-code-bast/src/assistant/AssistantSessionChooser.ts:1>)
|
||||
[src/commands/assistant/assistant.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/assistant.ts:7>)
|
||||
[src/assistant/index.ts](</e:/Source_code/Claude-code-bast/src/assistant/index.ts:1>)
|
||||
|
||||
### 目标
|
||||
|
||||
- 不一次性恢复整个 KAIROS 助手系统。
|
||||
- 先做“明确 sessionId 的 viewer attach 可用”,再逐步补 discovery / chooser / install。
|
||||
|
||||
### Phase 4A:MVP
|
||||
|
||||
- 只支持 `claude assistant <sessionId>`
|
||||
- 对 `claude assistant` 无参数模式,先返回明确提示:
|
||||
- 当前版本需要显式 `sessionId`
|
||||
- discovery 尚未启用
|
||||
- 这样可以直接复用现有 attach 分支,不必先恢复 chooser/install wizard
|
||||
|
||||
### Phase 4B
|
||||
|
||||
- 恢复 `discoverAssistantSessions()`
|
||||
- 数据来源优先复用现有 sessions / bridge / teleport API,而不是新协议
|
||||
- 让 `claude assistant` 无参数时能拿到候选 session 列表
|
||||
|
||||
### Phase 4C
|
||||
|
||||
- 恢复 `AssistantSessionChooser`
|
||||
- 多 session 时可交互选择
|
||||
|
||||
### Phase 4D
|
||||
|
||||
- 最后考虑 install wizard 辅助函数
|
||||
- 这部分属于“没有 session 时如何引导”,不是 attach 核心路径
|
||||
|
||||
### 为什么要拆
|
||||
|
||||
- attach 渲染层与远端消息通道大部分已经在
|
||||
- 真正缺的是“如何发现目标 session”和“如何交互选择”
|
||||
- 如果把 `src/assistant/index.ts` 的整套 KAIROS 正常模式也一起拉进来,范围会失控
|
||||
|
||||
### 代码范围
|
||||
|
||||
- Phase 4A:
|
||||
- [src/main.tsx](</e:/Source_code/Claude-code-bast/src/main.tsx:4708>)
|
||||
- [src/commands/assistant/index.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/index.ts:1>)
|
||||
- Phase 4B:
|
||||
- [src/assistant/sessionDiscovery.ts](</e:/Source_code/Claude-code-bast/src/assistant/sessionDiscovery.ts:1>)
|
||||
- Phase 4C:
|
||||
- [src/assistant/AssistantSessionChooser.ts](</e:/Source_code/Claude-code-bast/src/assistant/AssistantSessionChooser.ts:1>)
|
||||
- Phase 4D:
|
||||
- [src/commands/assistant/assistant.ts](</e:/Source_code/Claude-code-bast/src/commands/assistant/assistant.ts:7>)
|
||||
|
||||
### 验证
|
||||
|
||||
1. `claude assistant <sessionId>` 能进入 remote viewer
|
||||
2. 历史懒加载工作正常
|
||||
3. 无参数模式先给出明确提示
|
||||
4. 后续阶段再分别验证 discovery / chooser / install
|
||||
|
||||
### 风险
|
||||
|
||||
- 这是四项里范围最大的
|
||||
- 一旦把 KAIROS 正常模式整体拉入,会从“viewer attach”膨胀成“完整 assistant mode 恢复”
|
||||
|
||||
### 工作量判断
|
||||
|
||||
- Phase 4A 中等
|
||||
- 4A-4D 全做完很大
|
||||
|
||||
## 建议执行顺序
|
||||
|
||||
1. `claude daemon status` / `claude daemon stop`
|
||||
2. `BG_SESSIONS` 先做 `ps/logs/kill`
|
||||
3. `TEMPLATES` 先做 job 文件系统 MVP
|
||||
4. `assistant [sessionId]` 先做显式 sessionId attach,再补 discovery/chooser/install
|
||||
|
||||
## 简短结论
|
||||
|
||||
这四项里,最适合立刻实现的是 `daemon status/stop`。`BG_SESSIONS` 和 `TEMPLATES` 适合按 MVP 先补 handler 与文件系统闭环。`assistant [sessionId]` 不能整块硬上,应该按“attach → discovery → chooser → install”拆开恢复。
|
||||
77
docs/task/task-001-daemon-status-stop.md
Normal file
77
docs/task/task-001-daemon-status-stop.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Task 001: daemon status / stop
|
||||
|
||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 1 项
|
||||
> 优先级: P0 (首选实现项)
|
||||
> 工作量: 小
|
||||
> 状态: DONE
|
||||
|
||||
## 目标
|
||||
|
||||
让 `claude daemon status` 和 `claude daemon stop` 在任意 CLI 进程中都能正确工作,不依赖 TUI 内存态。
|
||||
|
||||
## 背景
|
||||
|
||||
- `start` 路径已有完整 supervisor + worker 生命周期 (`src/daemon/main.ts`, `src/daemon/workerRegistry.ts`)
|
||||
- `status` / `stop` 目前只是占位输出 (`src/daemon/main.ts:49`)
|
||||
- `/remote-control-server` 有自己的命令内 UI 状态,但只维护当前进程内的 `daemonProcess`,不适合跨进程管理
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/daemon/state.ts` | daemon 状态文件读写模块 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/daemon/main.ts` | `start` 写入状态文件;`status`/`stop` 调用 state 模块 |
|
||||
| `src/commands/remoteControlServer/remoteControlServer.tsx` | 读取同一份状态文件(轻量改动) |
|
||||
|
||||
### 状态文件
|
||||
|
||||
路径: `~/.claude/daemon/remote-control.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"pid": 12345,
|
||||
"cwd": "/path/to/project",
|
||||
"startedAt": "2026-04-12T10:00:00Z",
|
||||
"workerKinds": ["bridge", "rcs"],
|
||||
"lastStatus": "running"
|
||||
}
|
||||
```
|
||||
|
||||
### status 逻辑
|
||||
|
||||
1. 读取状态文件
|
||||
2. 用进程探测验证 pid 是否存活
|
||||
3. 输出 `running` / `stopped` / `stale`
|
||||
4. stale 时自动清理状态文件
|
||||
|
||||
### stop 逻辑
|
||||
|
||||
1. 读取 pid
|
||||
2. 发送 `SIGTERM`
|
||||
3. 等待退出(超时兜底)
|
||||
4. 超时后 `SIGKILL`
|
||||
5. 清理状态文件
|
||||
|
||||
## 验证步骤
|
||||
|
||||
- [ ] `claude daemon start` 正常启动并写入状态文件
|
||||
- [ ] 新开终端执行 `claude daemon status`,显示 `running`
|
||||
- [ ] 执行 `claude daemon stop`,daemon 正常退出
|
||||
- [ ] 再次执行 `claude daemon status`,返回 `stopped` 或 `stale cleaned`
|
||||
- [ ] Windows 下 stop 超时兜底正常工作
|
||||
|
||||
## 风险
|
||||
|
||||
- Windows 信号模型和 Unix 不同,`stop` 需要超时兜底
|
||||
- 当前设计默认单 supervisor,不处理多实例并发
|
||||
|
||||
## 依赖
|
||||
|
||||
无外部依赖,可独立实施。
|
||||
80
docs/task/task-002-bg-sessions-ps-logs-kill.md
Normal file
80
docs/task/task-002-bg-sessions-ps-logs-kill.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Task 002: BG_SESSIONS — ps / logs / kill
|
||||
|
||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 2 项
|
||||
> 优先级: P1
|
||||
> 工作量: 中等
|
||||
> 状态: DONE
|
||||
> 阶段: Phase 2A (MVP)
|
||||
|
||||
## 目标
|
||||
|
||||
把 `ps` / `logs` / `kill` 做成真正有用的 session 管理命令。不在第一阶段补完 `attach` / `--bg`。
|
||||
|
||||
## 背景
|
||||
|
||||
- fast-path 已接好 (`src/entrypoints/cli.tsx:218`)
|
||||
- session registry 已有真实实现 (`src/utils/concurrentSessions.ts`)
|
||||
- `exit` 在 bg session 内已会 `tmux detach-client` (`src/commands/exit/exit.tsx:20`)
|
||||
- CLI handler 仍全空 (`src/cli/bg.ts`)
|
||||
- task summary 仍然是 stub (`src/utils/taskSummary.ts`)
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/cli/bg.ts` | 实现 `ps` / `logs` / `kill` handler |
|
||||
| `src/utils/concurrentSessions.ts` | 扩展以便后续 attach/--bg 使用 |
|
||||
| `src/utils/taskSummary.ts` | 补充基础实现 |
|
||||
|
||||
### 复用模块
|
||||
|
||||
- `src/utils/sessionStorage.ts` — session 存储
|
||||
- `src/utils/udsClient.ts` — UDS 通信
|
||||
|
||||
### ps 命令
|
||||
|
||||
- 从 registry 读取 live sessions
|
||||
- 展示: pid, kind, sessionId, cwd, name, startedAt, bridgeSessionId
|
||||
- 如果有 activity/status,一并展示
|
||||
|
||||
### logs 命令
|
||||
|
||||
- 支持按 `sessionId` / `pid` / `name` 查找
|
||||
- 优先复用本地 transcript/log 读取能力
|
||||
- 如果 registry 里存在 `logPath`,支持 tail 文件
|
||||
|
||||
### kill 命令
|
||||
|
||||
- 解析目标 session
|
||||
- 发退出信号
|
||||
- 清理 stale registry
|
||||
|
||||
## 验证步骤
|
||||
|
||||
- [ ] `ps` 能列出当前 live sessions
|
||||
- [ ] `logs <sessionId|pid|name>` 能输出对应日志
|
||||
- [ ] `kill <sessionId|pid|name>` 能结束目标 session 并清理 registry
|
||||
- [ ] 无 live session 时各命令有明确提示
|
||||
|
||||
## Phase 2B (后续)
|
||||
|
||||
- [ ] 实现 `attach`
|
||||
- [ ] 实现 `--bg`
|
||||
- [ ] 实现 `taskSummary` 的中途状态更新
|
||||
|
||||
### 为什么拆分
|
||||
|
||||
- 现有 registry 记录了 `pid / sessionId / name / logPath`
|
||||
- 但没有可靠的 tmux attach target
|
||||
- `attach` 和 `--bg` 需要补启动/附着元数据设计,不是简单补 handler
|
||||
|
||||
## 风险
|
||||
|
||||
- `attach` / `--bg` 第二阶段需要 tmux 元数据设计
|
||||
- Windows 下 tmux 路径需要明确降级策略
|
||||
|
||||
## 依赖
|
||||
|
||||
- Task 001 (daemon 状态管理可复用模式,但非硬性依赖)
|
||||
87
docs/task/task-003-templates-job-mvp.md
Normal file
87
docs/task/task-003-templates-job-mvp.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Task 003: TEMPLATES — job 文件系统 MVP
|
||||
|
||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 3 项
|
||||
> 优先级: P2
|
||||
> 工作量: 中等
|
||||
> 状态: DONE
|
||||
> 阶段: MVP
|
||||
|
||||
## 目标
|
||||
|
||||
把 `new` / `list` / `reply` 做成可用的模板任务系统。第一阶段不碰复杂的自动分类与自动执行。
|
||||
|
||||
## 背景
|
||||
|
||||
- 命令入口只有 fast-path (`src/entrypoints/cli.tsx:249`)
|
||||
- handler 是空的 (`src/cli/handlers/templateJobs.ts`)
|
||||
- `markdownConfigLoader` 已把 `templates` 纳入配置目录 (`src/utils/markdownConfigLoader.ts:29`)
|
||||
- `query/stopHooks` 已预留 job classifier 链路 (`src/query/stopHooks.ts:103`)
|
||||
- `jobs/classifier.ts` 仍是 stub (`src/jobs/classifier.ts`)
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/jobs/state.ts` | job 状态管理 |
|
||||
| `src/jobs/templates.ts` | 模板解析与列表 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/cli/handlers/templateJobs.ts` | 实现 `new` / `list` / `reply` handler |
|
||||
|
||||
### 模板来源
|
||||
|
||||
`.claude/templates/*.md`
|
||||
|
||||
### 模板格式
|
||||
|
||||
复用现有 markdown + frontmatter 解析,不另外设计 DSL。
|
||||
|
||||
### list 命令
|
||||
|
||||
- 列出所有模板
|
||||
- 显示: 模板名, description, 路径
|
||||
|
||||
### new 命令
|
||||
|
||||
- 解析模板
|
||||
- 在 `~/.claude/jobs/<job-id>/` 下创建 job 目录
|
||||
- 写入 `template.md`, `input.txt`, `state.json`
|
||||
- 返回 job id 与目录路径
|
||||
|
||||
### reply 命令
|
||||
|
||||
- 将回复写入 `replies.jsonl` 或 `input.txt`
|
||||
- 更新 `state.json`
|
||||
|
||||
## 验证步骤
|
||||
|
||||
- [ ] `list` 能列出 `.claude/templates` 下的所有模板
|
||||
- [ ] `new <template> [args...]` 能创建 job 目录和状态文件
|
||||
- [ ] `reply <job-id> <text>` 能更新 job 内容和状态
|
||||
- [ ] frontmatter schema 最小字段集已定义
|
||||
|
||||
## Phase 2 (后续)
|
||||
|
||||
- [ ] 恢复 `src/jobs/classifier.ts`
|
||||
- [ ] 让带 `CLAUDE_JOB_DIR` 的 job session 在 turn 完成后自动更新 `state.json`
|
||||
- [ ] 再决定是否补自动 job runner
|
||||
|
||||
### 为什么拆分
|
||||
|
||||
- 当前是 "template job commands",不是单纯模板列表
|
||||
- 自动 job 运行链路没有足够现成实现
|
||||
- 先做文件系统 job lifecycle 更稳
|
||||
|
||||
## 风险
|
||||
|
||||
- frontmatter schema 需要先定义最小字段集
|
||||
- 一旦扩展到"自动运行 job",范围会明显膨胀
|
||||
|
||||
## 依赖
|
||||
|
||||
无硬性依赖,可独立实施。
|
||||
103
docs/task/task-004-assistant-session-attach.md
Normal file
103
docs/task/task-004-assistant-session-attach.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Task 004: assistant [sessionId] — 分阶段恢复
|
||||
|
||||
> 来源: [stub-recovery-design-1-4.md](../features/stub-recovery-design-1-4.md) 第 4 项
|
||||
> 优先级: P3
|
||||
> 工作量: Phase 4A 中等,4A-4D 全做完很大
|
||||
> 状态: Phase 4A DONE, 4B-4D TODO
|
||||
|
||||
## 目标
|
||||
|
||||
不一次性恢复整个 KAIROS 助手系统。先做"明确 sessionId 的 viewer attach 可用",再逐步补 discovery / chooser / install。
|
||||
|
||||
## 背景
|
||||
|
||||
- attach 主流程已存在 (`src/main.tsx:4708`)
|
||||
- 远端 viewer 所需基础模块已存在:
|
||||
- `src/remote/RemoteSessionManager.ts`
|
||||
- `src/hooks/useAssistantHistory.ts`
|
||||
- `src/assistant/sessionHistory.ts`
|
||||
- 真正 stub 的主要是:
|
||||
- `src/assistant/sessionDiscovery.ts`
|
||||
- `src/assistant/AssistantSessionChooser.ts`
|
||||
- `src/commands/assistant/assistant.ts:7`
|
||||
- `src/assistant/index.ts`
|
||||
|
||||
## 分阶段实现
|
||||
|
||||
### Phase 4A: MVP — 显式 sessionId attach
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/main.tsx` | 确保 attach 分支可用 |
|
||||
| `src/commands/assistant/index.ts` | 实现显式 sessionId 参数入口 |
|
||||
|
||||
**行为:**
|
||||
- `claude assistant <sessionId>` — 进入 remote viewer
|
||||
- `claude assistant` (无参数) — 返回明确提示: 当前版本需要显式 sessionId,discovery 尚未启用
|
||||
|
||||
**验证:**
|
||||
- [ ] `claude assistant <sessionId>` 能进入 remote viewer
|
||||
- [ ] 历史懒加载工作正常
|
||||
- [ ] 无参数模式给出明确提示
|
||||
|
||||
### Phase 4B: session discovery
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/assistant/sessionDiscovery.ts` | 恢复 `discoverAssistantSessions()` |
|
||||
|
||||
**行为:**
|
||||
- 数据来源优先复用现有 sessions / bridge / teleport API,不新增协议
|
||||
- `claude assistant` 无参数时能拿到候选 session 列表
|
||||
|
||||
**验证:**
|
||||
- [ ] 无参数调用能列出可用 sessions
|
||||
- [ ] 数据来源复用现有通道
|
||||
|
||||
### Phase 4C: session chooser
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/assistant/AssistantSessionChooser.ts` | 恢复交互式选择器 |
|
||||
|
||||
**行为:**
|
||||
- 多 session 时可交互选择
|
||||
|
||||
**验证:**
|
||||
- [ ] 多个 session 时弹出选择器
|
||||
- [ ] 选择后正确 attach
|
||||
|
||||
### Phase 4D: install wizard
|
||||
|
||||
**修改文件:**
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/commands/assistant/assistant.ts` | 恢复 install wizard 辅助函数 |
|
||||
|
||||
**行为:**
|
||||
- 没有 session 时如何引导用户
|
||||
|
||||
**验证:**
|
||||
- [ ] 无可用 session 时引导用户创建/连接
|
||||
|
||||
## 为什么拆分
|
||||
|
||||
- attach 渲染层与远端消息通道大部分已在
|
||||
- 真正缺的是"如何发现目标 session"和"如何交互选择"
|
||||
- 如果把 `src/assistant/index.ts` 的整套 KAIROS 正常模式也一起拉进来,范围会失控
|
||||
|
||||
## 风险
|
||||
|
||||
- 这是四项里范围最大的
|
||||
- 一旦把 KAIROS 正常模式整体拉入,会从"viewer attach"膨胀成"完整 assistant mode 恢复"
|
||||
|
||||
## 依赖
|
||||
|
||||
- Task 002 的 session registry 模式可复用
|
||||
196
docs/task/task-013-bg-engine-abstraction.md
Normal file
196
docs/task/task-013-bg-engine-abstraction.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Task 013: BgEngine 跨平台后台引擎抽象
|
||||
|
||||
> 设计文档: [daemon-restructure-design.md](../features/daemon-restructure-design.md) § 四
|
||||
> 依赖: 无
|
||||
> 分支: `feat/integrate-5-branches`
|
||||
|
||||
## 目标
|
||||
|
||||
将 `src/cli/bg.ts` 中硬编码的 tmux 逻辑提取为引擎抽象层,实现 TmuxEngine + DetachedEngine,使后台会话功能在 Windows / macOS / Linux 上都能工作。
|
||||
|
||||
## 背景
|
||||
|
||||
当前 `bg.ts` 中 `handleBgFlag()` 和 `attachHandler()` 直接调用 tmux 命令。Windows 上 `--bg` 直接报错退出。需要一个引擎抽象层,根据平台和可用工具自动选择最佳方案。
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 新增
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/cli/bg/engine.ts` | BgEngine 接口 + BgStartOptions/BgStartResult 类型 |
|
||||
| `src/cli/bg/engines/tmux.ts` | TmuxEngine: 从 `bg.ts` 提取 tmux 相关逻辑 |
|
||||
| `src/cli/bg/engines/detached.ts` | DetachedEngine: spawn({ detached }) + logFile 重定向 |
|
||||
| `src/cli/bg/engines/index.ts` | selectEngine() 自动选择 + re-export |
|
||||
| `src/cli/bg/tail.ts` | 跨平台日志 tail: fs.watch + 轮询 fallback |
|
||||
|
||||
### 修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/cli/bg.ts` | `handleBgFlag()` 改为调用 `selectEngine().start()`;`attachHandler()` 改为调用 `engine.attach()` |
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. BgEngine 接口 (`src/cli/bg/engine.ts`)
|
||||
|
||||
```typescript
|
||||
export interface BgEngine {
|
||||
readonly name: string
|
||||
available(): Promise<boolean>
|
||||
start(opts: BgStartOptions): Promise<BgStartResult>
|
||||
attach(session: SessionEntry): Promise<void>
|
||||
}
|
||||
|
||||
export interface BgStartOptions {
|
||||
sessionName: string
|
||||
args: string[] // CLI args (去除 --bg)
|
||||
env: Record<string, string | undefined>
|
||||
logPath: string
|
||||
cwd: string
|
||||
}
|
||||
|
||||
export interface BgStartResult {
|
||||
pid: number
|
||||
sessionName: string
|
||||
logPath: string
|
||||
engineUsed: 'tmux' | 'detached'
|
||||
}
|
||||
```
|
||||
|
||||
### 2. TmuxEngine (`src/cli/bg/engines/tmux.ts`)
|
||||
|
||||
从 `bg.ts:handleBgFlag()` 和 `bg.ts:attachHandler()` 提取:
|
||||
- `available()`: `execFileNoThrow('tmux', ['-V'])` 返回 code === 0
|
||||
- `start()`: `tmux new-session -d -s <name> <cmd>`
|
||||
- `attach()`: `tmux attach-session -t <session.tmuxSessionName>`
|
||||
|
||||
### 3. DetachedEngine (`src/cli/bg/engines/detached.ts`)
|
||||
|
||||
```typescript
|
||||
export class DetachedEngine implements BgEngine {
|
||||
readonly name = 'detached'
|
||||
|
||||
async available(): Promise<boolean> {
|
||||
return true // 总是可用
|
||||
}
|
||||
|
||||
async start(opts: BgStartOptions): Promise<BgStartResult> {
|
||||
const logFd = openSync(opts.logPath, 'a')
|
||||
const child = spawn(process.execPath, [process.argv[1]!, ...opts.args], {
|
||||
detached: true,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
env: opts.env,
|
||||
cwd: opts.cwd,
|
||||
})
|
||||
child.unref()
|
||||
closeSync(logFd)
|
||||
|
||||
return {
|
||||
pid: child.pid!,
|
||||
sessionName: opts.sessionName,
|
||||
logPath: opts.logPath,
|
||||
engineUsed: 'detached',
|
||||
}
|
||||
}
|
||||
|
||||
async attach(session: SessionEntry): Promise<void> {
|
||||
// 委托给 tail.ts
|
||||
await tailLog(session.logPath!)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 日志 Tail (`src/cli/bg/tail.ts`)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 跨平台实时日志输出。Ctrl+C 退出,不杀后台进程。
|
||||
*
|
||||
* 策略:
|
||||
* 1. 读取已有内容输出
|
||||
* 2. fs.watch() 监听文件变化 (主方案)
|
||||
* 3. 如果 fs.watch 不可靠 (某些 Windows 网络驱动器),fallback 到 500ms 轮询
|
||||
*/
|
||||
export async function tailLog(logPath: string): Promise<void>
|
||||
```
|
||||
|
||||
### 5. 引擎选择 (`src/cli/bg/engines/index.ts`)
|
||||
|
||||
```typescript
|
||||
export async function selectEngine(): Promise<BgEngine> {
|
||||
if (process.platform === 'win32') {
|
||||
return new DetachedEngine()
|
||||
}
|
||||
const tmux = new TmuxEngine()
|
||||
if (await tmux.available()) {
|
||||
return tmux
|
||||
}
|
||||
return new DetachedEngine()
|
||||
}
|
||||
```
|
||||
|
||||
### 6. bg.ts 重构
|
||||
|
||||
`handleBgFlag()` 改名为 `handleBgStart()`,内部逻辑:
|
||||
```typescript
|
||||
export async function handleBgStart(args: string[]): Promise<void> {
|
||||
const engine = await selectEngine()
|
||||
const sessionName = `claude-bg-${randomUUID().slice(0, 8)}`
|
||||
const logPath = join(getClaudeConfigHomeDir(), 'sessions', 'logs', `${sessionName}.log`)
|
||||
|
||||
const result = await engine.start({
|
||||
sessionName,
|
||||
args: filteredArgs,
|
||||
env: { ...process.env, CLAUDE_CODE_SESSION_KIND: 'bg', ... },
|
||||
logPath,
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
|
||||
console.log(`Background session started: ${result.sessionName}`)
|
||||
console.log(` Engine: ${result.engineUsed}`)
|
||||
console.log(` Log: ${result.logPath}`)
|
||||
console.log(` Use \`claude daemon attach ${result.sessionName}\` to reconnect.`)
|
||||
}
|
||||
```
|
||||
|
||||
`attachHandler()` 根据 `session.engine` 字段选择引擎:
|
||||
```typescript
|
||||
export async function attachHandler(target: string | undefined): Promise<void> {
|
||||
// ... 找到 session
|
||||
if (session.engine === 'tmux' && session.tmuxSessionName) {
|
||||
const tmux = new TmuxEngine()
|
||||
await tmux.attach(session)
|
||||
} else {
|
||||
const detached = new DetachedEngine()
|
||||
await detached.attach(session)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SessionEntry 扩展
|
||||
|
||||
`sessions/<PID>.json` 新增 `engine` 字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"pid": 12345,
|
||||
"engine": "detached",
|
||||
"logPath": "~/.claude/sessions/logs/claude-bg-a1b2c3d4.log",
|
||||
"sessionId": "...",
|
||||
"cwd": "..."
|
||||
}
|
||||
```
|
||||
|
||||
兼容旧格式: 如果 `engine` 字段缺失,检查 `tmuxSessionName` 存在则为 `tmux`,否则为 `detached`。
|
||||
|
||||
## 验证清单
|
||||
|
||||
- [ ] Windows: `claude daemon bg` 启动后台会话,无 tmux 依赖
|
||||
- [ ] Windows: `claude daemon attach <name>` 以 tail 模式附着,Ctrl+C 退出不杀进程
|
||||
- [ ] macOS/Linux (有 tmux): 行为与当前一致
|
||||
- [ ] macOS/Linux (无 tmux): 自动 fallback 到 detached 引擎
|
||||
- [ ] `claude daemon status` 正确显示 engine 类型
|
||||
- [ ] 旧格式 session JSON (无 engine 字段) 兼容
|
||||
- [ ] tsc --noEmit 零错误
|
||||
- [ ] bun test 通过
|
||||
275
docs/task/task-014-daemon-command-hierarchy.md
Normal file
275
docs/task/task-014-daemon-command-hierarchy.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Task 014: /daemon 命令层级化
|
||||
|
||||
> 设计文档: [daemon-restructure-design.md](../features/daemon-restructure-design.md) § 三.1
|
||||
> 依赖: Task 013 (BgEngine 抽象)
|
||||
> 分支: `feat/integrate-5-branches`
|
||||
|
||||
## 目标
|
||||
|
||||
将散落的 `daemon start/stop/status` + `ps/logs/attach/kill` + `--bg` 统一收归 `/daemon` 命名空间,实现 CLI + REPL 双注册。
|
||||
|
||||
## 背景
|
||||
|
||||
当前这些命令注册在两个互不关联的位置:
|
||||
- `cli.tsx:203-212`: `daemon [start|status|stop]` → `daemon/main.ts`
|
||||
- `cli.tsx:217-246`: `ps|logs|attach|kill|--bg` → `cli/bg.ts`
|
||||
|
||||
需要合并为统一的 `claude daemon <subcommand>` 入口,并新增 REPL `/daemon` 斜杠命令。
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 新增
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/commands/daemon/index.ts` | `/daemon` REPL 斜杠命令注册 (type: local-jsx) |
|
||||
| `src/commands/daemon/daemon.tsx` | `/daemon` 子命令路由 + status UI 组件 |
|
||||
|
||||
### 修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/entrypoints/cli.tsx` | 统一 daemon 快速路径: `daemon <sub>` 路由到对应 handler。旧命令 `ps/logs/attach/kill` 保留但输出 deprecation 警告后代理 |
|
||||
| `src/commands.ts` | 注册 `/daemon` 斜杠命令 (feature-gated: DAEMON \|\| BG_SESSIONS) |
|
||||
| `src/daemon/main.ts` | `daemonMain()` 扩展: 支持 `bg/attach/logs/kill/ps` 子命令 (委托给 bg.ts handlers) |
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. CLI 快速路径统一 (`cli.tsx`)
|
||||
|
||||
**改前** (两段独立路由):
|
||||
```typescript
|
||||
// 段 1: daemon
|
||||
if (feature('DAEMON') && args[0] === 'daemon') {
|
||||
await daemonMain(args.slice(1))
|
||||
}
|
||||
// 段 2: bg sessions
|
||||
if (feature('BG_SESSIONS') && ['ps','logs','attach','kill'].includes(args[0])) {
|
||||
// ...switch/case
|
||||
}
|
||||
```
|
||||
|
||||
**改后** (统一入口):
|
||||
```typescript
|
||||
// 统一 daemon 入口 — 合并 daemon supervisor + bg sessions
|
||||
if (
|
||||
(feature('DAEMON') || feature('BG_SESSIONS')) &&
|
||||
args[0] === 'daemon'
|
||||
) {
|
||||
profileCheckpoint('cli_daemon_path')
|
||||
const { enableConfigs } = await import('../utils/config.js')
|
||||
enableConfigs()
|
||||
const { initSinks } = await import('../utils/sinks.js')
|
||||
initSinks()
|
||||
const { daemonMain } = await import('../daemon/main.js')
|
||||
await daemonMain(args.slice(1))
|
||||
return
|
||||
}
|
||||
|
||||
// --bg 快捷方式 → daemon bg
|
||||
if (
|
||||
feature('BG_SESSIONS') &&
|
||||
(args.includes('--bg') || args.includes('--background'))
|
||||
) {
|
||||
profileCheckpoint('cli_daemon_path')
|
||||
const { enableConfigs } = await import('../utils/config.js')
|
||||
enableConfigs()
|
||||
const bg = await import('../cli/bg.js')
|
||||
await bg.handleBgStart(args.filter(a => a !== '--bg' && a !== '--background'))
|
||||
return
|
||||
}
|
||||
|
||||
// 向后兼容: ps/logs/attach/kill → daemon <sub> (deprecated)
|
||||
if (
|
||||
feature('BG_SESSIONS') &&
|
||||
['ps', 'logs', 'attach', 'kill'].includes(args[0] ?? '')
|
||||
) {
|
||||
const mapped = args[0] === 'ps' ? 'status' : args[0]
|
||||
console.error(`[deprecated] Use: claude daemon ${mapped} ${args.slice(1).join(' ')}`.trim())
|
||||
const { enableConfigs } = await import('../utils/config.js')
|
||||
enableConfigs()
|
||||
const { daemonMain } = await import('../daemon/main.js')
|
||||
await daemonMain([args[0]!, ...args.slice(1)])
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### 2. daemonMain 扩展 (`daemon/main.ts`)
|
||||
|
||||
```typescript
|
||||
export async function daemonMain(args: string[]): Promise<void> {
|
||||
const subcommand = args[0] || 'status'
|
||||
|
||||
switch (subcommand) {
|
||||
// --- Supervisor 管理 ---
|
||||
case 'start':
|
||||
await runSupervisor(args.slice(1))
|
||||
break
|
||||
case 'stop':
|
||||
await handleDaemonStop()
|
||||
break
|
||||
|
||||
// --- 会话管理 (委托给 bg.ts) ---
|
||||
case 'status':
|
||||
case 'ps':
|
||||
await showUnifiedStatus() // 新: daemon 状态 + 会话列表
|
||||
break
|
||||
case 'bg':
|
||||
const bg = await import('../cli/bg.js')
|
||||
await bg.handleBgStart(args.slice(1))
|
||||
break
|
||||
case 'attach':
|
||||
const bg2 = await import('../cli/bg.js')
|
||||
await bg2.attachHandler(args[1])
|
||||
break
|
||||
case 'logs':
|
||||
const bg3 = await import('../cli/bg.js')
|
||||
await bg3.logsHandler(args[1])
|
||||
break
|
||||
case 'kill':
|
||||
const bg4 = await import('../cli/bg.js')
|
||||
await bg4.killHandler(args[1])
|
||||
break
|
||||
|
||||
case '--help': case '-h': case 'help':
|
||||
printHelp()
|
||||
break
|
||||
default:
|
||||
console.error(`Unknown daemon subcommand: ${subcommand}`)
|
||||
printHelp()
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 统一状态面板 (`showUnifiedStatus`)
|
||||
|
||||
```typescript
|
||||
async function showUnifiedStatus(): Promise<void> {
|
||||
// 1. Daemon supervisor 状态
|
||||
const daemonResult = queryDaemonStatus()
|
||||
console.log('=== Daemon Supervisor ===')
|
||||
switch (daemonResult.status) {
|
||||
case 'running':
|
||||
console.log(` Status: running (PID: ${daemonResult.state!.pid})`)
|
||||
console.log(` Workers: ${daemonResult.state!.workerKinds.join(', ')}`)
|
||||
break
|
||||
case 'stopped':
|
||||
console.log(' Status: stopped')
|
||||
break
|
||||
case 'stale':
|
||||
console.log(' Status: stale (cleaned up)')
|
||||
break
|
||||
}
|
||||
|
||||
// 2. 后台会话列表
|
||||
console.log('\n=== Background Sessions ===')
|
||||
const bg = await import('../cli/bg.js')
|
||||
await bg.psHandler([])
|
||||
}
|
||||
```
|
||||
|
||||
### 4. REPL 斜杠命令注册
|
||||
|
||||
**`src/commands/daemon/index.ts`**:
|
||||
```typescript
|
||||
import type { Command } from '../../commands.js'
|
||||
import { feature } from 'bun:bundle'
|
||||
|
||||
const daemon = {
|
||||
type: 'local-jsx',
|
||||
name: 'daemon',
|
||||
description: 'Manage background sessions and daemon',
|
||||
argumentHint: '[status|start|stop|bg|attach|logs|kill]',
|
||||
isEnabled: () => {
|
||||
if (feature('DAEMON')) return true
|
||||
if (feature('BG_SESSIONS')) return true
|
||||
return false
|
||||
},
|
||||
load: () => import('./daemon.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default daemon
|
||||
```
|
||||
|
||||
**`src/commands/daemon/daemon.tsx`**:
|
||||
```typescript
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const parts = args.trim().split(/\s+/)
|
||||
const sub = parts[0] || 'status'
|
||||
|
||||
switch (sub) {
|
||||
case 'status':
|
||||
case 'ps':
|
||||
// 调用 showUnifiedStatus,捕获输出
|
||||
// 返回文本结果
|
||||
break
|
||||
case 'bg':
|
||||
// REPL 中启动后台会话
|
||||
break
|
||||
case 'start':
|
||||
case 'stop':
|
||||
case 'attach':
|
||||
case 'logs':
|
||||
case 'kill':
|
||||
// 委托给对应 handler
|
||||
break
|
||||
default:
|
||||
onDone(`Unknown: ${sub}. Use: status|start|stop|bg|attach|logs|kill`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`src/commands.ts`** 添加:
|
||||
```typescript
|
||||
// 条件导入
|
||||
const daemonCmd =
|
||||
feature('DAEMON') || feature('BG_SESSIONS')
|
||||
? require('./commands/daemon/index.js').default
|
||||
: null
|
||||
|
||||
// COMMANDS 数组中添加
|
||||
...(daemonCmd ? [daemonCmd] : []),
|
||||
```
|
||||
|
||||
### 5. 更新 help 文本 (`daemon/main.ts`)
|
||||
|
||||
```
|
||||
Claude Code Daemon — background process management
|
||||
|
||||
USAGE
|
||||
claude daemon [subcommand]
|
||||
|
||||
SUBCOMMANDS
|
||||
status Show daemon and session status (default)
|
||||
start Start the daemon supervisor
|
||||
stop Stop the daemon
|
||||
bg Start a background session
|
||||
attach Attach to a background session
|
||||
logs Show session logs
|
||||
kill Kill a session
|
||||
help Show this help
|
||||
|
||||
REPL
|
||||
/daemon [subcommand] Same commands available in interactive mode
|
||||
```
|
||||
|
||||
## 验证清单
|
||||
|
||||
- [ ] `claude daemon` (无参数) 显示统一状态面板
|
||||
- [ ] `claude daemon status` 显示 supervisor + 会话列表
|
||||
- [ ] `claude daemon start/stop` 与当前行为一致
|
||||
- [ ] `claude daemon bg` 启动后台会话 (调用 BgEngine)
|
||||
- [ ] `claude daemon attach/logs/kill <target>` 功能正常
|
||||
- [ ] `claude ps` 输出 deprecation 警告 + 正常工作
|
||||
- [ ] `claude logs/attach/kill` 同上
|
||||
- [ ] `claude --bg` 快捷方式正常
|
||||
- [ ] REPL 中 `/daemon` 可用,tab 补全显示
|
||||
- [ ] REPL 中 `/daemon status` 显示状态信息
|
||||
- [ ] tsc --noEmit 零错误
|
||||
- [ ] bun test 通过
|
||||
177
docs/task/task-015-job-command-hierarchy.md
Normal file
177
docs/task/task-015-job-command-hierarchy.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Task 015: /job 命令层级化
|
||||
|
||||
> 设计文档: [daemon-restructure-design.md](../features/daemon-restructure-design.md) § 三.2
|
||||
> 依赖: 无 (可与 Task 013 并行)
|
||||
> 分支: `feat/integrate-5-branches`
|
||||
|
||||
## 目标
|
||||
|
||||
将 `claude new/list/reply` 收归 `/job` 命名空间,实现 CLI + REPL 双注册。
|
||||
|
||||
## 背景
|
||||
|
||||
当前 `new`, `list`, `reply` 是顶级 CLI 命令 (`cli.tsx:250-261`),容易与其他命令冲突(特别是 `list` 这种通用词)。需要收归 `claude job <subcommand>` 并新增 REPL `/job` 入口。
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 新增
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/commands/job/index.ts` | `/job` REPL 斜杠命令注册 |
|
||||
| `src/commands/job/job.tsx` | `/job` 子命令路由 |
|
||||
|
||||
### 修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/entrypoints/cli.tsx` | 新增 `job` 快速路径 + 旧 `new/list/reply` deprecation 代理 |
|
||||
| `src/commands.ts` | 注册 `/job` 斜杠命令 |
|
||||
|
||||
### 不动
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/cli/handlers/templateJobs.ts` | 内部 handler 不变,只是被调用方式变了 |
|
||||
| `src/jobs/state.ts` | job 状态管理不变 |
|
||||
| `src/jobs/templates.ts` | 模板发现不变 |
|
||||
| `src/jobs/classifier.ts` | 任务分类器不变 |
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. CLI 快速路径 (`cli.tsx`)
|
||||
|
||||
**改后**:
|
||||
```typescript
|
||||
// 新: claude job <subcommand>
|
||||
if (
|
||||
feature('TEMPLATES') &&
|
||||
args[0] === 'job'
|
||||
) {
|
||||
profileCheckpoint('cli_templates_path')
|
||||
const { templatesMain } = await import('../cli/handlers/templateJobs.js')
|
||||
await templatesMain(args.slice(1))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// 向后兼容 (deprecated)
|
||||
if (
|
||||
feature('TEMPLATES') &&
|
||||
(args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')
|
||||
) {
|
||||
console.error(`[deprecated] Use: claude job ${args[0]} ${args.slice(1).join(' ')}`.trim())
|
||||
profileCheckpoint('cli_templates_path')
|
||||
const { templatesMain } = await import('../cli/handlers/templateJobs.js')
|
||||
await templatesMain(args)
|
||||
process.exit(0)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. templateJobs.ts 新增 status 子命令
|
||||
|
||||
在现有 `switch` 中增加:
|
||||
```typescript
|
||||
case 'status':
|
||||
handleStatus(args.slice(1))
|
||||
break
|
||||
```
|
||||
|
||||
```typescript
|
||||
function handleStatus(args: string[]): void {
|
||||
const jobId = args[0]
|
||||
if (!jobId) {
|
||||
console.error('Usage: claude job status <job-id>')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
const state = readJobState(jobId)
|
||||
if (!state) {
|
||||
console.error(`Job not found: ${jobId}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
console.log(`Job: ${state.jobId}`)
|
||||
console.log(` Template: ${state.templateName}`)
|
||||
console.log(` Status: ${state.status}`)
|
||||
console.log(` Created: ${state.createdAt}`)
|
||||
console.log(` Updated: ${state.updatedAt}`)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. REPL 斜杠命令
|
||||
|
||||
**`src/commands/job/index.ts`**:
|
||||
```typescript
|
||||
import type { Command } from '../../commands.js'
|
||||
import { feature } from 'bun:bundle'
|
||||
|
||||
const job = {
|
||||
type: 'local-jsx',
|
||||
name: 'job',
|
||||
description: 'Manage template jobs',
|
||||
argumentHint: '[list|new|reply|status]',
|
||||
isEnabled: () => {
|
||||
if (feature('TEMPLATES')) return true
|
||||
return false
|
||||
},
|
||||
load: () => import('./job.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default job
|
||||
```
|
||||
|
||||
**`src/commands/job/job.tsx`**:
|
||||
```typescript
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
_context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const parts = args.trim().split(/\s+/)
|
||||
const sub = parts[0] || 'list'
|
||||
|
||||
// 委托给 templatesMain
|
||||
const { templatesMain } = await import('../../cli/handlers/templateJobs.js')
|
||||
|
||||
// 捕获 console.log 输出作为结果返回给 REPL
|
||||
const lines: string[] = []
|
||||
const origLog = console.log
|
||||
const origError = console.error
|
||||
console.log = (...a: unknown[]) => lines.push(a.join(' '))
|
||||
console.error = (...a: unknown[]) => lines.push(a.join(' '))
|
||||
|
||||
try {
|
||||
await templatesMain([sub, ...parts.slice(1)])
|
||||
} finally {
|
||||
console.log = origLog
|
||||
console.error = origError
|
||||
}
|
||||
|
||||
onDone(lines.join('\n') || 'Done.', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
### 4. commands.ts 注册
|
||||
|
||||
```typescript
|
||||
const jobCmd = feature('TEMPLATES')
|
||||
? require('./commands/job/index.js').default
|
||||
: null
|
||||
|
||||
// COMMANDS 数组:
|
||||
...(jobCmd ? [jobCmd] : []),
|
||||
```
|
||||
|
||||
## 验证清单
|
||||
|
||||
- [ ] `claude job list` 列出模板
|
||||
- [ ] `claude job new <template>` 创建任务
|
||||
- [ ] `claude job reply <id> <text>` 回复任务
|
||||
- [ ] `claude job status <id>` 显示任务状态
|
||||
- [ ] `claude job` (无参数) 等同于 `claude job list`
|
||||
- [ ] `claude new/list/reply` 输出 deprecation 警告 + 正常工作
|
||||
- [ ] REPL 中 `/job` 可用
|
||||
- [ ] REPL 中 `/job list` 显示模板列表
|
||||
- [ ] tsc --noEmit 零错误
|
||||
- [ ] bun test 通过
|
||||
123
docs/task/task-016-backward-compat-tests.md
Normal file
123
docs/task/task-016-backward-compat-tests.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Task 016: 向后兼容 + 测试
|
||||
|
||||
> 设计文档: [daemon-restructure-design.md](../features/daemon-restructure-design.md) § 五
|
||||
> 依赖: Task 014, Task 015
|
||||
> 分支: `feat/integrate-5-branches`
|
||||
|
||||
## 目标
|
||||
|
||||
确保旧命令向后兼容 (deprecation 警告 + 正常代理),并为重构后的命令结构编写测试。
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 新增
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/daemon/__tests__/daemonMain.test.ts` | daemonMain 子命令路由测试 |
|
||||
| `src/cli/bg/__tests__/engine.test.ts` | BgEngine 选择逻辑测试 |
|
||||
| `src/cli/bg/__tests__/detached.test.ts` | DetachedEngine 启动/停止测试 |
|
||||
| `src/cli/bg/__tests__/tail.test.ts` | 日志 tail 功能测试 |
|
||||
|
||||
### 修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/entrypoints/cli.tsx` | 确认 deprecation 路径正确代理 |
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. 向后兼容矩阵
|
||||
|
||||
| 旧命令 | 新命令 | 处理方式 |
|
||||
|--------|--------|---------|
|
||||
| `claude ps` | `claude daemon status` | stderr 输出 `[deprecated] Use: claude daemon status`,然后执行 |
|
||||
| `claude logs <x>` | `claude daemon logs <x>` | 同上 |
|
||||
| `claude attach <x>` | `claude daemon attach <x>` | 同上 |
|
||||
| `claude kill <x>` | `claude daemon kill <x>` | 同上 |
|
||||
| `claude --bg` | `claude daemon bg` | 保留为快捷方式,**不** deprecate (太常用) |
|
||||
| `claude new <t>` | `claude job new <t>` | stderr deprecation + 执行 |
|
||||
| `claude list` | `claude job list` | stderr deprecation + 执行 |
|
||||
| `claude reply <id>` | `claude job reply <id>` | stderr deprecation + 执行 |
|
||||
|
||||
**关键**: deprecation 输出到 stderr 而非 stdout,不影响脚本管道。
|
||||
|
||||
### 2. 测试计划
|
||||
|
||||
#### 2.1 daemonMain 路由测试
|
||||
|
||||
```typescript
|
||||
describe('daemonMain', () => {
|
||||
test('无参数默认 status', async () => { ... })
|
||||
test('start 调用 runSupervisor', async () => { ... })
|
||||
test('stop 调用 handleDaemonStop', async () => { ... })
|
||||
test('bg 委托给 bg.handleBgStart', async () => { ... })
|
||||
test('attach 委托给 bg.attachHandler', async () => { ... })
|
||||
test('logs 委托给 bg.logsHandler', async () => { ... })
|
||||
test('kill 委托给 bg.killHandler', async () => { ... })
|
||||
test('未知子命令设置 exitCode=1', async () => { ... })
|
||||
})
|
||||
```
|
||||
|
||||
#### 2.2 引擎选择测试
|
||||
|
||||
```typescript
|
||||
describe('selectEngine', () => {
|
||||
test('win32 返回 DetachedEngine', async () => { ... })
|
||||
test('darwin + tmux 可用返回 TmuxEngine', async () => { ... })
|
||||
test('darwin + tmux 不可用返回 DetachedEngine', async () => { ... })
|
||||
test('linux + tmux 可用返回 TmuxEngine', async () => { ... })
|
||||
})
|
||||
```
|
||||
|
||||
#### 2.3 DetachedEngine 测试
|
||||
|
||||
```typescript
|
||||
describe('DetachedEngine', () => {
|
||||
test('available 始终返回 true', async () => { ... })
|
||||
test('start 创建 detached 子进程并写入日志', async () => { ... })
|
||||
test('start 返回的 PID 文件存在', async () => { ... })
|
||||
})
|
||||
```
|
||||
|
||||
#### 2.4 Tail 测试
|
||||
|
||||
```typescript
|
||||
describe('tailLog', () => {
|
||||
test('输出已有日志内容', async () => { ... })
|
||||
test('追加内容时实时输出', async () => { ... })
|
||||
test('SIGINT 退出 tail', async () => { ... })
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 集成验证脚本
|
||||
|
||||
可选: 在 `scripts/` 下添加一个手动验证脚本:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/verify-daemon-restructure.sh
|
||||
echo "=== 1. claude daemon status ==="
|
||||
bun run dev -- daemon status
|
||||
|
||||
echo "=== 2. claude daemon bg (should start) ==="
|
||||
bun run dev -- daemon bg --help
|
||||
|
||||
echo "=== 3. claude ps (deprecated) ==="
|
||||
bun run dev -- ps 2>&1 | head -1
|
||||
|
||||
echo "=== 4. claude job list ==="
|
||||
bun run dev -- job list
|
||||
|
||||
echo "=== 5. claude list (deprecated) ==="
|
||||
bun run dev -- list 2>&1 | head -1
|
||||
```
|
||||
|
||||
## 验证清单
|
||||
|
||||
- [ ] 旧命令全部正常工作 (仅多一行 stderr 警告)
|
||||
- [ ] `--bg` 保持无警告
|
||||
- [ ] 所有新增测试通过
|
||||
- [ ] 现有 2695 个测试无回归
|
||||
- [ ] tsc --noEmit 零错误
|
||||
- [ ] 手动在 Windows + macOS/Linux 上验证关键路径
|
||||
88
docs/test-plans/openclaw-autonomy-baseline.md
Normal file
88
docs/test-plans/openclaw-autonomy-baseline.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# OpenClaw Autonomy Baseline Test Spec
|
||||
|
||||
## Purpose
|
||||
|
||||
This test spec locks the current behavior of the existing trigger and context layers before any formal autonomy-subsystem implementation begins.
|
||||
|
||||
At this stage, production code is read-only. Only test files, fixtures, and planning documents may change.
|
||||
|
||||
## Goal
|
||||
|
||||
Establish a stable baseline around the parts of `Claude-code-bast` that later autonomy work is most likely to touch:
|
||||
|
||||
- proactive state handling
|
||||
- cron task storage semantics
|
||||
- cron scheduler helper semantics
|
||||
- user-context cache and `CLAUDE.md` injection behavior
|
||||
|
||||
## Out of Scope for This Baseline Round
|
||||
|
||||
- New authority behavior (`AGENTS.md` / `HEARTBEAT.md`)
|
||||
- New detached-run ledger behavior
|
||||
- New flow behavior
|
||||
- UI redesign
|
||||
|
||||
## Files Under Baseline Protection
|
||||
|
||||
- `src/proactive/index.ts`
|
||||
- `src/utils/cronTasks.ts`
|
||||
- `src/utils/cronScheduler.ts`
|
||||
- `src/context.ts`
|
||||
|
||||
## Test Files Added In This Round
|
||||
|
||||
- `src/proactive/__tests__/state.baseline.test.ts`
|
||||
- `src/commands/__tests__/proactive.baseline.test.ts`
|
||||
- `src/utils/__tests__/cronTasks.baseline.test.ts`
|
||||
- `src/utils/__tests__/cronScheduler.baseline.test.ts`
|
||||
- `src/__tests__/context.baseline.test.ts`
|
||||
|
||||
## Baseline Assertions
|
||||
|
||||
### Proactive state
|
||||
|
||||
1. Activating proactive mode sets active state and activation source.
|
||||
2. Pausing proactive mode suppresses `shouldTick()` and clears `nextTickAt`.
|
||||
3. Blocking context suppresses `shouldTick()` and clears `nextTickAt`.
|
||||
4. Subscribers are notified on state transitions.
|
||||
5. The `/proactive` command enables proactive mode and emits the expected hidden reminder.
|
||||
6. The `/proactive` command disables proactive mode on the second invocation.
|
||||
|
||||
### Cron task storage
|
||||
|
||||
1. Session-only cron tasks remain in memory only.
|
||||
2. Durable cron tasks are persisted to `.claude/scheduled_tasks.json`.
|
||||
3. Daemon-style `dir`-scoped reads exclude session-only cron tasks.
|
||||
4. `removeCronTasks()` without `dir` can remove session-only tasks.
|
||||
5. `removeCronTasks()` with `dir` does not mutate session-only task storage.
|
||||
|
||||
### Cron scheduler helpers
|
||||
|
||||
1. `isRecurringTaskAged()` preserves current aging semantics.
|
||||
2. `buildMissedTaskNotification()` preserves the current AskUserQuestion safety wording.
|
||||
3. `buildMissedTaskNotification()` preserves code-fence hardening for prompt bodies that contain backticks.
|
||||
|
||||
### User context caching
|
||||
|
||||
1. `getUserContext()` includes `currentDate`.
|
||||
2. `getUserContext()` includes mocked `claudeMd` content when memory loading is enabled.
|
||||
3. `CLAUDE_CODE_DISABLE_CLAUDE_MDS` suppresses `claudeMd`.
|
||||
4. `setSystemPromptInjection()` clears the memoized user-context cache.
|
||||
5. `getSystemContext()` reflects the injection after cache invalidation.
|
||||
|
||||
## Remaining Baseline Gaps
|
||||
|
||||
The following areas are intentionally deferred because they require higher-cost harnessing and should still avoid production-code changes:
|
||||
|
||||
1. `useScheduledTasks.ts` hook-level runtime behavior
|
||||
2. `src/cli/print.ts` full headless scheduler loop behavior
|
||||
3. `useProactive.ts` hook timer behavior
|
||||
4. end-to-end queue interaction between proactive ticks and `SleepTool`
|
||||
|
||||
## Acceptance
|
||||
|
||||
This baseline round is complete when:
|
||||
|
||||
1. The four new test files pass.
|
||||
2. No production source files are modified.
|
||||
3. The tests are stable enough to serve as a pre-implementation guardrail.
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "1.3.7",
|
||||
"version": "1.4.1",
|
||||
"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>",
|
||||
@@ -31,7 +31,8 @@
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"packages/@ant/*"
|
||||
"packages/@ant/*",
|
||||
"packages/@anthropic-ai/*"
|
||||
],
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -53,18 +54,19 @@
|
||||
"test": "bun test",
|
||||
"check:unused": "knip-bun",
|
||||
"health": "bun run scripts/health-check.ts",
|
||||
"postinstall": "node scripts/postinstall.cjs && node scripts/setup-chrome-mcp.mjs",
|
||||
"postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs 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",
|
||||
"@claude-code-best/mcp-chrome-bridge": "^2.0.8",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.3.0",
|
||||
"@ant/model-provider": "workspace:*",
|
||||
"@ant/claude-for-chrome-mcp": "workspace:*",
|
||||
"@ant/computer-use-input": "workspace:*",
|
||||
"@ant/computer-use-mcp": "workspace:*",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
18
packages/@ant/model-provider/package.json
Normal file
18
packages/@ant/model-provider/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@ant/model-provider",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types/index.ts",
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./client": "./src/client/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.80.0",
|
||||
"openai": "^6.33.0"
|
||||
}
|
||||
}
|
||||
27
packages/@ant/model-provider/src/client/index.ts
Normal file
27
packages/@ant/model-provider/src/client/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ClientFactories } from './types.js'
|
||||
|
||||
let registeredFactories: ClientFactories | null = null
|
||||
|
||||
/**
|
||||
* Register client factories from the main project.
|
||||
* Call this during application initialization.
|
||||
*/
|
||||
export function registerClientFactories(factories: ClientFactories): void {
|
||||
registeredFactories = factories
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered client factories.
|
||||
* Throws if not registered (fail-fast).
|
||||
*/
|
||||
export function getClientFactories(): ClientFactories {
|
||||
if (!registeredFactories) {
|
||||
throw new Error(
|
||||
'Client factories not registered. ' +
|
||||
'Call registerClientFactories() during app initialization.',
|
||||
)
|
||||
}
|
||||
return registeredFactories
|
||||
}
|
||||
|
||||
export type { ClientFactories }
|
||||
35
packages/@ant/model-provider/src/client/types.ts
Normal file
35
packages/@ant/model-provider/src/client/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Client factory interfaces.
|
||||
* Authentication is handled externally — main project provides factory implementations.
|
||||
*/
|
||||
export interface ClientFactories {
|
||||
/** Get Anthropic client (1st party, Bedrock, Foundry, Vertex) */
|
||||
getAnthropicClient: (params: {
|
||||
model?: string
|
||||
maxRetries: number
|
||||
fetchOverride?: unknown
|
||||
source?: string
|
||||
}) => Promise<unknown>
|
||||
|
||||
/** Get OpenAI-compatible client */
|
||||
getOpenAIClient: (params: {
|
||||
maxRetries: number
|
||||
fetchOverride?: unknown
|
||||
source?: string
|
||||
}) => unknown
|
||||
|
||||
/** Stream Gemini generate content */
|
||||
streamGeminiGenerateContent: (params: {
|
||||
model: string
|
||||
signal?: AbortSignal
|
||||
fetchOverride?: unknown
|
||||
body: Record<string, unknown>
|
||||
}) => AsyncIterable<unknown>
|
||||
|
||||
/** Get Grok client (OpenAI-compatible) */
|
||||
getGrokClient: (params: {
|
||||
maxRetries: number
|
||||
fetchOverride?: unknown
|
||||
source?: string
|
||||
}) => unknown
|
||||
}
|
||||
238
packages/@ant/model-provider/src/errorUtils.ts
Normal file
238
packages/@ant/model-provider/src/errorUtils.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { APIError } from '@anthropic-ai/sdk'
|
||||
|
||||
// SSL/TLS error codes from OpenSSL (used by both Node.js and Bun)
|
||||
// See: https://www.openssl.org/docs/man3.1/man3/X509_STORE_CTX_get_error.html
|
||||
const SSL_ERROR_CODES = new Set([
|
||||
// Certificate verification errors
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
'UNABLE_TO_GET_ISSUER_CERT',
|
||||
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||
'CERT_SIGNATURE_FAILURE',
|
||||
'CERT_NOT_YET_VALID',
|
||||
'CERT_HAS_EXPIRED',
|
||||
'CERT_REVOKED',
|
||||
'CERT_REJECTED',
|
||||
'CERT_UNTRUSTED',
|
||||
// Self-signed certificate errors
|
||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||
// Chain errors
|
||||
'CERT_CHAIN_TOO_LONG',
|
||||
'PATH_LENGTH_EXCEEDED',
|
||||
// Hostname/altname errors
|
||||
'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||
'HOSTNAME_MISMATCH',
|
||||
// TLS handshake errors
|
||||
'ERR_TLS_HANDSHAKE_TIMEOUT',
|
||||
'ERR_SSL_WRONG_VERSION_NUMBER',
|
||||
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
|
||||
])
|
||||
|
||||
export type ConnectionErrorDetails = {
|
||||
code: string
|
||||
message: string
|
||||
isSSLError: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts connection error details from the error cause chain.
|
||||
* The Anthropic SDK wraps underlying errors in the `cause` property.
|
||||
* This function walks the cause chain to find the root error code/message.
|
||||
*/
|
||||
export function extractConnectionErrorDetails(
|
||||
error: unknown,
|
||||
): ConnectionErrorDetails | null {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Walk the cause chain to find the root error with a code
|
||||
let current: unknown = error
|
||||
const maxDepth = 5 // Prevent infinite loops
|
||||
let depth = 0
|
||||
|
||||
while (current && depth < maxDepth) {
|
||||
if (
|
||||
current instanceof Error &&
|
||||
'code' in current &&
|
||||
typeof current.code === 'string'
|
||||
) {
|
||||
const code = current.code
|
||||
const isSSLError = SSL_ERROR_CODES.has(code)
|
||||
return {
|
||||
code,
|
||||
message: current.message,
|
||||
isSSLError,
|
||||
}
|
||||
}
|
||||
|
||||
// Move to the next cause in the chain
|
||||
if (
|
||||
current instanceof Error &&
|
||||
'cause' in current &&
|
||||
current.cause !== current
|
||||
) {
|
||||
current = current.cause
|
||||
depth++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an actionable hint for SSL/TLS errors, intended for contexts outside
|
||||
* the main API client (OAuth token exchange, preflight connectivity checks)
|
||||
* where `formatAPIError` doesn't apply.
|
||||
*/
|
||||
export function getSSLErrorHint(error: unknown): string | null {
|
||||
const details = extractConnectionErrorDetails(error)
|
||||
if (!details?.isSSLError) {
|
||||
return null
|
||||
}
|
||||
return `SSL certificate error (${details.code}). If you are behind a corporate proxy or TLS-intercepting firewall, set NODE_EXTRA_CA_CERTS to your CA bundle path, or ask IT to allowlist *.anthropic.com. Run /doctor for details.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips HTML content (e.g., CloudFlare error pages) from a message string,
|
||||
* returning a user-friendly title or empty string if HTML is detected.
|
||||
* Returns the original message unchanged if no HTML is found.
|
||||
*/
|
||||
function sanitizeMessageHTML(message: string): string {
|
||||
if (message.includes('<!DOCTYPE html') || message.includes('<html')) {
|
||||
const titleMatch = message.match(/<title>([^<]+)<\/title>/)
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
return titleMatch[1].trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if an error message contains HTML content (e.g., CloudFlare error pages)
|
||||
* and returns a user-friendly message instead
|
||||
*/
|
||||
export function sanitizeAPIError(apiError: APIError): string {
|
||||
const message = apiError.message
|
||||
if (!message) {
|
||||
return ''
|
||||
}
|
||||
return sanitizeMessageHTML(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shapes of deserialized API errors from session JSONL.
|
||||
*/
|
||||
type NestedAPIError = {
|
||||
error?: {
|
||||
message?: string
|
||||
error?: { message?: string }
|
||||
}
|
||||
}
|
||||
|
||||
function hasNestedError(value: unknown): value is NestedAPIError {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'error' in value &&
|
||||
typeof value.error === 'object' &&
|
||||
value.error !== null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a human-readable message from a deserialized API error that lacks
|
||||
* a top-level `.message`.
|
||||
*/
|
||||
function extractNestedErrorMessage(error: APIError): string | null {
|
||||
if (!hasNestedError(error)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const narrowed: NestedAPIError = error
|
||||
const nested = narrowed.error
|
||||
|
||||
// Standard Anthropic API shape: { error: { error: { message } } }
|
||||
const deepMsg = nested?.error?.message
|
||||
if (typeof deepMsg === 'string' && deepMsg.length > 0) {
|
||||
const sanitized = sanitizeMessageHTML(deepMsg)
|
||||
if (sanitized.length > 0) {
|
||||
return sanitized
|
||||
}
|
||||
}
|
||||
|
||||
// Bedrock shape: { error: { message } }
|
||||
const msg = nested?.message
|
||||
if (typeof msg === 'string' && msg.length > 0) {
|
||||
const sanitized = sanitizeMessageHTML(msg)
|
||||
if (sanitized.length > 0) {
|
||||
return sanitized
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatAPIError(error: APIError): string {
|
||||
// Extract connection error details from the cause chain
|
||||
const connectionDetails = extractConnectionErrorDetails(error)
|
||||
|
||||
if (connectionDetails) {
|
||||
const { code, isSSLError } = connectionDetails
|
||||
|
||||
// Handle timeout errors
|
||||
if (code === 'ETIMEDOUT') {
|
||||
return 'Request timed out. Check your internet connection and proxy settings'
|
||||
}
|
||||
|
||||
// Handle SSL/TLS errors with specific messages
|
||||
if (isSSLError) {
|
||||
switch (code) {
|
||||
case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
|
||||
case 'UNABLE_TO_GET_ISSUER_CERT':
|
||||
case 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY':
|
||||
return 'Unable to connect to API: SSL certificate verification failed. Check your proxy or corporate SSL certificates'
|
||||
case 'CERT_HAS_EXPIRED':
|
||||
return 'Unable to connect to API: SSL certificate has expired'
|
||||
case 'CERT_REVOKED':
|
||||
return 'Unable to connect to API: SSL certificate has been revoked'
|
||||
case 'DEPTH_ZERO_SELF_SIGNED_CERT':
|
||||
case 'SELF_SIGNED_CERT_IN_CHAIN':
|
||||
return 'Unable to connect to API: Self-signed certificate detected. Check your proxy or corporate SSL certificates'
|
||||
case 'ERR_TLS_CERT_ALTNAME_INVALID':
|
||||
case 'HOSTNAME_MISMATCH':
|
||||
return 'Unable to connect to API: SSL certificate hostname mismatch'
|
||||
case 'CERT_NOT_YET_VALID':
|
||||
return 'Unable to connect to API: SSL certificate is not yet valid'
|
||||
default:
|
||||
return `Unable to connect to API: SSL error (${code})`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error.message === 'Connection error.') {
|
||||
// If we have a code but it's not SSL, include it for debugging
|
||||
if (connectionDetails?.code) {
|
||||
return `Unable to connect to API (${connectionDetails.code})`
|
||||
}
|
||||
return 'Unable to connect to API. Check your internet connection'
|
||||
}
|
||||
|
||||
// Guard: when deserialized from JSONL (e.g. --resume), the error object may
|
||||
// be a plain object without a `.message` property.
|
||||
if (!error.message) {
|
||||
return (
|
||||
extractNestedErrorMessage(error) ??
|
||||
`API error (status ${error.status ?? 'unknown'})`
|
||||
)
|
||||
}
|
||||
|
||||
const sanitizedMessage = sanitizeAPIError(error)
|
||||
// Use sanitized message if it's different from the original (i.e., HTML was sanitized)
|
||||
return sanitizedMessage !== error.message && sanitizedMessage.length > 0
|
||||
? sanitizedMessage
|
||||
: error.message
|
||||
}
|
||||
27
packages/@ant/model-provider/src/hooks/index.ts
Normal file
27
packages/@ant/model-provider/src/hooks/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ModelProviderHooks } from './types.js'
|
||||
|
||||
let registeredHooks: ModelProviderHooks | null = null
|
||||
|
||||
/**
|
||||
* Register hooks from the main project.
|
||||
* Call this during application initialization.
|
||||
*/
|
||||
export function registerHooks(hooks: ModelProviderHooks): void {
|
||||
registeredHooks = hooks
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered hooks.
|
||||
* Throws if hooks not registered (fail-fast).
|
||||
*/
|
||||
export function getHooks(): ModelProviderHooks {
|
||||
if (!registeredHooks) {
|
||||
throw new Error(
|
||||
'ModelProvider hooks not registered. ' +
|
||||
'Call registerHooks() during app initialization.',
|
||||
)
|
||||
}
|
||||
return registeredHooks
|
||||
}
|
||||
|
||||
export type { ModelProviderHooks }
|
||||
48
packages/@ant/model-provider/src/hooks/types.ts
Normal file
48
packages/@ant/model-provider/src/hooks/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Hooks for dependency injection.
|
||||
* Main project provides implementations; model-provider calls them.
|
||||
*
|
||||
* This decouples the model-provider from main project specifics like
|
||||
* analytics, cost tracking, feature flags, etc.
|
||||
*/
|
||||
export interface ModelProviderHooks {
|
||||
/** Log an analytics event (replaces direct logEvent calls) */
|
||||
logEvent: (eventName: string, metadata?: Record<string, unknown>) => void
|
||||
|
||||
/** Report API cost after each response */
|
||||
reportCost: (params: {
|
||||
costUSD: number
|
||||
usage: Record<string, unknown>
|
||||
model: string
|
||||
}) => void
|
||||
|
||||
/** Get tool permission context */
|
||||
getToolPermissionContext?: () => Promise<Record<string, unknown>>
|
||||
|
||||
/** Debug logging */
|
||||
logForDebugging: (msg: string, opts?: { level?: string }) => void
|
||||
|
||||
/** Error logging */
|
||||
logError: (error: Error) => void
|
||||
|
||||
/** Get feature flag value */
|
||||
getFeatureFlag?: (flagName: string) => unknown
|
||||
|
||||
/** Get session ID */
|
||||
getSessionId: () => string
|
||||
|
||||
/** Add a notification */
|
||||
addNotification?: (notification: Record<string, unknown>) => void
|
||||
|
||||
/** Get API provider name */
|
||||
getAPIProvider: () => string
|
||||
|
||||
/** Get user ID */
|
||||
getOrCreateUserID: () => string
|
||||
|
||||
/** Check if non-interactive session */
|
||||
isNonInteractiveSession: () => boolean
|
||||
|
||||
/** Get OAuth account info */
|
||||
getOauthAccountInfo?: () => Record<string, unknown> | undefined
|
||||
}
|
||||
63
packages/@ant/model-provider/src/index.ts
Normal file
63
packages/@ant/model-provider/src/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// @ant/model-provider
|
||||
// Model provider abstraction layer for Claude Code
|
||||
//
|
||||
// This package owns the model calling logic and provides:
|
||||
// - Core query functions (queryModelWithStreaming, etc.)
|
||||
// - Provider implementations (Anthropic, OpenAI, Gemini, Grok)
|
||||
// - Type definitions (Message, Tool, Usage, etc.)
|
||||
// - Dependency injection hooks (analytics, cost tracking, etc.)
|
||||
//
|
||||
// Initialization:
|
||||
// registerClientFactories({ ... }) // inject auth clients
|
||||
// registerHooks({ ... }) // inject analytics/cost/logging
|
||||
|
||||
// Hooks (dependency injection)
|
||||
export { registerHooks, getHooks } from './hooks/index.js'
|
||||
export type { ModelProviderHooks } from './hooks/types.js'
|
||||
|
||||
// Client factories
|
||||
export { registerClientFactories, getClientFactories } from './client/index.js'
|
||||
export type { ClientFactories } from './client/types.js'
|
||||
|
||||
// Types
|
||||
export * from './types/index.js'
|
||||
|
||||
// Provider model mappings
|
||||
export { resolveOpenAIModel } from './providers/openai/modelMapping.js'
|
||||
export { resolveGrokModel } from './providers/grok/modelMapping.js'
|
||||
export { resolveGeminiModel } from './providers/gemini/modelMapping.js'
|
||||
|
||||
// Gemini provider utilities
|
||||
export { anthropicMessagesToGemini } from './providers/gemini/convertMessages.js'
|
||||
export { anthropicToolsToGemini, anthropicToolChoiceToGemini } from './providers/gemini/convertTools.js'
|
||||
export { adaptGeminiStreamToAnthropic } from './providers/gemini/streamAdapter.js'
|
||||
export {
|
||||
GEMINI_THOUGHT_SIGNATURE_FIELD,
|
||||
type GeminiContent,
|
||||
type GeminiGenerateContentRequest,
|
||||
type GeminiPart,
|
||||
type GeminiStreamChunk,
|
||||
type GeminiTool,
|
||||
type GeminiFunctionCallingConfig,
|
||||
type GeminiFunctionDeclaration,
|
||||
type GeminiFunctionCall,
|
||||
type GeminiFunctionResponse,
|
||||
type GeminiInlineData,
|
||||
type GeminiUsageMetadata,
|
||||
type GeminiCandidate,
|
||||
} from './providers/gemini/types.js'
|
||||
|
||||
// Error utilities
|
||||
export {
|
||||
formatAPIError,
|
||||
extractConnectionErrorDetails,
|
||||
sanitizeAPIError,
|
||||
getSSLErrorHint,
|
||||
type ConnectionErrorDetails,
|
||||
} from './errorUtils.js'
|
||||
|
||||
// Shared OpenAI conversion utilities
|
||||
export { anthropicMessagesToOpenAI } from './shared/openaiConvertMessages.js'
|
||||
export type { ConvertMessagesOptions } from './shared/openaiConvertMessages.js'
|
||||
export { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from './shared/openaiConvertTools.js'
|
||||
export { adaptOpenAIStreamToAnthropic } from './shared/openaiStreamAdapter.js'
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
UserMessage,
|
||||
} from '../../../../types/message.js'
|
||||
} from '../../../types/message.js'
|
||||
import { anthropicMessagesToGemini } from '../convertMessages.js'
|
||||
|
||||
function makeUserMsg(content: string | any[]): UserMessage {
|
||||
@@ -2,9 +2,8 @@ import type {
|
||||
BetaToolResultBlockParam,
|
||||
BetaToolUseBlock,
|
||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type { AssistantMessage, UserMessage } from '../../../types/message.js'
|
||||
import { safeParseJSON } from '../../../utils/json.js'
|
||||
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
||||
import type { AssistantMessage, UserMessage } from '../../types/message.js'
|
||||
import type { SystemPrompt } from '../../types/systemPrompt.js'
|
||||
import {
|
||||
GEMINI_THOUGHT_SIGNATURE_FIELD,
|
||||
type GeminiContent,
|
||||
@@ -12,6 +11,16 @@ import {
|
||||
type GeminiPart,
|
||||
} from './types.js'
|
||||
|
||||
// Simple JSON parse utility (replaces safeParseJSON from main project)
|
||||
function safeParseJSON(json: string | null | undefined): unknown {
|
||||
if (!json) return null
|
||||
try {
|
||||
return JSON.parse(json)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function anthropicMessagesToGemini(
|
||||
messages: (UserMessage | AssistantMessage)[],
|
||||
systemPrompt: SystemPrompt,
|
||||
@@ -113,7 +122,7 @@ function convertUserContentBlockToGeminiParts(
|
||||
]
|
||||
}
|
||||
|
||||
// 将 Anthropic image 块转换为 Gemini inlineData
|
||||
// Convert Anthropic image blocks to Gemini inlineData
|
||||
if (block.type === 'image') {
|
||||
const source = block.source as Record<string, unknown> | undefined
|
||||
if (source?.type === 'base64' && typeof source.data === 'string') {
|
||||
@@ -127,7 +136,7 @@ function convertUserContentBlockToGeminiParts(
|
||||
},
|
||||
]
|
||||
}
|
||||
// url 类型的图片,Gemini 不直接支持,转为文本描述
|
||||
// URL images not directly supported by Gemini, convert to text description
|
||||
if (source?.type === 'url' && typeof source.url === 'string') {
|
||||
return createTextGeminiParts(`[image: ${source.url}]`)
|
||||
}
|
||||
@@ -17,14 +17,12 @@ export function resolveGeminiModel(anthropicModel: string): string {
|
||||
return cleanModel
|
||||
}
|
||||
|
||||
// First, try Gemini-specific DEFAULT variables (separated from Anthropic)
|
||||
const geminiEnvVar = `GEMINI_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const geminiModel = process.env[geminiEnvVar]
|
||||
if (geminiModel) {
|
||||
return geminiModel
|
||||
}
|
||||
|
||||
// Fallback to Anthropic DEFAULT variables for backward compatibility
|
||||
const sharedEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const resolvedModel = process.env[sharedEnvVar]
|
||||
if (resolvedModel) {
|
||||
@@ -2,8 +2,7 @@
|
||||
* Default mapping from Anthropic model names to Grok model names.
|
||||
*
|
||||
* Users can override per-family via GROK_DEFAULT_{FAMILY}_MODEL env vars,
|
||||
* or override the entire mapping via GROK_MODEL_MAP env var (JSON string):
|
||||
* GROK_MODEL_MAP='{"opus":"grok-4","sonnet":"grok-3","haiku":"grok-3-mini-fast"}'
|
||||
* or override the entire mapping via GROK_MODEL_MAP env var (JSON string).
|
||||
*/
|
||||
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
||||
'claude-sonnet-4-20250514': 'grok-3-mini-fast',
|
||||
@@ -19,9 +18,6 @@ const DEFAULT_MODEL_MAP: Record<string, string> = {
|
||||
'claude-3-5-sonnet-20241022': 'grok-3-mini-fast',
|
||||
}
|
||||
|
||||
/**
|
||||
* Family-level mapping defaults (used by GROK_MODEL_MAP).
|
||||
*/
|
||||
const DEFAULT_FAMILY_MAP: Record<string, string> = {
|
||||
opus: 'grok-4.20-reasoning',
|
||||
sonnet: 'grok-3-mini-fast',
|
||||
@@ -35,10 +31,6 @@ function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse user-provided model map from GROK_MODEL_MAP env var.
|
||||
* Accepts JSON like: {"opus":"grok-4","sonnet":"grok-3","haiku":"grok-3-mini-fast"}
|
||||
*/
|
||||
function getUserModelMap(): Record<string, string> | null {
|
||||
const raw = process.env.GROK_MODEL_MAP
|
||||
if (!raw) return null
|
||||
@@ -55,18 +47,8 @@ function getUserModelMap(): Record<string, string> | null {
|
||||
|
||||
/**
|
||||
* Resolve the Grok model name for a given Anthropic model.
|
||||
*
|
||||
* Priority:
|
||||
* 1. GROK_MODEL env var (override all)
|
||||
* 2. GROK_MODEL_MAP env var — JSON family map (e.g. {"opus":"grok-4"})
|
||||
* 3. GROK_DEFAULT_{FAMILY}_MODEL env var (e.g. GROK_DEFAULT_OPUS_MODEL)
|
||||
* 4. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (backward compat)
|
||||
* 5. DEFAULT_MODEL_MAP lookup
|
||||
* 6. Family-level default
|
||||
* 7. Pass through original model name
|
||||
*/
|
||||
export function resolveGrokModel(anthropicModel: string): string {
|
||||
// 1. Global override
|
||||
if (process.env.GROK_MODEL) {
|
||||
return process.env.GROK_MODEL
|
||||
}
|
||||
@@ -74,34 +56,28 @@ export function resolveGrokModel(anthropicModel: string): string {
|
||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||
const family = getModelFamily(cleanModel)
|
||||
|
||||
// 2. User-provided model map
|
||||
const userMap = getUserModelMap()
|
||||
if (userMap && family && userMap[family]) {
|
||||
return userMap[family]
|
||||
}
|
||||
|
||||
if (family) {
|
||||
// 3. Grok-specific family override
|
||||
const grokEnvVar = `GROK_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const grokOverride = process.env[grokEnvVar]
|
||||
if (grokOverride) return grokOverride
|
||||
|
||||
// 4. Anthropic env var (backward compat)
|
||||
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const anthropicOverride = process.env[anthropicEnvVar]
|
||||
if (anthropicOverride) return anthropicOverride
|
||||
}
|
||||
|
||||
// 5. Exact model name lookup
|
||||
if (DEFAULT_MODEL_MAP[cleanModel]) {
|
||||
return DEFAULT_MODEL_MAP[cleanModel]
|
||||
}
|
||||
|
||||
// 6. Family-level default
|
||||
if (family && DEFAULT_FAMILY_MAP[family]) {
|
||||
return DEFAULT_FAMILY_MAP[family]
|
||||
}
|
||||
|
||||
// 7. Pass through
|
||||
return cleanModel
|
||||
}
|
||||
@@ -16,9 +16,6 @@ const DEFAULT_MODEL_MAP: Record<string, string> = {
|
||||
'claude-3-5-sonnet-20241022': 'gpt-4o',
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the model family (haiku / sonnet / opus) from an Anthropic model ID.
|
||||
*/
|
||||
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||
if (/haiku/i.test(model)) return 'haiku'
|
||||
if (/opus/i.test(model)) return 'opus'
|
||||
@@ -37,23 +34,18 @@ function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||
* 5. Pass through original model name
|
||||
*/
|
||||
export function resolveOpenAIModel(anthropicModel: string): string {
|
||||
// Highest priority: explicit override
|
||||
if (process.env.OPENAI_MODEL) {
|
||||
return process.env.OPENAI_MODEL
|
||||
}
|
||||
|
||||
// Strip [1m] suffix if present (Claude-specific modifier)
|
||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||
|
||||
// Check family-specific overrides
|
||||
const family = getModelFamily(cleanModel)
|
||||
if (family) {
|
||||
// OpenAI-specific family override (preferred for openai provider)
|
||||
const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const openaiOverride = process.env[openaiEnvVar]
|
||||
if (openaiOverride) return openaiOverride
|
||||
|
||||
// Anthropic env var (backward compatibility)
|
||||
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const anthropicOverride = process.env[anthropicEnvVar]
|
||||
if (anthropicOverride) return anthropicOverride
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { anthropicMessagesToOpenAI } from '../convertMessages.js'
|
||||
import type { UserMessage, AssistantMessage } from '../../../../types/message.js'
|
||||
import { anthropicMessagesToOpenAI } from '../openaiConvertMessages.js'
|
||||
import type { UserMessage, AssistantMessage } from '../../types/message.js'
|
||||
|
||||
// Helpers to create internal-format messages
|
||||
function makeUserMsg(content: string | any[]): UserMessage {
|
||||
@@ -396,10 +396,6 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
{ enableThinking: true },
|
||||
)
|
||||
|
||||
// All 3 assistant messages are in the current turn (after last user msg is the last tool_result,
|
||||
// but the "last user message" boundary logic finds the last user-typed message).
|
||||
// Actually, tool_result messages are also UserMessage type, so the last user message
|
||||
// is the one with tool_result for toolu_002. All assistant messages after that should have reasoning.
|
||||
const assistants = result.filter(m => m.role === 'assistant')
|
||||
expect(assistants.length).toBe(3)
|
||||
// All iterations within the same turn preserve reasoning
|
||||
@@ -435,6 +431,54 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
expect(assistant.reasoning_content).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── fix: reorder tool and user messages for OpenAI API compatibility (#168) ──
|
||||
|
||||
test('tool messages come BEFORE user text when mixed in same turn', () => {
|
||||
// OpenAI requires: assistant(tool_calls) → tool → user
|
||||
// Bug: previously user text was emitted before tool messages
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg('run ls'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'tool_use' as const, id: 'toolu_1', name: 'bash', input: { command: 'ls' } },
|
||||
]),
|
||||
makeUserMsg([
|
||||
{ type: 'tool_result' as const, tool_use_id: 'toolu_1', content: 'file.txt' },
|
||||
{ type: 'text' as const, text: 'looks good' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
// Find the tool message and the user text message
|
||||
const toolIdx = result.findIndex(m => m.role === 'tool')
|
||||
const userTextIdx = result.findIndex(
|
||||
m => m.role === 'user' && typeof m.content === 'string' && m.content.includes('looks good'),
|
||||
)
|
||||
expect(toolIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(userTextIdx).toBeGreaterThanOrEqual(0)
|
||||
// Tool MUST come before user text
|
||||
expect(toolIdx).toBeLessThan(userTextIdx)
|
||||
})
|
||||
|
||||
test('tool message immediately follows assistant tool_calls (no user message in between)', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg('do something'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'tool_use' as const, id: 'toolu_2', name: 'bash', input: { command: 'pwd' } },
|
||||
]),
|
||||
makeUserMsg([
|
||||
{ type: 'tool_result' as const, tool_use_id: 'toolu_2', content: '/home/user' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
const assistantIdx = result.findIndex(m => m.role === 'assistant' && (m as any).tool_calls)
|
||||
const toolIdx = result.findIndex(m => m.role === 'tool')
|
||||
expect(assistantIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(toolIdx).toBe(assistantIdx + 1)
|
||||
})
|
||||
|
||||
test('sets content to null when only thinking and tool_calls present', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('question'), makeAssistantMsg([
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../convertTools.js'
|
||||
import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../openaiConvertTools.js'
|
||||
|
||||
describe('anthropicToolsToOpenAI', () => {
|
||||
test('converts basic tool', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { adaptOpenAIStreamToAnthropic } from '../streamAdapter.js'
|
||||
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
|
||||
import { adaptOpenAIStreamToAnthropic } from '../openaiStreamAdapter.js'
|
||||
|
||||
/** Helper to create a mock async iterable from chunk array */
|
||||
function mockStream(chunks: ChatCompletionChunk[]): AsyncIterable<ChatCompletionChunk> {
|
||||
@@ -10,8 +10,8 @@ import type {
|
||||
ChatCompletionToolMessageParam,
|
||||
ChatCompletionUserMessageParam,
|
||||
} from 'openai/resources/chat/completions/completions.mjs'
|
||||
import type { AssistantMessage, UserMessage } from '../../../types/message.js'
|
||||
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
||||
import type { AssistantMessage, UserMessage } from '../types/message.js'
|
||||
import type { SystemPrompt } from '../types/systemPrompt.js'
|
||||
|
||||
export interface ConvertMessagesOptions {
|
||||
/** When true, preserve thinking blocks as reasoning_content on assistant messages
|
||||
@@ -152,7 +152,6 @@ function convertInternalUserMessage(
|
||||
// OpenAI API requires that a tool message immediately follows the assistant
|
||||
// message with tool_calls. If we emit a user message first, the API will
|
||||
// reject the request with "insufficient tool messages following tool_calls".
|
||||
// See: https://github.com/anthropics/claude-code/issues/xxx
|
||||
for (const tr of toolResults) {
|
||||
result.push(convertToolResult(tr))
|
||||
}
|
||||
@@ -51,10 +51,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
let textBlockOpen = false
|
||||
|
||||
// Track usage — all four Anthropic fields, populated from OpenAI usage fields:
|
||||
// prompt_tokens → input_tokens
|
||||
// completion_tokens → output_tokens
|
||||
// prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
||||
// (no standard OpenAI equivalent) → cache_creation_input_tokens (always 0)
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cachedReadTokens = 0
|
||||
@@ -62,10 +58,7 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
// Track all open content block indices (for cleanup)
|
||||
const openBlockIndices = new Set<number>()
|
||||
|
||||
// Deferred finish state: populated when finish_reason is encountered so that
|
||||
// message_delta / message_stop are emitted AFTER the stream loop ends.
|
||||
// This ensures usage chunks that arrive after the finish_reason chunk are
|
||||
// captured before we emit the final token counts.
|
||||
// Deferred finish state
|
||||
let pendingFinishReason: string | null = null
|
||||
let pendingHasToolCalls = false
|
||||
|
||||
@@ -74,16 +67,9 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
const delta = choice?.delta
|
||||
|
||||
// Extract usage from any chunk that carries it.
|
||||
// Many OpenAI-compatible endpoints (e.g. DeepSeek) send usage in a separate
|
||||
// final chunk that arrives AFTER the finish_reason chunk. Reading it here
|
||||
// (before emitting message_delta) ensures the token counts are available
|
||||
// when we later emit message_delta.
|
||||
if (chunk.usage) {
|
||||
inputTokens = chunk.usage.prompt_tokens ?? inputTokens
|
||||
outputTokens = chunk.usage.completion_tokens ?? outputTokens
|
||||
// OpenAI prompt caching: prompt_tokens_details.cached_tokens
|
||||
// → Anthropic cache_read_input_tokens
|
||||
// Note: OpenAI has no equivalent for cache_creation_input_tokens.
|
||||
const details = (chunk.usage as any).prompt_tokens_details
|
||||
if (details?.cached_tokens != null) {
|
||||
cachedReadTokens = details.cached_tokens
|
||||
@@ -118,7 +104,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
if (!delta) continue
|
||||
|
||||
// Handle reasoning_content → Anthropic thinking block
|
||||
// DeepSeek and compatible providers send delta.reasoning_content
|
||||
const reasoningContent = (delta as any).reasoning_content
|
||||
if (reasoningContent != null && reasoningContent !== '') {
|
||||
if (!thinkingBlockOpen) {
|
||||
@@ -150,7 +135,7 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
// Handle text content
|
||||
if (delta.content != null && delta.content !== '') {
|
||||
if (!textBlockOpen) {
|
||||
// Close thinking block if still open (reasoning done, now generating answer)
|
||||
// Close thinking block if still open
|
||||
if (thinkingBlockOpen) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
@@ -251,12 +236,8 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
}
|
||||
}
|
||||
|
||||
// Handle finish: close all open content blocks and record the finish_reason.
|
||||
// message_delta + message_stop are emitted AFTER the stream loop so that any
|
||||
// trailing usage chunk (sent after the finish chunk by some endpoints)
|
||||
// is captured first — ensuring token counts are non-zero.
|
||||
// Handle finish
|
||||
if (choice?.finish_reason) {
|
||||
// Close thinking block if still open
|
||||
if (thinkingBlockOpen) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
@@ -266,7 +247,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
thinkingBlockOpen = false
|
||||
}
|
||||
|
||||
// Close text block if still open
|
||||
if (textBlockOpen) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
@@ -276,7 +256,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
textBlockOpen = false
|
||||
}
|
||||
|
||||
// Close all tool blocks that haven't been closed yet
|
||||
for (const [, block] of toolBlocks) {
|
||||
if (openBlockIndices.has(block.contentIndex)) {
|
||||
yield {
|
||||
@@ -287,14 +266,12 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
}
|
||||
}
|
||||
|
||||
// Defer message_delta / message_stop until after the loop so that any
|
||||
// trailing usage chunk is processed before we emit the final token counts.
|
||||
pendingFinishReason = choice.finish_reason
|
||||
pendingHasToolCalls = toolBlocks.size > 0
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: close any remaining open blocks if stream ended without finish_reason
|
||||
// Safety: close any remaining open blocks
|
||||
for (const idx of openBlockIndices) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
@@ -302,15 +279,8 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
// Emit message_delta + message_stop now that the stream is fully consumed.
|
||||
// Usage values (inputTokens / outputTokens) reflect all chunks including any
|
||||
// trailing usage-only chunk sent after the finish_reason chunk.
|
||||
// Emit message_delta + message_stop
|
||||
if (pendingFinishReason !== null) {
|
||||
// Map finish_reason to Anthropic stop_reason.
|
||||
// CRITICAL: When finish_reason is 'length' (token budget exhausted), always
|
||||
// report 'max_tokens' regardless of whether partial tool calls were received.
|
||||
// Otherwise the query loop would try to execute tool calls with incomplete
|
||||
// JSON arguments instead of triggering the max_tokens retry/recovery path.
|
||||
const stopReason =
|
||||
pendingFinishReason === 'length'
|
||||
? 'max_tokens'
|
||||
@@ -324,19 +294,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
stop_reason: stopReason,
|
||||
stop_sequence: null,
|
||||
},
|
||||
// Carry all four Anthropic usage fields so queryModelOpenAI's message_delta
|
||||
// handler (which spreads this into the accumulated usage object) can override
|
||||
// every field that message_start emitted as 0. For endpoints that send usage
|
||||
// in a trailing chunk (e.g. DeepSeek), message_start is emitted on the first
|
||||
// content chunk before the trailing usage chunk arrives, so all four fields
|
||||
// start at 0. By the time we reach here (post-loop) the trailing chunk has
|
||||
// been processed and all values reflect the real counts.
|
||||
//
|
||||
// OpenAI → Anthropic field mapping:
|
||||
// prompt_tokens → input_tokens
|
||||
// completion_tokens → output_tokens
|
||||
// prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
||||
// (no OpenAI equivalent) → cache_creation_input_tokens (stays 0)
|
||||
usage: {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
@@ -353,11 +310,6 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
|
||||
/**
|
||||
* Map OpenAI finish_reason to Anthropic stop_reason.
|
||||
*
|
||||
* stop → end_turn
|
||||
* tool_calls → tool_use
|
||||
* length → max_tokens
|
||||
* content_filter → end_turn
|
||||
*/
|
||||
function mapFinishReason(reason: string): string {
|
||||
switch (reason) {
|
||||
54
packages/@ant/model-provider/src/types/errors.ts
Normal file
54
packages/@ant/model-provider/src/types/errors.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Error type constants for the model provider package.
|
||||
// Error string constants extracted from src/services/api/errors.ts.
|
||||
// The full error handling functions remain in the main project (Phase 4).
|
||||
|
||||
export const API_ERROR_MESSAGE_PREFIX = 'API Error'
|
||||
|
||||
export const PROMPT_TOO_LONG_ERROR_MESSAGE = 'Prompt is too long'
|
||||
|
||||
export const CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE = 'Credit balance is too low'
|
||||
export const INVALID_API_KEY_ERROR_MESSAGE = 'Not logged in · Please run /login'
|
||||
export const INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL =
|
||||
'Invalid API key · Fix external API key'
|
||||
export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH =
|
||||
'Your ANTHROPIC_API_KEY belongs to a disabled organization · Unset the environment variable to use your subscription instead'
|
||||
export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY =
|
||||
'Your ANTHROPIC_API_KEY belongs to a disabled organization · Update or unset the environment variable'
|
||||
export const TOKEN_REVOKED_ERROR_MESSAGE =
|
||||
'OAuth token revoked · Please run /login'
|
||||
export const CCR_AUTH_ERROR_MESSAGE =
|
||||
'Authentication error · This may be a temporary network issue, please try again'
|
||||
export const REPEATED_529_ERROR_MESSAGE = 'Repeated 529 Overloaded errors'
|
||||
export const CUSTOM_OFF_SWITCH_MESSAGE =
|
||||
'Opus is experiencing high load, please use /model to switch to Sonnet'
|
||||
export const API_TIMEOUT_ERROR_MESSAGE = 'Request timed out'
|
||||
export const OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE =
|
||||
'Your account does not have access to Claude Code. Please run /login.'
|
||||
|
||||
/** Error classification types returned by classifyAPIError */
|
||||
export type APIErrorClassification =
|
||||
| 'aborted'
|
||||
| 'api_timeout'
|
||||
| 'repeated_529'
|
||||
| 'capacity_off_switch'
|
||||
| 'rate_limit'
|
||||
| 'server_overload'
|
||||
| 'prompt_too_long'
|
||||
| 'pdf_too_large'
|
||||
| 'pdf_password_protected'
|
||||
| 'image_too_large'
|
||||
| 'tool_use_mismatch'
|
||||
| 'unexpected_tool_result'
|
||||
| 'duplicate_tool_use_id'
|
||||
| 'invalid_model'
|
||||
| 'credit_balance_low'
|
||||
| 'invalid_api_key'
|
||||
| 'token_revoked'
|
||||
| 'oauth_org_not_allowed'
|
||||
| 'auth_error'
|
||||
| 'bedrock_model_access'
|
||||
| 'server_error'
|
||||
| 'client_error'
|
||||
| 'ssl_cert_error'
|
||||
| 'connection_error'
|
||||
| 'unknown'
|
||||
6
packages/@ant/model-provider/src/types/index.ts
Normal file
6
packages/@ant/model-provider/src/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Type definitions for @ant/model-provider
|
||||
|
||||
export * from './message.js'
|
||||
export * from './usage.js'
|
||||
export * from './errors.js'
|
||||
export * from './systemPrompt.js'
|
||||
129
packages/@ant/model-provider/src/types/message.ts
Normal file
129
packages/@ant/model-provider/src/types/message.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// Core message types for the model provider package.
|
||||
// Moved from src/types/message.ts to decouple the API layer from the main project.
|
||||
|
||||
import type { UUID } from 'crypto'
|
||||
import type {
|
||||
ContentBlockParam,
|
||||
ContentBlock,
|
||||
} from '@anthropic-ai/sdk/resources/index.mjs'
|
||||
import type { BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
|
||||
/**
|
||||
* Base message type with discriminant `type` field and common properties.
|
||||
* Individual message subtypes (UserMessage, AssistantMessage, etc.) extend
|
||||
* this with narrower `type` literals and additional fields.
|
||||
*/
|
||||
export type MessageType = 'user' | 'assistant' | 'system' | 'attachment' | 'progress' | 'grouped_tool_use' | 'collapsed_read_search'
|
||||
|
||||
/** A single content element inside message.content arrays. */
|
||||
export type ContentItem = ContentBlockParam | ContentBlock
|
||||
|
||||
export type MessageContent = string | ContentBlockParam[] | ContentBlock[]
|
||||
|
||||
/**
|
||||
* Typed content array — used in narrowed message subtypes so that
|
||||
* `message.content[0]` resolves to `ContentItem` instead of
|
||||
* `string | ContentBlockParam | ContentBlock`.
|
||||
*/
|
||||
export type TypedMessageContent = ContentItem[]
|
||||
|
||||
export type Message = {
|
||||
type: MessageType
|
||||
uuid: UUID
|
||||
isMeta?: boolean
|
||||
isCompactSummary?: boolean
|
||||
toolUseResult?: unknown
|
||||
isVisibleInTranscriptOnly?: boolean
|
||||
attachment?: { type: string; toolUseID?: string; [key: string]: unknown; addedNames: string[]; addedLines: string[]; removedNames: string[] }
|
||||
message?: {
|
||||
role?: string
|
||||
id?: string
|
||||
content?: MessageContent
|
||||
usage?: BetaUsage | Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AssistantMessage = Message & {
|
||||
type: 'assistant'
|
||||
message: NonNullable<Message['message']>
|
||||
}
|
||||
export type AttachmentMessage<T = { type: string; [key: string]: unknown }> = Message & { type: 'attachment'; attachment: T }
|
||||
export type ProgressMessage<T = unknown> = Message & { type: 'progress'; data: T }
|
||||
export type SystemLocalCommandMessage = Message & { type: 'system' }
|
||||
export type SystemMessage = Message & { type: 'system' }
|
||||
export type UserMessage = Message & {
|
||||
type: 'user'
|
||||
message: NonNullable<Message['message']>
|
||||
imagePasteIds?: number[]
|
||||
}
|
||||
export type NormalizedUserMessage = UserMessage
|
||||
export type RequestStartEvent = { type: string; [key: string]: unknown }
|
||||
export type StreamEvent = { type: string; [key: string]: unknown }
|
||||
export type SystemCompactBoundaryMessage = Message & {
|
||||
type: 'system'
|
||||
compactMetadata: {
|
||||
preservedSegment?: {
|
||||
headUuid: UUID
|
||||
tailUuid: UUID
|
||||
anchorUuid: UUID
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
export type TombstoneMessage = Message
|
||||
export type ToolUseSummaryMessage = Message
|
||||
export type MessageOrigin = string
|
||||
export type CompactMetadata = Record<string, unknown>
|
||||
export type SystemAPIErrorMessage = Message & { type: 'system' }
|
||||
export type SystemFileSnapshotMessage = Message & { type: 'system' }
|
||||
export type NormalizedAssistantMessage<T = unknown> = AssistantMessage
|
||||
export type NormalizedMessage = Message
|
||||
export type PartialCompactDirection = string
|
||||
|
||||
export type StopHookInfo = {
|
||||
command?: string
|
||||
durationMs?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SystemAgentsKilledMessage = Message & { type: 'system' }
|
||||
export type SystemApiMetricsMessage = Message & { type: 'system' }
|
||||
export type SystemAwaySummaryMessage = Message & { type: 'system' }
|
||||
export type SystemBridgeStatusMessage = Message & { type: 'system' }
|
||||
export type SystemInformationalMessage = Message & { type: 'system' }
|
||||
export type SystemMemorySavedMessage = Message & { type: 'system' }
|
||||
export type SystemMessageLevel = string
|
||||
export type SystemMicrocompactBoundaryMessage = Message & { type: 'system' }
|
||||
export type SystemPermissionRetryMessage = Message & { type: 'system' }
|
||||
export type SystemScheduledTaskFireMessage = Message & { type: 'system' }
|
||||
|
||||
export type SystemStopHookSummaryMessage = Message & {
|
||||
type: 'system'
|
||||
subtype: string
|
||||
hookLabel: string
|
||||
hookCount: number
|
||||
totalDurationMs?: number
|
||||
hookInfos: StopHookInfo[]
|
||||
}
|
||||
|
||||
export type SystemTurnDurationMessage = Message & { type: 'system' }
|
||||
|
||||
export type GroupedToolUseMessage = Message & {
|
||||
type: 'grouped_tool_use'
|
||||
toolName: string
|
||||
messages: NormalizedAssistantMessage[]
|
||||
results: NormalizedUserMessage[]
|
||||
displayMessage: NormalizedAssistantMessage | NormalizedUserMessage
|
||||
}
|
||||
|
||||
// CollapsibleMessage is used by the main project's CollapsedReadSearchGroup
|
||||
export type CollapsibleMessage =
|
||||
| AssistantMessage
|
||||
| UserMessage
|
||||
| GroupedToolUseMessage
|
||||
|
||||
export type HookResultMessage = Message
|
||||
export type SystemThinkingMessage = Message & { type: 'system' }
|
||||
10
packages/@ant/model-provider/src/types/systemPrompt.ts
Normal file
10
packages/@ant/model-provider/src/types/systemPrompt.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// System prompt branded type.
|
||||
// Dependency-free so it can be imported from anywhere without circular imports.
|
||||
|
||||
export type SystemPrompt = readonly string[] & {
|
||||
readonly __brand: 'SystemPrompt'
|
||||
}
|
||||
|
||||
export function asSystemPrompt(value: readonly string[]): SystemPrompt {
|
||||
return value as SystemPrompt
|
||||
}
|
||||
49
packages/@ant/model-provider/src/types/usage.ts
Normal file
49
packages/@ant/model-provider/src/types/usage.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Usage types for the model provider package.
|
||||
// Moved from src/entrypoints/sdk/sdkUtilityTypes.ts and src/services/api/emptyUsage.ts
|
||||
|
||||
/**
|
||||
* Non-nullable usage object representing token consumption from an API response.
|
||||
* Moved from src/entrypoints/sdk/sdkUtilityTypes.ts
|
||||
*/
|
||||
export type NonNullableUsage = {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
cacheReadInputTokens?: number
|
||||
cacheCreationInputTokens?: number
|
||||
input_tokens: number
|
||||
cache_creation_input_tokens: number
|
||||
cache_read_input_tokens: number
|
||||
output_tokens: number
|
||||
server_tool_use: { web_search_requests: number; web_fetch_requests: number }
|
||||
service_tier: string
|
||||
cache_creation: {
|
||||
ephemeral_1h_input_tokens: number
|
||||
ephemeral_5m_input_tokens: number
|
||||
}
|
||||
inference_geo: string
|
||||
iterations: unknown[]
|
||||
speed: string
|
||||
cache_deleted_input_tokens?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-initialized usage object. Extracted from logging.ts so that
|
||||
* bridge/replBridge.ts can import it without transitively pulling in
|
||||
* api/errors.ts → utils/messages.ts → BashTool.tsx → the world.
|
||||
*/
|
||||
export const EMPTY_USAGE: Readonly<NonNullableUsage> = {
|
||||
input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
|
||||
service_tier: 'standard',
|
||||
cache_creation: {
|
||||
ephemeral_1h_input_tokens: 0,
|
||||
ephemeral_5m_input_tokens: 0,
|
||||
},
|
||||
inference_geo: '',
|
||||
iterations: [],
|
||||
speed: 'standard',
|
||||
}
|
||||
7
packages/@ant/model-provider/tsconfig.json
Normal file
7
packages/@ant/model-provider/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -7,6 +7,17 @@ mock.module("src/utils/model/agent.js", () => ({
|
||||
|
||||
mock.module("src/utils/settings/constants.js", () => ({
|
||||
getSourceDisplayName: (source: string) => source,
|
||||
getSourceDisplayNameLowercase: (source: string) => source,
|
||||
getSourceDisplayNameCapitalized: (source: string) => source,
|
||||
getSettingSourceName: (source: string) => source,
|
||||
getSettingSourceDisplayNameLowercase: (source: string) => source,
|
||||
getSettingSourceDisplayNameCapitalized: (source: string) => source,
|
||||
parseSettingSourcesFlag: () => [],
|
||||
getEnabledSettingSources: () => [],
|
||||
isSettingSourceEnabled: () => true,
|
||||
SETTING_SOURCES: ["localSettings", "userSettings", "projectSettings"],
|
||||
SOURCES: ["localSettings", "userSettings", "projectSettings"],
|
||||
CLAUDE_CODE_SETTINGS_SCHEMA_URL: "https://json.schemastore.org/claude-code-settings.json",
|
||||
}));
|
||||
|
||||
const {
|
||||
|
||||
@@ -87,7 +87,7 @@ mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({
|
||||
updateProgressFromMessage: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/debug.js", () => ({
|
||||
mock.module("src/utils/debug.ts", () => ({
|
||||
getMinDebugLogLevel: () => "warn",
|
||||
isDebugMode: () => false,
|
||||
enableDebugLogging: () => false,
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock commands.ts to cut the heavy shell/prefix.ts → analytics → api chain
|
||||
mock.module("src/utils/bash/commands.ts", () => ({
|
||||
splitCommand_DEPRECATED: (cmd: string) =>
|
||||
cmd.split(/\s*(?:[|;&]+)\s*/).filter(Boolean),
|
||||
quote: (args: string[]) => args.join(" "),
|
||||
}));
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
const { interpretCommandResult } = await import("../commandSemantics");
|
||||
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
mock.module("src/utils/debug.js", () => ({
|
||||
mock.module("src/utils/debug.ts", () => ({
|
||||
logForDebugging: () => {},
|
||||
isDebugMode: () => false,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/errors.js", () => ({
|
||||
errorMessage: (e: unknown) => String(e),
|
||||
}));
|
||||
|
||||
mock.module("src/utils/stringUtils.js", () => ({
|
||||
plural: (n: number, singular: string, plural?: string) =>
|
||||
n === 1 ? singular : (plural ?? singular + "s"),
|
||||
}));
|
||||
|
||||
const {
|
||||
formatGoToDefinitionResult,
|
||||
formatFindReferencesResult,
|
||||
|
||||
@@ -7,6 +7,18 @@ mock.module("src/utils/cwd.js", () => ({
|
||||
getCwd: () => mockCwd,
|
||||
}));
|
||||
|
||||
// Defensive: agent.test.ts can corrupt Bun's src/* path alias at runtime.
|
||||
mock.module("src/utils/powershell/parser.js", () => ({
|
||||
PS_TOKENIZER_DASH_CHARS: new Set(['-', '\u2013', '\u2014', '\u2015']),
|
||||
COMMON_ALIASES: {},
|
||||
commandHasArgAbbreviation: () => false,
|
||||
deriveSecurityFlags: () => ({}),
|
||||
getAllCommands: () => [],
|
||||
getVariablesByScope: () => [],
|
||||
hasCommandNamed: () => false,
|
||||
parsePowerShellCommandCached: () => ({ valid: false, errors: [], statements: [], variables: [], hasStopParsing: false, originalCommand: "" }),
|
||||
}))
|
||||
|
||||
const { isGitInternalPathPS, isDotGitPathPS } = await import("../gitSafety");
|
||||
|
||||
describe("isGitInternalPathPS", () => {
|
||||
|
||||
@@ -32,6 +32,58 @@ mock.module("src/utils/powershell/dangerousCmdlets.js", () => ({
|
||||
]),
|
||||
}));
|
||||
|
||||
// Defensive: agent.test.ts can corrupt Bun's src/* path alias at runtime.
|
||||
// Provide parser stubs so powershellSecurity.ts loads without the alias.
|
||||
// The tests build ParsedPowerShellCommand objects manually via makeParsed(),
|
||||
// so the real parser implementations are not needed for these specific tests.
|
||||
const MOCK_COMMON_ALIASES: Record<string, string> = {
|
||||
iex: "Invoke-Expression",
|
||||
ii: "Invoke-Item",
|
||||
sal: "Set-Alias",
|
||||
ipmo: "Import-Module",
|
||||
iwmi: "Invoke-WmiMethod",
|
||||
saps: "Start-Process",
|
||||
start: "Start-Process",
|
||||
};
|
||||
|
||||
mock.module("src/utils/powershell/parser.js", () => ({
|
||||
COMMON_ALIASES: MOCK_COMMON_ALIASES,
|
||||
commandHasArgAbbreviation: (cmd: any, fullParam: string, minPrefix: string) => {
|
||||
const fullLower = fullParam.toLowerCase()
|
||||
const prefixLower = minPrefix.toLowerCase()
|
||||
return cmd.args.some((a: string) => {
|
||||
const lower = a.toLowerCase()
|
||||
const colonIdx = lower.indexOf(':')
|
||||
const paramPart = colonIdx > 0 ? lower.slice(0, colonIdx) : lower
|
||||
return paramPart.startsWith(prefixLower) && fullLower.startsWith(paramPart)
|
||||
})
|
||||
},
|
||||
deriveSecurityFlags: () => ({ hasRedirectToVariable: false, hasPipelineVariable: false, hasFormatHex: false, hasScriptBlocks: false, hasSubExpressions: false, hasExpandableStrings: false, hasSplatting: false, hasStopParsing: false, hasMemberInvocations: false, hasAssignments: false }),
|
||||
getAllCommands: (parsed: any) => parsed.statements.flatMap((s: any) => s.commands || []),
|
||||
getVariablesByScope: () => [],
|
||||
hasCommandNamed: (parsed: any, name: string) => {
|
||||
const lower = name.toLowerCase()
|
||||
const canonicalFromAlias = MOCK_COMMON_ALIASES[lower]?.toLowerCase()
|
||||
return parsed.statements.some((s: any) => (s.commands || []).some((c: any) => {
|
||||
const cmdLower = c.name.toLowerCase()
|
||||
if (cmdLower === lower) return true
|
||||
const canonical = MOCK_COMMON_ALIASES[cmdLower]?.toLowerCase()
|
||||
if (canonical === lower) return true
|
||||
if (canonicalFromAlias && cmdLower === canonicalFromAlias) return true
|
||||
return false
|
||||
}))
|
||||
},
|
||||
parsePowerShellCommandCached: () => ({ valid: false, errors: [], statements: [], variables: [], hasStopParsing: false, originalCommand: "" }),
|
||||
PARSE_SCRIPT_BODY: "",
|
||||
WINDOWS_MAX_COMMAND_LENGTH: 32000,
|
||||
MAX_COMMAND_LENGTH: 32000,
|
||||
PS_TOKENIZER_DASH_CHARS: new Set(['-', '\u2013', '\u2014', '\u2015']),
|
||||
mapStatementType: (t: string) => t,
|
||||
mapElementType: (t: string) => t,
|
||||
classifyCommandName: () => ({ type: 'external', name: '' }),
|
||||
stripModulePrefix: (n: string) => n,
|
||||
}));
|
||||
|
||||
// Real parser functions work without mocks since they're pure
|
||||
const { powershellCommandIsSafe } = await import("../powershellSecurity.js");
|
||||
|
||||
|
||||
@@ -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.' } : {}),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,8 +3,11 @@ 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 { notifyAutomationStateChanged } from 'src/utils/sessionState.js'
|
||||
import { SLEEP_TOOL_NAME, DESCRIPTION, SLEEP_TOOL_PROMPT } from './prompt.js'
|
||||
|
||||
const SLEEP_WAKE_CHECK_INTERVAL_MS = 500
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
duration_seconds: z
|
||||
@@ -19,6 +22,36 @@ type SleepInput = z.infer<InputSchema>
|
||||
|
||||
type SleepOutput = { slept_seconds: number; interrupted: boolean }
|
||||
|
||||
function isProactiveAutomationEnabled(): boolean {
|
||||
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
|
||||
return false
|
||||
}
|
||||
|
||||
const mod =
|
||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||
return mod.isProactiveActive()
|
||||
}
|
||||
|
||||
function isProactiveSleepAllowed(): boolean {
|
||||
if (!(feature('PROACTIVE') || feature('KAIROS'))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const mod =
|
||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||
return mod.isProactiveActive()
|
||||
}
|
||||
|
||||
function hasQueuedWakeSignal(): boolean {
|
||||
const queue =
|
||||
require('src/utils/messageQueueManager.js') as typeof import('src/utils/messageQueueManager.js')
|
||||
return queue.hasCommandsInQueue()
|
||||
}
|
||||
|
||||
function shouldInterruptSleep(): boolean {
|
||||
return !isProactiveSleepAllowed() || hasQueuedWakeSignal()
|
||||
}
|
||||
|
||||
export const SleepTool = buildTool({
|
||||
name: SLEEP_TOOL_NAME,
|
||||
searchHint: 'wait pause sleep rest idle duration timer',
|
||||
@@ -42,6 +75,9 @@ export const SleepTool = buildTool({
|
||||
isReadOnly() {
|
||||
return true
|
||||
},
|
||||
interruptBehavior() {
|
||||
return 'cancel'
|
||||
},
|
||||
|
||||
userFacingName() {
|
||||
return SLEEP_TOOL_NAME
|
||||
@@ -67,53 +103,84 @@ export const SleepTool = buildTool({
|
||||
},
|
||||
|
||||
async call(input: SleepInput, context) {
|
||||
// Refuse to sleep when proactive mode is off — prevents the model from
|
||||
// re-issuing Sleep after an interruption caused by /proactive disable.
|
||||
if (feature('PROACTIVE') || feature('KAIROS')) {
|
||||
const mod =
|
||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||
if (!mod.isProactiveActive()) {
|
||||
return {
|
||||
data: {
|
||||
slept_seconds: 0,
|
||||
interrupted: true,
|
||||
},
|
||||
}
|
||||
// Don't enter sleep if proactive was disabled or new work arrived while
|
||||
// the model was deciding to wait.
|
||||
if (shouldInterruptSleep()) {
|
||||
return {
|
||||
data: {
|
||||
slept_seconds: 0,
|
||||
interrupted: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const { duration_seconds } = input
|
||||
const startTime = Date.now()
|
||||
const sleepUntil = startTime + duration_seconds * 1000
|
||||
|
||||
if (isProactiveAutomationEnabled()) {
|
||||
notifyAutomationStateChanged({
|
||||
enabled: true,
|
||||
phase: 'sleeping',
|
||||
next_tick_at: null,
|
||||
sleep_until: sleepUntil,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, duration_seconds * 1000)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let wakeCheck: ReturnType<typeof setInterval> | null = null
|
||||
let settled = false
|
||||
|
||||
const cleanup = () => {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
if (wakeCheck !== null) {
|
||||
clearInterval(wakeCheck)
|
||||
wakeCheck = null
|
||||
}
|
||||
context.abortController.signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
|
||||
const interrupt = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(new Error('interrupted'))
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
interrupt()
|
||||
}
|
||||
|
||||
timer = setTimeout(finish, duration_seconds * 1000)
|
||||
|
||||
// Abort via user interrupt
|
||||
context.abortController.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
clearInterval(proactiveCheck)
|
||||
reject(new Error('interrupted'))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
if (context.abortController.signal.aborted) {
|
||||
interrupt()
|
||||
return
|
||||
}
|
||||
context.abortController.signal.addEventListener('abort', onAbort, {
|
||||
once: true,
|
||||
})
|
||||
|
||||
// Poll proactive state — if deactivated mid-sleep, interrupt early
|
||||
// so the user doesn't have to wait for the full duration.
|
||||
const proactiveCheck =
|
||||
feature('PROACTIVE') || feature('KAIROS')
|
||||
? setInterval(() => {
|
||||
const mod =
|
||||
require('src/proactive/index.js') as typeof import('src/proactive/index.js')
|
||||
if (!mod.isProactiveActive()) {
|
||||
clearTimeout(timer)
|
||||
clearInterval(proactiveCheck)
|
||||
reject(new Error('interrupted'))
|
||||
}
|
||||
}, 500)
|
||||
: (null as unknown as ReturnType<typeof setInterval>)
|
||||
// Poll proactive state and the shared command queue so new work can
|
||||
// wake Sleep without waiting for the full duration.
|
||||
wakeCheck = setInterval(() => {
|
||||
if (shouldInterruptSleep()) {
|
||||
interrupt()
|
||||
}
|
||||
}, SLEEP_WAKE_CHECK_INTERVAL_MS)
|
||||
})
|
||||
return {
|
||||
data: {
|
||||
@@ -129,6 +196,17 @@ export const SleepTool = buildTool({
|
||||
interrupted: true,
|
||||
},
|
||||
}
|
||||
} finally {
|
||||
notifyAutomationStateChanged(
|
||||
isProactiveAutomationEnabled()
|
||||
? {
|
||||
enabled: true,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { SleepTool } from '../SleepTool'
|
||||
import {
|
||||
enqueue,
|
||||
getCommandQueue,
|
||||
resetCommandQueue,
|
||||
} from 'src/utils/messageQueueManager.js'
|
||||
|
||||
describe('SleepTool', () => {
|
||||
beforeEach(() => {
|
||||
resetCommandQueue()
|
||||
})
|
||||
|
||||
test('declares cancel interrupt behavior', () => {
|
||||
expect(SleepTool.interruptBehavior()).toBe('cancel')
|
||||
})
|
||||
|
||||
test('wakes early when queued work arrives', async () => {
|
||||
const sleepPromise = SleepTool.call(
|
||||
{ duration_seconds: 10 },
|
||||
{ abortController: new AbortController() } as any,
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
enqueue({
|
||||
value: 'wake up',
|
||||
mode: 'prompt',
|
||||
})
|
||||
}, 20)
|
||||
|
||||
const result = await sleepPromise
|
||||
|
||||
expect(result.data.interrupted).toBe(true)
|
||||
expect(result.data.slept_seconds).toBeLessThan(10)
|
||||
expect(getCommandQueue()).toHaveLength(1)
|
||||
expect(getCommandQueue()[0]).toMatchObject({
|
||||
value: 'wake up',
|
||||
mode: 'prompt',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,8 @@ let isFirstPartyBaseUrl = true
|
||||
// Only mock the external dependency that controls adapter selection
|
||||
mock.module('src/utils/model/providers.js', () => ({
|
||||
isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl,
|
||||
getAPIProvider: () => 'firstParty',
|
||||
getAPIProviderForStatsig: () => 'firstParty',
|
||||
}))
|
||||
|
||||
const { createAdapter } = await import('../adapters/index')
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
const _abortMock = () => ({
|
||||
AbortError: class AbortError extends Error {
|
||||
constructor(message?: string) { super(message); this.name = 'AbortError' }
|
||||
},
|
||||
isAbortError: (e: unknown) => e instanceof Error && (e as Error).name === 'AbortError',
|
||||
})
|
||||
mock.module('src/utils/errors.js', _abortMock)
|
||||
mock.module('src/utils/errors', _abortMock)
|
||||
|
||||
import { extractBingResults, decodeHtmlEntities } from '../adapters/bingAdapter'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
// Defensive mock: agent.test.ts mocks config.js which can corrupt Bun's
|
||||
// src/* path alias resolution. Provide AbortError directly so the dynamic
|
||||
// import in createAdapter() never needs to resolve the alias at runtime.
|
||||
const _abortMock = () => ({
|
||||
AbortError: class AbortError extends Error {
|
||||
constructor(message?: string) { super(message); this.name = 'AbortError' }
|
||||
},
|
||||
isAbortError: (e: unknown) => e instanceof Error && (e as Error).name === 'AbortError',
|
||||
})
|
||||
mock.module('src/utils/errors.js', _abortMock)
|
||||
mock.module('src/utils/errors', _abortMock)
|
||||
|
||||
const originalBraveSearchApiKey = process.env.BRAVE_SEARCH_API_KEY
|
||||
const originalBraveApiKey = process.env.BRAVE_API_KEY
|
||||
|
||||
|
||||
@@ -678,6 +678,44 @@ describe("Web Session Routes", () => {
|
||||
expect(getRes.status).toBe(200);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id — includes automation_state snapshot when worker metadata has it", 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();
|
||||
storeBindSession(id, "user-1");
|
||||
|
||||
await app.request(`/v1/code/sessions/${id}/worker`, {
|
||||
method: "PUT",
|
||||
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
worker_epoch: 1,
|
||||
external_metadata: {
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 123456,
|
||||
sleep_until: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const getRes = await app.request(`/web/sessions/${toWebSessionId(id)}?uuid=user-1`);
|
||||
expect(getRes.status).toBe(200);
|
||||
const body = await getRes.json();
|
||||
expect(body.automation_state).toEqual({
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 123456,
|
||||
sleep_until: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id — 403 for non-owner", async () => {
|
||||
const createRes = await app.request("/web/sessions?uuid=user-1", {
|
||||
method: "POST",
|
||||
@@ -704,6 +742,35 @@ describe("Web Session Routes", () => {
|
||||
expect(body.events).toEqual([]);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id/history — returns task_state snapshots", 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();
|
||||
|
||||
publishSessionEvent(
|
||||
id,
|
||||
"task_state",
|
||||
{
|
||||
task_list_id: "team-alpha",
|
||||
tasks: [{ id: "1", subject: "Investigate", status: "pending" }],
|
||||
},
|
||||
"inbound",
|
||||
);
|
||||
|
||||
const histRes = await app.request(`/web/sessions/${id}/history?uuid=user-1`);
|
||||
expect(histRes.status).toBe(200);
|
||||
const body = await histRes.json();
|
||||
expect(body.events).toHaveLength(1);
|
||||
expect(body.events[0]?.type).toBe("task_state");
|
||||
expect(body.events[0]?.payload.task_list_id).toBe("team-alpha");
|
||||
expect(body.events[0]?.payload.tasks).toEqual([
|
||||
{ id: "1", subject: "Investigate", status: "pending" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("GET /web/sessions/:id and history — supports compat code session IDs", async () => {
|
||||
const codeSession = storeCreateSession({ idPrefix: "cse_" });
|
||||
storeBindSession(codeSession.id, "user-1");
|
||||
@@ -1218,7 +1285,15 @@ describe("V2 Worker Events Routes", () => {
|
||||
body: JSON.stringify({
|
||||
worker_epoch: 1,
|
||||
worker_status: "running",
|
||||
external_metadata: { permission_mode: "default" },
|
||||
external_metadata: {
|
||||
permission_mode: "default",
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 123456,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(putRes.status).toBe(200);
|
||||
@@ -1230,6 +1305,21 @@ describe("V2 Worker Events Routes", () => {
|
||||
const body = await getRes.json();
|
||||
expect(body.worker.worker_status).toBe("running");
|
||||
expect(body.worker.external_metadata.permission_mode).toBe("default");
|
||||
expect(body.worker.external_metadata.automation_state).toEqual({
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 123456,
|
||||
});
|
||||
|
||||
const events = getEventBus(id).getEventsSince(0);
|
||||
expect(events.some((event) => event.type === "automation_state")).toBe(true);
|
||||
expect(events.at(-1)?.payload).toEqual({
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 123456,
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /v1/code/sessions/:id/worker/heartbeat — updates heartbeat", async () => {
|
||||
@@ -1284,6 +1374,123 @@ describe("V2 Worker Events Routes", () => {
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web permission approvals to control_response", 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();
|
||||
|
||||
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;
|
||||
|
||||
await reader.read(); // initial keepalive
|
||||
|
||||
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "permission_response",
|
||||
approved: true,
|
||||
request_id: "req-1",
|
||||
}),
|
||||
});
|
||||
expect(controlRes.status).toBe(200);
|
||||
|
||||
const chunk = await reader.read();
|
||||
const frame = new TextDecoder().decode(chunk.value!);
|
||||
expect(frame).toContain("event: client_event");
|
||||
expect(frame).toContain("\"event_type\":\"permission_response\"");
|
||||
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
|
||||
expect(frame).toContain("\"request_id\":\"req-1\"");
|
||||
expect(frame).toContain("\"behavior\":\"allow\"");
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web plan rejection feedback to deny control_response", 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();
|
||||
|
||||
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;
|
||||
|
||||
await reader.read(); // initial keepalive
|
||||
|
||||
const controlRes = await app.request(`/web/sessions/${id}/control?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "permission_response",
|
||||
approved: false,
|
||||
request_id: "req-2",
|
||||
message: "Need more detail",
|
||||
}),
|
||||
});
|
||||
expect(controlRes.status).toBe(200);
|
||||
|
||||
const chunk = await reader.read();
|
||||
const frame = new TextDecoder().decode(chunk.value!);
|
||||
expect(frame).toContain("event: client_event");
|
||||
expect(frame).toContain("\"event_type\":\"permission_response\"");
|
||||
expect(frame).toContain("\"payload\":{\"type\":\"control_response\"");
|
||||
expect(frame).toContain("\"request_id\":\"req-2\"");
|
||||
expect(frame).toContain("\"subtype\":\"error\"");
|
||||
expect(frame).toContain("\"behavior\":\"deny\"");
|
||||
expect(frame).toContain("\"message\":\"Need more detail\"");
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("GET /v1/code/sessions/:id/worker/events/stream — normalizes web interrupts to control_request", 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();
|
||||
|
||||
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;
|
||||
|
||||
await reader.read(); // initial keepalive
|
||||
|
||||
const interruptRes = await app.request(`/web/sessions/${id}/interrupt?uuid=user-1`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
expect(interruptRes.status).toBe(200);
|
||||
|
||||
const chunk = await reader.read();
|
||||
const frame = new TextDecoder().decode(chunk.value!);
|
||||
expect(frame).toContain("event: client_event");
|
||||
expect(frame).toContain("\"event_type\":\"interrupt\"");
|
||||
expect(frame).toContain("\"payload\":{\"type\":\"control_request\"");
|
||||
expect(frame).toContain("\"subtype\":\"interrupt\"");
|
||||
reader.cancel();
|
||||
});
|
||||
|
||||
test("PUT /v1/code/sessions/:id/worker/state — updates session status", async () => {
|
||||
const sessRes = await app.request("/v1/sessions", {
|
||||
method: "POST",
|
||||
|
||||
@@ -353,6 +353,14 @@ describe("Transport Service", () => {
|
||||
expect(result.uuid).toBe("msg_123");
|
||||
});
|
||||
|
||||
test("preserves isSynthetic field", () => {
|
||||
const result = normalizePayload("user", {
|
||||
content: "scheduled job: refresh analytics cache",
|
||||
isSynthetic: true,
|
||||
});
|
||||
expect(result.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("uses name as tool_name fallback", () => {
|
||||
const result = normalizePayload("tool", { name: "Read" });
|
||||
expect(result.tool_name).toBe("Read");
|
||||
@@ -370,6 +378,28 @@ describe("Transport Service", () => {
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
test("preserves task_state fields", () => {
|
||||
const result = normalizePayload("task_state", {
|
||||
task_list_id: "team-alpha",
|
||||
tasks: [{ id: "1", subject: "Task 1", status: "pending" }],
|
||||
});
|
||||
expect(result.task_list_id).toBe("team-alpha");
|
||||
expect(result.tasks).toEqual([
|
||||
{ id: "1", subject: "Task 1", status: "pending" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves status metadata for conversation reset events", () => {
|
||||
const result = normalizePayload("status", {
|
||||
status: "conversation_cleared",
|
||||
subtype: "status",
|
||||
message: "conversation_cleared",
|
||||
});
|
||||
expect(result.status).toBe("conversation_cleared");
|
||||
expect(result.subtype).toBe("status");
|
||||
expect(result.message).toBe("conversation_cleared");
|
||||
});
|
||||
|
||||
test("handles undefined payload", () => {
|
||||
const result = normalizePayload("user", undefined);
|
||||
expect(result.content).toBe("");
|
||||
|
||||
@@ -69,6 +69,19 @@ describe("ws-handler", () => {
|
||||
expect((events[0] as any).direction).toBe("inbound");
|
||||
});
|
||||
|
||||
test("preserves synthetic flag on inbound user messages", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
bus.subscribe((e) => events.push(e));
|
||||
ingestBridgeMessage("s1", {
|
||||
message: { role: "user", content: "scheduled job: refresh analytics cache" },
|
||||
uuid: "u_synth",
|
||||
isSynthetic: true,
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect((events[0] as any).payload.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("derives type from message.role for assistant messages", () => {
|
||||
const bus = getEventBus("s1");
|
||||
const events: unknown[] = [];
|
||||
@@ -163,6 +176,24 @@ describe("ws-handler", () => {
|
||||
expect(msg.type).toBe("user");
|
||||
});
|
||||
|
||||
test("replays synthetic user metadata back to the bridge", () => {
|
||||
const bus = getEventBus("s3");
|
||||
bus.publish({
|
||||
id: "e1",
|
||||
sessionId: "s3",
|
||||
type: "user",
|
||||
payload: { content: "scheduled job: refresh analytics cache", isSynthetic: true },
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
const ws = createMockWs();
|
||||
handleWebSocketOpen(ws, "s3");
|
||||
|
||||
const msg = JSON.parse(ws.getSentData()[0]);
|
||||
expect(msg.type).toBe("user");
|
||||
expect(msg.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("replaces existing connection for same session", () => {
|
||||
const ws1 = createMockWs();
|
||||
const ws2 = createMockWs();
|
||||
|
||||
10
packages/remote-control-server/src/logger.ts
Normal file
10
packages/remote-control-server/src/logger.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Thin logging wrapper — silent in test environment, uses console in production. */
|
||||
const isTest = process.env.NODE_ENV === "test" || (typeof Bun !== "undefined" && !!Bun.env.BUN_TEST);
|
||||
|
||||
export function log(...args: unknown[]): void {
|
||||
if (!isTest) console.log(...args);
|
||||
}
|
||||
|
||||
export function error(...args: unknown[]): void {
|
||||
if (!isTest) console.error(...args);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import { createBunWebSocket } from "hono/bun";
|
||||
import { validateApiKey } from "../../auth/api-key";
|
||||
@@ -30,14 +31,14 @@ function authenticateRequest(c: any, label: string, expectedSessionId?: string):
|
||||
const payload = verifyWorkerJwt(token);
|
||||
if (payload) {
|
||||
if (expectedSessionId && payload.session_id !== expectedSessionId) {
|
||||
console.log(`[Auth] ${label}: FAILED — JWT session_id mismatch`);
|
||||
log(`[Auth] ${label}: FAILED — JWT session_id mismatch`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Auth] ${label}: FAILED — no valid API key or JWT`);
|
||||
log(`[Auth] ${label}: FAILED — no valid API key or JWT`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -85,7 +86,7 @@ app.get(
|
||||
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
console.log(`[WS] Upgrade rejected: session ${sessionId} not found`);
|
||||
log(`[WS] Upgrade rejected: session ${sessionId} not found`);
|
||||
return {
|
||||
onOpen(_evt, ws) {
|
||||
ws.close(4001, "session not found");
|
||||
@@ -93,7 +94,7 @@ app.get(
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[WS] Upgrade accepted: session=${sessionId}`);
|
||||
log(`[WS] Upgrade accepted: session=${sessionId}`);
|
||||
return {
|
||||
onOpen(_evt, ws) {
|
||||
handleWebSocketOpen(ws as any, sessionId);
|
||||
@@ -110,7 +111,7 @@ app.get(
|
||||
handleWebSocketClose(ws as any, sessionId, closeEvt?.code, closeEvt?.reason);
|
||||
},
|
||||
onError(evt, ws) {
|
||||
console.error(`[WS] Error on session=${sessionId}:`, evt);
|
||||
logError(`[WS] Error on session=${sessionId}:`, evt);
|
||||
handleWebSocketClose(ws as any, sessionId, 1006, "websocket error");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
createSession,
|
||||
@@ -23,7 +24,7 @@ app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
try {
|
||||
await createWorkItem(body.environment_id, session.id);
|
||||
} catch (err) {
|
||||
console.error(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||
logError(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
|
||||
import {
|
||||
automationStatesEqual,
|
||||
getAutomationStateEventPayload,
|
||||
} from "../../services/automationState";
|
||||
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
|
||||
import { getEventBus } from "../../transport/event-bus";
|
||||
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -33,6 +39,9 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const prevAutomationState = getAutomationStateEventPayload(
|
||||
storeGetSessionWorker(sessionId)?.externalMetadata,
|
||||
);
|
||||
if (body.worker_status) {
|
||||
updateSessionStatus(sessionId, body.worker_status);
|
||||
} else {
|
||||
@@ -44,6 +53,17 @@ app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
externalMetadata: body.external_metadata,
|
||||
requiresActionDetails: body.requires_action_details,
|
||||
});
|
||||
const nextAutomationState = getAutomationStateEventPayload(worker.externalMetadata);
|
||||
|
||||
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
|
||||
getEventBus(sessionId).publish({
|
||||
id: uuid(),
|
||||
sessionId,
|
||||
type: "automation_state",
|
||||
payload: nextAutomationState,
|
||||
direction: "inbound",
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
status: "ok",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import { uuidAuth } from "../../auth/middleware";
|
||||
import { getSession, isSessionClosedStatus, resolveOwnedWebSessionId, updateSessionStatus } from "../../services/session";
|
||||
@@ -44,9 +45,9 @@ app.post("/sessions/:id/events", uuidAuth, async (c) => {
|
||||
|
||||
const body = await c.req.json();
|
||||
const eventType = body.type || "user";
|
||||
console.log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`);
|
||||
log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`);
|
||||
const event = publishSessionEvent(sessionId, eventType, body, "outbound");
|
||||
console.log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`);
|
||||
log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`);
|
||||
return c.json({ status: "ok", event }, 200);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import { uuidAuth } from "../../auth/middleware";
|
||||
import { getAutomationStateSnapshot } from "../../services/automationState";
|
||||
import {
|
||||
createSession,
|
||||
getSession,
|
||||
@@ -9,7 +11,7 @@ import {
|
||||
resolveOwnedWebSessionId,
|
||||
toWebSessionResponse,
|
||||
} from "../../services/session";
|
||||
import { storeBindSession } from "../../store";
|
||||
import { storeBindSession, storeGetSessionWorker } from "../../store";
|
||||
import { createWorkItem } from "../../services/work-dispatch";
|
||||
import { createSSEStream } from "../../transport/sse-writer";
|
||||
import { getEventBus } from "../../transport/event-bus";
|
||||
@@ -35,7 +37,7 @@ app.post("/sessions", uuidAuth, async (c) => {
|
||||
try {
|
||||
await createWorkItem(body.environment_id, session.id);
|
||||
} catch (err) {
|
||||
console.error(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||
logError(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +69,13 @@ app.get("/sessions/:id", uuidAuth, async (c) => {
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
}
|
||||
return c.json(toWebSessionResponse(session), 200);
|
||||
const worker = storeGetSessionWorker(sessionId);
|
||||
const automationState = getAutomationStateSnapshot(worker?.externalMetadata);
|
||||
const response = toWebSessionResponse(session);
|
||||
return c.json(
|
||||
automationState === undefined ? response : { ...response, automation_state: automationState },
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
/** GET /web/sessions/:id/history — Historical events for session */
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { AutomationStateResponse } from "../types/api";
|
||||
|
||||
const DISABLED_AUTOMATION_STATE: AutomationStateResponse = Object.freeze({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
|
||||
function cloneAutomationState(state: AutomationStateResponse): AutomationStateResponse {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
function normalizeAutomationState(raw: unknown): AutomationStateResponse {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return cloneAutomationState(DISABLED_AUTOMATION_STATE);
|
||||
}
|
||||
|
||||
const state = raw as Record<string, unknown>;
|
||||
return {
|
||||
enabled: state.enabled === true,
|
||||
phase: state.phase === "standby" || state.phase === "sleeping" ? state.phase : null,
|
||||
next_tick_at: typeof state.next_tick_at === "number" ? state.next_tick_at : null,
|
||||
sleep_until: typeof state.sleep_until === "number" ? state.sleep_until : null,
|
||||
};
|
||||
}
|
||||
|
||||
function readAutomationStateValue(metadata: Record<string, unknown> | null | undefined): unknown {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(metadata, "automation_state")) {
|
||||
return undefined;
|
||||
}
|
||||
return metadata.automation_state;
|
||||
}
|
||||
|
||||
export function getAutomationStateSnapshot(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): AutomationStateResponse | undefined {
|
||||
const raw = readAutomationStateValue(metadata);
|
||||
if (raw === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeAutomationState(raw);
|
||||
}
|
||||
|
||||
export function getAutomationStateEventPayload(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): AutomationStateResponse {
|
||||
return getAutomationStateSnapshot(metadata) ?? cloneAutomationState(DISABLED_AUTOMATION_STATE);
|
||||
}
|
||||
|
||||
export function automationStatesEqual(
|
||||
a: AutomationStateResponse,
|
||||
b: AutomationStateResponse,
|
||||
): boolean {
|
||||
return (
|
||||
a.enabled === b.enabled &&
|
||||
a.phase === b.phase &&
|
||||
a.next_tick_at === b.next_tick_at &&
|
||||
a.sleep_until === b.sleep_until
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { log, error as logError } from "../logger";
|
||||
import { storeListActiveEnvironments, storeUpdateEnvironment } from "../store";
|
||||
import { storeListSessions } from "../store";
|
||||
import { config } from "../config";
|
||||
@@ -10,7 +11,7 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
|
||||
const envs = storeListActiveEnvironments();
|
||||
for (const env of envs) {
|
||||
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
|
||||
console.log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
|
||||
log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
|
||||
storeUpdateEnvironment(env.id, { status: "disconnected" });
|
||||
}
|
||||
}
|
||||
@@ -21,7 +22,7 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
|
||||
if (session.status === "running" || session.status === "idle") {
|
||||
const elapsed = now - session.updatedAt.getTime();
|
||||
if (elapsed > timeoutMs * 2) {
|
||||
console.log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`);
|
||||
log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`);
|
||||
updateSessionStatus(session.id, "inactive");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
|
||||
};
|
||||
|
||||
if (typeof p.uuid === "string" && p.uuid) normalized.uuid = p.uuid;
|
||||
if (typeof p.isSynthetic === "boolean") normalized.isSynthetic = p.isSynthetic;
|
||||
if (typeof p.status === "string") normalized.status = p.status;
|
||||
if (typeof p.subtype === "string") normalized.subtype = p.subtype;
|
||||
|
||||
// Preserve tool fields
|
||||
if (p.tool_name) normalized.tool_name = p.tool_name;
|
||||
@@ -68,6 +71,12 @@ export function normalizePayload(type: string, payload: unknown): Record<string,
|
||||
// Preserve message field for backward compat
|
||||
if (p.message) normalized.message = p.message;
|
||||
|
||||
if (type === "task_state") {
|
||||
if (typeof p.task_list_id === "string") normalized.task_list_id = p.task_list_id;
|
||||
if (typeof p.taskListId === "string") normalized.taskListId = p.taskListId;
|
||||
if (Array.isArray(p.tasks)) normalized.tasks = p.tasks;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { log, error as logError } from "../logger";
|
||||
import {
|
||||
storeCreateWorkItem,
|
||||
storeGetWorkItem,
|
||||
@@ -35,7 +36,7 @@ export async function createWorkItem(environmentId: string, sessionId: string):
|
||||
|
||||
const secret = encodeWorkSecret();
|
||||
const record = storeCreateWorkItem({ environmentId, sessionId, secret });
|
||||
console.log(`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`);
|
||||
log(`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`);
|
||||
return record.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
|
||||
/**
|
||||
* Convert an internal session event into the SDK/control message shape that
|
||||
* bridge workers consume on both the legacy WS path and the v2 worker SSE path.
|
||||
*/
|
||||
export function toClientPayload(event: SessionEvent): Record<string, unknown> {
|
||||
const payload = event.payload as Record<string, unknown> | null;
|
||||
const messageUuid =
|
||||
typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
|
||||
|
||||
if (event.type === "user" || event.type === "user_message") {
|
||||
return {
|
||||
type: "user",
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
...(payload?.isSynthetic === true ? { isSynthetic: true } : {}),
|
||||
message: {
|
||||
role: "user",
|
||||
content: payload?.content ?? payload?.message ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "permission_response" || event.type === "control_response") {
|
||||
const approved = !!payload?.approved;
|
||||
const existingResponse = payload?.response as Record<string, unknown> | undefined;
|
||||
if (existingResponse) {
|
||||
return { type: "control_response", response: existingResponse };
|
||||
}
|
||||
|
||||
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
|
||||
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
|
||||
const feedbackMessage = payload?.message as string | undefined;
|
||||
|
||||
return {
|
||||
type: "control_response",
|
||||
response: {
|
||||
subtype: approved ? "success" : "error",
|
||||
request_id: payload?.request_id ?? "",
|
||||
...(approved
|
||||
? {
|
||||
response: {
|
||||
behavior: "allow" as const,
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
...(updatedPermissions ? { updatedPermissions } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
error: "Permission denied by user",
|
||||
response: { behavior: "deny" as const },
|
||||
...(feedbackMessage ? { message: feedbackMessage } : {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "interrupt") {
|
||||
return {
|
||||
type: "control_request",
|
||||
request_id: event.id,
|
||||
request: { subtype: "interrupt" },
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "control_request") {
|
||||
return {
|
||||
type: "control_request",
|
||||
request_id: payload?.request_id ?? event.id,
|
||||
request: payload?.request ?? payload,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: event.type,
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
message: payload,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { log, error as logError } from "../logger";
|
||||
|
||||
export interface SessionEvent {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
@@ -33,12 +35,12 @@ export class EventBus {
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.events.push(full);
|
||||
console.log(`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`);
|
||||
log(`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`);
|
||||
for (const cb of this.subscribers) {
|
||||
try {
|
||||
cb(full);
|
||||
} catch (err) {
|
||||
console.error(`[RC-DEBUG] bus subscriber error:`, err);
|
||||
logError(`[RC-DEBUG] bus subscriber error:`, err);
|
||||
}
|
||||
}
|
||||
return full;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { log, error as logError } from "../logger";
|
||||
import type { Context } from "hono";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { getEventBus } from "./event-bus";
|
||||
import { toClientPayload } from "./client-payload";
|
||||
|
||||
export interface SSEWriter {
|
||||
send(event: SessionEvent): void;
|
||||
@@ -76,7 +78,7 @@ export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
|
||||
seqNum: event.seqNum,
|
||||
});
|
||||
try {
|
||||
console.log(`[RC-DEBUG] SSE -> web: sessionId=${sessionId} type=${event.type} dir=${event.direction} seq=${event.seqNum}`);
|
||||
log(`[RC-DEBUG] SSE -> web: sessionId=${sessionId} type=${event.type} dir=${event.direction} seq=${event.seqNum}`);
|
||||
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
|
||||
} catch {
|
||||
unsub();
|
||||
@@ -117,6 +119,15 @@ export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
|
||||
}
|
||||
|
||||
function toWorkerClientPayload(event: SessionEvent): Record<string, unknown> {
|
||||
if (
|
||||
event.type === "permission_response" ||
|
||||
event.type === "control_response" ||
|
||||
event.type === "control_request" ||
|
||||
event.type === "interrupt"
|
||||
) {
|
||||
return toClientPayload(event);
|
||||
}
|
||||
|
||||
const normalized =
|
||||
event.payload && typeof event.payload === "object"
|
||||
? (event.payload as Record<string, unknown>)
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { WSContext } from "hono/ws";
|
||||
import { getEventBus } from "./event-bus";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { publishSessionEvent } from "../services/transport";
|
||||
import { log, error as logError } from "../logger";
|
||||
import { toClientPayload } from "./client-payload";
|
||||
|
||||
// Per-connection cleanup, keyed by sessionId (only one WS per session)
|
||||
interface CleanupEntry {
|
||||
@@ -23,87 +25,21 @@ const SERVER_KEEPALIVE_INTERVAL_MS = 60_000;
|
||||
* Convert internal EventBus event -> SDK message for bridge client.
|
||||
*/
|
||||
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,
|
||||
session_id: event.sessionId,
|
||||
message: {
|
||||
role: "user",
|
||||
content: payload?.content ?? payload?.message ?? "",
|
||||
},
|
||||
};
|
||||
} else if (event.type === "permission_response" || event.type === "control_response") {
|
||||
const approved = !!payload?.approved;
|
||||
const existingResponse = payload?.response as Record<string, unknown> | undefined;
|
||||
if (existingResponse) {
|
||||
msg = { type: "control_response", response: existingResponse };
|
||||
} else {
|
||||
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
|
||||
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
|
||||
const feedbackMessage = payload?.message as string | undefined;
|
||||
msg = {
|
||||
type: "control_response",
|
||||
response: {
|
||||
subtype: approved ? "success" : "error",
|
||||
request_id: payload?.request_id ?? "",
|
||||
...(approved
|
||||
? {
|
||||
response: {
|
||||
behavior: "allow" as const,
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
...(updatedPermissions ? { updatedPermissions } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
error: "Permission denied by user",
|
||||
response: { behavior: "deny" as const },
|
||||
...(feedbackMessage ? { message: feedbackMessage } : {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (event.type === "interrupt") {
|
||||
msg = {
|
||||
type: "control_request",
|
||||
request_id: event.id,
|
||||
request: { subtype: "interrupt" },
|
||||
};
|
||||
} else if (event.type === "control_request") {
|
||||
msg = {
|
||||
type: "control_request",
|
||||
request_id: payload?.request_id ?? event.id,
|
||||
request: payload?.request ?? payload,
|
||||
};
|
||||
} else {
|
||||
msg = {
|
||||
type: event.type,
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
message: payload,
|
||||
};
|
||||
}
|
||||
|
||||
// NDJSON format: each message MUST end with \n so the child process's
|
||||
// line-based parser can split messages correctly.
|
||||
return JSON.stringify(msg) + "\n";
|
||||
return JSON.stringify(toClientPayload(event)) + "\n";
|
||||
}
|
||||
|
||||
/** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */
|
||||
export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
||||
const openTime = Date.now();
|
||||
console.log(`[RC-DEBUG] [WS] Open session=${sessionId}`);
|
||||
log(`[RC-DEBUG] [WS] Open session=${sessionId}`);
|
||||
activeConnections.add(ws);
|
||||
|
||||
// If there's an existing connection for this session, clean it up first
|
||||
const existing = cleanupBySession.get(sessionId);
|
||||
if (existing) {
|
||||
console.log(`[WS] Replacing existing connection for session=${sessionId}`);
|
||||
log(`[WS] Replacing existing connection for session=${sessionId}`);
|
||||
existing.unsub();
|
||||
clearInterval(existing.keepalive);
|
||||
activeConnections.delete(existing.ws);
|
||||
@@ -115,7 +51,7 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
||||
// the full conversation history — assistant replies are inbound events.
|
||||
const missed = bus.getEventsSince(0);
|
||||
if (missed.length > 0) {
|
||||
console.log(`[WS] Replaying ${missed.length} missed event(s)`);
|
||||
log(`[WS] Replaying ${missed.length} missed event(s)`);
|
||||
for (const event of missed) {
|
||||
if (ws.readyState !== 1) break;
|
||||
try {
|
||||
@@ -131,10 +67,10 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
||||
if (event.direction !== "outbound") return;
|
||||
try {
|
||||
const sdkMsg = toSDKMessage(event);
|
||||
console.log(`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`);
|
||||
log(`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`);
|
||||
ws.send(sdkMsg);
|
||||
} catch (err) {
|
||||
console.error("[RC-DEBUG] [WS] send error:", err);
|
||||
logError("[RC-DEBUG] [WS] send error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -162,7 +98,7 @@ export function handleWebSocketMessage(ws: WSContext, sessionId: string, data: s
|
||||
try {
|
||||
ingestBridgeMessage(sessionId, JSON.parse(line));
|
||||
} catch (err) {
|
||||
console.error("[WS] parse error:", err);
|
||||
logError("[WS] parse error:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,7 +110,7 @@ export function handleWebSocketClose(ws: WSContext, sessionId: string, code?: nu
|
||||
const entry = cleanupBySession.get(sessionId);
|
||||
const duration = entry ? Math.round((Date.now() - entry.openTime) / 1000) : -1;
|
||||
|
||||
console.log(`[WS] Close session=${sessionId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
|
||||
log(`[WS] Close session=${sessionId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
|
||||
|
||||
if (entry) {
|
||||
entry.unsub();
|
||||
@@ -216,7 +152,7 @@ export function ingestBridgeMessage(sessionId: string, msg: Record<string, unkno
|
||||
|
||||
const eventType = deriveEventType(msg);
|
||||
|
||||
console.log(`[RC-DEBUG] [WS] <- bridge (inbound): sessionId=${sessionId} type=${eventType}${msg.uuid ? ` uuid=${msg.uuid}` : ""} msg=${JSON.stringify(msg).slice(0, 300)}`);
|
||||
log(`[RC-DEBUG] [WS] <- bridge (inbound): sessionId=${sessionId} type=${eventType}${msg.uuid ? ` uuid=${msg.uuid}` : ""} msg=${JSON.stringify(msg).slice(0, 300)}`);
|
||||
|
||||
let payload: unknown;
|
||||
|
||||
@@ -235,7 +171,11 @@ export function ingestBridgeMessage(sessionId: string, msg: Record<string, unkno
|
||||
}
|
||||
payload = { message: msg.message, uuid: msg.uuid, content: text };
|
||||
} else if (eventType === "user" || eventType === "system") {
|
||||
payload = { message: msg.message, uuid: msg.uuid };
|
||||
payload = {
|
||||
message: msg.message,
|
||||
uuid: msg.uuid,
|
||||
...(typeof msg.isSynthetic === "boolean" ? { isSynthetic: msg.isSynthetic } : {}),
|
||||
};
|
||||
} else if (eventType === "control_request") {
|
||||
payload = { request_id: msg.request_id, request: msg.request };
|
||||
} else if (eventType === "control_response") {
|
||||
@@ -256,7 +196,7 @@ export function closeAllConnections(): void {
|
||||
const count = activeConnections.size;
|
||||
if (count === 0) return;
|
||||
|
||||
console.log(`[WS] Gracefully closing ${count} active connection(s)...`);
|
||||
log(`[WS] Gracefully closing ${count} active connection(s)...`);
|
||||
for (const [sessionId, entry] of cleanupBySession) {
|
||||
try {
|
||||
entry.unsub();
|
||||
@@ -270,5 +210,5 @@ export function closeAllConnections(): void {
|
||||
}
|
||||
cleanupBySession.clear();
|
||||
activeConnections.clear();
|
||||
console.log("[WS] All connections closed");
|
||||
log("[WS] All connections closed");
|
||||
}
|
||||
|
||||
@@ -70,6 +70,14 @@ export interface SessionResponse {
|
||||
username: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
automation_state?: AutomationStateResponse;
|
||||
}
|
||||
|
||||
export interface AutomationStateResponse {
|
||||
enabled: boolean;
|
||||
phase: "standby" | "sleeping" | null;
|
||||
next_tick_at: number | null;
|
||||
sleep_until: number | null;
|
||||
}
|
||||
|
||||
// --- v2 Code Sessions ---
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface ControlRequest extends SDKMessage {
|
||||
export type SessionEventType =
|
||||
| "user"
|
||||
| "assistant"
|
||||
| "automation_state"
|
||||
| "permission_request"
|
||||
| "permission_response"
|
||||
| "control_request"
|
||||
@@ -49,6 +50,7 @@ export type SessionEventType =
|
||||
export interface NormalizedEventPayload {
|
||||
content: string;
|
||||
raw?: unknown;
|
||||
isSynthetic?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,24 @@
|
||||
*/
|
||||
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,
|
||||
getActivityMode,
|
||||
removeLoading,
|
||||
resetReplayState,
|
||||
renderReplayPendingRequests,
|
||||
setAutomationActivity,
|
||||
showLoading,
|
||||
} from "./render.js";
|
||||
import { initTaskPanel, toggleTaskPanel, resetTaskState } from "./task-panel.js";
|
||||
import {
|
||||
createAutomationState,
|
||||
getAutomationActivity,
|
||||
getAutomationIndicator,
|
||||
reduceAutomationState,
|
||||
renderAutomationIcon,
|
||||
shouldPulseAutomationIndicator,
|
||||
} from "./automation.js";
|
||||
import { esc, formatTime, statusClass, isClosedSessionStatus } from "./utils.js";
|
||||
|
||||
// ============================================================
|
||||
@@ -16,6 +32,8 @@ let currentSessionId = null;
|
||||
let currentSessionStatus = null;
|
||||
let dashboardInterval = null;
|
||||
let cachedEnvs = [];
|
||||
let automationState = createAutomationState();
|
||||
let automationPulseTimer = null;
|
||||
|
||||
function generateMessageUuid() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
@@ -24,6 +42,82 @@ function generateMessageUuid() {
|
||||
return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function renderAutomationIndicator() {
|
||||
const indicatorEl = document.getElementById("session-automation");
|
||||
if (!indicatorEl) return;
|
||||
|
||||
const indicator = getAutomationIndicator(automationState);
|
||||
if (!indicator.visible) {
|
||||
indicatorEl.className = "automation-pill hidden";
|
||||
indicatorEl.dataset.pulsing = "false";
|
||||
indicatorEl.innerHTML = "";
|
||||
indicatorEl.removeAttribute("title");
|
||||
return;
|
||||
}
|
||||
|
||||
indicatorEl.className = `automation-pill automation-pill-${indicator.tone}`;
|
||||
if (indicatorEl.dataset.pulsing === "true") {
|
||||
indicatorEl.classList.add("is-pulsing");
|
||||
}
|
||||
indicatorEl.innerHTML = `
|
||||
${renderAutomationIcon(indicator.iconVariant, { className: "automation-pill-icon" })}
|
||||
<span class="automation-pill-label">${esc(indicator.label)}</span>
|
||||
`;
|
||||
indicatorEl.title = indicator.title;
|
||||
}
|
||||
|
||||
function syncAutomationUI() {
|
||||
renderAutomationIndicator();
|
||||
setAutomationActivity(getAutomationActivity(automationState));
|
||||
}
|
||||
|
||||
function stopAutomationPulse() {
|
||||
if (automationPulseTimer) {
|
||||
clearTimeout(automationPulseTimer);
|
||||
automationPulseTimer = null;
|
||||
}
|
||||
const indicatorEl = document.getElementById("session-automation");
|
||||
if (indicatorEl) {
|
||||
indicatorEl.dataset.pulsing = "false";
|
||||
indicatorEl.classList.remove("is-pulsing");
|
||||
}
|
||||
}
|
||||
|
||||
function pulseAutomationIndicator() {
|
||||
if (!getAutomationIndicator(automationState).visible) return;
|
||||
|
||||
stopAutomationPulse();
|
||||
const indicatorEl = document.getElementById("session-automation");
|
||||
if (!indicatorEl) return;
|
||||
|
||||
indicatorEl.dataset.pulsing = "true";
|
||||
indicatorEl.classList.add("is-pulsing");
|
||||
automationPulseTimer = setTimeout(() => {
|
||||
indicatorEl.dataset.pulsing = "false";
|
||||
indicatorEl.classList.remove("is-pulsing");
|
||||
automationPulseTimer = null;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function resetAutomationIndicator() {
|
||||
automationState = createAutomationState();
|
||||
stopAutomationPulse();
|
||||
syncAutomationUI();
|
||||
}
|
||||
|
||||
function applyAutomationEvent(event, { replay = false } = {}) {
|
||||
automationState = reduceAutomationState(automationState, event);
|
||||
syncAutomationUI();
|
||||
if (!replay && shouldPulseAutomationIndicator(event)) {
|
||||
pulseAutomationIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
function applyAutomationSnapshot(snapshot) {
|
||||
if (snapshot === undefined) return;
|
||||
applyAutomationEvent({ type: "automation_state", payload: snapshot }, { replay: true });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Router
|
||||
// ============================================================
|
||||
@@ -75,7 +169,7 @@ function applySessionStatus(status) {
|
||||
|
||||
if (closed) {
|
||||
removeLoading();
|
||||
window.__updateActionBtn?.(false);
|
||||
window.__updateActionBtn?.("idle");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +180,7 @@ function handleSessionEvent(event) {
|
||||
disconnectSSE();
|
||||
}
|
||||
}
|
||||
applyAutomationEvent(event);
|
||||
appendEvent(event);
|
||||
}
|
||||
|
||||
@@ -104,7 +199,9 @@ async function syncClosedSessionState(err, actionLabel) {
|
||||
const session = await apiFetchSession(currentSessionId);
|
||||
applySessionStatus(session.status);
|
||||
if (isClosedSessionStatus(session.status)) {
|
||||
appendEvent({ type: "session_status", payload: { status: session.status } });
|
||||
const closedEvent = { type: "session_status", payload: { status: session.status } };
|
||||
applyAutomationEvent(closedEvent);
|
||||
appendEvent(closedEvent);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -159,6 +256,7 @@ async function handleRoute() {
|
||||
// Default: /code → dashboard
|
||||
currentSessionId = null;
|
||||
currentSessionStatus = null;
|
||||
resetAutomationIndicator();
|
||||
showPage("dashboard");
|
||||
disconnectSSE();
|
||||
renderDashboard();
|
||||
@@ -233,6 +331,8 @@ function stopDashboardRefresh() {
|
||||
|
||||
async function renderSessionDetail(id) {
|
||||
currentSessionId = id;
|
||||
resetAutomationIndicator();
|
||||
let session = null;
|
||||
|
||||
// Reset task state for new session and init panel
|
||||
resetTaskState();
|
||||
@@ -240,7 +340,7 @@ async function renderSessionDetail(id) {
|
||||
if (taskPanelEl) initTaskPanel(taskPanelEl);
|
||||
|
||||
try {
|
||||
const session = await apiFetchSession(id);
|
||||
session = await apiFetchSession(id);
|
||||
document.getElementById("session-title").textContent = session.title || session.id;
|
||||
document.getElementById("session-id").textContent = session.id;
|
||||
document.getElementById("session-env").textContent = session.environment_id || "";
|
||||
@@ -254,6 +354,7 @@ async function renderSessionDetail(id) {
|
||||
document.getElementById("event-stream").innerHTML = "";
|
||||
document.getElementById("permission-area").innerHTML = "";
|
||||
document.getElementById("permission-area").classList.add("hidden");
|
||||
applyAutomationSnapshot(session?.automation_state);
|
||||
|
||||
// Load historical events before connecting to live stream
|
||||
resetReplayState();
|
||||
@@ -262,6 +363,7 @@ async function renderSessionDetail(id) {
|
||||
const { events } = await apiFetchSessionHistory(id);
|
||||
if (events && events.length > 0) {
|
||||
for (const event of events) {
|
||||
applyAutomationEvent(event, { replay: true });
|
||||
appendEvent(event, { replay: true });
|
||||
if (event.seqNum && event.seqNum > lastSeqNum) lastSeqNum = event.seqNum;
|
||||
}
|
||||
@@ -273,7 +375,9 @@ async function renderSessionDetail(id) {
|
||||
renderReplayPendingRequests();
|
||||
|
||||
if (isClosedSessionStatus(currentSessionStatus)) {
|
||||
appendEvent({ type: "session_status", payload: { status: currentSessionStatus } });
|
||||
const closedEvent = { type: "session_status", payload: { status: currentSessionStatus } };
|
||||
applyAutomationEvent(closedEvent);
|
||||
appendEvent(closedEvent);
|
||||
disconnectSSE();
|
||||
return;
|
||||
}
|
||||
@@ -291,17 +395,20 @@ function setupControlBar() {
|
||||
const iconSend = document.getElementById("action-icon-send");
|
||||
const iconStop = document.getElementById("action-icon-stop");
|
||||
|
||||
function setBtnState(loading) {
|
||||
actionBtn.classList.toggle("loading", loading);
|
||||
actionBtn.setAttribute("aria-label", loading ? "Stop" : "Send");
|
||||
iconSend.classList.toggle("hidden", loading);
|
||||
iconStop.classList.toggle("hidden", !loading);
|
||||
function setBtnState(mode) {
|
||||
const working = mode === "working";
|
||||
actionBtn.classList.toggle("loading", working);
|
||||
actionBtn.dataset.mode = mode || "idle";
|
||||
actionBtn.setAttribute("aria-label", working ? "Stop" : "Send");
|
||||
iconSend.classList.toggle("hidden", working);
|
||||
iconStop.classList.toggle("hidden", !working);
|
||||
}
|
||||
|
||||
window.__updateActionBtn = setBtnState;
|
||||
setBtnState(getActivityMode());
|
||||
|
||||
actionBtn.addEventListener("click", () => {
|
||||
if (isLoading()) {
|
||||
if (getActivityMode() === "working") {
|
||||
doInterrupt();
|
||||
} else {
|
||||
sendMessage();
|
||||
@@ -319,7 +426,6 @@ async function doInterrupt() {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await apiInterrupt(currentSessionId);
|
||||
appendEvent({ type: "interrupt", payload: { message: "Session interrupted" } });
|
||||
} catch (err) {
|
||||
await syncClosedSessionState(err, "Interrupt failed");
|
||||
} finally {
|
||||
@@ -460,11 +566,28 @@ window._submitAnswers = async function (requestId, btn) {
|
||||
|
||||
function removePermissionPrompt(btn) {
|
||||
const prompt = btn.closest(".permission-prompt, .ask-panel, .plan-panel");
|
||||
const requestId = prompt?.dataset?.requestId || null;
|
||||
if (prompt) prompt.remove();
|
||||
if (requestId) {
|
||||
const stream = document.getElementById("event-stream");
|
||||
stream?.querySelectorAll("[data-pending-request-id]").forEach((row) => {
|
||||
if (row.dataset.pendingRequestId === requestId) row.remove();
|
||||
});
|
||||
}
|
||||
const area = document.getElementById("permission-area");
|
||||
if (area && area.children.length === 0) area.classList.add("hidden");
|
||||
}
|
||||
|
||||
function appendLocalSystemMessage(text) {
|
||||
const stream = document.getElementById("event-stream");
|
||||
if (!stream) return;
|
||||
const row = document.createElement("div");
|
||||
row.className = "msg-row system";
|
||||
row.innerHTML = `<div class="msg-bubble">${esc(text)}</div>`;
|
||||
stream.appendChild(row);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ExitPlanMode interactions
|
||||
// ============================================================
|
||||
@@ -509,6 +632,7 @@ window._submitPlanResponse = async function (requestId, btn) {
|
||||
...(feedback ? { message: feedback } : {}),
|
||||
});
|
||||
removePermissionPrompt(btn);
|
||||
appendLocalSystemMessage("Feedback sent. Continuing in plan mode.");
|
||||
} else {
|
||||
// Approval with permission mode
|
||||
const modeMap = {
|
||||
|
||||
380
packages/remote-control-server/web/automation.js
Normal file
380
packages/remote-control-server/web/automation.js
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Remote Control — Automation helpers
|
||||
*
|
||||
* Centralizes detection of non-human inputs so the web UI can hide
|
||||
* internal prompts while still surfacing session state.
|
||||
*/
|
||||
|
||||
export const PROACTIVE_ENABLED_TEXT =
|
||||
"Proactive mode enabled — model will work autonomously between ticks";
|
||||
export const PROACTIVE_DISABLED_TEXT = "Proactive mode disabled";
|
||||
|
||||
const CLOSED_SESSION_STATUSES = new Set(["archived", "inactive"]);
|
||||
|
||||
const HIDDEN_AUTOMATION_TAGS = new Set([
|
||||
"bash-input",
|
||||
"bash-stderr",
|
||||
"bash-stdout",
|
||||
"channel",
|
||||
"channel-message",
|
||||
"command-args",
|
||||
"command-message",
|
||||
"command-name",
|
||||
"cross-session-message",
|
||||
"fork-boilerplate",
|
||||
"local-command-caveat",
|
||||
"local-command-stderr",
|
||||
"local-command-stdout",
|
||||
"output-file",
|
||||
"reason",
|
||||
"remote-review",
|
||||
"remote-review-progress",
|
||||
"status",
|
||||
"summary",
|
||||
"system-reminder",
|
||||
"task-id",
|
||||
"task-notification",
|
||||
"task-type",
|
||||
"teammate-message",
|
||||
"tick",
|
||||
"tool-use-id",
|
||||
"ultraplan",
|
||||
"worktree",
|
||||
"worktreeBranch",
|
||||
"worktreePath",
|
||||
]);
|
||||
|
||||
const PRIMARY_AUTOMATION_TAGS = new Set([
|
||||
"bash-input",
|
||||
"bash-stderr",
|
||||
"bash-stdout",
|
||||
"channel-message",
|
||||
"command-args",
|
||||
"command-message",
|
||||
"command-name",
|
||||
"cross-session-message",
|
||||
"fork-boilerplate",
|
||||
"local-command-caveat",
|
||||
"local-command-stderr",
|
||||
"local-command-stdout",
|
||||
"remote-review",
|
||||
"remote-review-progress",
|
||||
"system-reminder",
|
||||
"task-notification",
|
||||
"teammate-message",
|
||||
"tick",
|
||||
"ultraplan",
|
||||
]);
|
||||
|
||||
const WORKING_AUTOMATION_TAGS = new Set(
|
||||
[...PRIMARY_AUTOMATION_TAGS].filter(
|
||||
(tag) => tag !== "local-command-caveat" && tag !== "system-reminder",
|
||||
),
|
||||
);
|
||||
|
||||
const XML_ONLY_BLOCK_PATTERN =
|
||||
/^(?:\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*)+$/;
|
||||
const XML_BLOCK_PATTERN =
|
||||
/\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gy;
|
||||
|
||||
function normalizeAutomationStatePayload(payload) {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return {
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: payload.enabled === true,
|
||||
phase: payload.phase === "standby" || payload.phase === "sleeping" ? payload.phase : null,
|
||||
next_tick_at: typeof payload.next_tick_at === "number" ? payload.next_tick_at : null,
|
||||
sleep_until: typeof payload.sleep_until === "number" ? payload.sleep_until : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractEventText(payload) {
|
||||
if (!payload) return "";
|
||||
|
||||
if (typeof payload.content === "string" && payload.content) return payload.content;
|
||||
|
||||
const msg = payload.message;
|
||||
if (msg && typeof msg === "object") {
|
||||
const mc = msg.content;
|
||||
if (typeof mc === "string") return mc;
|
||||
if (Array.isArray(mc)) {
|
||||
return mc
|
||||
.filter((block) => block && typeof block === "object" && block.type === "text")
|
||||
.map((block) => block.text || "")
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
return typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function getOpeningTagNames(text) {
|
||||
const trimmed = String(text).trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
XML_BLOCK_PATTERN.lastIndex = 0;
|
||||
const tags = [];
|
||||
while (XML_BLOCK_PATTERN.lastIndex < trimmed.length) {
|
||||
const match = XML_BLOCK_PATTERN.exec(trimmed);
|
||||
if (!match) return [];
|
||||
tags.push(match[1]);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function isAutomationEnvelopeText(text) {
|
||||
const trimmed = typeof text === "string" ? text.trim() : "";
|
||||
if (!trimmed) return false;
|
||||
if (!XML_ONLY_BLOCK_PATTERN.test(trimmed)) return false;
|
||||
|
||||
const tagNames = getOpeningTagNames(trimmed);
|
||||
return (
|
||||
tagNames.length > 0 &&
|
||||
tagNames.every((tagName) => HIDDEN_AUTOMATION_TAGS.has(tagName)) &&
|
||||
tagNames.some((tagName) => PRIMARY_AUTOMATION_TAGS.has(tagName))
|
||||
);
|
||||
}
|
||||
|
||||
export function isHiddenAutomationUserPayload(payload) {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
if (payload.isSynthetic === true) return true;
|
||||
return isAutomationEnvelopeText(extractEventText(payload));
|
||||
}
|
||||
|
||||
export function shouldHideAutomationUserEvent(payload, direction = "inbound") {
|
||||
return direction === "inbound" && isHiddenAutomationUserPayload(payload);
|
||||
}
|
||||
|
||||
export function shouldStartAutomationWorkFromUserEvent(payload, direction = "inbound") {
|
||||
if (!shouldHideAutomationUserEvent(payload, direction)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = extractEventText(payload).trim();
|
||||
if (!text || !XML_ONLY_BLOCK_PATTERN.test(text)) {
|
||||
return payload?.isSynthetic === true;
|
||||
}
|
||||
|
||||
const tagNames = getOpeningTagNames(text);
|
||||
return tagNames.some((tagName) => WORKING_AUTOMATION_TAGS.has(tagName));
|
||||
}
|
||||
|
||||
export function createAutomationState() {
|
||||
return {
|
||||
proactive: false,
|
||||
autoRun: false,
|
||||
hasAuthority: false,
|
||||
enabled: false,
|
||||
phase: null,
|
||||
nextTickAt: null,
|
||||
sleepUntil: null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyAuthoritativeAutomationState(state, payload) {
|
||||
const normalized = normalizeAutomationStatePayload(payload);
|
||||
state.hasAuthority = true;
|
||||
state.enabled = normalized.enabled;
|
||||
state.phase = normalized.phase;
|
||||
state.nextTickAt = normalized.next_tick_at;
|
||||
state.sleepUntil = normalized.sleep_until;
|
||||
state.proactive = normalized.enabled;
|
||||
state.autoRun = false;
|
||||
return state;
|
||||
}
|
||||
|
||||
export function reduceAutomationState(state, event) {
|
||||
const next = state ? { ...state } : createAutomationState();
|
||||
if (!event || typeof event !== "object") return next;
|
||||
|
||||
const type = event.type || "unknown";
|
||||
const payload = event.payload || {};
|
||||
const direction = event.direction || "inbound";
|
||||
|
||||
if (type === "automation_state") {
|
||||
return applyAuthoritativeAutomationState(next, payload);
|
||||
}
|
||||
|
||||
if (type === "session_status") {
|
||||
if (CLOSED_SESSION_STATUSES.has(payload.status)) {
|
||||
if (next.hasAuthority) {
|
||||
return applyAuthoritativeAutomationState(next, null);
|
||||
}
|
||||
next.proactive = false;
|
||||
next.autoRun = false;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
if (next.hasAuthority) {
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const text = extractEventText(payload).trim();
|
||||
if (text === PROACTIVE_ENABLED_TEXT) {
|
||||
next.proactive = true;
|
||||
next.autoRun = false;
|
||||
return next;
|
||||
}
|
||||
if (text === PROACTIVE_DISABLED_TEXT) {
|
||||
next.proactive = false;
|
||||
next.autoRun = false;
|
||||
return next;
|
||||
}
|
||||
next.autoRun = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === "result" || type === "result_success" || type === "error" || type === "interrupt") {
|
||||
next.autoRun = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === "user" && shouldHideAutomationUserEvent(payload, direction)) {
|
||||
next.autoRun = true;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function shouldPulseAutomationIndicator(event) {
|
||||
if (!event || typeof event !== "object") return false;
|
||||
|
||||
if (event.type === "automation_state") {
|
||||
return event.payload?.enabled === true;
|
||||
}
|
||||
|
||||
if (event.type === "assistant") {
|
||||
const text = extractEventText(event.payload || {}).trim();
|
||||
return text === PROACTIVE_ENABLED_TEXT;
|
||||
}
|
||||
|
||||
return event.type === "user" && shouldHideAutomationUserEvent(event.payload || {}, event.direction || "inbound");
|
||||
}
|
||||
|
||||
export function getAutomationIndicator(state) {
|
||||
if (state?.hasAuthority) {
|
||||
if (!state.enabled) {
|
||||
return {
|
||||
visible: false,
|
||||
label: "",
|
||||
tone: "",
|
||||
title: "",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.phase === "sleeping") {
|
||||
return {
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "sleeping",
|
||||
title: "Claude Code is in proactive mode and currently sleeping until the next wake-up or user message.",
|
||||
iconVariant: "sleeping",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.phase === "standby") {
|
||||
return {
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and waiting for the next scheduled check-in.",
|
||||
iconVariant: "standby",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and may continue working between user messages.",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
if (state?.proactive) {
|
||||
return {
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and may continue working between user messages.",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
if (state?.autoRun) {
|
||||
return {
|
||||
visible: true,
|
||||
label: "Auto Run",
|
||||
tone: "auto-run",
|
||||
title: "Claude Code is processing an automatic background trigger.",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
visible: false,
|
||||
label: "",
|
||||
tone: "",
|
||||
title: "",
|
||||
iconVariant: "active",
|
||||
};
|
||||
}
|
||||
|
||||
export function getAutomationActivity(state) {
|
||||
if (!state?.hasAuthority || !state.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state.phase === "standby") {
|
||||
return {
|
||||
mode: "standby",
|
||||
label: "standby",
|
||||
endsAt: state.nextTickAt,
|
||||
iconVariant: "standby",
|
||||
};
|
||||
}
|
||||
|
||||
if (state.phase === "sleeping") {
|
||||
return {
|
||||
mode: "sleeping",
|
||||
label: "sleeping",
|
||||
endsAt: state.sleepUntil,
|
||||
iconVariant: "sleeping",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function renderAutomationIcon(variant = "active", { className = "", decorative = true } = {}) {
|
||||
const classes = ["clawd-icon", `clawd-icon-${variant}`, className].filter(Boolean).join(" ");
|
||||
const ariaAttrs = decorative ? 'aria-hidden="true"' : 'role="img" aria-label="Claude Code status"';
|
||||
|
||||
return `
|
||||
<span class="${classes}" ${ariaAttrs}>
|
||||
<svg viewBox="0 0 40 30" fill="none">
|
||||
<path class="clawd-arm clawd-arm-left" d="M8.5 13.4C6.6 12.8 5.4 11.4 4.8 9.4C4.6 8.6 4.9 7.7 5.6 7.3C6.3 6.9 7.2 7 7.8 7.6L10.8 10.6L8.5 13.4Z" />
|
||||
<path class="clawd-arm clawd-arm-right" d="M31.5 13.4C33.4 12.8 34.6 11.4 35.2 9.4C35.4 8.6 35.1 7.7 34.4 7.3C33.7 6.9 32.8 7 32.2 7.6L29.2 10.6L31.5 13.4Z" />
|
||||
<path class="clawd-shell" d="M10 12.2C10 7.9 13.5 4.4 17.8 4.4H22.2C26.5 4.4 30 7.9 30 12.2V17.3C30 21 27 24 23.3 24H16.7C13 24 10 21 10 17.3V12.2Z" />
|
||||
<circle class="clawd-eye clawd-eye-left" cx="17.2" cy="13.4" r="1.55" />
|
||||
<circle class="clawd-eye clawd-eye-right" cx="22.8" cy="13.4" r="1.55" />
|
||||
<path class="clawd-eye-line clawd-eye-line-left" d="M15.9 13.6C16.3 12.8 17 12.4 17.9 12.4" />
|
||||
<path class="clawd-eye-line clawd-eye-line-right" d="M22.1 12.4C23 12.4 23.7 12.8 24.1 13.6" />
|
||||
<path class="clawd-foot clawd-foot-left" d="M14.3 25.1C14.3 24 15.2 23.1 16.3 23.1C17.4 23.1 18.3 24 18.3 25.1V25.8H14.3V25.1Z" />
|
||||
<path class="clawd-foot clawd-foot-right" d="M21.7 25.1C21.7 24 22.6 23.1 23.7 23.1C24.8 23.1 25.7 24 25.7 25.1V25.8H21.7V25.1Z" />
|
||||
</svg>
|
||||
<span class="clawd-z clawd-z-1">Z</span>
|
||||
<span class="clawd-z clawd-z-2">Z</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
207
packages/remote-control-server/web/automation.test.js
Normal file
207
packages/remote-control-server/web/automation.test.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
PROACTIVE_DISABLED_TEXT,
|
||||
PROACTIVE_ENABLED_TEXT,
|
||||
createAutomationState,
|
||||
getAutomationActivity,
|
||||
getAutomationIndicator,
|
||||
isAutomationEnvelopeText,
|
||||
reduceAutomationState,
|
||||
shouldHideAutomationUserEvent,
|
||||
shouldStartAutomationWorkFromUserEvent,
|
||||
} from "./automation.js";
|
||||
|
||||
describe("automation helpers", () => {
|
||||
test("keeps real user text visible", () => {
|
||||
expect(shouldHideAutomationUserEvent({ content: "hello from a human" }, "inbound")).toBe(false);
|
||||
});
|
||||
|
||||
test("hides internal xml wrappers without synthetic metadata", () => {
|
||||
expect(isAutomationEnvelopeText("<tick>2:56:47 PM</tick>")).toBe(true);
|
||||
expect(isAutomationEnvelopeText("<system-reminder>\nDo useful work.\n</system-reminder>")).toBe(true);
|
||||
expect(
|
||||
isAutomationEnvelopeText(
|
||||
"<task-notification><summary>Finished</summary><output-file>/tmp/out.log</output-file></task-notification>",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldHideAutomationUserEvent(
|
||||
{ content: "<local-command-caveat>Generated while running local commands.</local-command-caveat>" },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("does not treat slash-command scaffolding as active work", () => {
|
||||
expect(
|
||||
shouldStartAutomationWorkFromUserEvent(
|
||||
{ content: "<local-command-caveat>Generated while running local commands.</local-command-caveat>" },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldStartAutomationWorkFromUserEvent(
|
||||
{
|
||||
content:
|
||||
"<system-reminder>\nProactive mode is now enabled. You will receive periodic <tick> prompts.\n</system-reminder>",
|
||||
isSynthetic: true,
|
||||
},
|
||||
"inbound",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps true automatic triggers eligible for loading state", () => {
|
||||
expect(
|
||||
shouldStartAutomationWorkFromUserEvent(
|
||||
{ content: "<tick>2:56:47 PM</tick>", isSynthetic: true },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldStartAutomationWorkFromUserEvent(
|
||||
{ content: "scheduled job: refresh analytics cache", isSynthetic: true },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("hides synthetic automatic prompts even when they are plain text", () => {
|
||||
expect(
|
||||
shouldHideAutomationUserEvent(
|
||||
{ content: "scheduled job: refresh analytics cache", isSynthetic: true },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps mixed human text with tags visible", () => {
|
||||
expect(
|
||||
shouldHideAutomationUserEvent(
|
||||
{ content: "Please keep this: <system-reminder>not metadata</system-reminder>" },
|
||||
"inbound",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("shows autopilot while proactive mode remains active", () => {
|
||||
let state = createAutomationState();
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "assistant",
|
||||
payload: { content: PROACTIVE_ENABLED_TEXT },
|
||||
});
|
||||
expect(getAutomationIndicator(state)).toEqual({
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and may continue working between user messages.",
|
||||
iconVariant: "active",
|
||||
});
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "user",
|
||||
direction: "inbound",
|
||||
payload: { content: "<tick>3:15:00 PM</tick>" },
|
||||
});
|
||||
expect(getAutomationIndicator(state).label).toBe("Autopilot");
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "assistant",
|
||||
payload: { content: "Working on background maintenance." },
|
||||
});
|
||||
expect(getAutomationIndicator(state).label).toBe("Autopilot");
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "assistant",
|
||||
payload: { content: PROACTIVE_DISABLED_TEXT },
|
||||
});
|
||||
expect(getAutomationIndicator(state).visible).toBe(false);
|
||||
});
|
||||
|
||||
test("shows auto run until an automatic trigger settles", () => {
|
||||
let state = createAutomationState();
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "user",
|
||||
direction: "inbound",
|
||||
payload: { content: "scheduled job: refresh analytics cache", isSynthetic: true },
|
||||
});
|
||||
expect(getAutomationIndicator(state).label).toBe("Auto Run");
|
||||
expect(getAutomationIndicator(state).iconVariant).toBe("active");
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "assistant",
|
||||
payload: { content: "Completed scheduled refresh." },
|
||||
});
|
||||
expect(getAutomationIndicator(state).visible).toBe(false);
|
||||
});
|
||||
|
||||
test("authoritative automation_state drives standby and sleeping states", () => {
|
||||
let state = createAutomationState();
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "automation_state",
|
||||
payload: {
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 123456,
|
||||
sleep_until: null,
|
||||
},
|
||||
});
|
||||
expect(getAutomationIndicator(state)).toEqual({
|
||||
visible: true,
|
||||
label: "Autopilot",
|
||||
tone: "proactive",
|
||||
title: "Claude Code is in proactive mode and waiting for the next scheduled check-in.",
|
||||
iconVariant: "standby",
|
||||
});
|
||||
expect(getAutomationActivity(state)).toEqual({
|
||||
mode: "standby",
|
||||
label: "standby",
|
||||
endsAt: 123456,
|
||||
iconVariant: "standby",
|
||||
});
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "automation_state",
|
||||
payload: {
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: null,
|
||||
sleep_until: 999999,
|
||||
},
|
||||
});
|
||||
expect(getAutomationIndicator(state).tone).toBe("sleeping");
|
||||
expect(getAutomationIndicator(state).iconVariant).toBe("sleeping");
|
||||
expect(getAutomationActivity(state)).toEqual({
|
||||
mode: "sleeping",
|
||||
label: "sleeping",
|
||||
endsAt: 999999,
|
||||
iconVariant: "sleeping",
|
||||
});
|
||||
});
|
||||
|
||||
test("authoritative disabled snapshot suppresses heuristic auto-run fallback", () => {
|
||||
let state = createAutomationState();
|
||||
|
||||
state = reduceAutomationState(state, {
|
||||
type: "automation_state",
|
||||
payload: {
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
},
|
||||
});
|
||||
state = reduceAutomationState(state, {
|
||||
type: "user",
|
||||
direction: "inbound",
|
||||
payload: { content: "<tick>3:15:00 PM</tick>" },
|
||||
});
|
||||
|
||||
expect(getAutomationIndicator(state).visible).toBe(false);
|
||||
expect(getAutomationActivity(state)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,7 @@
|
||||
<div class="session-meta-row">
|
||||
<span id="session-id" class="meta-item"></span>
|
||||
<span id="session-status" class="status-badge"></span>
|
||||
<span id="session-automation" class="automation-pill hidden" aria-live="polite"></span>
|
||||
<span id="session-env" class="meta-item"></span>
|
||||
<span id="session-time" class="meta-item"></span>
|
||||
<button id="task-panel-toggle" class="nav-link btn-text" title="Tasks & Todos">
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
.msg-row.user { align-self: flex-end; }
|
||||
.msg-row.assistant { align-self: flex-start; }
|
||||
.msg-row.tool { align-self: flex-start; max-width: 95%; }
|
||||
.msg-row.tool-trace-row { align-self: flex-start; max-width: 92%; }
|
||||
.msg-row.system { align-self: center; }
|
||||
.msg-row.result { align-self: center; }
|
||||
|
||||
@@ -51,6 +52,124 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.assistant-turn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.assistant-turn-orphan {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.assistant-trace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.assistant-trace.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.assistant-trace-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid rgba(160, 120, 96, 0.16);
|
||||
background: rgba(245, 243, 239, 0.78);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px 6px 8px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
transition: all var(--transition-fast);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.assistant-trace-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(217, 119, 87, 0.28);
|
||||
background: rgba(250, 247, 242, 0.98);
|
||||
}
|
||||
|
||||
.assistant-trace-toggle.has-error {
|
||||
color: var(--red);
|
||||
border-color: rgba(196, 64, 64, 0.24);
|
||||
background: rgba(252, 238, 238, 0.88);
|
||||
}
|
||||
|
||||
.assistant-trace-glyph {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
min-width: 14px;
|
||||
}
|
||||
|
||||
.assistant-trace-glyph span {
|
||||
display: block;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.assistant-trace-glyph span:nth-child(1) { height: 7px; }
|
||||
.assistant-trace-glyph span:nth-child(2) { height: 10px; }
|
||||
.assistant-trace-glyph span:nth-child(3) { height: 5px; }
|
||||
|
||||
.assistant-trace-count {
|
||||
min-width: 1ch;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.assistant-trace-chevron {
|
||||
font-size: 0.9rem;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.assistant-trace-toggle.is-open .assistant-trace-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.assistant-trace-panel {
|
||||
width: min(100%, 720px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(160, 120, 96, 0.14);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(250, 247, 242, 0.98), rgba(245, 243, 239, 0.92)),
|
||||
var(--bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.assistant-trace-panel.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.assistant-trace-card {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.assistant-trace-card:hover {
|
||||
border-color: rgba(217, 119, 87, 0.24);
|
||||
}
|
||||
|
||||
.assistant-trace-card-error {
|
||||
border-color: rgba(196, 64, 64, 0.24);
|
||||
}
|
||||
|
||||
.assistant-trace-card-error:hover {
|
||||
border-color: rgba(196, 64, 64, 0.4);
|
||||
}
|
||||
|
||||
.msg-row.system .msg-bubble {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
@@ -98,6 +217,7 @@
|
||||
font-size: 0.7rem;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.tool-card-header.is-open .tool-icon,
|
||||
.tool-card-header:hover .tool-icon { transform: rotate(90deg); }
|
||||
|
||||
.tool-card-body {
|
||||
@@ -329,15 +449,51 @@
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.plan-panel .plan-content > :first-child { margin-top: 0; }
|
||||
.plan-panel .plan-content > :last-child { margin-bottom: 0; }
|
||||
.plan-panel .plan-content h1,
|
||||
.plan-panel .plan-content h2,
|
||||
.plan-panel .plan-content h3,
|
||||
.plan-panel .plan-content h4,
|
||||
.plan-panel .plan-content h5,
|
||||
.plan-panel .plan-content h6 {
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.3;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
}
|
||||
.plan-panel .plan-content h1 { font-size: 1.15rem; }
|
||||
.plan-panel .plan-content h2 { font-size: 1.05rem; }
|
||||
.plan-panel .plan-content h3,
|
||||
.plan-panel .plan-content h4,
|
||||
.plan-panel .plan-content h5,
|
||||
.plan-panel .plan-content h6 { font-size: 0.95rem; }
|
||||
.plan-panel .plan-content p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.plan-panel .plan-content ul,
|
||||
.plan-panel .plan-content ol {
|
||||
margin: 0 0 12px 1.35em;
|
||||
padding: 0;
|
||||
}
|
||||
.plan-panel .plan-content li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.plan-panel .plan-content pre {
|
||||
background: var(--bg-tool-card);
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 6px 0;
|
||||
margin: 10px 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.plan-panel .plan-content pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
.plan-panel .plan-content code {
|
||||
background: var(--bg-tool-card);
|
||||
padding: 2px 5px;
|
||||
@@ -479,3 +635,58 @@
|
||||
font-family: var(--font-mono);
|
||||
margin-left: auto;
|
||||
}
|
||||
.automation-activity-row {
|
||||
align-self: flex-start;
|
||||
max-width: 92%;
|
||||
}
|
||||
.automation-activity-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(217, 119, 87, 0.16);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(217, 119, 87, 0.08), rgba(250, 247, 242, 0.94)),
|
||||
var(--bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.automation-activity-standby .automation-activity-card {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.automation-activity-sleeping .automation-activity-card {
|
||||
color: var(--green);
|
||||
border-color: rgba(59, 138, 106, 0.16);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(59, 138, 106, 0.08), rgba(250, 247, 242, 0.94)),
|
||||
var(--bg-card);
|
||||
}
|
||||
.automation-activity-icon {
|
||||
width: 34px;
|
||||
height: 26px;
|
||||
}
|
||||
.automation-activity-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.automation-activity-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.automation-activity-countdown {
|
||||
margin-left: auto;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(160, 120, 96, 0.14);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -234,6 +234,164 @@
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.automation-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 10px 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
|
||||
}
|
||||
.automation-pill-icon { width: 24px; height: 18px; flex-shrink: 0; }
|
||||
.automation-pill-label { line-height: 1; }
|
||||
.automation-pill-proactive {
|
||||
color: var(--accent-hover);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(217, 119, 87, 0.12), rgba(217, 119, 87, 0.06)),
|
||||
var(--bg-card);
|
||||
border-color: rgba(217, 119, 87, 0.18);
|
||||
}
|
||||
.automation-pill-sleeping {
|
||||
color: var(--green);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(59, 138, 106, 0.14), rgba(59, 138, 106, 0.05)),
|
||||
var(--bg-card);
|
||||
border-color: rgba(59, 138, 106, 0.18);
|
||||
}
|
||||
.automation-pill-auto-run {
|
||||
color: var(--green);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(59, 138, 106, 0.12), rgba(59, 138, 106, 0.05)),
|
||||
var(--bg-card);
|
||||
border-color: rgba(59, 138, 106, 0.18);
|
||||
}
|
||||
.automation-pill.is-pulsing {
|
||||
animation: automationPillPulse 1.2s ease-out;
|
||||
}
|
||||
.automation-pill.is-pulsing .clawd-icon {
|
||||
animation: automationDotPulse 1.2s ease-out;
|
||||
}
|
||||
@keyframes automationPillPulse {
|
||||
0% { transform: translateY(0) scale(1); box-shadow: var(--shadow-sm); }
|
||||
35% { transform: translateY(-1px) scale(1.02); box-shadow: var(--shadow-md); }
|
||||
100% { transform: translateY(0) scale(1); box-shadow: var(--shadow-sm); }
|
||||
}
|
||||
@keyframes automationDotPulse {
|
||||
0% { transform: scale(1); opacity: 0.9; }
|
||||
35% { transform: scale(1.5); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 0.92; }
|
||||
}
|
||||
.clawd-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 30px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.clawd-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.clawd-shell,
|
||||
.clawd-foot { fill: currentColor; }
|
||||
.clawd-shell { opacity: 0.9; }
|
||||
.clawd-arm { fill: currentColor; opacity: 0.74; }
|
||||
.clawd-eye {
|
||||
fill: var(--text-primary);
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
.clawd-eye-line {
|
||||
display: none;
|
||||
stroke: var(--text-primary);
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.clawd-z {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -2px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.56rem;
|
||||
font-weight: 700;
|
||||
color: currentColor;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.clawd-z-2 {
|
||||
top: -9px;
|
||||
right: 4px;
|
||||
font-size: 0.48rem;
|
||||
}
|
||||
.clawd-icon-standby svg {
|
||||
animation: clawdStandbyBob 2.4s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-standby .clawd-eye-left {
|
||||
animation: clawdLookLeft 2.4s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-standby .clawd-eye-right {
|
||||
animation: clawdLookRight 2.4s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-sleeping svg {
|
||||
animation: clawdSleepFloat 3.2s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-eye {
|
||||
display: none;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-eye-line {
|
||||
display: block;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-z {
|
||||
opacity: 0.88;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-z-1 {
|
||||
animation: clawdSleepZ 2.7s ease-in-out infinite;
|
||||
}
|
||||
.clawd-icon-sleeping .clawd-z-2 {
|
||||
animation: clawdSleepZ 2.7s ease-in-out infinite 0.45s;
|
||||
}
|
||||
@keyframes clawdStandbyBob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-1px); }
|
||||
}
|
||||
@keyframes clawdLookLeft {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-0.8px); }
|
||||
55% { transform: translateX(0.6px); }
|
||||
}
|
||||
@keyframes clawdLookRight {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-0.6px); }
|
||||
55% { transform: translateX(0.8px); }
|
||||
}
|
||||
@keyframes clawdSleepFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(1px); }
|
||||
}
|
||||
@keyframes clawdSleepZ {
|
||||
0% { transform: translate(0, 0) scale(0.94); opacity: 0; }
|
||||
20% { opacity: 0.88; }
|
||||
100% { transform: translate(4px, -8px) scale(1.04); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.automation-pill.is-pulsing,
|
||||
.automation-pill.is-pulsing .clawd-icon,
|
||||
.clawd-icon-standby svg,
|
||||
.clawd-icon-standby .clawd-eye-left,
|
||||
.clawd-icon-standby .clawd-eye-right,
|
||||
.clawd-icon-sleeping svg,
|
||||
.clawd-icon-sleeping .clawd-z-1,
|
||||
.clawd-icon-sleeping .clawd-z-2 {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.meta-item {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
|
||||
30
packages/remote-control-server/web/render-activity.test.js
Normal file
30
packages/remote-control-server/web/render-activity.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
formatCountdownRemaining,
|
||||
resolveActivityMode,
|
||||
shouldRenderTranscriptActivity,
|
||||
} from "./render.js";
|
||||
|
||||
describe("render activity helpers", () => {
|
||||
test("authoritative standby and sleeping states override stale working spinners", () => {
|
||||
expect(resolveActivityMode(true, { mode: "standby" })).toBe("standby");
|
||||
expect(resolveActivityMode(true, { mode: "sleeping" })).toBe("sleeping");
|
||||
expect(resolveActivityMode(true, null)).toBe("working");
|
||||
expect(resolveActivityMode(false, null)).toBe("idle");
|
||||
});
|
||||
|
||||
test("formats countdowns compactly", () => {
|
||||
expect(formatCountdownRemaining(35_000, 0)).toBe("35s");
|
||||
expect(formatCountdownRemaining(185_000, 0)).toBe("3m 5s");
|
||||
expect(formatCountdownRemaining(3_900_000, 0)).toBe("1h 5m");
|
||||
expect(formatCountdownRemaining(null, 0)).toBe("");
|
||||
});
|
||||
|
||||
test("renders transcript activity only for active work", () => {
|
||||
expect(shouldRenderTranscriptActivity("working")).toBe(true);
|
||||
expect(shouldRenderTranscriptActivity("standby")).toBe(false);
|
||||
expect(shouldRenderTranscriptActivity("sleeping")).toBe(false);
|
||||
expect(shouldRenderTranscriptActivity("idle")).toBe(false);
|
||||
});
|
||||
});
|
||||
36
packages/remote-control-server/web/render-plan.test.js
Normal file
36
packages/remote-control-server/web/render-plan.test.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { formatPlanContent } from "./render.js";
|
||||
|
||||
describe("formatPlanContent", () => {
|
||||
test("renders headings, paragraphs, and lists for plan panels", () => {
|
||||
const html = formatPlanContent(`## Summary
|
||||
Line one
|
||||
Line two
|
||||
|
||||
- First item
|
||||
- Second item
|
||||
|
||||
1. Step one
|
||||
2. Step two`);
|
||||
|
||||
expect(html).toContain("<h2>Summary</h2>");
|
||||
expect(html).toContain("<p>Line one<br>Line two</p>");
|
||||
expect(html).toContain("<ul><li>First item</li><li>Second item</li></ul>");
|
||||
expect(html).toContain("<ol><li>Step one</li><li>Step two</li></ol>");
|
||||
});
|
||||
|
||||
test("escapes unsafe markup and preserves inline formatting plus code blocks", () => {
|
||||
const html = formatPlanContent(`**Bold** with \`inline\` and <script>alert(1)</script>
|
||||
|
||||
\`\`\`js
|
||||
const markup = "<div>";
|
||||
\`\`\``);
|
||||
|
||||
expect(html).toContain("<strong>Bold</strong>");
|
||||
expect(html).toContain("<code");
|
||||
expect(html).toContain("inline</code>");
|
||||
expect(html).toContain("<script>alert(1)</script>");
|
||||
expect(html).toContain("<pre><code>const markup = "<div>";</code></pre>");
|
||||
});
|
||||
});
|
||||
24
packages/remote-control-server/web/render-status.test.js
Normal file
24
packages/remote-control-server/web/render-status.test.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { isConversationClearedStatus } from "./render.js";
|
||||
|
||||
describe("status helpers", () => {
|
||||
test("detects direct conversation reset markers", () => {
|
||||
expect(isConversationClearedStatus({ status: "conversation_cleared" })).toBe(true);
|
||||
});
|
||||
|
||||
test("detects nested raw conversation reset markers", () => {
|
||||
expect(
|
||||
isConversationClearedStatus({
|
||||
status: "",
|
||||
raw: { status: "conversation_cleared" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores unrelated status payloads", () => {
|
||||
expect(isConversationClearedStatus({ status: "running" })).toBe(false);
|
||||
expect(isConversationClearedStatus({})).toBe(false);
|
||||
expect(isConversationClearedStatus(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
90
packages/remote-control-server/web/render-trace.test.js
Normal file
90
packages/remote-control-server/web/render-trace.test.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
addAssistantToolTraceHost,
|
||||
addToolTraceEntry,
|
||||
clearActiveToolTraceHost,
|
||||
createToolTraceState,
|
||||
} from "./render.js";
|
||||
|
||||
describe("tool trace grouping state", () => {
|
||||
test("keeps tool entries attached to the current assistant turn", () => {
|
||||
let state = createToolTraceState();
|
||||
|
||||
const assistant = addAssistantToolTraceHost(state, "Checking the repo");
|
||||
state = assistant.state;
|
||||
|
||||
const toolUse = addToolTraceEntry(state, "use");
|
||||
state = toolUse.state;
|
||||
|
||||
const toolResult = addToolTraceEntry(state, "result");
|
||||
state = toolResult.state;
|
||||
|
||||
expect(assistant.host).toEqual({
|
||||
id: "trace-1",
|
||||
kind: "assistant",
|
||||
assistantContent: "Checking the repo",
|
||||
entryKinds: [],
|
||||
});
|
||||
expect(toolUse.createdHost).toBeNull();
|
||||
expect(toolResult.createdHost).toBeNull();
|
||||
expect(state.hosts).toEqual([
|
||||
{
|
||||
id: "trace-1",
|
||||
kind: "assistant",
|
||||
assistantContent: "Checking the repo",
|
||||
entryKinds: ["use", "result"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("creates an orphan trace host when tool activity has no assistant turn", () => {
|
||||
const result = addToolTraceEntry(createToolTraceState(), "use");
|
||||
|
||||
expect(result.createdHost).toEqual({
|
||||
id: "trace-1",
|
||||
kind: "orphan",
|
||||
assistantContent: "",
|
||||
entryKinds: ["use"],
|
||||
});
|
||||
expect(result.state.hosts).toEqual([
|
||||
{
|
||||
id: "trace-1",
|
||||
kind: "orphan",
|
||||
assistantContent: "",
|
||||
entryKinds: ["use"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("starts a new orphan host after a visible user turn clears the active assistant host", () => {
|
||||
let state = createToolTraceState();
|
||||
state = addAssistantToolTraceHost(state, "Running tools").state;
|
||||
state = addToolTraceEntry(state, "use").state;
|
||||
|
||||
state = clearActiveToolTraceHost(state);
|
||||
|
||||
const nextResult = addToolTraceEntry(state, "result");
|
||||
|
||||
expect(nextResult.createdHost).toEqual({
|
||||
id: "trace-2",
|
||||
kind: "orphan",
|
||||
assistantContent: "",
|
||||
entryKinds: ["result"],
|
||||
});
|
||||
expect(nextResult.state.hosts).toEqual([
|
||||
{
|
||||
id: "trace-1",
|
||||
kind: "assistant",
|
||||
assistantContent: "Running tools",
|
||||
entryKinds: ["use"],
|
||||
},
|
||||
{
|
||||
id: "trace-2",
|
||||
kind: "orphan",
|
||||
assistantContent: "",
|
||||
entryKinds: ["result"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,13 @@
|
||||
*/
|
||||
|
||||
import { esc } from "./utils.js";
|
||||
import { processAssistantEvent } from "./task-panel.js";
|
||||
import {
|
||||
extractEventText,
|
||||
renderAutomationIcon,
|
||||
shouldHideAutomationUserEvent,
|
||||
shouldStartAutomationWorkFromUserEvent,
|
||||
} from "./automation.js";
|
||||
import { applyTaskStateEvent, processAssistantEvent } from "./task-panel.js";
|
||||
|
||||
// ============================================================
|
||||
// Replay state — tracks unresolved permission requests during history replay
|
||||
@@ -14,12 +20,116 @@ 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();
|
||||
const traceHostElements = new Map(); // host_id → DOM refs for inline tool traces
|
||||
|
||||
export function createToolTraceState() {
|
||||
return {
|
||||
nextHostId: 1,
|
||||
activeHostId: null,
|
||||
hosts: [],
|
||||
};
|
||||
}
|
||||
|
||||
function cloneToolTraceState(state) {
|
||||
return {
|
||||
nextHostId: state.nextHostId,
|
||||
activeHostId: state.activeHostId,
|
||||
hosts: state.hosts.map((host) => ({
|
||||
...host,
|
||||
entryKinds: [...host.entryKinds],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createToolTraceHost(nextState, kind, assistantContent = "") {
|
||||
const host = {
|
||||
id: `trace-${nextState.nextHostId}`,
|
||||
kind,
|
||||
assistantContent,
|
||||
entryKinds: [],
|
||||
};
|
||||
nextState.nextHostId += 1;
|
||||
nextState.activeHostId = host.id;
|
||||
nextState.hosts.push(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
export function addAssistantToolTraceHost(state, content) {
|
||||
const nextState = cloneToolTraceState(state);
|
||||
const host = createToolTraceHost(nextState, "assistant", content);
|
||||
return { state: nextState, host };
|
||||
}
|
||||
|
||||
export function clearActiveToolTraceHost(state) {
|
||||
if (!state.activeHostId) return state;
|
||||
const nextState = cloneToolTraceState(state);
|
||||
nextState.activeHostId = null;
|
||||
return nextState;
|
||||
}
|
||||
|
||||
export function addToolTraceEntry(state, entryKind) {
|
||||
const nextState = cloneToolTraceState(state);
|
||||
let host = nextState.hosts.find((item) => item.id === nextState.activeHostId);
|
||||
let createdHost = null;
|
||||
|
||||
if (!host) {
|
||||
createdHost = createToolTraceHost(nextState, "orphan");
|
||||
host = createdHost;
|
||||
}
|
||||
|
||||
host.entryKinds.push(entryKind);
|
||||
return { state: nextState, host, createdHost };
|
||||
}
|
||||
|
||||
let toolTraceState = createToolTraceState();
|
||||
|
||||
function resetToolTraceRuntime() {
|
||||
toolTraceState = createToolTraceState();
|
||||
traceHostElements.clear();
|
||||
}
|
||||
|
||||
/** Clear replay tracking state (call before each history load) */
|
||||
export function resetReplayState() {
|
||||
replayPendingRequests.clear();
|
||||
replayRespondedRequests.clear();
|
||||
renderedUserUuids.clear();
|
||||
resetToolTraceRuntime();
|
||||
}
|
||||
|
||||
export function isConversationClearedStatus(payload) {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
if (payload.status === "conversation_cleared") return true;
|
||||
const raw = payload.raw;
|
||||
return !!raw && typeof raw === "object" && raw.status === "conversation_cleared";
|
||||
}
|
||||
|
||||
function clearTranscriptView() {
|
||||
const stream = document.getElementById("event-stream");
|
||||
if (!stream) return;
|
||||
|
||||
let preservedClearCommand = null;
|
||||
for (let i = stream.children.length - 1; i >= 0; i -= 1) {
|
||||
const row = stream.children[i];
|
||||
if (!row || typeof row.textContent !== "string") continue;
|
||||
if (row.textContent.trim() === "/clear") {
|
||||
preservedClearCommand = row.cloneNode(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
stream.innerHTML = "";
|
||||
if (preservedClearCommand) {
|
||||
stream.appendChild(preservedClearCommand);
|
||||
}
|
||||
|
||||
const permissionArea = document.getElementById("permission-area");
|
||||
if (permissionArea) {
|
||||
permissionArea.innerHTML = "";
|
||||
permissionArea.classList.add("hidden");
|
||||
}
|
||||
|
||||
removeLoading();
|
||||
resetReplayState();
|
||||
}
|
||||
|
||||
/** After replay finishes, render any still-unresolved permission prompts */
|
||||
@@ -50,27 +160,15 @@ function truncate(str, max) {
|
||||
* Server-side normalization guarantees payload.content is a string.
|
||||
* Falls back to raw/message parsing for backward compat.
|
||||
*/
|
||||
export function extractText(payload) {
|
||||
if (!payload) return "";
|
||||
export const extractText = extractEventText;
|
||||
|
||||
// Normalized format (server standardized)
|
||||
if (typeof payload.content === "string" && payload.content) return payload.content;
|
||||
|
||||
// Fallback: raw message.content (child process format)
|
||||
const msg = payload.message;
|
||||
if (msg && typeof msg === "object") {
|
||||
const mc = msg.content;
|
||||
if (typeof mc === "string") return mc;
|
||||
if (Array.isArray(mc)) {
|
||||
return mc
|
||||
.filter((b) => b && typeof b === "object" && b.type === "text")
|
||||
.map((b) => b.text || "")
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
return typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
function formatInlineContent(content) {
|
||||
let html = esc(content);
|
||||
// Inline code: `...`
|
||||
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tool-card);padding:2px 5px;border-radius:3px;font-family:var(--font-mono);font-size:0.85em;">$1</code>');
|
||||
// Bold: **...**
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
return html;
|
||||
}
|
||||
|
||||
function formatAssistantContent(content) {
|
||||
@@ -79,13 +177,106 @@ function formatAssistantContent(content) {
|
||||
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||||
return `<pre style="background:var(--bg-tool-card);padding:10px;border-radius:6px;overflow-x:auto;margin:6px 0;font-family:var(--font-mono);font-size:0.82rem;">${code.trim()}</pre>`;
|
||||
});
|
||||
// Inline code: `...`
|
||||
html = html.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tool-card);padding:2px 5px;border-radius:3px;font-family:var(--font-mono);font-size:0.85em;">$1</code>');
|
||||
// Bold: **...**
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
html = formatInlineContent(html);
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderPlanCodeBlock(code) {
|
||||
return `<pre><code>${esc(code.trim())}</code></pre>`;
|
||||
}
|
||||
|
||||
function formatPlanTextBlock(content) {
|
||||
const blocks = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
let paragraph = [];
|
||||
let listType = null;
|
||||
let listItems = [];
|
||||
|
||||
function flushParagraph() {
|
||||
if (paragraph.length === 0) return;
|
||||
blocks.push(`<p>${paragraph.map(line => formatInlineContent(line)).join("<br>")}</p>`);
|
||||
paragraph = [];
|
||||
}
|
||||
|
||||
function flushList() {
|
||||
if (!listType || listItems.length === 0) return;
|
||||
blocks.push(`<${listType}>${listItems.map(item => `<li>${item}</li>`).join("")}</${listType}>`);
|
||||
listType = null;
|
||||
listItems = [];
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (headingMatch) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
const level = Math.min(headingMatch[1].length, 6);
|
||||
blocks.push(`<h${level}>${formatInlineContent(headingMatch[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const unorderedMatch = trimmed.match(/^[-*]\s+(.*)$/);
|
||||
if (unorderedMatch) {
|
||||
flushParagraph();
|
||||
if (listType !== "ul") {
|
||||
flushList();
|
||||
listType = "ul";
|
||||
}
|
||||
listItems.push(formatInlineContent(unorderedMatch[1]));
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/);
|
||||
if (orderedMatch) {
|
||||
flushParagraph();
|
||||
if (listType !== "ol") {
|
||||
flushList();
|
||||
listType = "ol";
|
||||
}
|
||||
listItems.push(formatInlineContent(orderedMatch[1]));
|
||||
continue;
|
||||
}
|
||||
|
||||
flushList();
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
flushList();
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
export function formatPlanContent(content) {
|
||||
const parts = [];
|
||||
const codeBlockPattern = /```(\w*)\n?([\s\S]*?)```/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = codeBlockPattern.exec(content)) !== null) {
|
||||
const precedingText = content.slice(lastIndex, match.index);
|
||||
if (precedingText.trim()) {
|
||||
parts.push(formatPlanTextBlock(precedingText));
|
||||
}
|
||||
parts.push(renderPlanCodeBlock(match[2]));
|
||||
lastIndex = codeBlockPattern.lastIndex;
|
||||
}
|
||||
|
||||
const trailingText = content.slice(lastIndex);
|
||||
if (trailingText.trim()) {
|
||||
parts.push(formatPlanTextBlock(trailingText));
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function getUserUuid(payload) {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
if (typeof payload.uuid === "string" && payload.uuid) return payload.uuid;
|
||||
@@ -95,7 +286,7 @@ function getUserUuid(payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldRenderUserEvent(payload, direction, replay) {
|
||||
function shouldProcessUserEvent(payload, direction) {
|
||||
const uuid = getUserUuid(payload);
|
||||
if (uuid) {
|
||||
if (renderedUserUuids.has(uuid)) return false;
|
||||
@@ -103,10 +294,10 @@ function shouldRenderUserEvent(payload, direction, replay) {
|
||||
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";
|
||||
// Legacy fallback with no uuid: inbound human messages are usually echoes
|
||||
// of a web-sent prompt, but hidden automation inputs still need to drive
|
||||
// loading state and the session status marker.
|
||||
return direction === "outbound" || shouldHideAutomationUserEvent(payload, direction);
|
||||
}
|
||||
|
||||
function getMessageContentBlocks(payload) {
|
||||
@@ -116,27 +307,8 @@ function getMessageContentBlocks(payload) {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
function getEmbeddedToolBlocks(payload, blockType) {
|
||||
return getMessageContentBlocks(payload).filter((block) => block.type === blockType);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -162,30 +334,63 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
switch (type) {
|
||||
case "user":
|
||||
{
|
||||
const toolResultEls = renderEmbeddedToolResultBlocks(payload);
|
||||
if (toolResultEls.length > 0) {
|
||||
histEls.push(...toolResultEls);
|
||||
const toolResultBlocks = getEmbeddedToolBlocks(payload, "tool_result");
|
||||
if (toolResultBlocks.length > 0) {
|
||||
for (const block of toolResultBlocks) {
|
||||
appendToolEntryToActiveTrace(
|
||||
"result",
|
||||
{
|
||||
content: block.content || "",
|
||||
output: block.content || "",
|
||||
is_error: !!block.is_error,
|
||||
},
|
||||
histEls,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (shouldRenderUserEvent(payload, direction, true)) {
|
||||
if (shouldProcessUserEvent(payload, direction)) {
|
||||
if (shouldHideAutomationUserEvent(payload, direction)) {
|
||||
break;
|
||||
}
|
||||
toolTraceState = clearActiveToolTraceHost(toolTraceState);
|
||||
histEls.push(renderUserMessage(payload, direction));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "assistant":
|
||||
{
|
||||
const toolUseEls = renderEmbeddedToolUseBlocks(payload);
|
||||
const text = extractText(payload);
|
||||
const toolUseBlocks = getEmbeddedToolBlocks(payload, "tool_use");
|
||||
if (text && text.trim()) histEls.push(renderAssistantMessage(payload));
|
||||
if (toolUseEls.length > 0) histEls.push(...toolUseEls);
|
||||
for (const block of toolUseBlocks) {
|
||||
appendToolEntryToActiveTrace(
|
||||
"use",
|
||||
{
|
||||
tool_name: block.name || "tool",
|
||||
tool_input: block.input || {},
|
||||
},
|
||||
histEls,
|
||||
);
|
||||
}
|
||||
processAssistantEvent(payload);
|
||||
}
|
||||
break;
|
||||
case "task_state":
|
||||
applyTaskStateEvent(payload);
|
||||
return;
|
||||
case "automation_state":
|
||||
return;
|
||||
case "status":
|
||||
if (isConversationClearedStatus(payload)) {
|
||||
clearTranscriptView();
|
||||
}
|
||||
return;
|
||||
case "tool_use":
|
||||
histEls.push(renderToolUse(payload));
|
||||
appendToolEntryToActiveTrace("use", payload, histEls);
|
||||
break;
|
||||
case "tool_result":
|
||||
histEls.push(renderToolResult(payload));
|
||||
appendToolEntryToActiveTrace("result", payload, histEls);
|
||||
break;
|
||||
case "error":
|
||||
histEls.push(renderSystemMessage(`Error: ${payload.message || payload.content || "Unknown error"}`));
|
||||
@@ -230,17 +435,32 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
const els = [];
|
||||
let needLoading = false;
|
||||
|
||||
switch (type) {
|
||||
switch (type) {
|
||||
case "user":
|
||||
{
|
||||
const toolResultEls = renderEmbeddedToolResultBlocks(payload);
|
||||
if (toolResultEls.length > 0) {
|
||||
els.push(...toolResultEls);
|
||||
const toolResultBlocks = getEmbeddedToolBlocks(payload, "tool_result");
|
||||
if (toolResultBlocks.length > 0) {
|
||||
for (const block of toolResultBlocks) {
|
||||
appendToolEntryToActiveTrace(
|
||||
"result",
|
||||
{
|
||||
content: block.content || "",
|
||||
output: block.content || "",
|
||||
is_error: !!block.is_error,
|
||||
},
|
||||
els,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!shouldRenderUserEvent(payload, direction, false)) return;
|
||||
els.push(renderUserMessage(payload, direction));
|
||||
needLoading = true;
|
||||
if (!shouldProcessUserEvent(payload, direction)) return;
|
||||
if (!shouldHideAutomationUserEvent(payload, direction)) {
|
||||
toolTraceState = clearActiveToolTraceHost(toolTraceState);
|
||||
els.push(renderUserMessage(payload, direction));
|
||||
needLoading = true;
|
||||
} else {
|
||||
needLoading = shouldStartAutomationWorkFromUserEvent(payload, direction);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "partial_assistant":
|
||||
@@ -249,26 +469,40 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
return;
|
||||
case "assistant":
|
||||
{
|
||||
const toolUseEls = renderEmbeddedToolUseBlocks(payload);
|
||||
const text = extractText(payload);
|
||||
const toolUseBlocks = getEmbeddedToolBlocks(payload, "tool_use");
|
||||
if (text && text.trim()) {
|
||||
removeLoading();
|
||||
els.push(renderAssistantMessage(payload));
|
||||
}
|
||||
if (toolUseEls.length > 0) els.push(...toolUseEls);
|
||||
for (const block of toolUseBlocks) {
|
||||
appendToolEntryToActiveTrace(
|
||||
"use",
|
||||
{
|
||||
tool_name: block.name || "tool",
|
||||
tool_input: block.input || {},
|
||||
},
|
||||
els,
|
||||
);
|
||||
}
|
||||
processAssistantEvent(payload);
|
||||
}
|
||||
break;
|
||||
case "task_state":
|
||||
applyTaskStateEvent(payload);
|
||||
return;
|
||||
case "automation_state":
|
||||
return;
|
||||
case "result":
|
||||
case "result_success":
|
||||
removeLoading();
|
||||
// Skip result — it just repeats the assistant message content
|
||||
return;
|
||||
case "tool_use":
|
||||
els.push(renderToolUse(payload));
|
||||
appendToolEntryToActiveTrace("use", payload, els);
|
||||
break;
|
||||
case "tool_result":
|
||||
els.push(renderToolResult(payload));
|
||||
appendToolEntryToActiveTrace("result", payload, els);
|
||||
break;
|
||||
case "control_request":
|
||||
case "permission_request":
|
||||
@@ -305,6 +539,10 @@ export function appendEvent(data, { replay = false } = {}) {
|
||||
return;
|
||||
case "status":
|
||||
// Skip connecting/waiting status noise from bridge
|
||||
if (isConversationClearedStatus(payload)) {
|
||||
clearTranscriptView();
|
||||
return;
|
||||
}
|
||||
{
|
||||
const msg = payload.message || payload.content || "";
|
||||
const fullText = typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||
@@ -359,14 +597,92 @@ function renderUserMessage(payload, direction) {
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderAssistantMessage(payload) {
|
||||
const content = extractText(payload);
|
||||
function renderTraceToggleGlyph() {
|
||||
return `
|
||||
<span class="assistant-trace-glyph" aria-hidden="true">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function bindTraceToggle(toggleEl, panelEl, traceEl) {
|
||||
if (!toggleEl || !panelEl || !traceEl) return;
|
||||
toggleEl.addEventListener("click", () => {
|
||||
const expanded = toggleEl.getAttribute("aria-expanded") === "true";
|
||||
toggleEl.setAttribute("aria-expanded", expanded ? "false" : "true");
|
||||
toggleEl.classList.toggle("is-open", !expanded);
|
||||
traceEl.classList.toggle("is-expanded", !expanded);
|
||||
panelEl.classList.toggle("hidden", expanded);
|
||||
});
|
||||
}
|
||||
|
||||
function updateTraceHostDisplay(refs) {
|
||||
if (!refs) return;
|
||||
refs.traceEl.classList.toggle("hidden", refs.entryCount === 0);
|
||||
refs.countEl.textContent = String(refs.entryCount);
|
||||
refs.toggleEl.classList.toggle("has-error", refs.hasError);
|
||||
refs.row.classList.toggle("has-tool-error", refs.hasError);
|
||||
refs.toggleEl.title = refs.hasError ? "Tool trace (contains errors)" : "Tool trace";
|
||||
}
|
||||
|
||||
function createTraceHostRow(host, content = "") {
|
||||
const row = document.createElement("div");
|
||||
row.className = "msg-row assistant";
|
||||
row.innerHTML = `<div class="msg-bubble">${formatAssistantContent(content)}</div>`;
|
||||
row.className = host.kind === "assistant" ? "msg-row assistant" : "msg-row tool-trace-row";
|
||||
row.dataset.traceHostId = host.id;
|
||||
row.innerHTML = `
|
||||
<div class="assistant-turn${host.kind === "orphan" ? " assistant-turn-orphan" : ""}">
|
||||
${content ? `<div class="msg-bubble">${formatAssistantContent(content)}</div>` : ""}
|
||||
<div class="assistant-trace hidden">
|
||||
<button type="button" class="assistant-trace-toggle" aria-expanded="false">
|
||||
${renderTraceToggleGlyph()}
|
||||
<span class="assistant-trace-count">0</span>
|
||||
<span class="assistant-trace-chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
<div class="assistant-trace-panel hidden"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const traceEl = row.querySelector(".assistant-trace");
|
||||
const panelEl = row.querySelector(".assistant-trace-panel");
|
||||
const toggleEl = row.querySelector(".assistant-trace-toggle");
|
||||
const countEl = row.querySelector(".assistant-trace-count");
|
||||
|
||||
bindTraceToggle(toggleEl, panelEl, traceEl);
|
||||
|
||||
const refs = {
|
||||
hostId: host.id,
|
||||
row,
|
||||
traceEl,
|
||||
panelEl,
|
||||
toggleEl,
|
||||
countEl,
|
||||
entryCount: host.entryKinds.length,
|
||||
hasError: false,
|
||||
};
|
||||
|
||||
traceHostElements.set(host.id, refs);
|
||||
updateTraceHostDisplay(refs);
|
||||
return row;
|
||||
}
|
||||
|
||||
function ensureTraceHostRow(host, rows = null, content = "") {
|
||||
const existing = traceHostElements.get(host.id);
|
||||
if (existing) return existing.row;
|
||||
const row = createTraceHostRow(host, content || host.assistantContent || "");
|
||||
if (Array.isArray(rows)) {
|
||||
rows.push(row);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderAssistantMessage(payload) {
|
||||
const content = extractText(payload).trim();
|
||||
const result = addAssistantToolTraceHost(toolTraceState, content);
|
||||
toolTraceState = result.state;
|
||||
return ensureTraceHostRow(result.host, null, content);
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
const text = payload.result || payload.subtype || "Session completed";
|
||||
const row = document.createElement("div");
|
||||
@@ -375,37 +691,64 @@ function renderResult(payload) {
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderToolCard({ titleHtml, body, isError = false }) {
|
||||
const card = document.createElement("div");
|
||||
card.className = `tool-card assistant-trace-card${isError ? " assistant-trace-card-error" : ""}`;
|
||||
card.innerHTML = `
|
||||
<div class="tool-card-header">
|
||||
<span class="tool-icon">▶</span>
|
||||
${titleHtml}
|
||||
</div>
|
||||
<div class="tool-card-body collapsed">${esc(body)}</div>`;
|
||||
|
||||
const header = card.querySelector(".tool-card-header");
|
||||
const bodyEl = card.querySelector(".tool-card-body");
|
||||
header?.addEventListener("click", () => {
|
||||
bodyEl?.classList.toggle("collapsed");
|
||||
header.classList.toggle("is-open", !bodyEl?.classList.contains("collapsed"));
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderToolUse(payload) {
|
||||
const name = payload.tool_name || payload.name || "tool";
|
||||
const input = payload.tool_input || payload.input || {};
|
||||
const inputStr = typeof input === "string" ? input : JSON.stringify(input, null, 2);
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "msg-row tool";
|
||||
card.innerHTML = `
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
||||
<span class="tool-icon">▶</span> Tool: <strong>${esc(name)}</strong>
|
||||
</div>
|
||||
<div class="tool-card-body collapsed">${esc(truncate(inputStr, 2000))}</div>
|
||||
</div>`;
|
||||
return card;
|
||||
return renderToolCard({
|
||||
titleHtml: `Tool: <strong>${esc(name)}</strong>`,
|
||||
body: inputStr || "",
|
||||
});
|
||||
}
|
||||
|
||||
function renderToolResult(payload) {
|
||||
const content = payload.content || payload.output || "";
|
||||
const contentStr = typeof content === "string" ? content : JSON.stringify(content, null, 2);
|
||||
return renderToolCard({
|
||||
titleHtml: payload.is_error ? "<strong>Tool Error</strong>" : "Tool Result",
|
||||
body: contentStr || "",
|
||||
isError: !!payload.is_error,
|
||||
});
|
||||
}
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "msg-row tool";
|
||||
card.innerHTML = `
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header" onclick="this.nextElementSibling.classList.toggle('collapsed')">
|
||||
<span class="tool-icon">▶</span> Tool Result
|
||||
</div>
|
||||
<div class="tool-card-body collapsed">${esc(truncate(contentStr, 2000))}</div>
|
||||
</div>`;
|
||||
return card;
|
||||
function appendToolEntryToActiveTrace(entryKind, payload, rows) {
|
||||
const result = addToolTraceEntry(toolTraceState, entryKind);
|
||||
toolTraceState = result.state;
|
||||
|
||||
if (result.createdHost) {
|
||||
ensureTraceHostRow(result.createdHost, rows);
|
||||
}
|
||||
|
||||
const refs = traceHostElements.get(result.host.id);
|
||||
if (!refs) return;
|
||||
|
||||
const card = entryKind === "use" ? renderToolUse(payload) : renderToolResult(payload);
|
||||
refs.panelEl.appendChild(card);
|
||||
refs.entryCount += 1;
|
||||
if (entryKind === "result" && payload.is_error) {
|
||||
refs.hasError = true;
|
||||
}
|
||||
updateTraceHostDisplay(refs);
|
||||
}
|
||||
|
||||
export function renderPermissionRequest(payload) {
|
||||
@@ -516,7 +859,9 @@ export function renderAskUserQuestion(payload) {
|
||||
el._answers = {};
|
||||
el._questions = questions;
|
||||
|
||||
return renderSystemMessage("Waiting for your response...");
|
||||
const status = renderSystemMessage("Waiting for your response...");
|
||||
status.dataset.pendingRequestId = requestId;
|
||||
return status;
|
||||
}
|
||||
|
||||
export function renderExitPlanMode(payload) {
|
||||
@@ -551,7 +896,7 @@ export function renderExitPlanMode(payload) {
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div class="plan-title">Ready to code?</div>
|
||||
<div class="plan-content">${formatAssistantContent(planContent)}</div>
|
||||
<div class="plan-content">${formatPlanContent(planContent)}</div>
|
||||
<div class="plan-options">
|
||||
<button class="plan-option" data-value="yes-accept-edits" onclick="window._selectPlanOption(this, 'yes-accept-edits')">
|
||||
<span class="plan-option-label">Yes, auto-accept edits</span>
|
||||
@@ -580,7 +925,9 @@ export function renderExitPlanMode(payload) {
|
||||
el._planContent = planContent;
|
||||
el._isEmpty = isEmpty;
|
||||
|
||||
return renderSystemMessage("Waiting for your response...");
|
||||
const status = renderSystemMessage("Waiting for your response...");
|
||||
status.dataset.pendingRequestId = requestId;
|
||||
return status;
|
||||
}
|
||||
|
||||
function renderSystemMessage(text) {
|
||||
@@ -594,10 +941,10 @@ function renderSystemMessage(text) {
|
||||
// Loading Indicator — TUI star spinner style
|
||||
// ============================================================
|
||||
|
||||
const LOADING_ID = "loading-indicator";
|
||||
const ACTIVITY_ID = "session-activity-indicator";
|
||||
|
||||
// TUI star spinner frames (same as Claude Code CLI)
|
||||
const SPINNER_FRAMES = ["·", "✢", "✳", "✶", "✻", "✽"];
|
||||
const SPINNER_FRAMES = ["·", "✢", "✱", "✶", "✻", "✽"];
|
||||
const SPINNER_CYCLE = [...SPINNER_FRAMES, ...SPINNER_FRAMES.slice().reverse()];
|
||||
|
||||
// 204 verbs from TUI src/constants/spinnerVerbs.ts
|
||||
@@ -640,35 +987,85 @@ const SPINNER_VERBS = [
|
||||
let spinnerInterval = null;
|
||||
let timerInterval = null;
|
||||
let stalledCheckInterval = null;
|
||||
let activityCountdownInterval = null;
|
||||
let spinnerFrame = 0;
|
||||
let loadingStartTime = 0;
|
||||
let lastActivityTime = 0;
|
||||
let isStalled = false;
|
||||
let loadingActive = false;
|
||||
let workingActive = false;
|
||||
let automationActivity = null;
|
||||
|
||||
export function resolveActivityMode(working, activity) {
|
||||
if (activity?.mode === "standby" || activity?.mode === "sleeping") {
|
||||
return activity.mode;
|
||||
}
|
||||
return working ? "working" : "idle";
|
||||
}
|
||||
|
||||
export function shouldRenderTranscriptActivity(mode) {
|
||||
return mode === "working";
|
||||
}
|
||||
|
||||
export function formatCountdownRemaining(endsAt, now = Date.now()) {
|
||||
if (typeof endsAt !== "number") return "";
|
||||
|
||||
const remainingSeconds = Math.max(0, Math.ceil((endsAt - now) / 1000));
|
||||
const hours = Math.floor(remainingSeconds / 3600);
|
||||
const minutes = Math.floor((remainingSeconds % 3600) / 60);
|
||||
const seconds = remainingSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function getActivityModeInternal() {
|
||||
return resolveActivityMode(workingActive, automationActivity);
|
||||
}
|
||||
|
||||
export function isLoading() {
|
||||
return loadingActive;
|
||||
return getActivityModeInternal() === "working";
|
||||
}
|
||||
|
||||
function syncActionBtn(state) {
|
||||
if (typeof window.__updateActionBtn === "function") window.__updateActionBtn(state);
|
||||
export function getActivityMode() {
|
||||
return getActivityModeInternal();
|
||||
}
|
||||
|
||||
export function showLoading() {
|
||||
removeLoading();
|
||||
const stream = document.getElementById("event-stream");
|
||||
if (!stream) return;
|
||||
function syncActionBtn(mode) {
|
||||
if (typeof window.__updateActionBtn === "function") window.__updateActionBtn(mode);
|
||||
}
|
||||
|
||||
loadingActive = true;
|
||||
syncActionBtn(true);
|
||||
function clearWorkingTimers() {
|
||||
if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; }
|
||||
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
|
||||
if (stalledCheckInterval) { clearInterval(stalledCheckInterval); stalledCheckInterval = null; }
|
||||
isStalled = false;
|
||||
}
|
||||
|
||||
function clearActivityCountdownTimer() {
|
||||
if (activityCountdownInterval) {
|
||||
clearInterval(activityCountdownInterval);
|
||||
activityCountdownInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeActivityElement() {
|
||||
const el = document.getElementById(ACTIVITY_ID);
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
function renderWorkingIndicator(stream) {
|
||||
const verb = SPINNER_VERBS[Math.floor(Math.random() * SPINNER_VERBS.length)];
|
||||
loadingStartTime = Date.now();
|
||||
lastActivityTime = Date.now();
|
||||
isStalled = false;
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.id = LOADING_ID;
|
||||
el.id = ACTIVITY_ID;
|
||||
el.className = "msg-row loading-row";
|
||||
el.innerHTML = `<span class="tui-spinner">${SPINNER_CYCLE[0]}</span><span class="tui-verb glimmer-text">${esc(verb)}…</span><span class="tui-timer">0s</span>`;
|
||||
stream.appendChild(el);
|
||||
@@ -678,14 +1075,12 @@ export function showLoading() {
|
||||
const timerEl = el.querySelector(".tui-timer");
|
||||
const loadingEl = el;
|
||||
|
||||
// Spinner animation — 120ms interval, same as TUI
|
||||
spinnerFrame = 0;
|
||||
spinnerInterval = setInterval(() => {
|
||||
spinnerFrame = (spinnerFrame + 1) % SPINNER_CYCLE.length;
|
||||
if (spinnerEl) spinnerEl.textContent = SPINNER_CYCLE[spinnerFrame];
|
||||
}, 120);
|
||||
|
||||
// Timer — update every second
|
||||
timerInterval = setInterval(() => {
|
||||
if (timerEl) {
|
||||
const elapsed = Math.floor((Date.now() - loadingStartTime) / 1000);
|
||||
@@ -693,7 +1088,6 @@ export function showLoading() {
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Stalled detection — check every 120ms (aligned with spinner)
|
||||
stalledCheckInterval = setInterval(() => {
|
||||
if (!isStalled && Date.now() - lastActivityTime > 3000) {
|
||||
isStalled = true;
|
||||
@@ -702,15 +1096,62 @@ export function showLoading() {
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function renderAutomationIndicator(stream, activity) {
|
||||
const el = document.createElement("div");
|
||||
el.id = ACTIVITY_ID;
|
||||
el.className = `msg-row automation-activity-row automation-activity-${activity.mode}`;
|
||||
el.innerHTML = `
|
||||
<div class="automation-activity-card">
|
||||
${renderAutomationIcon(activity.iconVariant, { className: "automation-activity-icon" })}
|
||||
<div class="automation-activity-copy">
|
||||
<span class="automation-activity-label">${esc(activity.label)}</span>
|
||||
</div>
|
||||
<span class="automation-activity-countdown"></span>
|
||||
</div>`;
|
||||
stream.appendChild(el);
|
||||
stream.scrollTop = stream.scrollHeight;
|
||||
|
||||
const countdownEl = el.querySelector(".automation-activity-countdown");
|
||||
const updateCountdown = () => {
|
||||
if (countdownEl) {
|
||||
countdownEl.textContent = formatCountdownRemaining(activity.endsAt);
|
||||
}
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
activityCountdownInterval = setInterval(updateCountdown, 1000);
|
||||
}
|
||||
|
||||
function renderActivityIndicator() {
|
||||
clearWorkingTimers();
|
||||
clearActivityCountdownTimer();
|
||||
removeActivityElement();
|
||||
|
||||
const mode = getActivityModeInternal();
|
||||
syncActionBtn(mode);
|
||||
|
||||
const stream = document.getElementById("event-stream");
|
||||
if (!stream) return;
|
||||
|
||||
if (shouldRenderTranscriptActivity(mode)) {
|
||||
renderWorkingIndicator(stream);
|
||||
}
|
||||
}
|
||||
|
||||
export function setAutomationActivity(activity) {
|
||||
automationActivity = activity ? { ...activity } : null;
|
||||
renderActivityIndicator();
|
||||
}
|
||||
|
||||
export function showLoading() {
|
||||
automationActivity = null;
|
||||
workingActive = true;
|
||||
renderActivityIndicator();
|
||||
}
|
||||
|
||||
export function removeLoading() {
|
||||
if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; }
|
||||
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
|
||||
if (stalledCheckInterval) { clearInterval(stalledCheckInterval); stalledCheckInterval = null; }
|
||||
isStalled = false;
|
||||
loadingActive = false;
|
||||
syncActionBtn(false);
|
||||
const el = document.getElementById(LOADING_ID);
|
||||
if (el) el.remove();
|
||||
workingActive = false;
|
||||
renderActivityIndicator();
|
||||
}
|
||||
|
||||
/** Reset stalled timer — call when SSE events arrive */
|
||||
@@ -718,7 +1159,7 @@ export function refreshLoadingActivity() {
|
||||
lastActivityTime = Date.now();
|
||||
if (isStalled) {
|
||||
isStalled = false;
|
||||
const loadingEl = document.getElementById(LOADING_ID);
|
||||
const loadingEl = document.getElementById(ACTIVITY_ID);
|
||||
if (loadingEl) loadingEl.classList.remove("stalled");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user