feat: enhance app bot relay

This commit is contained in:
musistudio
2026-07-14 20:58:08 +08:00
parent 2bf349fe03
commit 9e56580334
20 changed files with 3087 additions and 220 deletions
@@ -11,8 +11,9 @@ lead: Add a Bot, bind it to Agent Config, and choose message forwarding or hando
2. Select a platform and fill in the required token, secret, signing secret, robot code, or OAuth fields.
3. Save the Bot.
4. Open the target **Agent Config** and enable **Bot**.
5. Choose **Forward agent messages** or **Handoff**, then reopen the agent from CCR.
5. Configure forwarding, handoff, language, timeout, attachments, streaming, and **Allow Agent shell tools** as needed.
6. Reopen Claude, Codex, ZCode, or OpenCode App from CCR. The Bot is online only while the App is alive.
## Verification
Open the agent from CCR and send a test message. Confirm that Logs include Bot records and the IM side receives the message.
Open the Agent App from CCR, then send `/project current`, `/session list`, and one plain message from IM. The Profile card shows connection state, the last event and delivery, pending outbox count, and redacted errors; `/session doctor` provides the same runtime diagnostics. Closing the App should move the Bot to offline state.
+51 -1
View File
@@ -5,11 +5,61 @@ eyebrow: Detailed Configuration
lead: Forward agent messages to instant-messaging platforms or hand off active work after desktop idle.
---
CCR App Relay shares the lifecycle of an Agent App opened by CCR. Opening Claude, Codex, ZCode, or OpenCode App starts its companion worker; closing the App stops both the worker and Bot connection.
## Common Modes
- **Forward agent messages**: mirror agent messages into IM.
- **Handoff**: relay the interaction into IM after desktop idle.
- **Reply only**: with forwarding and handoff disabled, reply only to turns initiated from IM.
Natural-language turns are serialized per IM conversation. `/project`, `/session status`, and `/session cancel` remain immediately responsive; queueing, timeout, cancellation, and worker-restart recovery have explicit states.
## Projects And Sessions
A Project is an Agent-native project or working directory, and a Session is an Agent-native conversation inside that Project.
### Project Commands
| Command | Purpose |
| --- | --- |
| `/project` | Show Project help and the App-online boundary. |
| `/project list [page]` | List known Agent projects with pagination. |
| `/project find <text>` | Search project names and paths. |
| `/project current` | Show the current Project. |
| `/project use <n>` | Change Project and clear the previous Session selection. |
| `/project name <label>` | Set the Bot display label for this Project. |
### Session Commands
| Command | Purpose |
| --- | --- |
| `/session` | Show all Session commands. |
| `/session list [page]`, `/session find <text>` | Browse Sessions only in the current Project. |
| `/session current`, `new [title]`, `use <n>`, `reset` | Inspect, create, continue, or clear a Session selection. |
| `/session status`, `cancel` | Inspect the active turn/queue, or cancel it and clear the queue. |
| `/session approve [session]`, `deny`, `answer <text>` | Answer an Agent-generated permission or input request; every platform has text commands, and card-capable platforms also show action buttons. |
| `/session name <label>` | Rename the current Session. |
| `/session archive <n>`, `restore <n>`, `delete <n> confirm` | Archive, restore, or permanently delete with confirmation. |
| `/session history [count]`, `usage` | Show recent history and token/cache/cost summaries. |
| `/session models`, `model`, `effort`, `mode` | Inspect or change this conversation's Session runtime settings. |
| `/session memory ...`, `skills`, `skill`, `shortcut ...` | Manage persistent context, Agent skills, and shortcuts. |
| `/session doctor`, `deliveries` | Show connection, outbox, recent-delivery, and redacted-error diagnostics. |
The public Bot command domains are `/project` and `/session`. Other slash commands return the unknown-command response, while plain natural language such as `help` or `list` enters the Agent as a prompt.
## Bot Settings
- **Bot language**: automatic, English, or Simplified Chinese.
- **Maximum turn time**: interrupt timed-out turns and return a final state.
- **Session idle reset**: prepare a new Session in the same Project after inactivity; `0` disables it.
- **Message chunk and attachment limits**: adapt to platform limits and bound inbound files.
- **Streaming replies and progress**: forward visible text and tool stages.
- **Send and receive attachments**: accept inbound images/files and return artifacts from the current workspace.
- **Allow Agent shell tools**: controls Agent shell-tool permission; the Bot command surface remains `/project` and `/session`.
The local state keeps bounded deduplication records, pending turns, a durable outbox, and recent delivery results. Event idempotency gives each Agent turn one execution, and pending delivery resumes while the App is online again.
## Platform Pages
Slack, Discord, Telegram, LINE, Weixin, WeCom, Feishu, and DingTalk each have a dedicated page.
Slack, Discord, Telegram, LINE, Weixin, WeCom, Feishu, and DingTalk each have a dedicated page; iMessage uses a local integration. The SDK selects Markdown, cards, streaming updates, file messages, or text according to platform capabilities.
@@ -76,7 +76,7 @@ Claude App and Claude Code CLI use different model-list adapters:
| Environment variables | Injected into OpenCode CLI, OpenCode App, and its Bot worker. |
| Bot | Applies to the OpenCode App entry opened from CCR. Incoming Bot messages run through OpenCode CLI and replies are sent back to the same Bot conversation. |
CCR keeps one OpenCode Bot worker next to the OpenCode App process. The worker stores a Bot-conversation-to-OpenCode-session mapping, so later messages continue the same session. Send `help`, `ls`, `current`, `new`, `reset`, or `select <number-or-id>` in the Bot conversation to manage the selected session.
CCR keeps one OpenCode Bot worker next to the OpenCode App process. The worker stores a project and optional session for each Bot conversation. Send `/project list|current|use` to select an Agent project, then use `/session list|current|new|use|reset` to manage sessions inside that project. Selecting another project clears the previous session, and sessions from another project cannot be selected. Only these slash-command domains are intercepted; removed `/task` and legacy flat commands are not supported.
The OpenCode CLI must be available as `opencode` in the CCR Desktop process environment. If it is installed elsewhere, set `CCR_OPENCODE_BIN` in the Agent Config environment variables. Bot sessions default to the filesystem root used by a fresh OpenCode Desktop workspace; set `CCR_OPENCODE_BOT_CWD` to the same project directory currently opened in OpenCode App when using another workspace. CCR passes that directory explicitly through `opencode run --dir`, so the resulting session appears under the matching App project. Permissions are not auto-approved by default; `CCR_OPENCODE_BOT_AUTO_APPROVE=true` enables OpenCode's dangerous `--auto` mode and should be used only in a trusted environment.
@@ -131,17 +131,21 @@ Claude Code CLI config writes a settings file. With **Only opened from CCR**, CC
When opening Claude App from the desktop app, CCR also prepares a separate user-data directory for that config. Different Agent Config entries use different directories, so multiple Claude App instances can run at the same time.
With a Bot bound, Claude App's companion worker exposes Projects/Sessions, streaming replies, attachments, Session usage, and native permission/Ask User requests to IM. The worker stops with the App.
### Codex
Codex config writes `config.toml` and a model catalog file. With **Only opened from CCR**, CCR stores those files in a directory separated by config `id`.
Codex supports CLI and App. CLI opens through the launcher for the selected config; App launches ChatGPT, uses a separate user-data directory, and passes the selected model and provider into the app.
With a Bot bound, the Codex App companion worker uses native Codex rollout Sessions for Project/Session browsing and continuation, queueing, cancellation, model settings, usage, attachments, and diagnostics. It exists only alongside the managed App.
### OpenCode
OpenCode config writes a JSON/JSONC config that routes the selected provider and model through CCR. CLI opens through a profile-specific wrapper; App launches the installed OpenCode Desktop executable with the same effective config.
When a Bot is selected and the App is opened from CCR, CCR starts a companion worker that handles incoming Bot messages through `opencode run --format json`, persists the returned OpenCode session ID, and sends the final text event back through Bot Gateway. The worker stops when the managed OpenCode App exits or the profile is switched.
When a Bot is selected and the App is opened from CCR, CCR starts a companion worker using OpenCode-native Sessions and the same Project/Session, queue, media, settings, and diagnostics contract as the other Apps. The worker stops when the managed OpenCode App exits or the profile is switched.
### Grok CLI
@@ -151,6 +155,8 @@ Grok CLI supports CLI only. CCR opens it through a profile-specific wrapper that
ZCode supports App only. CCR writes ZCode CLI config, v2 config, and model cache based on ZCode home or a custom config file, then starts the App with the current Agent Config's model, provider, and separate user-data directory.
With a Bot bound, ZCode uses the Codex-compatible companion worker and native Session discovery. Closing ZCode App immediately takes the relay offline.
## Multi-Instance Suggestions
1. Create one Agent Config for each agent instance that should run independently.
@@ -5,11 +5,61 @@ eyebrow: 详细配置
lead: 通过 IM Bot 转发 Agent 消息,或在桌面空闲后把任务接力到手机。
---
CCR App Relay 的在线周期与由 CCR 打开的 Agent App 保持一致。打开 Claude、Codex、ZCode 或 OpenCode App 时,CCR 启动对应的伴生 workerApp 退出时 worker 和 Bot 连接同步停止。
## 常见模式
- **转发 Agent 消息**:把消息同步到 IM。
- **接力**:桌面空闲后,把交互接力到 IM。
- **仅回复**:关闭“转发 Agent 消息”和接力后,只回复从 IM 主动发起的 turn。
同一个 IM conversation 中的普通消息按顺序执行。`/project``/session status``/session cancel` 等管理命令保持即时响应;排队、取消、超时和 worker 重启恢复都有明确状态。
## Project 与 Session
Project 对应 Agent 的项目或工作目录,Session 对应该 Project 下的 Agent 原生会话。
### Project 命令
| 命令 | 作用 |
| --- | --- |
| `/project` | 查看 Project 命令帮助和 App 在线边界。 |
| `/project list [page]` | 分页列出 Agent 已知项目。 |
| `/project find <text>` | 搜索项目名称或路径。 |
| `/project current` | 查看当前 Project。 |
| `/project use <n>` | 切换 Project,并清除原 Session 选择。 |
| `/project name <label>` | 设置当前 Project 的 Bot 显示名称。 |
### Session 命令
| 命令 | 作用 |
| --- | --- |
| `/session` | 查看全部 Session 命令。 |
| `/session list [page]``/session find <text>` | 只浏览当前 Project 中的 Sessions。 |
| `/session current``new [title]``use <n>``reset` | 查看、新建、继续或清除 Session 选择。 |
| `/session status``cancel` | 查看当前 turn/队列,或取消当前 turn 并清空队列。 |
| `/session approve [session]``deny``answer <text>` | 响应 Agent 已经产生的权限或输入请求;所有平台提供文本命令,具备卡片能力的平台同时显示操作按钮。 |
| `/session name <label>` | 重命名当前 Session。 |
| `/session archive <n>``restore <n>``delete <n> confirm` | 归档、恢复或确认永久删除。 |
| `/session history [count]``usage` | 查看最近历史和 Token/缓存/成本摘要。 |
| `/session models``model``effort``mode` | 查看或调整当前 conversation 的 Session 运行设置。 |
| `/session memory ...``skills``skill``shortcut ...` | 管理持久上下文、Agent Skills 和快捷指令。 |
| `/session doctor``deliveries` | 查看连接、outbox、最近投递和脱敏错误。 |
Bot 的公开命令域为 `/project``/session`。其他 slash command 统一返回未知命令;普通自然语言(包括 `help``list`)作为 prompt 进入 Agent。
## Bot 设置
- **Bot 语言**:自动、English 或简体中文。
- **最长 turn 时间**:超时后中断 Agent turn 并回报最终状态。
- **Session 空闲重置**:超过指定时间后在当前 Project 中准备新 Session;设为 `0` 关闭。
- **消息分片与附件上限**:适配平台消息长度并限制入站文件大小。
- **流式回复与进度**:仅发送可见文本和工具阶段。
- **收发附件**:允许入站图片/文件和当前工作区产物回传。
- **允许 Agent 使用 Shell 工具**:控制 Agent 的 Shell 工具权限;Bot 命令域保持为 `/project``/session`
状态文件持久保存有界的去重记录、待处理 turn、outbox 和最近投递结果。事件幂等保证每个 Agent turn 执行一次;再次打开 App 后,在 App 在线期间恢复待投递消息。
## 平台页面
Slack、Discord、Telegram、LINE、微信、企业微信、飞书和钉钉都有独立页面。
Slack、Discord、Telegram、LINE、微信、企业微信、飞书和钉钉都有独立页面iMessage 使用本机接入。SDK 按平台能力选择 Markdown、卡片、流式更新、文件消息或文本消息
@@ -11,8 +11,9 @@ lead: 添加 Bot、绑定 Agent配置,并选择消息转发或接力模式。
2. 选择平台并填写 Token、Secret、Signing Secret、Robot Code 或 OAuth 信息。
3. 保存 Bot。
4. 打开目标 Agent配置,开启 **Bot**
5. 选择 **转发 Agent 消息****接力**,并重新从 CCR 打开 Agent
5. 按需设置转发、接力、语言、超时、附件、流式回复和 **允许 Agent 使用 Shell 工具**
6. 从 CCR 重新打开 Claude、Codex、ZCode 或 OpenCode App。Bot 只在 App 存活期间在线。
## 验证方式
从 CCR 打开 Agent 后发一条测试消息,确认请求日志中有 Bot 相关记录,IM 端也能收到消息
从 CCR 打开 Agent App 后,在 IM 发送 `/project current``/session list` 和一条普通消息。Profile 卡片会显示 Bot 连接、最后事件、最后投递、待投递数量和脱敏错误;也可发送 `/session doctor` 查看诊断。关闭 App 后,Bot 状态应切换为离线
@@ -76,7 +76,7 @@ Claude App 和 Claude Code CLI 的模型列表适配方式不同:
| 环境变量 | 注入 OpenCode CLI、OpenCode App 以及对应的 Bot worker。 |
| Bot | 在从 CCR 打开的 OpenCode App 入口生效。收到 Bot 消息后会通过 OpenCode CLI 执行,并把回复发回同一个 Bot 会话。 |
CCR 会为 OpenCode App 启动一个配套 Bot worker,并保存“Bot 会话—OpenCode session”的映射,因此后续消息会继续同一会话。在 Bot 会话中发送 `help``ls``current``new``reset``select <序号或ID>` 可以管理当前选择session。
CCR 会为 OpenCode App 启动一个配套 Bot worker,并为每个 Bot conversation 分别保存 Project 与可选 Session。先用 `/project list|current|use` 选择 Agent Project,再用 `/session list|current|new|use|reset` 管理该 Project 下的 Agent Session。切换 Project 会清除原 Session,其他 Project Session 无法被选中。Bot 只拦截这两个 slash 命令域;已移除的 `/task` 和旧平铺命令不再兼容
CCR Desktop 进程环境中必须能够执行 `opencode`。如果 CLI 安装在其他位置,可以在 Agent配置环境变量中设置 `CCR_OPENCODE_BIN`。Bot session 默认使用全新 OpenCode Desktop 工作区对应的文件系统根目录;如果 App 当前打开了其他项目,应通过 `CCR_OPENCODE_BOT_CWD` 设置同一个项目目录。CCR 会把该目录显式传给 `opencode run --dir`,使新 session 出现在 App 对应的项目下。默认不会自动批准权限;只有在可信环境中才应设置 `CCR_OPENCODE_BOT_AUTO_APPROVE=true`,因为它会启用 OpenCode 的高风险 `--auto` 模式。
@@ -131,17 +131,21 @@ Claude Code CLI 配置会写入设置文件。选择“仅从 CCR 打开时生
从桌面 App 打开 Claude App 时,CCR 还会为该配置准备独立用户数据目录。不同 Agent配置使用不同目录,因此可以同时打开多个 Claude App 实例。
绑定 Bot 后,Claude App 的伴生 worker 会把 Project/Session、流式回复、附件、会话用量和原生权限/Ask User 请求接入 IMApp 退出时 worker 同步停止。
### Codex
Codex 配置会写入 `config.toml`,并生成模型目录文件。选择“仅从 CCR 打开时生效”时,CCR 会把这些文件放在按配置 `id` 区分的目录中。
Codex 支持 CLI 和 App。CLI 会通过对应配置的启动器打开;App 会启动 ChatGPT、使用独立用户数据目录,并把当前配置中的模型和供应商信息带入 App。
绑定 Bot 后,Codex App 的伴生 worker 使用 Codex 原生 rollout Session,实现 Project/Session 浏览、续接、队列、取消、模型设置、用量、附件和诊断。该 worker 只随受管 App 存活。
### OpenCode
OpenCode 配置会写入 JSON/JSONC 文件,把当前选择的供应商和模型路由到 CCR。CLI 通过配置专属包装器启动;App 使用相同的有效配置启动已安装的 OpenCode Desktop。
选择 Bot 并从 CCR 打开 App 后,CCR 会启动配套 worker,通过 `opencode run --format json` 处理收到的 Bot 消息,保存 OpenCode 返回的 session ID,并把最终文本事件通过 Bot Gateway 发回。受管 OpenCode App 退出或切换配置时,该 worker 也会同步停止。
选择 Bot 并从 CCR 打开 App 后,CCR 会启动配套 worker,通过 OpenCode 原生 Session 处理收到的 Bot 消息,并提供与其他 App 一致的 Project/Session、队列、媒体、设置和诊断合同。受管 OpenCode App 退出或切换配置时,该 worker 也会同步停止。
### Grok CLI
@@ -151,6 +155,8 @@ Grok CLI 只支持 CLI。CCR 通过配置专属包装器启动它,注入 CCR
ZCode 只支持 App 打开。CCR 会根据 ZCode home 或自定义配置文件写入 ZCode 的 CLI 配置、v2 配置和模型缓存,并在 App 启动时使用当前 Agent配置的模型、供应商和独立用户数据目录。
绑定 Bot 后,ZCode 使用与 Codex 同类的 App 伴生 worker 和原生 Session 扫描;ZCode App 关闭时接力立即离线。
## 多开建议
1. 为每个需要独立运行的 Agent 实例创建一个 Agent配置。
@@ -29,7 +29,7 @@ Claude Code 的 Agent / Task / Workflow 可以派生新的模型请求。CCR 使
4. Claude Code 调用 `Agent` / `Task`,或 Workflow 内部创建 Agent 时,prompt 第一行会携带 `<CCR-SUBAGENT-MODEL>供应商/模型</CCR-SUBAGENT-MODEL>`
5. 派生请求进入 CCR 后,CCR 从 system 或前两条 user message 中提取并删除这个标签,然后把该请求路由到标签里的模型。
因此,Subagent / Workflow 的自动路由不是靠 `x-claude-code-agent-id` 之类的 Header 决定模型,而是靠 prompt 标签。Header 只能作为观测线索,真正的模型选择来自标签
因此,Subagent / Workflow 的自动路由由 prompt 标签决定模型。`x-claude-code-agent-id` 等 Header 用于观测,模型选择以标签为准
##### 与模型页配合
@@ -36,13 +36,21 @@ export function botGatewayProfileEnv(config: AppConfig, profile: ProfileConfig,
CCR_BOT_GATEWAY_ENABLED: "true",
CCR_BOT_GATEWAY_FORWARD_ALL_AGENT_MESSAGES: boolEnv(bot.forwardAllAgentMessages),
CCR_BOT_GATEWAY_INTEGRATION_ID: bot.integrationId ?? "",
CCR_BOT_GATEWAY_LANGUAGE: bot.language ?? "auto",
CCR_BOT_GATEWAY_MAX_ATTACHMENT_BYTES: String(bot.maxAttachmentBytes ?? 20 * 1024 * 1024),
CCR_BOT_GATEWAY_MAX_TURN_TIME_MS: String(bot.maxTurnTimeMs ?? 10 * 60 * 1000),
CCR_BOT_GATEWAY_MEDIA_ENABLED: boolEnv(bot.mediaEnabled),
CCR_BOT_GATEWAY_MESSAGE_CHUNK_CHARS: String(bot.messageChunkChars ?? 3500),
CCR_BOT_GATEWAY_PLATFORM: bot.platform,
CCR_BOT_GATEWAY_POLL_INTERVAL_MS: String(bot.pollIntervalMs ?? 2000),
CCR_BOT_GATEWAY_REQUEST_TIMEOUT_MS: String(bot.requestTimeoutMs ?? 600000),
CCR_BOT_GATEWAY_SESSION_IDLE_MINUTES: String(bot.sessionIdleMinutes ?? 0),
CCR_BOT_GATEWAY_SHELL_ENABLED: boolEnv(bot.shellEnabled),
CCR_BOT_GATEWAY_SOURCE_DIR: "",
...botGatewaySdkEnv(),
CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS: String(bot.startupTimeoutMs ?? 10000),
CCR_BOT_GATEWAY_STATE_DIR: stateDir,
CCR_BOT_GATEWAY_STREAM_REPLIES: boolEnv(bot.streamReplies),
CCR_BOT_GATEWAY_TENANT_ID: bot.tenantId ?? "ccr",
CCR_BOT_HANDOFF_ENABLED: boolEnv(handoff.enabled),
CCR_BOT_HANDOFF_IDLE_SECONDS: String(handoff.idleSeconds ?? 30),
@@ -217,6 +225,9 @@ function defaultBotGatewayAuthType(platform: string): string {
if (platform === "slack" || platform === "discord" || platform === "telegram" || platform === "line") {
return "bot_token";
}
if (platform === "imessage") {
return "local";
}
return "";
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -174,6 +174,9 @@ function defaultBotGatewayAuthType(platform: string): string {
if (platform === "slack" || platform === "discord" || platform === "telegram" || platform === "line") {
return "bot_token";
}
if (platform === "imessage") {
return "local";
}
return "";
}
@@ -1743,6 +1746,22 @@ function parseBotGateway(value: unknown): LoadedBotGatewayConfig | undefined {
config.forwardAllAgentMessages = Boolean(value.forward_all_agent_messages ?? value.forward_all_codex_messages);
}
if (typeof value.mediaEnabled === "boolean") {
config.mediaEnabled = value.mediaEnabled;
}
if (typeof value.streamReplies === "boolean") {
config.streamReplies = value.streamReplies;
}
if (typeof value.shellEnabled === "boolean") {
config.shellEnabled = value.shellEnabled;
} else if (typeof value.shell_enabled === "boolean") {
config.shellEnabled = value.shell_enabled;
}
const language = readString(value.language);
if (language === "auto" || language === "en" || language === "zh-CN") {
config.language = language;
}
const requestTimeoutMs = readNumber(value.requestTimeoutMs ?? value.request_timeout_ms);
if (requestTimeoutMs !== undefined) {
config.requestTimeoutMs = clampNumber(requestTimeoutMs, 1000, 3_600_000);
@@ -1755,6 +1774,22 @@ function parseBotGateway(value: unknown): LoadedBotGatewayConfig | undefined {
if (pollIntervalMs !== undefined) {
config.pollIntervalMs = clampNumber(pollIntervalMs, 500, 60_000);
}
const maxTurnTimeMs = readNumber(value.maxTurnTimeMs ?? value.max_turn_time_ms);
if (maxTurnTimeMs !== undefined) {
config.maxTurnTimeMs = clampNumber(maxTurnTimeMs, 10_000, 3_600_000);
}
const maxAttachmentBytes = readNumber(value.maxAttachmentBytes ?? value.max_attachment_bytes);
if (maxAttachmentBytes !== undefined) {
config.maxAttachmentBytes = clampNumber(maxAttachmentBytes, 1024, 100 * 1024 * 1024);
}
const messageChunkChars = readNumber(value.messageChunkChars ?? value.message_chunk_chars);
if (messageChunkChars !== undefined) {
config.messageChunkChars = clampNumber(messageChunkChars, 500, 20_000);
}
const sessionIdleMinutes = readNumber(value.sessionIdleMinutes ?? value.session_idle_minutes);
if (sessionIdleMinutes !== undefined) {
config.sessionIdleMinutes = clampNumber(sessionIdleMinutes, 0, 43_200);
}
const handoff = parseBotGatewayHandoff(value.handoff);
if (handoff) {
@@ -74,12 +74,20 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon
},
integrationConfig: {},
integrationId: "",
language: "auto",
maxAttachmentBytes: 20 * 1024 * 1024,
maxTurnTimeMs: 10 * 60 * 1000,
mediaEnabled: true,
messageChunkChars: 3500,
platform: "none",
pollIntervalMs: 2000,
requestTimeoutMs: 600000,
sessionIdleMinutes: 0,
shellEnabled: false,
sourceDir: "",
startupTimeoutMs: 10000,
stateDir: "",
streamReplies: true,
tenantId: "ccr"
},
gateway: {
+21
View File
@@ -1256,6 +1256,7 @@ export type ProfileOpenResult = {
export type ProfileRuntimeEntry = {
agent: AgentKind;
botGateway?: BotGatewayRuntimeStatus;
pid?: number;
profileId: string;
profileName: string;
@@ -1264,6 +1265,18 @@ export type ProfileRuntimeEntry = {
surface: ProfileOpenSurface;
};
export type BotGatewayRuntimeStatus = {
lastDeliveryAt?: string;
lastDeliveryStatus?: string;
lastError?: string;
lastErrorAt?: string;
lastEventAt?: string;
lastEventType?: string;
outboxCount: number;
state: "connected" | "error" | "starting" | "stopped" | "unknown";
updatedAt?: string;
};
export type ProfileRuntimeStatus = {
profiles: ProfileRuntimeEntry[];
};
@@ -1358,12 +1371,20 @@ export type BotGatewayRuntimeConfig = {
handoff: BotGatewayHandoffConfig;
integrationConfig: Record<string, unknown>;
integrationId: string;
language: "auto" | "en" | "zh-CN";
maxAttachmentBytes: number;
maxTurnTimeMs: number;
mediaEnabled: boolean;
messageChunkChars: number;
platform: string;
pollIntervalMs: number;
requestTimeoutMs: number;
sessionIdleMinutes: number;
shellEnabled: boolean;
sourceDir: string;
startupTimeoutMs: number;
stateDir: string;
streamReplies: boolean;
tenantId: string;
};
+116 -2
View File
@@ -25,9 +25,12 @@ const desktopCliRuntimeFileName = "ccr-cli.js";
const desktopCliCommandNameEnv = "CCR_CLI_COMMAND_NAME";
let claudeAppBotWorker: ChildProcess | undefined;
let claudeAppBotWorkerProfileId: string | undefined;
let claudeAppBotWorkerStateDir: string | undefined;
let openCodeAppBotWorker: ChildProcess | undefined;
let openCodeAppBotWorkerProfileId: string | undefined;
let openCodeAppBotWorkerSignature: string | undefined;
let openCodeAppBotWorkerStateDir: string | undefined;
const codexAppBotWorkers = new Map<string, { agent: ProfileConfig["agent"]; child: ChildProcess; stateDir?: string }>();
type ProfileOpenCommandOptions = {
commandName?: string;
@@ -74,6 +77,7 @@ type RunningProfileApp = ProfileRuntimeEntry & {
process.once("exit", () => {
stopClaudeAppBotWorker();
stopOpenCodeAppBotWorker();
stopCodexAppBotWorker();
});
export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest, options: ProfileOpenCommandOptions = {}): Promise<ProfileOpenCommandResult> {
@@ -238,6 +242,7 @@ async function openCodexAppProfile(config: AppConfig, profile: ReturnType<typeof
const existing = runningProfileApp(profile.id, "app");
if (existing) {
refreshCodexCompatibleAppProfileFiles(CONFIGDIR, profile, profileGatewayConfig);
startCodexAppBotWorker(profileGatewayConfig, profile);
activateProfileAppWindow(existing);
return {
message: `${appName} is already running with ${profile.name || profile.id}.`,
@@ -262,6 +267,7 @@ async function openCodexAppProfile(config: AppConfig, profile: ReturnType<typeof
].join(" "));
}
activateProfileAppWindow(entry);
startCodexAppBotWorker(profileGatewayConfig, profile);
return {
message: `Opened ${appName} with ${profile.name || profile.id}.`,
profileId: profile.id,
@@ -275,6 +281,7 @@ async function openClaudeAppProfile(config: AppConfig, profile: ReturnType<typeo
const existing = runningProfileApp(profile.id, "app");
if (existing) {
if (!claudeAppDesignProxyRequired(profileGatewayConfig) || existing.claudeDesignProxy) {
startClaudeAppBotWorker(config, profile);
activateProfileAppWindow(existing);
return {
message: `Claude App is already running with ${profile.name || profile.id}.`,
@@ -674,6 +681,36 @@ function claudeAppDesignProxyRequired(config: AppConfig): boolean {
return config.plugins.some((plugin) => plugin.enabled !== false && plugin.id === "claude-design");
}
function profileBotRuntimeStatus(profileId: string): ProfileRuntimeEntry["botGateway"] | undefined {
const codexWorker = codexAppBotWorkers.get(profileId);
const stateDir = claudeAppBotWorkerProfileId === profileId
? claudeAppBotWorkerStateDir
: openCodeAppBotWorkerProfileId === profileId
? openCodeAppBotWorkerStateDir
: codexWorker?.stateDir;
if (!stateDir) return undefined;
try {
const value = JSON.parse(readFileSync(path.join(stateDir, "bot-runtime-state.json"), "utf8")) as Record<string, unknown>;
const diagnostics = value.diagnostics && typeof value.diagnostics === "object" ? value.diagnostics as Record<string, unknown> : {};
const outbox = Array.isArray(value.outbox) ? value.outbox : [];
const rawState = typeof diagnostics.state === "string" ? diagnostics.state : "unknown";
const state = rawState === "connected" || rawState === "error" || rawState === "starting" || rawState === "stopped" ? rawState : "unknown";
return {
state,
outboxCount: outbox.length,
...(typeof diagnostics.lastDeliveryAt === "string" ? { lastDeliveryAt: diagnostics.lastDeliveryAt } : {}),
...(typeof diagnostics.lastDeliveryStatus === "string" ? { lastDeliveryStatus: diagnostics.lastDeliveryStatus } : {}),
...(typeof diagnostics.lastError === "string" && diagnostics.lastError ? { lastError: diagnostics.lastError } : {}),
...(typeof diagnostics.lastErrorAt === "string" ? { lastErrorAt: diagnostics.lastErrorAt } : {}),
...(typeof diagnostics.lastEventAt === "string" ? { lastEventAt: diagnostics.lastEventAt } : {}),
...(typeof diagnostics.lastEventType === "string" ? { lastEventType: diagnostics.lastEventType } : {}),
...(typeof diagnostics.updatedAt === "string" ? { updatedAt: diagnostics.updatedAt } : {})
};
} catch {
return { state: "starting", outboxCount: 0 };
}
}
export function getProfileRuntimeStatus(): ProfileRuntimeStatus {
cleanupExitedProfileApps();
return {
@@ -681,6 +718,7 @@ export function getProfileRuntimeStatus(): ProfileRuntimeStatus {
.filter((entry) => !entry.stopRequested)
.map((entry) => ({
agent: entry.agent,
...(profileBotRuntimeStatus(entry.profileId) ? { botGateway: profileBotRuntimeStatus(entry.profileId) } : {}),
pid: entry.pid,
profileId: entry.profileId,
profileName: entry.profileName,
@@ -716,6 +754,8 @@ export async function stopProfileFromCcr(config: AppConfig, request: ProfileOpen
stopClaudeAppBotWorker(profile.id);
} else if (profile.agent === "opencode") {
stopOpenCodeAppBotWorker(profile.id);
} else if (profile.agent === "codex" || profile.agent === "zcode") {
stopCodexAppBotWorker(profile.id);
}
}
return {
@@ -848,12 +888,15 @@ function cleanupProfileAppEntry(key: string, entry: RunningProfileApp): void {
return;
}
runningProfileApps.delete(key);
if (entry.stopRequested && entry.agent === "claude-code") {
if (entry.agent === "claude-code") {
stopClaudeAppBotWorker(entry.profileId);
}
if (entry.agent === "opencode") {
stopOpenCodeAppBotWorker(entry.profileId);
}
if (entry.agent === "codex" || entry.agent === "zcode") {
stopCodexAppBotWorker(entry.profileId);
}
}
async function stopRunningProfileApp(key: string, entry: RunningProfileApp): Promise<boolean> {
@@ -1158,6 +1201,7 @@ function startClaudeAppBotWorker(config: AppConfig, profile: ReturnType<typeof f
});
claudeAppBotWorker = child;
claudeAppBotWorkerProfileId = profile.id;
claudeAppBotWorkerStateDir = botEnv.CCR_BOT_GATEWAY_STATE_DIR;
child.stderr?.on("data", (chunk) => {
console.warn(`[profile] Claude App bot worker stderr: ${chunk.toString("utf8").trim()}`);
});
@@ -1165,6 +1209,7 @@ function startClaudeAppBotWorker(config: AppConfig, profile: ReturnType<typeof f
if (claudeAppBotWorker === child) {
claudeAppBotWorker = undefined;
claudeAppBotWorkerProfileId = undefined;
claudeAppBotWorkerStateDir = undefined;
}
if (code && code !== 0) {
console.warn(`[profile] Claude App bot worker exited: code=${code}${signal ? ` signal=${signal}` : ""}`);
@@ -1174,6 +1219,7 @@ function startClaudeAppBotWorker(config: AppConfig, profile: ReturnType<typeof f
if (claudeAppBotWorker === child) {
claudeAppBotWorker = undefined;
claudeAppBotWorkerProfileId = undefined;
claudeAppBotWorkerStateDir = undefined;
}
console.warn(`[profile] Claude App bot worker failed: ${formatError(error)}`);
});
@@ -1228,6 +1274,7 @@ function startOpenCodeAppBotWorker(
openCodeAppBotWorker = child;
openCodeAppBotWorkerProfileId = profile.id;
openCodeAppBotWorkerSignature = launchSignature;
openCodeAppBotWorkerStateDir = botEnv.CCR_BOT_GATEWAY_STATE_DIR;
child.stderr?.on("data", (chunk) => {
console.warn(`[profile] OpenCode App bot worker stderr: ${chunk.toString("utf8").trim()}`);
});
@@ -1236,6 +1283,7 @@ function startOpenCodeAppBotWorker(
openCodeAppBotWorker = undefined;
openCodeAppBotWorkerProfileId = undefined;
openCodeAppBotWorkerSignature = undefined;
openCodeAppBotWorkerStateDir = undefined;
}
if (code && code !== 0) {
console.warn(`[profile] OpenCode App bot worker exited: code=${code}${signal ? ` signal=${signal}` : ""}`);
@@ -1246,11 +1294,57 @@ function startOpenCodeAppBotWorker(
openCodeAppBotWorker = undefined;
openCodeAppBotWorkerProfileId = undefined;
openCodeAppBotWorkerSignature = undefined;
openCodeAppBotWorkerStateDir = undefined;
}
console.warn(`[profile] OpenCode App bot worker failed: ${formatError(error)}`);
});
}
function startCodexAppBotWorker(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>): void {
const botEnv = botGatewayProfileEnv(config, profile, "app");
if (botEnv.CCR_BOT_GATEWAY_ENABLED !== "true") {
stopCodexAppBotWorker(profile.id);
return;
}
const existing = codexAppBotWorkers.get(profile.id);
if (existing && !existing.child.killed && existing.child.exitCode === null) {
return;
}
stopCodexAppBotWorker(profile.id);
const plan = buildProfileLaunchPlan(CONFIGDIR, profile, "app", ["codex-bot-worker", "--workspace-name", profile.name || profile.id]);
const launch = profileLaunchSpawnCommand(plan);
const env: NodeJS.ProcessEnv = {
...process.env,
...plan.env,
...botEnv,
CCR_CODEX_BOT_WORKER: "1",
CCR_PROFILE_SURFACE: "app",
CODEXL_PROFILE_SURFACE: "app"
};
delete env.ELECTRON_NO_ATTACH_CONSOLE;
const child = spawn(launch.command, launch.args, {
detached: false,
env,
stdio: ["ignore", "ignore", "pipe"],
windowsHide: true,
windowsVerbatimArguments: launch.windowsVerbatimArguments
});
codexAppBotWorkers.set(profile.id, { agent: profile.agent, child, stateDir: botEnv.CCR_BOT_GATEWAY_STATE_DIR });
child.stderr?.on("data", (chunk) => {
console.warn(`[profile] ${profile.agent === "zcode" ? "ZCode" : "Codex"} App bot worker stderr: ${chunk.toString("utf8").trim()}`);
});
child.once("exit", (code, signal) => {
if (codexAppBotWorkers.get(profile.id)?.child === child) codexAppBotWorkers.delete(profile.id);
if (code && code !== 0) {
console.warn(`[profile] ${profile.agent === "zcode" ? "ZCode" : "Codex"} App bot worker exited: code=${code}${signal ? ` signal=${signal}` : ""}`);
}
});
child.once("error", (error) => {
if (codexAppBotWorkers.get(profile.id)?.child === child) codexAppBotWorkers.delete(profile.id);
console.warn(`[profile] ${profile.agent === "zcode" ? "ZCode" : "Codex"} App bot worker failed: ${formatError(error)}`);
});
}
function readClaudeCodeSettingsEnv(settingsFile: string): Record<string, string> {
if (!existsSync(settingsFile)) {
return {};
@@ -1294,7 +1388,9 @@ function ensureBotWorkerRuntime(runtimeFile: string): void {
!content.includes("CCR_CLAUDE_CODE_BOT_WORKER") ||
!content.includes("claude-bot-worker") ||
!content.includes("CCR_OPENCODE_BOT_WORKER") ||
!content.includes("opencode-bot-worker")
!content.includes("opencode-bot-worker") ||
!content.includes("CCR_CODEX_BOT_WORKER") ||
!content.includes("codex-bot-worker")
) {
throw new Error("Bot worker runtime does not contain all required entrypoints.");
}
@@ -1307,6 +1403,7 @@ function stopClaudeAppBotWorker(profileId?: string): void {
const child = claudeAppBotWorker;
claudeAppBotWorker = undefined;
claudeAppBotWorkerProfileId = undefined;
claudeAppBotWorkerStateDir = undefined;
if (!child || child.killed) {
return;
}
@@ -1325,6 +1422,7 @@ function stopOpenCodeAppBotWorker(profileId?: string): void {
openCodeAppBotWorker = undefined;
openCodeAppBotWorkerProfileId = undefined;
openCodeAppBotWorkerSignature = undefined;
openCodeAppBotWorkerStateDir = undefined;
if (!child || child.killed) {
return;
}
@@ -1335,6 +1433,22 @@ function stopOpenCodeAppBotWorker(profileId?: string): void {
}
}
function stopCodexAppBotWorker(profileId?: string): void {
const entries = profileId
? [[profileId, codexAppBotWorkers.get(profileId)] as const]
: [...codexAppBotWorkers.entries()];
for (const [id, entry] of entries) {
if (!entry) continue;
codexAppBotWorkers.delete(id);
if (entry.child.killed) continue;
try {
entry.child.kill("SIGTERM");
} catch {
// The worker may have already exited.
}
}
}
function nodeRuntimeLaunch(): { command: string; electronRunAsNode: boolean } {
const configured = process.env.CCR_NODE_BIN?.trim();
if (configured) {
@@ -82,9 +82,10 @@ export function ProfileView({
const summaryItems = profileSummaryItems(profile, config, t);
const cliBusy = profileActionBusy?.profileId === profile.id && profileActionBusy.surface === "cli";
const appBusy = profileActionBusy?.profileId === profile.id && profileActionBusy.surface === "app";
const appRunning = profileRuntimeStatus.profiles.some((entry) =>
const runtimeEntry = profileRuntimeStatus.profiles.find((entry) =>
entry.profileId === profile.id && entry.surface === "app" && entry.state === "running"
);
const appRunning = Boolean(runtimeEntry);
const appActionLabel = appRunning ? "Stop" : "Start";
const appActionTooltip = `${t(appActionLabel)} ${t("App")}`;
const cliActionTooltip = `${t("Copy")} ${t("CLI command")}`;
@@ -106,6 +107,11 @@ export function ProfileView({
{t(profileScopeLabel(scope))}
</Badge>
<Badge variant="outline">{t(profileSurfaceLabel(surface))}</Badge>
{runtimeEntry?.botGateway ? (
<Badge variant={runtimeEntry.botGateway.state === "connected" ? "success" : runtimeEntry.botGateway.lastError ? "warning" : "outline"}>
{t("Bot")} · {t(runtimeEntry.botGateway.state === "connected" ? "Connected" : runtimeEntry.botGateway.state === "starting" ? "Starting" : runtimeEntry.botGateway.state)}
</Badge>
) : null}
</div>
</div>
<div className="mt-2 min-w-0 space-y-1.5">
@@ -115,6 +121,19 @@ export function ProfileView({
<div className="min-w-0 truncate font-medium text-foreground" title={item.value}>{item.value}</div>
</div>
))}
{runtimeEntry?.botGateway ? (
<div className="grid min-w-0 grid-cols-[96px_minmax(0,1fr)] items-baseline gap-2 text-[12px] sm:grid-cols-[128px_minmax(0,1fr)]">
<div className="truncate text-muted-foreground">{t("Bot activity")}</div>
<div className="min-w-0 truncate font-medium text-foreground" title={runtimeEntry.botGateway.lastError || runtimeEntry.botGateway.lastEventAt || ""}>
{runtimeEntry.botGateway.lastError
? runtimeEntry.botGateway.lastError
: runtimeEntry.botGateway.lastEventAt
? `${t("Last event")}: ${new Date(runtimeEntry.botGateway.lastEventAt).toLocaleString()}`
: t("Waiting for messages")}
{runtimeEntry.botGateway.outboxCount > 0 ? ` · ${runtimeEntry.botGateway.outboxCount} ${t("pending")}` : ""}
</div>
</div>
) : null}
</div>
</div>
</div>
@@ -968,6 +987,9 @@ function BotGatewaySelectForm({
onRefresh={() => void scanHandoffTargets("bluetooth")}
onSelect={(botHandoffPhoneBluetoothTargets) => onChange({ botHandoffPhoneBluetoothTargets })}
/>
<div className="sm:col-span-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-[11px] leading-4 text-amber-700 dark:text-amber-300">
{t("Phone presence targets are experimental and do not affect runtime handoff yet. Handoff currently uses screen lock and idle time.")}
</div>
</div>
) : null}
</div>
@@ -1756,6 +1756,44 @@ function BotConfigDialog({
/>
</Field>
))}
<Field label={t("Bot language")}>
<SelectControl
onChange={(value) => update({ botLanguage: value as BotGatewayConfigDraft["botLanguage"] })}
options={[
{ label: t("Automatic"), value: "auto" },
{ label: "English", value: "en" },
{ label: "简体中文", value: "zh-CN" }
]}
value={draft.botLanguage}
/>
</Field>
<Field label={t("Maximum turn time (minutes)")}>
<Input min="1" max="60" type="number" value={draft.botMaxTurnMinutes} onChange={(event) => update({ botMaxTurnMinutes: event.target.value })} />
</Field>
<Field label={t("Session idle reset (minutes, 0 disables)")}>
<Input min="0" max="43200" type="number" value={draft.botSessionIdleMinutes} onChange={(event) => update({ botSessionIdleMinutes: event.target.value })} />
</Field>
<Field label={t("Long message chunk size")}>
<Input min="500" max="20000" type="number" value={draft.botMessageChunkChars} onChange={(event) => update({ botMessageChunkChars: event.target.value })} />
</Field>
<Field label={t("Maximum attachment size (MB)")}>
<Input min="1" max="100" type="number" value={draft.botMaxAttachmentMb} onChange={(event) => update({ botMaxAttachmentMb: event.target.value })} />
</Field>
<div className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2">
<span className="text-[12px] font-medium">{t("Stream replies and progress")}</span>
<Switch checked={draft.botStreamReplies} onCheckedChange={(checked) => update({ botStreamReplies: checked })} />
</div>
<div className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2">
<span className="text-[12px] font-medium">{t("Send and receive attachments")}</span>
<Switch checked={draft.botMediaEnabled} onCheckedChange={(checked) => update({ botMediaEnabled: checked })} />
</div>
<div className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2">
<div>
<div className="text-[12px] font-medium">{t("Allow Agent shell tools")}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">{t("Controls Agent tool permission; it does not add a Bot shell command.")}</div>
</div>
<Switch checked={draft.botShellEnabled} onCheckedChange={(checked) => update({ botShellEnabled: checked })} />
</div>
</fieldset>
{error ? (
<div className="mt-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[12px] text-destructive">
@@ -857,6 +857,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Group": "群组",
"Handoff": "接力",
"Handoff target scan is available in the Electron app.": "接力目标扫描仅在 Electron App 中可用。",
"Phone presence targets are experimental and do not affect runtime handoff yet. Handoff currently uses screen lock and idle time.": "手机在场目标仍为实验功能,当前不会参与运行时接力判断;接力目前使用锁屏与空闲时间。",
"Headers": "请求头",
"Header rows require keys.": "请求头行必须填写 Key。",
"Fetch usage": "获取用量",
@@ -1333,6 +1334,21 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Search engine": "搜索引擎",
"Safe search": "安全搜索",
"Language": "语言",
"Bot language": "Bot 语言",
"Maximum turn time (minutes)": "单次运行最长时间(分钟)",
"Session idle reset (minutes, 0 disables)": "会话空闲重置(分钟,0 为关闭)",
"Long message chunk size": "长消息分片字符数",
"Maximum attachment size (MB)": "附件大小上限(MB",
"Stream replies and progress": "流式回复与进度",
"Send and receive attachments": "收发附件",
"Allow Agent shell tools": "允许 Agent 使用 Shell 工具",
"Controls Agent tool permission; it does not add a Bot shell command.": "仅控制 Agent 工具权限,不会增加 Bot Shell 命令。",
"Local App": "本机 App",
"Starting": "正在连接",
"Bot activity": "Bot 动态",
"Last event": "最后事件",
"Waiting for messages": "等待消息",
"pending": "待发送",
"Country": "国家/地区",
"Advanced variables": "高级变量",
"Default": "默认",
@@ -673,6 +673,11 @@ const botGatewayPlatformSpecs: readonly BotGatewayPlatformSpec[] = [
]
}
]
},
{
value: "imessage",
label: "iMessage",
auth: [{ value: "local", label: "Local App", fields: [] }]
}
];
@@ -914,6 +919,7 @@ function botGatewayHandoffFromProfileDraft(
export function createBotGatewayConfigDraft(config?: BotGatewaySavedConfig): BotGatewayConfigDraft {
const botDraft = createBotGatewayDraft(config?.botGateway);
const bot = normalizeBotGatewayRuntimeConfig(config?.botGateway) ?? fallbackConfig.botGateway;
return {
botAuthFields: botDraft.botAuthFields,
botAuthType: botDraft.botAuthType,
@@ -922,7 +928,15 @@ export function createBotGatewayConfigDraft(config?: BotGatewaySavedConfig): Bot
botHandoffIdleSeconds: botDraft.botHandoffIdleSeconds,
botHandoffPhoneBluetoothTargets: botDraft.botHandoffPhoneBluetoothTargets,
botHandoffPhoneWifiTargets: botDraft.botHandoffPhoneWifiTargets,
botLanguage: bot.language,
botMaxAttachmentMb: String(Math.max(1, Math.round(bot.maxAttachmentBytes / (1024 * 1024)))),
botMaxTurnMinutes: String(Math.max(1, Math.round(bot.maxTurnTimeMs / 60_000))),
botMediaEnabled: bot.mediaEnabled,
botMessageChunkChars: String(bot.messageChunkChars),
botPlatform: botDraft.botPlatform === "none" ? "weixin-ilink" : botDraft.botPlatform,
botSessionIdleMinutes: String(bot.sessionIdleMinutes),
botShellEnabled: bot.shellEnabled,
botStreamReplies: bot.streamReplies,
name: config?.name ?? ""
};
}
@@ -990,12 +1004,20 @@ function botGatewayConfigFromDraft(
},
integrationConfig: authPayload.integrationConfig,
integrationId: existingBotGateway?.integrationId?.trim() || createBotGatewayIntegrationId(configId),
language: draft.botLanguage,
maxAttachmentBytes: numberDraftValue(draft.botMaxAttachmentMb, 20, 1, 100) * 1024 * 1024,
maxTurnTimeMs: numberDraftValue(draft.botMaxTurnMinutes, 10, 1, 60) * 60_000,
mediaEnabled: draft.botMediaEnabled,
messageChunkChars: numberDraftValue(draft.botMessageChunkChars, 3500, 500, 20_000),
platform,
pollIntervalMs: fallbackConfig.botGateway.pollIntervalMs,
requestTimeoutMs: fallbackConfig.botGateway.requestTimeoutMs,
sessionIdleMinutes: numberDraftValue(draft.botSessionIdleMinutes, 0, 0, 43_200),
shellEnabled: draft.botShellEnabled,
sourceDir: "",
startupTimeoutMs: fallbackConfig.botGateway.startupTimeoutMs,
stateDir: existingBotGateway?.stateDir?.trim() || createBotGatewayStateDir(configId),
streamReplies: draft.botStreamReplies,
tenantId: existingBotGateway?.tenantId?.trim() || createBotGatewayTenantId(configName || configId)
};
return config;
@@ -1309,6 +1331,17 @@ export function normalizeBotGatewayRuntimeConfig(value: unknown): BotGatewayRunt
},
integrationConfig: websocketBotGatewayIntegrationConfig(platform, isPlainRecord(record.integrationConfig) ? record.integrationConfig : {}),
integrationId: typeof record.integrationId === "string" ? record.integrationId : fallbackConfig.botGateway.integrationId,
language: record.language === "en" || record.language === "zh-CN" || record.language === "auto" ? record.language : fallbackConfig.botGateway.language,
maxAttachmentBytes: Number.isFinite(Number(record.maxAttachmentBytes))
? numberDraftValue(String(record.maxAttachmentBytes), fallbackConfig.botGateway.maxAttachmentBytes, 1024, 100 * 1024 * 1024)
: fallbackConfig.botGateway.maxAttachmentBytes,
maxTurnTimeMs: Number.isFinite(Number(record.maxTurnTimeMs))
? numberDraftValue(String(record.maxTurnTimeMs), fallbackConfig.botGateway.maxTurnTimeMs, 10_000, 3_600_000)
: fallbackConfig.botGateway.maxTurnTimeMs,
mediaEnabled: typeof record.mediaEnabled === "boolean" ? record.mediaEnabled : fallbackConfig.botGateway.mediaEnabled,
messageChunkChars: Number.isFinite(Number(record.messageChunkChars))
? numberDraftValue(String(record.messageChunkChars), fallbackConfig.botGateway.messageChunkChars, 500, 20_000)
: fallbackConfig.botGateway.messageChunkChars,
platform,
pollIntervalMs: Number.isFinite(Number(record.pollIntervalMs))
? numberDraftValue(String(record.pollIntervalMs), fallbackConfig.botGateway.pollIntervalMs, 500, 60_000)
@@ -1316,11 +1349,16 @@ export function normalizeBotGatewayRuntimeConfig(value: unknown): BotGatewayRunt
requestTimeoutMs: Number.isFinite(Number(record.requestTimeoutMs))
? numberDraftValue(String(record.requestTimeoutMs), fallbackConfig.botGateway.requestTimeoutMs, 1000, 3_600_000)
: fallbackConfig.botGateway.requestTimeoutMs,
sessionIdleMinutes: Number.isFinite(Number(record.sessionIdleMinutes))
? numberDraftValue(String(record.sessionIdleMinutes), fallbackConfig.botGateway.sessionIdleMinutes, 0, 43_200)
: fallbackConfig.botGateway.sessionIdleMinutes,
shellEnabled: typeof record.shellEnabled === "boolean" ? record.shellEnabled : fallbackConfig.botGateway.shellEnabled,
sourceDir: typeof record.sourceDir === "string" ? record.sourceDir : fallbackConfig.botGateway.sourceDir,
startupTimeoutMs: Number.isFinite(Number(record.startupTimeoutMs))
? numberDraftValue(String(record.startupTimeoutMs), fallbackConfig.botGateway.startupTimeoutMs, 1000, 120_000)
: fallbackConfig.botGateway.startupTimeoutMs,
stateDir: typeof record.stateDir === "string" ? record.stateDir : fallbackConfig.botGateway.stateDir,
streamReplies: typeof record.streamReplies === "boolean" ? record.streamReplies : fallbackConfig.botGateway.streamReplies,
tenantId: typeof record.tenantId === "string" ? record.tenantId : fallbackConfig.botGateway.tenantId
};
if (conversationRef) {
@@ -518,7 +518,15 @@ export type BotGatewayConfigDraft = {
botHandoffIdleSeconds: string;
botHandoffPhoneBluetoothTargets: string;
botHandoffPhoneWifiTargets: string;
botLanguage: "auto" | "en" | "zh-CN";
botMaxAttachmentMb: string;
botMaxTurnMinutes: string;
botMediaEnabled: boolean;
botMessageChunkChars: string;
botPlatform: string;
botSessionIdleMinutes: string;
botShellEnabled: boolean;
botStreamReplies: boolean;
name: string;
};
+20
View File
@@ -109,3 +109,23 @@ test("botGatewayProfileEnv disables create integration for QR login platforms",
assert.equal(env.CCR_BOT_GATEWAY_CREATE_INTEGRATION, "false");
assert.equal(JSON.parse(env.CCR_BOT_GATEWAY_CONFIG_JSON).transport, "websocket");
});
test("botGatewayProfileEnv defaults iMessage to local auth", () => {
const env = botGatewayProfileEnv(
{
botConfigs: [],
botGateway: botGateway({
authType: "",
credentials: {},
integrationConfig: {},
platform: "imessage"
})
},
{ ...profile, botConfigId: undefined },
"app"
);
assert.equal(env.CCR_BOT_GATEWAY_PLATFORM, "imessage");
assert.equal(env.CCR_BOT_GATEWAY_AUTH_TYPE, "local");
assert.equal(JSON.parse(env.CCR_BOT_GATEWAY_CONFIG_JSON).transport, "websocket");
});
@@ -375,8 +375,10 @@ test("OpenCode bot worker keeps commands responsive while preserving per-convers
const callsFile = path.join(dir, "opencode-calls.jsonl");
const repliesFile = path.join(dir, "bot-replies.jsonl");
const stateDir = path.join(dir, "bot-state");
const otherProject = path.join(dir, "other-project");
const configFile = path.join(dir, "opencode.json");
mkdirSync(stateDir, { recursive: true });
mkdirSync(otherProject, { recursive: true });
writeFileSync(path.join(stateDir, "opencode-bot-sessions.json"), JSON.stringify({
version: 1,
conversations: {
@@ -402,7 +404,10 @@ test("OpenCode bot worker keeps commands responsive while preserving per-convers
" workerMarker: process.env.CCR_OPENCODE_BOT_WORKER || ''",
"}) + '\\n');",
"if (argv[0] === 'session') {",
" process.stdout.write(JSON.stringify([{ id: 'ses_existing', title: 'Existing session', directory: process.cwd(), time: { updated: Date.now() } }]) + '\\n');",
" process.stdout.write(JSON.stringify([",
" { id: 'ses_existing', title: 'Existing session', directory: process.cwd(), time: { updated: Date.now() } },",
" { id: 'ses_other', title: 'Other session', directory: process.env.CCR_FAKE_OTHER_PROJECT, time: { updated: Date.now() - 1 } }",
" ]) + '\\n');",
"} else {",
" const prompt = argv[argv.length - 1];",
" const reply = () => process.stdout.write(JSON.stringify({",
@@ -433,7 +438,16 @@ test("OpenCode bot worker keeps commands responsive while preserving per-convers
" message: { id: 'message-' + id, text }",
" }",
" });",
" return { events: [event('event-1', 'first'), event('event-ls', 'ls'), event('event-2', 'second')] };",
" return { events: [",
" event('event-1', 'first'),",
" event('event-natural-help', 'help'),",
" event('event-old-task', '/task'),",
" event('event-project-help', '/project'),",
" event('event-project-list', '/project list'),",
" event('event-session-help', '/session'),",
" event('event-session-list', '/session list'),",
" event('event-2', 'second')",
" ] };",
" },",
" send: async (payload) => fs.appendFileSync(process.env.CCR_FAKE_BOT_REPLIES, JSON.stringify(payload) + '\\n'),",
" ackEvent: async () => ({}),",
@@ -457,10 +471,12 @@ test("OpenCode bot worker keeps commands responsive while preserving per-convers
CCR_BOT_GATEWAY_ACK_EVENTS: "true",
CCR_BOT_GATEWAY_POLL_INTERVAL_MS: "50",
CCR_BOT_GATEWAY_REQUEST_TIMEOUT_MS: "2000",
CCR_BOT_GATEWAY_SHELL_ENABLED: "true",
CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS: "2000",
CCR_BOT_GATEWAY_SDK_MODULE: fakeSdk,
CCR_BOT_GATEWAY_STATE_DIR: stateDir,
CCR_FAKE_OPENCODE_CALLS: callsFile,
CCR_FAKE_OTHER_PROJECT: otherProject,
CCR_FAKE_BOT_REPLIES: repliesFile,
OPENCODE_CONFIG: configFile,
OPENCODE_CONFIG_CONTENT: "{\"provider\":{}}"
@@ -469,15 +485,27 @@ test("OpenCode bot worker keeps commands responsive while preserving per-convers
});
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
try {
const replies = await waitForJsonLines(repliesFile, 3, 7000, () => stderr);
const calls = await waitForJsonLines(callsFile, 3, 2000, () => stderr);
assert.match(replies[0].intent.text, /^OpenCode sessions:/);
assert.deepEqual(replies.slice(1).map((reply) => reply.intent.text), ["reply:first", "reply:second"]);
// Three natural-language turns produce two queue-position notices while
// the first delayed turn is running, in addition to the command replies.
const replies = await waitForJsonLines(repliesFile, 10, 7000, () => stderr);
const calls = await waitForJsonLines(callsFile, 5, 2000, () => stderr);
const replyTexts = replies.map((reply) => reply.intent.text);
assert.ok(replyTexts.includes("Unknown Bot command. Send /project or /session to see available commands."));
assert.ok(replyTexts.some((text) => /^CCR App project commands \(OpenCode\):/.test(text)));
assert.ok(replyTexts.some((text) => /^OpenCode projects:/.test(text)));
assert.ok(replyTexts.some((text) => /^CCR App session commands \(OpenCode\):/.test(text)));
assert.ok(replyTexts.some((text) => /^OpenCode sessions in /.test(text)));
assert.ok(replyTexts.some((text) => text.includes(otherProject)));
assert.ok(!replyTexts.some((text) => text.includes("Other session")));
assert.deepEqual(replyTexts.filter((text) => text.startsWith("reply:")), ["reply:first", "reply:help", "reply:second"]);
const runCalls = calls.filter((call) => call.argv[0] === "run");
assert.deepEqual(runCalls[0].argv.slice(0, 7), ["run", "--format", "json", "--dir", dir, "--title", "Bot: OpenCode Test"]);
assert.ok(!runCalls[0].argv.includes("--auto"));
assert.equal(runCalls[0].argv.at(-1), "first");
assert.deepEqual(runCalls[1].argv.slice(0, 7), ["run", "--format", "json", "--dir", dir, "--session", "ses_bot_1"]);
assert.equal(runCalls[1].argv.at(-1), "second");
assert.equal(runCalls[1].argv.at(-1), "help");
assert.deepEqual(runCalls[2].argv.slice(0, 7), ["run", "--format", "json", "--dir", dir, "--session", "ses_bot_1"]);
assert.equal(runCalls[2].argv.at(-1), "second");
assert.equal(realpathSync(runCalls[0].cwd), realpathSync(dir));
assert.equal(runCalls[0].pwd, dir);
assert.equal(runCalls[0].config, configFile);
@@ -485,9 +513,394 @@ test("OpenCode bot worker keeps commands responsive while preserving per-convers
assert.equal(runCalls[0].client, "cli");
assert.equal(runCalls[0].workerMarker, "");
const store = JSON.parse(readFileSync(path.join(stateDir, "opencode-bot-sessions.json"), "utf8"));
assert.equal(store.version, 2);
assert.equal(store.version, 3);
assert.equal(Object.values(store.conversations)[0].sessionId, "ses_bot_1");
assert.equal(Object.values(store.conversations)[0].directory, dir);
assert.equal(Object.values(store.conversations)[0].projectDirectory, dir);
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
await waitForChildExit(child, 3000);
}
});
test("OpenCode bot worker streams without duplicate final text replies or implicit auto approval", { skip: process.platform === "win32" }, async () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-opencode-bot-stream-"));
const runtimeFile = writeRuntimeScript(dir);
const fakeOpenCode = path.join(dir, "fake-opencode");
const fakeSdk = path.join(dir, "fake-bot-gateway-sdk.mjs");
const callsFile = path.join(dir, "opencode-calls.jsonl");
const repliesFile = path.join(dir, "bot-replies.jsonl");
const stateDir = path.join(dir, "bot-state");
mkdirSync(stateDir, { recursive: true });
writeFileSync(fakeOpenCode, [
"#!/usr/bin/env node",
"const fs = require('node:fs');",
"const argv = process.argv.slice(2);",
"fs.appendFileSync(process.env.CCR_FAKE_OPENCODE_CALLS, JSON.stringify({ argv, cwd: process.cwd() }) + '\\n');",
"process.stdout.write(JSON.stringify({ type: 'text', sessionID: 'ses_stream', part: { id: 'part-1', type: 'text', text: 'reply:stream me' } }) + '\\n');",
""
].join("\n"));
chmodSync(fakeOpenCode, 0o700);
writeFileSync(fakeSdk, [
"import fs from 'node:fs';",
"let delivered = false;",
"export function createBotGatewayClient() {",
" return {",
" health: async () => ({}),",
" events: async () => {",
" if (delivered) return { events: [] };",
" delivered = true;",
" return { events: [{ id: 'event-stream', event: {",
" id: 'event-stream', tenantId: 'ccr', integrationId: 'bot-test', platform: 'slack', actor: { isBot: false },",
" conversation: { id: 'conversation-1', type: 'dm' }, message: { id: 'message-stream', text: 'stream me' }",
" } }] };",
" },",
" send: async (payload) => fs.appendFileSync(process.env.CCR_FAKE_BOT_REPLIES, JSON.stringify(payload) + '\\n'),",
" ackEvent: async () => ({}),",
" close: async () => ({})",
" };",
"}",
""
].join("\n"));
let stderr = "";
const child = spawn(process.execPath, [runtimeFile, "opencode-bot-worker", "--workspace-name", "OpenCode Test"], {
env: {
...process.env,
CCR_OPENCODE_BOT_WORKER: "1",
CCR_OPENCODE_BOT_CWD: dir,
CCR_OPENCODE_BIN: fakeOpenCode,
CCR_BOT_GATEWAY_ENABLED: "true",
CCR_BOT_GATEWAY_PLATFORM: "slack",
CCR_BOT_GATEWAY_INTEGRATION_ID: "bot-test",
CCR_BOT_GATEWAY_TENANT_ID: "ccr",
CCR_BOT_GATEWAY_ACK_EVENTS: "true",
CCR_BOT_GATEWAY_POLL_INTERVAL_MS: "50",
CCR_BOT_GATEWAY_REQUEST_TIMEOUT_MS: "2000",
CCR_BOT_GATEWAY_SHELL_ENABLED: "true",
CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS: "2000",
CCR_BOT_GATEWAY_STREAM_REPLIES: "true",
CCR_BOT_GATEWAY_SDK_MODULE: fakeSdk,
CCR_BOT_GATEWAY_STATE_DIR: stateDir,
CCR_FAKE_OPENCODE_CALLS: callsFile,
CCR_FAKE_BOT_REPLIES: repliesFile
},
stdio: ["ignore", "ignore", "pipe"]
});
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
try {
const replies = await waitForJsonLines(repliesFile, 2, 7000, () => stderr);
const calls = await waitForJsonLines(callsFile, 1, 2000, () => stderr);
assert.equal(calls[0].argv.includes("--auto"), false);
assert.equal(replies.every((reply) => reply.intent.type === "stream_text"), true);
assert.equal(replies.some((reply) => reply.intent.type === "text" && reply.intent.text === "reply:stream me"), false);
assert.equal(replies.filter((reply) => reply.intent.final === true).length, 1);
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
await waitForChildExit(child, 3000);
}
});
test("Codex App bot worker uses native projects and sessions without enabling shell tools", { skip: process.platform === "win32" }, async () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-codex-bot-"));
const runtimeFile = writeRuntimeScript(dir);
const fakeCodex = path.join(dir, "fake-codex");
const fakeSdk = path.join(dir, "fake-bot-gateway-sdk.mjs");
const callsFile = path.join(dir, "codex-calls.jsonl");
const repliesFile = path.join(dir, "bot-replies.jsonl");
const stateDir = path.join(dir, "bot-state");
const codexHome = path.join(dir, "codex-home");
const sessionsDir = path.join(codexHome, "sessions", "2026", "07", "14");
mkdirSync(stateDir, { recursive: true });
mkdirSync(sessionsDir, { recursive: true });
writeFileSync(path.join(sessionsDir, "rollout-ses_existing.jsonl"), [
JSON.stringify({ type: "session_meta", payload: { id: "ses_existing", cwd: dir, timestamp: "2026-07-14T00:00:00.000Z" } }),
JSON.stringify({ type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text: "Existing Codex session" }] } }),
""
].join("\n"));
writeFileSync(fakeCodex, [
"#!/usr/bin/env node",
"const fs = require('node:fs');",
"const argv = process.argv.slice(2);",
"fs.appendFileSync(process.env.CCR_FAKE_CODEX_CALLS, JSON.stringify({ argv, cwd: process.cwd() }) + '\\n');",
"const prompt = argv[argv.length - 1];",
"const reply = () => {",
" process.stdout.write(JSON.stringify({ type: 'thread.started', thread_id: 'ses_codex' }) + '\\n');",
" process.stdout.write(JSON.stringify({ type: 'item.completed', item: { id: 'answer-' + prompt, type: 'agent_message', text: 'reply:' + prompt } }) + '\\n');",
"};",
"if (prompt === 'first') setTimeout(reply, 500); else reply();",
""
].join("\n"));
chmodSync(fakeCodex, 0o700);
writeFileSync(fakeSdk, [
"import fs from 'node:fs';",
"let delivered = false;",
"export function createBotGatewayClient() {",
" return {",
" health: async () => ({}),",
" events: async () => {",
" if (delivered) return { events: [] };",
" delivered = true;",
" const event = (id, text) => ({ id, event: {",
" id, tenantId: 'ccr', integrationId: 'bot-test', platform: 'slack', actor: { isBot: false },",
" conversation: { id: 'conversation-1', type: 'dm' }, message: { id: 'message-' + id, text }",
" } });",
" return { events: [",
" event('project-list', '/project list'),",
" event('project-use', '/project use 1'),",
" event('session-list', '/session list'),",
" event('session-use', '/session use 1'),",
" event('first', 'first'),",
" event('second', 'second')",
" ] };",
" },",
" send: async (payload) => fs.appendFileSync(process.env.CCR_FAKE_BOT_REPLIES, JSON.stringify(payload) + '\\n'),",
" ackEvent: async () => ({}),",
" close: async () => ({})",
" };",
"}",
""
].join("\n"));
let stderr = "";
const child = spawn(process.execPath, [runtimeFile, "codex-bot-worker", "--workspace-name", "Codex Test"], {
env: {
...process.env,
CODEX_HOME: codexHome,
CCR_CODEX_BOT_WORKER: "1",
CCR_CODEX_PROFILE: "claude-code-router",
CCR_REAL_CODEX_CLI_PATH: fakeCodex,
CCR_BOT_GATEWAY_CWD: dir,
CCR_BOT_GATEWAY_ENABLED: "true",
CCR_BOT_GATEWAY_PLATFORM: "slack",
CCR_BOT_GATEWAY_INTEGRATION_ID: "bot-test",
CCR_BOT_GATEWAY_TENANT_ID: "ccr",
CCR_BOT_GATEWAY_ACK_EVENTS: "true",
CCR_BOT_GATEWAY_POLL_INTERVAL_MS: "50",
CCR_BOT_GATEWAY_REQUEST_TIMEOUT_MS: "2000",
CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS: "2000",
CCR_BOT_GATEWAY_SDK_MODULE: fakeSdk,
CCR_BOT_GATEWAY_STATE_DIR: stateDir,
CCR_FAKE_CODEX_CALLS: callsFile,
CCR_FAKE_BOT_REPLIES: repliesFile
},
stdio: ["ignore", "ignore", "pipe"]
});
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
try {
const replies = await waitForJsonLines(repliesFile, 7, 7000, () => stderr);
const calls = await waitForJsonLines(callsFile, 2, 2000, () => stderr);
const replyTexts = replies.map((reply) => reply.intent.text);
assert.ok(replyTexts.some((text) => text.startsWith("Codex projects:")));
assert.ok(replyTexts.some((text) => text.startsWith("Selected project")));
assert.ok(replyTexts.some((text) => text.startsWith("Codex sessions in") && text.includes("Existing Codex session")));
assert.ok(replyTexts.some((text) => text.startsWith("Selected session ses_exis")));
const agentReplies = replyTexts.filter((text) => text.startsWith("reply:"));
assert.equal(agentReplies.length, 2);
assert.ok(agentReplies[0].endsWith("first"));
assert.ok(agentReplies[1].endsWith("second"));
for (const call of calls) {
assert.equal(call.argv[0], "exec");
assert.ok(call.argv.includes("resume"));
assert.ok(call.argv.includes('sandbox_mode="read-only"'));
assert.equal(realpathSync(call.cwd), realpathSync(dir));
}
const store = JSON.parse(readFileSync(path.join(stateDir, "codex-bot-sessions.json"), "utf8"));
assert.equal(Object.values(store.conversations)[0].sessionId, "ses_codex");
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
await waitForChildExit(child, 3000);
}
});
test("Codex App bot worker passes image attachments to exec and avoids duplicate stream replies", { skip: process.platform === "win32" }, async () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-codex-bot-image-"));
const runtimeFile = writeRuntimeScript(dir);
const fakeCodex = path.join(dir, "fake-codex");
const fakeSdk = path.join(dir, "fake-bot-gateway-sdk.mjs");
const callsFile = path.join(dir, "codex-calls.jsonl");
const repliesFile = path.join(dir, "bot-replies.jsonl");
const stateDir = path.join(dir, "bot-state");
const codexHome = path.join(dir, "codex-home");
mkdirSync(stateDir, { recursive: true });
mkdirSync(codexHome, { recursive: true });
writeFileSync(fakeCodex, [
"#!/usr/bin/env node",
"const fs = require('node:fs');",
"const argv = process.argv.slice(2);",
"fs.appendFileSync(process.env.CCR_FAKE_CODEX_CALLS, JSON.stringify({ argv, cwd: process.cwd() }) + '\\n');",
"process.stdout.write(JSON.stringify({ type: 'thread.started', thread_id: 'ses_image' }) + '\\n');",
"process.stdout.write(JSON.stringify({ type: 'item.completed', item: { id: 'answer-image', type: 'agent_message', text: 'reply:describe image' } }) + '\\n');",
""
].join("\n"));
chmodSync(fakeCodex, 0o700);
writeFileSync(fakeSdk, [
"import fs from 'node:fs';",
"let delivered = false;",
"globalThis.fetch = async () => new Response(Buffer.from([1, 2, 3]), { status: 200, headers: { 'content-length': '3' } });",
"export function createBotGatewayClient() {",
" return {",
" health: async () => ({}),",
" events: async () => {",
" if (delivered) return { events: [] };",
" delivered = true;",
" return { events: [{ id: 'event-image', event: {",
" id: 'event-image', tenantId: 'ccr', integrationId: 'bot-test', platform: 'slack', actor: { isBot: false },",
" conversation: { id: 'conversation-1', type: 'dm' },",
" message: { id: 'message-image', text: 'describe image', attachments: [{ id: 'att-1', type: 'image', url: 'https://attachments.local/screenshot.png', name: 'screenshot.png', mimeType: 'image/png', sizeBytes: 3 }] }",
" } }] };",
" },",
" send: async (payload) => fs.appendFileSync(process.env.CCR_FAKE_BOT_REPLIES, JSON.stringify(payload) + '\\n'),",
" ackEvent: async () => ({}),",
" close: async () => ({})",
" };",
"}",
""
].join("\n"));
let stderr = "";
const child = spawn(process.execPath, [runtimeFile, "codex-bot-worker", "--workspace-name", "Codex Test"], {
env: {
...process.env,
CODEX_HOME: codexHome,
CCR_CODEX_BOT_WORKER: "1",
CCR_CODEX_PROFILE: "claude-code-router",
CCR_REAL_CODEX_CLI_PATH: fakeCodex,
CCR_BOT_GATEWAY_CWD: dir,
CCR_BOT_GATEWAY_ENABLED: "true",
CCR_BOT_GATEWAY_MEDIA_ENABLED: "true",
CCR_BOT_GATEWAY_PLATFORM: "slack",
CCR_BOT_GATEWAY_INTEGRATION_ID: "bot-test",
CCR_BOT_GATEWAY_TENANT_ID: "ccr",
CCR_BOT_GATEWAY_ACK_EVENTS: "true",
CCR_BOT_GATEWAY_POLL_INTERVAL_MS: "50",
CCR_BOT_GATEWAY_REQUEST_TIMEOUT_MS: "2000",
CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS: "2000",
CCR_BOT_GATEWAY_STREAM_REPLIES: "true",
CCR_BOT_GATEWAY_SDK_MODULE: fakeSdk,
CCR_BOT_GATEWAY_STATE_DIR: stateDir,
CCR_FAKE_CODEX_CALLS: callsFile,
CCR_FAKE_BOT_REPLIES: repliesFile
},
stdio: ["ignore", "ignore", "pipe"]
});
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
try {
const replies = await waitForJsonLines(repliesFile, 2, 7000, () => stderr);
const calls = await waitForJsonLines(callsFile, 1, 2000, () => stderr);
const imageFlagIndex = calls[0].argv.indexOf("--image");
assert.notEqual(imageFlagIndex, -1);
assert.match(calls[0].argv[imageFlagIndex + 1], /screenshot\.png$/);
assert.equal(replies.every((reply) => reply.intent.type === "stream_text"), true);
assert.equal(replies.some((reply) => reply.intent.type === "text" && reply.intent.text === "reply:describe image"), false);
assert.equal(replies.filter((reply) => reply.intent.final === true).length, 1);
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
await waitForChildExit(child, 3000);
}
});
test("Claude App bot worker keeps project and session selection as separate levels", { skip: process.platform === "win32" }, async () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "ccr-runtime-claude-bot-projects-"));
const runtimeFile = writeRuntimeScript(dir);
const fakeSdk = path.join(dir, "fake-bot-gateway-sdk.mjs");
const repliesFile = path.join(dir, "bot-replies.jsonl");
const stateDir = path.join(dir, "bot-state");
const userDataDir = path.join(dir, "claude-user-data");
const sessionsDir = path.join(userDataDir, "local-agent-mode-sessions", "account", "organization");
const projectA = path.join(dir, "project-a");
const projectB = path.join(dir, "project-b");
mkdirSync(stateDir, { recursive: true });
mkdirSync(sessionsDir, { recursive: true });
mkdirSync(projectA, { recursive: true });
mkdirSync(projectB, { recursive: true });
const writeSession = (id, title, cwd, lastActivityAt) => {
writeFileSync(path.join(sessionsDir, `${id}.json`), JSON.stringify({
sessionId: id,
cliSessionId: `cli-${id}`,
cwd,
userSelectedFolders: [cwd],
title,
lastActivityAt,
isArchived: false
}));
};
writeSession("local_a", "Session A", projectA, 300);
writeSession("local_b", "Session B", projectB, 200);
writeFileSync(fakeSdk, [
"import fs from 'node:fs';",
"let delivered = false;",
"export function createBotGatewayClient() {",
" return {",
" health: async () => ({}),",
" events: async () => {",
" if (delivered) return { events: [] };",
" delivered = true;",
" const event = (id, text) => ({",
" id,",
" event: {",
" id, tenantId: 'ccr', integrationId: 'bot-test', platform: 'slack',",
" actor: { isBot: false },",
" conversation: { id: 'conversation-1', type: 'dm' },",
" message: { id: 'message-' + id, text }",
" }",
" });",
" return { events: [",
" event('project-list', '/project list'),",
" event('project-use', '/project use 2'),",
" event('session-list', '/session list'),",
" event('session-use', '/session use 1'),",
" event('session-current', '/session current'),",
" event('session-reset', '/session reset'),",
" event('session-current-reset', '/session current'),",
" event('old-task', '/task')",
" ] };",
" },",
" send: async (payload) => fs.appendFileSync(process.env.CCR_FAKE_BOT_REPLIES, JSON.stringify(payload) + '\\n'),",
" ackEvent: async () => ({}),",
" close: async () => ({})",
" };",
"}",
""
].join("\n"));
let stderr = "";
const child = spawn(process.execPath, [runtimeFile, "claude-bot-worker", "--workspace-name", "Claude Test"], {
env: {
...process.env,
CCR_CLAUDE_CODE_BOT_WORKER: "1",
CCR_CLAUDE_APP_USER_DATA_PATH: userDataDir,
CLAUDE_USER_DATA_DIR: userDataDir,
CCR_BOT_GATEWAY_ENABLED: "true",
CCR_BOT_GATEWAY_PLATFORM: "slack",
CCR_BOT_GATEWAY_INTEGRATION_ID: "bot-test",
CCR_BOT_GATEWAY_TENANT_ID: "ccr",
CCR_BOT_GATEWAY_ACK_EVENTS: "true",
CCR_BOT_GATEWAY_POLL_INTERVAL_MS: "50",
CCR_BOT_GATEWAY_REQUEST_TIMEOUT_MS: "2000",
CCR_BOT_GATEWAY_STARTUP_TIMEOUT_MS: "2000",
CCR_BOT_GATEWAY_SDK_MODULE: fakeSdk,
CCR_BOT_GATEWAY_STATE_DIR: stateDir,
CCR_FAKE_BOT_REPLIES: repliesFile
},
stdio: ["ignore", "ignore", "pipe"]
});
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
try {
const replies = await waitForJsonLines(repliesFile, 8, 7000, () => stderr);
const replyTexts = replies.map((reply) => reply.intent.text);
assert.ok(replyTexts.some((text) => text.startsWith("Claude App projects:") && text.includes(projectA) && text.includes(projectB)));
assert.ok(replyTexts.some((text) => text.startsWith("Selected project project-b")));
assert.ok(replyTexts.some((text) => text.startsWith("Claude App sessions in project-b:") && text.includes("Session B")));
assert.ok(!replyTexts.some((text) => text.startsWith("Claude App sessions in project-b:") && text.includes("Session A")));
assert.ok(replyTexts.some((text) => text.startsWith("Selected session local_b: Session B")));
assert.ok(replyTexts.some((text) => text.startsWith("Current Claude App session:") && text.includes("Session B")));
assert.ok(replyTexts.some((text) => text.startsWith("No selected Claude App session in project project-b.")));
assert.ok(replyTexts.includes("Unknown Bot command. Send /project or /session to see available commands."));
const store = JSON.parse(readFileSync(path.join(stateDir, "claude-bot-sessions.json"), "utf8"));
const entry = Object.values(store.conversations)[0];
assert.equal(store.version, 3);
assert.equal(realpathSync(entry.projectDirectory), realpathSync(projectB));
assert.equal(entry.sessionId, undefined);
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
await waitForChildExit(child, 3000);