feat: 远程群控 (#243)

* feat: restore pipe IPC, LAN pipes, monitor tool, and PR-package features

Core IPC system (UDS_INBOX):
- PipeServer/PipeClient with UDS + TCP dual transport, NDJSON protocol
- PipeRegistry: machineId-based role assignment, file locking
- Master/slave attach, prompt relay, permission forwarding
- Heartbeat lifecycle with parallel isPipeAlive probes
- Commands: /pipes, /attach, /detach, /send, /claim-main, /pipe-status

LAN Pipes (LAN_PIPES):
- UDP multicast beacon (224.0.71.67:7101) for zero-config LAN discovery
- PipeServer TCP listener, PipeClient TCP connect mode
- Heartbeat auto-attaches LAN peers via TCP
- Cross-machine attach allowed regardless of role
- /pipes shows [LAN] peers with role + hostname/IP
- SendMessageTool supports tcp: scheme with user consent

Architecture — extracted hooks from REPL.tsx (~830 lines → ~20 lines):
- usePipeIpc: lifecycle (bootstrap, handlers, heartbeat, cleanup)
- usePipeRelay: slave→master message relay via module singleton
- usePipePermissionForward: permission request/cancel forwarding
- usePipeRouter: selected pipe input routing with role+IP labels
- Shared ndjsonFramer.ts replaces 3 duplicate NDJSON parsers

Key fixes applied during development:
- Multicast binds to correct LAN interface (not WSL/Docker)
- Beacon ref stored as module singleton (not Zustand state mutation)
- Heartbeat preserves LAN peers in discoveredPipes and selectedPipes
- Disconnect handler calls removeSlaveClient (fixes listener leak)
- cleanupStaleEntries probes without lock, writes briefly under lock
- getMachineId uses async execFile (not blocking execSync)
- globalThis.__pipeSendToMaster replaced with setPipeRelay singleton
- M key only toggles route mode when selector panel is expanded
- User prompt displayed in message list on pipe broadcast
- Broadcast notifications show [role] + hostname/IP for LAN peers

Other restored features:
- Monitor tool: /monitor command, MonitorTool, MonitorMcpTask lifecycle
- Daemon supervisor and remoteControlServer command
- Tools: SnipTool, SleepTool, ListPeersTool, SendUserFileTool,
  WebBrowserTool, WorkflowTool, and 10+ stub→implementation rewrites
- Feature flags: UDS_INBOX, LAN_PIPES, MONITOR_TOOL, FORK_SUBAGENT,
  KAIROS, COORDINATOR_MODE, WORKFLOW_SCRIPTS, HISTORY_SNIP

Tests: 2190 pass / 0 fail (15 new: lanBeacon 7, peerAddress 8)

* fix: resolve merge conflicts and fix all tsc/test errors after main merge

- Export ToolResultBlockParam from Tool.ts (14 tool files fixed)
- Migrate ink imports from ../../ink.js to @anthropic/ink (7 files)
- Fix toolUseID → toolUseId typo in monitor.ts and MonitorTool.tsx
- Add fallback values for string|undefined type errors (8 locations)
- Fix AppState type in assistant.ts, add NewInstallWizard stubs
- Fix ParsedRepository.repo → .name in subscribe-pr.ts
- Fix AgentId/string type mismatch in BackgroundTasksDialog.tsx
- Fix PipeRelayFn return type in pipePermissionRelay.ts
- Use PipeMessage type in usePipeRelay.ts
- Fix lanBeacon.test.ts mock type assertions
- Create missing MouseActionEvent class for ink package
- Use ansi: color format instead of bare "green"/"red"
- Resolve theme.permission access via getTheme()

Result: 0 tsc errors, 2496 tests pass, 0 fail

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 恢复 /poor 的说明

---------

Co-authored-by: unraid <local@unraid.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-11 23:22:55 +08:00
committed by GitHub
parent 2fea429dc6
commit 09fc515edb
124 changed files with 10958 additions and 577 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ src/utils/vendor/
# AI tool runtime directories
.agents/
.claude/
.codex/
.omx/

View File

@@ -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+ 工具/组件<E7BB84><E4BBB6><EFBFBD>复、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<Socket>``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 peersLAN 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 <20><><EFBFBD>火墙添加规则
echo "pass in proto udp from any to any port 7101" | sudo pfctl -ef -
# 或<><E68896>接在 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<EFBC8C><E8B7AF><EFBFBD>器未开启 AP 隔离。
---
## Daemon + Remote Control Server 还原 (2026-04-07)
**分支**: `feat/daemon-remote-control-server`

View File

@@ -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 封存为历史版本)
- 🚀 [想要启动项目](#快速开始源码版)

View File

@@ -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_<NAME>=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',
]

View File

@@ -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 统计 |

View File

@@ -1005,38 +1005,32 @@ src/utils/swarm/ 目录22 个文件):
## 28. UDS_INBOX
**编译时引用次数**: 18单引号 17 + 双引号 1
**功能描述**: UDSUnix 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/utils/udsMessaging.ts``src/utils/udsClient.ts` 已实现,不再是空壳。
- `src/utils/pipeTransport.ts` 使用本机 named pipe / Unix socket`localIp` / `hostname` / `machineId` 仅用于注册表展示与身份判定,不是已上线的局域网传输层。
- `src/screens/REPL.tsx` 内联承载当前有效的 pipe 控制平面;早期 hook 试验路径已清理。
**核心实现文件**:
| 文件路径 | 行数 | 功能说明 |
|----------|------|----------|
| 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 | 通用 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 与心跳 |
**引用该标志的文件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 客户端和消息模块,并创建命令入口。
**备注**: 如需真实局域网通信,需要单独引入 TCP/WebSocket 传输、认证与发现机制;现有代码尚未实现该层。详见 `docs/features/uds-inbox.md`
---

View File

@@ -1011,38 +1011,32 @@ src/utils/swarm/ 目录22 个文件):
## 28. UDS_INBOX
**编译时引用次数**: 18单引号 17 + 双引号 1
**功能描述**: UDSUnix 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/utils/udsMessaging.ts``src/utils/udsClient.ts` 已实现,不再是空壳。
- `src/utils/pipeTransport.ts` 使用本机 named pipe / Unix socket`localIp` / `hostname` / `machineId` 仅用于注册表展示与身份判定,不是已上线的局域网传输层。
- `src/screens/REPL.tsx` 内联承载当前有效的 pipe 控制平面;早期 hook 试验路径已清理。
**核心实现文件**:
| 文件路径 | 行数 | 功能说明 |
|----------|------|----------|
| 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 | 通用 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 与心跳 |
**引用该标志的文件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 客户端和消息模块,并创建命令入口。
**备注**: 如需真实局域网通信,需要单独引入 TCP/WebSocket 传输、认证与发现机制;现有代码尚未实现该层。详见 `docs/features/uds-inbox.md`
---

View File

@@ -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 和 TCPPipeClient 根据参数自动选择连接模式
- **本地优先**:本地 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:<random> │◄──TCP───►│ TCP: 0.0.0.0:<random> │
├───────────────────────────┤ ├───────────────────────────┤
│ 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<Socket>``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<PipeServer>
export async function connectToPipe(
targetName: string,
senderName?: string,
timeoutMs?: number,
tcpEndpoint?: TcpEndpoint, // 新增
): Promise<PipeClient>
```
---
### 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<LanAnnounce, 'proto' | 'ts'>)
start(): void // 开始广播 + 监听
stop(): void // 停止并释放资源
getPeers(): Map<string, LanAnnounce> // 当前已知 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<string, LanAnnounce>,
): 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 <20><>
在 main heartbeat 循环中:
1. `refreshDiscoveredPipes(aliveSubs)` 同时包含本地 subs 和 LAN beacon peers
2. auto-attach 循环同时遍历本地 subs 和 LAN peersLAN 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:<host>:<port>" 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 的 subsLAN 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="<bun路径>" enable=yes
```
### macOS
首次运行时系统弹窗允许即可。
### Linux
```bash
sudo firewall-cmd --add-port=7101/udp
# TCP 端口随机,建议放行 bun 进程
```

View File

@@ -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 <pipe-name>
```
自动检测 LAN peer 并通过 TCP 连接。
### 发送消息到 LAN Peer
```
/send tcp:192.168.1.20:7100 <message>
```
或通过 SendMessage tool 使用 `tcp:` scheme。
## 安全
- TCP 连接需用户显式同意checkPermissions 返回 `ask`
- Multicast TTL=1仅限链路本地
- 后续可增加 HMAC-SHA256 challenge 认证

View File

@@ -0,0 +1,342 @@
# Pipes + LAN Pipes 完整功能指南
## 概述
Pipes 系统提供 Claude Code CLI 实例之间的通讯能力,分两层:
1. **Pipes本机**:同一台机器上的多个 CLI 实例通过 UDSUnix 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 <name> — 选中某实例(消息会广播到它)
/pipes deselect <name> — 取消选中
/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 <name>
手动 attach 到一个实例,使其成为你的 slave。
```
/attach cli-04d67950 — 连接到指定 pipe自动解析 LAN TCP 端点)
```
attach 后,对方变为 slave你变为 master。可以向它发送 prompt。通常不需要手动 attach——heartbeat 会自动发现并连接。
### /detach <name>
断开与某个 slave 的连接。
```
/detach cli-04d67950
```
### /send <name> <message>
向指定 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` | 消息仅在本地执行,不转发 |
## 架构
### 通信协议
所有通讯使用 NDJSONNewline-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 发送结果/请求

View File

@@ -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, LODESTONEAnthropic 内部使用,外部无法运行
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 先完善

114
docs/features/uds-inbox.md Normal file
View File

@@ -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:<socket-path>` 地址。
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:<socket-path>`
- 传输方式:**本机 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` 为事实来源

View File

@@ -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<string>([
'onPasteCapture',
'onResize',
'onClick',
'onMouseDown',
'onMouseUp',
'onMouseDrag',
'onMouseEnter',
'onMouseLeave',
])

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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",
];

View File

@@ -2,6 +2,7 @@ import type {
ToolResultBlockParam,
ToolUseBlockParam,
} from '@anthropic-ai/sdk/resources/index.mjs'
export type { ToolResultBlockParam }
import type {
ElicitRequestURLParams,
ElicitResult,

View File

@@ -1,3 +1,25 @@
// Auto-generated stub — replace with real implementation
export {};
export const isKairosEnabled: () => Promise<boolean> = () => 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<boolean> {
if (!feature('KAIROS')) {
return false
}
if (
!getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_assistant', false)
) {
return false
}
return getKairosActive()
}

View File

@@ -1,8 +1,9 @@
// Auto-generated stub — replace with real implementation
export {};
export const isAssistantMode: () => boolean = () => false;
export const initializeAssistantTeam: () => Promise<void> = async () => {};
export const markAssistantForced: () => void = () => {};
export const isAssistantForced: () => boolean = () => false;
export const getAssistantSystemPromptAddendum: () => string = () => '';
export const getAssistantActivationPath: () => string | undefined = () => undefined;
export {}
export const isAssistantMode: () => boolean = () => false
export const initializeAssistantTeam: () => Promise<void> = async () => {}
export const markAssistantForced: () => void = () => {}
export const isAssistantForced: () => boolean = () => false
export const getAssistantSystemPromptAddendum: () => string = () => ''
export const getAssistantActivationPath: () => string | undefined = () =>
undefined

View File

@@ -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] : []),

View File

@@ -1,11 +1,53 @@
// Auto-generated stub — replace with real implementation
import type React from 'react';
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'
export {};
export const NewInstallWizard: React.FC<{
defaultDir: string;
onInstalled: (dir: string) => void;
onCancel: () => void;
onError: (message: string) => void;
}> = (() => null);
export const computeDefaultInstallDir: () => Promise<string> = (() => Promise.resolve(''));
/** Stub — install wizard is not yet restored. */
export async function computeDefaultInstallDir(): Promise<string> {
return ''
}
/** Stub — install wizard is not yet restored. */
export function NewInstallWizard(_props: {
defaultDir: string
onInstalled: (dir: string) => void
onCancel: () => void
onError: (message: string) => void
}): React.ReactNode {
return null
}
/**
* /assistant command implementation.
*
* Opens the Kairos assistant panel. In the current build the panel is
* rendered by the REPL layer when kairosActive is true; the slash command
* simply toggles visibility and prints a confirmation line.
*/
export async function call(
onDone: LocalJSXCommandOnDone,
context: LocalJSXCommandContext,
_args: string,
): Promise<React.ReactNode> {
const { setAppState, getAppState } = context
const current = getAppState()
const isVisible = (current as Record<string, unknown>).assistantPanelVisible
if (isVisible) {
setAppState((prev: AppState) => ({
...prev,
assistantPanelVisible: false,
} as AppState))
onDone('Assistant panel hidden.', { display: 'system' })
} else {
setAppState((prev: AppState) => ({
...prev,
assistantPanelVisible: true,
} as AppState))
onDone('Assistant panel opened.', { display: 'system' })
}
return null
}

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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 <pipe-name>\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} <message> 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 },
})
})
}

View File

@@ -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

View File

@@ -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') }
}

View File

@@ -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

View File

@@ -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<React.ReactNode> {
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: [
'<system-reminder>\nCoordinator mode is now disabled. You have access to all standard tools again. Work directly instead of dispatching to workers.\n</system-reminder>',
],
})
} 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: [
'<system-reminder>\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</system-reminder>',
],
},
)
}
return null
},
}),
} satisfies Command
export default coordinator

View File

@@ -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.`,
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 <pipe-name> 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 <pipe-name>\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}]`
}
}

View File

@@ -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

108
src/commands/monitor.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* /monitor <command> — 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<React.ReactNode> {
let command = args.trim()
if (!command) {
onDone(
process.platform === 'win32'
? 'Usage: /monitor <command>\nExample: /monitor powershell -c "while(1){git status; Start-Sleep 5}"'
: 'Usage: /monitor <command>\nExample: /monitor watch -n 5 git status',
{ display: 'system' },
)
return null
}
// Windows compatibility: convert `watch -n <sec> <cmd>` 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

View File

@@ -1,3 +1,12 @@
// Auto-generated stub — replace with real implementation
const _default: Record<string, unknown> = {};
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

View File

@@ -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:<socket-path>"',
)
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`
}

View File

@@ -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

View File

@@ -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 <pipe-name> 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 <pipe-name> 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 <name> <msg> — Send a task to a sub session')
lines.push(' /history <name> — View sub session transcript')
lines.push(' /detach [name] — Disconnect from a sub session (or all)')
return { type: 'text', value: lines.join('\n') }
}

View File

@@ -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

231
src/commands/pipes/pipes.ts Normal file
View File

@@ -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 <pipe-name>' }
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 <pipe-name>' }
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 <name> — select pipe for broadcast')
lines.push(' /pipes deselect <name> — deselect pipe')
lines.push(' /pipes all — select all connected')
lines.push(' /pipes none — deselect all')
lines.push(' /send <name> <msg> — send to specific pipe')
lines.push(' /claim-main — claim this machine as main')
return { type: 'text', value: lines.join('\n') }
}

56
src/commands/proactive.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* /proactive — Toggle proactive (autonomous tick-driven) mode.
*
* When enabled, the model receives periodic <tick> 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<React.ReactNode> {
// 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: [
'<system-reminder>\nProactive mode is now enabled. You will receive periodic <tick> 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</system-reminder>',
],
},
)
}
return null
},
}),
} satisfies Command
export default proactive

View File

@@ -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

97
src/commands/send/send.ts Normal file
View File

@@ -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 <pipe-name> 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 <pipe-name> <message>',
}
}
const targetName = trimmed.slice(0, spaceIdx)
const message = trimmed.slice(spaceIdx + 1).trim()
if (!message) {
return {
type: 'text',
value: 'Usage: /send <pipe-name> <message>',
}
}
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)}`,
}
}
}

View File

@@ -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 <pr-url-or-number>',
}
}
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: '<pr-url-or-number>',
supportsNonInteractive: false,
isHidden: true,
load: () => Promise.resolve({ call }),
} satisfies Command
export default subscribePr

1
src/commands/torch.ts Normal file
View File

@@ -0,0 +1 @@
export default null

View File

@@ -1,3 +1,25 @@
// Auto-generated stub — replace with real implementation
const _default: Record<string, unknown> = {};
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

View File

@@ -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<ScrollBoxHandle | null>
modalScrollRef?: React.RefObject<ScrollBoxHandle | null>;
/** Ref to the scroll box for keyboard scrolling. RefObject (not Ref) so
* pillVisible's useSyncExternalStore can subscribe to scroll changes. */
scrollRef?: RefObject<ScrollBoxHandle | null>
scrollRef?: RefObject<ScrollBoxHandle | null>;
/** 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<number | null>
dividerYRef?: RefObject<number | null>;
/** 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<number | null>
onScrollAway: (handle: ScrollBoxHandle) => void
onRepin: () => void
dividerYRef: RefObject<number | null>;
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<number | null>(null)
const [dividerIndex, setDividerIndex] = useState<number | null>(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<number | null>(null)
const dividerYRef = useRef<number | null>(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))
const shiftDivider = useCallback((indexDelta: number, heightDelta: number) => {
setDividerIndex(idx => (idx === null ? null : idx + indexDelta));
if (dividerYRef.current !== null) {
dividerYRef.current += heightDelta
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<StickyPrompt | null>(null)
const chromeCtx = useMemo(() => ({ setStickyPrompt }), [])
const [stickyPrompt, setStickyPrompt] = useState<StickyPrompt | null>(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,19 +366,15 @@ 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 (
<PromptOverlayProvider>
<Box flexDirection="row" flexGrow={1} overflow="hidden" width="100%">
<Box flexDirection="column" flexGrow={1} width={columns} overflow="hidden">
<Box flexGrow={1} flexDirection="column" overflow="hidden">
{headerPrompt && (
<StickyPromptHeader
text={headerPrompt.text}
onClick={headerPrompt.scrollTo}
/>
)}
{headerPrompt && <StickyPromptHeader text={headerPrompt.text} onClick={headerPrompt.scrollTo} />}
<ScrollBox
ref={scrollRef}
flexGrow={1}
@@ -399,9 +382,7 @@ export function FullscreenLayout({
paddingTop={padCollapsed ? 0 : 1}
stickyScroll
>
<ScrollChromeContext value={chromeCtx}>
{scrollable}
</ScrollChromeContext>
<ScrollChromeContext value={chromeCtx}>{scrollable}</ScrollChromeContext>
{overlay}
</ScrollBox>
{!hidePill && pillVisible && overlay == null && (
@@ -416,15 +397,12 @@ export function FullscreenLayout({
<Box flexDirection="column" flexShrink={0} width="100%" maxHeight="50%">
<SuggestionsOverlay />
<DialogOverlay />
<Box
flexDirection="column"
width="100%"
flexGrow={1}
overflowY="hidden"
>
<Box flexDirection="column" width="100%" flexGrow={1} overflowY="hidden">
{bottom}
</Box>
</Box>
</Box>
</Box>
{modal != null && (
<ModalContext
value={{
@@ -465,19 +443,14 @@ export function FullscreenLayout({
<Box flexShrink={0}>
<Text color="permission">{'▔'.repeat(columns)}</Text>
</Box>
<Box
flexDirection="column"
paddingX={2}
flexShrink={0}
overflow="hidden"
>
<Box flexDirection="column" paddingX={2} flexShrink={0} overflow="hidden">
{modal}
</Box>
</Box>
</ModalContext>
)}
</PromptOverlayProvider>
)
);
}
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 (
<Box
position="absolute"
bottom={0}
left={0}
right={0}
justifyContent="center"
>
<Box
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<Text
backgroundColor={
hover ? 'userMessageBackgroundHover' : 'userMessageBackground'
}
dimColor
>
<Box position="absolute" bottom={0} left={0} right={0} justifyContent="center">
<Box onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
<Text backgroundColor={hover ? 'userMessageBackgroundHover' : 'userMessageBackground'} dimColor>
{' '}
{count > 0
? `${count} new ${plural(count, 'message')}`
: 'Jump to bottom'}{' '}
{figures.arrowDown}{' '}
{count > 0 ? `${count} new ${plural(count, 'message')}` : 'Jump to bottom'} {figures.arrowDown}{' '}
</Text>
</Box>
</Box>
)
);
}
// 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 (
<Box
flexShrink={0}
width="100%"
height={1}
paddingRight={1}
backgroundColor={
hover ? 'userMessageBackgroundHover' : 'userMessageBackground'
}
backgroundColor={hover ? 'userMessageBackgroundHover' : 'userMessageBackground'}
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
@@ -572,7 +513,7 @@ function StickyPromptHeader({
{figures.pointer} {text}
</Text>
</Box>
)
);
}
// 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 (
<Box
position="absolute"
bottom="100%"
left={0}
right={0}
paddingX={2}
paddingTop={1}
flexDirection="column"
opaque
>
<Box position="absolute" bottom="100%" left={0} right={0} paddingX={2} paddingTop={1} flexDirection="column" opaque>
<PromptInputFooterSuggestions
suggestions={data.suggestions}
selectedSuggestion={data.selectedSuggestion}
@@ -604,18 +536,18 @@ function SuggestionsOverlay(): React.ReactNode {
overlay
/>
</Box>
)
);
}
// 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 (
<Box position="absolute" bottom="100%" left={0} right={0} opaque>
{node}
</Box>
)
);
}

View File

@@ -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}
/>
</Box>
)
);
}
if (helpOpen) {
return (
<PromptInputHelpMenu dimColor={true} fixedWidth={true} paddingX={2} />
)
return <PromptInputHelpMenu dimColor={true} fixedWidth={true} paddingX={2} />;
}
return (
@@ -171,17 +162,10 @@ function PromptInputFooter({
gap={isNarrow ? 0 : 1}
>
<Box flexDirection="column" flexShrink={isNarrow ? 0 : 1}>
{mode === 'prompt' &&
!isShort &&
!exitMessage.show &&
!isPasting &&
statusLineShouldDisplay(settings) && (
<StatusLine
messagesRef={messagesRef}
lastAssistantMessageId={lastAssistantMessageId}
vimMode={vimMode}
/>
{mode === 'prompt' && !isShort && !exitMessage.show && !isPasting && statusLineShouldDisplay(settings) && (
<StatusLine messagesRef={messagesRef} lastAssistantMessageId={lastAssistantMessageId} vimMode={vimMode} />
)}
<PipeStatusInline />
<PromptInputFooterLeftSide
exitMessage={exitMessage}
vimMode={vimMode}
@@ -218,62 +202,215 @@ function PromptInputFooter({
isNarrow={isNarrow}
/>
)}
{process.env.USER_TYPE === 'ant' && isUndercover() && (
<Text dimColor>undercover</Text>
)}
{process.env.USER_TYPE === 'ant' && isUndercover() && <Text dimColor>undercover</Text>}
<BridgeStatusIndicator bridgeSelected={bridgeSelected} />
</Box>
</Box>
{process.env.USER_TYPE === 'ant' && <CoordinatorTaskPanel />}
</>
)
);
}
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 (
<Text
color={bridgeSelected ? 'background' : status.color}
inverse={bridgeSelected}
wrap="truncate"
>
<Text color={bridgeSelected ? 'background' : status.color} inverse={bridgeSelected} wrap="truncate">
{status.label}
{bridgeSelected && <Text dimColor> · Enter to view</Text>}
</Text>
)
);
}
/**
* 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 (
<Box height={1} gap={1}>
<Text dimColor>pipe:</Text>
<Text bold>{pipeIpc.serverName}</Text>
<Text dimColor>({displayRole})</Text>
{pipeIpc.localIp && <Text dimColor>{pipeIpc.localIp}</Text>}
{allPipes.length > 0 && (
<Text color={selectedRouteActive ? 'success' : undefined} dimColor={selectedPipes.length === 0}>
{selectedPipes.length}/{allPipes.length} selected
</Text>
)}
{pipeIpc && isPipeControlled(pipeIpc) && pipeIpc.attachedBy && (
<Text color="warning">
{'→ '}
{pipeIpc.attachedBy}
</Text>
)}
{allPipes.length > 0 && (
<Text color={selectedRouteActive ? 'success' : undefined} dimColor={!selectedRouteActive}>
{selectedPipes.length > 0
? `${routeMode === 'local' ? 'local main' : 'selected pipes only'} · ←/→ switch · Shift+↓ edit`
: 'local main · Shift+↓ select'}
</Text>
)}
</Box>
);
}
// Expanded mode: header + pipe list with checkboxes
return (
<Box flexDirection="column">
<Box height={1} gap={1}>
<Text dimColor>pipe:</Text>
<Text bold>{pipeIpc.serverName}</Text>
<Text dimColor>({displayRole})</Text>
{pipeIpc.localIp && <Text dimColor>{pipeIpc.localIp}</Text>}
<Text color="warning"> move Space select / or m route Enter/Esc close Shift+ toggle</Text>
</Box>
<Box height={1} paddingLeft={2}>
<Text dimColor>
{selectedPipes.length > 0
? `当前普通 prompt 走 ${routeMode === 'local' ? '本地 main' : '已选 sub'};切换不会清空选择`
: '当前未选择 pipe普通 prompt 会在本地 main 对话执行'}
</Text>
</Box>
{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 (
<Box key={name} height={1} paddingLeft={2}>
<Text
inverse={isCursor}
color={isSelected ? 'success' : isConnected ? undefined : 'error'}
dimColor={!isConnected && !isCursor}
>
{isSelected ? '☑' : '☐'} {name}
{isConnected ? '' : ' [offline]'}
{label ? ` (${label})` : ''}
</Text>
</Box>
);
})}
{allPipes.length === 0 && (
<Box height={1} paddingLeft={2}>
<Text dimColor>No other pipes found. Start another instance.</Text>
</Box>
)}
</Box>
);
}

View File

@@ -1,3 +0,0 @@
// Auto-generated stub — replace with real implementation
export {};
export const MonitorPermissionRequest: (props: Record<string, unknown>) => null = () => null;

View File

@@ -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<OptionValue>[] = useMemo(() => {
const opts: PermissionPromptOption<OptionValue>[] = [
{
label: 'Yes',
value: 'yes',
feedbackConfig: { type: 'accept' as const },
},
]
if (showAlwaysAllowOptions) {
opts.push({
label: (
<Text>
Yes, and don{'\u2019'}t ask again for{' '}
<Text bold>{toolUseConfirm.tool.name}</Text> commands
</Text>
),
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 (
<PermissionDialog
title="Monitor"
workerBadge={workerBadge}
>
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Text bold color={theme.permission as any}>
{input.description}
</Text>
<Text dimColor>
{truncateToLines(input.command, 5)}
</Text>
</Box>
<PermissionRuleExplanation
permissionResult={toolUseConfirm.permissionResult}
toolType="command"
/>
<PermissionPrompt<OptionValue>
options={options}
onSelect={handleSelect}
onCancel={handleCancel}
/>
</Box>
</PermissionDialog>
)
}

View File

@@ -1,3 +0,0 @@
// Auto-generated stub — replace with real implementation
export {};
export const ReviewArtifactPermissionRequest: (props: Record<string, unknown>) => null = () => null;

View File

@@ -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 (
<PermissionDialog
color="permission"
title="Review artifact?"
workerBadge={workerBadge}
>
<Box flexDirection="column" marginTop={1} paddingX={1}>
<Text>
Claude wants to review{title ? `: ${title}` : ' an artifact'}.
</Text>
<Box marginTop={1} flexDirection="column">
<Text dimColor>
{annotationCount} annotation{annotationCount !== 1 ? 's' : ''} will
be presented.
</Text>
{summary ? <Text dimColor>Summary: {summary}</Text> : null}
</Box>
<Box marginTop={1}>
<Select
options={[
{ label: 'Yes, show review', value: 'yes' as const },
{ label: 'No, skip', value: 'no' as const },
]}
onChange={handleResponse}
onCancel={() => handleResponse('no')}
/>
</Box>
</Box>
</PermissionDialog>
)
}

View File

@@ -528,7 +528,7 @@ export function BackgroundTasksDialog({
return (
<WorkflowDetailDialog
workflow={task}
onDone={onDone}
onDone={onDone as (message?: string, options?: { display?: string }) => void}
onKill={
task.status === 'running' && killWorkflowTask
? () => killWorkflowTask(task.id, setAppState)
@@ -536,12 +536,12 @@ export function BackgroundTasksDialog({
}
onSkipAgent={
task.status === 'running' && skipWorkflowAgent
? (agentId: AgentId) => skipWorkflowAgent(task.id, agentId, setAppState)
? (agentId: string) => skipWorkflowAgent(task.id, agentId as AgentId, setAppState)
: undefined
}
onRetryAgent={
task.status === 'running' && retryWorkflowAgent
? (agentId: AgentId) => retryWorkflowAgent(task.id, agentId, setAppState)
? (agentId: string) => retryWorkflowAgent(task.id, agentId as AgentId, setAppState)
: undefined
}
onBack={goBackToList}

View File

@@ -1,3 +0,0 @@
// Auto-generated stub — replace with real implementation
export {};
export const MonitorMcpDetailDialog: (props: Record<string, unknown>) => null = () => null;

View File

@@ -0,0 +1,100 @@
import React from 'react'
import type { DeepImmutable } from 'src/types/utils.js'
import { useElapsedTime } from '../../hooks/useElapsedTime.js'
import { Box, Text, type KeyboardEvent } from '@anthropic/ink'
import { useKeybindings } from '../../keybindings/useKeybinding.js'
import type { MonitorMcpTaskState } from '../../tasks/MonitorMcpTask/MonitorMcpTask.js'
import { Byline } from '../design-system/Byline.js'
import { Dialog } from '../design-system/Dialog.js'
import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'
type Props = {
task: DeepImmutable<MonitorMcpTaskState>
onBack?: () => void
onKill?: () => void
}
/**
* Detail dialog for MCP monitor tasks shown in the Shift+Down background
* tasks overlay. Displays the server name, resource URI, and current status.
* Follows the DreamDetailDialog/ShellDetailDialog pattern.
*/
export function MonitorMcpDetailDialog({
task,
onBack,
onKill,
}: Props): React.ReactNode {
const elapsedTime = useElapsedTime(
task.startTime,
task.status === 'running',
1000,
0,
)
useKeybindings(
{},
{ context: 'MonitorMcpDetail' },
)
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'left' && onBack) {
e.preventDefault()
onBack()
} else if (e.key === 'x' && task.status === 'running' && onKill) {
e.preventDefault()
onKill()
}
}
return (
<Box flexDirection="column" tabIndex={0} borderStyle="round" onKeyDown={handleKeyDown}>
<Dialog
title="MCP Monitor"
subtitle={
<Text dimColor>
{elapsedTime} · {task.serverName}:{task.resourceUri}
</Text>
}
onCancel={onBack ?? (() => {})}
inputGuide={() => (
<Byline>
{onBack && (
<KeyboardShortcutHint shortcut="←" action="go back" />
)}
<KeyboardShortcutHint shortcut="Esc" action="close" />
{task.status === 'running' && onKill && (
<KeyboardShortcutHint shortcut="x" action="stop" />
)}
</Byline>
)}
>
<Box flexDirection="column" gap={1}>
<Text>
<Text bold>Status:</Text>{' '}
{task.status === 'running' ? (
<Text color="ansi:green">running</Text>
) : task.status === 'completed' ? (
<Text color="ansi:green">{task.status}</Text>
) : (
<Text color="ansi:red">{task.status}</Text>
)}
</Text>
<Text>
<Text bold>Description:</Text> {task.description}
</Text>
<Text>
<Text bold>Server:</Text> {task.serverName}
</Text>
<Text>
<Text bold>Resource:</Text> {task.resourceUri}
</Text>
{task.command && (
<Text>
<Text bold>Command:</Text> {task.command}
</Text>
)}
</Box>
</Dialog>
</Box>
)
}

View File

@@ -1,3 +0,0 @@
// Auto-generated stub — replace with real implementation
export {};
export const WorkflowDetailDialog: (props: Record<string, unknown>) => null = () => null;

View File

@@ -0,0 +1,115 @@
import React, { useCallback } from 'react'
import type { DeepImmutable } from 'src/types/utils.js'
import { useElapsedTime } from '../../hooks/useElapsedTime.js'
import { Box, Text, type KeyboardEvent } from '@anthropic/ink'
import { useKeybindings } from '../../keybindings/useKeybinding.js'
import type { LocalWorkflowTaskState } from '../../tasks/LocalWorkflowTask/LocalWorkflowTask.js'
import { Byline } from '../design-system/Byline.js'
import { Dialog } from '../design-system/Dialog.js'
import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'
type Props = {
workflow: DeepImmutable<LocalWorkflowTaskState>
onDone: (message?: string, options?: { display?: string }) => void
onKill?: () => void
onSkipAgent?: (agentId: string) => void
onRetryAgent?: (agentId: string) => void
onBack?: () => void
}
/**
* Detail dialog for local workflow tasks shown in the Shift+Down background
* tasks overlay. Displays the workflow name, file, status, and output.
* Follows the DreamDetailDialog/ShellDetailDialog pattern.
*/
export function WorkflowDetailDialog({
workflow,
onDone,
onKill,
onSkipAgent: _onSkipAgent,
onRetryAgent: _onRetryAgent,
onBack,
}: Props): React.ReactNode {
const elapsedTime = useElapsedTime(
workflow.startTime,
workflow.status === 'running',
1000,
0,
)
useKeybindings(
{},
{ context: 'WorkflowDetail' },
)
const handleKeyDown = useCallback(
(e: KeyboardEvent): void => {
if (e.key === 'left' && onBack) {
e.preventDefault()
onBack()
} else if (e.key === 'x' && workflow.status === 'running' && onKill) {
e.preventDefault()
onKill()
}
},
[onBack, onKill, workflow.status],
)
return (
<Box flexDirection="column" tabIndex={0} borderStyle="round" onKeyDown={handleKeyDown}>
<Dialog
title="Workflow"
subtitle={
<Text dimColor>
{elapsedTime} · {workflow.workflowName}
</Text>
}
onCancel={onBack ?? (() => {})}
inputGuide={() => (
<Byline>
{onBack && (
<KeyboardShortcutHint shortcut={'\u2190'} action="go back" />
)}
<KeyboardShortcutHint shortcut="Esc" action="close" />
{workflow.status === 'running' && onKill && (
<KeyboardShortcutHint shortcut="x" action="stop" />
)}
</Byline>
)}
>
<Box flexDirection="column" gap={1}>
<Text>
<Text bold>Status:</Text>{' '}
{workflow.status === 'running' ? (
<Text color="ansi:green">running</Text>
) : workflow.status === 'completed' ? (
<Text color="ansi:green">{workflow.status}</Text>
) : (
<Text color="ansi:red">{workflow.status}</Text>
)}
</Text>
<Text>
<Text bold>Description:</Text> {workflow.description}
</Text>
<Text>
<Text bold>Workflow:</Text> {workflow.workflowName}
</Text>
<Text>
<Text bold>File:</Text> {workflow.workflowFile}
</Text>
{workflow.summary && (
<Text>
<Text bold>Summary:</Text> {workflow.summary}
</Text>
)}
{workflow.output && (
<Box flexDirection="column">
<Text bold>Output:</Text>
<Text dimColor>{workflow.output}</Text>
</Box>
)}
</Box>
</Dialog>
</Box>
)
}

View File

@@ -1,4 +1,67 @@
// Auto-generated stub — replace with real implementation
export {};
import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js';
export const getCoordinatorAgents: () => AgentDefinition[] = () => [];
/**
* Coordinator-mode worker agent definition.
*
* When COORDINATOR_MODE is active, getBuiltInAgents() returns only
* the agents from getCoordinatorAgents(). The coordinator's system
* prompt instructs it to use `subagent_type: "worker"` when spawning
* tasks via the Agent tool.
*
* Workers get the full standard tool set (minus internal orchestration
* tools like TeamCreate/SendMessage) so they can research, implement,
* and verify autonomously.
*/
import { ASYNC_AGENT_ALLOWED_TOOLS } from '../constants/tools.js'
import { SEND_MESSAGE_TOOL_NAME } from '../tools/SendMessageTool/constants.js'
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../tools/SyntheticOutputTool/SyntheticOutputTool.js'
import { TEAM_CREATE_TOOL_NAME } from '../tools/TeamCreateTool/constants.js'
import { TEAM_DELETE_TOOL_NAME } from '../tools/TeamDeleteTool/constants.js'
import type { BuiltInAgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
/**
* Tools that workers must NOT have — these are coordinator-only
* orchestration primitives.
*/
const INTERNAL_ORCHESTRATION_TOOLS = new Set([
TEAM_CREATE_TOOL_NAME,
TEAM_DELETE_TOOL_NAME,
SEND_MESSAGE_TOOL_NAME,
SYNTHETIC_OUTPUT_TOOL_NAME,
])
/**
* Build the worker's allowed tool list from ASYNC_AGENT_ALLOWED_TOOLS,
* excluding internal orchestration tools.
*/
function getWorkerTools(): string[] {
return Array.from(ASYNC_AGENT_ALLOWED_TOOLS).filter(
name => !INTERNAL_ORCHESTRATION_TOOLS.has(name),
)
}
const WORKER_AGENT: BuiltInAgentDefinition = {
agentType: 'worker',
whenToUse:
'Worker agent for coordinator mode. Executes research, implementation, and verification tasks autonomously with the full standard tool set.',
tools: getWorkerTools(),
source: 'built-in',
baseDir: 'built-in',
getSystemPrompt: () =>
`You are a worker agent spawned by a coordinator. Your job is to complete the task described in the prompt thoroughly and report back with a concise summary of what you did and what you found.
Guidelines:
- Complete the task fully — don't leave it half-done, but don't gold-plate either.
- Use tools proactively: read files, search code, run commands, edit files.
- Be thorough in research: check multiple locations, consider different naming conventions.
- For implementation: make targeted changes, run tests to verify, commit if appropriate.
- Report back with actionable findings — the coordinator will synthesize your results.
- If you encounter errors, investigate and attempt to fix them before reporting failure.
- NEVER create documentation files unless explicitly instructed.`,
}
/**
* Returns the agent definitions available in coordinator mode.
* Called by getBuiltInAgents() when COORDINATOR_MODE is active.
*/
export function getCoordinatorAgents(): BuiltInAgentDefinition[] {
return [WORKER_AGENT]
}

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env bun
import { feature } from 'bun:bundle'
import { isEnvTruthy } from '../utils/envUtils.js'
// Runtime fallback for MACRO.* when not injected by build/dev defines.
// This happens when running cli.tsx directly (not via `bun run dev` or built dist/).
@@ -15,6 +16,21 @@ if (typeof globalThis.MACRO === 'undefined') {
}
}
if (isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE)) {
for (const stream of [process.stdin, process.stdout, process.stderr]) {
if (!stream.isTTY) {
try {
Object.defineProperty(stream, 'isTTY', {
value: true,
configurable: true,
})
} catch {
// Best-effort dev-only override for nested bun launch on Windows.
}
}
}
}
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
// eslint-disable-next-line custom-rules/no-top-level-side-effects
process.env.COREPACK_ENABLE_AUTO_PIN = '0'

View File

@@ -0,0 +1,116 @@
import { afterEach, describe, expect, test } from 'bun:test'
import {
addSlaveClient,
applyPipeEntryToSlaveState,
getConnectedSlaveTargets,
resetSlaveClientsForTesting,
subscribePipeEntries,
} from '../useMasterMonitor.js'
afterEach(() => {
resetSlaveClientsForTesting()
})
describe('useMasterMonitor registry helpers', () => {
test('returns only attached and connected targets from a selection list', () => {
addSlaveClient('cli-a', { connected: true } as any)
addSlaveClient('cli-b', { connected: false } as any)
const targets = getConnectedSlaveTargets(['cli-a', 'cli-b', 'cli-c'])
expect(targets).toHaveLength(1)
expect(targets[0]?.name).toBe('cli-a')
expect(targets[0]?.client.connected).toBe(true)
})
test('returns an empty array when no selected targets are connected', () => {
addSlaveClient('cli-a', { connected: false } as any)
expect(getConnectedSlaveTargets(['cli-a', 'cli-missing'])).toEqual([])
})
test('applies prompt_ack as busy activity with a summary', () => {
const next = applyPipeEntryToSlaveState(
{
name: 'cli-a',
connectedAt: '2026-04-08T00:00:00.000Z',
status: 'idle',
unreadCount: 0,
history: [],
},
{
type: 'prompt_ack',
content: 'accepted',
from: 'cli-a',
timestamp: '2026-04-08T00:00:01.000Z',
},
)
expect(next.status).toBe('busy')
expect(next.lastEventType).toBe('prompt_ack')
expect(next.lastSummary).toBe('accepted')
expect(next.unreadCount).toBe(1)
})
test('applies done and error entries to terminal slave states', () => {
const doneState = applyPipeEntryToSlaveState(
{
name: 'cli-a',
connectedAt: '2026-04-08T00:00:00.000Z',
status: 'busy',
unreadCount: 1,
history: [],
},
{
type: 'done',
content: 'completed',
from: 'cli-a',
timestamp: '2026-04-08T00:00:02.000Z',
},
)
expect(doneState.status).toBe('idle')
expect(doneState.lastSummary).toBe('completed')
const errorState = applyPipeEntryToSlaveState(doneState, {
type: 'error',
content: 'failed',
from: 'cli-a',
timestamp: '2026-04-08T00:00:03.000Z',
})
expect(errorState.status).toBe('error')
expect(errorState.lastEventType).toBe('error')
expect(errorState.lastSummary).toBe('failed')
expect(errorState.unreadCount).toBe(3)
})
test('emits pipe entries immediately when connected clients receive messages', () => {
const handlers = new Map<string, (msg: any) => void>()
const client = {
connected: true,
on(event: string, handler: (msg: any) => void) {
handlers.set(event, handler)
},
removeListener(event: string) {
handlers.delete(event)
},
}
const seen: Array<{ name: string; type: string; content: string }> = []
const unsubscribe = subscribePipeEntries((name, entry) => {
seen.push({ name, type: entry.type, content: entry.content })
})
addSlaveClient('cli-a', client as any)
handlers.get('message')?.({
type: 'stream',
data: 'hello',
from: 'cli-a',
ts: '2026-04-08T00:00:04.000Z',
})
expect(seen).toEqual([{ name: 'cli-a', type: 'stream', content: 'hello' }])
unsubscribe()
})
})

View File

@@ -4,6 +4,7 @@ import { randomUUID } from 'crypto'
import { logForDebugging } from 'src/utils/debug.js'
import { getAllowedChannels } from '../../../bootstrap/state.js'
import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js'
import type { ToolUseConfirm } from '../../../components/permissions/PermissionRequest.js'
import { getTerminalFocused } from '@anthropic/ink'
import {
CHANNEL_PERMISSION_REQUEST_METHOD,
@@ -25,6 +26,11 @@ import {
setYoloClassifierApproval,
} from '../../../utils/classifierApprovals.js'
import { errorMessage } from '../../../utils/errors.js'
import {
forgetPipePermissionRequest,
notifyPipePermissionCancel,
tryRelayPipePermissionRequest,
} from '../../../utils/pipePermissionRelay.js'
import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
import { hasPermissionsToUseTool } from '../../../utils/permissions/permissions.js'
@@ -82,6 +88,18 @@ function handleInteractivePermission(
const permissionPromptStartTimeMs = Date.now()
const displayInput = result.updatedInput ?? ctx.input
let pipePermissionRequestId: string | null = null
function forgetPipePermission(reason?: string): void {
notifyPipePermissionCancel(pipePermissionRequestId, reason)
forgetPipePermissionRequest(pipePermissionRequestId)
pipePermissionRequestId = null
}
function forgetPipePermissionSilently(): void {
forgetPipePermissionRequest(pipePermissionRequestId)
pipePermissionRequestId = null
}
function clearClassifierIndicator(): void {
if (feature('BASH_CLASSIFIER')) {
@@ -89,7 +107,7 @@ function handleInteractivePermission(
}
}
ctx.pushToQueue({
const toolUseConfirm: ToolUseConfirm = {
assistantMessage: ctx.assistantMessage,
tool: ctx.tool,
description,
@@ -136,6 +154,7 @@ function handleInteractivePermission(
},
onAbort() {
if (!claim()) return
forgetPipePermission('Permission request was aborted locally in sub.')
if (bridgeCallbacks && bridgeRequestId) {
bridgeCallbacks.sendResponse(bridgeRequestId, {
behavior: 'deny',
@@ -158,6 +177,7 @@ function handleInteractivePermission(
contentBlocks?: ContentBlockParam[],
) {
if (!claim()) return // atomic check-and-mark before await
forgetPipePermission('Permission request was approved locally in sub.')
if (bridgeCallbacks && bridgeRequestId) {
bridgeCallbacks.sendResponse(bridgeRequestId, {
@@ -182,6 +202,7 @@ function handleInteractivePermission(
},
onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) {
if (!claim()) return
forgetPipePermission('Permission request was rejected locally in sub.')
if (bridgeCallbacks && bridgeRequestId) {
bridgeCallbacks.sendResponse(bridgeRequestId, {
@@ -220,6 +241,7 @@ function handleInteractivePermission(
// a CCR-initiated mode switch, the very case this callback exists
// for after useReplBridge started calling it).
if (!claim()) return
forgetPipePermission('Permission request was resolved locally in sub.')
if (bridgeCallbacks && bridgeRequestId) {
bridgeCallbacks.cancelRequest(bridgeRequestId)
}
@@ -229,7 +251,65 @@ function handleInteractivePermission(
resolveOnce(ctx.buildAllow(freshResult.updatedInput ?? ctx.input))
}
},
})
}
ctx.pushToQueue(toolUseConfirm)
pipePermissionRequestId = tryRelayPipePermissionRequest(
toolUseConfirm,
response => {
if (!claim()) return
forgetPipePermissionSilently()
clearClassifierChecking(ctx.toolUseID)
clearClassifierIndicator()
ctx.removeFromQueue()
channelUnsubscribe?.()
if (bridgeCallbacks && bridgeRequestId) {
bridgeCallbacks.cancelRequest(bridgeRequestId)
}
if (response.behavior === 'allow') {
void (async () => {
if (response.permissionUpdates?.length) {
void ctx.persistPermissions(response.permissionUpdates)
}
ctx.logDecision(
{
decision: 'accept',
source: {
type: 'user',
permanent: !!response.permissionUpdates?.length,
},
},
{ permissionPromptStartTimeMs },
)
resolveOnce(
ctx.buildAllow(response.updatedInput ?? displayInput, {
acceptFeedback: response.feedback,
contentBlocks: response.contentBlocks,
}),
)
})()
} else {
ctx.logDecision(
{
decision: 'reject',
source: {
type: 'user_reject',
hasFeedback: !!response.feedback,
},
},
{ permissionPromptStartTimeMs },
)
resolveOnce(
ctx.cancelAndAbort(
response.feedback,
undefined,
response.contentBlocks,
),
)
}
},
)
// Race 4: Bridge permission response from CCR (claude.ai)
// When the bridge is connected, send the permission request to CCR and
@@ -257,6 +337,9 @@ function handleInteractivePermission(
bridgeRequestId,
response => {
if (!claim()) return // Local user/hook/classifier already responded
forgetPipePermission(
'Permission request was resolved by bridge before pipe response.',
)
signal.removeEventListener('abort', unsubscribe)
clearClassifierChecking(ctx.toolUseID)
clearClassifierIndicator()
@@ -364,6 +447,9 @@ function handleInteractivePermission(
channelRequestId,
response => {
if (!claim()) return // Another racer won
forgetPipePermission(
'Permission request was resolved by channel before pipe response.',
)
channelUnsubscribe?.() // both: map delete + listener remove
clearClassifierChecking(ctx.toolUseID)
clearClassifierIndicator()
@@ -421,6 +507,9 @@ function handleInteractivePermission(
permissionPromptStartTimeMs,
)
if (!hookDecision || !claim()) return
forgetPipePermission(
'Permission request was resolved by hook before pipe response.',
)
if (bridgeCallbacks && bridgeRequestId) {
bridgeCallbacks.cancelRequest(bridgeRequestId)
}
@@ -453,6 +542,9 @@ function handleInteractivePermission(
},
onAllow: decisionReason => {
if (!claim()) return
forgetPipePermission(
'Permission request was auto-approved before pipe response.',
)
if (bridgeCallbacks && bridgeRequestId) {
bridgeCallbacks.cancelRequest(bridgeRequestId)
}

View File

@@ -61,15 +61,18 @@ function stepTeammateSelection(
* Custom hook that handles Shift+Up/Down keyboard navigation for background tasks.
* When teammates (swarm) are present, navigates between leader and teammates.
* When only non-teammate background tasks exist, opens the background tasks dialog.
* When pipe IPC is active (UDS_INBOX), Shift+Down toggles the pipe selector panel.
* Also handles Enter to confirm selection, 'f' to view transcript, and 'k' to kill.
*/
export function useBackgroundTaskNavigation(options?: {
onOpenBackgroundTasks?: () => void
onTogglePipeSelector?: () => void
}): { handleKeyDown: (e: KeyboardEvent) => void } {
const tasks = useAppState(s => s.tasks)
const viewSelectionMode = useAppState(s => s.viewSelectionMode)
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
const selectedIPAgentIndex = useAppState(s => s.selectedIPAgentIndex)
const pipeIpc = useAppState(s => (s as any).pipeIpc)
const setAppState = useSetAppState()
// Filter to running teammates and sort alphabetically to match TeammateSpinnerTree display
@@ -177,12 +180,20 @@ export function useBackgroundTaskNavigation(options?: {
// Shift+Up/Down for teammate transcript switching (with wrapping)
// Index -1 represents the leader, 0+ are teammates
// When showSpinnerTree is true, index === teammateCount is the "hide" row
// Third case: when pipe IPC is active and no teammates/background tasks, toggle pipe selector
if (e.shift && (e.key === 'up' || e.key === 'down')) {
e.preventDefault()
if (teammateCount > 0) {
stepTeammateSelection(e.key === 'down' ? 1 : -1, setAppState)
} else if (hasNonTeammateBackgroundTasks) {
options?.onOpenBackgroundTasks?.()
} else if (
e.key === 'down' &&
pipeIpc?.statusVisible &&
options?.onTogglePipeSelector
) {
// Shift+Down opens pipe selector when pipe IPC is active and no other navigation targets
options.onTogglePipeSelector()
}
return
}

View File

@@ -0,0 +1,327 @@
/**
* useMasterMonitor — master-side slave registry helpers plus an optional hook
*
* The module-level registry helpers are the live integration point used by
* attach/send/status flows. The hook remains available for history syncing if
* a caller wants AppState to mirror slave session events.
*
* The master CLI itself remains fully functional — this hook only collects
* data from slaves for review via /history and /status commands.
*/
import { useEffect, useSyncExternalStore } from 'react'
import { useAppState, useSetAppState } from '../state/AppState.js'
import {
getPipeIpc,
type PipeClient,
type PipeMessage,
type PipeIpcSlaveState,
} from '../utils/pipeTransport.js'
import { logForDebugging } from '../utils/debug.js'
/** Session history entry for pipe IPC monitoring. */
export type SessionEntry = {
type: string
content: string
from: string
timestamp: string
meta?: Record<string, unknown>
}
function summarizePipeEntry(entry: SessionEntry): string | undefined {
const content = entry.content.trim()
switch (entry.type) {
case 'prompt':
return content ? `Queued: ${content}` : 'Queued prompt'
case 'prompt_ack':
return content || 'Accepted'
case 'stream':
return content || undefined
case 'tool_start':
return content ? `Tool: ${content}` : 'Tool started'
case 'tool_result':
return content ? `Tool result: ${content}` : 'Tool completed'
case 'done':
return content || 'Completed'
case 'error':
return content || 'Error'
default:
return content || undefined
}
}
function statusForPipeEntry(
currentStatus: PipeIpcSlaveState['status'],
entryType: SessionEntry['type'],
): PipeIpcSlaveState['status'] {
switch (entryType) {
case 'prompt':
case 'prompt_ack':
case 'stream':
case 'tool_start':
case 'tool_result':
return 'busy'
case 'done':
return 'idle'
case 'error':
return 'error'
default:
return currentStatus
}
}
export function applyPipeEntryToSlaveState(
slave: PipeIpcSlaveState,
entry: SessionEntry,
): PipeIpcSlaveState {
return {
...slave,
status: statusForPipeEntry(slave.status, entry.type),
lastActivityAt: entry.timestamp,
lastSummary: summarizePipeEntry(entry),
lastEventType: entry.type as PipeIpcSlaveState['lastEventType'],
unreadCount: (slave.unreadCount ?? 0) + 1,
history: [...slave.history, entry],
}
}
/**
* Module-level registry of connected slave PipeClients.
* Keyed by slave pipe name. Managed by /attach and /detach commands.
*/
const _slaveClients = new Map<string, PipeClient>()
const _slaveClientRegistryListeners = new Set<() => void>()
const _pipeEntryListeners = new Set<
(slaveName: string, entry: SessionEntry) => void
>()
const _pipeEntryHandlers = new Map<string, (msg: PipeMessage) => void>()
let _slaveClientRegistryVersion = 0
const MONITORED_PIPE_ENTRY_TYPES = [
'prompt_ack',
'stream',
'tool_start',
'tool_result',
'done',
'error',
'prompt',
'permission_request',
'permission_cancel',
]
function isMonitoredPipeEntryType(type: string): boolean {
return MONITORED_PIPE_ENTRY_TYPES.includes(type)
}
function pipeMessageToSessionEntry(
slaveName: string,
msg: PipeMessage,
): SessionEntry {
return {
type: msg.type as SessionEntry['type'],
content: msg.data ?? '',
from: msg.from ?? slaveName,
timestamp: msg.ts ?? new Date().toISOString(),
meta: msg.meta,
}
}
function emitPipeEntry(slaveName: string, entry: SessionEntry): void {
for (const listener of _pipeEntryListeners) {
listener(slaveName, entry)
}
}
export function subscribePipeEntries(
listener: (slaveName: string, entry: SessionEntry) => void,
): () => void {
_pipeEntryListeners.add(listener)
return () => {
_pipeEntryListeners.delete(listener)
}
}
function detachPipeEntryEmitter(name: string, client?: PipeClient): void {
const handler = _pipeEntryHandlers.get(name)
if (!handler) return
client?.removeListener?.('message', handler)
_pipeEntryHandlers.delete(name)
}
function attachPipeEntryEmitter(name: string, client: PipeClient): void {
detachPipeEntryEmitter(name, _slaveClients.get(name))
if (typeof client.on !== 'function') return
const handler = (msg: PipeMessage) => {
if (!isMonitoredPipeEntryType(msg.type)) return
emitPipeEntry(name, pipeMessageToSessionEntry(name, msg))
}
_pipeEntryHandlers.set(name, handler)
client.on('message', handler)
}
function emitSlaveClientRegistryChanged(): void {
_slaveClientRegistryVersion += 1
for (const listener of _slaveClientRegistryListeners) {
listener()
}
}
function subscribeToSlaveClientRegistry(listener: () => void): () => void {
_slaveClientRegistryListeners.add(listener)
return () => {
_slaveClientRegistryListeners.delete(listener)
}
}
function getSlaveClientRegistryVersion(): number {
return _slaveClientRegistryVersion
}
export function addSlaveClient(name: string, client: PipeClient): void {
attachPipeEntryEmitter(name, client)
_slaveClients.set(name, client)
emitSlaveClientRegistryChanged()
}
export function removeSlaveClient(name: string): PipeClient | undefined {
const client = _slaveClients.get(name)
detachPipeEntryEmitter(name, client)
_slaveClients.delete(name)
emitSlaveClientRegistryChanged()
return client
}
export function getSlaveClient(name: string): PipeClient | undefined {
return _slaveClients.get(name)
}
export function getAllSlaveClients(): Map<string, PipeClient> {
return _slaveClients
}
export type ConnectedSlaveTarget = {
name: string
client: PipeClient
}
/**
* Resolve a selection list to currently connected slave clients.
*
* The pipe selector can include discovered-but-not-attached names. Routing
* should only treat attached, connected clients as broadcast targets.
*/
export function getConnectedSlaveTargets(
selectedNames: string[],
): ConnectedSlaveTarget[] {
const targets: ConnectedSlaveTarget[] = []
for (const name of selectedNames) {
const client = _slaveClients.get(name)
if (client?.connected) {
targets.push({ name, client })
}
}
return targets
}
export function resetSlaveClientsForTesting(): void {
for (const [name, client] of _slaveClients.entries()) {
detachPipeEntryEmitter(name, client)
}
_slaveClients.clear()
emitSlaveClientRegistryChanged()
}
export function useMasterMonitor(): void {
const role = useAppState(s => getPipeIpc(s).role)
const setAppState = useSetAppState()
const registryVersion = useSyncExternalStore(
subscribeToSlaveClientRegistry,
getSlaveClientRegistryVersion,
getSlaveClientRegistryVersion,
)
useEffect(() => {
if (role !== 'master' && _slaveClients.size === 0) return
// Set up listeners for each connected slave client
const cleanups: (() => void)[] = []
for (const [slaveName, client] of _slaveClients.entries()) {
const handler = (msg: PipeMessage) => {
const entry = pipeMessageToSessionEntry(slaveName, msg)
// Only record relevant message types
if (!isMonitoredPipeEntryType(msg.type)) {
return
}
setAppState(prev => {
const slave = getPipeIpc(prev).slaves[slaveName]
if (!slave) return prev
const newStatus =
msg.type === 'done' || msg.type === 'error'
? 'idle'
: msg.type === 'prompt'
? 'busy'
: slave.status
return {
...prev,
pipeIpc: {
...getPipeIpc(prev),
slaves: {
...getPipeIpc(prev).slaves,
[slaveName]: applyPipeEntryToSlaveState(
{
...slave,
status: newStatus,
},
entry,
),
},
},
}
})
if (msg.type === 'done') {
logForDebugging(`[MasterMonitor] Slave "${slaveName}" turn complete`)
}
}
client.on('message', handler)
// Handle slave disconnect
const onDisconnect = () => {
logForDebugging(`[MasterMonitor] Slave "${slaveName}" disconnected`)
removeSlaveClient(slaveName)
setAppState(prev => {
const { [slaveName]: _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,
},
}
})
}
client.on('disconnect', onDisconnect)
cleanups.push(() => {
client.removeListener('message', handler)
client.removeListener('disconnect', onDisconnect)
})
}
return () => {
for (const cleanup of cleanups) {
cleanup()
}
}
}, [registryVersion, role, setAppState])
}

623
src/hooks/usePipeIpc.ts Normal file
View File

@@ -0,0 +1,623 @@
/**
* usePipeIpc — Pipe IPC lifecycle hook.
*
* Extracted from REPL.tsx's 575-line inline useEffect. Manages:
* 1. Server creation (UDS + optional TCP for LAN)
* 2. LAN beacon startup
* 3. Message handlers (ping, attach, prompt, permission, detach)
* 4. Heartbeat loop (main: auto-attach + cleanup; sub: detect main alive)
* 5. Cleanup on unmount
*
* Feature-gated by UDS_INBOX. LAN extensions gated by LAN_PIPES.
*/
import { feature } from 'bun:bundle'
import { useEffect } from 'react'
import type {
PipeMessage,
PipeServer,
PipeIpcState,
} from '../utils/pipeTransport.js'
// Lazy-loaded module accessors (cached by Bun/Node require)
/* eslint-disable @typescript-eslint/no-require-imports */
const pt = () =>
require('../utils/pipeTransport.js') as typeof import('../utils/pipeTransport.js')
const pr = () =>
require('../utils/pipeRegistry.js') as typeof import('../utils/pipeRegistry.js')
const mm = () =>
require('./useMasterMonitor.js') as typeof import('./useMasterMonitor.js')
const bs = () =>
require('../bootstrap/state.js') as typeof import('../bootstrap/state.js')
const lb = () =>
require('../utils/lanBeacon.js') as typeof import('../utils/lanBeacon.js')
const pp = () =>
require('../utils/pipePermissionRelay.js') as typeof import('../utils/pipePermissionRelay.js')
const osm = () => require('os') as typeof import('os')
/* eslint-enable @typescript-eslint/no-require-imports */
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type StoreApi = {
getState: () => any
setState: (updater: (prev: any) => any) => void
}
export type UsePipeIpcOptions = {
store: StoreApi
handleIncomingPrompt: (content: string) => boolean
}
// ---------------------------------------------------------------------------
// Helper: remove a dead slave from registry + state
// ---------------------------------------------------------------------------
function removeDeadSlave(slaveName: string, store: StoreApi): void {
mm().removeSlaveClient(slaveName)
store.setState((prev: any) => {
const pipeIpc = pt().getPipeIpc(prev)
const { [slaveName]: _removed, ...remainingSlaves } = pipeIpc.slaves
return {
...prev,
pipeIpc: {
...pipeIpc,
role: Object.keys(remainingSlaves).length > 0 ? 'master' : 'main',
displayRole:
Object.keys(remainingSlaves).length > 0 ? 'master' : 'main',
slaves: remainingSlaves,
selectedPipes: (pipeIpc.selectedPipes ?? []).filter(
(name: string) => name !== slaveName,
),
discoveredPipes: (pipeIpc.discoveredPipes ?? []).filter(
(pipe: { pipeName: string }) => pipe.pipeName !== slaveName,
),
},
}
})
}
// ---------------------------------------------------------------------------
// Helper: refresh discovered pipes (local subs + LAN peers)
// ---------------------------------------------------------------------------
function refreshDiscoveredPipes(
pipeName: string,
aliveSubs: Array<{
id: string
pipeName: string
subIndex: number
machineId: string
ip: string
hostname: string
}>,
store: StoreApi,
): void {
const freshDiscovered = aliveSubs
.filter(sub => sub.pipeName !== pipeName)
.map(sub => ({
id: sub.id,
pipeName: sub.pipeName,
role: `sub-${sub.subIndex}`,
machineId: sub.machineId,
ip: sub.ip,
hostname: sub.hostname,
alive: true,
}))
// Include LAN beacon peers so they aren't wiped out by heartbeat
let lanDiscovered: typeof freshDiscovered = []
if (feature('LAN_PIPES')) {
const beacon = lb().getLanBeacon()
if (beacon) {
const localNames = new Set(freshDiscovered.map(p => p.pipeName))
localNames.add(pipeName)
for (const [pName, peer] of beacon.getPeers()) {
if (!localNames.has(pName)) {
lanDiscovered.push({
id: `lan-${pName}`,
pipeName: pName,
role: peer.role,
machineId: peer.machineId,
ip: peer.ip,
hostname: peer.hostname,
alive: true,
})
}
}
}
}
const allDiscovered = [...freshDiscovered, ...lanDiscovered]
// Only update state if the list actually changed
const prev = pt().getPipeIpc(store.getState())
const prevNames = (prev.discoveredPipes ?? [])
.map((p: any) => p.pipeName)
.join(',')
const newNames = allDiscovered.map(p => p.pipeName).join(',')
if (prevNames === newNames) return
store.setState((prev: any) => {
const pipeIpc = pt().getPipeIpc(prev)
const aliveNames = new Set(allDiscovered.map(pipe => pipe.pipeName))
return {
...prev,
pipeIpc: {
...pipeIpc,
discoveredPipes: allDiscovered,
selectedPipes: (pipeIpc.selectedPipes ?? []).filter((name: string) =>
aliveNames.has(name),
),
},
}
})
}
// ---------------------------------------------------------------------------
// Phase: Register message handlers on server
// ---------------------------------------------------------------------------
function registerMessageHandlers(
server: PipeServer,
pipeName: string,
machineId: string,
store: StoreApi,
handleIncomingPrompt: (content: string) => boolean,
): void {
// Auto-reply pings for health checks
server.onMessage((msg: PipeMessage, reply) => {
if (msg.type === 'ping') reply({ type: 'pong' })
})
// Handle attach requests
server.onMessage((msg: PipeMessage, reply) => {
if (msg.type !== 'attach_request') return
const state = store.getState()
const currentPipeState = pt().getPipeIpc(state)
if (pt().isPipeControlled(currentPipeState)) {
reply({ type: 'attach_reject', data: 'Already controlled' })
return
}
// Allow LAN peers (different machineId) to attach regardless of role.
const isLanPeer = msg.meta?.machineId && msg.meta.machineId !== machineId
if (!isLanPeer && currentPipeState.role !== 'sub') {
reply({
type: 'attach_reject',
data: 'Only sub sessions can be attached.',
})
return
}
reply({ type: 'attach_accept' })
const clients = Array.from((server as any).clients as Set<any>)
const masterSocket = clients[clients.length - 1]
pp().setPipeRelay((relayMsg: any) => {
if (masterSocket && !masterSocket.destroyed) {
relayMsg.from = relayMsg.from ?? pipeName
relayMsg.ts = relayMsg.ts ?? new Date().toISOString()
masterSocket.write(JSON.stringify(relayMsg) + '\n')
}
})
store.setState((prev: any) => ({
...prev,
pipeIpc: {
...pt().getPipeIpc(prev),
role: 'sub',
displayRole: pt().getPipeDisplayRole(pt().getPipeIpc(prev)),
attachedBy: msg.from ?? 'unknown',
},
}))
})
// Handle prompts from master
server.onMessage((msg: PipeMessage, reply) => {
if (msg.type === 'prompt' && msg.data) {
const accepted = handleIncomingPrompt(msg.data)
if (accepted) {
reply({ type: 'prompt_ack', data: 'accepted' })
} else {
reply({
type: 'error',
data: 'Slave is busy and could not accept the prompt.',
})
}
}
})
// Handle permission decisions from master
server.onMessage((msg: PipeMessage, _reply) => {
if (msg.type !== 'permission_response' && msg.type !== 'permission_cancel')
return
const { resolvePipePermissionResponse, cancelPipePermissionRequest } =
require('../utils/pipePermissionRelay.js') as typeof import('../utils/pipePermissionRelay.js')
try {
const payload = msg.data ? JSON.parse(msg.data) : undefined
if (!payload?.requestId) return
if (msg.type === 'permission_response') {
resolvePipePermissionResponse(payload)
} else {
cancelPipePermissionRequest(payload.requestId, payload.reason)
}
} catch {
// Malformed — ignore
}
})
// Handle detach
server.onMessage((msg: PipeMessage, _reply) => {
if (msg.type !== 'detach') return
const { clearPendingPipePermissions } =
require('../utils/pipePermissionRelay.js') as typeof import('../utils/pipePermissionRelay.js')
clearPendingPipePermissions('Pipe detached before permission was resolved.')
pp().setPipeRelay(null)
store.setState((prev: any) => ({
...prev,
pipeIpc: (() => {
const pipeIpc = pt().getPipeIpc(prev)
const nextRole = pipeIpc.subIndex != null ? 'sub' : 'main'
const nextPipeState = { ...pipeIpc, role: nextRole, attachedBy: null }
return {
...nextPipeState,
displayRole: pt().getPipeDisplayRole(nextPipeState as PipeIpcState),
}
})(),
}))
})
}
// ---------------------------------------------------------------------------
// Phase: Heartbeat
// ---------------------------------------------------------------------------
function runMainHeartbeat(
pipeName: string,
machineId: string,
store: StoreApi,
disposed: { current: boolean },
): void {
void (async () => {
try {
await pr().cleanupStaleEntries()
const aliveSubs = await pr().getAliveSubs()
refreshDiscoveredPipes(pipeName, aliveSubs, store)
const connectedSlaves = mm().getAllSlaveClients()
const aliveSubNames = new Set(aliveSubs.map(sub => sub.pipeName))
// Build unified attach target list: local subs + LAN peers
type AttachTarget = {
pipeName: string
tcpEndpoint?: { host: string; port: number }
}
const attachTargets: AttachTarget[] = aliveSubs.map(sub => ({
pipeName: sub.pipeName,
}))
// Add LAN peers as attach targets
if (feature('LAN_PIPES')) {
const beacon = lb().getLanBeacon()
if (beacon) {
const localNames = new Set(attachTargets.map(t => t.pipeName))
localNames.add(pipeName)
for (const [pName, peer] of beacon.getPeers()) {
if (!localNames.has(pName)) {
attachTargets.push({
pipeName: pName,
tcpEndpoint: { host: peer.ip, port: peer.tcpPort },
})
aliveSubNames.add(pName)
}
}
}
}
const currentPipeState = pt().getPipeIpc(store.getState())
for (const target of attachTargets) {
if (target.pipeName === pipeName) continue
if (connectedSlaves.has(target.pipeName)) continue
try {
const myName = currentPipeState.serverName ?? pipeName
const client = await pt().connectToPipe(
target.pipeName,
myName,
3000,
target.tcpEndpoint,
)
const attached = await new Promise<boolean>(resolve => {
const timeout = setTimeout(() => {
client.disconnect()
resolve(false)
}, 3000)
client.onMessage((msg: any) => {
if (msg.type === 'attach_accept') {
clearTimeout(timeout)
resolve(true)
} else if (msg.type === 'attach_reject') {
clearTimeout(timeout)
client.disconnect()
resolve(false)
}
})
client.send({
type: 'attach_request',
meta: { machineId },
})
})
if (attached && !disposed.current) {
mm().addSlaveClient(target.pipeName, client)
client.on('disconnect', () => {
removeDeadSlave(target.pipeName, store)
})
store.setState((prev: any) => ({
...prev,
pipeIpc: {
...pt().getPipeIpc(prev),
role: 'master',
displayRole: 'master',
slaves: {
...pt().getPipeIpc(prev).slaves,
[target.pipeName]: {
name: target.pipeName,
connectedAt: new Date().toISOString(),
status: 'idle',
unreadCount: 0,
history: [],
},
},
},
}))
}
} catch {
// Connection failed — skip this cycle
}
}
// Clean up slaves that are no longer alive
let lanPeerNames: Set<string> | null = null
if (feature('LAN_PIPES')) {
const beacon = lb().getLanBeacon()
if (beacon) {
lanPeerNames = new Set(beacon.getPeers().keys())
}
}
for (const [slaveName, client] of connectedSlaves.entries()) {
const inLocalRegistry = aliveSubNames.has(slaveName)
const inLanBeacon = lanPeerNames?.has(slaveName) ?? false
if (!client.connected || (!inLocalRegistry && !inLanBeacon)) {
removeDeadSlave(slaveName, store)
}
}
} catch {
// Heartbeat cycle error — non-fatal
}
})()
}
function runSubHeartbeat(
pipeName: string,
machineId: string,
entry: any,
store: StoreApi,
disposed: { current: boolean },
): void {
void (async () => {
try {
const mainAlive = await pr().isMainAlive()
if (!mainAlive && !disposed.current) {
const registry = await pr().readRegistry()
const isSameMachine = pr().isMainMachine(machineId, registry)
if (isSameMachine) {
await pr().registerAsMain(entry)
} else {
await pr().revertToIndependent(pipeName)
}
store.setState((prev: any) => ({
...prev,
pipeIpc: {
...pt().getPipeIpc(prev),
role: 'main',
subIndex: null,
displayRole: 'main',
attachedBy: null,
},
}))
pp().setPipeRelay(null)
}
} catch {
// Heartbeat check error — non-fatal
}
})()
}
// ---------------------------------------------------------------------------
// Main hook
// ---------------------------------------------------------------------------
export function usePipeIpc({
store,
handleIncomingPrompt,
}: UsePipeIpcOptions): void {
if (!feature('UDS_INBOX')) return
useEffect(() => {
const pipeName = `cli-${bs().getSessionId().slice(0, 8)}`
const disposed = { current: false }
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
let heartbeatBusy = false
let pipeServer: PipeServer | null = null
void (async () => {
try {
// --- Phase 1: Role determination ---
const machId = await pr().getMachineId()
const mac = pr().getMacAddress()
const localIp = pt().getLocalIp()
const host = osm().hostname()
const roleResult = await pr().determineRole(machId)
const entry = {
id: pipeName,
pid: process.pid,
machineId: machId,
startedAt: Date.now(),
ip: localIp,
mac,
hostname: host,
pipeName,
}
let initialRole: 'main' | 'sub' = 'main'
let subIndex: number | null = null
let displayRole = 'main'
if (roleResult.role === 'main' || roleResult.role === 'main-recover') {
await pr().registerAsMain(entry)
} else {
subIndex = roleResult.subIndex
await pr().registerAsSub(entry, subIndex)
initialRole = 'sub'
displayRole = `sub-${subIndex}`
}
// --- Phase 2: Server creation ---
const server = await pt().createPipeServer(
pipeName,
feature('LAN_PIPES') ? { enableTcp: true, tcpPort: 0 } : undefined,
)
pipeServer = server
if (disposed.current) {
await server.close()
await pr().unregister(pipeName)
return
}
// --- Phase 3: LAN beacon ---
if (feature('LAN_PIPES') && server.tcpAddress) {
const beacon = new (lb().LanBeacon)({
pipeName,
machineId: machId,
hostname: host,
ip: localIp,
tcpPort: server.tcpAddress.port,
role: initialRole,
})
beacon.start()
lb().setLanBeacon(beacon)
const entryWithTcp = {
...entry,
tcpPort: server.tcpAddress.port,
lanVisible: true,
}
if (initialRole === 'main') {
await pr().registerAsMain(entryWithTcp)
} else if (subIndex != null) {
await pr().registerAsSub(entryWithTcp, subIndex)
}
}
// Update store
store.setState((prev: any) => ({
...prev,
pipeIpc: {
...pt().getPipeIpc(prev),
serverName: pipeName,
role: initialRole,
subIndex,
displayRole,
localIp,
hostname: host,
machineId: machId,
mac,
},
}))
// --- Phase 4: Message handlers ---
registerMessageHandlers(
server,
pipeName,
machId,
store,
handleIncomingPrompt,
)
// --- Phase 5: Heartbeat ---
const HEARTBEAT_INTERVAL_MS = 5000
heartbeatTimer = setInterval(() => {
if (disposed.current || heartbeatBusy) return
heartbeatBusy = true
const currentPipeState = pt().getPipeIpc(store.getState())
if (
currentPipeState.role === 'main' ||
currentPipeState.role === 'master'
) {
runMainHeartbeat(pipeName, machId, store, disposed)
} else if (currentPipeState.role === 'sub') {
runSubHeartbeat(pipeName, machId, entry, store, disposed)
}
// Reset busy flag after a short delay to allow the async work to settle
setTimeout(() => {
heartbeatBusy = false
}, 4000)
}, HEARTBEAT_INTERVAL_MS)
} catch {
// PipeServer creation failed — non-fatal
}
})()
// --- Phase 6: Cleanup ---
return () => {
disposed.current = true
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
// Send detach to all slaves
const allClients = mm().getAllSlaveClients()
for (const [name, client] of allClients.entries()) {
try {
client.send({ type: 'detach' })
} catch {}
client.disconnect()
removeDeadSlave(name, store)
}
// Stop LAN beacon
const beacon = lb().getLanBeacon()
if (beacon) {
try {
beacon.stop()
} catch {}
lb().setLanBeacon(null)
}
// Unregister + close server
void pr()
.unregister(pipeName)
.catch(() => {})
if (pipeServer) {
void pipeServer.close().catch(() => {})
pipeServer = null
}
pp().setPipeRelay(null)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
}

View File

@@ -0,0 +1,195 @@
/**
* usePipePermissionForward — Forward slave permission requests to master UI.
*
* Subscribes to slave pipe messages via subscribePipeEntries, and:
* 1. permission_request → enqueue into toolUseConfirmQueue for master approval
* 2. permission_cancel → remove from queue
* 3. stream/error/done → display as system messages
*/
import { feature } from 'bun:bundle'
import { useEffect } from 'react'
import type { Tool, ToolUseContext } from '../Tool.js'
import type { MessageType } from '../types/message.js'
type Deps = {
store: { getState: () => any }
tools: Tool<any, any>[]
setMessages: (action: React.SetStateAction<MessageType[]>) => void
setToolUseConfirmQueue: (action: React.SetStateAction<any[]>) => void
getToolUseContext: (...args: any[]) => ToolUseContext
mainLoopModel: string
}
export function usePipePermissionForward({
store,
tools,
setMessages,
setToolUseConfirmQueue,
getToolUseContext,
mainLoopModel,
}: Deps): void {
useEffect(() => {
if (!feature('UDS_INBOX')) return
/* eslint-disable @typescript-eslint/no-require-imports */
const { subscribePipeEntries, getSlaveClient } =
require('./useMasterMonitor.js') as typeof import('./useMasterMonitor.js')
const { getPipeIpc } =
require('../utils/pipeTransport.js') as typeof import('../utils/pipeTransport.js')
const { createAssistantMessage, createSystemMessage } =
require('../utils/messages.js') as typeof import('../utils/messages.js')
/* eslint-enable @typescript-eslint/no-require-imports */
return subscribePipeEntries(
(pipeName: string, entry: { type: string; content: string }) => {
const content = entry.content.trim()
const pipeIpcState = getPipeIpc(store.getState())
const peerInfo = (pipeIpcState.discoveredPipes ?? []).find(
(pipe: { pipeName: string }) => pipe.pipeName === pipeName,
)
const isLan = peerInfo?.ip && peerInfo.ip !== pipeIpcState.localIp
const displayRole = peerInfo
? isLan
? `${peerInfo.role} ${peerInfo.hostname}/${peerInfo.ip}`
: peerInfo.role
: pipeName
if (entry.type === 'permission_request') {
try {
const payload = JSON.parse(content)
const tool = tools.find(
candidate => candidate.name === payload.toolName,
)
const client = getSlaveClient(pipeName)
if (!client) return
if (!tool) {
client.send({
type: 'permission_response',
data: JSON.stringify({
requestId: payload.requestId,
behavior: 'deny',
feedback: `Tool "${payload.toolName}" is not available in main.`,
}),
})
return
}
const assistantMessage = createAssistantMessage({ content: '' })
const toolUseContext = getToolUseContext(
[],
[],
new AbortController(),
mainLoopModel,
)
setToolUseConfirmQueue((queue: any[]) => [
...queue,
{
assistantMessage,
tool,
description: payload.description,
input: payload.input,
toolUseContext,
toolUseID: `pipe:${payload.requestId}`,
permissionResult: payload.permissionResult,
permissionPromptStartTimeMs:
payload.permissionPromptStartTimeMs,
workerBadge: {
name: `${displayRole} / ${pipeName}`,
color: 'cyan',
},
onUserInteraction() {},
onAbort() {
client.send({
type: 'permission_response',
data: JSON.stringify({
requestId: payload.requestId,
behavior: 'deny',
feedback: 'Permission request was aborted in main.',
}),
})
},
onAllow(
updatedInput: any,
permissionUpdates: any,
feedback: any,
contentBlocks: any,
) {
client.send({
type: 'permission_response',
data: JSON.stringify({
requestId: payload.requestId,
behavior: 'allow',
updatedInput,
permissionUpdates,
feedback,
contentBlocks,
}),
})
},
onReject(feedback: any, contentBlocks: any) {
client.send({
type: 'permission_response',
data: JSON.stringify({
requestId: payload.requestId,
behavior: 'deny',
feedback,
contentBlocks,
}),
})
},
async recheckPermission() {},
},
])
} catch {
// Malformed permission request — ignore
}
return
}
if (entry.type === 'permission_cancel') {
try {
const payload = JSON.parse(content)
setToolUseConfirmQueue((queue: any[]) =>
queue.filter(
(item: any) => item.toolUseID !== `pipe:${payload.requestId}`,
),
)
} catch {
// Malformed — ignore
}
return
}
let message: any = null
if (entry.type === 'stream' && content) {
message = createSystemMessage(
`[${displayRole} / ${pipeName}] ${content}`,
'warning',
)
} else if (entry.type === 'error') {
message = createSystemMessage(
`[${displayRole} / ${pipeName}] Error: ${content || 'no output'}`,
'error',
)
} else if (entry.type === 'done') {
message = createSystemMessage(
`[${displayRole} / ${pipeName}] Completed`,
'warning',
)
}
if (message) {
setMessages((prev: MessageType[]) => [...prev, message])
}
},
)
}, [
getToolUseContext,
mainLoopModel,
setMessages,
setToolUseConfirmQueue,
store,
tools,
])
}

39
src/hooks/usePipeRelay.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* usePipeRelay — Pipe message relay utilities for slave → master communication.
*
* Provides `relayPipeMessage` and `pipeReturnHadErrorRef` for use in
* onQuery callbacks. The relay function reads from the module-level
* `getPipeRelay()` singleton set by usePipeIpc's attach handler.
*/
import { useRef, useCallback } from 'react'
import { getPipeRelay } from '../utils/pipePermissionRelay.js'
import type { PipeMessage } from '../utils/pipeTransport.js'
export type PipeRelayHandle = {
/** Send a relay message to the master. Returns false if no relay is active. */
relayPipeMessage: (message: PipeMessage) => boolean
/** Tracks whether an error was already relayed for this query turn. */
pipeReturnHadErrorRef: React.MutableRefObject<boolean>
}
/**
* Hook that provides pipe relay utilities. Safe to call unconditionally —
* when UDS_INBOX is off, the relay function is a no-op that returns false.
*/
export function usePipeRelay(): PipeRelayHandle {
const pipeReturnHadErrorRef = useRef(false)
const relayPipeMessage = useCallback(
(message: PipeMessage): boolean => {
const relay = getPipeRelay()
if (typeof relay !== 'function') {
return false
}
relay(message)
return true
},
[],
)
return { relayPipeMessage, pipeReturnHadErrorRef }
}

151
src/hooks/usePipeRouter.ts Normal file
View File

@@ -0,0 +1,151 @@
/**
* usePipeRouter — Route user input to selected pipe targets.
*
* Returns `routeToSelectedPipes(input)` which checks selectedPipes +
* routeMode and sends the prompt to all connected slave targets.
* Returns true if routed (caller should skip local execution).
*/
import { feature } from 'bun:bundle'
import { useCallback } from 'react'
type StoreApi = { getState: () => any }
type SetAppState = (updater: (prev: any) => any) => void
type AddNotification = (opts: {
key: string
text: string
color: string
priority: string
timeoutMs: number
}) => void
type Deps = {
store: StoreApi
setAppState: SetAppState
addNotification: AddNotification
}
/**
* Attempt to route user input to selected pipes.
* Returns true if routed to at least one pipe (skip local execution).
*/
export function usePipeRouter({ store, setAppState, addNotification }: Deps): {
routeToSelectedPipes: (input: string) => boolean
} {
const routeToSelectedPipes = useCallback(
(input: string): boolean => {
if (!feature('UDS_INBOX')) return false
if (!input.trim() || input.trim().startsWith('/')) return false
/* eslint-disable @typescript-eslint/no-require-imports */
const pipeState = (store.getState() as any).pipeIpc
const selectedPipes: string[] = pipeState?.selectedPipes ?? []
const routeMode: 'selected' | 'local' = pipeState?.routeMode ?? 'selected'
if (selectedPipes.length === 0 || routeMode === 'local') return false
const { getConnectedSlaveTargets } =
require('./useMasterMonitor.js') as typeof import('./useMasterMonitor.js')
const { getPipeIpc } =
require('../utils/pipeTransport.js') as typeof import('../utils/pipeTransport.js')
/* eslint-enable @typescript-eslint/no-require-imports */
const targets = getConnectedSlaveTargets(selectedPipes)
const pipeIpcForDisplay = getPipeIpc(store.getState())
const discovered: Array<{
pipeName: string
role: string
ip: string
hostname: string
}> = pipeIpcForDisplay.discoveredPipes ?? []
const sentTargetNames: string[] = []
const sentTargetLabels: string[] = []
const failedTargetNames: string[] = []
for (const { name, client } of targets) {
try {
client.send({ type: 'prompt', data: input.trim() })
sentTargetNames.push(name)
// Build display label: [role] hostname/ip for LAN, [role] for local
const info = discovered.find((d: any) => d.pipeName === name)
if (info) {
const isLan = info.ip && info.ip !== pipeIpcForDisplay.localIp
sentTargetLabels.push(
isLan
? `[${info.role}] ${info.hostname}/${info.ip}`
: `[${info.role}]`,
)
} else {
sentTargetLabels.push(name)
}
} catch {
failedTargetNames.push(name)
}
}
if (sentTargetNames.length > 0) {
const promptText = input.trim()
const promptTimestamp = new Date().toISOString()
setAppState((prev: any) => {
const pipeIpc = getPipeIpc(prev)
const nextSlaves = { ...pipeIpc.slaves }
for (const name of sentTargetNames) {
const slave = nextSlaves[name]
if (!slave) continue
nextSlaves[name] = {
...slave,
status: 'busy',
lastActivityAt: promptTimestamp,
lastSummary: `Queued: ${promptText}`,
lastEventType: 'prompt',
history: [
...slave.history,
{
type: 'prompt',
content: promptText,
from: pipeIpc.serverName ?? 'master',
timestamp: promptTimestamp,
},
],
}
}
return {
...prev,
pipeIpc: { ...pipeIpc, slaves: nextSlaves },
}
})
addNotification({
key: 'pipe-route-success',
text: `Routed to ${sentTargetLabels.join(', ')}; main can continue other tasks`,
color: 'success',
priority: 'immediate',
timeoutMs: 3000,
})
} else {
addNotification({
key: 'pipe-route-fallback',
text: 'Selected pipes are unavailable; processing locally.',
color: 'warning',
priority: 'immediate',
timeoutMs: 4000,
})
}
if (failedTargetNames.length > 0) {
addNotification({
key: 'pipe-route-partial-failure',
text: `Failed to send to: ${failedTargetNames.join(', ')}`,
color: 'warning',
priority: 'immediate',
timeoutMs: 4000,
})
}
return sentTargetNames.length > 0
},
[store, setAppState, addNotification],
)
return { routeToSelectedPipes }
}

View File

@@ -0,0 +1,122 @@
/**
* useSlaveNotifications — Real-time toast notifications for slave CLI events
*
* When role === 'master', watches slave session history for key events
* and shows toast notifications in the master CLI footer.
*/
import { useEffect, useRef } from 'react'
import { useNotifications } from '../context/notifications.js'
import { useAppState } from '../state/AppState.js'
import { getPipeIpc } from '../utils/pipeTransport.js'
import type { SessionEntry } from './useMasterMonitor.js'
import type { Notification } from '../context/notifications.js'
function foldSlaveNotif(
acc: Notification,
_incoming: Notification,
): Notification {
if (!('text' in acc)) return acc
const match = acc.text.match(/\((\d+)\)$/)
const count = match ? parseInt(match[1], 10) + 1 : 2
const base = acc.text.replace(/\s*\(\d+\)$/, '')
return {
...acc,
text: `${base} (${count})`,
fold: foldSlaveNotif,
}
}
function truncate(s: string, max: number): string {
if (s.length <= max) return s
return s.slice(0, max) + '…'
}
export function useSlaveNotifications(): void {
const role = useAppState(s => getPipeIpc(s).role)
const slaves = useAppState(s => getPipeIpc(s).slaves)
const { addNotification } = useNotifications()
const lastSeenRef = useRef<Record<string, number>>({})
useEffect(() => {
if (role !== 'master') return
for (const [name, slave] of Object.entries(slaves)) {
const lastSeen = lastSeenRef.current[name] ?? 0
const newEntries = slave.history.slice(lastSeen)
lastSeenRef.current[name] = slave.history.length
for (const entry of newEntries) {
const notification = makeNotification(name, entry)
if (notification) {
addNotification(notification)
}
}
}
for (const name of Object.keys(lastSeenRef.current)) {
if (!(name in slaves)) {
delete lastSeenRef.current[name]
}
}
}, [addNotification, role, slaves])
}
function makeNotification(
slaveName: string,
entry: SessionEntry,
): Notification | null {
const shortName =
slaveName.length > 16 ? `${slaveName.slice(0, 16)}` : slaveName
switch (entry.type) {
case 'prompt_ack':
return {
key: `slave-ack-${slaveName}`,
text: `[${shortName}] ✓ 已接收任务`,
priority: 'low',
timeoutMs: 2500,
fold: foldSlaveNotif,
}
case 'done':
return {
key: `slave-done-${slaveName}`,
text: `[${shortName}] ✓ 任务完成`,
priority: 'medium',
timeoutMs: 5000,
fold: foldSlaveNotif,
}
case 'error':
return {
key: `slave-error-${slaveName}`,
text: `[${shortName}] ✗ 错误: ${truncate(entry.content, 60)}`,
color: 'error',
priority: 'high',
timeoutMs: 8000,
}
case 'tool_start':
return {
key: `slave-tool-${slaveName}`,
text: `[${shortName}] 工具: ${truncate(entry.content, 40)}`,
priority: 'low',
timeoutMs: 3000,
fold: foldSlaveNotif,
}
case 'prompt':
return {
key: `slave-prompt-${slaveName}`,
text: `[${shortName}] ▶ 开始处理: ${truncate(entry.content, 50)}`,
priority: 'medium',
timeoutMs: 4000,
}
case 'stream':
case 'tool_result':
default:
return null
}
}

View File

@@ -1128,8 +1128,9 @@ export async function main() {
const hasPrintFlag = cliArgs.includes("-p") || cliArgs.includes("--print");
const hasInitOnlyFlag = cliArgs.includes("--init-only");
const hasSdkUrl = cliArgs.some((arg) => arg.startsWith("--sdk-url"));
const forceInteractive = isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE);
const isNonInteractive =
hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || !process.stdout.isTTY;
hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || (!forceInteractive && !process.stdout.isTTY);
// Stop capturing early input for non-interactive modes
if (isNonInteractive) {

View File

@@ -1,6 +1,135 @@
// Auto-generated stub — replace with real implementation
export {};
export const isProactiveActive: () => boolean = () => false;
export const activateProactive: (source?: string) => void = () => {};
export const isProactivePaused: () => boolean = () => false;
export const deactivateProactive: () => void = () => {};
/**
* Proactive mode — tick-driven autonomous agent.
*
* State machine: inactive → active (→ paused → active) → inactive
*
* When active, the REPL periodically injects <tick> prompts so the model
* keeps working even when the user is idle. SleepTool lets the model
* control its own wake-up cadence.
*/
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let active = false
let paused = false
let contextBlocked = false
let nextTickAt: number | null = null
let activationSource: string | undefined
const listeners = new Set<() => void>()
function notify(): void {
for (const cb of listeners) {
try {
cb()
} catch {
// subscriber errors must not break the notifier
}
}
}
// ---------------------------------------------------------------------------
// Public API — consumed by REPL.tsx, PromptInputFooterLeftSide, prompts.ts
// ---------------------------------------------------------------------------
export function isProactiveActive(): boolean {
return active
}
export function activateProactive(source?: string): void {
if (active) return
active = true
paused = false
contextBlocked = false
activationSource = source
notify()
}
export function deactivateProactive(): void {
if (!active) return
active = false
paused = false
contextBlocked = false
nextTickAt = null
activationSource = undefined
notify()
}
export function isProactivePaused(): boolean {
return paused
}
export function pauseProactive(): void {
if (!active || paused) return
paused = true
nextTickAt = null
notify()
}
export function resumeProactive(): void {
if (!active || !paused) return
paused = false
notify()
}
/**
* Block / unblock tick generation.
*
* Set to `true` on API errors to prevent tick → error → tick runaway loops.
* Cleared on successful response or after compaction.
*/
export function setContextBlocked(blocked: boolean): void {
if (contextBlocked === blocked) return
contextBlocked = blocked
if (blocked) {
nextTickAt = null
}
notify()
}
export function isContextBlocked(): boolean {
return contextBlocked
}
/**
* Schedule the next tick timestamp (epoch ms).
* Called by useProactive after submitting a tick.
*/
export function setNextTickAt(ts: number | null): void {
nextTickAt = ts
notify()
}
/**
* Returns the epoch-ms timestamp of the next scheduled tick, or null.
* Used by PromptInputFooterLeftSide to render a countdown.
*/
export function getNextTickAt(): number | null {
if (!active || paused || contextBlocked) return null
return nextTickAt
}
export function getActivationSource(): string | undefined {
return activationSource
}
/**
* Subscribe to any proactive state change.
* Returns an unsubscribe function.
*/
export function subscribeToProactiveChanges(cb: () => void): () => void {
listeners.add(cb)
return () => {
listeners.delete(cb)
}
}
/**
* Whether ticks should fire right now.
* Convenience predicate combining all blocking conditions.
*/
export function shouldTick(): boolean {
return active && !paused && !contextBlocked
}

View File

@@ -0,0 +1,102 @@
/**
* useProactive — React hook that drives tick generation for proactive mode.
*
* Mounted inside REPL.tsx when feature('PROACTIVE') || feature('KAIROS').
* Generates <tick>HH:MM:SS</tick> prompts at a fixed interval while
* proactive mode is active and not blocked.
*/
import { useEffect, useRef } from 'react'
import { TICK_TAG } from '../constants/xml.js'
import {
isProactiveActive,
isProactivePaused,
isContextBlocked,
setNextTickAt,
shouldTick,
} from './index.js'
/** Default interval between ticks (ms). Prompt cache TTL is ~5 min so we
* stay well under that to keep the cache warm. */
const TICK_INTERVAL_MS = 30_000
type UseProactiveOpts = {
isLoading: boolean
queuedCommandsLength: number
hasActiveLocalJsxUI: boolean
isInPlanMode: boolean
onSubmitTick: (prompt: string) => void
onQueueTick: (prompt: string) => void
}
export function useProactive(opts: UseProactiveOpts): void {
const optsRef = useRef(opts)
optsRef.current = opts
useEffect(() => {
if (!isProactiveActive()) return
let timer: ReturnType<typeof setTimeout> | null = null
function scheduleTick(): void {
const nextTs = Date.now() + TICK_INTERVAL_MS
setNextTickAt(nextTs)
timer = setTimeout(() => {
timer = null
// Guard: skip tick if any blocking condition is met
if (!shouldTick()) {
// Reschedule — conditions may clear later
scheduleTick()
return
}
const {
isLoading,
queuedCommandsLength,
hasActiveLocalJsxUI,
isInPlanMode,
} = optsRef.current
// Don't fire while a query is in-flight, plan mode is active,
// a local JSX UI is showing, or commands are queued
if (
isLoading ||
isInPlanMode ||
hasActiveLocalJsxUI ||
queuedCommandsLength > 0
) {
scheduleTick()
return
}
const tickContent = `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`
// If nothing is in the queue, submit directly; otherwise queue
if (queuedCommandsLength === 0) {
optsRef.current.onSubmitTick(tickContent)
} else {
optsRef.current.onQueueTick(tickContent)
}
// Schedule next tick
scheduleTick()
}, TICK_INTERVAL_MS)
}
scheduleTick()
return () => {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
setNextTickAt(null)
}
}, [
// Re-mount when proactive state changes
isProactiveActive(),
isProactivePaused(),
isContextBlocked(),
])
}

View File

@@ -258,6 +258,7 @@ import { useManagePlugins } from '../hooks/useManagePlugins.js';
import { Messages } from '../components/Messages.js';
import { TaskListV2 } from '../components/TaskListV2.js';
import { TeammateViewHeader } from '../components/TeammateViewHeader.js';
import { getPipeDisplayRole, getPipeIpc, isPipeControlled } from '../utils/pipeTransport.js';
import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js';
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js';
import type { MCPServerConnection } from '../services/mcp/types.js';
@@ -332,6 +333,22 @@ const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
const useProactive =
feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null;
const useMasterMonitor = feature('UDS_INBOX')
? require('../hooks/useMasterMonitor.js').useMasterMonitor
: () => undefined;
const useSlaveNotifications = feature('UDS_INBOX')
? require('../hooks/useSlaveNotifications.js').useSlaveNotifications
: () => undefined;
const usePipeIpc = feature('UDS_INBOX') ? require('../hooks/usePipeIpc.js').usePipeIpc : () => undefined;
const usePipeRelay = feature('UDS_INBOX')
? require('../hooks/usePipeRelay.js').usePipeRelay
: () => ({ relayPipeMessage: () => false, pipeReturnHadErrorRef: { current: false } });
const usePipePermissionForward = feature('UDS_INBOX')
? require('../hooks/usePipePermissionForward.js').usePipePermissionForward
: () => undefined;
const usePipeRouter = feature('UDS_INBOX')
? require('../hooks/usePipeRouter.js').usePipeRouter
: () => ({ routeToSelectedPipes: () => false });
/* eslint-enable @typescript-eslint/no-require-imports */
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js';
import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js';
@@ -823,8 +840,7 @@ export function REPL({
);
const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []);
const disableMessageActions = feature('MESSAGE_ACTIONS')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), [])
? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), [])
: false;
// Log REPL mount/unmount lifecycle
@@ -1478,7 +1494,6 @@ export function REPL({
messages.length,
);
if (feature('AWAY_SUMMARY')) {
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAwaySummary(messages, setMessages, isLoading);
}
const [cursor, setCursor] = useState<MessageActionsState | null>(null);
@@ -1515,8 +1530,7 @@ export function REPL({
// the branch is dead-code-eliminated in non-KAIROS builds (same pattern
// as useUnseenDivider above).
const { maybeLoadOlder } = feature('KAIROS')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAssistantHistory({
? useAssistantHistory({
config: remoteSessionConfig,
setMessages,
scrollRef,
@@ -3091,6 +3105,34 @@ export function REPL({
proactiveModule?.setContextBlocked(false);
}
}
// Relay assistant response to master when in slave mode.
if (feature('UDS_INBOX') && newMessage.type === 'assistant') {
// Extract text from content blocks (API format)
const msg = newMessage.message as any;
const contentBlocks = msg?.content ?? (newMessage as any).content ?? [];
const textParts: string[] = [];
if (Array.isArray(contentBlocks)) {
for (const block of contentBlocks) {
if (typeof block === 'string') {
textParts.push(block);
} else if (block?.type === 'text' && block.text) {
textParts.push(block.text);
}
}
} else if (typeof contentBlocks === 'string') {
textParts.push(contentBlocks);
}
const text = textParts.join('\n').trim();
if ('isApiErrorMessage' in newMessage && newMessage.isApiErrorMessage) {
pipeReturnHadErrorRef.current = true;
relayPipeMessage({
type: 'error',
data: text || 'Slave request failed',
});
} else if (text) {
relayPipeMessage({ type: 'stream', data: text });
}
}
},
newContent => {
// setResponseLength handles updating both responseLengthRef (for
@@ -3320,6 +3362,16 @@ export function REPL({
queryCheckpoint('query_end');
if (feature('UDS_INBOX')) {
if (abortController.signal.aborted) {
pipeReturnHadErrorRef.current = true;
relayPipeMessage({
type: 'error',
data: 'Slave request was interrupted before completion.',
});
}
}
// Capture ant-only API metrics before resetLoadingState clears the ref.
// For multi-request turns (tool use loops), compute P50 across all requests.
if (process.env.USER_TYPE === 'ant' && apiMetricsRef.current.length > 0) {
@@ -3431,6 +3483,7 @@ export function REPL({
}
try {
pipeReturnHadErrorRef.current = false;
// isLoading is derived from queryGuard — tryStart() above already
// transitioned dispatching→running, so no setter call needed here.
resetTimingRefs();
@@ -3463,6 +3516,7 @@ export function REPL({
}
}
try {
await onQueryImpl(
latestMessages,
newMessages,
@@ -3472,6 +3526,16 @@ export function REPL({
mainLoopModelParam,
effort,
);
} catch (error) {
if (feature('UDS_INBOX')) {
pipeReturnHadErrorRef.current = true;
relayPipeMessage({
type: 'error',
data: error instanceof Error ? error.message : String(error),
});
}
throw error;
}
} finally {
// queryGuard.end() atomically checks generation and transitions
// running→idle. Returns false if a newer query owns the guard
@@ -3486,6 +3550,13 @@ export function REPL({
await mrOnTurnComplete(messagesRef.current, abortController.signal.aborted);
if (feature('UDS_INBOX') && !pipeReturnHadErrorRef.current) {
relayPipeMessage({
type: 'done',
data: '',
});
}
// Notify bridge clients that the turn is complete so mobile apps
// can stop the spark animation and show post-turn UI.
sendBridgeResultRef.current();
@@ -3747,6 +3818,27 @@ export function REPL({
proactiveModule?.resumeProactive();
}
// Route user input to selected pipe targets (extracted to usePipeRouter)
if (routeToSelectedPipes(input)) {
// Show the user's prompt in the message list so they can see what was sent
const userMessage = createUserMessage({ content: input });
setMessages(prev => [...prev, userMessage]);
if (!options?.fromKeybinding) {
addToHistory({
display: prependModeCharacterToInput(input, inputMode),
pastedContents,
});
}
setInputValue('');
helpers.setCursorOffset(0);
helpers.clearBuffer();
setPastedContents({});
setInputMode('prompt');
setIDESelection(undefined);
return;
}
// Handle immediate commands - these bypass the queue and execute right away
// even while Claude is processing. Commands opt-in via `immediate: true`.
// Commands triggered via keybindings are always treated as immediate.
@@ -4739,10 +4831,11 @@ export function REPL({
[onQuery, mainLoopModel, store],
);
const { relayPipeMessage, pipeReturnHadErrorRef } = usePipeRelay();
// Voice input integration (VOICE_MODE builds only)
const voice = feature('VOICE_MODE')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef })
? useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef })
: {
stripTrailing: () => 0,
handleKeyEvent: () => {},
@@ -4758,6 +4851,15 @@ export function REPL({
});
useMailboxBridge({ isLoading, onSubmitMessage: handleIncomingPrompt });
useMasterMonitor();
useSlaveNotifications();
const pipeIpcState = useAppState(s => getPipeIpc(s as any));
usePipePermissionForward({ store, tools, setMessages, setToolUseConfirmQueue, getToolUseContext, mainLoopModel });
// Pipe IPC lifecycle — extracted to usePipeIpc hook
usePipeIpc({ store, handleIncomingPrompt });
const { routeToSelectedPipes } = usePipeRouter({ store, setAppState, addNotification });
// Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
if (feature('AGENT_TRIGGERS')) {
@@ -4768,7 +4870,6 @@ export function REPL({
// useScheduledTasks's effect (not here) since wrapping a hook call in a dynamic
// condition would break rules-of-hooks.
const assistantMode = store.getState().kairosEnabled;
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useScheduledTasks!({ isLoading, assistantMode, setMessages });
}
@@ -4779,16 +4880,16 @@ export function REPL({
if (process.env.USER_TYPE === 'ant') {
// Tasks mode: watch for tasks and auto-process them
// eslint-disable-next-line react-hooks/rules-of-hooks
// biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds
useTaskListWatcher({
taskListId,
isLoading,
onSubmitTask: handleIncomingPrompt,
});
}
// Loop mode: auto-tick when enabled (via /job command)
// Proactive mode: auto-tick when enabled (via /proactive command)
// Moved out of USER_TYPE === 'ant' block so external users can use it.
// eslint-disable-next-line react-hooks/rules-of-hooks
// biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds
useProactive?.({
// Suppress ticks while an initial message is pending — the initial
// message will be processed asynchronously and a premature tick would
@@ -4800,7 +4901,6 @@ export function REPL({
onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, { isMeta: true }),
onQueueTick: (prompt: string) => enqueue({ mode: 'prompt', value: prompt, isMeta: true }),
});
}
// Abort the current operation when a 'now' priority message arrives
// (e.g. from a chat UI client via UDS).
@@ -5119,8 +5219,15 @@ export function REPL({
// Handle shift+down for teammate navigation and background task management.
// Guard onOpenBackgroundTasks when a local-jsx dialog (e.g. /mcp) is open —
// otherwise Shift+Down stacks BackgroundTasksDialog on top and deadlocks input.
// Third case: Shift+Down toggles the pipe IPC selector panel when pipes are active.
useBackgroundTaskNavigation({
onOpenBackgroundTasks: isShowingLocalJSXCommand ? undefined : () => setShowBashesDialog(true),
onTogglePipeSelector: () => {
setAppState((prev: any) => {
const pIpc = prev.pipeIpc ?? {};
return { ...prev, pipeIpc: { ...pIpc, selectorOpen: !pIpc.selectorOpen } };
});
},
});
// Auto-exit viewing mode when teammate completes or errors
useTeammateViewAutoExit();
@@ -5375,12 +5482,12 @@ export function REPL({
// /config, /theme, /diff, ...) both go here now.
const toolJsxCentered = isFullscreenEnvEnabled() && toolJSX?.isLocalJSXCommand === true;
const centeredModal: React.ReactNode = toolJsxCentered ? toolJSX!.jsx : null;
// <AlternateScreen> at the root: everything below is inside its
// <Box height={rows}>. Handlers/contexts are zero-height so ScrollBox's
// flexGrow in FullscreenLayout resolves against this Box. The transcript
// early return above wraps its virtual-scroll branch the same way; only
// the 30-cap dump branch stays unwrapped for native terminal scrollback.
const mainReturn = (
<KeybindingSetup>
<AnimatedTerminalTitle
@@ -5413,7 +5520,7 @@ export function REPL({
isFullscreenEnvEnabled() &&
(centeredModal != null || !focusedInputDialog || focusedInputDialog === 'tool-permission')
}
onScroll={centeredModal || toolPermissionOverlay || viewedAgentTask ? undefined : composedOnScroll}
onScroll={composedOnScroll}
/>
{feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? (
<MessageActionsKeybindings handlers={messageActionHandlers} isActive={cursor !== null} />

View File

@@ -1,7 +1,142 @@
// Auto-generated stub — replace with real implementation
export {};
import type { Command } from 'src/types/command.js';
export const fetchMcpSkillsForClient: ((...args: unknown[]) => Promise<Command[]>) & { cache: Map<string, unknown> } = Object.assign(
(..._args: unknown[]) => Promise.resolve([] as Command[]),
{ cache: new Map<string, unknown>() }
);
import {
type ListResourcesResult,
ListResourcesResultSchema,
type ReadResourceResult,
ReadResourceResultSchema,
} from '@modelcontextprotocol/sdk/types.js'
import type { Command } from '../commands.js'
import type { MCPServerConnection } from '../services/mcp/types.js'
import { normalizeNameForMCP } from '../services/mcp/normalization.js'
import { memoizeWithLRU } from '../utils/memoize.js'
import { errorMessage } from '../utils/errors.js'
import { logMCPDebug, logMCPError } from '../utils/log.js'
import { recursivelySanitizeUnicode } from '../utils/sanitization.js'
import { parseFrontmatter } from '../utils/frontmatterParser.js'
import { getMCPSkillBuilders } from './mcpSkillBuilders.js'
const SKILL_URI_PREFIX = 'skill://'
const MCP_FETCH_CACHE_SIZE = 20
/**
* Discovers skills exposed as `skill://` resources by an MCP server.
*
* Each matching resource is read, its markdown content is parsed for
* frontmatter, and the result is converted into a Command that the skill
* system can index and invoke just like a local `.md` skill file.
*
* Memoized by server name so repeated calls within a connection lifecycle
* return the cached result. Callers invalidate via `.cache.delete(name)`.
*/
export const fetchMcpSkillsForClient = memoizeWithLRU(
async (client: MCPServerConnection): Promise<Command[]> => {
if (client.type !== 'connected') return []
try {
if (!client.capabilities?.resources) {
return []
}
// List all resources and filter to skill:// URIs
const result = (await client.client.request(
{ method: 'resources/list' },
ListResourcesResultSchema,
)) as ListResourcesResult
if (!result.resources) return []
const skillResources = result.resources.filter(r =>
r.uri.startsWith(SKILL_URI_PREFIX),
)
if (skillResources.length === 0) return []
logMCPDebug(
client.name,
`Found ${skillResources.length} skill resource(s)`,
)
const { createSkillCommand, parseSkillFrontmatterFields } =
getMCPSkillBuilders()
const commands: Command[] = []
for (const resource of skillResources) {
try {
// Read the skill resource content
const readResult = (await client.client.request(
{
method: 'resources/read',
params: { uri: resource.uri },
},
ReadResourceResultSchema,
)) as ReadResourceResult
// Extract text content from the resource
const textContent = readResult.contents
?.map(c => ('text' in c ? c.text : undefined))
.filter(Boolean)
.join('\n')
if (!textContent) {
logMCPDebug(
client.name,
`Skill resource ${resource.uri} returned no text content, skipping`,
)
continue
}
const sanitizedContent = recursivelySanitizeUnicode(textContent)
// Parse the markdown frontmatter
const { frontmatter, content: markdownContent } =
parseFrontmatter(sanitizedContent)
// Derive a skill name from the resource URI. Strip the skill://
// prefix and use the remainder, prefixed with the MCP server name
// so it is unique across servers.
const rawName = resource.uri.slice(SKILL_URI_PREFIX.length)
const skillName =
'mcp__' + normalizeNameForMCP(client.name) + '__' + rawName
const parsed = parseSkillFrontmatterFields(
frontmatter,
markdownContent,
skillName,
)
commands.push(
createSkillCommand({
...parsed,
skillName,
markdownContent,
source: 'mcp',
loadedFrom: 'mcp',
baseDir: undefined,
paths: undefined,
}),
)
} catch (error) {
logMCPError(
client.name,
`Failed to load skill resource ${resource.uri}: ${errorMessage(error)}`,
)
}
}
logMCPDebug(
client.name,
`Loaded ${commands.length} skill(s) from resources`,
)
return commands
} catch (error) {
logMCPError(
client.name,
`Failed to fetch skill resources: ${errorMessage(error)}`,
)
return []
}
},
(client: MCPServerConnection) => client.name,
MCP_FETCH_CACHE_SIZE,
)

View File

@@ -1,11 +1,207 @@
// Auto-generated stub — replace with real implementation
import type { TaskStateBase, SetAppState } from '../../Task.js'
// Background task entry for local workflow execution.
// Makes workflow scripts visible in the footer pill and Shift+Down
// dialog. Follows the DreamTask pattern: lifecycle + UI surfacing via
// the existing task registry.
import type { AppState } from '../../state/AppState.js'
import type { SetAppState, Task, TaskStateBase } from '../../Task.js'
import { createTaskStateBase, generateTaskId } from '../../Task.js'
import type { AgentId } from '../../types/ids.js'
import { logForDebugging } from '../../utils/debug.js'
import { registerTask, updateTaskState } from '../../utils/task/framework.js'
export type LocalWorkflowTaskState = TaskStateBase & {
type: 'local_workflow'
/** meta.name from the workflow script (e.g. 'spec'). */
workflowName: string
/** Absolute path to the workflow file on disk. */
workflowFile: string
/** Human-readable one-line summary for the task list. */
summary?: string
description: string
/** Number of sub-agents spawned by this workflow. */
agentCount?: number
/** Captured output from workflow execution. */
output?: string
/** Agent that spawned this task. Used for orphan cleanup. */
agentId?: AgentId
/** Abort controller for cancellation. */
abortController?: AbortController
/**
* Pending action for a sub-agent within this workflow.
* The workflow execution loop polls this field and acts on it.
*/
pendingAgentAction?: {
kind: 'skip' | 'retry'
agentId: AgentId
requestedAt: number
}
}
export function isLocalWorkflowTask(
value: unknown,
): value is LocalWorkflowTaskState {
return (
typeof value === 'object' &&
value !== null &&
'type' in value &&
(value as { type: string }).type === 'local_workflow'
)
}
export function registerLocalWorkflowTask(
setAppState: SetAppState,
opts: {
description: string
workflowName: string
workflowFile: string
summary?: string
toolUseId?: string
agentId?: AgentId
abortController?: AbortController
},
): string {
const id = generateTaskId('local_workflow')
const task: LocalWorkflowTaskState = {
...createTaskStateBase(id, 'local_workflow', opts.description, opts.toolUseId),
type: 'local_workflow',
status: 'running',
workflowName: opts.workflowName,
workflowFile: opts.workflowFile,
summary: opts.summary,
agentId: opts.agentId,
abortController: opts.abortController,
}
registerTask(task, setAppState)
return id
}
export function completeWorkflowTask(
taskId: string,
setAppState: SetAppState,
): void {
updateTaskState<LocalWorkflowTaskState>(taskId, setAppState, task => ({
...task,
status: 'completed',
endTime: Date.now(),
notified: true,
abortController: undefined,
}))
}
export function failWorkflowTask(
taskId: string,
setAppState: SetAppState,
): void {
updateTaskState<LocalWorkflowTaskState>(taskId, setAppState, task => ({
...task,
status: 'failed',
endTime: Date.now(),
notified: true,
abortController: undefined,
}))
}
/**
* Kill a running workflow task. Called from BackgroundTasksDialog
* via the feature-gated `killWorkflowTask` binding.
*/
export function killWorkflowTask(
taskId: string,
setAppState: SetAppState,
): void {
updateTaskState<LocalWorkflowTaskState>(taskId, setAppState, task => {
if (task.status !== 'running') return task
task.abortController?.abort()
return {
...task,
status: 'killed',
endTime: Date.now(),
notified: true,
abortController: undefined,
}
})
}
/**
* Skip the current agent step within a running workflow.
* Called from BackgroundTasksDialog via the feature-gated
* `skipWorkflowAgent` binding: skipWorkflowAgent(taskId, agentId, setAppState).
*/
export function skipWorkflowAgent(
taskId: string,
agentId: AgentId,
setAppState: SetAppState,
): void {
logForDebugging(
`skipWorkflowAgent: skipping agent ${agentId} in workflow task ${taskId}`,
)
updateTaskState<LocalWorkflowTaskState>(taskId, setAppState, task => {
if (task.status !== 'running') return task
return {
...task,
pendingAgentAction: {
kind: 'skip',
agentId,
requestedAt: Date.now(),
},
}
})
}
/**
* Retry the current agent step within a running workflow.
* Called from BackgroundTasksDialog via the feature-gated
* `retryWorkflowAgent` binding: retryWorkflowAgent(taskId, agentId, setAppState).
*/
export function retryWorkflowAgent(
taskId: string,
agentId: AgentId,
setAppState: SetAppState,
): void {
logForDebugging(
`retryWorkflowAgent: retrying agent ${agentId} in workflow task ${taskId}`,
)
updateTaskState<LocalWorkflowTaskState>(taskId, setAppState, task => {
if (task.status !== 'running') return task
return {
...task,
pendingAgentAction: {
kind: 'retry',
agentId,
requestedAt: Date.now(),
},
}
})
}
/**
* Kill all running workflow tasks spawned by a given agent.
* Called from runAgent.ts finally block.
*/
export function killWorkflowTasksForAgent(
agentId: AgentId,
getAppState: () => AppState,
setAppState: SetAppState,
): void {
const tasks = getAppState().tasks ?? {}
for (const [taskId, task] of Object.entries(tasks)) {
if (
isLocalWorkflowTask(task) &&
task.agentId === agentId &&
task.status === 'running'
) {
logForDebugging(
`killWorkflowTasksForAgent: killing orphaned workflow task ${taskId} (agent ${agentId} exiting)`,
)
killWorkflowTask(taskId, setAppState)
}
}
}
export const LocalWorkflowTask: Task = {
name: 'LocalWorkflowTask',
type: 'local_workflow',
async kill(taskId: string, setAppState: SetAppState) {
killWorkflowTask(taskId, setAppState)
},
}
export const killWorkflowTask: (id: string, setAppState: SetAppState) => void = (() => {});
export const skipWorkflowAgent: (id: string, agentId: string, setAppState: SetAppState) => void = (() => {});
export const retryWorkflowAgent: (id: string, agentId: string, setAppState: SetAppState) => void = (() => {});

View File

@@ -1,10 +1,139 @@
// Auto-generated stub — replace with real implementation
import type { TaskStateBase, SetAppState } from '../../Task.js';
import type { AppState } from '../../state/AppState.js';
import type { AgentId } from '../../types/ids.js';
// Background task entry for MCP resource monitoring.
// Tracks a long-running subscription to an MCP server resource so the
// otherwise-invisible stream is visible in the footer pill and Shift+Down
// dialog. Follows the DreamTask pattern: pure UI surfacing via the existing
// task registry.
import type { AppState } from '../../state/AppState.js'
import type { SetAppState, Task, TaskStateBase } from '../../Task.js'
import { createTaskStateBase, generateTaskId } from '../../Task.js'
import type { AgentId } from '../../types/ids.js'
import { logForDebugging } from '../../utils/debug.js'
import { registerTask, updateTaskState } from '../../utils/task/framework.js'
export type MonitorMcpTaskState = TaskStateBase & {
type: 'monitor_mcp';
};
export const killMonitorMcp: (taskId: string, setAppState: SetAppState) => void = (() => {});
export const killMonitorMcpTasksForAgent: (agentId: AgentId, getAppState: () => AppState, setAppState: SetAppState) => void = (() => {});
type: 'monitor_mcp'
/** The MCP server name being monitored. */
serverName: string
/** The resource URI being subscribed to. */
resourceUri: string
/** The shell command used to drive monitoring (if any). */
command?: string
/** Agent that spawned this task. Used to kill orphaned tasks on agent exit. */
agentId?: AgentId
/** Abort controller to cancel the subscription. */
abortController?: AbortController
}
export function isMonitorMcpTask(task: unknown): task is MonitorMcpTaskState {
return (
typeof task === 'object' &&
task !== null &&
'type' in task &&
task.type === 'monitor_mcp'
)
}
export function registerMonitorMcpTask(
setAppState: SetAppState,
opts: {
description: string
serverName: string
resourceUri: string
command?: string
toolUseId?: string
agentId?: AgentId
abortController?: AbortController
},
): string {
const id = generateTaskId('monitor_mcp')
const task: MonitorMcpTaskState = {
...createTaskStateBase(id, 'monitor_mcp', opts.description, opts.toolUseId),
type: 'monitor_mcp',
status: 'running',
serverName: opts.serverName,
resourceUri: opts.resourceUri,
command: opts.command,
agentId: opts.agentId,
abortController: opts.abortController,
}
registerTask(task, setAppState)
return id
}
export function completeMonitorMcpTask(
taskId: string,
setAppState: SetAppState,
): void {
updateTaskState<MonitorMcpTaskState>(taskId, setAppState, task => ({
...task,
status: 'completed',
endTime: Date.now(),
notified: true,
abortController: undefined,
}))
}
export function failMonitorMcpTask(
taskId: string,
setAppState: SetAppState,
): void {
updateTaskState<MonitorMcpTaskState>(taskId, setAppState, task => ({
...task,
status: 'failed',
endTime: Date.now(),
notified: true,
abortController: undefined,
}))
}
export function killMonitorMcp(
taskId: string,
setAppState: SetAppState,
): void {
updateTaskState<MonitorMcpTaskState>(taskId, setAppState, task => {
if (task.status !== 'running') return task
task.abortController?.abort()
return {
...task,
status: 'killed',
endTime: Date.now(),
notified: true,
abortController: undefined,
}
})
}
/**
* Kill all running monitor_mcp tasks spawned by a given agent.
* Called from runAgent.ts finally block so subscriptions don't outlive
* the agent that started them.
*/
export function killMonitorMcpTasksForAgent(
agentId: AgentId,
getAppState: () => AppState,
setAppState: SetAppState,
): void {
const tasks = getAppState().tasks ?? {}
for (const [taskId, task] of Object.entries(tasks)) {
if (
isMonitorMcpTask(task) &&
task.agentId === agentId &&
task.status === 'running'
) {
logForDebugging(
`killMonitorMcpTasksForAgent: killing orphaned monitor task ${taskId} (agent ${agentId} exiting)`,
)
killMonitorMcp(taskId, setAppState)
}
}
}
export const MonitorMcpTask: Task = {
name: 'MonitorMcpTask',
type: 'monitor_mcp',
async kill(taskId, setAppState) {
killMonitorMcp(taskId, setAppState)
},
}

View File

@@ -121,6 +121,10 @@ const coordinatorModeModule = feature('COORDINATOR_MODE')
const SnipTool = feature('HISTORY_SNIP')
? require('./tools/SnipTool/SnipTool.js').SnipTool
: null
const ReviewArtifactTool = feature('REVIEW_ARTIFACT')
? require('./tools/ReviewArtifactTool/ReviewArtifactTool.js')
.ReviewArtifactTool
: null
const ListPeersTool = feature('UDS_INBOX')
? require('./tools/ListPeersTool/ListPeersTool.js').ListPeersTool
: null
@@ -237,6 +241,7 @@ export function getAllBaseTools(): Tools {
...(SendUserFileTool ? [SendUserFileTool] : []),
...(PushNotificationTool ? [PushNotificationTool] : []),
...(SubscribePRTool ? [SubscribePRTool] : []),
...(ReviewArtifactTool ? [ReviewArtifactTool] : []),
...(getPowerShellTool() ? [getPowerShellTool()] : []),
...(SnipTool ? [SnipTool] : []),
...(process.env.NODE_ENV === 'test' ? [TestingPermissionTool] : []),

View File

@@ -0,0 +1,80 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
const CTX_INSPECT_TOOL_NAME = 'CtxInspect'
const inputSchema = lazySchema(() =>
z.strictObject({
query: z
.string()
.optional()
.describe('Optional query to filter context entries. If omitted, returns a summary of all context.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type CtxInput = z.infer<InputSchema>
type CtxOutput = {
total_tokens: number
message_count: number
summary: string
}
export const CtxInspectTool = buildTool({
name: CTX_INSPECT_TOOL_NAME,
searchHint: 'context inspect tokens usage messages window collapse',
maxResultSizeChars: 50_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Inspect the current context window contents and token usage'
},
async prompt() {
return `Inspect the current conversation context. Shows token usage, message count, and a breakdown of what's consuming context space.
Use this to understand your context budget before deciding whether to snip old messages or adjust your approach.`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'CtxInspect'
},
renderToolUseMessage() {
return 'Context Inspect'
},
mapToolResultToToolResultBlockParam(
content: CtxOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `Context: ${content.total_tokens} tokens, ${content.message_count} messages\n${content.summary}`,
}
},
async call() {
// Context inspection is wired into the context collapse system.
return {
data: {
total_tokens: 0,
message_count: 0,
summary: 'Context inspection requires the CONTEXT_COLLAPSE runtime.',
},
}
},
})

View File

@@ -0,0 +1,107 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
const LIST_PEERS_TOOL_NAME = 'ListPeers'
const inputSchema = lazySchema(() =>
z.strictObject({
include_self: z
.boolean()
.optional()
.describe('Whether to include the current session in the list. Defaults to false.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type ListPeersInput = z.infer<InputSchema>
type PeerInfo = {
address: string
name?: string
cwd?: string
pid?: number
}
type ListPeersOutput = { peers: PeerInfo[] }
export const ListPeersTool = buildTool({
name: LIST_PEERS_TOOL_NAME,
searchHint: 'list peers sessions discover uds socket messaging',
maxResultSizeChars: 50_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Discover other Claude Code sessions for cross-session messaging'
},
async prompt() {
return `List active Claude Code sessions that can receive messages via SendMessage.
Returns an array of peers with their addresses. Use these addresses as the \`to\` field in SendMessage:
- \`"uds:/path/to.sock"\` — local sessions on the same machine (Unix Domain Socket)
- \`"bridge:session_..."\` — remote sessions via Remote Control
Use this tool to discover messaging targets before sending cross-session messages. Only running sessions with active messaging sockets are returned.`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return LIST_PEERS_TOOL_NAME
},
renderToolUseMessage() {
return 'ListPeers'
},
mapToolResultToToolResultBlockParam(
content: ListPeersOutput,
toolUseID: string,
): ToolResultBlockParam {
const lines = content.peers.map(
p => `${p.address}${p.name ? ` (${p.name})` : ''}${p.cwd ? ` @ ${p.cwd}` : ''}`,
)
return {
tool_use_id: toolUseID,
type: 'tool_result',
content:
lines.length > 0
? `Found ${lines.length} peer(s):\n${lines.join('\n')}`
: 'No peers found.',
}
},
async call(_input: ListPeersInput, context) {
// Peer discovery uses the concurrent sessions PID registry and
// UDS socket directory. The implementation scans for live sockets
// and optionally includes Remote Control bridge peers.
const peers: PeerInfo[] = []
// Discovery is handled by the UDS messaging subsystem initialized in setup.ts.
// Return discovered peers from the app state.
const appState = context.getAppState()
const messagingSocketPath = (appState as Record<string, unknown>).messagingSocketPath as string | undefined
if (messagingSocketPath) {
// Self entry for reference
if (_input.include_self) {
peers.push({
address: `uds:${messagingSocketPath}`,
name: 'self',
pid: process.pid,
})
}
}
return {
data: { peers },
}
},
})

View File

@@ -1,3 +0,0 @@
// Auto-generated stub — replace with real implementation
export {};
export const MonitorTool: Record<string, unknown> = {};

View File

@@ -0,0 +1,190 @@
import React from 'react'
import { Text } from '@anthropic/ink'
import { z } from 'zod/v4'
import { TOOL_SUMMARY_MAX_LENGTH } from '../../constants/toolLimits.js'
import type { ToolResultBlockParam, ToolUseContext, ValidationResult } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { spawnShellTask } from '../../tasks/LocalShellTask/LocalShellTask.js'
import { bashToolHasPermission } from '../BashTool/bashPermissions.js'
import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { truncate } from '../../utils/format.js'
import { exec } from '../../utils/Shell.js'
import { getTaskOutputPath } from '../../utils/task/diskOutput.js'
import { logEvent } from '../../services/analytics/index.js'
const MONITOR_TOOL_NAME = 'Monitor'
const inputSchema = lazySchema(() =>
z.strictObject({
command: z
.string()
.describe(
'The shell command to run as a long-running monitor. Should produce streaming output (e.g., tail -f, watch, polling loops).',
),
description: z
.string()
.describe(
'Clear, concise description of what this monitor watches. Used as the label in the background tasks UI.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
export type MonitorInput = z.infer<InputSchema>
const outputSchema = lazySchema(() =>
z.object({
taskId: z.string(),
outputFile: z.string(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type MonitorOutput = z.infer<OutputSchema>
export const MonitorTool = buildTool({
name: MONITOR_TOOL_NAME,
searchHint: 'start long-running background monitor for streaming events',
maxResultSizeChars: 10_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
async description() {
return 'Start a long-running background monitor'
},
async prompt() {
return `Use Monitor to start a long-running background process that streams output (watching logs, polling APIs, tailing files, etc.). The command runs in the background and you receive a notification when it exits. Use the Read tool with the output file path to check its output at any time.
Guidelines:
- Use Monitor for commands that produce ongoing streaming output: \`tail -f\`, log watchers, file watchers, API polling loops, \`watch\` commands
- Do NOT use Monitor for one-shot commands that finish quickly — use Bash for those
- Do NOT use Monitor for commands that need interactive input — they will hang
- The description should clearly explain what is being monitored
- You'll get a task notification when the monitor process exits (stream ends, script fails, or killed)
- To check output at any time, use Read on the output file path returned by this tool
Examples:
- Watching a log file: command="tail -f /var/log/app.log", description="Watch app log for errors"
- Polling an API: command="while true; do curl -s http://localhost:3000/health; sleep 5; done", description="Poll health endpoint every 5s"
- Watching for file changes: command="inotifywait -m -r ./src", description="Watch src directory for file changes"`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
// Monitor executes shell commands which may have side effects
return false
},
toAutoClassifierInput(input: MonitorInput) {
return `Monitor: ${input.command}`
},
async checkPermissions(
input: MonitorInput,
context: ToolUseContext,
): Promise<PermissionResult> {
// Reuse bash permission checking for the underlying command
return bashToolHasPermission(
{ command: input.command },
context,
)
},
userFacingName() {
return MONITOR_TOOL_NAME
},
getActivityDescription(input: MonitorInput) {
if (!input?.description) {
return 'Starting monitor'
}
return `Monitoring: ${truncate(input.description, TOOL_SUMMARY_MAX_LENGTH)}`
},
async validateInput(input: MonitorInput): Promise<ValidationResult> {
if (!input.command || input.command.trim() === '') {
return {
result: false,
message: 'Monitor command cannot be empty.',
errorCode: 1,
}
}
if (!input.description || input.description.trim() === '') {
return {
result: false,
message: 'Monitor description cannot be empty.',
errorCode: 2,
}
}
return { result: true }
},
async call(input: MonitorInput, context: ToolUseContext) {
const { command, description } = input
const {
abortController,
setAppState,
toolUseId,
agentId,
} = context
logEvent('tengu_monitor_tool_used', {})
// Create the shell command via exec
const shellCommand = await exec(command, abortController.signal, 'bash')
// Spawn as a background task with kind: 'monitor'
const handle = await spawnShellTask(
{
command,
description,
shellCommand,
toolUseId: toolUseId,
agentId,
kind: 'monitor',
},
{
abortController,
getAppState: context.getAppState,
setAppState,
},
)
const outputFile = getTaskOutputPath(handle.taskId)
return {
data: {
taskId: handle.taskId,
outputFile,
},
}
},
renderToolUseMessage(input: MonitorInput, { verbose }) {
const desc = truncate(input.description || input.command, 80)
return `Monitor: ${desc}`
},
mapToolResultToToolResultBlockParam(
content: MonitorOutput,
toolUseId: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseId,
type: 'tool_result',
content: `Monitor started (task ${content.taskId}). Output file: ${content.outputFile}`,
}
},
renderToolResultMessage(output: MonitorOutput) {
return <Text>Monitor started (task {output.taskId}). Output: {output.outputFile}</Text>
},
})

View File

@@ -7,10 +7,6 @@ mock.module("src/utils/cwd.js", () => ({
getCwd: () => mockCwd,
}));
mock.module("src/utils/powershell/parser.js", () => ({
PS_TOKENIZER_DASH_CHARS: new Set(["-", "\u2013", "\u2014", "\u2015"]),
}));
const { isGitInternalPathPS, isDotGitPathPS } = await import("../gitSafety");
describe("isGitInternalPathPS", () => {

View File

@@ -0,0 +1,87 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
const PUSH_NOTIFICATION_TOOL_NAME = 'PushNotification'
const inputSchema = lazySchema(() =>
z.strictObject({
title: z
.string()
.describe('Title of the push notification.'),
body: z
.string()
.describe('Body text of the push notification.'),
priority: z
.enum(['normal', 'high'])
.optional()
.describe('Notification priority. Use "high" for blockers or permission prompts.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type PushInput = z.infer<InputSchema>
type PushOutput = { sent: boolean }
export const PushNotificationTool = buildTool({
name: PUSH_NOTIFICATION_TOOL_NAME,
searchHint: 'push notification mobile alert notify user',
maxResultSizeChars: 1_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Send a push notification to the user\'s mobile device'
},
async prompt() {
return `Send a push notification to the user's mobile device via Remote Control.
Use this when:
- A long-running task completes and the user may not be watching
- A permission prompt is waiting and you need user input
- Something urgent requires the user's attention
Requires Remote Control to be configured. Respects user notification settings (taskCompleteNotifEnabled, inputNeededNotifEnabled, agentPushNotifEnabled).`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'Notify'
},
renderToolUseMessage(input: Partial<PushInput>) {
return `Push: ${input.title ?? '...'}`
},
mapToolResultToToolResultBlockParam(
content: PushOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.sent ? 'Notification sent.' : 'Failed to send notification.',
}
},
async call(_input: PushInput) {
// Push delivery is handled by the Remote Control / KAIROS transport layer.
// Without the KAIROS runtime, this tool is not available.
return {
data: {
sent: false,
error: 'PushNotification requires the KAIROS transport layer.',
},
}
},
})

View File

@@ -1 +0,0 @@
export const REPLTool = { name: 'REPLTool', isEnabled: () => false }

View File

@@ -0,0 +1,89 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { REPL_TOOL_NAME } from './constants.js'
const inputSchema = lazySchema(() =>
z.strictObject({
code: z
.string()
.describe(
'The code to execute in the REPL. Can call any primitive tool (Read, Write, Edit, Glob, Grep, Bash, NotebookEdit, Agent) via their APIs.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type REPLInput = z.infer<InputSchema>
type REPLOutput = { result: string; tool_calls: number }
export const REPLTool = buildTool({
name: REPL_TOOL_NAME,
searchHint: 'repl execute batch code read write edit glob grep bash',
maxResultSizeChars: 100_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Execute code in the REPL environment with access to all primitive tools'
},
async prompt() {
return `Execute code in the REPL — a sandboxed environment with direct access to primitive tools (Read, Write, Edit, Glob, Grep, Bash, NotebookEdit, Agent).
When REPL mode is active, primitive tools are only accessible through this tool. Use REPL for:
- Batch operations across many files
- Complex multi-step file transformations
- Operations that benefit from programmatic control flow
- Combining search results with edits in a single turn
The REPL runs in a VM context with tool APIs available as functions. Results from each tool call are collected and returned together.`
},
isConcurrencySafe() {
return false
},
isReadOnly() {
return false
},
isTransparentWrapper() {
return true
},
userFacingName() {
return REPL_TOOL_NAME
},
renderToolUseMessage(input: Partial<REPLInput>) {
const code = input.code ?? ''
const preview = code.length > 80 ? code.slice(0, 77) + '...' : code
return `REPL: ${preview}`
},
mapToolResultToToolResultBlockParam(
content: REPLOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.result,
}
},
async call(_input: REPLInput) {
// REPL execution engine is provided by the ant-native runtime.
// This stub satisfies the tool interface; the actual VM dispatch
// is wired in the ant build. Without the ant runtime, REPL is
// not available and callers should be informed.
return {
data: {
result: 'Error: REPL tool is not available in this build. The REPL execution engine requires the ant-native runtime.',
tool_calls: 0,
},
}
},
})

View File

@@ -1,3 +1,142 @@
// Auto-generated stub — replace with real implementation
export {};
export const ReviewArtifactTool: Record<string, unknown> = {};
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import React from 'react'
import { Box, Text } from '@anthropic/ink'
const REVIEW_ARTIFACT_TOOL_NAME = 'ReviewArtifact'
const DESCRIPTION =
'Review an artifact (code snippet, document, or other content) with inline annotations and feedback.'
const inputSchema = lazySchema(() =>
z.strictObject({
artifact: z
.string()
.describe(
'The content of the artifact to review (code snippet, document text, etc.).',
),
title: z
.string()
.optional()
.describe(
'Optional title or file path for the artifact being reviewed.',
),
annotations: z
.array(
z.object({
line: z.number().optional().describe('Line number for the annotation (1-based).'),
message: z.string().describe('The annotation or feedback message.'),
severity: z
.enum(['info', 'warning', 'error', 'suggestion'])
.optional()
.describe('Severity level of the annotation.'),
}),
)
.describe('List of annotations/comments on the artifact.'),
summary: z
.string()
.optional()
.describe('An overall summary of the review.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
artifact: z.string().describe('The reviewed artifact content.'),
title: z.string().optional().describe('Title of the reviewed artifact.'),
annotationCount: z
.number()
.describe('Number of annotations applied.'),
summary: z.string().optional().describe('Summary of the review.'),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
export const ReviewArtifactTool = buildTool({
name: REVIEW_ARTIFACT_TOOL_NAME,
searchHint: 'review code or documents with inline annotations',
maxResultSizeChars: 100_000,
async description(input) {
const { title } = input as { title?: string }
return title
? `Claude wants to review: ${title}`
: 'Claude wants to review an artifact'
},
userFacingName() {
return 'ReviewArtifact'
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
toAutoClassifierInput(input) {
return input.title ?? input.artifact.slice(0, 200)
},
async prompt() {
return `Use this tool to present a review of a code snippet, document, or other artifact with inline annotations and feedback. Each annotation can target a specific line and include a severity level. ${DESCRIPTION}`
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `Review delivered with ${output.annotationCount} annotation(s).${output.summary ? ` Summary: ${output.summary}` : ''}`,
}
},
renderToolUseMessage(
input: Partial<z.infer<InputSchema>>,
{ verbose }: { theme?: string; verbose: boolean },
): React.ReactNode {
const title = input.title ?? 'Untitled artifact'
const count = input.annotations?.length ?? 0
if (verbose) {
return `Review: "${title}" (${count} annotation(s))`
}
return title
},
renderToolResultMessage(
output: Output,
_progressMessages: unknown[],
{ verbose }: { verbose: boolean },
): React.ReactNode {
if (verbose) {
return React.createElement(
Box,
{ flexDirection: 'column' },
React.createElement(
Text,
null,
`Reviewed artifact: ${output.title ?? 'Untitled'} (${output.annotationCount} annotations)`,
),
output.summary
? React.createElement(Text, { dimColor: true }, output.summary)
: null,
)
}
return React.createElement(
Text,
null,
`Review complete: ${output.annotationCount} annotation(s)`,
)
},
async call({ artifact, title, annotations, summary }, _context) {
const output: Output = {
artifact,
title,
annotationCount: annotations.length,
summary,
}
return { data: output }
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -70,7 +70,7 @@ const inputSchema = lazySchema(() =>
.string()
.describe(
feature('UDS_INBOX')
? 'Recipient: teammate name, "*" for broadcast, "uds:<socket-path>" for a local peer, or "bridge:<session-id>" for a Remote Control peer (use ListPeers to discover)'
? `Recipient: teammate name, "*" for broadcast, "uds:<socket-path>" for a local peer, "bridge:<session-id>" for a Remote Control peer${feature('LAN_PIPES') ? ', or "tcp:<host>:<port>" for a LAN peer' : ''} (use ListPeers to discover)`
: 'Recipient: teammate name, or "*" for broadcast to all teammates',
),
summary: z
@@ -587,9 +587,6 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
return {
behavior: 'ask' as const,
message: `Send a message to Remote Control session ${input.to}? It arrives as a user prompt on the receiving Claude (possibly another machine) via Anthropic's servers.`,
// safetyCheck (not mode) — permissions.ts guards this before both
// bypassPermissions (step 1g) and auto-mode's allowlist/classifier.
// Cross-machine prompt injection must stay bypass-immune.
decisionReason: {
type: 'safetyCheck',
reason:
@@ -598,6 +595,17 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},
}
}
if (feature('LAN_PIPES') && parseAddress(input.to).scheme === 'tcp') {
return {
behavior: 'ask' as const,
message: `Send a message to LAN peer ${input.to}? This connects directly over TCP to a machine on your local network.`,
decisionReason: {
type: 'safetyCheck',
reason: 'Cross-machine LAN message requires explicit user consent',
classifierApprovable: false,
},
}
}
return { behavior: 'allow' as const, updatedInput: input }
},
@@ -611,7 +619,9 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
}
const addr = parseAddress(input.to)
if (
(addr.scheme === 'bridge' || addr.scheme === 'uds') &&
(addr.scheme === 'bridge' ||
addr.scheme === 'uds' ||
addr.scheme === 'tcp') &&
addr.target.trim().length === 0
) {
return {
@@ -659,9 +669,13 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
parseAddress(input.to).scheme === 'uds' &&
typeof input.message === 'string'
) {
// UDS cross-session send: summary isn't rendered (UI.tsx returns null
// for string messages), so don't require it. Structured messages fall
// through to the rejection below.
return { result: true }
}
if (
feature('LAN_PIPES') &&
parseAddress(input.to).scheme === 'tcp' &&
typeof input.message === 'string'
) {
return { result: true }
}
if (typeof input.message === 'string') {
@@ -783,7 +797,7 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
return {
data: {
success: true,
message: `${preview}” → ${input.to}`,
message: `${preview}” → ${input.to}`,
},
}
} catch (e) {
@@ -795,6 +809,41 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
}
}
}
if (addr.scheme === 'tcp' && feature('LAN_PIPES')) {
const { parseTcpTarget } =
require('../../utils/peerAddress.js') as typeof import('../../utils/peerAddress.js')
const { PipeClient } =
require('../../utils/pipeTransport.js') as typeof import('../../utils/pipeTransport.js')
const ep = parseTcpTarget(addr.target)
if (!ep) {
return {
data: {
success: false,
message: `Invalid TCP target format: ${addr.target}. Expected host:port`,
},
}
}
try {
const client = new PipeClient(input.to, `send-${process.pid}`, ep)
await client.connect(5000)
client.send({ type: 'chat', data: input.message })
client.disconnect()
const preview = input.summary || truncate(input.message, 50)
return {
data: {
success: true,
message: `${preview}” → ${input.to} (TCP ${ep.host}:${ep.port})`,
},
}
} catch (e) {
return {
data: {
success: false,
message: `Failed to send via TCP to ${input.to}: ${errorMessage(e)}`,
},
}
}
}
}
// Route to in-process subagent by name or raw agentId before falling
@@ -826,7 +875,9 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
prompt: input.message,
toolUseContext: context,
canUseTool,
invokingRequestId: assistantMessage?.requestId as string | undefined,
invokingRequestId: assistantMessage?.requestId as
| string
| undefined,
})
return {
data: {
@@ -853,7 +904,9 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
prompt: input.message,
toolUseContext: context,
canUseTool,
invokingRequestId: assistantMessage?.requestId as string | undefined,
invokingRequestId: assistantMessage?.requestId as
| string
| undefined,
})
return {
data: {

View File

@@ -0,0 +1,84 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { SEND_USER_FILE_TOOL_NAME } from './prompt.js'
const inputSchema = lazySchema(() =>
z.strictObject({
file_path: z
.string()
.describe('Absolute path to the file to send to the user.'),
description: z
.string()
.optional()
.describe('Optional description of the file being sent.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type SendUserFileInput = z.infer<InputSchema>
type SendUserFileOutput = { sent: boolean; file_path: string }
export const SendUserFileTool = buildTool({
name: SEND_USER_FILE_TOOL_NAME,
searchHint: 'send file to user mobile device upload share',
maxResultSizeChars: 5_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Send a file to the user (KAIROS assistant mode)'
},
async prompt() {
return `Send a file to the user's device. Use this in assistant mode when the user requests a file or when a file is relevant to the conversation.
Guidelines:
- Use absolute paths
- The file must exist and be readable
- Large files may take time to transfer`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'SendFile'
},
renderToolUseMessage(input: Partial<SendUserFileInput>) {
return `Send file: ${input.file_path ?? '...'}`
},
mapToolResultToToolResultBlockParam(
content: SendUserFileOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.sent
? `File sent: ${content.file_path}`
: `Failed to send file: ${content.file_path}`,
}
},
async call(_input: SendUserFileInput) {
// File transfer is handled by the KAIROS assistant transport layer.
// Without the KAIROS runtime, this tool is not available.
return {
data: {
sent: false,
file_path: _input.file_path,
error: 'SendUserFile requires the KAIROS assistant transport layer.',
},
}
},
})

View File

@@ -1,3 +1 @@
// Auto-generated stub — replace with real implementation
export {};
export const SEND_USER_FILE_TOOL_NAME: string = '';
export const SEND_USER_FILE_TOOL_NAME = 'SendUserFile'

View File

@@ -0,0 +1,134 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { SLEEP_TOOL_NAME, DESCRIPTION, SLEEP_TOOL_PROMPT } from './prompt.js'
const inputSchema = lazySchema(() =>
z.strictObject({
duration_seconds: z
.number()
.describe(
'How long to sleep in seconds. Can be interrupted by the user at any time.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type SleepInput = z.infer<InputSchema>
type SleepOutput = { slept_seconds: number; interrupted: boolean }
export const SleepTool = buildTool({
name: SLEEP_TOOL_NAME,
searchHint: 'wait pause sleep rest idle duration timer',
maxResultSizeChars: 1_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return DESCRIPTION
},
async prompt() {
return SLEEP_TOOL_PROMPT
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return SLEEP_TOOL_NAME
},
renderToolUseMessage(input: Partial<SleepInput>) {
const secs = input.duration_seconds ?? '?'
return `Sleep: ${secs}s`
},
mapToolResultToToolResultBlockParam(
content: SleepOutput,
toolUseID: string,
): ToolResultBlockParam {
const msg = content.interrupted
? `Sleep interrupted after ${content.slept_seconds}s`
: `Slept for ${content.slept_seconds}s`
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: msg,
}
},
async call(input: SleepInput, context) {
// Refuse to sleep when proactive mode is off — prevents the model from
// re-issuing Sleep after an interruption caused by /proactive disable.
if (feature('PROACTIVE') || feature('KAIROS')) {
const mod =
require('../../proactive/index.js') as typeof import('../../proactive/index.js')
if (!mod.isProactiveActive()) {
return {
data: {
slept_seconds: 0,
interrupted: true,
},
}
}
}
const { duration_seconds } = input
const startTime = Date.now()
try {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, duration_seconds * 1000)
// Abort via user interrupt
context.abortController.signal.addEventListener(
'abort',
() => {
clearTimeout(timer)
clearInterval(proactiveCheck)
reject(new Error('interrupted'))
},
{ once: true },
)
// Poll proactive state — if deactivated mid-sleep, interrupt early
// so the user doesn't have to wait for the full duration.
const proactiveCheck =
feature('PROACTIVE') || feature('KAIROS')
? setInterval(() => {
const mod =
require('../../proactive/index.js') as typeof import('../../proactive/index.js')
if (!mod.isProactiveActive()) {
clearTimeout(timer)
clearInterval(proactiveCheck)
reject(new Error('interrupted'))
}
}, 500)
: (null as unknown as ReturnType<typeof setInterval>)
})
return {
data: {
slept_seconds: duration_seconds,
interrupted: false,
},
}
} catch {
const elapsed = Math.round((Date.now() - startTime) / 1000)
return {
data: {
slept_seconds: elapsed,
interrupted: true,
},
}
}
},
})

View File

@@ -0,0 +1,92 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { SNIP_TOOL_NAME } from './prompt.js'
const inputSchema = lazySchema(() =>
z.strictObject({
message_ids: z
.array(z.string())
.describe(
'IDs of the messages to snip from history. Snipped messages are replaced with a short summary.',
),
reason: z
.string()
.optional()
.describe(
'Why these messages are being snipped. Used in the summary replacement.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type SnipInput = z.infer<InputSchema>
type SnipOutput = { snipped_count: number; summary: string }
export const SnipTool = buildTool({
name: SNIP_TOOL_NAME,
searchHint: 'snip trim history remove old messages compact context',
maxResultSizeChars: 5_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Snip messages from conversation history to free up context'
},
async prompt() {
return `Snip messages from your conversation history to free up context window space. Snipped messages are replaced with a compact summary so you retain awareness of what happened without the full content.
Use this when:
- Your context is getting full and you need to make room
- Earlier messages contain large tool outputs you no longer need in full
- You want to compact a long exploration sequence into a summary
Guidelines:
- Only snip messages you're confident you won't need verbatim again
- The summary replacement preserves key facts (file paths, decisions, errors found)
- You cannot un-snip — the original content is gone from context`
},
isConcurrencySafe() {
return false
},
isReadOnly() {
return false
},
userFacingName() {
return 'Snip'
},
renderToolUseMessage(input: Partial<SnipInput>) {
const count = input.message_ids?.length ?? 0
return `Snip: ${count} message${count !== 1 ? 's' : ''}`
},
mapToolResultToToolResultBlockParam(
content: SnipOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `Snipped ${content.snipped_count} messages. Summary: ${content.summary}`,
}
},
async call(input: SnipInput) {
// Snip implementation is handled by the query engine's projection system.
// The tool call itself records the intent; the query engine intercepts
// snip tool results and adjusts its message projection accordingly.
return {
data: {
snipped_count: input.message_ids.length,
summary: input.reason ?? `Snipped ${input.message_ids.length} messages`,
},
}
},
})

View File

@@ -1,3 +1 @@
// Auto-generated stub — replace with real implementation
export {};
export const SNIP_TOOL_NAME: string = '';
export const SNIP_TOOL_NAME = 'Snip'

View File

@@ -0,0 +1,88 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
const SUBSCRIBE_PR_TOOL_NAME = 'SubscribePR'
const inputSchema = lazySchema(() =>
z.strictObject({
repo: z
.string()
.describe('Repository in owner/repo format.'),
pr_number: z
.number()
.describe('Pull request number to subscribe to.'),
events: z
.array(z.enum(['comment', 'review', 'ci', 'merge', 'close']))
.optional()
.describe('Event types to subscribe to. Defaults to all events.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type SubscribeInput = z.infer<InputSchema>
type SubscribeOutput = { subscribed: boolean; subscription_id: string }
export const SubscribePRTool = buildTool({
name: SUBSCRIBE_PR_TOOL_NAME,
searchHint: 'subscribe pull request github webhook events watch',
maxResultSizeChars: 5_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Subscribe to pull request events via GitHub webhooks'
},
async prompt() {
return `Subscribe to events on a GitHub pull request. You'll receive notifications when selected events occur (comments, reviews, CI status changes, merge, close).
Use this to monitor PRs you've created or are reviewing. Events are delivered as messages you can act on.`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'SubscribePR'
},
renderToolUseMessage(input: Partial<SubscribeInput>) {
const pr = input.repo && input.pr_number
? `${input.repo}#${input.pr_number}`
: '...'
return `Subscribe PR: ${pr}`
},
mapToolResultToToolResultBlockParam(
content: SubscribeOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.subscribed
? `Subscribed to PR events (id: ${content.subscription_id})`
: 'Failed to subscribe to PR events.',
}
},
async call(_input: SubscribeInput) {
// Webhook subscription is managed by the KAIROS GitHub webhook subsystem.
// Without the KAIROS runtime, this tool is not available.
return {
data: {
subscribed: false,
subscription_id: '',
error: 'SubscribePR requires the KAIROS GitHub webhook subsystem.',
},
}
},
})

View File

@@ -1 +0,0 @@
export const SuggestBackgroundPRTool = { name: 'SuggestBackgroundPRTool', isEnabled: () => false }

View File

@@ -0,0 +1,84 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
const SUGGEST_BACKGROUND_PR_TOOL_NAME = 'SuggestBackgroundPR'
const inputSchema = lazySchema(() =>
z.strictObject({
title: z
.string()
.describe('Suggested title for the background PR.'),
description: z
.string()
.describe('Description of the changes to make in the background PR.'),
branch: z
.string()
.optional()
.describe('Branch name for the PR. Auto-generated if omitted.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type SuggestInput = z.infer<InputSchema>
type SuggestOutput = { suggested: boolean; suggestion_id: string }
export const SuggestBackgroundPRTool = buildTool({
name: SUGGEST_BACKGROUND_PR_TOOL_NAME,
searchHint: 'suggest background pr pull request create',
maxResultSizeChars: 5_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Suggest creating a background PR for follow-up changes'
},
async prompt() {
return `Suggest creating a pull request in the background for follow-up work. Use this when you identify improvements or cleanup that should be done but aren't part of the current task.
The suggestion is presented to the user who can approve or dismiss it. If approved, a background agent creates the PR.`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'SuggestPR'
},
renderToolUseMessage(input: Partial<SuggestInput>) {
return `Suggest PR: ${input.title ?? '...'}`
},
mapToolResultToToolResultBlockParam(
content: SuggestOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.suggested
? `PR suggestion recorded (id: ${content.suggestion_id})`
: 'Failed to record PR suggestion.',
}
},
async call(_input: SuggestInput) {
// Background PR suggestion requires the KAIROS runtime.
return {
data: {
suggested: false,
suggestion_id: '',
error: 'SuggestBackgroundPR requires the KAIROS runtime.',
},
}
},
})

View File

@@ -0,0 +1,82 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { TERMINAL_CAPTURE_TOOL_NAME } from './prompt.js'
const inputSchema = lazySchema(() =>
z.strictObject({
lines: z
.number()
.optional()
.describe('Number of lines to capture from the terminal. Defaults to 50.'),
panel_id: z
.string()
.optional()
.describe('ID of the terminal panel to capture from. Defaults to the active panel.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type CaptureInput = z.infer<InputSchema>
type CaptureOutput = { content: string; line_count: number }
export const TerminalCaptureTool = buildTool({
name: TERMINAL_CAPTURE_TOOL_NAME,
searchHint: 'terminal capture screen output panel read',
maxResultSizeChars: 100_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Capture output from a terminal panel'
},
async prompt() {
return `Capture the current content of a terminal panel. Use this to read output from terminal sessions running in the terminal panel UI.
Guidelines:
- Specify the number of lines to capture (default 50)
- Optionally target a specific panel by ID
- Content is returned as plain text`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'TerminalCapture'
},
renderToolUseMessage(input: Partial<CaptureInput>) {
const lines = input.lines ?? 50
return `Terminal Capture: ${lines} lines`
},
mapToolResultToToolResultBlockParam(
content: CaptureOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.content || '(empty terminal)',
}
},
async call(input: CaptureInput) {
// Terminal panel capture is provided by the TERMINAL_PANEL runtime.
return {
data: {
content: '',
line_count: 0,
},
}
},
})

View File

@@ -1,3 +1 @@
// Auto-generated stub — replace with real implementation
export {};
export const TERMINAL_CAPTURE_TOOL_NAME: string = '';
export const TERMINAL_CAPTURE_TOOL_NAME = 'TerminalCapture'

View File

@@ -1 +0,0 @@
export const VerifyPlanExecutionTool = { name: 'VerifyPlanExecutionTool', isEnabled: () => false }

View File

@@ -0,0 +1,93 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { VERIFY_PLAN_EXECUTION_TOOL_NAME } from './constants.js'
const inputSchema = lazySchema(() =>
z.strictObject({
plan_summary: z
.string()
.describe('A summary of the plan that was executed.'),
verification_notes: z
.string()
.optional()
.describe(
'Notes on what was verified and any issues found during verification.',
),
all_steps_completed: z
.boolean()
.describe('Whether all planned steps were completed successfully.'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type VerifyInput = z.infer<InputSchema>
type VerifyOutput = { verified: boolean; summary: string }
export const VerifyPlanExecutionTool = buildTool({
name: VERIFY_PLAN_EXECUTION_TOOL_NAME,
searchHint: 'verify plan execution check completion',
maxResultSizeChars: 10_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Verify that a plan was executed correctly before exiting plan mode'
},
async prompt() {
return `Verify that a plan has been executed correctly. Call this tool before exiting plan mode to confirm all steps were completed.
Guidelines:
- Summarize the plan that was executed
- Note whether all steps completed successfully
- Include any verification notes (tests passed, files created, etc.)
- If steps were skipped or failed, explain why in verification_notes`
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
userFacingName() {
return 'VerifyPlan'
},
renderToolUseMessage(input: Partial<VerifyInput>) {
if (input.all_steps_completed === true) {
return 'Verify Plan: all steps completed'
}
if (input.all_steps_completed === false) {
return 'Verify Plan: incomplete'
}
return 'Verify Plan'
},
mapToolResultToToolResultBlockParam(
content: VerifyOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: content.verified
? `Plan verified: ${content.summary}`
: `Plan verification failed: ${content.summary}`,
}
},
async call(input: VerifyInput) {
return {
data: {
verified: input.all_steps_completed,
summary: input.plan_summary,
},
}
},
})

View File

@@ -1,3 +1 @@
// Auto-generated stub — replace with real implementation
export {};
export const VERIFY_PLAN_EXECUTION_TOOL_NAME: string = '';
export const VERIFY_PLAN_EXECUTION_TOOL_NAME = 'VerifyPlanExecution'

View File

@@ -0,0 +1,97 @@
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from '../../Tool.js'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'
const WEB_BROWSER_TOOL_NAME = 'WebBrowser'
const inputSchema = lazySchema(() =>
z.strictObject({
url: z
.string()
.describe('URL to navigate to in the browser.'),
action: z
.enum(['navigate', 'screenshot', 'click', 'type', 'scroll'])
.optional()
.describe('Browser action to perform. Defaults to "navigate".'),
selector: z
.string()
.optional()
.describe('CSS selector for click/type actions.'),
text: z
.string()
.optional()
.describe('Text to type when action is "type".'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
type BrowserInput = z.infer<InputSchema>
type BrowserOutput = {
title: string
url: string
content?: string
screenshot?: string
}
export const WebBrowserTool = buildTool({
name: WEB_BROWSER_TOOL_NAME,
searchHint: 'web browser navigate url page screenshot click',
maxResultSizeChars: 100_000,
strict: true,
get inputSchema(): InputSchema {
return inputSchema()
},
async description() {
return 'Browse the web using an embedded browser'
},
async prompt() {
return `Open and interact with web pages in an embedded browser. Supports navigation, screenshots, clicking, typing, and scrolling.
Use this for:
- Viewing web pages and their content
- Taking screenshots of UI
- Interacting with web applications
- Testing web endpoints with full browser rendering`
},
isConcurrencySafe() {
return false
},
isReadOnly() {
return true
},
userFacingName() {
return 'Browser'
},
renderToolUseMessage(input: Partial<BrowserInput>) {
const action = input.action ?? 'navigate'
return `Browser ${action}: ${input.url ?? '...'}`
},
mapToolResultToToolResultBlockParam(
content: BrowserOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `${content.title} (${content.url})\n${content.content ?? ''}`,
}
},
async call(input: BrowserInput) {
// Browser integration requires the WEB_BROWSER_TOOL runtime (Bun WebView).
return {
data: {
title: '',
url: input.url,
content: 'Web browser requires the WEB_BROWSER_TOOL runtime.',
},
}
},
})

Some files were not shown because too many files have changed in this diff Show More