mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-29 03:12:10 +08:00
feat: enhance app bot relay
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 启动对应的伴生 worker;App 退出时 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 请求接入 IM;App 退出时 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
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user