diff --git a/.gitignore b/.gitignore index d7dcad328..f03bc66b5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ src/utils/vendor/ # AI tool runtime directories .agents/ +.claude/ .codex/ .omx/ diff --git a/DEV-LOG.md b/DEV-LOG.md index 33f328793..03d7571a5 100644 --- a/DEV-LOG.md +++ b/DEV-LOG.md @@ -10,6 +10,185 @@ --- +## Pipe IPC + LAN Pipes + Monitor Tool + 工具恢复 (2026-04-08 ~ 2026-04-11) + +**分支**: `feat/pr-package-adapt` + +### 背景 + +从 decompiled 代码恢复大量 stub 为完整实现,同时新增 LAN 跨机器通讯能力。本次 PR 覆盖:Pipe IPC 系统、LAN Pipes、Monitor Tool、20+ 工具/组件���复、REPL hook 架构重构。 + +### 实现 + +#### 1. PipeServer TCP 双模式(`src/utils/pipeTransport.ts`) + +从原始的纯 UDS 服务器扩展为 UDS + TCP 双模式: + +- 提取 `setupSocket()` 共享方法,UDS 和 TCP 的 socket 处理逻辑完全一致 +- `start(options?: PipeServerOptions)` 新增可选参数 `{ enableTcp, tcpPort }` +- 内部维护两个 `net.Server`(UDS + TCP),共享同一组 `clients: Set` 和 `handlers` +- TCP server 绑定 `0.0.0.0` + 动态端口(port=0 由 OS 分配) +- `tcpAddress` getter 暴露 TCP 端口信息 +- `close()` 同时关闭两个 server +- 新增类型:`PipeTransportMode`、`TcpEndpoint`、`PipeServerOptions` + +PipeClient 对应扩展: +- 构造函数新增可选 `TcpEndpoint` 参数 +- `connect()` 根据是否有 TCP endpoint 分派到 `connectTcp()` 或 `connectUds()` +- TCP 连接不需要文件存在轮询,直接建立连接 + +#### 2. LAN Beacon — UDP Multicast 发现(`src/utils/lanBeacon.ts`,新文件) + +零配置局域网 peer 发现: + +- **协议**:UDP multicast 组 `224.0.71.67`("CC" ASCII),端口 `7101`,TTL=1 +- **Announce 包**:JSON `{ proto, pipeName, machineId, hostname, ip, tcpPort, role, ts }` +- **广播间隔**:3 秒,首次在 socket bind 完成后立即发送 +- **Peer 超时**:15 秒无 announce 视为 lost +- **事件**:`peer-discovered`、`peer-lost` +- **存储**:module-level singleton `getLanBeacon()`/`setLanBeacon()`,不挂在 Zustand state 上 + +关键修复: +- `addMembership(group, localIp)` + `setMulticastInterface(localIp)` 指定 LAN 网卡,解决 Windows 上 WSL/Docker 虚拟网卡劫持 multicast 的问题 +- announce/cleanup 定时器移入 `bind()` 回调内,修复 socket 未就绪时发送的竞态 + +#### 3. Registry 扩展(`src/utils/pipeRegistry.ts`) + +- `PipeRegistryEntry` 新增 `tcpPort?` 和 `lanVisible?` 字段 +- `mergeWithLanPeers(registry, lanPeers)` 合并本地 registry 和 LAN beacon peers,本地优先 + +#### 4. Peer Address 扩展(`src/utils/peerAddress.ts`) + +- `parseAddress()` 新增 `tcp` scheme:`tcp:192.168.1.20:7100` +- 新增 `parseTcpTarget()` 解析 `host:port` 字符串 + +#### 5. REPL 集成(`src/screens/REPL.tsx`) + +三个阶段的改动: + +**Bootstrap**:`createPipeServer()` 时根据 `feature('LAN_PIPES')` 传入 TCP 选项 → 启动 `LanBeacon` → 注册 entry 携带 tcpPort + +**Heartbeat**(每 5 秒): +- `refreshDiscoveredPipes()` 同时包含本地 subs 和 LAN beacon peers,防止 LAN peer 状态被覆盖 +- auto-attach 循环统一遍历本地 subs + LAN peers,LAN peers 通过 TCP endpoint 连接 +- cleanup 检查 LAN beacon peers 列表,避免误删存活的 LAN 连接 +- attach 请求携带 `machineId`,接收方区分 LAN peer(不要求 sub 角色) + +**Cleanup**:通过 `getLanBeacon()` 获取并 `stop()`,`setLanBeacon(null)` 清除 + +#### 6. 命令更新 + +- `/pipes`(`src/commands/pipes/pipes.ts`):显示 `[LAN]` 标记的远端实例 +- `/attach`(`src/commands/attach/attach.ts`):自动查找 LAN beacon 获取 TCP endpoint +- `SendMessageTool`(`src/tools/SendMessageTool/SendMessageTool.ts`):支持 `tcp:` scheme,权限检查要求用户确认 + +#### 7. Feature Flag + +`LAN_PIPES` — 在 `scripts/dev.ts` 和 `build.ts` 的默认 features 列表中启用。所有 LAN 代码路径均通过 `feature('LAN_PIPES')` 门控。 + +#### 8. Pipe IPC 基础系统(`UDS_INBOX` feature) + +- `PipeServer`/`PipeClient`:UDS 传输,NDJSON 协议(共享 `ndjsonFramer.ts`) +- `PipeRegistry`:machineId 绑定的角色分配(main/sub),文件锁,并行探测 +- Master/slave attach 流程、prompt 转发、permission 转发 +- Heartbeat 生命周期(5s 间隔,stale entry 清理,busy flag 防重叠) +- 命令:`/pipes`、`/attach`、`/detach`、`/send`、`/claim-main`、`/pipe-status` + +#### 9. Monitor Tool(`MONITOR_TOOL` feature) + +- `MonitorTool`:AI 可调用的后台 shell 监控工具 +- `/monitor` 命令:用户快捷入口,Windows 兼容(watch → PowerShell 循环) +- `MonitorMcpTask`:从 stub 恢复完整生命周期(register/complete/fail/kill) +- `MonitorPermissionRequest`:React 权限确认 UI +- `MonitorMcpDetailDialog`:Shift+Down 详情面板 + +#### 10. 工具恢复(stub → 实现) + +- SnipTool、SleepTool、ListPeersTool、SendUserFileTool +- WebBrowserTool、SubscribePRTool、PushNotificationTool +- CtxInspectTool、TerminalCaptureTool、WorkflowTool +- REPLTool (.js → .ts)、VerifyPlanExecutionTool (.js → .ts)、SuggestBackgroundPRTool (.js → .ts) +- 组件 .ts → .tsx 重写:MonitorPermissionRequest、ReviewArtifactPermissionRequest、MonitorMcpDetailDialog、WorkflowDetailDialog、WorkflowPermissionRequest + +#### 11. REPL Hook 架构重构 + +从 REPL.tsx 提取 ~830 行 Pipe IPC 内联代码为 4 个独立 hook: + +| Hook | 行数 | 职责 | +|------|------|------| +| `usePipeIpc` | 623 | 生命周期:bootstrap、handlers、heartbeat、cleanup | +| `usePipeRelay` | 38 | slave→master 消息回传(通过 `setPipeRelay` singleton) | +| `usePipePermissionForward` | 159 | 权限请求转发 + 流式通知显示 | +| `usePipeRouter` | 130 | selected pipe 输入路由 + role/IP 标签显示 | + +共享工具:`ndjsonFramer.ts` 替换 3 份重复的 NDJSON 解析。 + +#### 12. Feature Flags 新增启用 + +UDS_INBOX、LAN_PIPES、MONITOR_TOOL、FORK_SUBAGENT、KAIROS、COORDINATOR_MODE、WORKFLOW_SCRIPTS、HISTORY_SNIP、CONTEXT_COLLAPSE + +### 踩坑记录 + +1. **Multicast 绑错网卡**:Windows 上 `addMembership(group)` 不指定本地接口时,默认绑到 WSL/Docker 虚拟网卡(`172.19.112.1`),LAN 上的真实机器收不到。必须 `addMembership(group, localIp)` + `setMulticastInterface(localIp)`。 + +2. **Beacon ref 丢失**:最初用 `(store.getState() as any)._lanBeacon` 挂载 beacon 引用,但 Zustand `setState` 展开 `prev` 时不包含 `_lanBeacon` 属性,下次读取就是 `undefined`。改为 module-level singleton 解决。 + +3. **Heartbeat 清洗 LAN 连接**:`refreshDiscoveredPipes()` 每 5 秒用仅含本地 registry subs 的列表完全覆盖 `discoveredPipes` + `selectedPipes`,LAN peer 的发现和选择状态被持续清空。必须在 refresh 中同时包含 beacon peers。 + +4. **Heartbeat cleanup 误删**:`!aliveSubNames.has(slaveName)` 导致 LAN peer(不在本地 registry)被判定为死连接每 5 秒清除一次。需要同时检查 beacon peers 列表。 + +5. **跨机器 attach 被拒**:两台机器各自为 `main`,attach handler 硬编码 `role !== 'sub'` 拒绝。通过 attach_request 携带 `machineId`,接收方对不同 machineId 的请求放行。 + +6. **`feature()` 使用约束**:Bun 的 `feature()` 是编译时常量,只能在 `if` 语句或三元条件中直接使用,不能赋值给变量(如 `const x = feature('...')`),否则构建报错。 + +### 已知限制 + +- TCP 无认证:同 LAN 内任何设备知道端口号即可连接 +- JSON.parse 无 schema 验证:code review 建议增加 Zod 校验 +- Beacon 明文广播 IP/hostname/machineId:建议后续 hash 处理 +- `getLocalIp()` 可能返回 VPN 地址:多网卡环境需更精确的接口选择 + +### 测试 + +- `src/utils/__tests__/lanBeacon.test.ts`:7 个测试(mock dgram) +- `src/utils/__tests__/peerAddress.test.ts`:8 个测试(纯函数) +- 全量:2190 pass / 0 fail + +### 防火墙配置 + +**Windows**(管理员 PowerShell): +```powershell +New-NetFirewallRule -DisplayName "Claude Code LAN Beacon (UDP)" -Direction Inbound -Protocol UDP -LocalPort 7101 -Action Allow -Profile Private +New-NetFirewallRule -DisplayName "Claude Code LAN Pipes (TCP)" -Direction Inbound -Protocol TCP -LocalPort 1024-65535 -Program (Get-Command bun).Source -Action Allow -Profile Private +New-NetFirewallRule -DisplayName "Claude Code LAN Beacon Out (UDP)" -Direction Outbound -Protocol UDP -RemotePort 7101 -Action Allow -Profile Private +``` + +**macOS**(首次运行时系统会弹出"允许接受传入连接"对话框,点击允许即可。手动放行): +```bash +# 如果使用 pf ���火墙,添加规则: +echo "pass in proto udp from any to any port 7101" | sudo pfctl -ef - +# 或��接在 System Settings → Network → Firewall 中允许 bun 进程 +``` + +**Linux**(firewalld): +```bash +sudo firewall-cmd --zone=trusted --add-port=7101/udp --permanent +sudo firewall-cmd --zone=trusted --add-port=1024-65535/tcp --permanent +sudo firewall-cmd --reload +``` + +**Linux**(iptables): +```bash +sudo iptables -A INPUT -p udp --dport 7101 -j ACCEPT +sudo iptables -A INPUT -p tcp --dport 1024:65535 -m owner --uid-owner $(id -u) -j ACCEPT +sudo iptables-save | sudo tee /etc/iptables/rules.v4 +``` + +**通用验证**:确认网络为局域网(非公共 WiFi),路���器未开启 AP 隔离。 + +--- + + ## Daemon + Remote Control Server 还原 (2026-04-07) **分支**: `feat/daemon-remote-control-server` diff --git a/README.md b/README.md index 81aa988d2..5ec20b770 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ [文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/qZU6zS7Q) - ✅ [x] V4 — 测试补全、[Buddy](https://ccb.agent-aura.top/docs/features/buddy)、[Auto Mode](https://ccb.agent-aura.top/docs/safety/auto-mode)、环境变量 Feature 开关 -- ✅ [x] V5 — [Sentry](https://ccb.agent-aura.top/docs/internals/sentry-setup) / [GrowthBook](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) 企业监控、[自定义 Login](https://ccb.agent-aura.top/docs/features/custom-platform-login)、[OpenAI 兼容](https://ccb.agent-aura.top/docs/plans/openai-compatibility)、[Web Search](https://ccb.agent-aura.top/docs/features/web-browser-tool)、[Computer Use](https://ccb.agent-aura.top/docs/features/computer-use) / [Chrome Use](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp)、[Voice Mode](https://ccb.agent-aura.top/docs/features/voice-mode)、[Bridge Mode](https://ccb.agent-aura.top/docs/features/bridge-mode)、[Remote Control 私有部署](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting)、[/dream 记忆整理](https://ccb.agent-aura.top/docs/features/auto-dream) +- ✅ [x] V5 — [Sentry](https://ccb.agent-aura.top/docs/internals/sentry-setup) / [GrowthBook](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) 企业监控、[自定义 Login](https://ccb.agent-aura.top/docs/features/custom-platform-login)、[OpenAI 兼容](https://ccb.agent-aura.top/docs/plans/openai-compatibility)、[Web Search](https://ccb.agent-aura.top/docs/features/web-browser-tool)、[Computer Use](https://ccb.agent-aura.top/docs/features/computer-use) / [Chrome Use](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp)、[Voice Mode](https://ccb.agent-aura.top/docs/features/voice-mode)、[Bridge Mode](https://ccb.agent-aura.top/docs/features/bridge-mode)、[Remote Control 私有部署](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting)、[/dream 记忆整理](https://ccb.agent-aura.top/docs/features/auto-dream)、**全网独家支持 Claude 群控技术** — [Pipe IPC 多实例协作](https://ccb.agent-aura.top/docs/features/pipes-and-lan)(同机 main/sub 自动编排 + [LAN 跨机器零配置发现与通讯](https://ccb.agent-aura.top/docs/features/lan-pipes),`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由) - 🔮 [ ] V6 — 大规模重构石山代码,全面模块分包(全新分支,main 封存为历史版本) - 🚀 [想要启动项目](#快速开始源码版) diff --git a/build.ts b/build.ts index 6ea97a428..b5ad80dbf 100644 --- a/build.ts +++ b/build.ts @@ -11,9 +11,6 @@ rmSync(outdir, { recursive: true, force: true }) // Default features that match the official CLI build. // Additional features can be enabled via FEATURE_=1 env vars. const DEFAULT_BUILD_FEATURES = [ - 'BUDDY', - 'TRANSCRIPT_CLASSIFIER', - 'BRIDGE_MODE', 'AGENT_TRIGGERS_REMOTE', 'CHICAGO_MCP', 'VOICE_MODE', @@ -33,6 +30,28 @@ const DEFAULT_BUILD_FEATURES = [ 'ULTRAPLAN', // P2: daemon + remote control server 'DAEMON', + // PR-package restored features + 'WORKFLOW_SCRIPTS', + 'HISTORY_SNIP', + 'CONTEXT_COLLAPSE', + 'MONITOR_TOOL', + 'FORK_SUBAGENT', + 'UDS_INBOX', + 'KAIROS', + 'COORDINATOR_MODE', + 'LAN_PIPES', + // 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性 + // PR-package restored features + 'WORKFLOW_SCRIPTS', + 'HISTORY_SNIP', + 'CONTEXT_COLLAPSE', + 'MONITOR_TOOL', + 'FORK_SUBAGENT', + 'UDS_INBOX', + 'KAIROS', + 'COORDINATOR_MODE', + 'LAN_PIPES', + // 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性 // P3: poor mode (disable extract_memories + prompt_suggestion) 'POOR', ] diff --git a/docs/feature-exploration-plan.md b/docs/feature-exploration-plan.md index a1c8cf41d..62e46f03b 100644 --- a/docs/feature-exploration-plan.md +++ b/docs/feature-exploration-plan.md @@ -250,7 +250,7 @@ FEATURE_KAIROS=1 FEATURE_PROACTIVE=1 FEATURE_FORK_SUBAGENT=1 bun run dev | Feature | 引用 | 状态 | 说明 | |---------|------|------|------| | CHICAGO_MCP | 16 | N/A | Anthropic 内部 MCP 基础设施 | -| UDS_INBOX | 17 | Stub | Unix 域套接字对等消息 | +| UDS_INBOX | 17 | Experimental | 本机 UDS 消息层 + 本机 named-pipe 协调层 | | MONITOR_TOOL | 13 | Stub | 文件/进程监控工具 | | BG_SESSIONS | 11 | Stub | 后台会话管理 | | SHOT_STATS | 10 | 无实现 | 逐 prompt 统计 | diff --git a/docs/feature-flags-audit-complete.md b/docs/feature-flags-audit-complete.md index 5d5ac83c5..898fa3689 100644 --- a/docs/feature-flags-audit-complete.md +++ b/docs/feature-flags-audit-complete.md @@ -1005,38 +1005,32 @@ src/utils/swarm/ 目录(22 个文件): ## 28. UDS_INBOX -**编译时引用次数**: 18(单引号 17 + 双引号 1) -**功能描述**: UDS(Unix Domain Socket)收件箱。允许 Claude Code 实例之间通过 Unix 套接字发送消息。 -**分类**: PARTIAL -**缺失原因**: `src/utils/udsMessaging.ts` 仅 1 行,`src/utils/udsClient.ts` 仅 3 行(空壳),命令入口缺失 +**编译时引用次数**: 18(历史快照) +**功能描述**: 本机进程间通信能力。当前由两层组成: +1. `udsMessaging` / `udsClient`:通用 UDS 消息层,供 `SendMessageTool` 与 `/peers` 使用。 +2. `pipeTransport` / `pipeRegistry`:会话级 named-pipe 协调层,供 `/pipes`、`/attach`、`/detach`、`/send`、`/pipe-status`、`/history`、`/claim-main` 使用。 -**核心实现文件**: +**当前分类**: IMPLEMENTED / EXPERIMENTAL -| 文件路径 | 行数 | 功能说明 | -|----------|------|----------| -| src/tools/SendMessageTool/SendMessageTool.ts | 917 行 | 发送消息工具(完整实现) | -| src/tools/SendMessageTool/prompt.ts | 49 行 | 消息工具提示词 | -| src/utils/udsClient.ts | 3 行 | UDS 客户端(桩) | -| src/utils/udsMessaging.ts | 1 行 | UDS 消息(桩) | +**当前事实**: +- `src/utils/udsMessaging.ts` 与 `src/utils/udsClient.ts` 已实现,不再是空壳。 +- `src/utils/pipeTransport.ts` 使用本机 named pipe / Unix socket;`localIp` / `hostname` / `machineId` 仅用于注册表展示与身份判定,不是已上线的局域网传输层。 +- `src/screens/REPL.tsx` 内联承载当前有效的 pipe 控制平面;早期 hook 试验路径已清理。 -**引用该标志的文件(10 个)**: -1. src/cli/print.ts — CLI 输出 -2. src/commands.ts — 命令注册(引用 `commands/peers/index.js`) -3. src/components/messages/UserTextMessage.tsx — 用户消息 -4. src/main.tsx — 主入口 -5. src/setup.ts — 初始化 -6. src/tools.ts — 工具注册 -7. src/tools/SendMessageTool/SendMessageTool.ts — 发送消息工具 -8. src/tools/SendMessageTool/prompt.ts — 提示词 -9. src/utils/concurrentSessions.ts — 并发会话 -10. src/utils/messages/systemInit.ts — 系统初始化消息 - -**缺失文件**: -- src/commands/peers/index.ts — 命令入口缺失 -- src/utils/udsMessaging.ts — 仅 1 行空壳 -- src/utils/udsClient.ts — 仅 3 行空壳 +**核心实现文件**: -**启用所需修复**: 需要实现 UDS 客户端和消息模块,并创建命令入口。 +| 文件路径 | 功能说明 | +|----------|----------| +| src/utils/udsMessaging.ts | 通用 UDS server / inbox | +| src/utils/udsClient.ts | 通用 peer 发现、探活、发送 | +| src/utils/pipeTransport.ts | named-pipe server/client、探活、AppState 扩展 | +| src/utils/pipeRegistry.ts | main/sub 注册表、machineId、claim-main | +| src/commands/peers/peers.ts | UDS peer 可达性检查 | +| src/commands/pipes/pipes.ts | pipe 注册表检查与选择器入口 | +| src/commands/attach/attach.ts | master -> slave attach | +| src/screens/REPL.tsx | 当前生效的 REPL pipe bootstrap 与心跳 | + +**备注**: 如需真实局域网通信,需要单独引入 TCP/WebSocket 传输、认证与发现机制;现有代码尚未实现该层。详见 `docs/features/uds-inbox.md`。 --- diff --git a/docs/features/feature-flags-audit-complete.md b/docs/features/feature-flags-audit-complete.md index bfedff447..cf357ade0 100644 --- a/docs/features/feature-flags-audit-complete.md +++ b/docs/features/feature-flags-audit-complete.md @@ -1011,38 +1011,32 @@ src/utils/swarm/ 目录(22 个文件): ## 28. UDS_INBOX -**编译时引用次数**: 18(单引号 17 + 双引号 1) -**功能描述**: UDS(Unix Domain Socket)收件箱。允许 Claude Code 实例之间通过 Unix 套接字发送消息。 -**分类**: PARTIAL -**缺失原因**: `src/utils/udsMessaging.ts` 仅 1 行,`src/utils/udsClient.ts` 仅 3 行(空壳),命令入口缺失 +**编译时引用次数**: 18(历史快照) +**功能描述**: 本机进程间通信能力。当前由两层组成: +1. `udsMessaging` / `udsClient`:通用 UDS 消息层,供 `SendMessageTool` 与 `/peers` 使用。 +2. `pipeTransport` / `pipeRegistry`:会话级 named-pipe 协调层,供 `/pipes`、`/attach`、`/detach`、`/send`、`/pipe-status`、`/history`、`/claim-main` 使用。 -**核心实现文件**: +**当前分类**: IMPLEMENTED / EXPERIMENTAL -| 文件路径 | 行数 | 功能说明 | -|----------|------|----------| -| src/tools/SendMessageTool/SendMessageTool.ts | 917 行 | 发送消息工具(完整实现) | -| src/tools/SendMessageTool/prompt.ts | 49 行 | 消息工具提示词 | -| src/utils/udsClient.ts | 3 行 | UDS 客户端(桩) | -| src/utils/udsMessaging.ts | 1 行 | UDS 消息(桩) | +**当前事实**: +- `src/utils/udsMessaging.ts` 与 `src/utils/udsClient.ts` 已实现,不再是空壳。 +- `src/utils/pipeTransport.ts` 使用本机 named pipe / Unix socket;`localIp` / `hostname` / `machineId` 仅用于注册表展示与身份判定,不是已上线的局域网传输层。 +- `src/screens/REPL.tsx` 内联承载当前有效的 pipe 控制平面;早期 hook 试验路径已清理。 -**引用该标志的文件(10 个)**: -1. src/cli/print.ts — CLI 输出 -2. src/commands.ts — 命令注册(引用 `commands/peers/index.js`) -3. src/components/messages/UserTextMessage.tsx — 用户消息 -4. src/main.tsx — 主入口 -5. src/setup.ts — 初始化 -6. src/tools.ts — 工具注册 -7. src/tools/SendMessageTool/SendMessageTool.ts — 发送消息工具 -8. src/tools/SendMessageTool/prompt.ts — 提示词 -9. src/utils/concurrentSessions.ts — 并发会话 -10. src/utils/messages/systemInit.ts — 系统初始化消息 - -**缺失文件**: -- src/commands/peers/index.ts — 命令入口缺失 -- src/utils/udsMessaging.ts — 仅 1 行空壳 -- src/utils/udsClient.ts — 仅 3 行空壳 +**核心实现文件**: -**启用所需修复**: 需要实现 UDS 客户端和消息模块,并创建命令入口。 +| 文件路径 | 功能说明 | +|----------|----------| +| src/utils/udsMessaging.ts | 通用 UDS server / inbox | +| src/utils/udsClient.ts | 通用 peer 发现、探活、发送 | +| src/utils/pipeTransport.ts | named-pipe server/client、探活、AppState 扩展 | +| src/utils/pipeRegistry.ts | main/sub 注册表、machineId、claim-main | +| src/commands/peers/peers.ts | UDS peer 可达性检查 | +| src/commands/pipes/pipes.ts | pipe 注册表检查与选择器入口 | +| src/commands/attach/attach.ts | master -> slave attach | +| src/screens/REPL.tsx | 当前生效的 REPL pipe bootstrap 与心跳 | + +**备注**: 如需真实局域网通信,需要单独引入 TCP/WebSocket 传输、认证与发现机制;现有代码尚未实现该层。详见 `docs/features/uds-inbox.md`。 --- diff --git a/docs/features/lan-pipes-implementation.md b/docs/features/lan-pipes-implementation.md new file mode 100644 index 000000000..c25b3391a --- /dev/null +++ b/docs/features/lan-pipes-implementation.md @@ -0,0 +1,545 @@ +# LAN Pipes 实现文档 + +## 1. 概述 + +### 1.1 目标 + +在现有 UDS (Unix Domain Socket) 本地 Pipe 通讯系统基础上,增加 **TCP 传输层** 和 **UDP Multicast 发现机制**,使同一局域网内不同机器上的 Claude Code CLI 实例可以: + +1. **自动发现** — 通过 UDP multicast 零配置发现 LAN 内的其他实例 +2. **TCP 连接** — 通过 TCP 建立跨机器的双向 NDJSON 管道 +3. **复用现有协议** — attach/detach/prompt/stream 等消息类型无需修改 + +### 1.2 设计原则 + +- **向后兼容**:所有 LAN 功能通过 `feature('LAN_PIPES')` 门控,不影响现有 UDS 功能 +- **双模式共存**:PipeServer 同时监听 UDS 和 TCP,PipeClient 根据参数自动选择连接模式 +- **本地优先**:本地 registry 条目优先于 LAN beacon 发现的条目 +- **安全保守**:TCP 连接需用户显式同意,multicast TTL=1 不跨路由器 + +### 1.3 架构总览 + +``` +Machine A (192.168.1.10) Machine B (192.168.1.20) +┌───────────────────────────┐ ┌───────────────────────────┐ +│ PipeServer │ │ PipeServer │ +│ UDS: cli-abc.sock │ │ UDS: cli-def.sock │ +│ TCP: 0.0.0.0: │◄──TCP───►│ TCP: 0.0.0.0: │ +├───────────────────────────┤ ├───────────────────────────┤ +│ LanBeacon │ │ LanBeacon │ +│ UDP multicast │◄──UDP───►│ UDP multicast │ +│ 224.0.71.67:7101 │ mcast │ 224.0.71.67:7101 │ +├───────────────────────────┤ ├───────────────────────────┤ +│ PipeRegistry │ │ PipeRegistry │ +│ registry.json (local) │ │ registry.json (local) │ +│ + mergeWithLanPeers() │ │ + mergeWithLanPeers() │ +└───────────────────────────┘ └───────────────────────────┘ +``` + +--- + +## 2. Feature Flag + +### 2.1 注册 + +**文件**: `scripts/dev.ts` (L49), `build.ts` (L43) + +`LAN_PIPES` 添加到 `DEFAULT_FEATURES` / `DEFAULT_BUILD_FEATURES` 数组中,dev 和 build 默认启用。 + +也可通过环境变量 `FEATURE_LAN_PIPES=1` 单独启用。 + +### 2.2 使用约束 + +Bun 的 `feature()` 只能在 `if` 语句或三元条件中直接使用(编译时常量),不能赋值给变量。所有使用点均遵循此约束。 + +--- + +## 3. 核心变更详情 + +### 3.1 PipeServer TCP 扩展 + +**文件**: `src/utils/pipeTransport.ts` + +#### 新增类型 + +```typescript +export type PipeTransportMode = 'uds' | 'tcp' +export type TcpEndpoint = { host: string; port: number } +export type PipeServerOptions = { + enableTcp?: boolean + tcpPort?: number // 0 = 随机端口 +} +``` + +#### PipeServer 类变更 + +| 成员 | 变更类型 | 说明 | +|------|----------|------| +| `tcpServer: Server \| null` | 新增字段 | TCP net.Server 实例 | +| `_tcpAddress: TcpEndpoint \| null` | 新增字段 | TCP 监听地址 | +| `tcpAddress` getter | 新增 | 公开 TCP 端口信息 | +| `setupSocket(socket)` | 重构提取 | 从 `start()` 中提取,UDS 和 TCP 共用 | +| `start(options?)` | 修改签名 | 新增可选 `PipeServerOptions` 参数 | +| `startTcpServer(port)` | 新增私有方法 | 启动 TCP 监听 | +| `close()` | 修改 | 增加 TCP server 关闭逻辑 | + +**关键设计决策**:`setupSocket()` 方法被提取为共享逻辑,使 UDS 和 TCP 的 socket 处理完全一致。两种传输模式共享同一组 `clients: Set` 和 `handlers`,对上层代码完全透明。 + +#### 代码路径 + +``` +start(options?) + ├── ensurePipesDir() + ├── 清理 stale socket (Unix) + ├── createServer() → UDS 监听 (现有逻辑) + │ └── setupSocket() ← 提取的共享逻辑 + └── if options.enableTcp + └── startTcpServer(port) + ├── createServer() → TCP 监听 0.0.0.0 + │ └── setupSocket() ← 同一个方法 + └── 记录 _tcpAddress +``` + +### 3.2 PipeClient TCP 扩展 + +**文件**: `src/utils/pipeTransport.ts` + +#### PipeClient 类变更 + +| 成员 | 变更类型 | 说明 | +|------|----------|------| +| `tcpEndpoint: TcpEndpoint \| null` | 新增字段 | TCP 连接目标 | +| `constructor(target, sender?, tcpEndpoint?)` | 修改签名 | 新增可选 TCP endpoint | +| `connect(timeout)` | 修改 | 根据 tcpEndpoint 分派 | +| `connectTcp(timeout)` | 新增私有方法 | TCP 连接实现 | +| `connectUds(timeout)` | 重构提取 | 原 `connect()` 的 UDS 逻辑 | + +**关键设计决策**:TCP 连接不需要等待文件存在(UDS 的 `access()` 轮询),直接建立 TCP 连接。超时机制相同。 + +### 3.3 工厂函数更新 + +```typescript +// 新签名 +export async function createPipeServer( + name: string, + options?: PipeServerOptions, // 新增 +): Promise + +export async function connectToPipe( + targetName: string, + senderName?: string, + timeoutMs?: number, + tcpEndpoint?: TcpEndpoint, // 新增 +): Promise +``` + +--- + +### 3.4 LAN Beacon — UDP Multicast 发现 + +**文件**: `src/utils/lanBeacon.ts` (新文件,~170 行) + +#### 协议参数 + +| 参数 | 值 | 说明 | +|------|-----|------| +| Multicast 组 | `224.0.71.67` | "CC" = Claude Code 的 ASCII 对应 | +| 端口 | `7101` | 固定 UDP 端口 | +| 广播间隔 | `3000ms` | 3 秒一次 announce | +| Peer 超时 | `15000ms` | 15 秒无 announce 视为 lost | +| TTL | `1` | 仅链路本地,不跨路由器 | + +#### Announce 包格式 + +```typescript +type LanAnnounce = { + proto: 'claude-pipe-v1' // 协议标识符(用于过滤非本协议 UDP 包) + pipeName: string // e.g. "cli-abc12345" + machineId: string // OS-level 稳定指纹 + hostname: string // 主机名 + ip: string // 发送端本地 IPv4 + tcpPort: number // TCP PipeServer 端口 + role: 'main' | 'sub' // 当前角色 + ts: number // unix ms 时间戳 +} +``` + +#### LanBeacon 类 API + +```typescript +class LanBeacon extends EventEmitter { + constructor(announce: Omit) + start(): void // 开始广播 + 监听 + stop(): void // 停止并释放资源 + getPeers(): Map // 当前已知 peers + updateAnnounce(partial): void // 更新自身 announce 数据 + + // Events + on('peer-discovered', (peer: LanAnnounce) => void) + on('peer-lost', (pipeName: string) => void) +} +``` + +#### 内部行为 + +1. **启动**:`createSocket({ type: 'udp4', reuseAddr: true })` → `bind(7101)` → `addMembership('224.0.71.67')` → `setMulticastTTL(1)` +2. **广播**:`setInterval(sendAnnounce, 3000)` + 启动时立即发一次 +3. **接收**:`socket.on('message')` → JSON.parse → 过滤 `proto !== 'claude-pipe-v1'` 和自身 → 更新 peers Map → 触发 `peer-discovered` 事件 +4. **清理**:`setInterval(cleanupStalePeers, 7500)` — 超过 15 秒未收到 announce 的 peer 从 Map 移除,触发 `peer-lost` 事件 +5. **停止**:清除所有 timer → `dropMembership` → `socket.close()` → 清空 peers + +#### 错误处理 + +所有 socket/网络错误均为 **non-fatal**(logError 但不 throw)。multicast 在某些网络环境可能不支持,这不应阻止 CLI 正常运行。 + +--- + +### 3.5 Registry 扩展 + +**文件**: `src/utils/pipeRegistry.ts` + +#### 类型变更 + +```typescript +export interface PipeRegistryEntry { + // ... 现有字段 ... + tcpPort?: number // 新增:TCP 监听端口 + lanVisible?: boolean // 新增:是否参与 LAN 广播 +} +``` + +#### 新增函数 + +```typescript +export type MergedPipeEntry = { + id: string + pipeName: string + role: string + machineId: string + ip: string + hostname: string + alive: boolean + source: 'local' | 'lan' // 来源标识 + tcpEndpoint?: TcpEndpoint // LAN peer 的 TCP 端点 +} + +export function mergeWithLanPeers( + registry: PipeRegistry, + lanPeers: Map, +): MergedPipeEntry[] +``` + +**合并逻辑**: +1. 先添加本地 registry 的 main 和所有 subs(`source: 'local'`) +2. 遍历 LAN peers,跳过已在本地 registry 中存在的 pipeName +3. 剩余的 LAN peers 作为 `source: 'lan'` 条目添加 + +--- + +### 3.6 Peer Address 扩展 + +**文件**: `src/utils/peerAddress.ts` + +#### parseAddress 变更 + +```typescript +// 之前 +export function parseAddress(to: string): { + scheme: 'uds' | 'bridge' | 'other' + target: string +} + +// 之后 +export function parseAddress(to: string): { + scheme: 'uds' | 'bridge' | 'tcp' | 'other' // 新增 'tcp' + target: string +} +``` + +新增 `tcp:` 前缀解析:`tcp:192.168.1.20:7100` → `{ scheme: 'tcp', target: '192.168.1.20:7100' }` + +#### 新增 parseTcpTarget + +```typescript +export function parseTcpTarget( + target: string, +): { host: string; port: number } | null +``` + +解析 `host:port` 字符串,正则 `^([^:]+):(\d+)$`。 + +--- + +### 3.7 REPL Bootstrap 集成 + +**文件**: `src/screens/REPL.tsx` + +#### 启动阶段 (L5165-5200) + +在现有 `createPipeServer(pipeName)` 调用处: + +```typescript +// 根据 LAN_PIPES flag 决定是否启用 TCP +const server = await createPipeServer( + pipeName, + feature('LAN_PIPES') ? { enableTcp: true, tcpPort: 0 } : undefined +); + +// 启动 LAN beacon +if (feature('LAN_PIPES') && server.tcpAddress) { + const { LanBeacon } = require('../utils/lanBeacon.js'); + lanBeaconInstance = new LanBeacon({ + pipeName, machineId, hostname, ip, tcpPort: server.tcpAddress.port, role + }); + lanBeaconInstance.start(); + + // Store beacon in module-level singleton (not on Zustand state) + const { setLanBeacon } = require('../utils/lanBeacon.js'); + setLanBeacon(lanBeaconInstance); + + // 注册 entry 时附带 tcpPort + await registerAsMain({ ...entry, tcpPort: server.tcpAddress.port, lanVisible: true }); +} +``` + +#### Heartbeat ��段 + +在 main heartbeat 循环中: + +1. `refreshDiscoveredPipes(aliveSubs)` 同时包含本地 subs 和 LAN beacon peers +2. auto-attach 循环同时遍历本地 subs 和 LAN peers(LAN peers 通过 TCP endpoint 连接) +3. cleanup 时检查 LAN beacon peers 列表,避免误删 LAN 连接 + +```typescript +// auto-attach 统一目标列表:本地 subs + LAN peers +const attachTargets = [...aliveSubs.map(s => ({ pipeName: s.pipeName }))]; +if (feature('LAN_PIPES')) { + const beacon = getLanBeacon(); + for (const [name, peer] of beacon.getPeers()) { + attachTargets.push({ pipeName: name, tcpEndpoint: { host: peer.ip, port: peer.tcpPort } }); + } +} +``` + +#### Cleanup 阶段 + +```typescript +// 停止 LAN beacon +const { getLanBeacon, setLanBeacon } = require('../utils/lanBeacon.js'); +const beacon = getLanBeacon(); +if (beacon) { + try { beacon.stop(); } catch {} + setLanBeacon(null); +} +``` + +**Beacon 存储方案**:使用 `lanBeacon.ts` 中的 module-level singleton(`getLanBeacon()`/`setLanBeacon()`),不挂在 Zustand store state 上,避免 `setState` 展开时丢失引用。 + +--- + +### 3.8 /pipes 命令 LAN 显示 + +**文件**: `src/commands/pipes/pipes.ts` + +在现有 registry 显示之后,如果 `feature('LAN_PIPES')` 启用: + +1. 通过 `getLanBeacon()` 获取 LAN peers +2. 调用 `mergeWithLanPeers()` 合并 +3. 过滤 `source === 'lan'` 的条目 +4. 显示格式:`☐ [role] pipeName hostname/ip tcp:host:port [LAN]` + +--- + +### 3.9 /attach 命令 TCP 支持 + +**文件**: `src/commands/attach/attach.ts` + +在连接之前,如果 `feature('LAN_PIPES')` 启用: + +1. 在 `discoveredPipes` 中查找目标 pipe +2. 通过 `_lanBeacon.getPeers()` 检查是否为 LAN peer +3. 如果是,构造 `TcpEndpoint` 传给 `connectToPipe()` +4. 错误消息中包含 TCP 端点信息便于诊断 + +--- + +### 3.10 SendMessageTool TCP 支持 + +**文件**: `src/tools/SendMessageTool/SendMessageTool.ts` + +#### inputSchema 描述更新 + +当 `LAN_PIPES` 启用时,`to` 字段描述追加 `, or "tcp::" for a LAN peer`。 + +#### checkPermissions + +```typescript +if (feature('LAN_PIPES') && parseAddress(input.to).scheme === 'tcp') { + return { + behavior: 'ask', + message: `Send a message to LAN peer ${input.to}?...`, + decisionReason: { + type: 'safetyCheck', + reason: 'Cross-machine LAN message requires explicit user consent', + classifierApprovable: false, + }, + } +} +``` + +**安全设计**:`classifierApprovable: false` 确保自动模式不会跳过用户确认。 + +#### validateInput + +新增 `tcp:` scheme 验证分支(与 `uds:` 类似,仅允许 plain text 消息)。 + +#### call() + +```typescript +if (addr.scheme === 'tcp' && feature('LAN_PIPES')) { + const ep = parseTcpTarget(addr.target); + const client = new PipeClient(input.to, `send-${process.pid}`, ep); + await client.connect(5000); + client.send({ type: 'chat', data: input.message }); + client.disconnect(); + return { data: { success: true, message: `... → TCP ${ep.host}:${ep.port}` } }; +} +``` + +--- + +## 4. 数据流 + +### 4.1 LAN 发现流程 + +``` +CLI-A 启动 + → PipeServer.start({ enableTcp: true, tcpPort: 0 }) + → TCP server 监听 0.0.0.0:随机端口 + → LanBeacon.start() + → 每 3s 广播 UDP announce (pipeName, ip, tcpPort, role, machineId) + +CLI-B 启动 (另一台机器) + → 同上 + → LanBeacon 收到 CLI-A 的 announce + → peer-discovered 事件 + → Heartbeat 循环合并 LAN peers 到 discoveredPipes + +用户在 CLI-B 执行 /pipes + → 显示 CLI-A 条目,标记 [LAN] +``` + +### 4.2 跨机器 Attach 流程 + +``` +CLI-B 执行 /attach cli-abc12345 + → feature('LAN_PIPES') → 查找 discoveredPipes → 找到 LAN peer + → _lanBeacon.getPeers() → 获取 { ip: '192.168.1.10', tcpPort: 7100 } + → connectToPipe(name, myName, undefined, { host: '192.168.1.10', port: 7100 }) + → PipeClient.connectTcp() → net.createConnection({ host, port }) + → client.send({ type: 'attach_request' }) + → 等待 attach_accept / attach_reject + → 成功:注册 slave client,切换 master 角色 +``` + +### 4.3 跨机器消息发送 + +``` +用户或 AI 使用 SendMessageTool + → to: "tcp:192.168.1.20:7102" + → checkPermissions → behavior: 'ask' → 用户确认 + → parseTcpTarget('192.168.1.20:7102') → { host, port } + → new PipeClient(to, sender, { host, port }) + → client.connect(5000) + → client.send({ type: 'chat', data: message }) + → client.disconnect() +``` + +--- + +## 5. 测试 + +### 5.1 新增测试文件 + +| 文件 | 测试数 | 覆盖内容 | +|------|--------|----------| +| `src/utils/__tests__/lanBeacon.test.ts` | 7 | socket 初始化、announce 发送、peer 发现、自身过滤、协议过滤、role 更新 | +| `src/utils/__tests__/peerAddress.test.ts` | 8 | uds/bridge/tcp/other scheme 解析、parseTcpTarget 正确/异常 | + +### 5.2 测试策略 + +- **lanBeacon.test.ts**:mock dgram 模块,验证 beacon 的发送/接收/清理逻辑 +- **peerAddress.test.ts**:纯函数测试,无外部依赖 +- **现有 pipeTransport.test.ts**:2 个现有测试继续通过(TCP 扩展不改变 UDS 行为) + +### 5.3 测试结果 + +``` +全量测试:2190 pass / 0 fail / 130 files / 4.27s +``` + +--- + +## 6. 变更文件清单 + +| 文件 | 操作 | 变更行数(约) | +|------|------|-------------| +| `scripts/dev.ts` | 修改 | +1 (feature flag) | +| `build.ts` | 修改 | +1 (feature flag) | +| `src/utils/pipeTransport.ts` | 修改 | +120 (TCP 扩展) | +| `src/utils/lanBeacon.ts` | **新增** | ~170 (UDP beacon) | +| `src/utils/pipeRegistry.ts` | 修改 | +80 (类型 + merge 函数) | +| `src/utils/peerAddress.ts` | 修改 | +12 (tcp scheme + parseTcpTarget) | +| `src/screens/REPL.tsx` | 修改 | +45 (bootstrap + heartbeat + cleanup) | +| `src/commands/pipes/pipes.ts` | 修改 | +25 (LAN peers 显示) | +| `src/commands/attach/attach.ts` | 修改 | +25 (TCP endpoint 解析) | +| `src/tools/SendMessageTool/SendMessageTool.ts` | 修改 | +45 (tcp scheme 全链路) | +| `src/utils/__tests__/lanBeacon.test.ts` | **新增** | ~140 (7 tests) | +| `src/utils/__tests__/peerAddress.test.ts` | **新增** | ~60 (8 tests) | +| `docs/features/lan-pipes.md` | **新增** | ~90 (用户文档) | + +--- + +## 7. 已知限制和后续改进 + +### 7.1 当前限制 + +1. **无 TCP 认证**:TCP 连接无握手认证,同一局域网内任何知道端口号的进程都能连接 +2. **beacon ref 通过 `(state as any)._lanBeacon` 传递**:这是一个 pragmatic hack,因为 AppState 类型由 decompiled 代码定义,修改类型的成本过高 +3. **multicast 依赖网络环境**:部分企业网络、AP 隔离的 WiFi 可能不支持 multicast +4. **TCP 端口随机**:每次启动分配不同端口,需依赖 beacon 发现 + +### 7.2 后续改进方向 + +1. **HMAC-SHA256 认证**:首次 TCP 握手交换 machineId + challenge token +2. **heartbeat 中 TCP auto-attach LAN peers**:目前 heartbeat 只 auto-attach 本地 registry 的 subs,LAN peers 需手动 /attach +3. **固定端口范围配置**:允许用户配置 TCP 端口范围,便于防火墙规则 +4. **mDNS/DNS-SD 作为 beacon 替代**:在 multicast 受限的环境提供更可靠的发现 +5. **加密传输**:TLS over TCP,确保消息不被中间人窃听 + +--- + +## 8. 防火墙要求 + +| 协议 | 端口 | 方向 | 用途 | +|------|------|------|------| +| UDP | 7101 | IN + OUT | Multicast beacon 发现 | +| TCP | 动态 (0) | IN | PipeServer TCP 监听 | + +### Windows + +```powershell +netsh advfirewall firewall add rule name="Claude LAN Beacon" dir=in action=allow protocol=UDP localport=7101 +netsh advfirewall firewall add rule name="Claude LAN Pipes" dir=in action=allow program="" enable=yes +``` + +### macOS + +首次运行时系统弹窗允许即可。 + +### Linux + +```bash +sudo firewall-cmd --add-port=7101/udp +# TCP 端口随机,建议放行 bun 进程 +``` diff --git a/docs/features/lan-pipes.md b/docs/features/lan-pipes.md new file mode 100644 index 000000000..62cac518b --- /dev/null +++ b/docs/features/lan-pipes.md @@ -0,0 +1,86 @@ +# LAN Pipes — 局域网跨机器通讯 + +## 概述 + +在现有 UDS (Unix Domain Socket) 本地 Pipe 通讯基础上,增加 TCP 传输层和 UDP Multicast 发现机制,使同一局域网内不同机器上的 Claude Code 实例可以互相发现、连接和双向通讯。 + +## Feature Flag + +`LAN_PIPES` — dev/build 默认启用。也可通过 `FEATURE_LAN_PIPES=1` 环境变量启用。 + +## 架构 + +``` +Machine A (192.168.1.10) Machine B (192.168.1.20) +┌─────────────────────────┐ ┌─────────────────────────┐ +│ PipeServer │ │ PipeServer │ +│ UDS: cli-abc.sock │ │ UDS: cli-def.sock │ +│ TCP: 0.0.0.0:7100 │◄─TCP────►│ TCP: 0.0.0.0:7102 │ +├─────────────────────────┤ ├─────────────────────────┤ +│ LanBeacon │◄─UDP─────│ LanBeacon │ +│ multicast 224.0.71.67 │ mcast ►│ multicast 224.0.71.67 │ +└─────────────────────────┘ └─────────────────────────┘ +``` + +## 组件 + +### 1. PipeServer TCP 扩展 (`pipeTransport.ts`) + +- `PipeServer.start()` 接受 `PipeServerOptions`,可选启用 TCP 监听 +- 内部维护两个 `net.Server` — UDS + TCP,共享同一组 clients 和 handlers +- `PipeServer.tcpAddress` getter 返回 TCP 端口信息 + +### 2. PipeClient TCP 扩展 (`pipeTransport.ts`) + +- 构造函数新增可选 `TcpEndpoint` 参数 +- `connect()` 根据是否有 TCP endpoint 选择连接模式 +- 对下游调用者完全透明 + +### 3. LAN Beacon (`lanBeacon.ts`) + +- UDP multicast 组: `224.0.71.67:7101` +- 每 3 秒广播 announce 包,包含 pipeName、machineId、hostname、ip、tcpPort、role +- 15 秒无 announce 视为 peer lost +- TTL=1,仅 link-local,不跨路由器 + +### 4. Registry 扩展 (`pipeRegistry.ts`) + +- `PipeRegistryEntry` 新增 `tcpPort?` 和 `lanVisible?` 字段 +- `mergeWithLanPeers()` 合并本地 registry 和 LAN beacon 发现的远端 peers + +### 5. Peer Address (`peerAddress.ts`) + +- `parseAddress()` 新增 `tcp` scheme: `tcp:192.168.1.20:7100` +- `parseTcpTarget()` 解析 `host:port` 字符串 + +## 使用方式 + +### 查看 LAN Peers + +``` +/pipes +``` + +输出中会显示 `[LAN]` 标记的远端实例。 + +### 连接远端实例 + +``` +/attach +``` + +自动检测 LAN peer 并通过 TCP 连接。 + +### 发送消息到 LAN Peer + +``` +/send tcp:192.168.1.20:7100 +``` + +或通过 SendMessage tool 使用 `tcp:` scheme。 + +## 安全 + +- TCP 连接需用户显式同意(checkPermissions 返回 `ask`) +- Multicast TTL=1,仅限链路本地 +- 后续可增加 HMAC-SHA256 challenge 认证 diff --git a/docs/features/pipes-and-lan.md b/docs/features/pipes-and-lan.md new file mode 100644 index 000000000..8559d4d52 --- /dev/null +++ b/docs/features/pipes-and-lan.md @@ -0,0 +1,342 @@ +# Pipes + LAN Pipes 完整功能指南 + +## 概述 + +Pipes 系统提供 Claude Code CLI 实例之间的通讯能力,分两层: + +1. **Pipes(本机)**:同一台机器上的多个 CLI 实例通过 UDS(Unix Domain Socket / Windows Named Pipe)协作 +2. **LAN Pipes(局域网)**:不同机器上的 CLI 实例通过 TCP + UDP Multicast 协作 + +两层使用同一套协议(NDJSON)和同一套命令(`/pipes`、`/attach`、`/send` 等),对用户透明。 + +## Feature Flags + +| Flag | 控制范围 | 默认 | +|------|----------|------| +| `UDS_INBOX` | 本机 Pipe IPC 全部功能 | dev/build 启用 | +| `LAN_PIPES` | 局域网 TCP + beacon 扩展 | dev/build 启用 | + +手动启用:`FEATURE_UDS_INBOX=1 FEATURE_LAN_PIPES=1 bun run dev` + +## 快速上手 + +### 本机多实例 + +```bash +# 终端 1 +bun run dev +# 启动后自动注册为 main + +# 终端 2 +bun run dev +# 自动注册为 sub-1,被 main 自动 attach +``` + +在终端 1 中输入 `/pipes`,可以看到两个实例。选中 sub-1 后,输入的消息会自动转发到 sub-1 执行。 + +### 局域网多机器 + +```bash +# 机器 A (192.168.50.22) +bun run dev + +# 机器 B (192.168.50.27) +bun run dev +``` + +两边启动后等 3-5 秒(beacon 广播间隔),LAN peers 会自动发现并 attach。输入 `/pipes` 可看到标记 `[LAN]` 的远端实例。 + +### 防火墙配置(两台机器都需要) + +**Windows**(管理员 PowerShell): +```powershell +New-NetFirewallRule -DisplayName "Claude Code LAN Beacon (UDP)" -Direction Inbound -Protocol UDP -LocalPort 7101 -Action Allow -Profile Private +New-NetFirewallRule -DisplayName "Claude Code LAN Pipes (TCP)" -Direction Inbound -Protocol TCP -LocalPort 1024-65535 -Program (Get-Command bun).Source -Action Allow -Profile Private +New-NetFirewallRule -DisplayName "Claude Code LAN Beacon Out (UDP)" -Direction Outbound -Protocol UDP -RemotePort 7101 -Action Allow -Profile Private +# 确认网络为"专用":Get-NetConnectionProfile +``` + +**macOS**(首次运行时系统弹出对话框,点击"允许"即可): +```bash +# 如果需要手动放行 pf 防火墙: +echo "pass in proto udp from any to any port 7101" | sudo pfctl -ef - +``` + +**Linux**(firewalld / iptables): +```bash +# firewalld +sudo firewall-cmd --zone=trusted --add-port=7101/udp --permanent +sudo firewall-cmd --zone=trusted --add-port=1024-65535/tcp --permanent +sudo firewall-cmd --reload + +# 或 iptables +sudo iptables -A INPUT -p udp --dport 7101 -j ACCEPT +sudo iptables -A INPUT -p tcp --dport 1024:65535 -m owner --uid-owner $(id -u) -j ACCEPT +``` + +确认:网络为局域网(非公共 WiFi),路由器未开启 AP 隔离。 + +## 交互面板与快捷键 + +### 状态栏 + +执行 `/pipes` 后,输入框底部出现 pipe 状态栏(单行): + +``` +pipe: cli-a91bad56 (main) 192.168.50.22 2/3 selected selected pipes only · ←/→ or m switch · Shift+↓ edit +``` + +状态栏始终可见(直到会话结束),显示:当前 pipe 名、角色、IP、已选数/总数、路由模式。 + +### 展开选择面板 + +按 **Shift+↓**(Shift + 下箭头)展开选择面板: + +``` +pipe: cli-a91bad56 (main) 192.168.50.22 ↑↓ move Space select ←/→ or m route Enter/Esc close Shift+↓ toggle + 当前普通 prompt 走 已选 sub;切换不会清空选择 + ☑ cli-da029538 (sub-1 XC/192.168.50.22) + ☐ cli-04d67950 (main vmwin11/192.168.50.27) + ☑ cli-893747d3 [offline] (sub-2 vmwin11/192.168.50.27) +``` + +### 面板内快捷键 + +| 快捷键 | 场景 | 作用 | +|--------|------|------| +| **Shift+↓** | 状态栏可见时 | 展开/收起选择面板 | +| **↑ / ↓** | 面板展开时 | 上下移动光标 | +| **Space** | 面板展开时 | 切换当前光标所在 pipe 的选中状态(☑ ↔ ☐) | +| **Enter** | 面板展开时 | 确认并关闭面板 | +| **Esc** | 面板展开时 | 取消并关闭面板 | +| **← / → 或 M** | 状态栏可见且有选中 pipe 时 | 切换路由模式(`selected pipes only` ↔ `local main`) | + +### M 键 — 路由模式切换 + +M 键(或 ← / →)用于在两种路由模式之间切换,**无需展开面板**: + +| 模式 | 状态栏显示 | 行为 | +|------|-----------|------| +| `selected pipes only` | 绿色高亮 | 输入的 prompt **仅**发送到选中的 pipe,本地不执行 | +| `local main` | 灰色 | 输入的 prompt 在**本地 main** 执行,不转发到任何 pipe | + +切换路由模式**不会清空选择**。你可以在 `local main` 模式下保持选择,随时按 M 切回 `selected pipes only` 继续向远端发送。 + +### 完整操作流程示例 + +``` +1. 输入 /pipes → 状态栏出现,显示发现的实例 +2. 按 Shift+↓ → 展开选择面板 +3. 按 ↓ 移动到目标 pipe → 光标移到 cli-04d67950 +4. 按 Space → 选中 ☑ cli-04d67950 +5. 按 Enter → 确认,面板收起 +6. 输入 "帮我检查 git status" → prompt 自动发送到 cli-04d67950 执行 +7. 按 M → 切换到 local main 模式 +8. 输入 "本地做点什么" → 仅在本地执行 +9. 按 M → 切回 selected pipes only +10. 输入 "继续远端任务" → 又发送到 cli-04d67950 +``` + +## 命令参考 + +### /pipes + +显示所有发现的实例,管理选择状态。再次执行 `/pipes` 切换面板展开/收起。 + +``` +/pipes — 显示所有实例 + 切换选择面板 +/pipes select — 选中某实例(消息会广播到它) +/pipes deselect — 取消选中 +/pipes all — 全选 +/pipes none — 全部取消 +``` + +输出示例: +``` +Your pipe: cli-a91bad56 +Role: main +Machine ID: 205d6c3a... +IP: 192.168.50.22 +Host: XC + +Main machine: 205d6c3a... (this machine) + [main] cli-a91bad56 XC/192.168.50.22 [alive] (you) + ☑ [sub-1] cli-da029538 XC/192.168.50.22 [alive] [connected] + +LAN Peers: + ☐ [main] cli-04d67950 vmwin11/192.168.50.27 tcp:192.168.50.27:58853 [LAN] + +Selected: cli-da029538 +``` + +### /attach + +手动 attach 到一个实例,使其成为你的 slave。 + +``` +/attach cli-04d67950 — 连接到指定 pipe(自动解析 LAN TCP 端点) +``` + +attach 后,对方变为 slave,你变为 master。可以向它发送 prompt。通常不需要手动 attach——heartbeat 会自动发现并连接。 + +### /detach + +断开与某个 slave 的连接。 + +``` +/detach cli-04d67950 +``` + +### /send + +向指定 pipe 发送消息(不依赖选择状态,直接指定目标)。 + +``` +/send cli-04d67950 请帮我检查一下日志 +/send tcp:192.168.50.27:58853 hello — 直接通过 TCP 地址发送 +``` + +### /claim-main + +强制声明当前机器为 main(用于 main 意外退出后的恢复)。 + +## 消息路由 + +### 选中 pipe 后的自动路由 + +1. 通过 `/pipes select` 或 Shift+Down 面板选中一个或多个 pipe +2. 在输入框中正常输入消息 +3. 消息自动发送到所有选中的已连接 pipe +4. 每个 pipe 独立执行,结果流式回传到 main 的消息列表 + +### 路由模式 + +| 模式 | 行为 | +|------|------| +| `selected`(默认) | 消息发送到选中的 pipe | +| `local` | 消息仅在本地执行,不转发 | + +## 架构 + +### 通信协议 + +所有通讯使用 NDJSON(Newline-Delimited JSON),每行一个消息: + +```json +{"type":"ping","from":"cli-abc","ts":"2026-04-11T00:00:00.000Z"} +{"type":"prompt","data":"帮我查看 git status","from":"cli-abc","ts":"..."} +{"type":"stream","data":"正在执行...","from":"cli-def","ts":"..."} +{"type":"done","data":"","from":"cli-def","ts":"..."} +``` + +### 消息类型 + +| 类型 | 方向 | 说明 | +|------|------|------| +| `ping`/`pong` | 双向 | 健康检查 | +| `attach_request`/`accept`/`reject` | M→S/S→M | 连接控制 | +| `detach` | M→S | 断开连接 | +| `prompt` | M→S | 主向从发送 prompt | +| `prompt_ack` | S→M | 从确认接收 | +| `stream` | S→M | 从流式回传 AI 输出 | +| `tool_start`/`tool_result` | S→M | 工具执行通知 | +| `done` | S→M | 本轮完成 | +| `error` | 双向 | 错误通知 | +| `permission_request`/`response`/`cancel` | 双向 | 权限审批转发 | + +### 传输层 + +``` + 本机 LAN + ┌──────────────┐ ┌──────────────┐ + │ PipeServer │ │ PipeServer │ + │ UDS sock │ │ UDS sock │ + │ TCP :rand │◄───TCP───►│ TCP :rand │ + ├──────────────┤ ├──────────────┤ + │ LanBeacon │◄──UDP────►│ LanBeacon │ + │ 224.0.71.67 │ mcast │ 224.0.71.67 │ + └──────────────┘ └──────────────┘ +``` + +- **UDS**:本机实例间通讯,通过文件系统路径寻址(`~/.claude/pipes/cli-xxx.sock`) +- **TCP**:LAN 实例间通讯,动态端口,通过 beacon 发现 +- **UDP Multicast**:peer 发现,3 秒广播一次 announce 包 + +### 角色模型 + +| 角色 | 说明 | +|------|------| +| `main` | 首个启动的实例,管理 registry | +| `sub` | 后续启动的同机实例(或被 attach 的 LAN 实例) | +| `master` | attach 了至少一个 slave 的实例 | +| `slave` | 被 master attach 控制的实例 | + +角色转换: +- 首个启动 → `main` +- 同机后续启动 → `sub`(自动被 main attach → `slave`) +- LAN 发现 → 两边都是 `main`,heartbeat 自动互相 attach +- 被 attach → 变为 `slave`(可通过 `/detach` 恢复) + +### 发现机制 + +**本机**:通过 `~/.claude/pipes/registry.json` 文件(带文件锁),`machineId` 绑定主机身份。 + +**LAN**:通过 UDP multicast beacon: +1. 每 3 秒广播 `{ proto, pipeName, machineId, ip, tcpPort, role }` +2. 收到其他实例的 announce → 记入 peers Map +3. 15 秒未收到 → 标记 peer lost +4. Heartbeat 合并 local registry + beacon peers → 统一 attach 目标列表 + +### Heartbeat 循环(5 秒间隔) + +``` +main/master 角色: + 1. cleanupStaleEntries() — 清理 registry 中死掉的条目 + 2. getAliveSubs() — 获取存活的本地 subs + 3. refreshDiscoveredPipes() — 刷新 discoveredPipes(包含 LAN peers) + 4. 合并 LAN peers 到 state + 5. 构建统一 attach 目标列表 — 本地 subs + LAN peers + 6. 遍历未连接的目标 → 自动 attach + 7. 清理断开的 slave 连接 — 同时检查 local registry 和 beacon + +sub 角色: + 1. 检测 main 是否存活 + 2. main 死亡 → 同机则接管 main 角色,跨机则独立 +``` + +## 关键文件 + +| 文件 | 职责 | +|------|------| +| `src/utils/pipeTransport.ts` | PipeServer(双模 UDS+TCP)、PipeClient、类型定义 | +| `src/utils/lanBeacon.ts` | UDP multicast beacon、singleton 管理 | +| `src/utils/pipeRegistry.ts` | Registry CRUD、角色判定、machineId、LAN merge | +| `src/utils/peerAddress.ts` | 地址解析(uds:/bridge:/tcp: scheme) | +| `src/screens/REPL.tsx` | Bootstrap、heartbeat、cleanup、prompt 路由 | +| `src/hooks/useMasterMonitor.ts` | Slave client registry、消息订阅 | +| `src/hooks/useSlaveNotifications.ts` | Slave 端通知处理 | +| `src/commands/pipes/pipes.ts` | /pipes 命令 | +| `src/commands/attach/attach.ts` | /attach 命令 | +| `src/commands/send/send.ts` | /send 命令 | +| `src/tools/SendMessageTool/SendMessageTool.ts` | AI 发消息工具(含 tcp: 支持) | + +## 后续优化方向 + +### 安全(P0) + +1. **TCP 认证**:首次连接时交换 HMAC-SHA256 token(基于 machineId + session secret),防止未授权设备连接 +2. **JSON schema 验证**:在所有 `JSON.parse` 入口点增加 Zod 校验,防止 prototype pollution +3. **Beacon 信息脱敏**:hash machineId 后再广播,不暴露硬件序列号 + +### 可靠性(P1) + +4. **多网卡选择**:`getLocalIp()` 应优先选择 RFC 1918 地址,排除 VPN/Docker 接口 +5. **TCP target 验证**:`parseTcpTarget()` 应限制目标为已知 beacon peers 或 RFC 1918 范围 +6. **PipeServer close()**:改为 `Promise.allSettled` 并行关闭 UDS + TCP,加 `_closing` guard + +### 功能(P2) + +7. **mDNS/DNS-SD**:作为 multicast 受限环境下的 beacon 替代方案 +8. **固定端口配置**:允许用户指定 TCP 端口范围,便于防火墙精确配置 +9. **TLS 加密**:TCP 传输加密,防中间人窃听 +10. **双向 prompt**:当前只有 master → slave 方向,可考虑 slave 主动向 master 发送结果/请求 diff --git a/docs/features/tier3-stubs.md b/docs/features/tier3-stubs.md index aaa84decc..e15f0e303 100644 --- a/docs/features/tier3-stubs.md +++ b/docs/features/tier3-stubs.md @@ -8,7 +8,6 @@ | Feature | 引用 | 状态 | 类别 | 简要说明 | |---------|------|------|------|---------| | CHICAGO_MCP | 16 | N/A | 内部基础设施 | Anthropic 内部 MCP 基础设施,非外部可用 | -| UDS_INBOX | 17 | Stub | 消息通信 | Unix 域套接字对等消息,进程间消息传递 | | MONITOR_TOOL | 13 | Stub | 工具 | 文件/进程监控工具,检测变更并通知 | | BG_SESSIONS | 11 | Stub | 会话管理 | 后台会话管理,支持多会话并行 | | SHOT_STATS | 10 | 无实现 | 统计 | 逐 prompt 统计信息收集 | @@ -68,7 +67,7 @@ BUILDING_CLAUDE_APPS, ANTI_DISTILLATION_CC, AGENT_TRIGGERS, ABLATION_BASELINE 这些 feature 被列为 Tier 3 的原因: 1. **内部基础设施**(CHICAGO_MCP, LODESTONE):Anthropic 内部使用,外部无法运行 -2. **纯 Stub 且引用低**(UDS_INBOX, MONITOR_TOOL, BG_SESSIONS):需要大量工作才能实现 +2. **纯 Stub 且引用低**(MONITOR_TOOL, BG_SESSIONS):需要大量工作才能实现 3. **实验性功能**(SHOT_STATS, EXTRACT_MEMORIES):尚在概念阶段 4. **辅助功能**(STREAMLINED_OUTPUT, HOOK_PROMPTS):影响范围小 5. **CCR 系列**:依赖远程控制基础设施,需要 BRIDGE_MODE 先完善 diff --git a/docs/features/uds-inbox.md b/docs/features/uds-inbox.md new file mode 100644 index 000000000..947db7621 --- /dev/null +++ b/docs/features/uds-inbox.md @@ -0,0 +1,114 @@ +# UDS_INBOX / pipes + +## 概述 + +`UDS_INBOX` 现在不是一个“空壳 flag”,而是一套已经落地的本机 IPC 能力。但它同时承载了两层不同目标,必须拆开理解: + +1. **UDS peer messaging** + - 面向任意 Claude Code 进程。 + - 使用 `src/utils/udsMessaging.ts` 和 `src/utils/udsClient.ts`。 + - 对外入口是 `/peers` 和 `SendMessageTool` 的 `uds:` 地址。 +2. **pipes control plane** + - 面向交互式 REPL 会话之间的主从协作。 + - 使用 `src/utils/pipeTransport.ts`、`src/utils/pipeRegistry.ts` 和 `src/screens/REPL.tsx` 中的内联 bootstrap。 + - 对外入口是 `/pipes`、`/attach`、`/detach`、`/send`、`/pipe-status`、`/history`、`/claim-main`。 + +这两层都依赖本机 socket,但职责不同。`/peers` 解决“找到其他会话并发消息”,`/pipes` 解决“把一个 REPL 变成另一个 REPL 的受控 worker”。 + +## 为什么要有单独的 `pipes` + +单独的 `pipes` 层有三个实际理由: + +1. **命名与角色模型不同** + - UDS peer 层按 `messagingSocketPath` 寻址。 + - pipes 层按 `cli-xxxxxxxx` 会话名、`main/sub/master/slave` 角色和 `machineId` 注册表工作。 +2. **交互语义不同** + - peer 层是通用消息投递。 + - pipes 层需要 attach、detach、历史收集、选择性广播、状态栏和 REPL 快捷键。 +3. **UI 集成不同** + - peer 层主要服务工具调用。 + - pipes 层直接影响 REPL 提交路径和 PromptInput 页脚。 + +如果把两者硬合并,`SendMessageTool` 的通用寻址和 REPL 的主从控制会互相污染,命令语义也会变得混乱。 + +## 当前通信模型 + +### 1. UDS peer messaging + +- 服务端:`src/utils/udsMessaging.ts` +- 客户端:`src/utils/udsClient.ts` +- 发现方式:读取 `~/.claude/sessions/*.json` +- 地址方式:`uds:` +- 传输方式:**本机 Unix socket / Windows named pipe** + +这层是真正的“通用收件箱”。 + +### 2. pipes control plane + +- 服务端/客户端:`src/utils/pipeTransport.ts` +- 注册表:`src/utils/pipeRegistry.ts` +- 生效入口:`src/screens/REPL.tsx` +- 发现方式:扫描 `~/.claude/pipes/` + `registry.json` +- 会话名:`cli-${sessionId.slice(0, 8)}` +- 传输方式:**本机 Unix socket / Windows named pipe** + +这层是真正的“主从 REPL 协调平面”。 + +## 关于“局域网通信”的事实 + +当前实现**不是**真正的局域网传输。 + +代码里虽然保存了这些字段: + +- `localIp` +- `hostname` +- `machineId` +- `mac` + +但这些字段当前只用于: + +1. 注册表展示 +2. main/sub 身份判定 +3. `claim-main` 的机器级归属切换 +4. 状态输出与排障信息 + +它们**没有**被用于创建 TCP/WebSocket 连接。真正的传输仍然是 `getPipePath(name)` 返回的本机 socket 路径。 + +所以目前更准确的描述应该是: + +- `pipes` 支持 **本机多实例协作** +- `registry` 带有 **机器身份元数据** +- 但 **尚未实现跨机器局域网 transport** + +如果未来要做真局域网版本,至少还需要: + +1. TCP/WebSocket transport +2. 认证与会话授权 +3. 发现与地址交换 +4. 超时、重连和安全边界 + +## 当前 REPL 行为 + +当前线上行为由 `src/screens/REPL.tsx` 的内联实现负责: + +1. 启动时创建当前 REPL 的 pipe server +2. 通过 `pipeRegistry` 判定 `main` / `sub` +3. 处理 `attach_request` / `detach` / `prompt` +4. 主实例心跳探测并维护 `slaves` +5. `/pipes` 打开状态栏并维护选择器 +6. 提交普通消息时,仅向**已连接**的 selected pipes 广播 + +最近的收敛点: + +- 过去遗留了一套未接线的 hook 方案 +- 当前已明确以 `REPL.tsx` 内联 bootstrap 为唯一生效实现 +- 选中但未连接的 pipe 不再导致本地处理被错误跳过 + +## 文档与代码对齐约定 + +后续关于 `UDS_INBOX` / `pipes` 的说明应遵守以下表述: + +1. 默认称为“本机 IPC / 本机多实例协作” +2. 不把 `localIp` / `hostname` 元数据表述成已完成的 LAN transport +3. 明确区分 `/peers` 和 `/pipes` 的两层职责 +4. 以 `src/screens/REPL.tsx`、`src/utils/pipeTransport.ts`、`src/utils/pipeRegistry.ts` 为事实来源 diff --git a/packages/@ant/ink/src/core/events/event-handlers.ts b/packages/@ant/ink/src/core/events/event-handlers.ts index 7865f5b3c..bf2f00664 100644 --- a/packages/@ant/ink/src/core/events/event-handlers.ts +++ b/packages/@ant/ink/src/core/events/event-handlers.ts @@ -1,6 +1,7 @@ import type { ClickEvent } from './click-event.js' import type { FocusEvent } from './focus-event.js' import type { KeyboardEvent } from './keyboard-event.js' +import type { MouseActionEvent } from './mouse-action-event.js' import type { PasteEvent } from './paste-event.js' import type { ResizeEvent } from './resize-event.js' @@ -9,6 +10,7 @@ type FocusEventHandler = (event: FocusEvent) => void type PasteEventHandler = (event: PasteEvent) => void type ResizeEventHandler = (event: ResizeEvent) => void type ClickEventHandler = (event: ClickEvent) => void +type MouseActionEventHandler = (event: MouseActionEvent) => void type HoverEventHandler = () => void /** @@ -33,6 +35,9 @@ export type EventHandlerProps = { onResize?: ResizeEventHandler onClick?: ClickEventHandler + onMouseDown?: MouseActionEventHandler + onMouseUp?: MouseActionEventHandler + onMouseDrag?: MouseActionEventHandler onMouseEnter?: HoverEventHandler onMouseLeave?: HoverEventHandler } @@ -51,6 +56,9 @@ export const HANDLER_FOR_EVENT: Record< paste: { bubble: 'onPaste', capture: 'onPasteCapture' }, resize: { bubble: 'onResize' }, click: { bubble: 'onClick' }, + mousedown: { bubble: 'onMouseDown' }, + mouseup: { bubble: 'onMouseUp' }, + mousedrag: { bubble: 'onMouseDrag' }, } /** @@ -68,6 +76,9 @@ export const EVENT_HANDLER_PROPS = new Set([ 'onPasteCapture', 'onResize', 'onClick', + 'onMouseDown', + 'onMouseUp', + 'onMouseDrag', 'onMouseEnter', 'onMouseLeave', ]) diff --git a/packages/@ant/ink/src/core/events/mouse-action-event.ts b/packages/@ant/ink/src/core/events/mouse-action-event.ts new file mode 100644 index 000000000..b13d40dda --- /dev/null +++ b/packages/@ant/ink/src/core/events/mouse-action-event.ts @@ -0,0 +1,44 @@ +import { Event } from './event.js' +import type { EventTarget } from './terminal-event.js' + +/** + * Mouse action event (mousedown, mouseup, mousedrag). + * Bubbles from the deepest hit node up through parentNode. + */ +export class MouseActionEvent extends Event { + /** Action type */ + readonly type: 'mousedown' | 'mouseup' | 'mousedrag' + /** 0-indexed screen column */ + readonly col: number + /** 0-indexed screen row */ + readonly row: number + /** Mouse button number */ + readonly button: number + /** + * Column relative to the current handler's Box. + * Recomputed before each handler fires. + */ + localCol = 0 + /** Row relative to the current handler's Box. */ + localRow = 0 + + constructor( + type: 'mousedown' | 'mouseup' | 'mousedrag', + col: number, + row: number, + button: number, + ) { + super() + this.type = type + this.col = col + this.row = row + this.button = button + } + + /** Recompute local coords relative to the target Box. */ + prepareForTarget(target: EventTarget): void { + const dom = target as unknown as { yogaNode?: { getComputedLeft?(): number; getComputedTop?(): number } } + this.localCol = this.col - (dom.yogaNode?.getComputedLeft?.() ?? 0) + this.localRow = this.row - (dom.yogaNode?.getComputedTop?.() ?? 0) + } +} diff --git a/packages/@ant/ink/src/core/hit-test.ts b/packages/@ant/ink/src/core/hit-test.ts index 53ddb869e..888ab7347 100644 --- a/packages/@ant/ink/src/core/hit-test.ts +++ b/packages/@ant/ink/src/core/hit-test.ts @@ -1,6 +1,7 @@ import type { DOMElement } from './dom.js' import { ClickEvent } from './events/click-event.js' import type { EventHandlerProps } from './events/event-handlers.js' +import { MouseActionEvent } from './events/mouse-action-event.js' import { nodeCache } from './node-cache.js' /** @@ -128,3 +129,43 @@ export function dispatchHover( } } } + +export function dispatchMouseAction( + root: DOMElement, + col: number, + row: number, + button: number, + type: 'mousedown' | 'mouseup' | 'mousedrag', + targetOverride?: DOMElement, +): DOMElement | null { + let target: DOMElement | undefined = + targetOverride ?? hitTest(root, col, row) ?? undefined + if (!target) return null + + const propName = + type === 'mousedown' + ? 'onMouseDown' + : type === 'mouseup' + ? 'onMouseUp' + : 'onMouseDrag' + + const event = new MouseActionEvent(type, col, row, button) + let handledBy: DOMElement | null = null + + while (target) { + const handler = target._eventHandlers?.[propName] as + | ((event: MouseActionEvent) => void) + | undefined + if (handler) { + handledBy ??= target + event.prepareForTarget(target) + handler(event) + if (event.didStopImmediatePropagation()) { + return handledBy + } + } + target = target.parentNode as DOMElement | undefined + } + + return handledBy +} diff --git a/scripts/dev.ts b/scripts/dev.ts index 7ca9f5335..dbe149434 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -37,6 +37,28 @@ const DEFAULT_FEATURES = [ "KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN", // P2: daemon + remote control server "DAEMON", + // PR-package restored features + "WORKFLOW_SCRIPTS", + "HISTORY_SNIP", + "CONTEXT_COLLAPSE", + "MONITOR_TOOL", + "FORK_SUBAGENT", + "UDS_INBOX", + "KAIROS", + "COORDINATOR_MODE", + "LAN_PIPES", + // "REVIEW_ARTIFACT", // API 请求无响应,需进一步排查 schema 兼容性 + // PR-package restored features + "WORKFLOW_SCRIPTS", + "HISTORY_SNIP", + "CONTEXT_COLLAPSE", + "MONITOR_TOOL", + "FORK_SUBAGENT", + "UDS_INBOX", + "KAIROS", + "COORDINATOR_MODE", + "LAN_PIPES", + // "REVIEW_ARTIFACT", // API 请求无响应,需进一步排查 schema 兼容性 // P3: poor mode (disable extract_memories + prompt_suggestion) "POOR", ]; diff --git a/src/Tool.ts b/src/Tool.ts index b14a1d594..335d9bb7c 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -2,6 +2,7 @@ import type { ToolResultBlockParam, ToolUseBlockParam, } from '@anthropic-ai/sdk/resources/index.mjs' +export type { ToolResultBlockParam } import type { ElicitRequestURLParams, ElicitResult, diff --git a/src/assistant/gate.ts b/src/assistant/gate.ts index c08265c2d..1602a3be4 100644 --- a/src/assistant/gate.ts +++ b/src/assistant/gate.ts @@ -1,3 +1,25 @@ -// Auto-generated stub — replace with real implementation -export {}; -export const isKairosEnabled: () => Promise = () => Promise.resolve(false); +import { feature } from 'bun:bundle' +import { getKairosActive } from '../bootstrap/state.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' + +/** + * Runtime gate for KAIROS features. + * + * Build-time: feature('KAIROS') must be on (checked by caller before + * this module is required). + * + * Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill + * switch, and kairosActive state must be true (set during bootstrap when + * the session qualifies for KAIROS features). + */ +export async function isKairosEnabled(): Promise { + if (!feature('KAIROS')) { + return false + } + if ( + !getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false) + ) { + return false + } + return getKairosActive() +} diff --git a/src/assistant/index.ts b/src/assistant/index.ts index 3e23f69d9..5b75255ad 100644 --- a/src/assistant/index.ts +++ b/src/assistant/index.ts @@ -1,8 +1,9 @@ // Auto-generated stub — replace with real implementation -export {}; -export const isAssistantMode: () => boolean = () => false; -export const initializeAssistantTeam: () => Promise = async () => {}; -export const markAssistantForced: () => void = () => {}; -export const isAssistantForced: () => boolean = () => false; -export const getAssistantSystemPromptAddendum: () => string = () => ''; -export const getAssistantActivationPath: () => string | undefined = () => undefined; +export {} +export const isAssistantMode: () => boolean = () => false +export const initializeAssistantTeam: () => Promise = async () => {} +export const markAssistantForced: () => void = () => {} +export const isAssistantForced: () => boolean = () => false +export const getAssistantSystemPromptAddendum: () => string = () => '' +export const getAssistantActivationPath: () => string | undefined = () => + undefined diff --git a/src/commands.ts b/src/commands.ts index 2cb106c01..f411746a5 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -80,6 +80,12 @@ const remoteControlServerCommand = const voiceCommand = feature('VOICE_MODE') ? require('./commands/voice/index.js').default : null +const monitorCmd = feature('MONITOR_TOOL') + ? require('./commands/monitor.js').default + : null +const coordinatorCmd = feature('COORDINATOR_MODE') + ? require('./commands/coordinator.js').default + : null const forceSnip = feature('HISTORY_SNIP') ? require('./commands/force-snip.js').default : null @@ -110,6 +116,27 @@ const peersCmd = feature('UDS_INBOX') require('./commands/peers/index.js') as typeof import('./commands/peers/index.js') ).default : null +const attachCmd = feature('UDS_INBOX') + ? require('./commands/attach/index.js').default + : null +const detachCmd = feature('UDS_INBOX') + ? require('./commands/detach/index.js').default + : null +const sendCmd = feature('UDS_INBOX') + ? require('./commands/send/index.js').default + : null +const pipesCmd = feature('UDS_INBOX') + ? require('./commands/pipes/index.js').default + : null +const pipeStatusCmd = feature('UDS_INBOX') + ? require('./commands/pipe-status/index.js').default + : null +const historyCmd = feature('UDS_INBOX') + ? require('./commands/history/index.js').default + : null +const claimMainCmd = feature('UDS_INBOX') + ? require('./commands/claim-main/index.js').default + : null const forkCmd = feature('FORK_SUBAGENT') ? ( require('./commands/fork/index.js') as typeof import('./commands/fork/index.js') @@ -328,6 +355,8 @@ const COMMANDS = memoize((): Command[] => [ ...(buddy ? [buddy] : []), ...(poor ? [poor] : []), ...(proactive ? [proactive] : []), + ...(monitorCmd ? [monitorCmd] : []), + ...(coordinatorCmd ? [coordinatorCmd] : []), ...(briefCommand ? [briefCommand] : []), ...(assistantCommand ? [assistantCommand] : []), ...(bridge ? [bridge] : []), @@ -344,6 +373,13 @@ const COMMANDS = memoize((): Command[] => [ ...(!isUsing3PServices() ? [logout, login()] : []), passes, ...(peersCmd ? [peersCmd] : []), + ...(attachCmd ? [attachCmd] : []), + ...(detachCmd ? [detachCmd] : []), + ...(sendCmd ? [sendCmd] : []), + ...(pipesCmd ? [pipesCmd] : []), + ...(pipeStatusCmd ? [pipeStatusCmd] : []), + ...(historyCmd ? [historyCmd] : []), + ...(claimMainCmd ? [claimMainCmd] : []), tasks, ...(workflowsCmd ? [workflowsCmd] : []), ...(ultraplan ? [ultraplan] : []), diff --git a/src/commands/assistant/assistant.ts b/src/commands/assistant/assistant.ts index 80a04ca62..7234e0ac8 100644 --- a/src/commands/assistant/assistant.ts +++ b/src/commands/assistant/assistant.ts @@ -1,11 +1,53 @@ -// Auto-generated stub — replace with real implementation -import type React from 'react'; - -export {}; -export const NewInstallWizard: React.FC<{ - defaultDir: string; - onInstalled: (dir: string) => void; - onCancel: () => void; - onError: (message: string) => void; -}> = (() => null); -export const computeDefaultInstallDir: () => Promise = (() => Promise.resolve('')); +import * as React from 'react' +import type { LocalJSXCommandContext } from '../../commands.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js' +import type { AppState } from '../../state/AppState.js' + +/** Stub — install wizard is not yet restored. */ +export async function computeDefaultInstallDir(): Promise { + return '' +} + +/** Stub — install wizard is not yet restored. */ +export function NewInstallWizard(_props: { + defaultDir: string + onInstalled: (dir: string) => void + onCancel: () => void + onError: (message: string) => void +}): React.ReactNode { + return null +} + +/** + * /assistant command implementation. + * + * Opens the Kairos assistant panel. In the current build the panel is + * rendered by the REPL layer when kairosActive is true; the slash command + * simply toggles visibility and prints a confirmation line. + */ +export async function call( + onDone: LocalJSXCommandOnDone, + context: LocalJSXCommandContext, + _args: string, +): Promise { + const { setAppState, getAppState } = context + + const current = getAppState() + const isVisible = (current as Record).assistantPanelVisible + + if (isVisible) { + setAppState((prev: AppState) => ({ + ...prev, + assistantPanelVisible: false, + } as AppState)) + onDone('Assistant panel hidden.', { display: 'system' }) + } else { + setAppState((prev: AppState) => ({ + ...prev, + assistantPanelVisible: true, + } as AppState)) + onDone('Assistant panel opened.', { display: 'system' }) + } + + return null +} diff --git a/src/commands/assistant/gate.ts b/src/commands/assistant/gate.ts new file mode 100644 index 000000000..0bf42b1f5 --- /dev/null +++ b/src/commands/assistant/gate.ts @@ -0,0 +1,25 @@ +import { feature } from 'bun:bundle' +import { getKairosActive } from '../../bootstrap/state.js' +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' + +/** + * Runtime gate for the /assistant command. + * + * Build-time: feature('KAIROS') must be on (checked in commands.ts before + * the module is even required). + * + * Runtime: tengu_kairos_assistant GrowthBook flag acts as a remote kill + * switch, and kairosActive state must be true (set during bootstrap when + * the session qualifies for KAIROS features). + */ +export function isAssistantEnabled(): boolean { + if (!feature('KAIROS')) { + return false + } + if ( + !getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false) + ) { + return false + } + return getKairosActive() +} diff --git a/src/commands/assistant/index.ts b/src/commands/assistant/index.ts new file mode 100644 index 000000000..18263be39 --- /dev/null +++ b/src/commands/assistant/index.ts @@ -0,0 +1,16 @@ +import type { Command } from '../../commands.js' +import { isAssistantEnabled } from './gate.js' + +const assistant = { + type: 'local-jsx', + name: 'assistant', + description: 'Open the Kairos assistant panel', + isEnabled: isAssistantEnabled, + get isHidden() { + return !isAssistantEnabled() + }, + immediate: true, + load: () => import('./assistant.js'), +} satisfies Command + +export default assistant diff --git a/src/commands/attach/attach.ts b/src/commands/attach/attach.ts new file mode 100644 index 000000000..4501ca628 --- /dev/null +++ b/src/commands/attach/attach.ts @@ -0,0 +1,137 @@ +import { feature } from 'bun:bundle' +import type { LocalCommandCall } from '../../types/command.js' +import { + connectToPipe, + getPipeIpc, + isPipeControlled, + type PipeClient, + type PipeMessage, + type TcpEndpoint, +} from '../../utils/pipeTransport.js' +import { addSlaveClient } from '../../hooks/useMasterMonitor.js' + +export const call: LocalCommandCall = async (args, context) => { + const targetName = args.trim() + if (!targetName) { + return { + type: 'text', + value: 'Usage: /attach \nUse /pipes to list available pipes.', + } + } + + const currentState = context.getAppState() + + // Check if already attached to this slave + if (getPipeIpc(currentState).slaves[targetName]) { + return { + type: 'text', + value: `Already attached to "${targetName}".`, + } + } + + // Controlled sub sessions cannot attach to other sub sessions. + if (isPipeControlled(getPipeIpc(currentState))) { + return { + type: 'text', + value: + 'Cannot attach: this sub is currently controlled by a master. Detach it from the master first.', + } + } + + // Resolve TCP endpoint for LAN peers + let tcpEndpoint: TcpEndpoint | undefined + if (feature('LAN_PIPES')) { + const pipeState = getPipeIpc(currentState) + const discoveredPeer = pipeState.discoveredPipes.find( + (p: { pipeName: string }) => p.pipeName === targetName, + ) + if (discoveredPeer) { + // Check if this is a LAN peer by looking up beacon data + const { getLanBeacon } = + require('../../utils/lanBeacon.js') as typeof import('../../utils/lanBeacon.js') + const beaconRef = getLanBeacon() + if (beaconRef) { + const lanPeers = beaconRef.getPeers() + const lanPeer = lanPeers.get(targetName) + if (lanPeer) { + tcpEndpoint = { host: lanPeer.ip, port: lanPeer.tcpPort } + } + } + } + } + + // Connect to the target pipe server (UDS or TCP) + let client: PipeClient + try { + const myName = + getPipeIpc(currentState).serverName ?? `master-${process.pid}` + client = await connectToPipe(targetName, myName, undefined, tcpEndpoint) + } catch (err) { + return { + type: 'text', + value: `Failed to connect to "${targetName}"${tcpEndpoint ? ` (TCP ${tcpEndpoint.host}:${tcpEndpoint.port})` : ''}: ${err instanceof Error ? err.message : String(err)}`, + } + } + + // Send attach request and wait for response + return new Promise(resolve => { + const timeout = setTimeout(() => { + client.disconnect() + resolve({ + type: 'text', + value: `Attach to "${targetName}" timed out (no response within 5s).`, + }) + }, 5000) + + client.onMessage((msg: PipeMessage) => { + if (msg.type === 'attach_accept') { + clearTimeout(timeout) + + // Register the slave client in the module-level registry + addSlaveClient(targetName, client) + + // Update AppState: add slave and switch to master role + context.setAppState(prev => ({ + ...prev, + pipeIpc: { + ...getPipeIpc(prev), + role: 'master', + displayRole: 'master', + slaves: { + ...getPipeIpc(prev).slaves, + [targetName]: { + name: targetName, + connectedAt: new Date().toISOString(), + status: 'idle' as const, + unreadCount: 0, + history: [], + }, + }, + }, + })) + + const slaveCount = + Object.keys(getPipeIpc(currentState).slaves).length + 1 + resolve({ + type: 'text', + value: `Attached to "${targetName}" as master. Now monitoring ${slaveCount} sub session(s).\nUse /send ${targetName} to send tasks.\nUse /status to see all connected subs.\nUse /detach ${targetName} to disconnect.`, + }) + } else if (msg.type === 'attach_reject') { + clearTimeout(timeout) + client.disconnect() + + resolve({ + type: 'text', + value: `Attach rejected by "${targetName}": ${msg.data ?? 'unknown reason'}`, + }) + } + }) + + // Include machineId so remote can distinguish LAN peers from local peers + const pipeState = getPipeIpc(currentState) + client.send({ + type: 'attach_request', + meta: { machineId: pipeState.machineId }, + }) + }) +} diff --git a/src/commands/attach/index.ts b/src/commands/attach/index.ts new file mode 100644 index 000000000..f2b133846 --- /dev/null +++ b/src/commands/attach/index.ts @@ -0,0 +1,11 @@ +import type { Command } from '../../commands.js' + +const attach = { + type: 'local', + name: 'attach', + description: 'Attach to a sub Claude CLI instance via named pipe', + supportsNonInteractive: false, + load: () => import('./attach.js'), +} satisfies Command + +export default attach diff --git a/src/commands/claim-main/claim-main.ts b/src/commands/claim-main/claim-main.ts new file mode 100644 index 000000000..4a97ec4ca --- /dev/null +++ b/src/commands/claim-main/claim-main.ts @@ -0,0 +1,76 @@ +import type { LocalCommandCall } from '../../types/command.js' +import { getPipeIpc } from '../../utils/pipeTransport.js' +import { + getMachineId, + getMacAddress, + claimMain, + readRegistry, +} from '../../utils/pipeRegistry.js' +import { getLocalIp } from '../../utils/pipeTransport.js' + +export const call: LocalCommandCall = async (_args, context) => { + const currentState = context.getAppState() + const pipeState = getPipeIpc(currentState) + const myName = pipeState.serverName + + if (!myName) { + return { + type: 'text', + value: 'Pipe server not started. Cannot claim main.', + } + } + + const machineId = await getMachineId() + const registry = await readRegistry() + + // Already main machine? + if (registry.mainMachineId === machineId && registry.main?.id === myName) { + return { + type: 'text', + value: 'This instance is already the main. No change needed.', + } + } + + const { hostname } = require('os') as typeof import('os') + + const entry = { + id: myName, + pid: process.pid, + machineId, + startedAt: Date.now(), + ip: getLocalIp(), + mac: getMacAddress(), + hostname: hostname(), + pipeName: myName, + } + + await claimMain(machineId, entry) + + // Update local state + context.setAppState(prev => ({ + ...prev, + pipeIpc: { + ...getPipeIpc(prev), + role: 'main', + subIndex: null, + displayRole: 'main', + machineId, + attachedBy: null, + }, + })) + + const lines: string[] = [] + lines.push('Main role claimed successfully.') + lines.push(`Machine ID: ${machineId.slice(0, 8)}...`) + lines.push(`Pipe: ${myName}`) + if (registry.mainMachineId && registry.mainMachineId !== machineId) { + lines.push( + `Previous main machine: ${registry.mainMachineId.slice(0, 8)}...`, + ) + } + lines.push('') + lines.push('All existing subs are now bound to this instance.') + lines.push('Use /pipes to verify.') + + return { type: 'text', value: lines.join('\n') } +} diff --git a/src/commands/claim-main/index.ts b/src/commands/claim-main/index.ts new file mode 100644 index 000000000..ce49f6ca4 --- /dev/null +++ b/src/commands/claim-main/index.ts @@ -0,0 +1,12 @@ +import type { Command } from '../../commands.js' + +const claimMain = { + type: 'local', + name: 'claim-main', + description: + 'Claim main role for this machine (overrides current main machine)', + supportsNonInteractive: false, + load: () => import('./claim-main.js'), +} satisfies Command + +export default claimMain diff --git a/src/commands/coordinator.ts b/src/commands/coordinator.ts new file mode 100644 index 000000000..fecce7ead --- /dev/null +++ b/src/commands/coordinator.ts @@ -0,0 +1,63 @@ +/** + * /coordinator — Toggle coordinator (multi-worker orchestration) mode. + * + * When enabled, the CLI becomes an orchestrator that dispatches tasks + * to worker agents via Agent({ subagent_type: "worker" }). + * The coordinator can only use Agent, SendMessage, and TaskStop. + */ +import { feature } from 'bun:bundle' +import type { ToolUseContext } from '../Tool.js' +import type { + Command, + LocalJSXCommandContext, + LocalJSXCommandOnDone, +} from '../types/command.js' + +const coordinator = { + type: 'local-jsx', + name: 'coordinator', + description: 'Toggle coordinator (multi-worker) mode', + isEnabled: () => { + if (feature('COORDINATOR_MODE')) { + return true + } + return false + }, + immediate: true, + load: () => + Promise.resolve({ + async call( + onDone: LocalJSXCommandOnDone, + _context: ToolUseContext & LocalJSXCommandContext, + ): Promise { + const mod = + require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js') + + if (mod.isCoordinatorMode()) { + // Disable: clear the env var + delete process.env.CLAUDE_CODE_COORDINATOR_MODE + onDone('Coordinator mode disabled — back to normal mode', { + display: 'system', + metaMessages: [ + '\nCoordinator mode is now disabled. You have access to all standard tools again. Work directly instead of dispatching to workers.\n', + ], + }) + } else { + // Enable: set the env var + process.env.CLAUDE_CODE_COORDINATOR_MODE = '1' + onDone( + 'Coordinator mode enabled — use Agent(subagent_type: "worker") to dispatch tasks', + { + display: 'system', + metaMessages: [ + '\nCoordinator mode is now enabled. You are an orchestrator. Use Agent({ subagent_type: "worker" }) to spawn workers, SendMessage to continue them, TaskStop to stop them. Do not use other tools directly.\n', + ], + }, + ) + } + return null + }, + }), +} satisfies Command + +export default coordinator diff --git a/src/commands/detach/detach.ts b/src/commands/detach/detach.ts new file mode 100644 index 000000000..56ddfb375 --- /dev/null +++ b/src/commands/detach/detach.ts @@ -0,0 +1,95 @@ +import type { LocalCommandCall } from '../../types/command.js' +import { + removeSlaveClient, + getAllSlaveClients, +} from '../../hooks/useMasterMonitor.js' +import { getPipeIpc, isPipeControlled } from '../../utils/pipeTransport.js' + +export const call: LocalCommandCall = async (args, context) => { + const currentState = context.getAppState() + + if (getPipeIpc(currentState).role === 'main') { + return { type: 'text', value: 'Not attached to any CLI.' } + } + + if (isPipeControlled(getPipeIpc(currentState))) { + return { + type: 'text', + value: + 'This sub session is controlled by a master. The master must detach.', + } + } + + // Master mode + const targetName = args.trim() + + if (targetName) { + // Detach from a specific slave + const client = removeSlaveClient(targetName) + if (!client) { + return { + type: 'text', + value: `Not attached to "${targetName}". Use /status to see connected sub sessions.`, + } + } + + try { + client.send({ type: 'detach' }) + } catch { + // Socket may already be closed + } + client.disconnect() + + // Remove slave from state + context.setAppState(prev => { + const { [targetName]: _removed, ...remainingSlaves } = + getPipeIpc(prev).slaves + const hasSlaves = Object.keys(remainingSlaves).length > 0 + return { + ...prev, + pipeIpc: { + ...getPipeIpc(prev), + role: hasSlaves ? 'master' : 'main', + displayRole: hasSlaves ? 'master' : 'main', + slaves: remainingSlaves, + }, + } + }) + + return { + type: 'text', + value: `Detached from "${targetName}".`, + } + } + + // No target specified — detach from ALL slaves + const allClients = getAllSlaveClients() + const slaveNames = Array.from(allClients.keys()) + + for (const name of slaveNames) { + const client = removeSlaveClient(name) + if (client) { + try { + client.send({ type: 'detach' }) + } catch { + // Ignore + } + client.disconnect() + } + } + + context.setAppState(prev => ({ + ...prev, + pipeIpc: { + ...getPipeIpc(prev), + role: 'main', + displayRole: 'main', + slaves: {}, + }, + })) + + return { + type: 'text', + value: `Detached from ${slaveNames.length} sub session(s): ${slaveNames.join(', ')}. Back to main mode.`, + } +} diff --git a/src/commands/detach/index.ts b/src/commands/detach/index.ts new file mode 100644 index 000000000..fdba08390 --- /dev/null +++ b/src/commands/detach/index.ts @@ -0,0 +1,11 @@ +import type { Command } from '../../commands.js' + +const detach = { + type: 'local', + name: 'detach', + description: 'Detach from a sub CLI (or all connected subs)', + supportsNonInteractive: false, + load: () => import('./detach.js'), +} satisfies Command + +export default detach diff --git a/src/commands/force-snip.ts b/src/commands/force-snip.ts new file mode 100644 index 000000000..6d1a355af --- /dev/null +++ b/src/commands/force-snip.ts @@ -0,0 +1,59 @@ +import { randomUUID } from 'crypto' +import type { Command, LocalCommandCall } from '../types/command.js' +import type { Message } from '../types/message.js' + +/** + * Insert a snip boundary into the message array. + * + * A snip boundary is a system message that marks everything before it as + * "snipped". During the next query cycle, `snipCompactIfNeeded` (in + * services/compact/snipCompact.ts) detects this boundary and removes — or + * collapses — the older messages so they no longer consume context-window + * tokens. The REPL keeps the full history for UI scrollback; the boundary + * only affects model-facing projections. + * + * The `snipMetadata.removedUuids` field tells downstream consumers + * (sessionStorage persistence, snipProjection) which messages were removed. + */ +const call: LocalCommandCall = async (_args, context) => { + const { messages, setMessages } = context + + if (messages.length === 0) { + return { type: 'text', value: 'No messages to snip.' } + } + + // Collect UUIDs of every message that will be snipped (everything currently + // in the conversation). The next call to `snipCompactIfNeeded` will honour + // the boundary and strip these from the model-facing view. + const removedUuids = messages.map((m) => m.uuid) + + const boundaryMessage: Message = { + type: 'system', + subtype: 'snip_boundary', + content: '[snip] Conversation history before this point has been snipped.', + isMeta: true, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + snipMetadata: { + removedUuids, + }, + } as Message // subtype is feature-gated; cast through Message + + setMessages((prev) => [...prev, boundaryMessage]) + + return { + type: 'text', + value: `Snipped ${removedUuids.length} message(s). Older history will be excluded from the next model query.`, + } +} + +const forceSnip = { + type: 'local', + name: 'force-snip', + description: 'Force snip conversation history at current point', + supportsNonInteractive: true, + isHidden: true, + load: () => Promise.resolve({ call }), +} satisfies Command + +export default forceSnip diff --git a/src/commands/history/history.ts b/src/commands/history/history.ts new file mode 100644 index 000000000..ad53b5f1b --- /dev/null +++ b/src/commands/history/history.ts @@ -0,0 +1,93 @@ +import type { LocalCommandCall } from '../../types/command.js' +import { getPipeIpc } from '../../utils/pipeTransport.js' + +export const call: LocalCommandCall = async (args, context) => { + const currentState = context.getAppState() + + if (getPipeIpc(currentState).role !== 'master') { + return { + type: 'text', + value: 'Not in master mode. Use /attach first.', + } + } + + const parts = args.trim().split(/\s+/) + const targetName = parts[0] + + if (!targetName) { + // Show list of connected sub sessions + const slaveNames = Object.keys(getPipeIpc(currentState).slaves) + if (slaveNames.length === 0) { + return { type: 'text', value: 'No sub sessions connected.' } + } + return { + type: 'text', + value: `Usage: /history \nConnected sub sessions: ${slaveNames.join(', ')}`, + } + } + + const slave = getPipeIpc(currentState).slaves[targetName] + if (!slave) { + return { + type: 'text', + value: `Not attached to "${targetName}". Use /status to see connected sub sessions.`, + } + } + + // Parse --last N + let limit = slave.history.length + const lastIdx = parts.indexOf('--last') + if (lastIdx !== -1 && parts[lastIdx + 1]) { + const n = parseInt(parts[lastIdx + 1], 10) + if (!isNaN(n) && n > 0) { + limit = n + } + } + + const entries = slave.history.slice(-limit) + + if (entries.length === 0) { + return { + type: 'text', + value: `No session history for "${targetName}" yet.`, + } + } + + const lines: string[] = [ + `Session history for "${targetName}" (${entries.length}/${slave.history.length} entries):`, + '', + ] + + for (const entry of entries) { + const time = entry.timestamp.slice(11, 19) // HH:MM:SS + const prefix = formatEntryType(entry.type) + const content = + entry.content.length > 200 + ? entry.content.slice(0, 200) + '...' + : entry.content + lines.push(`[${time}] ${prefix} ${content}`) + } + + return { type: 'text', value: lines.join('\n') } +} + +function formatEntryType(type: string): string { + switch (type) { + case 'prompt': + return '[PROMPT]' + case 'prompt_ack': + return '[ACK] ' + case 'stream': + return '[AI] ' + case 'tool_start': + return '[TOOL>] ' + case 'tool_result': + return '[TOOL<] ' + case 'done': + return '[DONE] ' + case 'error': + return '[ERROR] ' + default: + return `[${type}]` + } +} diff --git a/src/commands/history/index.ts b/src/commands/history/index.ts new file mode 100644 index 000000000..86ad9cb7b --- /dev/null +++ b/src/commands/history/index.ts @@ -0,0 +1,12 @@ +import type { Command } from '../../commands.js' + +const history = { + type: 'local', + name: 'history', + aliases: ['hist'], + description: 'View session history of a connected sub CLI', + supportsNonInteractive: false, + load: () => import('./history.js'), +} satisfies Command + +export default history diff --git a/src/commands/monitor.ts b/src/commands/monitor.ts new file mode 100644 index 000000000..f397dad82 --- /dev/null +++ b/src/commands/monitor.ts @@ -0,0 +1,108 @@ +/** + * /monitor — Start a background monitor task. + * + * Shortcut for the MonitorTool. Spawns a long-running shell command + * as a background task visible in the footer pill (Shift+Down to view). + * + * Usage: + * /monitor tail -f /var/log/syslog + * /monitor watch -n 5 git status + * /monitor "while true; do curl -s http://localhost:3000/health; sleep 10; done" + */ +import { feature } from 'bun:bundle' +import type { + Command, + LocalJSXCommandContext, + LocalJSXCommandOnDone, +} from '../types/command.js' +import type { ToolUseContext } from '../Tool.js' + +const monitor = { + type: 'local-jsx', + name: 'monitor', + description: 'Start a background shell monitor (Shift+Down to view)', + isEnabled: () => { + if (feature('MONITOR_TOOL')) { + return true + } + return false + }, + immediate: false, + userFacingName: () => 'monitor', + load: () => + Promise.resolve({ + async call( + onDone: LocalJSXCommandOnDone, + context: ToolUseContext & LocalJSXCommandContext, + args: string, + ): Promise { + let command = args.trim() + if (!command) { + onDone( + process.platform === 'win32' + ? 'Usage: /monitor \nExample: /monitor powershell -c "while(1){git status; Start-Sleep 5}"' + : 'Usage: /monitor \nExample: /monitor watch -n 5 git status', + { display: 'system' }, + ) + return null + } + + // Windows compatibility: convert `watch -n ` to a PowerShell loop + if (process.platform === 'win32') { + const watchMatch = command.match(/^watch\s+-n\s+(\d+)\s+(.+)$/) + if (watchMatch) { + const interval = watchMatch[1] + const innerCmd = watchMatch[2] + command = `powershell -c "while(1){${innerCmd}; Start-Sleep ${interval}}"` + } + } + + // Dynamic require to stay behind feature gate + const { spawnShellTask } = + require('../tasks/LocalShellTask/LocalShellTask.js') as typeof import('../tasks/LocalShellTask/LocalShellTask.js') + const { exec } = + require('../utils/Shell.js') as typeof import('../utils/Shell.js') + const { getTaskOutputPath } = + require('../utils/task/diskOutput.js') as typeof import('../utils/task/diskOutput.js') + + try { + const shellCommand = await exec( + command, + context.abortController.signal, + 'bash', + ) + + const handle = await spawnShellTask( + { + command, + description: command, + shellCommand, + toolUseId: context.toolUseId ?? `monitor-${Date.now()}`, + agentId: undefined, + kind: 'monitor', + }, + { + abortController: context.abortController, + getAppState: context.getAppState, + setAppState: context.setAppState, + }, + ) + + const outputFile = getTaskOutputPath(handle.taskId) + onDone( + `Monitor started (${handle.taskId}). Press Shift+Down to view.\nOutput: ${outputFile}`, + { display: 'system' }, + ) + } catch (err) { + onDone( + `Monitor failed: ${err instanceof Error ? err.message : String(err)}`, + { display: 'system' }, + ) + } + + return null + }, + }), +} satisfies Command + +export default monitor diff --git a/src/commands/peers/index.ts b/src/commands/peers/index.ts index 29ae6094c..c5731145b 100644 --- a/src/commands/peers/index.ts +++ b/src/commands/peers/index.ts @@ -1,3 +1,12 @@ -// Auto-generated stub — replace with real implementation -const _default: Record = {}; -export default _default; +import type { Command } from '../../commands.js' + +const peers = { + type: 'local', + name: 'peers', + aliases: ['who'], + description: 'List connected Claude Code peers', + supportsNonInteractive: true, + load: () => import('./peers.js'), +} satisfies Command + +export default peers diff --git a/src/commands/peers/peers.ts b/src/commands/peers/peers.ts new file mode 100644 index 000000000..aed37d327 --- /dev/null +++ b/src/commands/peers/peers.ts @@ -0,0 +1,61 @@ +import type { LocalCommandCall } from '../../types/command.js' +import { listPeers, isPeerAlive } from '../../utils/udsClient.js' +import { getUdsMessagingSocketPath } from '../../utils/udsMessaging.js' + +export const call: LocalCommandCall = async (_args, _context) => { + const mySocket = getUdsMessagingSocketPath() + const peers = await listPeers() + + const lines: string[] = [] + + // Show own socket + lines.push(`Your socket: ${mySocket ?? '(not started)'}`) + lines.push('') + + if (peers.length === 0) { + lines.push('No other Claude Code peers found.') + } else { + lines.push(`Peers (${peers.length}):`) + lines.push('') + + for (const peer of peers) { + const alive = peer.messagingSocketPath + ? await isPeerAlive(peer.messagingSocketPath) + : false + const status = alive ? 'reachable' : 'unreachable' + const label = peer.name ?? peer.kind ?? 'interactive' + const cwd = peer.cwd ? ` cwd: ${peer.cwd}` : '' + const age = peer.startedAt + ? ` started: ${formatAge(peer.startedAt)}` + : '' + + lines.push( + ` [${status}] PID ${peer.pid} (${label})${cwd}${age}`, + ) + if (peer.messagingSocketPath) { + lines.push(` socket: ${peer.messagingSocketPath}`) + } + if (peer.sessionId) { + lines.push(` session: ${peer.sessionId}`) + } + } + } + + lines.push('') + lines.push( + 'To message a peer: use SendMessage with to="uds:"', + ) + + return { type: 'text', value: lines.join('\n') } +} + +function formatAge(startedAt: number): string { + const elapsed = Date.now() - startedAt + const seconds = Math.floor(elapsed / 1000) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + const remainingMinutes = minutes % 60 + return `${hours}h ${remainingMinutes}m ago` +} diff --git a/src/commands/pipe-status/index.ts b/src/commands/pipe-status/index.ts new file mode 100644 index 000000000..0d8cb91c6 --- /dev/null +++ b/src/commands/pipe-status/index.ts @@ -0,0 +1,11 @@ +import type { Command } from '../../commands.js' + +const pipeStatus = { + type: 'local', + name: 'pipe-status', + description: 'Show current pipe connection status', + supportsNonInteractive: true, + load: () => import('./pipe-status.js'), +} satisfies Command + +export default pipeStatus diff --git a/src/commands/pipe-status/pipe-status.ts b/src/commands/pipe-status/pipe-status.ts new file mode 100644 index 000000000..b27be06d2 --- /dev/null +++ b/src/commands/pipe-status/pipe-status.ts @@ -0,0 +1,65 @@ +import type { LocalCommandCall } from '../../types/command.js' +import { getAllSlaveClients } from '../../hooks/useMasterMonitor.js' +import { + getPipeDisplayRole, + getPipeIpc, + isPipeControlled, +} from '../../utils/pipeTransport.js' + +export const call: LocalCommandCall = async (_args, context) => { + const currentState = context.getAppState() + + if (getPipeIpc(currentState).role === 'main') { + return { + type: 'text', + value: + 'Main mode — not connected to any CLIs.\nUse /attach to connect to a sub session.', + } + } + + if (isPipeControlled(getPipeIpc(currentState))) { + return { + type: 'text', + value: `${getPipeDisplayRole(getPipeIpc(currentState))} mode — controlled by "${getPipeIpc(currentState).attachedBy}".\nAll session data is being reported to the master.`, + } + } + + // Master mode + const slaves = getPipeIpc(currentState).slaves + const slaveNames = Object.keys(slaves) + const clients = getAllSlaveClients() + + if (slaveNames.length === 0) { + return { + type: 'text', + value: + 'Master mode but no sub sessions connected.\nUse /attach to connect.', + } + } + + const lines: string[] = [ + `Master mode — ${slaveNames.length} sub session(s) connected:`, + '', + ] + + for (const name of slaveNames) { + const slave = slaves[name]! + const client = clients.get(name) + const connected = client?.connected ? 'connected' : 'disconnected' + const historyCount = slave.history.length + const connectedAt = slave.connectedAt.slice(11, 19) + + lines.push(` ${name}`) + lines.push(` Status: ${slave.status} (${connected})`) + lines.push(` Connected: ${connectedAt}`) + lines.push(` History: ${historyCount} entries`) + lines.push('') + } + + lines.push('Commands:') + lines.push(' /send — Send a task to a sub session') + lines.push(' /history — View sub session transcript') + lines.push(' /detach [name] — Disconnect from a sub session (or all)') + + return { type: 'text', value: lines.join('\n') } +} diff --git a/src/commands/pipes/index.ts b/src/commands/pipes/index.ts new file mode 100644 index 000000000..9d06b1994 --- /dev/null +++ b/src/commands/pipes/index.ts @@ -0,0 +1,11 @@ +import type { Command } from '../../commands.js' + +const pipes = { + type: 'local', + name: 'pipes', + description: 'Inspect pipe registry state and toggle the pipe selector', + supportsNonInteractive: true, + load: () => import('./pipes.js'), +} satisfies Command + +export default pipes diff --git a/src/commands/pipes/pipes.ts b/src/commands/pipes/pipes.ts new file mode 100644 index 000000000..050db2b40 --- /dev/null +++ b/src/commands/pipes/pipes.ts @@ -0,0 +1,231 @@ +import { feature } from 'bun:bundle' +import type { LocalCommandCall } from '../../types/command.js' +import { + isPipeAlive, + getPipeIpc, + getPipeDisplayRole, + isPipeControlled, +} from '../../utils/pipeTransport.js' +import { + cleanupStaleEntries, + readRegistry, + isMainMachine, + mergeWithLanPeers, +} from '../../utils/pipeRegistry.js' + +export const call: LocalCommandCall = async (_args, context) => { + const args = _args.trim() + + // Enable status line + toggle selector open + context.setAppState(prev => { + const pipeIpc = getPipeIpc(prev) + return { + ...prev, + pipeIpc: { + ...pipeIpc, + statusVisible: true, + selectorOpen: !pipeIpc.selectorOpen, + }, + } + }) + + // Handle select/deselect subcommands + if (args.startsWith('select ') || args.startsWith('sel ')) { + const pipeName = args.replace(/^(select|sel)\s+/, '').trim() + if (!pipeName) + return { type: 'text', value: 'Usage: /pipes select ' } + context.setAppState(prev => { + const pipeIpc = getPipeIpc(prev) + const selected = pipeIpc.selectedPipes ?? [] + if (selected.includes(pipeName)) return prev + return { + ...prev, + pipeIpc: { ...pipeIpc, selectedPipes: [...selected, pipeName] }, + } + }) + return { + type: 'text', + value: `Selected ${pipeName} — messages will be broadcast to this pipe.`, + } + } + + if ( + args.startsWith('deselect ') || + args.startsWith('desel ') || + args.startsWith('unsel ') + ) { + const pipeName = args.replace(/^(deselect|desel|unsel)\s+/, '').trim() + if (!pipeName) + return { type: 'text', value: 'Usage: /pipes deselect ' } + context.setAppState(prev => { + const pipeIpc = getPipeIpc(prev) + const selected = (pipeIpc.selectedPipes ?? []).filter( + (n: string) => n !== pipeName, + ) + return { ...prev, pipeIpc: { ...pipeIpc, selectedPipes: selected } } + }) + return { type: 'text', value: `Deselected ${pipeName}.` } + } + + if (args === 'select-all' || args === 'all') { + const currentState = context.getAppState() + const pipeState = getPipeIpc(currentState) + const slaveNames = Object.keys(pipeState.slaves) + context.setAppState(prev => ({ + ...prev, + pipeIpc: { ...getPipeIpc(prev), selectedPipes: slaveNames }, + })) + return { + type: 'text', + value: `Selected all ${slaveNames.length} connected pipes.`, + } + } + + if (args === 'deselect-all' || args === 'none') { + context.setAppState(prev => ({ + ...prev, + pipeIpc: { ...getPipeIpc(prev), selectedPipes: [] }, + })) + return { + type: 'text', + value: 'Deselected all pipes. Messages will only run locally.', + } + } + + const currentState = context.getAppState() + const pipeState = getPipeIpc(currentState) + const myName = pipeState.serverName + const displayRole = getPipeDisplayRole(pipeState) + const selected: string[] = pipeState.selectedPipes ?? [] + + await cleanupStaleEntries() + const registry = await readRegistry() + + const lines: string[] = [] + + lines.push(`Your pipe: ${myName ?? '(not started)'}`) + lines.push(`Role: ${displayRole}`) + if (pipeState.machineId) + lines.push(`Machine ID: ${pipeState.machineId.slice(0, 8)}...`) + if (pipeState.localIp) lines.push(`IP: ${pipeState.localIp}`) + if (pipeState.hostname) lines.push(`Host: ${pipeState.hostname}`) + + if (isPipeControlled(pipeState)) { + lines.push(`Controlled by: ${pipeState.attachedBy}`) + } + + lines.push('') + + if (registry.mainMachineId) { + const isMyMachine = isMainMachine(pipeState.machineId ?? '', registry) + lines.push( + `Main machine: ${registry.mainMachineId.slice(0, 8)}...${isMyMachine ? ' (this machine)' : ''}`, + ) + } + + // Show main from registry + if (registry.main) { + const m = registry.main + const alive = await isPipeAlive(m.pipeName, 1000) + const isSelf = m.pipeName === myName + lines.push( + ` [main] ${m.pipeName} ${m.hostname}/${m.ip} [${alive ? 'alive' : 'stale'}]${isSelf ? ' (you)' : ''}`, + ) + } + + // Show subs from registry with selection status + const discoveredPipes: Array<{ + id: string + pipeName: string + role: string + machineId: string + ip: string + hostname: string + alive: boolean + }> = [] + + for (const sub of registry.subs) { + const alive = await isPipeAlive(sub.pipeName, 1000) + const isSelf = sub.pipeName === myName + const isSelected = selected.includes(sub.pipeName) + const checkbox = isSelected ? '☑' : '☐' + const isAttached = pipeState.slaves[sub.pipeName] ? ' [connected]' : '' + lines.push( + ` ${checkbox} [sub-${sub.subIndex}] ${sub.pipeName} ${sub.hostname}/${sub.ip} [${alive ? 'alive' : 'stale'}]${isAttached}${isSelf ? ' (you)' : ''}`, + ) + if (alive) { + discoveredPipes.push({ + id: sub.id, + pipeName: sub.pipeName, + role: `sub-${sub.subIndex}`, + machineId: sub.machineId, + ip: sub.ip, + hostname: sub.hostname, + alive, + }) + } + } + + if (!registry.main && registry.subs.length === 0) { + lines.push('No other pipes in registry.') + } + + // Show LAN peers (if LAN_PIPES enabled) + if (feature('LAN_PIPES')) { + const { getLanBeacon } = + require('../../utils/lanBeacon.js') as typeof import('../../utils/lanBeacon.js') + const lanBeaconRef = getLanBeacon() + if (lanBeaconRef) { + const lanPeers = lanBeaconRef.getPeers() + const merged = mergeWithLanPeers(registry, lanPeers) + const lanOnly = merged.filter(e => e.source === 'lan') + if (lanOnly.length > 0) { + lines.push('') + lines.push('LAN Peers:') + for (const peer of lanOnly) { + const isSelected = selected.includes(peer.pipeName) + const checkbox = isSelected ? '☑' : '☐' + const ep = peer.tcpEndpoint + ? `tcp:${peer.tcpEndpoint.host}:${peer.tcpEndpoint.port}` + : '' + lines.push( + ` ${checkbox} [${peer.role}] ${peer.pipeName} ${peer.hostname}/${peer.ip} ${ep} [LAN]`, + ) + discoveredPipes.push({ + id: peer.id, + pipeName: peer.pipeName, + role: peer.role, + machineId: peer.machineId, + ip: peer.ip, + hostname: peer.hostname, + alive: true, + }) + } + } else { + lines.push('') + lines.push('LAN Peers: (none discovered)') + } + } + } + + // Update state + context.setAppState(prev => ({ + ...prev, + pipeIpc: { ...getPipeIpc(prev), discoveredPipes }, + })) + + lines.push('') + lines.push( + `Selected: ${selected.length > 0 ? selected.join(', ') : '(none — messages run locally only)'}`, + ) + lines.push('') + lines.push('Commands:') + lines.push(' /pipes select — select pipe for broadcast') + lines.push(' /pipes deselect — deselect pipe') + lines.push(' /pipes all — select all connected') + lines.push(' /pipes none — deselect all') + lines.push(' /send — send to specific pipe') + lines.push(' /claim-main — claim this machine as main') + + return { type: 'text', value: lines.join('\n') } +} diff --git a/src/commands/proactive.ts b/src/commands/proactive.ts new file mode 100644 index 000000000..3d63bb362 --- /dev/null +++ b/src/commands/proactive.ts @@ -0,0 +1,56 @@ +/** + * /proactive — Toggle proactive (autonomous tick-driven) mode. + * + * When enabled, the model receives periodic prompts and works + * autonomously between user inputs. SleepTool controls pacing. + */ +import { feature } from 'bun:bundle' +import type { ToolUseContext } from '../Tool.js' +import type { + Command, + LocalJSXCommandContext, + LocalJSXCommandOnDone, +} from '../types/command.js' + +const proactive = { + type: 'local-jsx', + name: 'proactive', + description: 'Toggle proactive (autonomous) mode', + isEnabled: () => { + if (feature('PROACTIVE') || feature('KAIROS')) { + return true + } + return false + }, + immediate: true, + load: () => + Promise.resolve({ + async call( + onDone: LocalJSXCommandOnDone, + _context: ToolUseContext & LocalJSXCommandContext, + ): Promise { + // Dynamic require to avoid pulling proactive into non-gated builds + const mod = + require('../proactive/index.js') as typeof import('../proactive/index.js') + + if (mod.isProactiveActive()) { + mod.deactivateProactive() + onDone('Proactive mode disabled', { display: 'system' }) + } else { + mod.activateProactive('slash_command') + onDone( + 'Proactive mode enabled — model will work autonomously between ticks', + { + display: 'system', + metaMessages: [ + '\nProactive mode is now enabled. You will receive periodic prompts. Do useful work on each tick, or call Sleep if there is nothing to do. Do not output "still waiting" — either act or sleep.\n', + ], + }, + ) + } + return null + }, + }), +} satisfies Command + +export default proactive diff --git a/src/commands/send/index.ts b/src/commands/send/index.ts new file mode 100644 index 000000000..eb79d4e9f --- /dev/null +++ b/src/commands/send/index.ts @@ -0,0 +1,11 @@ +import type { Command } from '../../commands.js' + +const send = { + type: 'local', + name: 'send', + description: 'Send a message to a connected sub CLI', + supportsNonInteractive: false, + load: () => import('./send.js'), +} satisfies Command + +export default send diff --git a/src/commands/send/send.ts b/src/commands/send/send.ts new file mode 100644 index 000000000..ed7345c89 --- /dev/null +++ b/src/commands/send/send.ts @@ -0,0 +1,97 @@ +import type { LocalCommandCall } from '../../types/command.js' +import { getSlaveClient } from '../../hooks/useMasterMonitor.js' +import { getPipeIpc } from '../../utils/pipeTransport.js' + +export const call: LocalCommandCall = async (args, context) => { + const currentState = context.getAppState() + + if (getPipeIpc(currentState).role !== 'master') { + return { + type: 'text', + value: 'Not in master mode. Use /attach first.', + } + } + + // Parse: first word is pipe name, rest is the message + const trimmed = args.trim() + const spaceIdx = trimmed.indexOf(' ') + if (spaceIdx === -1) { + return { + type: 'text', + value: 'Usage: /send ', + } + } + + const targetName = trimmed.slice(0, spaceIdx) + const message = trimmed.slice(spaceIdx + 1).trim() + + if (!message) { + return { + type: 'text', + value: 'Usage: /send ', + } + } + + const client = getSlaveClient(targetName) + if (!client) { + return { + type: 'text', + value: `Not attached to "${targetName}". Use /status to see connected sub sessions.`, + } + } + + if (!client.connected) { + return { + type: 'text', + value: `Connection to "${targetName}" is closed. Use /detach ${targetName} and re-attach.`, + } + } + + try { + client.send({ + type: 'prompt', + data: message, + }) + + // Record the sent prompt in history + context.setAppState(prev => { + const slave = getPipeIpc(prev).slaves[targetName] + if (!slave) return prev + return { + ...prev, + pipeIpc: { + ...getPipeIpc(prev), + slaves: { + ...getPipeIpc(prev).slaves, + [targetName]: { + ...slave, + status: 'busy' as const, + lastActivityAt: new Date().toISOString(), + lastSummary: `Queued: ${message}`, + lastEventType: 'prompt', + history: [ + ...slave.history, + { + type: 'prompt' as const, + content: message, + from: getPipeIpc(currentState).serverName ?? 'master', + timestamp: new Date().toISOString(), + }, + ], + }, + }, + }, + } + }) + + return { + type: 'text', + value: `Sent to "${targetName}": ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`, + } + } catch (err) { + return { + type: 'text', + value: `Failed to send to "${targetName}": ${err instanceof Error ? err.message : String(err)}`, + } + } +} diff --git a/src/commands/subscribe-pr.ts b/src/commands/subscribe-pr.ts new file mode 100644 index 000000000..db1b10658 --- /dev/null +++ b/src/commands/subscribe-pr.ts @@ -0,0 +1,174 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import type { Command, LocalCommandCall } from '../types/command.js' +import { detectCurrentRepositoryWithHost } from '../utils/detectRepository.js' +import { getClaudeConfigHomeDir } from '../utils/envUtils.js' + +/** + * File-backed store for PR webhook subscriptions. + * Each subscription tracks the repo + PR number so the bridge layer + * (useReplBridge / webhookSanitizer) can filter inbound events. + */ +interface PRSubscription { + repo: string // "owner/repo" + prNumber: number + subscribedAt: string // ISO 8601 +} + +function getSubscriptionsFilePath(): string { + return path.join(getClaudeConfigHomeDir(), 'pr-subscriptions.json') +} + +function readSubscriptions(): PRSubscription[] { + const filePath = getSubscriptionsFilePath() + try { + const raw = fs.readFileSync(filePath, 'utf-8') + return JSON.parse(raw) as PRSubscription[] + } catch { + return [] + } +} + +function writeSubscriptions(subs: PRSubscription[]): void { + const filePath = getSubscriptionsFilePath() + const dir = path.dirname(filePath) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(filePath, JSON.stringify(subs, null, 2), 'utf-8') +} + +/** + * Parse a PR URL or number into { repo, prNumber }. + * + * Accepts: + * - Full URL: https://github.com/owner/repo/pull/123 + * - Short ref: owner/repo#123 + * - Bare number: 123 (uses the current git repository) + */ +async function parsePRArg( + arg: string, +): Promise<{ repo: string; prNumber: number } | { error: string }> { + const trimmed = arg.trim() + + // Full GitHub PR URL + const urlMatch = trimmed.match( + /^https?:\/\/[^/]+\/([^/]+\/[^/]+)\/pull\/(\d+)/, + ) + if (urlMatch) { + return { repo: urlMatch[1]!, prNumber: parseInt(urlMatch[2]!, 10) } + } + + // Short ref: owner/repo#123 + const shortMatch = trimmed.match(/^([^/]+\/[^/]+)#(\d+)$/) + if (shortMatch) { + return { repo: shortMatch[1]!, prNumber: parseInt(shortMatch[2]!, 10) } + } + + // Bare number — resolve repo from current git checkout + const numMatch = trimmed.match(/^#?(\d+)$/) + if (numMatch) { + const prNumber = parseInt(numMatch[1]!, 10) + const detected = await detectCurrentRepositoryWithHost() + if (!detected) { + return { + error: + 'Could not detect the GitHub repository for the current directory. Provide a full PR URL instead.', + } + } + const repo = `${detected.owner}/${detected.name}` + return { repo, prNumber } + } + + return { + error: `Unrecognised PR reference: "${trimmed}". Expected a PR URL, owner/repo#123, or a PR number.`, + } +} + +const call: LocalCommandCall = async (args, _context) => { + const trimmed = args.trim() + + // List current subscriptions + if (!trimmed || trimmed === '--list' || trimmed === 'list') { + const subs = readSubscriptions() + if (subs.length === 0) { + return { + type: 'text', + value: + 'No active PR subscriptions. Usage: /subscribe-pr ', + } + } + const lines = subs.map( + (s) => ` ${s.repo}#${s.prNumber} (since ${s.subscribedAt})`, + ) + return { + type: 'text', + value: `Active PR subscriptions:\n${lines.join('\n')}`, + } + } + + // Unsubscribe + if (trimmed.startsWith('--remove ') || trimmed.startsWith('remove ')) { + const rest = trimmed.replace(/^(--remove|remove)\s+/, '') + const parsed = await parsePRArg(rest) + if ('error' in parsed) { + return { type: 'text', value: parsed.error } + } + const subs = readSubscriptions() + const before = subs.length + const after = subs.filter( + (s) => !(s.repo === parsed.repo && s.prNumber === parsed.prNumber), + ) + if (after.length === before) { + return { + type: 'text', + value: `No subscription found for ${parsed.repo}#${parsed.prNumber}.`, + } + } + writeSubscriptions(after) + return { + type: 'text', + value: `Unsubscribed from ${parsed.repo}#${parsed.prNumber}.`, + } + } + + // Subscribe + const parsed = await parsePRArg(trimmed) + if ('error' in parsed) { + return { type: 'text', value: parsed.error } + } + + const subs = readSubscriptions() + const existing = subs.find( + (s) => s.repo === parsed.repo && s.prNumber === parsed.prNumber, + ) + if (existing) { + return { + type: 'text', + value: `Already subscribed to ${parsed.repo}#${parsed.prNumber} (since ${existing.subscribedAt}).`, + } + } + + subs.push({ + repo: parsed.repo, + prNumber: parsed.prNumber, + subscribedAt: new Date().toISOString(), + }) + writeSubscriptions(subs) + + return { + type: 'text', + value: `Subscribed to ${parsed.repo}#${parsed.prNumber}. You will receive notifications for comments, CI status, and reviews.`, + } +} + +const subscribePr = { + type: 'local', + name: 'subscribe-pr', + aliases: ['watch-pr'], + description: 'Subscribe to GitHub PR activity (comments, CI, reviews)', + argumentHint: '', + supportsNonInteractive: false, + isHidden: true, + load: () => Promise.resolve({ call }), +} satisfies Command + +export default subscribePr diff --git a/src/commands/torch.ts b/src/commands/torch.ts new file mode 100644 index 000000000..7b8595488 --- /dev/null +++ b/src/commands/torch.ts @@ -0,0 +1 @@ +export default null diff --git a/src/commands/workflows/index.ts b/src/commands/workflows/index.ts index 29ae6094c..d7d64472c 100644 --- a/src/commands/workflows/index.ts +++ b/src/commands/workflows/index.ts @@ -1,3 +1,25 @@ -// Auto-generated stub — replace with real implementation -const _default: Record = {}; -export default _default; +import type { Command, LocalCommandCall } from '../../types/command.js' +import { getWorkflowCommands } from '../../tools/WorkflowTool/createWorkflowCommand.js' +import { getCwd } from '../../utils/cwd.js' + +const call: LocalCommandCall = async (_args, _context) => { + const commands = await getWorkflowCommands(getCwd()) + if (commands.length === 0) { + return { + type: 'text', + value: 'No workflows found. Add workflow files to .claude/workflows/ (YAML or Markdown).', + } + } + const list = commands.map((cmd) => ` /${cmd.name} - ${cmd.description}`).join('\n') + return { type: 'text', value: `Available workflows:\n${list}` } +} + +const workflows = { + type: 'local', + name: 'workflows', + description: 'List available workflow scripts', + supportsNonInteractive: true, + load: () => Promise.resolve({ call }), +} satisfies Command + +export default workflows diff --git a/src/components/FullscreenLayout.tsx b/src/components/FullscreenLayout.tsx index e767f10c3..4427fa4d4 100644 --- a/src/components/FullscreenLayout.tsx +++ b/src/components/FullscreenLayout.tsx @@ -1,4 +1,4 @@ -import figures from 'figures' +import figures from 'figures'; import React, { createContext, type ReactNode, @@ -29,53 +29,53 @@ import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggest import type { StickyPrompt } from './VirtualMessageList.js' /** Rows of transcript context kept visible above the modal pane's ▔ divider. */ -const MODAL_TRANSCRIPT_PEEK = 2 +const MODAL_TRANSCRIPT_PEEK = 2; /** Context for scroll-derived chrome (sticky header, pill). StickyTracker * in VirtualMessageList writes via this instead of threading a callback * up through Messages → REPL → FullscreenLayout. The setter is stable so * consuming this context never causes re-renders. */ export const ScrollChromeContext = createContext<{ - setStickyPrompt: (p: StickyPrompt | null) => void -}>({ setStickyPrompt: () => {} }) + setStickyPrompt: (p: StickyPrompt | null) => void; +}>({ setStickyPrompt: () => {} }); type Props = { /** Content that scrolls (messages, tool output) */ - scrollable: ReactNode + scrollable: ReactNode; /** Content pinned to the bottom (spinner, prompt, permissions) */ - bottom: ReactNode + bottom: ReactNode; /** Content rendered inside the ScrollBox after messages — user can scroll * up to see context while it's showing (used by PermissionRequest). */ - overlay?: ReactNode + overlay?: ReactNode; /** Absolute-positioned content anchored at the bottom-right of the * ScrollBox area, floating over scrollback. Rendered inside the flexGrow * region (not the bottom slot) so the overflowY:hidden cap doesn't clip * it. Fullscreen only — used for the companion speech bubble. */ - bottomFloat?: ReactNode + bottomFloat?: ReactNode; /** Slash-command dialog content. Rendered in an absolute-positioned * bottom-anchored pane (▔ divider, paddingX=2) that paints over the * ScrollBox AND bottom slot. Provides ModalContext so Pane/Dialog inside * skip their own frame. Fullscreen only; inline after overlay otherwise. */ - modal?: ReactNode + modal?: ReactNode; /** Ref passed via ModalContext so Tabs (or any scroll-owning descendant) * can attach it to their own ScrollBox for tall content. */ - modalScrollRef?: React.RefObject + modalScrollRef?: React.RefObject; /** Ref to the scroll box for keyboard scrolling. RefObject (not Ref) so * pillVisible's useSyncExternalStore can subscribe to scroll changes. */ - scrollRef?: RefObject + scrollRef?: RefObject; /** Y-position (scrollHeight at snapshot) of the unseen-divider. Pill * shows while viewport bottom hasn't reached this. Ref so REPL doesn't * re-render on the one-shot snapshot write. */ - dividerYRef?: RefObject + dividerYRef?: RefObject; /** Force-hide the pill (e.g. viewing a sub-agent task). */ - hidePill?: boolean + hidePill?: boolean; /** Force-hide the sticky prompt header (e.g. viewing a teammate task). */ - hideSticky?: boolean + hideSticky?: boolean; /** Count for the pill text. 0 → "Jump to bottom", >0 → "N new messages". */ - newMessageCount?: number + newMessageCount?: number; /** Called when the user clicks the "N new" pill. */ - onPillClick?: () => void -} + onPillClick?: () => void; +}; /** * Tracks the in-transcript "N new messages" divider position while the @@ -98,33 +98,33 @@ export function useUnseenDivider(messageCount: number): { /** Index into messages[] where the divider line renders. Cleared on * sticky-resume (scroll back to bottom) so the "N new" line doesn't * linger once everything is visible. */ - dividerIndex: number | null + dividerIndex: number | null; /** scrollHeight snapshot at first scroll-away — the divider's y-position. * FullscreenLayout subscribes to ScrollBox and compares viewport bottom * against this for pillVisible. Ref so writes don't re-render REPL. */ - dividerYRef: RefObject - onScrollAway: (handle: ScrollBoxHandle) => void - onRepin: () => void + dividerYRef: RefObject; + onScrollAway: (handle: ScrollBoxHandle) => void; + onRepin: () => void; /** Scroll the handle so the divider line is at the top of the viewport. */ - jumpToNew: (handle: ScrollBoxHandle | null) => void + jumpToNew: (handle: ScrollBoxHandle | null) => void; /** Shift dividerIndex and dividerYRef when messages are prepended * (infinite scroll-back). indexDelta = number of messages prepended; * heightDelta = content height growth in rows. */ - shiftDivider: (indexDelta: number, heightDelta: number) => void + shiftDivider: (indexDelta: number, heightDelta: number) => void; } { - const [dividerIndex, setDividerIndex] = useState(null) + const [dividerIndex, setDividerIndex] = useState(null); // Ref holds the current count for onScrollAway to snapshot. Written in // the render body (not useEffect) so wheel events arriving between a // message-append render and its effect flush don't capture a stale // count (off-by-one in the baseline). React Compiler bails out here — // acceptable for a hook instantiated once in REPL. - const countRef = useRef(messageCount) - countRef.current = messageCount + const countRef = useRef(messageCount); + countRef.current = messageCount; // scrollHeight snapshot — the divider's y in content coords. Ref-only: // read synchronously in onScrollAway (setState is batched, can't // read-then-write in the same callback) AND by FullscreenLayout's // pillVisible subscription. null = pinned to bottom. - const dividerYRef = useRef(null) + const dividerYRef = useRef(null); const onRepin = useCallback(() => { // Don't clear dividerYRef here — a trackpad momentum wheel event @@ -132,8 +132,8 @@ export function useUnseenDivider(messageCount: number): { // overriding the setDividerIndex(null) below. The useEffect below // clears the ref after React commits the null dividerIndex, so the // ref stays non-null until the state settles. - setDividerIndex(null) - }, []) + setDividerIndex(null); + }, []); const onScrollAway = useCallback((handle: ScrollBoxHandle) => { // Nothing below the viewport → nothing to jump to. Covers both: @@ -145,24 +145,21 @@ export function useUnseenDivider(messageCount: number): { // at max (Sarah Deaton, #claude-code-feedback 2026-03-15) // pendingDelta: scrollBy accumulates without updating scrollTop. Without // it, wheeling up from max would see scrollTop==max and suppress the pill. - const max = Math.max( - 0, - handle.getScrollHeight() - handle.getViewportHeight(), - ) - if (handle.getScrollTop() + handle.getPendingDelta() >= max) return + const max = Math.max(0, handle.getScrollHeight() - handle.getViewportHeight()); + if (handle.getScrollTop() + handle.getPendingDelta() >= max) return; // Snapshot only on the FIRST scroll-away. onScrollAway fires on EVERY // scroll action (not just the initial break from sticky) — this guard // preserves the original baseline so the count doesn't reset on the // second PageUp. Subsequent calls are ref-only no-ops (no REPL re-render). if (dividerYRef.current === null) { - dividerYRef.current = handle.getScrollHeight() + dividerYRef.current = handle.getScrollHeight(); // New scroll-away session → move the divider here (replaces old one) - setDividerIndex(countRef.current) + setDividerIndex(countRef.current); } - }, []) + }, []); const jumpToNew = useCallback((handle: ScrollBoxHandle | null) => { - if (!handle) return + if (!handle) return; // scrollToBottom (not scrollTo(dividerY)): sets stickyScroll=true so // useVirtualScroll mounts the tail and render-node-to-output pins // scrollTop=maxScroll. scrollTo sets stickyScroll=false → the clamp @@ -170,8 +167,8 @@ export function useUnseenDivider(messageCount: number): { // back, stopping short. The divider stays rendered (dividerIndex // unchanged) so users see where new messages started; the clear on // next submit/explicit scroll-to-bottom handles cleanup. - handle.scrollToBottom() - }, []) + handle.scrollToBottom(); + }, []); // Sync dividerYRef with dividerIndex. When onRepin fires (submit, // scroll-to-bottom), it sets dividerIndex=null but leaves the ref @@ -184,22 +181,19 @@ export function useUnseenDivider(messageCount: number): { // below the divider index, the divider would point at nothing. useEffect(() => { if (dividerIndex === null) { - dividerYRef.current = null + dividerYRef.current = null; } else if (messageCount < dividerIndex) { - dividerYRef.current = null - setDividerIndex(null) + dividerYRef.current = null; + setDividerIndex(null); } - }, [messageCount, dividerIndex]) + }, [messageCount, dividerIndex]); - const shiftDivider = useCallback( - (indexDelta: number, heightDelta: number) => { - setDividerIndex(idx => (idx === null ? null : idx + indexDelta)) - if (dividerYRef.current !== null) { - dividerYRef.current += heightDelta - } - }, - [], - ) + const shiftDivider = useCallback((indexDelta: number, heightDelta: number) => { + setDividerIndex(idx => (idx === null ? null : idx + indexDelta)); + if (dividerYRef.current !== null) { + dividerYRef.current += heightDelta; + } + }, []); return { dividerIndex, @@ -208,7 +202,7 @@ export function useUnseenDivider(messageCount: number): { onRepin, jumpToNew, shiftDivider, - } + }; } /** @@ -219,25 +213,22 @@ export function useUnseenDivider(messageCount: number): { * carry text — tool-use-only entries are skipped (like progress messages) * so "⏺ Searched for 13 patterns, read 6 files" doesn't tick the pill. */ -export function countUnseenAssistantTurns( - messages: readonly Message[], - dividerIndex: number, -): number { - let count = 0 - let prevWasAssistant = false +export function countUnseenAssistantTurns(messages: readonly Message[], dividerIndex: number): number { + let count = 0; + let prevWasAssistant = false; for (let i = dividerIndex; i < messages.length; i++) { - const m = messages[i]! - if (m.type === 'progress') continue + const m = messages[i]!; + if (m.type === 'progress') continue; // Tool-use-only assistant entries aren't "new messages" to the user — // skip them the same way we skip progress. prevWasAssistant is NOT // updated, so a text block immediately following still counts as the // same turn (tool_use + text from one API response = 1). - if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue - const isAssistant = m.type === 'assistant' - if (isAssistant && !prevWasAssistant) count++ - prevWasAssistant = isAssistant + if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue; + const isAssistant = m.type === 'assistant'; + if (isAssistant && !prevWasAssistant) count++; + prevWasAssistant = isAssistant; } - return count + return count; } function assistantHasVisibleText(m: Message): boolean { @@ -246,10 +237,10 @@ function assistantHasVisibleText(m: Message): boolean { for (const b of m.message!.content) { if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true } - return false + return false; } -export type UnseenDivider = { firstUnseenUuid: Message['uuid']; count: number } +export type UnseenDivider = { firstUnseenUuid: Message['uuid']; count: number }; /** * Builds the unseenDivider object REPL passes to Messages + the pill. @@ -265,23 +256,22 @@ export function computeUnseenDivider( messages: readonly Message[], dividerIndex: number | null, ): UnseenDivider | undefined { - if (dividerIndex === null) return undefined + if (dividerIndex === null) return undefined; // Skip progress and null-rendering attachments when picking the divider // anchor — Messages.tsx filters these out of renderableMessages before the // dividerBeforeIndex search, so their UUID wouldn't be found (CC-724). // Hook attachments use randomUUID() so nothing shares their 24-char prefix. - let anchorIdx = dividerIndex + let anchorIdx = dividerIndex; while ( anchorIdx < messages.length && - (messages[anchorIdx]?.type === 'progress' || - isNullRenderingAttachment(messages[anchorIdx]!)) + (messages[anchorIdx]?.type === 'progress' || isNullRenderingAttachment(messages[anchorIdx]!)) ) { - anchorIdx++ + anchorIdx++; } - const uuid = messages[anchorIdx]?.uuid - if (!uuid) return undefined - const count = countUnseenAssistantTurns(messages, dividerIndex) - return { firstUnseenUuid: uuid, count: Math.max(1, count) } + const uuid = messages[anchorIdx]?.uuid; + if (!uuid) return undefined; + const count = countUnseenAssistantTurns(messages, dividerIndex); + return { firstUnseenUuid: uuid, count: Math.max(1, count) }; } /** @@ -310,56 +300,53 @@ export function FullscreenLayout({ newMessageCount = 0, onPillClick, }: Props): React.ReactNode { - const { rows: terminalRows, columns } = useTerminalSize() + const { rows: terminalRows, columns } = useTerminalSize(); // Scroll-derived chrome state lives HERE, not in REPL. StickyTracker // writes via ScrollChromeContext; pillVisible subscribes directly to // ScrollBox. Both change rarely (pill flips once per threshold crossing, // sticky changes ~5-20×/transcript) — re-rendering FullscreenLayout on // those is fine; re-rendering the 6966-line REPL + its 22+ useAppState // selectors per-scroll-frame was not. - const [stickyPrompt, setStickyPrompt] = useState(null) - const chromeCtx = useMemo(() => ({ setStickyPrompt }), []) + const [stickyPrompt, setStickyPrompt] = useState(null); + const chromeCtx = useMemo(() => ({ setStickyPrompt }), []); // Boolean-quantized scroll subscription. Snapshot is "is viewport bottom // above the divider y?" — Object.is on a boolean → FullscreenLayout only // re-renders when the pill should actually flip, not per-frame. const subscribe = useCallback( - (listener: () => void) => - scrollRef?.current?.subscribe(listener) ?? (() => {}), + (listener: () => void) => scrollRef?.current?.subscribe(listener) ?? (() => {}), [scrollRef], - ) + ); const pillVisible = useSyncExternalStore(subscribe, () => { - const s = scrollRef?.current - const dividerY = dividerYRef?.current - if (!s || dividerY == null) return false - return ( - s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY - ) - }) + const s = scrollRef?.current; + const dividerY = dividerYRef?.current; + if (!s || dividerY == null) return false; + return s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY; + }); // Wire up hyperlink click handling — in fullscreen mode, mouse tracking // intercepts clicks before the terminal can open OSC 8 links natively. useLayoutEffect(() => { - if (!isFullscreenEnvEnabled()) return - const ink = instances.get(process.stdout) - if (!ink) return + if (!isFullscreenEnvEnabled()) return; + const ink = instances.get(process.stdout); + if (!ink) return; ink.onHyperlinkClick = url => { // Most OSC 8 links emitted by Claude Code are file:// URLs from // FilePathLink (FileEdit/FileWrite/FileRead tool output). openBrowser // rejects non-http(s) protocols — route file: to openPath instead. if (url.startsWith('file:')) { try { - void openPath(fileURLToPath(url)) + void openPath(fileURLToPath(url)); } catch { // Malformed file: URLs (e.g. file://host/path from plain-text // detection) cause fileURLToPath to throw — ignore silently. } } else { - void openBrowser(url) + void openBrowser(url); } - } + }; return () => { - ink.onHyperlinkClick = undefined - } - }, []) + ink.onHyperlinkClick = undefined; + }; + }, []); if (isFullscreenEnvEnabled()) { // Overlay renders BELOW messages inside the same ScrollBox — user can @@ -379,50 +366,41 @@ export function FullscreenLayout({ // row 0. On next scroll the onChange fires with a fresh {text} and // header comes back (viewportTop 0→1, a single 1-row shift — // acceptable since user explicitly scrolled). - const sticky = hideSticky ? null : stickyPrompt - const headerPrompt = - sticky != null && sticky !== 'clicked' && overlay == null ? sticky : null - const padCollapsed = sticky != null && overlay == null + const sticky = hideSticky ? null : stickyPrompt; + const headerPrompt = sticky != null && sticky !== 'clicked' && overlay == null ? sticky : null; + const padCollapsed = sticky != null && overlay == null; return ( - - {headerPrompt && ( - - )} - - - {scrollable} - - {overlay} - - {!hidePill && pillVisible && overlay == null && ( - - )} - {bottomFloat != null && ( - - {bottomFloat} + + + + {headerPrompt && } + + {scrollable} + {overlay} + + {!hidePill && pillVisible && overlay == null && ( + + )} + {bottomFloat != null && ( + + {bottomFloat} + + )} + + + + + + {bottom} + - )} - - - - - - {bottom} {modal != null && ( @@ -465,19 +443,14 @@ export function FullscreenLayout({ {'▔'.repeat(columns)} - + {modal} )} - ) + ); } return ( @@ -487,7 +460,7 @@ export function FullscreenLayout({ {overlay} {modal} - ) + ); } // Slack-style pill. Absolute overlay at bottom={0} of the scrollwrap — floats @@ -497,42 +470,18 @@ export function FullscreenLayout({ // (absoluteRectsPrev third-pass in render-node-to-output.ts, #23939). Shows // "Jump to bottom" when count is 0 (scrolled away but no new messages yet — // the dead zone where users previously thought chat stalled). -function NewMessagesPill({ - count, - onClick, -}: { - count: number - onClick?: () => void -}): React.ReactNode { - const [hover, setHover] = useState(false) +function NewMessagesPill({ count, onClick }: { count: number; onClick?: () => void }): React.ReactNode { + const [hover, setHover] = useState(false); return ( - - setHover(true)} - onMouseLeave={() => setHover(false)} - > - + + setHover(true)} onMouseLeave={() => setHover(false)}> + {' '} - {count > 0 - ? `${count} new ${plural(count, 'message')}` - : 'Jump to bottom'}{' '} - {figures.arrowDown}{' '} + {count > 0 ? `${count} new ${plural(count, 'message')}` : 'Jump to bottom'} {figures.arrowDown}{' '} - ) + ); } // Context breadcrumb: when scrolled up into history, pin the current @@ -547,23 +496,15 @@ function NewMessagesPill({ // even with scrollTop unchanged (the DECSTBM region top shifts with the // ScrollBox, and the diff engine sees "everything moved"). Fixed height // keeps the ScrollBox anchored; only the header TEXT changes, not its box. -function StickyPromptHeader({ - text, - onClick, -}: { - text: string - onClick: () => void -}): React.ReactNode { - const [hover, setHover] = useState(false) +function StickyPromptHeader({ text, onClick }: { text: string; onClick: () => void }): React.ReactNode { + const [hover, setHover] = useState(false); return ( setHover(true)} onMouseLeave={() => setHover(false)} @@ -572,7 +513,7 @@ function StickyPromptHeader({ {figures.pointer} {text} - ) + ); } // Slash-command suggestion overlay — see promptOverlayContext.tsx for why @@ -584,19 +525,10 @@ function StickyPromptHeader({ // flex-end here: they would create empty padding rows that shift visible // items down into the prompt area when the list has fewer items than max. function SuggestionsOverlay(): React.ReactNode { - const data = usePromptOverlay() - if (!data || data.suggestions.length === 0) return null + const data = usePromptOverlay(); + if (!data || data.suggestions.length === 0) return null; return ( - + - ) + ); } // Dialog portaled from PromptInput (AutoModeOptInDialog) — same clip-escape // pattern as SuggestionsOverlay. Renders later in tree order so it paints // over suggestions if both are ever up (they shouldn't be). function DialogOverlay(): React.ReactNode { - const node = usePromptOverlayDialog() - if (!node) return null + const node = usePromptOverlayDialog(); + if (!node) return null; return ( {node} - ) + ); } diff --git a/src/components/PromptInput/PromptInputFooter.tsx b/src/components/PromptInput/PromptInputFooter.tsx index 46b3981f5..2261e56b0 100644 --- a/src/components/PromptInput/PromptInputFooter.tsx +++ b/src/components/PromptInput/PromptInputFooter.tsx @@ -1,6 +1,6 @@ import { feature } from 'bun:bundle' import * as React from 'react' -import { memo, type ReactNode, useMemo, useRef } from 'react' +import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react' import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js' import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js' import { useSetPromptOverlay } from '../../context/promptOverlayContext.js' @@ -8,14 +8,16 @@ import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js' import type { IDESelection } from '../../hooks/useIdeSelection.js' import { useSettings } from '../../hooks/useSettings.js' import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, Text } from '@anthropic/ink' +import { Box, Text, useInput } from '@anthropic/ink' import type { MCPServerConnection } from '../../services/mcp/types.js' -import { useAppState } from '../../state/AppState.js' +import { useRegisterOverlay } from '../../context/overlayContext.js' +import { useAppState, useSetAppState } from '../../state/AppState.js' import type { ToolPermissionContext } from '../../Tool.js' import type { Message } from '../../types/message.js' import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js' import type { AutoUpdaterResult } from '../../utils/autoUpdater.js' import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' +import { getPipeDisplayRole, isPipeControlled } from '../../utils/pipeTransport.js' import { isUndercover } from '../../utils/undercover.js' import { CoordinatorTaskPanel, @@ -28,49 +30,48 @@ import { } from '../StatusLine.js' import { Notifications } from './Notifications.js' import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js' -import { - PromptInputFooterSuggestions, - type SuggestionItem, -} from './PromptInputFooterSuggestions.js' + +// Inline pipe status is shown only after /pipes sets pipeIpc.statusVisible. +import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js' import { PromptInputHelpMenu } from './PromptInputHelpMenu.js' type Props = { - apiKeyStatus: VerificationStatus - debug: boolean + apiKeyStatus: VerificationStatus; + debug: boolean; exitMessage: { - show: boolean - key?: string - } - vimMode: VimMode | undefined - mode: PromptInputMode - autoUpdaterResult: AutoUpdaterResult | null - isAutoUpdating: boolean - verbose: boolean - onAutoUpdaterResult: (result: AutoUpdaterResult) => void - onChangeIsUpdating: (isUpdating: boolean) => void - suggestions: SuggestionItem[] - selectedSuggestion: number - maxColumnWidth?: number - toolPermissionContext: ToolPermissionContext - helpOpen: boolean - suppressHint: boolean - isLoading: boolean - tasksSelected: boolean - teamsSelected: boolean - bridgeSelected: boolean - tmuxSelected: boolean - teammateFooterIndex?: number - ideSelection: IDESelection | undefined - mcpClients?: MCPServerConnection[] - isPasting?: boolean - isInputWrapped?: boolean - messages: Message[] - isSearching: boolean - historyQuery: string - setHistoryQuery: (query: string) => void - historyFailedMatch: boolean - onOpenTasksDialog?: (taskId?: string) => void -} + show: boolean; + key?: string; + }; + vimMode: VimMode | undefined; + mode: PromptInputMode; + autoUpdaterResult: AutoUpdaterResult | null; + isAutoUpdating: boolean; + verbose: boolean; + onAutoUpdaterResult: (result: AutoUpdaterResult) => void; + onChangeIsUpdating: (isUpdating: boolean) => void; + suggestions: SuggestionItem[]; + selectedSuggestion: number; + maxColumnWidth?: number; + toolPermissionContext: ToolPermissionContext; + helpOpen: boolean; + suppressHint: boolean; + isLoading: boolean; + tasksSelected: boolean; + teamsSelected: boolean; + bridgeSelected: boolean; + tmuxSelected: boolean; + teammateFooterIndex?: number; + ideSelection: IDESelection | undefined; + mcpClients?: MCPServerConnection[]; + isPasting?: boolean; + isInputWrapped?: boolean; + messages: Message[]; + isSearching: boolean; + historyQuery: string; + setHistoryQuery: (query: string) => void; + historyFailedMatch: boolean; + onOpenTasksDialog?: (taskId?: string) => void; +}; function PromptInputFooter({ apiKeyStatus, @@ -106,43 +107,35 @@ function PromptInputFooter({ historyFailedMatch, onOpenTasksDialog, }: Props): ReactNode { - const settings = useSettings() - const { columns, rows } = useTerminalSize() - const messagesRef = useRef(messages) - messagesRef.current = messages - const lastAssistantMessageId = useMemo( - () => getLastAssistantMessageId(messages), - [messages], - ) - const isNarrow = columns < 80 + const settings = useSettings(); + const { columns, rows } = useTerminalSize(); + const messagesRef = useRef(messages); + messagesRef.current = messages; + const lastAssistantMessageId = useMemo(() => getLastAssistantMessageId(messages), [messages]); + const isNarrow = columns < 80; // In fullscreen the bottom slot is flexShrink:0, so every row here is a row // stolen from the ScrollBox. Drop the optional StatusLine first. Non-fullscreen // has terminal scrollback to absorb overflow, so we never hide StatusLine there. - const isFullscreen = isFullscreenEnvEnabled() - const isShort = isFullscreen && rows < 24 + const isFullscreen = isFullscreenEnvEnabled(); + const isShort = isFullscreen && rows < 24; // Pill highlights when tasks is the active footer item AND no specific // agent row is selected. When coordinatorTaskIndex >= 0 the pointer has // moved into CoordinatorTaskPanel, so the pill should un-highlight. // coordinatorTaskCount === 0 covers the bash-only case (no agent rows // exist, pill is the only selectable item). - const coordinatorTaskCount = useCoordinatorTaskCount() - const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex) - const pillSelected = - tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0) + const coordinatorTaskCount = useCoordinatorTaskCount(); + const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex); + const pillSelected = tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0); // Hide `? for shortcuts` if the user has a custom status line, or during ctrl-r - const suppressHint = - suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching + const suppressHint = suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching; // Fullscreen: portal data to FullscreenLayout — see promptOverlayContext.tsx const overlayData = useMemo( - () => - isFullscreen && suggestions.length - ? { suggestions, selectedSuggestion, maxColumnWidth } - : null, + () => (isFullscreen && suggestions.length ? { suggestions, selectedSuggestion, maxColumnWidth } : null), [isFullscreen, suggestions, selectedSuggestion, maxColumnWidth], - ) - useSetPromptOverlay(overlayData) + ); + useSetPromptOverlay(overlayData); if (suggestions.length && !isFullscreen) { return ( @@ -153,13 +146,11 @@ function PromptInputFooter({ maxColumnWidth={maxColumnWidth} /> - ) + ); } if (helpOpen) { - return ( - - ) + return ; } return ( @@ -171,17 +162,10 @@ function PromptInputFooter({ gap={isNarrow ? 0 : 1} > - {mode === 'prompt' && - !isShort && - !exitMessage.show && - !isPasting && - statusLineShouldDisplay(settings) && ( - - )} + {mode === 'prompt' && !isShort && !exitMessage.show && !isPasting && statusLineShouldDisplay(settings) && ( + + )} + )} - {process.env.USER_TYPE === 'ant' && isUndercover() && ( - undercover - )} + {process.env.USER_TYPE === 'ant' && isUndercover() && undercover} {process.env.USER_TYPE === 'ant' && } - ) + ); } -export default memo(PromptInputFooter) +export default memo(PromptInputFooter); type BridgeStatusProps = { - bridgeSelected: boolean -} + bridgeSelected: boolean; +}; -function BridgeStatusIndicator({ - bridgeSelected, -}: BridgeStatusProps): React.ReactNode { - if (!feature('BRIDGE_MODE')) return null +function BridgeStatusIndicator({ bridgeSelected }: BridgeStatusProps): React.ReactNode { + if (!feature('BRIDGE_MODE')) return null; - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const enabled = useAppState(s => s.replBridgeEnabled) - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const connected = useAppState(s => s.replBridgeConnected) - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const sessionActive = useAppState(s => s.replBridgeSessionActive) - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const reconnecting = useAppState(s => s.replBridgeReconnecting) - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const explicit = useAppState(s => s.replBridgeExplicit) + const enabled = useAppState(s => s.replBridgeEnabled); + const connected = useAppState(s => s.replBridgeConnected); + const sessionActive = useAppState(s => s.replBridgeSessionActive); + const reconnecting = useAppState(s => s.replBridgeReconnecting); + const explicit = useAppState(s => s.replBridgeExplicit); // Failed state is surfaced via notification (useReplBridge), not a footer pill. - if (!isBridgeEnabled() || !enabled) return null + if (!isBridgeEnabled() || !enabled) return null; const status = getBridgeStatus({ error: undefined, connected, sessionActive, reconnecting, - }) + }); // For implicit (config-driven) remote, only show the reconnecting state if (!explicit && status.label !== 'Remote Control reconnecting') { - return null + return null; } return ( - + {status.label} {bridgeSelected && · Enter to view} - ) + ); +} + +/** + * Inline pipe status panel with interactive checkbox selection. + * + * Shows after /pipes sets statusVisible. Displays: + * - Header: own pipe info (collapsed mode) + * - Ctrl+P: toggle expanded mode with sub list + checkboxes + * - Expanded: ↑↓ to move cursor, Space to toggle, Enter/Esc to collapse + * + * Only uses AppState + Ink — no heavy external imports. + */ +function PipeStatusInline(): React.ReactNode { + if (!feature('UDS_INBOX')) return null; + // All hooks must be called before any conditional return to maintain + // consistent hook count across renders (React rules of hooks). + const pipeIpc = useAppState(s => (s as any).pipeIpc); + const setAppState = useSetAppState(); + const [cursorIndex, setCursorIndex] = useState(0); + + const isVisible = !!pipeIpc?.statusVisible && !!pipeIpc?.serverName; + const selectorOpen: boolean = !!pipeIpc?.selectorOpen; + + const slaves = pipeIpc?.slaves ?? {}; + const slaveNames = Object.keys(slaves); + const discovered: Array<{ pipeName: string; role: string; ip: string; hostname: string }> = + pipeIpc?.discoveredPipes ?? []; + const allPipes = [...new Set([...slaveNames, ...discovered.map(d => d.pipeName)])].filter( + n => n !== pipeIpc?.serverName, + ); + const selectedPipes: string[] = pipeIpc?.selectedPipes ?? []; + const displayRole = pipeIpc ? getPipeDisplayRole(pipeIpc) : 'main'; + const routeMode: 'selected' | 'local' = pipeIpc?.routeMode ?? 'selected'; + const selectedRouteActive = routeMode !== 'local' && selectedPipes.length > 0; + const setRouteMode = (mode: 'selected' | 'local') => { + setAppState((prev: any) => { + const pIpc = prev.pipeIpc ?? {}; + return { ...prev, pipeIpc: { ...pIpc, routeMode: mode } }; + }); + }; + + // Register as modal overlay when selector is open. + // This sets isModalOverlayActive=true in PromptInput → TextInput focus=false + // → TextInput's useInput is deactivated → ↑↓ no longer trigger history navigation. + // Same mechanism used by BackgroundTasksDialog, FuzzyPicker, etc. + useRegisterOverlay('pipe-selector', isVisible && selectorOpen); + + // Keyboard handler — must be called every render (hooks rules). + // ↑↓ navigate list, Space toggles selection, ←/→ or m switches route mode, Enter/Esc close selector. + // No conflict with history nav: useRegisterOverlay above disables TextInput when open. + useInput((_input, key) => { + if (!isVisible) return; + + // When collapsed: only ←/→ arrow keys toggle route mode (no overlay, + // so printable keys like 'm' would leak into the TextInput). + // When expanded: ←/→ and 'm' all work (overlay blocks TextInput). + if (selectedPipes.length > 0) { + const arrowToggle = key.leftArrow || key.rightArrow; + const mToggle = selectorOpen && _input.toLowerCase() === 'm'; + if (arrowToggle || mToggle) { + setRouteMode(routeMode === 'local' ? 'selected' : 'local'); + return; + } + } + + if (!selectorOpen) return; + + if (key.downArrow) { + setCursorIndex(i => Math.min(i + 1, allPipes.length - 1)); + } else if (key.upArrow) { + setCursorIndex(i => Math.max(i - 1, 0)); + } else if (_input === ' ') { + const pipeName = allPipes[cursorIndex]; + if (pipeName) { + setAppState((prev: any) => { + const pIpc = prev.pipeIpc ?? {}; + const sel: string[] = pIpc.selectedPipes ?? []; + const newSel = sel.includes(pipeName) ? sel.filter((n: string) => n !== pipeName) : [...sel, pipeName]; + return { ...prev, pipeIpc: { ...pIpc, selectedPipes: newSel } }; + }); + } + } else if (key.return || key.escape) { + setAppState((prev: any) => { + const pIpc = prev.pipeIpc ?? {}; + return { ...prev, pipeIpc: { ...pIpc, selectorOpen: false } }; + }); + } + }); + + // Early return AFTER all hooks + if (!isVisible) return null; + + if (!selectorOpen) { + return ( + + pipe: + {pipeIpc.serverName} + ({displayRole}) + {pipeIpc.localIp && {pipeIpc.localIp}} + {allPipes.length > 0 && ( + + {selectedPipes.length}/{allPipes.length} selected + + )} + {pipeIpc && isPipeControlled(pipeIpc) && pipeIpc.attachedBy && ( + + {'→ '} + {pipeIpc.attachedBy} + + )} + {allPipes.length > 0 && ( + + {selectedPipes.length > 0 + ? `${routeMode === 'local' ? 'local main' : 'selected pipes only'} · ←/→ switch · Shift+↓ edit` + : 'local main · Shift+↓ select'} + + )} + + ); + } + + // Expanded mode: header + pipe list with checkboxes + return ( + + + pipe: + {pipeIpc.serverName} + ({displayRole}) + {pipeIpc.localIp && {pipeIpc.localIp}} + ↑↓ move Space select ←/→ or m route Enter/Esc close Shift+↓ toggle + + + + {selectedPipes.length > 0 + ? `当前普通 prompt 走 ${routeMode === 'local' ? '本地 main' : '已选 sub'};切换不会清空选择` + : '当前未选择 pipe;普通 prompt 会在本地 main 对话执行'} + + + {allPipes.map((name, idx) => { + const isSelected = selectedPipes.includes(name); + const isCursor = idx === cursorIndex; + const isConnected = !!slaves[name]; + const disc = discovered.find(d => d.pipeName === name); + const label = disc ? `${disc.role} ${disc.hostname}/${disc.ip}` : ''; + + return ( + + + {isSelected ? '☑' : '☐'} {name} + {isConnected ? '' : ' [offline]'} + {label ? ` (${label})` : ''} + + + ); + })} + {allPipes.length === 0 && ( + + No other pipes found. Start another instance. + + )} + + ); } diff --git a/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.ts b/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.ts deleted file mode 100644 index ea96e4e48..000000000 --- a/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Auto-generated stub — replace with real implementation -export {}; -export const MonitorPermissionRequest: (props: Record) => null = () => null; diff --git a/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.tsx b/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.tsx new file mode 100644 index 000000000..0041f3d6e --- /dev/null +++ b/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.tsx @@ -0,0 +1,165 @@ +import React, { useCallback, useMemo } from 'react' +import { Box, Text, useTheme } from '@anthropic/ink' +import { getTheme } from '../../../utils/theme.js' +import { env } from '../../../utils/env.js' +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' +import { truncateToLines } from '../../../utils/stringUtils.js' +import { logUnaryEvent } from '../../../utils/unaryLogging.js' +import { PermissionDialog } from '../PermissionDialog.js' +import { + PermissionPrompt, + type PermissionPromptOption, +} from '../PermissionPrompt.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' + +type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no' + +/** + * Permission request UI for the MonitorTool. Asks the user to confirm + * starting a long-running background monitor process. + * Follows the FallbackPermissionRequest pattern. + */ +export function MonitorPermissionRequest({ + toolUseConfirm, + onDone, + onReject, + workerBadge, +}: PermissionRequestProps): React.ReactNode { + const [themeName] = useTheme() + const theme = getTheme(themeName) + + const input = toolUseConfirm.input as { + command: string + description: string + } + + const showAlwaysAllowOptions = useMemo( + () => shouldShowAlwaysAllowOptions(), + [], + ) + + const options: PermissionPromptOption[] = useMemo(() => { + const opts: PermissionPromptOption[] = [ + { + label: 'Yes', + value: 'yes', + feedbackConfig: { type: 'accept' as const }, + }, + ] + if (showAlwaysAllowOptions) { + opts.push({ + label: ( + + Yes, and don{'\u2019'}t ask again for{' '} + {toolUseConfirm.tool.name} commands + + ), + value: 'yes-dont-ask-again', + }) + } + opts.push({ + label: 'No', + value: 'no', + feedbackConfig: { type: 'reject' as const }, + }) + return opts + }, [showAlwaysAllowOptions, toolUseConfirm.tool.name]) + + const handleSelect = useCallback( + (value: OptionValue, feedback?: string) => { + switch (value) { + case 'yes': + logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id ?? '', + platform: env.platform, + }, + }) + toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback) + onDone() + break + case 'yes-dont-ask-again': + logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id ?? '', + platform: env.platform, + }, + }) + toolUseConfirm.onAllow(toolUseConfirm.input, [ + { + type: 'addRules', + rules: [{ toolName: toolUseConfirm.tool.name }], + behavior: 'allow', + destination: 'localSettings', + }, + ]) + onDone() + break + case 'no': + logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id ?? '', + platform: env.platform, + }, + }) + toolUseConfirm.onReject(feedback) + onReject() + onDone() + break + } + }, + [toolUseConfirm, onDone, onReject], + ) + + const handleCancel = useCallback(() => { + logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id ?? '', + platform: env.platform, + }, + }) + toolUseConfirm.onReject() + onReject() + onDone() + }, [toolUseConfirm, onDone, onReject]) + + return ( + + + + + {input.description} + + + {truncateToLines(input.command, 5)} + + + + + options={options} + onSelect={handleSelect} + onCancel={handleCancel} + /> + + + ) +} diff --git a/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.ts b/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.ts deleted file mode 100644 index a812ebe9d..000000000 --- a/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Auto-generated stub — replace with real implementation -export {}; -export const ReviewArtifactPermissionRequest: (props: Record) => null = () => null; diff --git a/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.tsx b/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.tsx new file mode 100644 index 000000000..df24c2ea3 --- /dev/null +++ b/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { Box, Text } from '@anthropic/ink' +import { Select } from '../../CustomSelect/select.js' +import { usePermissionRequestLogging } from '../hooks.js' +import { PermissionDialog } from '../PermissionDialog.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { logUnaryPermissionEvent } from '../utils.js' + +export function ReviewArtifactPermissionRequest({ + toolUseConfirm, + onDone, + onReject, + workerBadge, +}: PermissionRequestProps): React.ReactNode { + const { title, annotations, summary } = toolUseConfirm.input as { + title?: string + annotations?: Array<{ line?: number; message: string; severity?: string }> + summary?: string + } + + const unaryEvent = { + completion_type: 'tool_use_single' as const, + language_name: 'none', + } + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + const annotationCount = annotations?.length ?? 0 + + function handleResponse(value: 'yes' | 'no'): void { + if (value === 'yes') { + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + toolUseConfirm.onAllow(toolUseConfirm.input, []) + onDone() + } else { + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'reject') + toolUseConfirm.onReject() + onReject() + onDone() + } + } + + return ( + + + + Claude wants to review{title ? `: ${title}` : ' an artifact'}. + + + + + {annotationCount} annotation{annotationCount !== 1 ? 's' : ''} will + be presented. + + {summary ? Summary: {summary} : null} + + + + + +type WorkflowOutput = { output: string } + +export const WorkflowTool = buildTool({ + name: WORKFLOW_TOOL_NAME, + searchHint: 'execute user-defined workflow scripts', + maxResultSizeChars: 50_000, + strict: true, + + inputSchema, + + async description() { + return 'Execute a user-defined workflow script from .claude/workflows/' + }, + async prompt() { + return `Use the Workflow tool to execute user-defined workflow scripts located in .claude/workflows/. Workflows are YAML or Markdown files that define a sequence of steps for common development tasks. + +Guidelines: +- Specify the workflow name to execute (must match a file in .claude/workflows/) +- Optionally pass arguments that the workflow can use +- Workflows run in the context of the current project` + }, + userFacingName() { + return 'Workflow' + }, + isReadOnly() { + return false + }, + isEnabled() { + return true + }, + + renderToolUseMessage(input: Partial) { + const name = input.workflow ?? 'unknown' + if (input.args) { + return `Workflow: ${name} ${input.args}` + } + return `Workflow: ${name}` + }, + + mapToolResultToToolResultBlockParam( + content: WorkflowOutput, + toolUseID: string, + ): ToolResultBlockParam { + return { + tool_use_id: toolUseID, + type: 'tool_result', + content: truncate(content.output, 50_000), + } + }, + + async call(_input: WorkflowInput, _context, _progress) { + // Workflow execution is wired by the WORKFLOW_SCRIPTS feature bootstrap. + // Without it, this tool is not functional. + return { + data: { + output: + 'Error: Workflow execution requires the WORKFLOW_SCRIPTS runtime.', + }, + } + }, +}) diff --git a/src/tools/WorkflowTool/bundled/index.ts b/src/tools/WorkflowTool/bundled/index.ts new file mode 100644 index 000000000..eb6620cd0 --- /dev/null +++ b/src/tools/WorkflowTool/bundled/index.ts @@ -0,0 +1,15 @@ +// Bundled workflow initialization. +// Called by tools.ts when WORKFLOW_SCRIPTS feature flag is enabled. +// Sets up any pre-bundled workflow scripts that ship with the CLI. + +/** + * Initialize bundled workflows. Called once at startup when the + * WORKFLOW_SCRIPTS feature flag is active. This is the hook point + * for registering any workflow scripts that are compiled into the + * binary (as opposed to user-authored ones in .claude/workflows/). + */ +export function initBundledWorkflows(): void { + // Bundled workflows are registered here at startup. + // Currently a no-op — all workflows are user-authored in .claude/workflows/. + // This function exists as the extension point for future built-in workflows. +} diff --git a/src/tools/WorkflowTool/constants.ts b/src/tools/WorkflowTool/constants.ts index 9e49474d9..49249caf5 100644 --- a/src/tools/WorkflowTool/constants.ts +++ b/src/tools/WorkflowTool/constants.ts @@ -1,2 +1,3 @@ -// Auto-generated stub — replace with real implementation -export const WORKFLOW_TOOL_NAME: string = ''; +export const WORKFLOW_TOOL_NAME = 'workflow' +export const WORKFLOW_DIR_NAME = '.claude/workflows' +export const WORKFLOW_FILE_EXTENSIONS = ['.yml', '.yaml', '.md'] diff --git a/src/tools/WorkflowTool/createWorkflowCommand.ts b/src/tools/WorkflowTool/createWorkflowCommand.ts index cf9046a53..a6369f565 100644 --- a/src/tools/WorkflowTool/createWorkflowCommand.ts +++ b/src/tools/WorkflowTool/createWorkflowCommand.ts @@ -1,3 +1,41 @@ -// Auto-generated stub — replace with real implementation -export {}; -export const getWorkflowCommands: (...args: unknown[]) => unknown = () => {}; +import { readdir } from 'fs/promises' +import { join, parse } from 'path' +import type { Command } from '../../types/command.js' +import { WORKFLOW_DIR_NAME, WORKFLOW_FILE_EXTENSIONS } from './constants.js' + +/** + * Scans .claude/workflows/ directory and creates Command objects for each workflow file. + * Each workflow file becomes a slash command (e.g. /workflow-name). + */ +export async function getWorkflowCommands(cwd: string): Promise { + const workflowDir = join(cwd, WORKFLOW_DIR_NAME) + let files: string[] + try { + files = await readdir(workflowDir) + } catch { + return [] + } + + const workflowFiles = files.filter((f) => { + const ext = parse(f).ext.toLowerCase() + return WORKFLOW_FILE_EXTENSIONS.includes(ext) + }) + + return workflowFiles.map((file) => { + const name = parse(file).name + return { + type: 'prompt' as const, + name, + description: `Run workflow: ${name}`, + kind: 'workflow' as const, + source: 'builtin' as const, + progressMessage: `Running workflow ${name}...`, + contentLength: 0, + async getPromptForCommand(args, _context) { + const { readFile } = await import('fs/promises') + const content = await readFile(join(workflowDir, file), 'utf-8') + return [{ type: 'text' as const, text: `Execute this workflow:\n\n${content}${args ? `\n\nArguments: ${args}` : ''}` }] + }, + } satisfies Command + }) +} diff --git a/src/utils/__tests__/lanBeacon.test.ts b/src/utils/__tests__/lanBeacon.test.ts new file mode 100644 index 000000000..7a5f42e89 --- /dev/null +++ b/src/utils/__tests__/lanBeacon.test.ts @@ -0,0 +1,165 @@ +import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test' + +// Mock dgram before importing LanBeacon +const mockSocket = { + on: mock(() => mockSocket), + bind: mock((port: number, cb: () => void) => cb()), + addMembership: mock(() => {}), + setMulticastInterface: mock(() => {}), + setMulticastTTL: mock(() => {}), + setBroadcast: mock(() => {}), + dropMembership: mock(() => {}), + send: mock(() => {}), + close: mock(() => {}), +} + +mock.module('dgram', () => ({ + createSocket: () => mockSocket, +})) + +const { LanBeacon } = await import('../lanBeacon.js') + +type MockCall = [string, ...unknown[]] + +function getMessageHandler(): ((msg: Buffer, rinfo: { address: string; port: number }) => void) | undefined { + const calls = mockSocket.on.mock.calls as unknown as MockCall[] + const call = calls.find(c => c[0] === 'message') + return call?.[1] as ((msg: Buffer, rinfo: { address: string; port: number }) => void) | undefined +} + +describe('LanBeacon', () => { + let beacon: InstanceType + + const announceData = { + pipeName: 'cli-test1234', + machineId: 'machine-abc', + hostname: 'test-host', + ip: '192.168.1.10', + tcpPort: 7100, + role: 'main' as const, + } + + beforeEach(() => { + mockSocket.on.mockClear() + mockSocket.bind.mockClear() + mockSocket.send.mockClear() + mockSocket.close.mockClear() + mockSocket.addMembership.mockClear() + mockSocket.dropMembership.mockClear() + beacon = new LanBeacon(announceData) + }) + + afterEach(() => { + beacon.stop() + }) + + test('start initializes socket and sends first announce', () => { + beacon.start() + expect(mockSocket.bind).toHaveBeenCalledTimes(1) + expect(mockSocket.addMembership).toHaveBeenCalledWith( + '224.0.71.67', + '192.168.1.10', + ) + expect(mockSocket.setMulticastTTL).toHaveBeenCalledWith(1) + // First announce sent immediately + expect(mockSocket.send).toHaveBeenCalled() + }) + + test('getPeers returns empty map initially', () => { + beacon.start() + expect(beacon.getPeers().size).toBe(0) + }) + + test('stop closes socket and clears peers', () => { + beacon.start() + beacon.stop() + expect(mockSocket.close).toHaveBeenCalled() + }) + + test('processes incoming announce from different peer', () => { + beacon.start() + + const messageHandler = getMessageHandler() + if (!messageHandler) return + + const peerAnnounce = JSON.stringify({ + proto: 'claude-pipe-v1', + pipeName: 'cli-peer5678', + machineId: 'machine-xyz', + hostname: 'peer-host', + ip: '192.168.1.20', + tcpPort: 7102, + role: 'sub', + ts: Date.now(), + }) + + let discoveredPeer: any = null + beacon.on('peer-discovered', (peer: any) => { + discoveredPeer = peer + }) + + messageHandler(Buffer.from(peerAnnounce), { + address: '192.168.1.20', + port: 7101, + }) + + expect(beacon.getPeers().size).toBe(1) + expect(beacon.getPeers().has('cli-peer5678')).toBe(true) + expect(discoveredPeer).not.toBeNull() + expect(discoveredPeer.pipeName).toBe('cli-peer5678') + }) + + test('ignores self-announces', () => { + beacon.start() + + const messageHandler = getMessageHandler() + if (!messageHandler) return + + const selfAnnounce = JSON.stringify({ + proto: 'claude-pipe-v1', + pipeName: 'cli-test1234', // same as our pipeName + machineId: 'machine-abc', + hostname: 'test-host', + ip: '192.168.1.10', + tcpPort: 7100, + role: 'main', + ts: Date.now(), + }) + + messageHandler(Buffer.from(selfAnnounce), { + address: '192.168.1.10', + port: 7101, + }) + expect(beacon.getPeers().size).toBe(0) + }) + + test('ignores non-claude-pipe protocol messages', () => { + beacon.start() + + const messageHandler = getMessageHandler() + if (!messageHandler) return + + const foreignMessage = JSON.stringify({ + proto: 'something-else', + pipeName: 'cli-foreign', + }) + + messageHandler(Buffer.from(foreignMessage), { + address: '192.168.1.30', + port: 7101, + }) + expect(beacon.getPeers().size).toBe(0) + }) + + test('updateAnnounce changes role', () => { + beacon.updateAnnounce({ role: 'sub' }) + beacon.start() + // The send call should include the updated role + const sendCalls = mockSocket.send.mock.calls as unknown as [Buffer, ...unknown[]][] + const sendCall = sendCalls[0] + if (sendCall) { + const payload = JSON.parse(sendCall[0].toString()) + expect(payload.role).toBe('sub') + } + }) +}) diff --git a/src/utils/__tests__/path.test.ts b/src/utils/__tests__/path.test.ts index 37ed257f3..532cf3e88 100644 --- a/src/utils/__tests__/path.test.ts +++ b/src/utils/__tests__/path.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test"; +import { tmpdir } from "os"; import { resolve } from "path"; +import { + getFsImplementation, + setFsImplementation, + setOriginalFsImplementation, + type FsOperations, +} from "../fsOperations"; import { containsPathTraversal, expandPath, @@ -176,24 +183,67 @@ describe("toRelativePath", () => { describe("getDirectoryForPath", () => { test("returns the path itself when given an existing directory", () => { - // The src directory is guaranteed to exist in this repo - const dir = resolve(process.cwd(), "src"); - const result = getDirectoryForPath(dir); - expect(result).toBe(dir); + setOriginalFsImplementation(); + const dir = resolve(tmpdir(), "ccb-existing-dir"); + const baseFs = getFsImplementation(); + setFsImplementation({ + ...baseFs, + statSync: ((path: string) => { + if (path === dir) { + return { isDirectory: () => true } as any; + } + return baseFs.statSync(path); + }) as FsOperations["statSync"], + }); + try { + const result = getDirectoryForPath(dir); + expect(result).toBe(dir); + } finally { + setOriginalFsImplementation(); + } }); test("returns parent directory for a known file", () => { - // package.json is at the repo root - const file = resolve(process.cwd(), "package.json"); - const expectedParent = process.cwd(); - const result = getDirectoryForPath(file); - expect(result).toBe(expectedParent); + setOriginalFsImplementation(); + const expectedParent = resolve(tmpdir(), "ccb-file-parent"); + const file = resolve(expectedParent, "sample.txt"); + const baseFs = getFsImplementation(); + setFsImplementation({ + ...baseFs, + statSync: ((path: string) => { + if (path === file) { + return { isDirectory: () => false } as any; + } + return baseFs.statSync(path); + }) as FsOperations["statSync"], + }); + try { + const result = getDirectoryForPath(file); + expect(result).toBe(expectedParent); + } finally { + setOriginalFsImplementation(); + } }); test("returns parent directory for a non-existent path", () => { - const nonExistent = resolve(process.cwd(), "does-not-exist-xyz123.ts"); - const expectedParent = process.cwd(); - const result = getDirectoryForPath(nonExistent); - expect(result).toBe(expectedParent); + setOriginalFsImplementation(); + const expectedParent = resolve(tmpdir(), "ccb-missing-parent"); + const nonExistent = resolve(expectedParent, "does-not-exist-xyz123.ts"); + const baseFs = getFsImplementation(); + setFsImplementation({ + ...baseFs, + statSync: ((path: string) => { + if (path === nonExistent) { + throw new Error("ENOENT"); + } + return baseFs.statSync(path); + }) as FsOperations["statSync"], + }); + try { + const result = getDirectoryForPath(nonExistent); + expect(result).toBe(expectedParent); + } finally { + setOriginalFsImplementation(); + } }); }); diff --git a/src/utils/__tests__/peerAddress.test.ts b/src/utils/__tests__/peerAddress.test.ts new file mode 100644 index 000000000..3e7d80850 --- /dev/null +++ b/src/utils/__tests__/peerAddress.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect } from 'bun:test' +import { parseAddress, parseTcpTarget } from '../peerAddress.js' + +describe('parseAddress', () => { + test('uds: scheme', () => { + expect(parseAddress('uds:/tmp/test.sock')).toEqual({ + scheme: 'uds', + target: '/tmp/test.sock', + }) + }) + + test('bridge: scheme', () => { + expect(parseAddress('bridge:session-123')).toEqual({ + scheme: 'bridge', + target: 'session-123', + }) + }) + + test('tcp: scheme', () => { + expect(parseAddress('tcp:192.168.1.20:7100')).toEqual({ + scheme: 'tcp', + target: '192.168.1.20:7100', + }) + }) + + test('bare path routes to uds', () => { + expect(parseAddress('/var/run/test.sock')).toEqual({ + scheme: 'uds', + target: '/var/run/test.sock', + }) + }) + + test('other falls through', () => { + expect(parseAddress('teammate-name')).toEqual({ + scheme: 'other', + target: 'teammate-name', + }) + }) +}) + +describe('parseTcpTarget', () => { + test('valid host:port', () => { + expect(parseTcpTarget('192.168.1.20:7100')).toEqual({ + host: '192.168.1.20', + port: 7100, + }) + }) + + test('hostname:port', () => { + expect(parseTcpTarget('my-host:8080')).toEqual({ + host: 'my-host', + port: 8080, + }) + }) + + test('invalid format returns null', () => { + expect(parseTcpTarget('no-port')).toBeNull() + expect(parseTcpTarget('')).toBeNull() + }) +}) diff --git a/src/utils/__tests__/pipePermissionRelay.test.ts b/src/utils/__tests__/pipePermissionRelay.test.ts new file mode 100644 index 000000000..a659351d4 --- /dev/null +++ b/src/utils/__tests__/pipePermissionRelay.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + clearPendingPipePermissions, + resolvePipePermissionResponse, + tryRelayPipePermissionRequest, + setPipeRelay, +} from '../pipePermissionRelay.js' + +afterEach(() => { + setPipeRelay(null) + clearPendingPipePermissions() +}) + +function makeToolUseConfirm(overrides: Record = {}) { + return { + assistantMessage: { message: { id: 'msg-1' } }, + tool: { name: 'Bash' }, + description: 'Run command', + input: { command: 'echo hello' }, + toolUseID: 'tool-1', + permissionResult: { behavior: 'ask', message: 'Approve?' }, + permissionPromptStartTimeMs: 1, + ...overrides, + } as any +} + +describe('pipe permission relay', () => { + test('serializes permission requests through the active pipe sender', () => { + const sent: any[] = [] + setPipeRelay((message: any) => { + sent.push(message) + }) + + const requestId = tryRelayPipePermissionRequest( + makeToolUseConfirm(), + () => {}, + ) + + expect(requestId).toBeString() + expect(sent).toHaveLength(1) + expect(sent[0].type).toBe('permission_request') + const payload = JSON.parse(sent[0].data) + expect(payload.requestId).toBe(requestId) + expect(payload.toolName).toBe('Bash') + expect(payload.input).toEqual({ command: 'echo hello' }) + }) + + test('dispatches permission responses to the pending request handler', () => { + setPipeRelay(() => {}) + const seen: any[] = [] + const requestId = tryRelayPipePermissionRequest( + makeToolUseConfirm(), + payload => { + seen.push(payload) + }, + ) + + expect(requestId).toBeString() + const resolved = resolvePipePermissionResponse({ + requestId: requestId!, + behavior: 'allow', + updatedInput: { command: 'echo ok' }, + permissionUpdates: [], + }) + + expect(resolved).toBe(true) + expect(seen).toEqual([ + { + requestId, + behavior: 'allow', + updatedInput: { command: 'echo ok' }, + permissionUpdates: [], + }, + ]) + }) +}) diff --git a/src/utils/__tests__/pipeTransport.test.ts b/src/utils/__tests__/pipeTransport.test.ts new file mode 100644 index 000000000..f8f7d3e99 --- /dev/null +++ b/src/utils/__tests__/pipeTransport.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' +import { + getPipeDisplayRole, + isPipeControlled, + type PipeIpcState, +} from '../pipeTransport.js' + +function makePipeState(overrides: Partial = {}): PipeIpcState { + return { + role: 'main', + subIndex: null, + displayRole: 'main', + serverName: 'cli-main', + attachedBy: null, + localIp: null, + hostname: null, + machineId: null, + mac: null, + statusVisible: false, + selectorOpen: false, + selectedPipes: [], + routeMode: 'selected', + slaves: {}, + discoveredPipes: [], + ...overrides, + } +} + +describe('pipe transport role helpers', () => { + test('keeps controlled subs on their sub-N display role', () => { + const state = makePipeState({ + role: 'sub', + subIndex: 2, + displayRole: 'slave', + attachedBy: 'cli-master', + }) + + expect(isPipeControlled(state)).toBe(true) + expect(getPipeDisplayRole(state)).toBe('sub-2') + }) + + test('preserves master and main display roles', () => { + expect(getPipeDisplayRole(makePipeState())).toBe('main') + expect( + getPipeDisplayRole( + makePipeState({ + role: 'master', + displayRole: 'main', + }), + ), + ).toBe('master') + }) +}) diff --git a/src/utils/__tests__/truncate.test.ts b/src/utils/__tests__/truncate.test.ts index e63ebb6a0..a1335ce87 100644 --- a/src/utils/__tests__/truncate.test.ts +++ b/src/utils/__tests__/truncate.test.ts @@ -1,4 +1,26 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, mock, test } from "bun:test"; + +mock.module("src/ink/stringWidth.js", () => ({ + stringWidth: (str: string) => { + let width = 0; + for (const char of str) { + const code = char.codePointAt(0)!; + if ( + (code >= 0x4e00 && code <= 0x9fff) || + (code >= 0x3000 && code <= 0x303f) || + (code >= 0xff01 && code <= 0xff60) || + (code >= 0xf900 && code <= 0xfaff) + ) { + width += 2; + } else if (code >= 0x1f300 && code <= 0x1faff) { + width += 2; + } else if (code > 0) { + width += 1; + } + } + return width; + }, +})); import { truncatePathMiddle, truncateToWidth, diff --git a/src/utils/claudemd.ts b/src/utils/claudemd.ts index 5ea8ab6d7..1a9f5202d 100644 --- a/src/utils/claudemd.ts +++ b/src/utils/claudemd.ts @@ -1434,6 +1434,7 @@ export async function shouldShowClaudeMdExternalIncludesWarning(): Promise | null = null + private cleanupTimer: ReturnType | null = null + private peers: Map = new Map() + private announce: LanAnnounce + + constructor(announce: Omit) { + super() + this.announce = { + ...announce, + proto: 'claude-pipe-v1', + ts: Date.now(), + } + } + + /** + * Start broadcasting announcements and listening for peers. + */ + start(): void { + if (this.socket) return + + try { + this.socket = createSocket({ type: 'udp4', reuseAddr: true }) + + this.socket.on('error', err => { + logError(err) + // Non-fatal — multicast may not be supported on this network + }) + + this.socket.on('message', (buf, rinfo) => { + try { + const msg = JSON.parse(buf.toString()) as LanAnnounce + if (msg.proto !== 'claude-pipe-v1') return + if (msg.pipeName === this.announce.pipeName) return // ignore self + + const isNew = !this.peers.has(msg.pipeName) + this.peers.set(msg.pipeName, { ...msg, ts: Date.now() }) + + if (isNew) { + this.emit('peer-discovered', msg) + } + } catch { + // Malformed packet — ignore + } + }) + + this.socket.bind(MULTICAST_PORT, () => { + try { + // Specify the local LAN interface for multicast membership. + // Without this, Windows may bind to a WSL/Docker virtual adapter + // and multicast packets never reach the real LAN. + const localIp = this.announce.ip + this.socket!.addMembership(MULTICAST_GROUP, localIp) + this.socket!.setMulticastInterface(localIp) + this.socket!.setMulticastTTL(1) // link-local only + this.socket!.setBroadcast(true) + } catch (err) { + logError(err as Error) + } + + // Start announce + cleanup timers after socket is fully bound + this.announceTimer = setInterval( + () => this.sendAnnounce(), + ANNOUNCE_INTERVAL_MS, + ) + // Send first announce immediately + this.sendAnnounce() + + // Periodic cleanup of stale peers + this.cleanupTimer = setInterval( + () => this.cleanupStalePeers(), + PEER_TIMEOUT_MS / 2, + ) + }) + } catch (err) { + logError(err as Error) + } + } + + /** + * Stop broadcasting and close the socket. + */ + stop(): void { + if (this.announceTimer) { + clearInterval(this.announceTimer) + this.announceTimer = null + } + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer) + this.cleanupTimer = null + } + if (this.socket) { + try { + this.socket.dropMembership(MULTICAST_GROUP) + } catch { + // May fail if socket already closed + } + this.socket.close() + this.socket = null + } + this.peers.clear() + } + + /** + * Get all currently known peers (excluding self). + */ + getPeers(): Map { + return new Map(this.peers) + } + + /** + * Update the announce data (e.g., when role changes). + */ + updateAnnounce(partial: Partial>): void { + this.announce = { ...this.announce, ...partial } + } + + private sendAnnounce(): void { + if (!this.socket) return + try { + const payload = Buffer.from( + JSON.stringify({ ...this.announce, ts: Date.now() }), + ) + this.socket.send( + payload, + 0, + payload.length, + MULTICAST_PORT, + MULTICAST_GROUP, + ) + } catch { + // Send failure — non-fatal + } + } + + private cleanupStalePeers(): void { + const now = Date.now() + for (const [name, peer] of this.peers) { + if (now - peer.ts > PEER_TIMEOUT_MS) { + this.peers.delete(name) + this.emit('peer-lost', name) + } + } + } +} diff --git a/src/utils/ndjsonFramer.ts b/src/utils/ndjsonFramer.ts new file mode 100644 index 000000000..968ee5217 --- /dev/null +++ b/src/utils/ndjsonFramer.ts @@ -0,0 +1,39 @@ +/** + * Shared NDJSON (Newline-Delimited JSON) socket framing. + * + * Accumulates incoming data chunks, splits on newlines, and emits + * parsed JSON objects. Used by both pipeTransport (UDS+TCP) and + * udsMessaging to avoid duplicating the same buffer logic. + */ +import type { Socket } from 'net' + +/** + * Attach an NDJSON framer to a socket. Calls `onMessage` for each + * complete JSON line received. Malformed lines are silently skipped. + * + * @param parse - Optional custom JSON parser (defaults to JSON.parse). + * Useful when the caller uses a wrapped parser like jsonParse + * from slowOperations. + */ +export function attachNdjsonFramer( + socket: Socket, + onMessage: (msg: T) => void, + parse: (text: string) => T = text => JSON.parse(text) as T, +): void { + let buffer = '' + + socket.on('data', (chunk: Buffer) => { + buffer += chunk.toString() + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (!line.trim()) continue + try { + onMessage(parse(line)) + } catch { + // Malformed JSON — skip + } + } + }) +} diff --git a/src/utils/path.ts b/src/utils/path.ts index a4d33d8c1..bc323e2bd 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -1,5 +1,5 @@ import { homedir } from 'os' -import { dirname, isAbsolute, join, normalize, relative, resolve } from 'path' +import { dirname, isAbsolute, join, normalize, posix, relative, resolve } from 'path' import { getCwd } from './cwd.js' import { getFsImplementation } from './fsOperations.js' import { getPlatform } from './platform.js' @@ -49,9 +49,15 @@ export function expandPath(path: string, baseDir?: string): string { throw new Error('Path contains null bytes') } + const isSyntheticPosixPath = (value: string): boolean => + value.includes('/') && !value.includes('\\') && !/^[A-Za-z]:/.test(value) + // Handle empty or whitespace-only paths const trimmedPath = path.trim() if (!trimmedPath) { + if (getPlatform() === 'windows' && isSyntheticPosixPath(actualBaseDir)) { + return posix.normalize(actualBaseDir).normalize('NFC') + } return normalize(actualBaseDir).normalize('NFC') } @@ -77,10 +83,21 @@ export function expandPath(path: string, baseDir?: string): string { // Handle absolute paths if (isAbsolute(processedPath)) { + if (getPlatform() === 'windows' && isSyntheticPosixPath(processedPath)) { + return posix.normalize(processedPath).normalize('NFC') + } return normalize(processedPath).normalize('NFC') } // Handle relative paths + if ( + getPlatform() === 'windows' && + isSyntheticPosixPath(actualBaseDir) && + !/^[A-Za-z]:/.test(processedPath) && + !processedPath.startsWith('\\\\') + ) { + return posix.resolve(actualBaseDir, processedPath).normalize('NFC') + } return resolve(actualBaseDir, processedPath).normalize('NFC') } diff --git a/src/utils/peerAddress.ts b/src/utils/peerAddress.ts index ff465bef4..cf93a3786 100644 --- a/src/utils/peerAddress.ts +++ b/src/utils/peerAddress.ts @@ -6,11 +6,12 @@ /** Parse a URI-style address into scheme + target. */ export function parseAddress(to: string): { - scheme: 'uds' | 'bridge' | 'other' + scheme: 'uds' | 'bridge' | 'tcp' | 'other' target: string } { if (to.startsWith('uds:')) return { scheme: 'uds', target: to.slice(4) } if (to.startsWith('bridge:')) return { scheme: 'bridge', target: to.slice(7) } + if (to.startsWith('tcp:')) return { scheme: 'tcp', target: to.slice(4) } // Legacy: old-code UDS senders emit bare socket paths in from=; route them // through the UDS branch so replies aren't silently dropped into teammate // routing. (No bare-session-ID fallback — bridge messaging is new enough @@ -19,3 +20,14 @@ export function parseAddress(to: string): { if (to.startsWith('/')) return { scheme: 'uds', target: to } return { scheme: 'other', target: to } } + +/** Parse a tcp: target string into host and port. */ +export function parseTcpTarget( + target: string, +): { host: string; port: number } | null { + const match = target.match(/^([^:]+):(\d+)$/) + if (!match) return null + const port = parseInt(match[2]!, 10) + if (port < 1 || port > 65535) return null + return { host: match[1]!, port } +} diff --git a/src/utils/pipePermissionRelay.ts b/src/utils/pipePermissionRelay.ts new file mode 100644 index 000000000..e02638491 --- /dev/null +++ b/src/utils/pipePermissionRelay.ts @@ -0,0 +1,156 @@ +import { randomUUID } from 'crypto' +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' +import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js' +import type { + PipeMessage, + PipePermissionRequestPayload, + PipePermissionResponsePayload, +} from './pipeTransport.js' +import type { PermissionUpdate } from './permissions/PermissionUpdateSchema.js' + +type PendingPipePermission = { + onResponse: (payload: PipePermissionResponsePayload) => void +} + +const pendingPipePermissions = new Map() + +// Module-level singleton for the relay function to master. +// Replaces the old (globalThis as any).__pipeSendToMaster pattern. +type PipeRelayFn = (message: PipeMessage) => void +let _pipeRelay: PipeRelayFn | null = null + +export function setPipeRelay(fn: PipeRelayFn | null): void { + _pipeRelay = fn +} + +export function getPipeRelay(): PipeRelayFn | null { + return _pipeRelay +} + +function getPipeSender(): + | ((message: PipeMessage) => void) + | null { + return _pipeRelay ?? null +} + +export function tryRelayPipePermissionRequest( + toolUseConfirm: ToolUseConfirm, + onResponse: (payload: PipePermissionResponsePayload) => void, +): string | null { + const send = getPipeSender() + if (!send) return null + + const requestId = randomUUID() + const payload: PipePermissionRequestPayload = { + requestId, + toolName: toolUseConfirm.tool.name, + toolUseID: toolUseConfirm.toolUseID, + description: toolUseConfirm.description, + input: toolUseConfirm.input as Record, + permissionResult: toolUseConfirm.permissionResult, + permissionPromptStartTimeMs: toolUseConfirm.permissionPromptStartTimeMs, + } + + pendingPipePermissions.set(requestId, { onResponse }) + send({ type: 'permission_request', data: JSON.stringify(payload) }) + return requestId +} + +export function resolvePipePermissionResponse( + payload: PipePermissionResponsePayload, +): boolean { + const pending = pendingPipePermissions.get(payload.requestId) + if (!pending) return false + pendingPipePermissions.delete(payload.requestId) + pending.onResponse(payload) + return true +} + +export function cancelPipePermissionRequest( + requestId: string, + reason?: string, +): boolean { + const pending = pendingPipePermissions.get(requestId) + if (!pending) return false + pendingPipePermissions.delete(requestId) + pending.onResponse({ + requestId, + behavior: 'deny', + feedback: reason ?? 'Permission request was cancelled by main.', + }) + return true +} + +export function forgetPipePermissionRequest( + requestId: string | null | undefined, +): void { + if (!requestId) return + pendingPipePermissions.delete(requestId) +} + +export function notifyPipePermissionCancel( + requestId: string | null | undefined, + reason?: string, +): void { + if (!requestId) return + const send = getPipeSender() + if (!send) return + send({ + type: 'permission_cancel', + data: JSON.stringify({ requestId, reason }), + }) +} + +export function clearPendingPipePermissions( + reason = 'Pipe permission relay was disconnected.', +): void { + for (const requestId of [...pendingPipePermissions.keys()]) { + cancelPipePermissionRequest(requestId, reason) + } +} + +export function makePipePermissionResponsePayload( + requestId: string, + behavior: 'allow', + updatedInput: Record, + permissionUpdates: PermissionUpdate[], + feedback?: string, + contentBlocks?: ContentBlockParam[], +): PipePermissionResponsePayload +export function makePipePermissionResponsePayload( + requestId: string, + behavior: 'deny', + feedback?: string, + contentBlocks?: ContentBlockParam[], +): PipePermissionResponsePayload +export function makePipePermissionResponsePayload( + requestId: string, + behavior: 'allow' | 'deny', + updatedInputOrFeedback?: Record | string, + permissionUpdatesOrContentBlocks?: PermissionUpdate[] | ContentBlockParam[], + feedback?: string, + contentBlocks?: ContentBlockParam[], +): PipePermissionResponsePayload { + if (behavior === 'allow') { + return { + requestId, + behavior, + updatedInput: + (updatedInputOrFeedback as Record | undefined) ?? {}, + permissionUpdates: + (permissionUpdatesOrContentBlocks as PermissionUpdate[] | undefined) ?? + [], + feedback, + contentBlocks, + } + } + + return { + requestId, + behavior, + feedback: updatedInputOrFeedback as string | undefined, + contentBlocks: permissionUpdatesOrContentBlocks as + | ContentBlockParam[] + | undefined, + } +} diff --git a/src/utils/pipeRegistry.ts b/src/utils/pipeRegistry.ts new file mode 100644 index 000000000..8e5554f8f --- /dev/null +++ b/src/utils/pipeRegistry.ts @@ -0,0 +1,521 @@ +/** + * Pipe Registry — central registry for multi-instance pipe coordination. + * + * Manages a shared registry.json that tracks all CLI instances (main + subs). + * Main role is bound to machineId (OS-level stable fingerprint), not to + * instance startup order. + * + * File locking prevents race conditions when multiple instances start + * simultaneously. + */ +import { readFile, writeFile, unlink, mkdir } from 'fs/promises' +import { join } from 'path' +import { createHash } from 'crypto' +import { getClaudeConfigHomeDir } from './envUtils.js' +import { isPipeAlive, getPipesDir } from './pipeTransport.js' +import type { TcpEndpoint } from './pipeTransport.js' +import type { LanAnnounce } from './lanBeacon.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface PipeRegistryEntry { + id: string + pid: number + machineId: string + startedAt: number + ip: string + mac: string + hostname: string + pipeName: string + tcpPort?: number + lanVisible?: boolean +} + +export interface PipeRegistrySub extends PipeRegistryEntry { + subIndex: number + boundToMain: string | null +} + +export interface PipeRegistry { + version: number + mainMachineId: string | null + main: PipeRegistryEntry | null + subs: PipeRegistrySub[] +} + +export type DetermineRoleResult = + | { role: 'main' } + | { role: 'main-recover' } + | { role: 'sub'; subIndex: number } + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +function getRegistryPath(): string { + return join(getPipesDir(), 'registry.json') +} + +function getLockPath(): string { + return join(getPipesDir(), 'registry.lock') +} + +// --------------------------------------------------------------------------- +// Machine ID — stable OS-level fingerprint +// --------------------------------------------------------------------------- + +let _cachedMachineId: string | null = null + +export async function getMachineId(): Promise { + if (_cachedMachineId) return _cachedMachineId + + let raw: string | null = null + + if (process.platform === 'win32') { + // Windows: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid (async) + try { + const { execFile } = + require('child_process') as typeof import('child_process') + raw = await new Promise((resolve, reject) => { + execFile( + 'reg', + [ + 'query', + 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', + '/v', + 'MachineGuid', + ], + { timeout: 3000 }, + (err, stdout) => (err ? reject(err) : resolve(stdout)), + ) + }) + const match = raw.match(/MachineGuid\s+REG_SZ\s+(\S+)/) + if (match) { + _cachedMachineId = match[1]! + return _cachedMachineId + } + } catch {} + } else if (process.platform === 'linux') { + // Linux: /etc/machine-id (already async) + try { + raw = await readFile('/etc/machine-id', 'utf8') + raw = raw.trim() + if (raw) { + _cachedMachineId = raw + return _cachedMachineId + } + } catch {} + } else if (process.platform === 'darwin') { + // macOS: IOPlatformSerialNumber (async) + try { + const { execFile } = + require('child_process') as typeof import('child_process') + raw = await new Promise((resolve, reject) => { + execFile( + 'bash', + [ + '-c', + 'ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformSerialNumber', + ], + { timeout: 3000 }, + (err, stdout) => (err ? reject(err) : resolve(stdout)), + ) + }) + const match = raw.match(/"IOPlatformSerialNumber"\s*=\s*"(\S+)"/) + if (match) { + _cachedMachineId = match[1]! + return _cachedMachineId + } + } catch {} + } + + // Fallback: hash hostname + MAC addresses + _cachedMachineId = generateFallbackId() + return _cachedMachineId +} + +function generateFallbackId(): string { + const os = require('os') as typeof import('os') + const nets = os.networkInterfaces() + const macs: string[] = [] + for (const name of Object.keys(nets)) { + for (const net of nets[name] ?? []) { + if (net.mac && net.mac !== '00:00:00:00:00:00') { + macs.push(net.mac) + } + } + } + macs.sort() + const raw = `${os.hostname()}:${macs.join(',')}` + return createHash('sha256').update(raw).digest('hex').slice(0, 32) +} + +export function getMacAddress(): string { + const os = require('os') as typeof import('os') + const nets = os.networkInterfaces() + for (const name of Object.keys(nets)) { + for (const net of nets[name] ?? []) { + if ( + net.family === 'IPv4' && + !net.internal && + net.mac && + net.mac !== '00:00:00:00:00:00' + ) { + return net.mac + } + } + } + return '00:00:00:00:00:00' +} + +// --------------------------------------------------------------------------- +// File lock — simple .lock file with timeout +// --------------------------------------------------------------------------- + +const LOCK_TIMEOUT_MS = 2000 +const LOCK_RETRY_MS = 50 + +async function acquireLock(): Promise { + await mkdir(getPipesDir(), { recursive: true }) + const lockPath = getLockPath() + const deadline = Date.now() + LOCK_TIMEOUT_MS + + while (Date.now() < deadline) { + try { + // O_CREAT | O_EXCL — fails if file exists + await writeFile(lockPath, String(process.pid), { flag: 'wx' }) + return // Lock acquired + } catch (err: any) { + if (err.code === 'EEXIST') { + // Check if lock is stale (older than LOCK_TIMEOUT_MS) + try { + const content = await readFile(lockPath, 'utf8') + const lockPid = parseInt(content, 10) + if (lockPid && lockPid !== process.pid) { + try { + process.kill(lockPid, 0) // Check if process alive + } catch { + // Process dead — remove stale lock + await unlink(lockPath).catch(() => {}) + continue + } + } + } catch { + // Can't read lock file — try to remove + await unlink(lockPath).catch(() => {}) + continue + } + await new Promise(r => setTimeout(r, LOCK_RETRY_MS)) + } else { + throw err + } + } + } + + // Timeout — force remove and retry once + await unlink(getLockPath()).catch(() => {}) + await writeFile(lockPath, String(process.pid), { flag: 'wx' }).catch(() => {}) +} + +async function releaseLock(): Promise { + await unlink(getLockPath()).catch(() => {}) +} + +// --------------------------------------------------------------------------- +// Registry CRUD +// --------------------------------------------------------------------------- + +const EMPTY_REGISTRY: PipeRegistry = { + version: 1, + mainMachineId: null, + main: null, + subs: [], +} + +export async function readRegistry(): Promise { + try { + const content = await readFile(getRegistryPath(), 'utf8') + const parsed = JSON.parse(content) as PipeRegistry + if (parsed.version !== 1) return { ...EMPTY_REGISTRY } + return parsed + } catch { + return { ...EMPTY_REGISTRY } + } +} + +export async function writeRegistry(registry: PipeRegistry): Promise { + await mkdir(getPipesDir(), { recursive: true }) + await writeFile(getRegistryPath(), JSON.stringify(registry, null, 2)) +} + +// --------------------------------------------------------------------------- +// Role management (all operations are lock-protected) +// --------------------------------------------------------------------------- + +export async function determineRole( + machineId: string, +): Promise { + await acquireLock() + try { + const registry = await readRegistry() + + // Case A: no main registered + if (!registry.mainMachineId || !registry.main) { + return { role: 'main' } + } + + // Case B: this machine is the main machine + if (registry.mainMachineId === machineId) { + if (registry.main && (await isPipeAlive(registry.main.pipeName, 1000))) { + // Main instance is alive → this is a same-machine sub + const subIndex = registry.subs.length + 1 + return { role: 'sub', subIndex } + } + // Main instance is dead → recover main on same machine + return { role: 'main-recover' } + } + + // Case C: different machine + const subIndex = registry.subs.length + 1 + return { role: 'sub', subIndex } + } finally { + await releaseLock() + } +} + +export async function registerAsMain(entry: PipeRegistryEntry): Promise { + await acquireLock() + try { + const registry = await readRegistry() + registry.mainMachineId = entry.machineId + registry.main = entry + await writeRegistry(registry) + } finally { + await releaseLock() + } +} + +export async function registerAsSub( + entry: PipeRegistryEntry, + subIndex: number, +): Promise { + await acquireLock() + try { + const registry = await readRegistry() + // Remove existing entry with same id (re-registration) + registry.subs = registry.subs.filter(s => s.id !== entry.id) + registry.subs.push({ + ...entry, + subIndex, + boundToMain: registry.main?.id ?? null, + }) + await writeRegistry(registry) + } finally { + await releaseLock() + } +} + +export async function unregister(id: string): Promise { + await acquireLock() + try { + const registry = await readRegistry() + if (registry.main?.id === id) { + registry.main = null + // Don't clear mainMachineId — same machine can recover + } + registry.subs = registry.subs.filter(s => s.id !== id) + await writeRegistry(registry) + } finally { + await releaseLock() + } +} + +export async function revertToIndependent(id: string): Promise { + await acquireLock() + try { + const registry = await readRegistry() + const sub = registry.subs.find(s => s.id === id) + if (sub) { + sub.boundToMain = null + } + await writeRegistry(registry) + } finally { + await releaseLock() + } +} + +export async function claimMain( + newMachineId: string, + entry: PipeRegistryEntry, +): Promise { + await acquireLock() + try { + const registry = await readRegistry() + registry.mainMachineId = newMachineId + registry.main = entry + // All existing subs become bound to new main + for (const sub of registry.subs) { + sub.boundToMain = entry.id + } + await writeRegistry(registry) + } finally { + await releaseLock() + } +} + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +export async function isMainAlive(): Promise { + const registry = await readRegistry() + if (!registry.main) return false + return isPipeAlive(registry.main.pipeName, 1000) +} + +export function isMainMachine( + machineId: string, + registry: PipeRegistry, +): boolean { + return registry.mainMachineId === machineId +} + +export async function getAliveSubs(): Promise { + const registry = await readRegistry() + const results = await Promise.all( + registry.subs.map(sub => + isPipeAlive(sub.pipeName, 1000).then(alive => (alive ? sub : null)), + ), + ) + return results.filter((s): s is PipeRegistrySub => s !== null) +} + +export async function cleanupStaleEntries(): Promise { + // Phase 1: Probe all entries in parallel WITHOUT holding the lock + const registry = await readRegistry() + const [mainAlive, subResults] = await Promise.all([ + registry.main + ? isPipeAlive(registry.main.pipeName, 1000) + : Promise.resolve(true), + Promise.all( + registry.subs.map(sub => + isPipeAlive(sub.pipeName, 1000).then(alive => ({ sub, alive })), + ), + ), + ]) + + const needsWrite = !mainAlive || subResults.some(r => !r.alive) + if (!needsWrite) return + + // Phase 2: Briefly hold lock to apply changes + await acquireLock() + try { + const fresh = await readRegistry() + let changed = false + + if (!mainAlive && fresh.main?.pipeName === registry.main?.pipeName) { + fresh.main = null + changed = true + } + + const deadNames = new Set( + subResults.filter(r => !r.alive).map(r => r.sub.pipeName), + ) + const aliveSubs = fresh.subs.filter(s => !deadNames.has(s.pipeName)) + if (aliveSubs.length !== fresh.subs.length) { + fresh.subs = aliveSubs + changed = true + } + + if (changed) { + await writeRegistry(fresh) + } + } finally { + await releaseLock() + } +} + +// --------------------------------------------------------------------------- +// LAN peer merging +// --------------------------------------------------------------------------- + +export type MergedPipeEntry = { + id: string + pipeName: string + role: string + machineId: string + ip: string + hostname: string + alive: boolean + source: 'local' | 'lan' + tcpEndpoint?: TcpEndpoint +} + +/** + * Merge local registry entries with LAN beacon-discovered peers. + * Local entries take precedence — LAN peers are only added if not + * already present in the local registry. + */ +export function mergeWithLanPeers( + registry: PipeRegistry, + lanPeers: Map, +): MergedPipeEntry[] { + const result: MergedPipeEntry[] = [] + const knownPipes = new Set() + + // Add main from local registry + if (registry.main) { + knownPipes.add(registry.main.pipeName) + result.push({ + id: registry.main.id, + pipeName: registry.main.pipeName, + role: 'main', + machineId: registry.main.machineId, + ip: registry.main.ip, + hostname: registry.main.hostname, + alive: true, // caller should verify + source: 'local', + tcpEndpoint: registry.main.tcpPort + ? { host: registry.main.ip, port: registry.main.tcpPort } + : undefined, + }) + } + + // Add subs from local registry + for (const sub of registry.subs) { + knownPipes.add(sub.pipeName) + result.push({ + id: sub.id, + pipeName: sub.pipeName, + role: `sub-${sub.subIndex}`, + machineId: sub.machineId, + ip: sub.ip, + hostname: sub.hostname, + alive: true, + source: 'local', + tcpEndpoint: sub.tcpPort + ? { host: sub.ip, port: sub.tcpPort } + : undefined, + }) + } + + // Add LAN peers not already in local registry + for (const [pipeName, peer] of lanPeers) { + if (knownPipes.has(pipeName)) continue + result.push({ + id: `lan-${pipeName}`, + pipeName, + role: peer.role, + machineId: peer.machineId, + ip: peer.ip, + hostname: peer.hostname, + alive: true, + source: 'lan', + tcpEndpoint: { host: peer.ip, port: peer.tcpPort }, + }) + } + + return result +} diff --git a/src/utils/pipeTransport.ts b/src/utils/pipeTransport.ts new file mode 100644 index 000000000..8f16baa25 --- /dev/null +++ b/src/utils/pipeTransport.ts @@ -0,0 +1,719 @@ +/** + * Named Pipe Transport - Unix domain socket IPC for CLI terminals + * + * Supports two modes: + * 1. Standalone: Two independent terminals chat via pipes + * 2. Master-Slave bridge: Master CLI attaches to Slave CLI, forwarding + * prompts and receiving streamed AI output back. + * + * Each CLI auto-creates a PipeServer at: + * ~/.claude/pipes/{session-short-id}.sock + * + * Protocol: newline-delimited JSON (NDJSON), one message per line. + */ + +import { createServer, createConnection, type Server, type Socket } from 'net' +import { mkdir, unlink, readdir, writeFile } from 'fs/promises' +import { join } from 'path' +import { EventEmitter } from 'events' +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' +import type { PermissionDecision } from '../types/permissions.js' +import type { PermissionUpdate } from './permissions/PermissionUpdateSchema.js' +import { getClaudeConfigHomeDir } from './envUtils.js' +import { logError } from './log.js' +import { attachNdjsonFramer } from './ndjsonFramer.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Message types exchanged over the pipe. + * + * Basic: ping, pong + * Control: attach_request, attach_accept, attach_reject, detach + * Data (M→S): prompt — master sends user input to slave + * Data (S→M): stream — slave streams AI output fragments + * tool_start — slave notifies tool execution start + * tool_result — slave notifies tool result + * done — slave signals turn complete + * error — either side reports an error + * Legacy: chat, cmd, result, exit — kept for backward compat + */ +export type PipeMessageType = + // Basic + | 'ping' + | 'pong' + // Control flow (master-slave bridge) + | 'attach_request' + | 'attach_accept' + | 'attach_reject' + | 'detach' + // Data flow (master → slave) + | 'prompt' + // Data flow (slave → master) + | 'prompt_ack' + | 'stream' + | 'tool_start' + | 'tool_result' + | 'done' + | 'error' + | 'permission_request' + | 'permission_response' + | 'permission_cancel' + // Legacy (standalone chat demo) + | 'chat' + | 'cmd' + | 'result' + | 'exit' + +export type PipeMessage = { + /** Discriminator */ + type: PipeMessageType + /** Payload (text, command output, prompt, stream fragment, etc.) */ + data?: string + /** Sender pipe name */ + from?: string + /** ISO timestamp */ + ts?: string + /** Additional metadata (tool name, error details, etc.) */ + meta?: Record +} + +export type PipePermissionRequestPayload = { + requestId: string + toolName: string + toolUseID: string + description: string + input: Record + permissionResult: PermissionDecision + permissionPromptStartTimeMs: number +} + +export type PipePermissionResponsePayload = + | { + requestId: string + behavior: 'allow' + updatedInput?: Record + permissionUpdates?: PermissionUpdate[] + feedback?: string + contentBlocks?: ContentBlockParam[] + } + | { + requestId: string + behavior: 'deny' + feedback?: string + contentBlocks?: ContentBlockParam[] + } + +export type PipePermissionCancelPayload = { + requestId: string + reason?: string +} + +export type PipeMessageHandler = ( + msg: PipeMessage, + reply: (msg: PipeMessage) => void, +) => void + +// --------------------------------------------------------------------------- +// TCP transport types +// --------------------------------------------------------------------------- + +export type PipeTransportMode = 'uds' | 'tcp' + +export type TcpEndpoint = { host: string; port: number } + +export type PipeServerOptions = { + enableTcp?: boolean + tcpPort?: number // 0 = random port +} + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +export function getPipesDir(): string { + return join(getClaudeConfigHomeDir(), 'pipes') +} + +export function getPipePath(name: string): string { + const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_') + if (process.platform === 'win32') { + return `\\\\.\\pipe\\claude-code-${safeName}` + } + return join(getPipesDir(), `${safeName}.sock`) +} + +async function ensurePipesDir(): Promise { + await mkdir(getPipesDir(), { recursive: true }) +} + +// --------------------------------------------------------------------------- +// Server (listener side) +// --------------------------------------------------------------------------- + +export class PipeServer extends EventEmitter { + private server: Server | null = null + private tcpServer: Server | null = null + private clients: Set = new Set() + private handlers: PipeMessageHandler[] = [] + private _tcpAddress: TcpEndpoint | null = null + readonly name: string + readonly socketPath: string + + constructor(name: string) { + super() + this.name = name + this.socketPath = getPipePath(name) + } + + /** TCP endpoint if TCP is enabled, null otherwise. */ + get tcpAddress(): TcpEndpoint | null { + return this._tcpAddress + } + + /** + * Shared handler for both UDS and TCP sockets. + */ + private setupSocket(socket: Socket): void { + this.clients.add(socket) + this.emit('connection', socket) + + attachNdjsonFramer(socket, msg => { + this.emit('message', msg) + const reply = (replyMsg: PipeMessage) => { + replyMsg.from = replyMsg.from ?? this.name + replyMsg.ts = replyMsg.ts ?? new Date().toISOString() + if (!socket.destroyed) { + socket.write(JSON.stringify(replyMsg) + '\n') + } + } + for (const handler of this.handlers) { + handler(msg, reply) + } + }) + + socket.on('close', () => { + this.clients.delete(socket) + this.emit('disconnect', socket) + }) + + socket.on('error', err => { + this.clients.delete(socket) + logError(err) + }) + } + + /** + * Start listening for incoming connections. + * @param options - Optional TCP configuration for LAN mode. + */ + async start(options?: PipeServerOptions): Promise { + await ensurePipesDir() + + // Clean up stale socket file (Unix only) + if (process.platform !== 'win32') { + try { + await unlink(this.socketPath) + } catch { + // File doesn't exist — fine + } + } + + // Start UDS/Named Pipe server + await new Promise((resolve, reject) => { + this.server = createServer(socket => this.setupSocket(socket)) + + this.server.on('error', reject) + + this.server.listen(this.socketPath, () => { + // On Windows, Named Pipes don't exist in the filesystem. + // Write a registry file so listPipes() can discover this server. + if (process.platform === 'win32') { + const regFile = join(getPipesDir(), `${this.name}.pipe`) + const { hostname } = require('os') as typeof import('os') + void writeFile( + regFile, + JSON.stringify({ + pid: process.pid, + ts: Date.now(), + ip: getLocalIp(), + hostname: hostname(), + }), + ).catch(() => {}) + } + resolve() + }) + }) + + // Optionally start TCP server for LAN connectivity + if (options?.enableTcp) { + await this.startTcpServer(options.tcpPort ?? 0) + } + } + + /** + * Start TCP listener for LAN peers. + */ + private async startTcpServer(port: number): Promise { + return new Promise((resolve, reject) => { + this.tcpServer = createServer(socket => this.setupSocket(socket)) + this.tcpServer.on('error', reject) + this.tcpServer.listen(port, '0.0.0.0', () => { + const addr = this.tcpServer!.address() + if (addr && typeof addr === 'object') { + this._tcpAddress = { host: '0.0.0.0', port: addr.port } + } + resolve() + }) + }) + } + + /** + * Register a handler for incoming messages. + */ + onMessage(handler: PipeMessageHandler): void { + this.handlers.push(handler) + } + + /** + * Broadcast a message to all connected clients. + */ + broadcast(msg: PipeMessage): void { + msg.from = msg.from ?? this.name + msg.ts = msg.ts ?? new Date().toISOString() + const line = JSON.stringify(msg) + '\n' + for (const client of this.clients) { + if (!client.destroyed) { + client.write(line) + } + } + } + + /** + * Send to a specific socket (used for directed replies in attach flow). + */ + sendTo(socket: Socket, msg: PipeMessage): void { + msg.from = msg.from ?? this.name + msg.ts = msg.ts ?? new Date().toISOString() + if (!socket.destroyed) { + socket.write(JSON.stringify(msg) + '\n') + } + } + + get connectionCount(): number { + return this.clients.size + } + + async close(): Promise { + for (const client of this.clients) { + client.destroy() + } + this.clients.clear() + + // Close TCP server if running + if (this.tcpServer) { + await new Promise(resolve => { + this.tcpServer!.close(() => { + this.tcpServer = null + this._tcpAddress = null + resolve() + }) + }) + } + + return new Promise(resolve => { + if (!this.server) { + resolve() + return + } + this.server.close(() => { + this.server = null + if (process.platform === 'win32') { + // Remove the registry file + const regFile = join(getPipesDir(), `${this.name}.pipe`) + void unlink(regFile).catch(() => {}) + } else { + void unlink(this.socketPath).catch(() => {}) + } + resolve() + }) + }) + } +} + +// --------------------------------------------------------------------------- +// Client (connector side) +// --------------------------------------------------------------------------- + +export class PipeClient extends EventEmitter { + private socket: Socket | null = null + private handlers: PipeMessageHandler[] = [] + readonly targetName: string + readonly senderName: string + readonly socketPath: string + private tcpEndpoint: TcpEndpoint | null + + constructor( + targetName: string, + senderName?: string, + tcpEndpoint?: TcpEndpoint, + ) { + super() + this.targetName = targetName + this.senderName = senderName ?? `client-${process.pid}` + this.socketPath = getPipePath(targetName) + this.tcpEndpoint = tcpEndpoint ?? null + } + + /** + * Connect to a pipe server (UDS or TCP). + * When tcpEndpoint was provided in constructor, connects over TCP. + * Otherwise uses UDS with retry for socket file existence. + */ + async connect(timeoutMs: number = 5000): Promise { + if (this.tcpEndpoint) { + return this.connectTcp(timeoutMs) + } + return this.connectUds(timeoutMs) + } + + private async connectTcp(timeoutMs: number): Promise { + const { host, port } = this.tcpEndpoint! + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new Error( + `TCP connection to "${this.targetName}" at ${host}:${port} timed out after ${timeoutMs}ms`, + ), + ) + }, timeoutMs) + + const socket = createConnection({ host, port }, () => { + clearTimeout(timer) + this.socket = socket + this.setupSocketListeners(socket) + this.emit('connected') + resolve() + }) + + socket.on('error', err => { + clearTimeout(timer) + socket.destroy() + reject(err) + }) + }) + } + + private async connectUds(timeoutMs: number): Promise { + const { access } = await import('fs/promises') + const deadline = Date.now() + timeoutMs + const retryDelayMs = 300 + + // Wait for socket file to exist (Unix only) + if (process.platform !== 'win32') { + while (Date.now() < deadline) { + try { + await access(this.socketPath) + break + } catch { + if (Date.now() + retryDelayMs >= deadline) { + throw new Error( + `Pipe "${this.targetName}" not found at ${this.socketPath}. Is the server running?`, + ) + } + await new Promise(r => setTimeout(r, retryDelayMs)) + } + } + } + + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => { + reject( + new Error( + `Connection to pipe "${this.targetName}" timed out after ${timeoutMs}ms`, + ), + ) + }, + Math.max(deadline - Date.now(), 1000), + ) + + const socket = createConnection({ path: this.socketPath }, () => { + clearTimeout(timer) + this.socket = socket + this.setupSocketListeners(socket) + this.emit('connected') + resolve() + }) + + socket.on('error', err => { + clearTimeout(timer) + socket.destroy() + reject(err) + }) + }) + } + + private setupSocketListeners(socket: Socket): void { + attachNdjsonFramer(socket, msg => { + this.emit('message', msg) + const reply = (replyMsg: PipeMessage) => this.send(replyMsg) + for (const handler of this.handlers) { + handler(msg, reply) + } + }) + + socket.on('close', () => { + this.emit('disconnect') + }) + + socket.on('error', err => { + logError(err) + }) + } + + onMessage(handler: PipeMessageHandler): void { + this.handlers.push(handler) + } + + send(msg: PipeMessage): void { + if (!this.socket || this.socket.destroyed) { + throw new Error(`Not connected to pipe "${this.targetName}"`) + } + msg.from = msg.from ?? this.senderName + msg.ts = msg.ts ?? new Date().toISOString() + this.socket.write(JSON.stringify(msg) + '\n') + } + + disconnect(): void { + if (this.socket) { + this.socket.destroy() + this.socket = null + } + } + + get connected(): boolean { + return this.socket !== null && !this.socket.destroyed + } +} + +// --------------------------------------------------------------------------- +// Convenience factory functions +// --------------------------------------------------------------------------- + +export async function createPipeServer( + name: string, + options?: PipeServerOptions, +): Promise { + const server = new PipeServer(name) + await server.start(options) + return server +} + +export async function connectToPipe( + targetName: string, + senderName?: string, + timeoutMs?: number, + tcpEndpoint?: TcpEndpoint, +): Promise { + const client = new PipeClient(targetName, senderName, tcpEndpoint) + await client.connect(timeoutMs) + return client +} + +/** + * List all registered pipe names (fast — file scan only, no network probe). + * Use isPipeAlive() separately to check liveness. + */ +export async function listPipes(): Promise { + try { + await ensurePipesDir() + const files = await readdir(getPipesDir()) + const ext = process.platform === 'win32' ? '.pipe' : '.sock' + return files + .filter(f => f.endsWith(ext)) + .map(f => f.replace(new RegExp(`\\${ext}$`), '')) + } catch { + return [] + } +} + +/** + * List only alive pipes (probes each one — slower, use sparingly). + * Automatically cleans up stale registry files. + */ +export async function listAlivePipes(): Promise { + const names = await listPipes() + const ext = process.platform === 'win32' ? '.pipe' : '.sock' + const alive: string[] = [] + for (const name of names) { + if (await isPipeAlive(name, 1000)) { + alive.push(name) + } else { + const staleFile = join(getPipesDir(), `${name}${ext}`) + void unlink(staleFile).catch(() => {}) + } + } + return alive +} + +/** + * Probe whether a pipe server is alive by sending a ping. + */ +export async function isPipeAlive( + name: string, + timeoutMs: number = 2000, +): Promise { + try { + const client = new PipeClient(name, '_probe') + await client.connect(timeoutMs) + + return new Promise(resolve => { + const timer = setTimeout(() => { + client.disconnect() + resolve(false) + }, timeoutMs) + + client.onMessage(msg => { + if (msg.type === 'pong') { + clearTimeout(timer) + client.disconnect() + resolve(true) + } + }) + + client.send({ type: 'ping' }) + }) + } catch { + return false + } +} + +// ─── PipeIpc AppState extension ────────────────────────────────────── +// AppState.pipeIpc is added at runtime when feature('PIPE_IPC') is on. +// These types and the default accessor ensure safe access from hooks +// and commands without modifying the original AppStateStore. + +export type PipeIpcSlaveState = { + name: string + connectedAt: string + status: 'idle' | 'busy' | 'error' + lastActivityAt?: string + lastSummary?: string + lastEventType?: + | 'prompt' + | 'prompt_ack' + | 'stream' + | 'tool_start' + | 'tool_result' + | 'done' + | 'error' + unreadCount?: number + history: Array<{ + type: string + content: string + from: string + timestamp: string + meta?: Record + }> +} + +export type PipeIpcState = { + role: 'main' | 'sub' | 'master' | 'slave' + /** Sub instance sequence number (1-based), null for main */ + subIndex: number | null + /** Display name shown in UI. Controlled subs still display as "sub-N". */ + displayRole: string + serverName: string | null + attachedBy: string | null + /** Local IP address for registry display and machine identity metadata */ + localIp: string | null + /** Host info for registry display and machine identity metadata */ + hostname: string | null + /** OS-level stable machine fingerprint */ + machineId: string | null + /** Primary NIC MAC address */ + mac: string | null + /** Show pipe status line in footer (set by /pipes command) */ + statusVisible: boolean + /** Selector panel expanded (toggled by /pipes command) */ + selectorOpen: boolean + /** Pipes selected for message broadcast (toggled via /pipes or status panel) */ + selectedPipes: string[] + /** Current routing mode for normal prompts. `local` preserves selections but talks to main. */ + routeMode: 'selected' | 'local' + slaves: Record + /** Discovered pipe entries from registry (populated by /pipes) */ + discoveredPipes: Array<{ + id: string + pipeName: string + role: string + machineId: string + ip: string + hostname: string + alive: boolean + }> +} + +const DEFAULT_PIPE_IPC: PipeIpcState = { + role: 'main', + subIndex: null, + displayRole: 'main', + serverName: null, + attachedBy: null, + localIp: null, + hostname: null, + machineId: null, + mac: null, + statusVisible: false, + selectorOpen: false, + selectedPipes: [], + routeMode: 'selected', + slaves: {}, + discoveredPipes: [], +} + +export function isPipeControlled(pipeIpc: PipeIpcState): boolean { + return Boolean(pipeIpc.attachedBy) +} + +export function getPipeDisplayRole(pipeIpc: PipeIpcState): string { + if (pipeIpc.role === 'master') { + return 'master' + } + + if (pipeIpc.subIndex != null) { + return `sub-${pipeIpc.subIndex}` + } + + return 'main' +} + +/** + * Get the local (non-loopback) IPv4 address for registry metadata. + */ +export function getLocalIp(): string { + try { + const { networkInterfaces } = require('os') as typeof import('os') + const nets = networkInterfaces() + for (const name of Object.keys(nets)) { + for (const net of nets[name] ?? []) { + if (net.family === 'IPv4' && !net.internal) { + return net.address + } + } + } + } catch {} + return '127.0.0.1' +} + +/** + * Safely read pipeIpc from AppState, returning the default if not yet initialized. + * This avoids crashes when the state hasn't been extended by the PIPE_IPC bootstrap. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getPipeIpc(state: any): PipeIpcState { + return state?.pipeIpc ?? DEFAULT_PIPE_IPC +} diff --git a/src/utils/udsClient.ts b/src/utils/udsClient.ts index fdadc94d1..781f3ddd1 100644 --- a/src/utils/udsClient.ts +++ b/src/utils/udsClient.ts @@ -1,3 +1,219 @@ -// Auto-generated stub — replace with real implementation -export const sendToUdsSocket: (target: string, message: string) => Promise = async () => {}; -export const listAllLiveSessions: () => Promise> = async () => []; +/** + * UDS Client — connect to peer Claude Code sessions via Unix Domain Sockets. + * + * Peers are discovered by reading the PID-file registry in ~/.claude/sessions/ + * (written by concurrentSessions.ts) and checking each entry's + * `messagingSocketPath` field. A peer is "alive" if its PID is running and + * its socket accepts a ping/pong round-trip. + */ + +import { createConnection, type Socket } from 'net' +import { readdir, readFile } from 'fs/promises' +import { join } from 'path' +import { getClaudeConfigHomeDir } from './envUtils.js' +import { logForDebugging } from './debug.js' +import { errorMessage, isFsInaccessible } from './errors.js' +import { isProcessRunning } from './genericProcessUtils.js' +import { jsonParse, jsonStringify } from './slowOperations.js' +import type { SessionKind } from './concurrentSessions.js' +import type { UdsMessage } from './udsMessaging.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type PeerSession = { + pid: number + sessionId?: string + cwd?: string + startedAt?: number + kind?: SessionKind + name?: string + messagingSocketPath?: string + entrypoint?: string + bridgeSessionId?: string | null + alive: boolean +} + +// --------------------------------------------------------------------------- +// Session directory +// --------------------------------------------------------------------------- + +function getSessionsDir(): string { + return join(getClaudeConfigHomeDir(), 'sessions') +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +/** + * List all live sessions from the PID registry, optionally probing their + * UDS sockets for liveness. Sessions whose PID is no longer running are + * excluded (and their stale files cleaned up). + */ +export async function listAllLiveSessions(): Promise { + const dir = getSessionsDir() + let files: string[] + try { + files = await readdir(dir) + } catch (e) { + if (!isFsInaccessible(e)) { + logForDebugging(`[udsClient] readdir failed: ${errorMessage(e)}`) + } + return [] + } + + const results: PeerSession[] = [] + + for (const file of files) { + if (!/^\d+\.json$/.test(file)) continue + const pid = parseInt(file.slice(0, -5), 10) + + if (!isProcessRunning(pid)) { + // Stale — skip (concurrentSessions handles cleanup) + continue + } + + try { + const raw = await readFile(join(dir, file), 'utf8') + const data = jsonParse(raw) as Record + results.push({ + pid, + sessionId: data.sessionId as string | undefined, + cwd: data.cwd as string | undefined, + startedAt: data.startedAt as number | undefined, + kind: data.kind as SessionKind | undefined, + name: data.name as string | undefined, + messagingSocketPath: data.messagingSocketPath as string | undefined, + entrypoint: data.entrypoint as string | undefined, + bridgeSessionId: data.bridgeSessionId as string | null | undefined, + alive: true, + }) + } catch { + // Corrupted file — skip + } + } + + return results +} + +/** + * List peer sessions that have a UDS messaging socket (i.e. can receive + * messages). Excludes the current process. + */ +export async function listPeers(): Promise { + const all = await listAllLiveSessions() + return all.filter( + s => s.pid !== process.pid && s.messagingSocketPath != null, + ) +} + +// --------------------------------------------------------------------------- +// Connection helpers +// --------------------------------------------------------------------------- + +/** + * Probe a UDS socket to check if a server is listening (ping/pong). + * Returns true if the peer responds within the timeout. + */ +export async function isPeerAlive(socketPath: string, timeoutMs = 3000): Promise { + return new Promise((resolve) => { + const conn = createConnection(socketPath, () => { + const ping: UdsMessage = { type: 'ping', ts: new Date().toISOString() } + conn.write(jsonStringify(ping) + '\n') + }) + + let resolved = false + + const timer = setTimeout(() => { + if (!resolved) { + resolved = true + conn.destroy() + resolve(false) + } + }, timeoutMs) + + let buffer = '' + conn.on('data', (chunk) => { + buffer += chunk.toString() + if (buffer.includes('"pong"')) { + if (!resolved) { + resolved = true + clearTimeout(timer) + conn.end() + resolve(true) + } + } + }) + + conn.on('error', () => { + if (!resolved) { + resolved = true + clearTimeout(timer) + resolve(false) + } + }) + }) +} + +/** + * Send a text message to a peer's UDS socket. This is the high-level helper + * used by SendMessageTool for `uds:` addresses. + */ +export async function sendToUdsSocket( + targetSocketPath: string, + message: string | Record, +): Promise { + const data = typeof message === 'string' ? message : jsonStringify(message) + const udsMsg: UdsMessage = { + type: 'text', + data, + ts: new Date().toISOString(), + } + + // Lazily import to avoid circular dep at module-load time + const { getUdsMessagingSocketPath } = await import('./udsMessaging.js') + udsMsg.from = getUdsMessagingSocketPath() + + return new Promise((resolve, reject) => { + const conn = createConnection(targetSocketPath, () => { + conn.write(jsonStringify(udsMsg) + '\n', (err) => { + conn.end() + if (err) reject(err) + else resolve() + }) + }) + conn.on('error', (err) => { + reject(new Error(`Failed to connect to peer at ${targetSocketPath}: ${errorMessage(err)}`)) + }) + conn.setTimeout(5000, () => { + conn.destroy(new Error('Connection timed out')) + }) + }) +} + +/** + * Connect to a peer and return the raw socket for bidirectional communication. + * The caller is responsible for managing the connection lifecycle. + */ +export function connectToPeer(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const conn = createConnection(socketPath, () => { + resolve(conn) + }) + conn.on('error', reject) + conn.setTimeout(5000, () => { + conn.destroy(new Error('Connection timed out')) + }) + }) +} + +/** + * Disconnect a previously connected peer socket. + */ +export function disconnectPeer(socket: Socket): void { + if (!socket.destroyed) { + socket.end() + } +} diff --git a/src/utils/udsMessaging.ts b/src/utils/udsMessaging.ts index 3f717be6f..92a313cfa 100644 --- a/src/utils/udsMessaging.ts +++ b/src/utils/udsMessaging.ts @@ -1,3 +1,264 @@ -// Auto-generated stub — replace with real implementation -export const startUdsMessaging: (socketPath: string, options: { isExplicit: boolean }) => Promise = async () => {}; -export const getDefaultUdsSocketPath: () => string = () => ''; +/** + * UDS Messaging Layer — Unix Domain Socket IPC for Claude Code instances. + * + * Each session auto-creates a UDS server so peer sessions can send messages. + * Protocol: newline-delimited JSON (NDJSON), one message per line. + * + * Socket path defaults to a tmpdir-based path derived from the session PID, + * but can be overridden via --messaging-socket-path. + */ + +import { createServer, type Server, type Socket } from 'net' +import { mkdir, unlink } from 'fs/promises' +import { dirname, join } from 'path' +import { tmpdir } from 'os' +import { registerCleanup } from './cleanupRegistry.js' +import { logForDebugging } from './debug.js' +import { errorMessage } from './errors.js' +import { attachNdjsonFramer } from './ndjsonFramer.js' +import { jsonParse, jsonStringify } from './slowOperations.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type UdsMessageType = + | 'text' + | 'notification' + | 'query' + | 'response' + | 'ping' + | 'pong' + +export type UdsMessage = { + /** Discriminator */ + type: UdsMessageType + /** Payload text / JSON content */ + data?: string + /** Sender socket path (so the receiver can reply) */ + from?: string + /** ISO timestamp */ + ts?: string + /** Optional metadata */ + meta?: Record +} + +export type UdsInboxEntry = { + id: string + message: UdsMessage + receivedAt: number + status: 'pending' | 'processed' +} + +// --------------------------------------------------------------------------- +// Module state +// --------------------------------------------------------------------------- + +let server: Server | null = null +let socketPath: string | null = null +let onEnqueueCb: (() => void) | null = null +const clients = new Set() +const inbox: UdsInboxEntry[] = [] +let nextId = 1 + +// --------------------------------------------------------------------------- +// Public API — socket path helpers +// --------------------------------------------------------------------------- + +/** + * Default socket path based on PID, placed in a tmpdir subdirectory so it + * survives across config-home changes and avoids polluting ~/.claude. + */ +export function getDefaultUdsSocketPath(): string { + return join(tmpdir(), 'claude-code-socks', `${process.pid}.sock`) +} + +/** + * Returns the socket path of the currently running server, or undefined + * if the server has not been started. + */ +export function getUdsMessagingSocketPath(): string | undefined { + return socketPath ?? undefined +} + +// --------------------------------------------------------------------------- +// Inbox +// --------------------------------------------------------------------------- + +/** + * Register a callback invoked whenever a message is enqueued into the inbox. + * Used by the print/SDK query loop to kick off processing. + */ +export function setOnEnqueue(cb: (() => void) | null): void { + onEnqueueCb = cb +} + +/** + * Drain all pending inbox messages, marking them processed. + */ +export function drainInbox(): UdsInboxEntry[] { + const pending = inbox.filter(e => e.status === 'pending') + for (const entry of pending) { + entry.status = 'processed' + } + return pending +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +/** + * Start the UDS messaging server on the given socket path. + * + * Exports `CLAUDE_CODE_MESSAGING_SOCKET` into `process.env` so child + * processes (hooks, spawned agents) can discover and connect back. + */ +export async function startUdsMessaging( + path: string, + opts?: { isExplicit?: boolean }, +): Promise { + if (server) { + logForDebugging('[udsMessaging] server already running, skipping start') + return + } + + // Ensure parent directory exists + await mkdir(dirname(path), { recursive: true }) + + // Clean up stale socket file + try { + await unlink(path) + } catch { + // ENOENT is fine + } + + socketPath = path + + await new Promise((resolve, reject) => { + const srv = createServer(socket => { + clients.add(socket) + logForDebugging( + `[udsMessaging] client connected (total: ${clients.size})`, + ) + + attachNdjsonFramer( + socket, + msg => { + // Handle ping with automatic pong + if (msg.type === 'ping') { + const pong: UdsMessage = { + type: 'pong', + from: socketPath ?? undefined, + ts: new Date().toISOString(), + } + if (!socket.destroyed) { + socket.write(jsonStringify(pong) + '\n') + } + return + } + + // Enqueue into inbox + const entry: UdsInboxEntry = { + id: `uds-${nextId++}`, + message: msg, + receivedAt: Date.now(), + status: 'pending', + } + inbox.push(entry) + logForDebugging( + `[udsMessaging] enqueued message type=${msg.type} from=${msg.from ?? 'unknown'}`, + ) + onEnqueueCb?.() + }, + text => jsonParse(text) as UdsMessage, + ) + + socket.on('close', () => { + clients.delete(socket) + }) + + socket.on('error', err => { + clients.delete(socket) + logForDebugging(`[udsMessaging] client error: ${errorMessage(err)}`) + }) + }) + + srv.on('error', reject) + + srv.listen(path, () => { + server = srv + // Export so child processes can discover the socket + process.env.CLAUDE_CODE_MESSAGING_SOCKET = path + logForDebugging( + `[udsMessaging] server listening on ${path}${opts?.isExplicit ? ' (explicit)' : ''}`, + ) + resolve() + }) + }) + + // Register cleanup so the socket file is removed on exit + registerCleanup(async () => { + await stopUdsMessaging() + }) +} + +/** + * Stop the UDS messaging server and clean up the socket file. + */ +export async function stopUdsMessaging(): Promise { + if (!server) return + + // Close all connected clients + for (const socket of clients) { + socket.destroy() + } + clients.clear() + + await new Promise(resolve => { + server!.close(() => resolve()) + }) + server = null + + // Remove socket file + if (socketPath) { + try { + await unlink(socketPath) + } catch { + // Already gone + } + delete process.env.CLAUDE_CODE_MESSAGING_SOCKET + logForDebugging( + `[udsMessaging] server stopped, socket removed: ${socketPath}`, + ) + socketPath = null + } +} + +/** + * Send a UDS message to a specific socket path (outbound — used when this + * session wants to push a message to a peer's server). + */ +export async function sendUdsMessage( + targetSocketPath: string, + message: UdsMessage, +): Promise { + const { createConnection } = await import('net') + message.from = message.from ?? socketPath ?? undefined + message.ts = message.ts ?? new Date().toISOString() + + return new Promise((resolve, reject) => { + const conn = createConnection(targetSocketPath, () => { + conn.write(jsonStringify(message) + '\n', err => { + conn.end() + if (err) reject(err) + else resolve() + }) + }) + conn.on('error', reject) + // Timeout so we don't hang on unreachable sockets + conn.setTimeout(5000, () => { + conn.destroy(new Error('Connection timed out')) + }) + }) +} diff --git a/src/utils/xdg.ts b/src/utils/xdg.ts index c9ec16bca..e156e2f0d 100644 --- a/src/utils/xdg.ts +++ b/src/utils/xdg.ts @@ -8,7 +8,7 @@ */ import { homedir as osHomedir } from 'os' -import { join } from 'path' +import { join, posix } from 'path' type EnvLike = Record @@ -24,6 +24,13 @@ function resolveOptions(options?: XDGOptions): { env: EnvLike; home: string } { } } +function joinPortable(base: string, ...parts: string[]): string { + if (base.includes('/') && !base.includes('\\') && !/^[A-Za-z]:/.test(base)) { + return posix.join(base, ...parts) + } + return join(base, ...parts) +} + /** * Get XDG state home directory * Default: ~/.local/state @@ -31,7 +38,7 @@ function resolveOptions(options?: XDGOptions): { env: EnvLike; home: string } { */ export function getXDGStateHome(options?: XDGOptions): string { const { env, home } = resolveOptions(options) - return env.XDG_STATE_HOME ?? join(home, '.local', 'state') + return env.XDG_STATE_HOME ?? joinPortable(home, '.local', 'state') } /** @@ -41,7 +48,7 @@ export function getXDGStateHome(options?: XDGOptions): string { */ export function getXDGCacheHome(options?: XDGOptions): string { const { env, home } = resolveOptions(options) - return env.XDG_CACHE_HOME ?? join(home, '.cache') + return env.XDG_CACHE_HOME ?? joinPortable(home, '.cache') } /** @@ -51,7 +58,7 @@ export function getXDGCacheHome(options?: XDGOptions): string { */ export function getXDGDataHome(options?: XDGOptions): string { const { env, home } = resolveOptions(options) - return env.XDG_DATA_HOME ?? join(home, '.local', 'share') + return env.XDG_DATA_HOME ?? joinPortable(home, '.local', 'share') } /** @@ -61,5 +68,5 @@ export function getXDGDataHome(options?: XDGOptions): string { */ export function getUserBinDir(options?: XDGOptions): string { const { home } = resolveOptions(options) - return join(home, '.local', 'bin') + return joinPortable(home, '.local', 'bin') }