mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,6 +12,7 @@ src/utils/vendor/
|
|||||||
|
|
||||||
# AI tool runtime directories
|
# AI tool runtime directories
|
||||||
.agents/
|
.agents/
|
||||||
|
.claude/
|
||||||
.codex/
|
.codex/
|
||||||
.omx/
|
.omx/
|
||||||
|
|
||||||
|
|||||||
179
DEV-LOG.md
179
DEV-LOG.md
@@ -10,6 +10,185 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Pipe IPC + LAN Pipes + Monitor Tool + 工具恢复 (2026-04-08 ~ 2026-04-11)
|
||||||
|
|
||||||
|
**分支**: `feat/pr-package-adapt`
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
|
||||||
|
从 decompiled 代码恢复大量 stub 为完整实现,同时新增 LAN 跨机器通讯能力。本次 PR 覆盖:Pipe IPC 系统、LAN Pipes、Monitor Tool、20+ 工具/组件<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 peers,LAN peers 通过 TCP endpoint 连接
|
||||||
|
- cleanup 检查 LAN beacon peers 列表,避免误删存活的 LAN 连接
|
||||||
|
- attach 请求携带 `machineId`,接收方区分 LAN peer(不要求 sub 角色)
|
||||||
|
|
||||||
|
**Cleanup**:通过 `getLanBeacon()` 获取并 `stop()`,`setLanBeacon(null)` 清除
|
||||||
|
|
||||||
|
#### 6. 命令更新
|
||||||
|
|
||||||
|
- `/pipes`(`src/commands/pipes/pipes.ts`):显示 `[LAN]` 标记的远端实例
|
||||||
|
- `/attach`(`src/commands/attach/attach.ts`):自动查找 LAN beacon 获取 TCP endpoint
|
||||||
|
- `SendMessageTool`(`src/tools/SendMessageTool/SendMessageTool.ts`):支持 `tcp:` scheme,权限检查要求用户确认
|
||||||
|
|
||||||
|
#### 7. Feature Flag
|
||||||
|
|
||||||
|
`LAN_PIPES` — 在 `scripts/dev.ts` 和 `build.ts` 的默认 features 列表中启用。所有 LAN 代码路径均通过 `feature('LAN_PIPES')` 门控。
|
||||||
|
|
||||||
|
#### 8. Pipe IPC 基础系统(`UDS_INBOX` feature)
|
||||||
|
|
||||||
|
- `PipeServer`/`PipeClient`:UDS 传输,NDJSON 协议(共享 `ndjsonFramer.ts`)
|
||||||
|
- `PipeRegistry`:machineId 绑定的角色分配(main/sub),文件锁,并行探测
|
||||||
|
- Master/slave attach 流程、prompt 转发、permission 转发
|
||||||
|
- Heartbeat 生命周期(5s 间隔,stale entry 清理,busy flag 防重叠)
|
||||||
|
- 命令:`/pipes`、`/attach`、`/detach`、`/send`、`/claim-main`、`/pipe-status`
|
||||||
|
|
||||||
|
#### 9. Monitor Tool(`MONITOR_TOOL` feature)
|
||||||
|
|
||||||
|
- `MonitorTool`:AI 可调用的后台 shell 监控工具
|
||||||
|
- `/monitor` 命令:用户快捷入口,Windows 兼容(watch → PowerShell 循环)
|
||||||
|
- `MonitorMcpTask`:从 stub 恢复完整生命周期(register/complete/fail/kill)
|
||||||
|
- `MonitorPermissionRequest`:React 权限确认 UI
|
||||||
|
- `MonitorMcpDetailDialog`:Shift+Down 详情面板
|
||||||
|
|
||||||
|
#### 10. 工具恢复(stub → 实现)
|
||||||
|
|
||||||
|
- SnipTool、SleepTool、ListPeersTool、SendUserFileTool
|
||||||
|
- WebBrowserTool、SubscribePRTool、PushNotificationTool
|
||||||
|
- CtxInspectTool、TerminalCaptureTool、WorkflowTool
|
||||||
|
- REPLTool (.js → .ts)、VerifyPlanExecutionTool (.js → .ts)、SuggestBackgroundPRTool (.js → .ts)
|
||||||
|
- 组件 .ts → .tsx 重写:MonitorPermissionRequest、ReviewArtifactPermissionRequest、MonitorMcpDetailDialog、WorkflowDetailDialog、WorkflowPermissionRequest
|
||||||
|
|
||||||
|
#### 11. REPL Hook 架构重构
|
||||||
|
|
||||||
|
从 REPL.tsx 提取 ~830 行 Pipe IPC 内联代码为 4 个独立 hook:
|
||||||
|
|
||||||
|
| Hook | 行数 | 职责 |
|
||||||
|
|------|------|------|
|
||||||
|
| `usePipeIpc` | 623 | 生命周期:bootstrap、handlers、heartbeat、cleanup |
|
||||||
|
| `usePipeRelay` | 38 | slave→master 消息回传(通过 `setPipeRelay` singleton) |
|
||||||
|
| `usePipePermissionForward` | 159 | 权限请求转发 + 流式通知显示 |
|
||||||
|
| `usePipeRouter` | 130 | selected pipe 输入路由 + role/IP 标签显示 |
|
||||||
|
|
||||||
|
共享工具:`ndjsonFramer.ts` 替换 3 份重复的 NDJSON 解析。
|
||||||
|
|
||||||
|
#### 12. Feature Flags 新增启用
|
||||||
|
|
||||||
|
UDS_INBOX、LAN_PIPES、MONITOR_TOOL、FORK_SUBAGENT、KAIROS、COORDINATOR_MODE、WORKFLOW_SCRIPTS、HISTORY_SNIP、CONTEXT_COLLAPSE
|
||||||
|
|
||||||
|
### 踩坑记录
|
||||||
|
|
||||||
|
1. **Multicast 绑错网卡**:Windows 上 `addMembership(group)` 不指定本地接口时,默认绑到 WSL/Docker 虚拟网卡(`172.19.112.1`),LAN 上的真实机器收不到。必须 `addMembership(group, localIp)` + `setMulticastInterface(localIp)`。
|
||||||
|
|
||||||
|
2. **Beacon ref 丢失**:最初用 `(store.getState() as any)._lanBeacon` 挂载 beacon 引用,但 Zustand `setState` 展开 `prev` 时不包含 `_lanBeacon` 属性,下次读取就是 `undefined`。改为 module-level singleton 解决。
|
||||||
|
|
||||||
|
3. **Heartbeat 清洗 LAN 连接**:`refreshDiscoveredPipes()` 每 5 秒用仅含本地 registry subs 的列表完全覆盖 `discoveredPipes` + `selectedPipes`,LAN peer 的发现和选择状态被持续清空。必须在 refresh 中同时包含 beacon peers。
|
||||||
|
|
||||||
|
4. **Heartbeat cleanup 误删**:`!aliveSubNames.has(slaveName)` 导致 LAN peer(不在本地 registry)被判定为死连接每 5 秒清除一次。需要同时检查 beacon peers 列表。
|
||||||
|
|
||||||
|
5. **跨机器 attach 被拒**:两台机器各自为 `main`,attach handler 硬编码 `role !== 'sub'` 拒绝。通过 attach_request 携带 `machineId`,接收方对不同 machineId 的请求放行。
|
||||||
|
|
||||||
|
6. **`feature()` 使用约束**:Bun 的 `feature()` 是编译时常量,只能在 `if` 语句或三元条件中直接使用,不能赋值给变量(如 `const x = feature('...')`),否则构建报错。
|
||||||
|
|
||||||
|
### 已知限制
|
||||||
|
|
||||||
|
- TCP 无认证:同 LAN 内任何设备知道端口号即可连接
|
||||||
|
- JSON.parse 无 schema 验证:code review 建议增加 Zod 校验
|
||||||
|
- Beacon 明文广播 IP/hostname/machineId:建议后续 hash 处理
|
||||||
|
- `getLocalIp()` 可能返回 VPN 地址:多网卡环境需更精确的接口选择
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- `src/utils/__tests__/lanBeacon.test.ts`:7 个测试(mock dgram)
|
||||||
|
- `src/utils/__tests__/peerAddress.test.ts`:8 个测试(纯函数)
|
||||||
|
- 全量:2190 pass / 0 fail
|
||||||
|
|
||||||
|
### 防火墙配置
|
||||||
|
|
||||||
|
**Windows**(管理员 PowerShell):
|
||||||
|
```powershell
|
||||||
|
New-NetFirewallRule -DisplayName "Claude Code LAN Beacon (UDP)" -Direction Inbound -Protocol UDP -LocalPort 7101 -Action Allow -Profile Private
|
||||||
|
New-NetFirewallRule -DisplayName "Claude Code LAN Pipes (TCP)" -Direction Inbound -Protocol TCP -LocalPort 1024-65535 -Program (Get-Command bun).Source -Action Allow -Profile Private
|
||||||
|
New-NetFirewallRule -DisplayName "Claude Code LAN Beacon Out (UDP)" -Direction Outbound -Protocol UDP -RemotePort 7101 -Action Allow -Profile Private
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS**(首次运行时系统会弹出"允许接受传入连接"对话框,点击允许即可。手动放行):
|
||||||
|
```bash
|
||||||
|
# 如果使用 pf <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)
|
## Daemon + Remote Control Server 还原 (2026-04-07)
|
||||||
|
|
||||||
**分支**: `feat/daemon-remote-control-server`
|
**分支**: `feat/daemon-remote-control-server`
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/qZU6zS7Q)
|
[文档在这里, 支持投稿 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] 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 封存为历史版本)
|
- 🔮 [ ] V6 — 大规模重构石山代码,全面模块分包(全新分支,main 封存为历史版本)
|
||||||
|
|
||||||
- 🚀 [想要启动项目](#快速开始源码版)
|
- 🚀 [想要启动项目](#快速开始源码版)
|
||||||
|
|||||||
25
build.ts
25
build.ts
@@ -11,9 +11,6 @@ rmSync(outdir, { recursive: true, force: true })
|
|||||||
// Default features that match the official CLI build.
|
// Default features that match the official CLI build.
|
||||||
// Additional features can be enabled via FEATURE_<NAME>=1 env vars.
|
// Additional features can be enabled via FEATURE_<NAME>=1 env vars.
|
||||||
const DEFAULT_BUILD_FEATURES = [
|
const DEFAULT_BUILD_FEATURES = [
|
||||||
'BUDDY',
|
|
||||||
'TRANSCRIPT_CLASSIFIER',
|
|
||||||
'BRIDGE_MODE',
|
|
||||||
'AGENT_TRIGGERS_REMOTE',
|
'AGENT_TRIGGERS_REMOTE',
|
||||||
'CHICAGO_MCP',
|
'CHICAGO_MCP',
|
||||||
'VOICE_MODE',
|
'VOICE_MODE',
|
||||||
@@ -33,6 +30,28 @@ const DEFAULT_BUILD_FEATURES = [
|
|||||||
'ULTRAPLAN',
|
'ULTRAPLAN',
|
||||||
// P2: daemon + remote control server
|
// P2: daemon + remote control server
|
||||||
'DAEMON',
|
'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)
|
// P3: poor mode (disable extract_memories + prompt_suggestion)
|
||||||
'POOR',
|
'POOR',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ FEATURE_KAIROS=1 FEATURE_PROACTIVE=1 FEATURE_FORK_SUBAGENT=1 bun run dev
|
|||||||
| Feature | 引用 | 状态 | 说明 |
|
| Feature | 引用 | 状态 | 说明 |
|
||||||
|---------|------|------|------|
|
|---------|------|------|------|
|
||||||
| CHICAGO_MCP | 16 | N/A | Anthropic 内部 MCP 基础设施 |
|
| CHICAGO_MCP | 16 | N/A | Anthropic 内部 MCP 基础设施 |
|
||||||
| UDS_INBOX | 17 | Stub | Unix 域套接字对等消息 |
|
| UDS_INBOX | 17 | Experimental | 本机 UDS 消息层 + 本机 named-pipe 协调层 |
|
||||||
| MONITOR_TOOL | 13 | Stub | 文件/进程监控工具 |
|
| MONITOR_TOOL | 13 | Stub | 文件/进程监控工具 |
|
||||||
| BG_SESSIONS | 11 | Stub | 后台会话管理 |
|
| BG_SESSIONS | 11 | Stub | 后台会话管理 |
|
||||||
| SHOT_STATS | 10 | 无实现 | 逐 prompt 统计 |
|
| SHOT_STATS | 10 | 无实现 | 逐 prompt 统计 |
|
||||||
|
|||||||
@@ -1005,38 +1005,32 @@ src/utils/swarm/ 目录(22 个文件):
|
|||||||
|
|
||||||
## 28. UDS_INBOX
|
## 28. UDS_INBOX
|
||||||
|
|
||||||
**编译时引用次数**: 18(单引号 17 + 双引号 1)
|
**编译时引用次数**: 18(历史快照)
|
||||||
**功能描述**: UDS(Unix Domain Socket)收件箱。允许 Claude Code 实例之间通过 Unix 套接字发送消息。
|
**功能描述**: 本机进程间通信能力。当前由两层组成:
|
||||||
**分类**: PARTIAL
|
1. `udsMessaging` / `udsClient`:通用 UDS 消息层,供 `SendMessageTool` 与 `/peers` 使用。
|
||||||
**缺失原因**: `src/utils/udsMessaging.ts` 仅 1 行,`src/utils/udsClient.ts` 仅 3 行(空壳),命令入口缺失
|
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/utils/udsMessaging.ts | 通用 UDS server / inbox |
|
||||||
| src/tools/SendMessageTool/prompt.ts | 49 行 | 消息工具提示词 |
|
| src/utils/udsClient.ts | 通用 peer 发现、探活、发送 |
|
||||||
| src/utils/udsClient.ts | 3 行 | UDS 客户端(桩) |
|
| src/utils/pipeTransport.ts | named-pipe server/client、探活、AppState 扩展 |
|
||||||
| src/utils/udsMessaging.ts | 1 行 | UDS 消息(桩) |
|
| 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 个)**:
|
**备注**: 如需真实局域网通信,需要单独引入 TCP/WebSocket 传输、认证与发现机制;现有代码尚未实现该层。详见 `docs/features/uds-inbox.md`。
|
||||||
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 客户端和消息模块,并创建命令入口。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1011,38 +1011,32 @@ src/utils/swarm/ 目录(22 个文件):
|
|||||||
|
|
||||||
## 28. UDS_INBOX
|
## 28. UDS_INBOX
|
||||||
|
|
||||||
**编译时引用次数**: 18(单引号 17 + 双引号 1)
|
**编译时引用次数**: 18(历史快照)
|
||||||
**功能描述**: UDS(Unix Domain Socket)收件箱。允许 Claude Code 实例之间通过 Unix 套接字发送消息。
|
**功能描述**: 本机进程间通信能力。当前由两层组成:
|
||||||
**分类**: PARTIAL
|
1. `udsMessaging` / `udsClient`:通用 UDS 消息层,供 `SendMessageTool` 与 `/peers` 使用。
|
||||||
**缺失原因**: `src/utils/udsMessaging.ts` 仅 1 行,`src/utils/udsClient.ts` 仅 3 行(空壳),命令入口缺失
|
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/utils/udsMessaging.ts | 通用 UDS server / inbox |
|
||||||
| src/tools/SendMessageTool/prompt.ts | 49 行 | 消息工具提示词 |
|
| src/utils/udsClient.ts | 通用 peer 发现、探活、发送 |
|
||||||
| src/utils/udsClient.ts | 3 行 | UDS 客户端(桩) |
|
| src/utils/pipeTransport.ts | named-pipe server/client、探活、AppState 扩展 |
|
||||||
| src/utils/udsMessaging.ts | 1 行 | UDS 消息(桩) |
|
| 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 个)**:
|
**备注**: 如需真实局域网通信,需要单独引入 TCP/WebSocket 传输、认证与发现机制;现有代码尚未实现该层。详见 `docs/features/uds-inbox.md`。
|
||||||
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 客户端和消息模块,并创建命令入口。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
545
docs/features/lan-pipes-implementation.md
Normal file
545
docs/features/lan-pipes-implementation.md
Normal 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 和 TCP,PipeClient 根据参数自动选择连接模式
|
||||||
|
- **本地优先**:本地 registry 条目优先于 LAN beacon 发现的条目
|
||||||
|
- **安全保守**:TCP 连接需用户显式同意,multicast TTL=1 不跨路由器
|
||||||
|
|
||||||
|
### 1.3 架构总览
|
||||||
|
|
||||||
|
```
|
||||||
|
Machine A (192.168.1.10) Machine B (192.168.1.20)
|
||||||
|
┌───────────────────────────┐ ┌───────────────────────────┐
|
||||||
|
│ PipeServer │ │ PipeServer │
|
||||||
|
│ UDS: cli-abc.sock │ │ UDS: cli-def.sock │
|
||||||
|
│ TCP: 0.0.0.0:<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 peers(LAN peers 通过 TCP endpoint 连接)
|
||||||
|
3. cleanup 时检查 LAN beacon peers 列表,避免误删 LAN 连接
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// auto-attach 统一目标列表:本地 subs + LAN peers
|
||||||
|
const attachTargets = [...aliveSubs.map(s => ({ pipeName: s.pipeName }))];
|
||||||
|
if (feature('LAN_PIPES')) {
|
||||||
|
const beacon = getLanBeacon();
|
||||||
|
for (const [name, peer] of beacon.getPeers()) {
|
||||||
|
attachTargets.push({ pipeName: name, tcpEndpoint: { host: peer.ip, port: peer.tcpPort } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cleanup 阶段
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 停止 LAN beacon
|
||||||
|
const { getLanBeacon, setLanBeacon } = require('../utils/lanBeacon.js');
|
||||||
|
const beacon = getLanBeacon();
|
||||||
|
if (beacon) {
|
||||||
|
try { beacon.stop(); } catch {}
|
||||||
|
setLanBeacon(null);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beacon 存储方案**:使用 `lanBeacon.ts` 中的 module-level singleton(`getLanBeacon()`/`setLanBeacon()`),不挂在 Zustand store state 上,避免 `setState` 展开时丢失引用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.8 /pipes 命令 LAN 显示
|
||||||
|
|
||||||
|
**文件**: `src/commands/pipes/pipes.ts`
|
||||||
|
|
||||||
|
在现有 registry 显示之后,如果 `feature('LAN_PIPES')` 启用:
|
||||||
|
|
||||||
|
1. 通过 `getLanBeacon()` 获取 LAN peers
|
||||||
|
2. 调用 `mergeWithLanPeers()` 合并
|
||||||
|
3. 过滤 `source === 'lan'` 的条目
|
||||||
|
4. 显示格式:`☐ [role] pipeName hostname/ip tcp:host:port [LAN]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.9 /attach 命令 TCP 支持
|
||||||
|
|
||||||
|
**文件**: `src/commands/attach/attach.ts`
|
||||||
|
|
||||||
|
在连接之前,如果 `feature('LAN_PIPES')` 启用:
|
||||||
|
|
||||||
|
1. 在 `discoveredPipes` 中查找目标 pipe
|
||||||
|
2. 通过 `_lanBeacon.getPeers()` 检查是否为 LAN peer
|
||||||
|
3. 如果是,构造 `TcpEndpoint` 传给 `connectToPipe()`
|
||||||
|
4. 错误消息中包含 TCP 端点信息便于诊断
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.10 SendMessageTool TCP 支持
|
||||||
|
|
||||||
|
**文件**: `src/tools/SendMessageTool/SendMessageTool.ts`
|
||||||
|
|
||||||
|
#### inputSchema 描述更新
|
||||||
|
|
||||||
|
当 `LAN_PIPES` 启用时,`to` 字段描述追加 `, or "tcp:<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 的 subs,LAN peers 需手动 /attach
|
||||||
|
3. **固定端口范围配置**:允许用户配置 TCP 端口范围,便于防火墙规则
|
||||||
|
4. **mDNS/DNS-SD 作为 beacon 替代**:在 multicast 受限的环境提供更可靠的发现
|
||||||
|
5. **加密传输**:TLS over TCP,确保消息不被中间人窃听
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 防火墙要求
|
||||||
|
|
||||||
|
| 协议 | 端口 | 方向 | 用途 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| UDP | 7101 | IN + OUT | Multicast beacon 发现 |
|
||||||
|
| TCP | 动态 (0) | IN | PipeServer TCP 监听 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
netsh advfirewall firewall add rule name="Claude LAN Beacon" dir=in action=allow protocol=UDP localport=7101
|
||||||
|
netsh advfirewall firewall add rule name="Claude LAN Pipes" dir=in action=allow program="<bun路径>" enable=yes
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
首次运行时系统弹窗允许即可。
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo firewall-cmd --add-port=7101/udp
|
||||||
|
# TCP 端口随机,建议放行 bun 进程
|
||||||
|
```
|
||||||
86
docs/features/lan-pipes.md
Normal file
86
docs/features/lan-pipes.md
Normal 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 认证
|
||||||
342
docs/features/pipes-and-lan.md
Normal file
342
docs/features/pipes-and-lan.md
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
# Pipes + LAN Pipes 完整功能指南
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
Pipes 系统提供 Claude Code CLI 实例之间的通讯能力,分两层:
|
||||||
|
|
||||||
|
1. **Pipes(本机)**:同一台机器上的多个 CLI 实例通过 UDS(Unix Domain Socket / Windows Named Pipe)协作
|
||||||
|
2. **LAN Pipes(局域网)**:不同机器上的 CLI 实例通过 TCP + UDP Multicast 协作
|
||||||
|
|
||||||
|
两层使用同一套协议(NDJSON)和同一套命令(`/pipes`、`/attach`、`/send` 等),对用户透明。
|
||||||
|
|
||||||
|
## Feature Flags
|
||||||
|
|
||||||
|
| Flag | 控制范围 | 默认 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `UDS_INBOX` | 本机 Pipe IPC 全部功能 | dev/build 启用 |
|
||||||
|
| `LAN_PIPES` | 局域网 TCP + beacon 扩展 | dev/build 启用 |
|
||||||
|
|
||||||
|
手动启用:`FEATURE_UDS_INBOX=1 FEATURE_LAN_PIPES=1 bun run dev`
|
||||||
|
|
||||||
|
## 快速上手
|
||||||
|
|
||||||
|
### 本机多实例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 终端 1
|
||||||
|
bun run dev
|
||||||
|
# 启动后自动注册为 main
|
||||||
|
|
||||||
|
# 终端 2
|
||||||
|
bun run dev
|
||||||
|
# 自动注册为 sub-1,被 main 自动 attach
|
||||||
|
```
|
||||||
|
|
||||||
|
在终端 1 中输入 `/pipes`,可以看到两个实例。选中 sub-1 后,输入的消息会自动转发到 sub-1 执行。
|
||||||
|
|
||||||
|
### 局域网多机器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 机器 A (192.168.50.22)
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# 机器 B (192.168.50.27)
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
两边启动后等 3-5 秒(beacon 广播间隔),LAN peers 会自动发现并 attach。输入 `/pipes` 可看到标记 `[LAN]` 的远端实例。
|
||||||
|
|
||||||
|
### 防火墙配置(两台机器都需要)
|
||||||
|
|
||||||
|
**Windows**(管理员 PowerShell):
|
||||||
|
```powershell
|
||||||
|
New-NetFirewallRule -DisplayName "Claude Code LAN Beacon (UDP)" -Direction Inbound -Protocol UDP -LocalPort 7101 -Action Allow -Profile Private
|
||||||
|
New-NetFirewallRule -DisplayName "Claude Code LAN Pipes (TCP)" -Direction Inbound -Protocol TCP -LocalPort 1024-65535 -Program (Get-Command bun).Source -Action Allow -Profile Private
|
||||||
|
New-NetFirewallRule -DisplayName "Claude Code LAN Beacon Out (UDP)" -Direction Outbound -Protocol UDP -RemotePort 7101 -Action Allow -Profile Private
|
||||||
|
# 确认网络为"专用":Get-NetConnectionProfile
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS**(首次运行时系统弹出对话框,点击"允许"即可):
|
||||||
|
```bash
|
||||||
|
# 如果需要手动放行 pf 防火墙:
|
||||||
|
echo "pass in proto udp from any to any port 7101" | sudo pfctl -ef -
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux**(firewalld / iptables):
|
||||||
|
```bash
|
||||||
|
# firewalld
|
||||||
|
sudo firewall-cmd --zone=trusted --add-port=7101/udp --permanent
|
||||||
|
sudo firewall-cmd --zone=trusted --add-port=1024-65535/tcp --permanent
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
|
||||||
|
# 或 iptables
|
||||||
|
sudo iptables -A INPUT -p udp --dport 7101 -j ACCEPT
|
||||||
|
sudo iptables -A INPUT -p tcp --dport 1024:65535 -m owner --uid-owner $(id -u) -j ACCEPT
|
||||||
|
```
|
||||||
|
|
||||||
|
确认:网络为局域网(非公共 WiFi),路由器未开启 AP 隔离。
|
||||||
|
|
||||||
|
## 交互面板与快捷键
|
||||||
|
|
||||||
|
### 状态栏
|
||||||
|
|
||||||
|
执行 `/pipes` 后,输入框底部出现 pipe 状态栏(单行):
|
||||||
|
|
||||||
|
```
|
||||||
|
pipe: cli-a91bad56 (main) 192.168.50.22 2/3 selected selected pipes only · ←/→ or m switch · Shift+↓ edit
|
||||||
|
```
|
||||||
|
|
||||||
|
状态栏始终可见(直到会话结束),显示:当前 pipe 名、角色、IP、已选数/总数、路由模式。
|
||||||
|
|
||||||
|
### 展开选择面板
|
||||||
|
|
||||||
|
按 **Shift+↓**(Shift + 下箭头)展开选择面板:
|
||||||
|
|
||||||
|
```
|
||||||
|
pipe: cli-a91bad56 (main) 192.168.50.22 ↑↓ move Space select ←/→ or m route Enter/Esc close Shift+↓ toggle
|
||||||
|
当前普通 prompt 走 已选 sub;切换不会清空选择
|
||||||
|
☑ cli-da029538 (sub-1 XC/192.168.50.22)
|
||||||
|
☐ cli-04d67950 (main vmwin11/192.168.50.27)
|
||||||
|
☑ cli-893747d3 [offline] (sub-2 vmwin11/192.168.50.27)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 面板内快捷键
|
||||||
|
|
||||||
|
| 快捷键 | 场景 | 作用 |
|
||||||
|
|--------|------|------|
|
||||||
|
| **Shift+↓** | 状态栏可见时 | 展开/收起选择面板 |
|
||||||
|
| **↑ / ↓** | 面板展开时 | 上下移动光标 |
|
||||||
|
| **Space** | 面板展开时 | 切换当前光标所在 pipe 的选中状态(☑ ↔ ☐) |
|
||||||
|
| **Enter** | 面板展开时 | 确认并关闭面板 |
|
||||||
|
| **Esc** | 面板展开时 | 取消并关闭面板 |
|
||||||
|
| **← / → 或 M** | 状态栏可见且有选中 pipe 时 | 切换路由模式(`selected pipes only` ↔ `local main`) |
|
||||||
|
|
||||||
|
### M 键 — 路由模式切换
|
||||||
|
|
||||||
|
M 键(或 ← / →)用于在两种路由模式之间切换,**无需展开面板**:
|
||||||
|
|
||||||
|
| 模式 | 状态栏显示 | 行为 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `selected pipes only` | 绿色高亮 | 输入的 prompt **仅**发送到选中的 pipe,本地不执行 |
|
||||||
|
| `local main` | 灰色 | 输入的 prompt 在**本地 main** 执行,不转发到任何 pipe |
|
||||||
|
|
||||||
|
切换路由模式**不会清空选择**。你可以在 `local main` 模式下保持选择,随时按 M 切回 `selected pipes only` 继续向远端发送。
|
||||||
|
|
||||||
|
### 完整操作流程示例
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 输入 /pipes → 状态栏出现,显示发现的实例
|
||||||
|
2. 按 Shift+↓ → 展开选择面板
|
||||||
|
3. 按 ↓ 移动到目标 pipe → 光标移到 cli-04d67950
|
||||||
|
4. 按 Space → 选中 ☑ cli-04d67950
|
||||||
|
5. 按 Enter → 确认,面板收起
|
||||||
|
6. 输入 "帮我检查 git status" → prompt 自动发送到 cli-04d67950 执行
|
||||||
|
7. 按 M → 切换到 local main 模式
|
||||||
|
8. 输入 "本地做点什么" → 仅在本地执行
|
||||||
|
9. 按 M → 切回 selected pipes only
|
||||||
|
10. 输入 "继续远端任务" → 又发送到 cli-04d67950
|
||||||
|
```
|
||||||
|
|
||||||
|
## 命令参考
|
||||||
|
|
||||||
|
### /pipes
|
||||||
|
|
||||||
|
显示所有发现的实例,管理选择状态。再次执行 `/pipes` 切换面板展开/收起。
|
||||||
|
|
||||||
|
```
|
||||||
|
/pipes — 显示所有实例 + 切换选择面板
|
||||||
|
/pipes select <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` | 消息仅在本地执行,不转发 |
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
### 通信协议
|
||||||
|
|
||||||
|
所有通讯使用 NDJSON(Newline-Delimited JSON),每行一个消息:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"ping","from":"cli-abc","ts":"2026-04-11T00:00:00.000Z"}
|
||||||
|
{"type":"prompt","data":"帮我查看 git status","from":"cli-abc","ts":"..."}
|
||||||
|
{"type":"stream","data":"正在执行...","from":"cli-def","ts":"..."}
|
||||||
|
{"type":"done","data":"","from":"cli-def","ts":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 消息类型
|
||||||
|
|
||||||
|
| 类型 | 方向 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `ping`/`pong` | 双向 | 健康检查 |
|
||||||
|
| `attach_request`/`accept`/`reject` | M→S/S→M | 连接控制 |
|
||||||
|
| `detach` | M→S | 断开连接 |
|
||||||
|
| `prompt` | M→S | 主向从发送 prompt |
|
||||||
|
| `prompt_ack` | S→M | 从确认接收 |
|
||||||
|
| `stream` | S→M | 从流式回传 AI 输出 |
|
||||||
|
| `tool_start`/`tool_result` | S→M | 工具执行通知 |
|
||||||
|
| `done` | S→M | 本轮完成 |
|
||||||
|
| `error` | 双向 | 错误通知 |
|
||||||
|
| `permission_request`/`response`/`cancel` | 双向 | 权限审批转发 |
|
||||||
|
|
||||||
|
### 传输层
|
||||||
|
|
||||||
|
```
|
||||||
|
本机 LAN
|
||||||
|
┌──────────────┐ ┌──────────────┐
|
||||||
|
│ PipeServer │ │ PipeServer │
|
||||||
|
│ UDS sock │ │ UDS sock │
|
||||||
|
│ TCP :rand │◄───TCP───►│ TCP :rand │
|
||||||
|
├──────────────┤ ├──────────────┤
|
||||||
|
│ LanBeacon │◄──UDP────►│ LanBeacon │
|
||||||
|
│ 224.0.71.67 │ mcast │ 224.0.71.67 │
|
||||||
|
└──────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **UDS**:本机实例间通讯,通过文件系统路径寻址(`~/.claude/pipes/cli-xxx.sock`)
|
||||||
|
- **TCP**:LAN 实例间通讯,动态端口,通过 beacon 发现
|
||||||
|
- **UDP Multicast**:peer 发现,3 秒广播一次 announce 包
|
||||||
|
|
||||||
|
### 角色模型
|
||||||
|
|
||||||
|
| 角色 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `main` | 首个启动的实例,管理 registry |
|
||||||
|
| `sub` | 后续启动的同机实例(或被 attach 的 LAN 实例) |
|
||||||
|
| `master` | attach 了至少一个 slave 的实例 |
|
||||||
|
| `slave` | 被 master attach 控制的实例 |
|
||||||
|
|
||||||
|
角色转换:
|
||||||
|
- 首个启动 → `main`
|
||||||
|
- 同机后续启动 → `sub`(自动被 main attach → `slave`)
|
||||||
|
- LAN 发现 → 两边都是 `main`,heartbeat 自动互相 attach
|
||||||
|
- 被 attach → 变为 `slave`(可通过 `/detach` 恢复)
|
||||||
|
|
||||||
|
### 发现机制
|
||||||
|
|
||||||
|
**本机**:通过 `~/.claude/pipes/registry.json` 文件(带文件锁),`machineId` 绑定主机身份。
|
||||||
|
|
||||||
|
**LAN**:通过 UDP multicast beacon:
|
||||||
|
1. 每 3 秒广播 `{ proto, pipeName, machineId, ip, tcpPort, role }`
|
||||||
|
2. 收到其他实例的 announce → 记入 peers Map
|
||||||
|
3. 15 秒未收到 → 标记 peer lost
|
||||||
|
4. Heartbeat 合并 local registry + beacon peers → 统一 attach 目标列表
|
||||||
|
|
||||||
|
### Heartbeat 循环(5 秒间隔)
|
||||||
|
|
||||||
|
```
|
||||||
|
main/master 角色:
|
||||||
|
1. cleanupStaleEntries() — 清理 registry 中死掉的条目
|
||||||
|
2. getAliveSubs() — 获取存活的本地 subs
|
||||||
|
3. refreshDiscoveredPipes() — 刷新 discoveredPipes(包含 LAN peers)
|
||||||
|
4. 合并 LAN peers 到 state
|
||||||
|
5. 构建统一 attach 目标列表 — 本地 subs + LAN peers
|
||||||
|
6. 遍历未连接的目标 → 自动 attach
|
||||||
|
7. 清理断开的 slave 连接 — 同时检查 local registry 和 beacon
|
||||||
|
|
||||||
|
sub 角色:
|
||||||
|
1. 检测 main 是否存活
|
||||||
|
2. main 死亡 → 同机则接管 main 角色,跨机则独立
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键文件
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `src/utils/pipeTransport.ts` | PipeServer(双模 UDS+TCP)、PipeClient、类型定义 |
|
||||||
|
| `src/utils/lanBeacon.ts` | UDP multicast beacon、singleton 管理 |
|
||||||
|
| `src/utils/pipeRegistry.ts` | Registry CRUD、角色判定、machineId、LAN merge |
|
||||||
|
| `src/utils/peerAddress.ts` | 地址解析(uds:/bridge:/tcp: scheme) |
|
||||||
|
| `src/screens/REPL.tsx` | Bootstrap、heartbeat、cleanup、prompt 路由 |
|
||||||
|
| `src/hooks/useMasterMonitor.ts` | Slave client registry、消息订阅 |
|
||||||
|
| `src/hooks/useSlaveNotifications.ts` | Slave 端通知处理 |
|
||||||
|
| `src/commands/pipes/pipes.ts` | /pipes 命令 |
|
||||||
|
| `src/commands/attach/attach.ts` | /attach 命令 |
|
||||||
|
| `src/commands/send/send.ts` | /send 命令 |
|
||||||
|
| `src/tools/SendMessageTool/SendMessageTool.ts` | AI 发消息工具(含 tcp: 支持) |
|
||||||
|
|
||||||
|
## 后续优化方向
|
||||||
|
|
||||||
|
### 安全(P0)
|
||||||
|
|
||||||
|
1. **TCP 认证**:首次连接时交换 HMAC-SHA256 token(基于 machineId + session secret),防止未授权设备连接
|
||||||
|
2. **JSON schema 验证**:在所有 `JSON.parse` 入口点增加 Zod 校验,防止 prototype pollution
|
||||||
|
3. **Beacon 信息脱敏**:hash machineId 后再广播,不暴露硬件序列号
|
||||||
|
|
||||||
|
### 可靠性(P1)
|
||||||
|
|
||||||
|
4. **多网卡选择**:`getLocalIp()` 应优先选择 RFC 1918 地址,排除 VPN/Docker 接口
|
||||||
|
5. **TCP target 验证**:`parseTcpTarget()` 应限制目标为已知 beacon peers 或 RFC 1918 范围
|
||||||
|
6. **PipeServer close()**:改为 `Promise.allSettled` 并行关闭 UDS + TCP,加 `_closing` guard
|
||||||
|
|
||||||
|
### 功能(P2)
|
||||||
|
|
||||||
|
7. **mDNS/DNS-SD**:作为 multicast 受限环境下的 beacon 替代方案
|
||||||
|
8. **固定端口配置**:允许用户指定 TCP 端口范围,便于防火墙精确配置
|
||||||
|
9. **TLS 加密**:TCP 传输加密,防中间人窃听
|
||||||
|
10. **双向 prompt**:当前只有 master → slave 方向,可考虑 slave 主动向 master 发送结果/请求
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
| Feature | 引用 | 状态 | 类别 | 简要说明 |
|
| Feature | 引用 | 状态 | 类别 | 简要说明 |
|
||||||
|---------|------|------|------|---------|
|
|---------|------|------|------|---------|
|
||||||
| CHICAGO_MCP | 16 | N/A | 内部基础设施 | Anthropic 内部 MCP 基础设施,非外部可用 |
|
| CHICAGO_MCP | 16 | N/A | 内部基础设施 | Anthropic 内部 MCP 基础设施,非外部可用 |
|
||||||
| UDS_INBOX | 17 | Stub | 消息通信 | Unix 域套接字对等消息,进程间消息传递 |
|
|
||||||
| MONITOR_TOOL | 13 | Stub | 工具 | 文件/进程监控工具,检测变更并通知 |
|
| MONITOR_TOOL | 13 | Stub | 工具 | 文件/进程监控工具,检测变更并通知 |
|
||||||
| BG_SESSIONS | 11 | Stub | 会话管理 | 后台会话管理,支持多会话并行 |
|
| BG_SESSIONS | 11 | Stub | 会话管理 | 后台会话管理,支持多会话并行 |
|
||||||
| SHOT_STATS | 10 | 无实现 | 统计 | 逐 prompt 统计信息收集 |
|
| SHOT_STATS | 10 | 无实现 | 统计 | 逐 prompt 统计信息收集 |
|
||||||
@@ -68,7 +67,7 @@ BUILDING_CLAUDE_APPS, ANTI_DISTILLATION_CC, AGENT_TRIGGERS, ABLATION_BASELINE
|
|||||||
这些 feature 被列为 Tier 3 的原因:
|
这些 feature 被列为 Tier 3 的原因:
|
||||||
|
|
||||||
1. **内部基础设施**(CHICAGO_MCP, LODESTONE):Anthropic 内部使用,外部无法运行
|
1. **内部基础设施**(CHICAGO_MCP, LODESTONE):Anthropic 内部使用,外部无法运行
|
||||||
2. **纯 Stub 且引用低**(UDS_INBOX, MONITOR_TOOL, BG_SESSIONS):需要大量工作才能实现
|
2. **纯 Stub 且引用低**(MONITOR_TOOL, BG_SESSIONS):需要大量工作才能实现
|
||||||
3. **实验性功能**(SHOT_STATS, EXTRACT_MEMORIES):尚在概念阶段
|
3. **实验性功能**(SHOT_STATS, EXTRACT_MEMORIES):尚在概念阶段
|
||||||
4. **辅助功能**(STREAMLINED_OUTPUT, HOOK_PROMPTS):影响范围小
|
4. **辅助功能**(STREAMLINED_OUTPUT, HOOK_PROMPTS):影响范围小
|
||||||
5. **CCR 系列**:依赖远程控制基础设施,需要 BRIDGE_MODE 先完善
|
5. **CCR 系列**:依赖远程控制基础设施,需要 BRIDGE_MODE 先完善
|
||||||
|
|||||||
114
docs/features/uds-inbox.md
Normal file
114
docs/features/uds-inbox.md
Normal 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` 为事实来源
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ClickEvent } from './click-event.js'
|
import type { ClickEvent } from './click-event.js'
|
||||||
import type { FocusEvent } from './focus-event.js'
|
import type { FocusEvent } from './focus-event.js'
|
||||||
import type { KeyboardEvent } from './keyboard-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 { PasteEvent } from './paste-event.js'
|
||||||
import type { ResizeEvent } from './resize-event.js'
|
import type { ResizeEvent } from './resize-event.js'
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ type FocusEventHandler = (event: FocusEvent) => void
|
|||||||
type PasteEventHandler = (event: PasteEvent) => void
|
type PasteEventHandler = (event: PasteEvent) => void
|
||||||
type ResizeEventHandler = (event: ResizeEvent) => void
|
type ResizeEventHandler = (event: ResizeEvent) => void
|
||||||
type ClickEventHandler = (event: ClickEvent) => void
|
type ClickEventHandler = (event: ClickEvent) => void
|
||||||
|
type MouseActionEventHandler = (event: MouseActionEvent) => void
|
||||||
type HoverEventHandler = () => void
|
type HoverEventHandler = () => void
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +35,9 @@ export type EventHandlerProps = {
|
|||||||
onResize?: ResizeEventHandler
|
onResize?: ResizeEventHandler
|
||||||
|
|
||||||
onClick?: ClickEventHandler
|
onClick?: ClickEventHandler
|
||||||
|
onMouseDown?: MouseActionEventHandler
|
||||||
|
onMouseUp?: MouseActionEventHandler
|
||||||
|
onMouseDrag?: MouseActionEventHandler
|
||||||
onMouseEnter?: HoverEventHandler
|
onMouseEnter?: HoverEventHandler
|
||||||
onMouseLeave?: HoverEventHandler
|
onMouseLeave?: HoverEventHandler
|
||||||
}
|
}
|
||||||
@@ -51,6 +56,9 @@ export const HANDLER_FOR_EVENT: Record<
|
|||||||
paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
|
paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
|
||||||
resize: { bubble: 'onResize' },
|
resize: { bubble: 'onResize' },
|
||||||
click: { bubble: 'onClick' },
|
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',
|
'onPasteCapture',
|
||||||
'onResize',
|
'onResize',
|
||||||
'onClick',
|
'onClick',
|
||||||
|
'onMouseDown',
|
||||||
|
'onMouseUp',
|
||||||
|
'onMouseDrag',
|
||||||
'onMouseEnter',
|
'onMouseEnter',
|
||||||
'onMouseLeave',
|
'onMouseLeave',
|
||||||
])
|
])
|
||||||
|
|||||||
44
packages/@ant/ink/src/core/events/mouse-action-event.ts
Normal file
44
packages/@ant/ink/src/core/events/mouse-action-event.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { DOMElement } from './dom.js'
|
import type { DOMElement } from './dom.js'
|
||||||
import { ClickEvent } from './events/click-event.js'
|
import { ClickEvent } from './events/click-event.js'
|
||||||
import type { EventHandlerProps } from './events/event-handlers.js'
|
import type { EventHandlerProps } from './events/event-handlers.js'
|
||||||
|
import { MouseActionEvent } from './events/mouse-action-event.js'
|
||||||
import { nodeCache } from './node-cache.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
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,28 @@ const DEFAULT_FEATURES = [
|
|||||||
"KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN",
|
"KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN",
|
||||||
// P2: daemon + remote control server
|
// P2: daemon + remote control server
|
||||||
"DAEMON",
|
"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)
|
// P3: poor mode (disable extract_memories + prompt_suggestion)
|
||||||
"POOR",
|
"POOR",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
ToolResultBlockParam,
|
ToolResultBlockParam,
|
||||||
ToolUseBlockParam,
|
ToolUseBlockParam,
|
||||||
} from '@anthropic-ai/sdk/resources/index.mjs'
|
} from '@anthropic-ai/sdk/resources/index.mjs'
|
||||||
|
export type { ToolResultBlockParam }
|
||||||
import type {
|
import type {
|
||||||
ElicitRequestURLParams,
|
ElicitRequestURLParams,
|
||||||
ElicitResult,
|
ElicitResult,
|
||||||
|
|||||||
@@ -1,3 +1,25 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
import { feature } from 'bun:bundle'
|
||||||
export {};
|
import { getKairosActive } from '../bootstrap/state.js'
|
||||||
export const isKairosEnabled: () => Promise<boolean> = () => Promise.resolve(false);
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
// Auto-generated stub — replace with real implementation
|
||||||
export {};
|
export {}
|
||||||
export const isAssistantMode: () => boolean = () => false;
|
export const isAssistantMode: () => boolean = () => false
|
||||||
export const initializeAssistantTeam: () => Promise<void> = async () => {};
|
export const initializeAssistantTeam: () => Promise<void> = async () => {}
|
||||||
export const markAssistantForced: () => void = () => {};
|
export const markAssistantForced: () => void = () => {}
|
||||||
export const isAssistantForced: () => boolean = () => false;
|
export const isAssistantForced: () => boolean = () => false
|
||||||
export const getAssistantSystemPromptAddendum: () => string = () => '';
|
export const getAssistantSystemPromptAddendum: () => string = () => ''
|
||||||
export const getAssistantActivationPath: () => string | undefined = () => undefined;
|
export const getAssistantActivationPath: () => string | undefined = () =>
|
||||||
|
undefined
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ const remoteControlServerCommand =
|
|||||||
const voiceCommand = feature('VOICE_MODE')
|
const voiceCommand = feature('VOICE_MODE')
|
||||||
? require('./commands/voice/index.js').default
|
? require('./commands/voice/index.js').default
|
||||||
: null
|
: 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')
|
const forceSnip = feature('HISTORY_SNIP')
|
||||||
? require('./commands/force-snip.js').default
|
? require('./commands/force-snip.js').default
|
||||||
: null
|
: null
|
||||||
@@ -110,6 +116,27 @@ const peersCmd = feature('UDS_INBOX')
|
|||||||
require('./commands/peers/index.js') as typeof import('./commands/peers/index.js')
|
require('./commands/peers/index.js') as typeof import('./commands/peers/index.js')
|
||||||
).default
|
).default
|
||||||
: null
|
: 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')
|
const forkCmd = feature('FORK_SUBAGENT')
|
||||||
? (
|
? (
|
||||||
require('./commands/fork/index.js') as typeof import('./commands/fork/index.js')
|
require('./commands/fork/index.js') as typeof import('./commands/fork/index.js')
|
||||||
@@ -328,6 +355,8 @@ const COMMANDS = memoize((): Command[] => [
|
|||||||
...(buddy ? [buddy] : []),
|
...(buddy ? [buddy] : []),
|
||||||
...(poor ? [poor] : []),
|
...(poor ? [poor] : []),
|
||||||
...(proactive ? [proactive] : []),
|
...(proactive ? [proactive] : []),
|
||||||
|
...(monitorCmd ? [monitorCmd] : []),
|
||||||
|
...(coordinatorCmd ? [coordinatorCmd] : []),
|
||||||
...(briefCommand ? [briefCommand] : []),
|
...(briefCommand ? [briefCommand] : []),
|
||||||
...(assistantCommand ? [assistantCommand] : []),
|
...(assistantCommand ? [assistantCommand] : []),
|
||||||
...(bridge ? [bridge] : []),
|
...(bridge ? [bridge] : []),
|
||||||
@@ -344,6 +373,13 @@ const COMMANDS = memoize((): Command[] => [
|
|||||||
...(!isUsing3PServices() ? [logout, login()] : []),
|
...(!isUsing3PServices() ? [logout, login()] : []),
|
||||||
passes,
|
passes,
|
||||||
...(peersCmd ? [peersCmd] : []),
|
...(peersCmd ? [peersCmd] : []),
|
||||||
|
...(attachCmd ? [attachCmd] : []),
|
||||||
|
...(detachCmd ? [detachCmd] : []),
|
||||||
|
...(sendCmd ? [sendCmd] : []),
|
||||||
|
...(pipesCmd ? [pipesCmd] : []),
|
||||||
|
...(pipeStatusCmd ? [pipeStatusCmd] : []),
|
||||||
|
...(historyCmd ? [historyCmd] : []),
|
||||||
|
...(claimMainCmd ? [claimMainCmd] : []),
|
||||||
tasks,
|
tasks,
|
||||||
...(workflowsCmd ? [workflowsCmd] : []),
|
...(workflowsCmd ? [workflowsCmd] : []),
|
||||||
...(ultraplan ? [ultraplan] : []),
|
...(ultraplan ? [ultraplan] : []),
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
import * as React from 'react'
|
||||||
import type React from 'react';
|
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||||
|
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||||
|
import type { AppState } from '../../state/AppState.js'
|
||||||
|
|
||||||
export {};
|
/** Stub — install wizard is not yet restored. */
|
||||||
export const NewInstallWizard: React.FC<{
|
export async function computeDefaultInstallDir(): Promise<string> {
|
||||||
defaultDir: string;
|
return ''
|
||||||
onInstalled: (dir: string) => void;
|
}
|
||||||
onCancel: () => void;
|
|
||||||
onError: (message: string) => void;
|
/** Stub — install wizard is not yet restored. */
|
||||||
}> = (() => null);
|
export function NewInstallWizard(_props: {
|
||||||
export const computeDefaultInstallDir: () => Promise<string> = (() => Promise.resolve(''));
|
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
|
||||||
|
}
|
||||||
|
|||||||
25
src/commands/assistant/gate.ts
Normal file
25
src/commands/assistant/gate.ts
Normal 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()
|
||||||
|
}
|
||||||
16
src/commands/assistant/index.ts
Normal file
16
src/commands/assistant/index.ts
Normal 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
|
||||||
137
src/commands/attach/attach.ts
Normal file
137
src/commands/attach/attach.ts
Normal 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 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
11
src/commands/attach/index.ts
Normal file
11
src/commands/attach/index.ts
Normal 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
|
||||||
76
src/commands/claim-main/claim-main.ts
Normal file
76
src/commands/claim-main/claim-main.ts
Normal 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') }
|
||||||
|
}
|
||||||
12
src/commands/claim-main/index.ts
Normal file
12
src/commands/claim-main/index.ts
Normal 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
|
||||||
63
src/commands/coordinator.ts
Normal file
63
src/commands/coordinator.ts
Normal 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
|
||||||
95
src/commands/detach/detach.ts
Normal file
95
src/commands/detach/detach.ts
Normal 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.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/commands/detach/index.ts
Normal file
11
src/commands/detach/index.ts
Normal 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
|
||||||
59
src/commands/force-snip.ts
Normal file
59
src/commands/force-snip.ts
Normal 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
|
||||||
93
src/commands/history/history.ts
Normal file
93
src/commands/history/history.ts
Normal 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}]`
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/commands/history/index.ts
Normal file
12
src/commands/history/index.ts
Normal 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
108
src/commands/monitor.ts
Normal 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
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
import type { Command } from '../../commands.js'
|
||||||
const _default: Record<string, unknown> = {};
|
|
||||||
export default _default;
|
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
|
||||||
|
|||||||
61
src/commands/peers/peers.ts
Normal file
61
src/commands/peers/peers.ts
Normal 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`
|
||||||
|
}
|
||||||
11
src/commands/pipe-status/index.ts
Normal file
11
src/commands/pipe-status/index.ts
Normal 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
|
||||||
65
src/commands/pipe-status/pipe-status.ts
Normal file
65
src/commands/pipe-status/pipe-status.ts
Normal 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') }
|
||||||
|
}
|
||||||
11
src/commands/pipes/index.ts
Normal file
11
src/commands/pipes/index.ts
Normal 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
231
src/commands/pipes/pipes.ts
Normal 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
56
src/commands/proactive.ts
Normal 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
|
||||||
11
src/commands/send/index.ts
Normal file
11
src/commands/send/index.ts
Normal 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
97
src/commands/send/send.ts
Normal 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)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/commands/subscribe-pr.ts
Normal file
174
src/commands/subscribe-pr.ts
Normal 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
1
src/commands/torch.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default null
|
||||||
@@ -1,3 +1,25 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
import type { Command, LocalCommandCall } from '../../types/command.js'
|
||||||
const _default: Record<string, unknown> = {};
|
import { getWorkflowCommands } from '../../tools/WorkflowTool/createWorkflowCommand.js'
|
||||||
export default _default;
|
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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import figures from 'figures'
|
import figures from 'figures';
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -29,53 +29,53 @@ import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggest
|
|||||||
import type { StickyPrompt } from './VirtualMessageList.js'
|
import type { StickyPrompt } from './VirtualMessageList.js'
|
||||||
|
|
||||||
/** Rows of transcript context kept visible above the modal pane's ▔ divider. */
|
/** 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
|
/** Context for scroll-derived chrome (sticky header, pill). StickyTracker
|
||||||
* in VirtualMessageList writes via this instead of threading a callback
|
* in VirtualMessageList writes via this instead of threading a callback
|
||||||
* up through Messages → REPL → FullscreenLayout. The setter is stable so
|
* up through Messages → REPL → FullscreenLayout. The setter is stable so
|
||||||
* consuming this context never causes re-renders. */
|
* consuming this context never causes re-renders. */
|
||||||
export const ScrollChromeContext = createContext<{
|
export const ScrollChromeContext = createContext<{
|
||||||
setStickyPrompt: (p: StickyPrompt | null) => void
|
setStickyPrompt: (p: StickyPrompt | null) => void;
|
||||||
}>({ setStickyPrompt: () => {} })
|
}>({ setStickyPrompt: () => {} });
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** Content that scrolls (messages, tool output) */
|
/** Content that scrolls (messages, tool output) */
|
||||||
scrollable: ReactNode
|
scrollable: ReactNode;
|
||||||
/** Content pinned to the bottom (spinner, prompt, permissions) */
|
/** Content pinned to the bottom (spinner, prompt, permissions) */
|
||||||
bottom: ReactNode
|
bottom: ReactNode;
|
||||||
/** Content rendered inside the ScrollBox after messages — user can scroll
|
/** Content rendered inside the ScrollBox after messages — user can scroll
|
||||||
* up to see context while it's showing (used by PermissionRequest). */
|
* 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
|
/** Absolute-positioned content anchored at the bottom-right of the
|
||||||
* ScrollBox area, floating over scrollback. Rendered inside the flexGrow
|
* ScrollBox area, floating over scrollback. Rendered inside the flexGrow
|
||||||
* region (not the bottom slot) so the overflowY:hidden cap doesn't clip
|
* region (not the bottom slot) so the overflowY:hidden cap doesn't clip
|
||||||
* it. Fullscreen only — used for the companion speech bubble. */
|
* it. Fullscreen only — used for the companion speech bubble. */
|
||||||
bottomFloat?: ReactNode
|
bottomFloat?: ReactNode;
|
||||||
/** Slash-command dialog content. Rendered in an absolute-positioned
|
/** Slash-command dialog content. Rendered in an absolute-positioned
|
||||||
* bottom-anchored pane (▔ divider, paddingX=2) that paints over the
|
* bottom-anchored pane (▔ divider, paddingX=2) that paints over the
|
||||||
* ScrollBox AND bottom slot. Provides ModalContext so Pane/Dialog inside
|
* ScrollBox AND bottom slot. Provides ModalContext so Pane/Dialog inside
|
||||||
* skip their own frame. Fullscreen only; inline after overlay otherwise. */
|
* 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)
|
/** Ref passed via ModalContext so Tabs (or any scroll-owning descendant)
|
||||||
* can attach it to their own ScrollBox for tall content. */
|
* 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
|
/** Ref to the scroll box for keyboard scrolling. RefObject (not Ref) so
|
||||||
* pillVisible's useSyncExternalStore can subscribe to scroll changes. */
|
* 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
|
/** Y-position (scrollHeight at snapshot) of the unseen-divider. Pill
|
||||||
* shows while viewport bottom hasn't reached this. Ref so REPL doesn't
|
* shows while viewport bottom hasn't reached this. Ref so REPL doesn't
|
||||||
* re-render on the one-shot snapshot write. */
|
* 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). */
|
/** 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). */
|
/** 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". */
|
/** 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. */
|
/** Called when the user clicks the "N new" pill. */
|
||||||
onPillClick?: () => void
|
onPillClick?: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks the in-transcript "N new messages" divider position while the
|
* 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
|
/** Index into messages[] where the divider line renders. Cleared on
|
||||||
* sticky-resume (scroll back to bottom) so the "N new" line doesn't
|
* sticky-resume (scroll back to bottom) so the "N new" line doesn't
|
||||||
* linger once everything is visible. */
|
* linger once everything is visible. */
|
||||||
dividerIndex: number | null
|
dividerIndex: number | null;
|
||||||
/** scrollHeight snapshot at first scroll-away — the divider's y-position.
|
/** scrollHeight snapshot at first scroll-away — the divider's y-position.
|
||||||
* FullscreenLayout subscribes to ScrollBox and compares viewport bottom
|
* FullscreenLayout subscribes to ScrollBox and compares viewport bottom
|
||||||
* against this for pillVisible. Ref so writes don't re-render REPL. */
|
* against this for pillVisible. Ref so writes don't re-render REPL. */
|
||||||
dividerYRef: RefObject<number | null>
|
dividerYRef: RefObject<number | null>;
|
||||||
onScrollAway: (handle: ScrollBoxHandle) => void
|
onScrollAway: (handle: ScrollBoxHandle) => void;
|
||||||
onRepin: () => void
|
onRepin: () => void;
|
||||||
/** Scroll the handle so the divider line is at the top of the viewport. */
|
/** 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
|
/** Shift dividerIndex and dividerYRef when messages are prepended
|
||||||
* (infinite scroll-back). indexDelta = number of messages prepended;
|
* (infinite scroll-back). indexDelta = number of messages prepended;
|
||||||
* heightDelta = content height growth in rows. */
|
* 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
|
// Ref holds the current count for onScrollAway to snapshot. Written in
|
||||||
// the render body (not useEffect) so wheel events arriving between a
|
// the render body (not useEffect) so wheel events arriving between a
|
||||||
// message-append render and its effect flush don't capture a stale
|
// message-append render and its effect flush don't capture a stale
|
||||||
// count (off-by-one in the baseline). React Compiler bails out here —
|
// count (off-by-one in the baseline). React Compiler bails out here —
|
||||||
// acceptable for a hook instantiated once in REPL.
|
// acceptable for a hook instantiated once in REPL.
|
||||||
const countRef = useRef(messageCount)
|
const countRef = useRef(messageCount);
|
||||||
countRef.current = messageCount
|
countRef.current = messageCount;
|
||||||
// scrollHeight snapshot — the divider's y in content coords. Ref-only:
|
// scrollHeight snapshot — the divider's y in content coords. Ref-only:
|
||||||
// read synchronously in onScrollAway (setState is batched, can't
|
// read synchronously in onScrollAway (setState is batched, can't
|
||||||
// read-then-write in the same callback) AND by FullscreenLayout's
|
// read-then-write in the same callback) AND by FullscreenLayout's
|
||||||
// pillVisible subscription. null = pinned to bottom.
|
// pillVisible subscription. null = pinned to bottom.
|
||||||
const dividerYRef = useRef<number | null>(null)
|
const dividerYRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const onRepin = useCallback(() => {
|
const onRepin = useCallback(() => {
|
||||||
// Don't clear dividerYRef here — a trackpad momentum wheel event
|
// 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
|
// overriding the setDividerIndex(null) below. The useEffect below
|
||||||
// clears the ref after React commits the null dividerIndex, so the
|
// clears the ref after React commits the null dividerIndex, so the
|
||||||
// ref stays non-null until the state settles.
|
// ref stays non-null until the state settles.
|
||||||
setDividerIndex(null)
|
setDividerIndex(null);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const onScrollAway = useCallback((handle: ScrollBoxHandle) => {
|
const onScrollAway = useCallback((handle: ScrollBoxHandle) => {
|
||||||
// Nothing below the viewport → nothing to jump to. Covers both:
|
// 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)
|
// at max (Sarah Deaton, #claude-code-feedback 2026-03-15)
|
||||||
// pendingDelta: scrollBy accumulates without updating scrollTop. Without
|
// pendingDelta: scrollBy accumulates without updating scrollTop. Without
|
||||||
// it, wheeling up from max would see scrollTop==max and suppress the pill.
|
// it, wheeling up from max would see scrollTop==max and suppress the pill.
|
||||||
const max = Math.max(
|
const max = Math.max(0, handle.getScrollHeight() - handle.getViewportHeight());
|
||||||
0,
|
if (handle.getScrollTop() + handle.getPendingDelta() >= max) return;
|
||||||
handle.getScrollHeight() - handle.getViewportHeight(),
|
|
||||||
)
|
|
||||||
if (handle.getScrollTop() + handle.getPendingDelta() >= max) return
|
|
||||||
// Snapshot only on the FIRST scroll-away. onScrollAway fires on EVERY
|
// Snapshot only on the FIRST scroll-away. onScrollAway fires on EVERY
|
||||||
// scroll action (not just the initial break from sticky) — this guard
|
// scroll action (not just the initial break from sticky) — this guard
|
||||||
// preserves the original baseline so the count doesn't reset on the
|
// 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).
|
// second PageUp. Subsequent calls are ref-only no-ops (no REPL re-render).
|
||||||
if (dividerYRef.current === null) {
|
if (dividerYRef.current === null) {
|
||||||
dividerYRef.current = handle.getScrollHeight()
|
dividerYRef.current = handle.getScrollHeight();
|
||||||
// New scroll-away session → move the divider here (replaces old one)
|
// New scroll-away session → move the divider here (replaces old one)
|
||||||
setDividerIndex(countRef.current)
|
setDividerIndex(countRef.current);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const jumpToNew = useCallback((handle: ScrollBoxHandle | null) => {
|
const jumpToNew = useCallback((handle: ScrollBoxHandle | null) => {
|
||||||
if (!handle) return
|
if (!handle) return;
|
||||||
// scrollToBottom (not scrollTo(dividerY)): sets stickyScroll=true so
|
// scrollToBottom (not scrollTo(dividerY)): sets stickyScroll=true so
|
||||||
// useVirtualScroll mounts the tail and render-node-to-output pins
|
// useVirtualScroll mounts the tail and render-node-to-output pins
|
||||||
// scrollTop=maxScroll. scrollTo sets stickyScroll=false → the clamp
|
// 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
|
// back, stopping short. The divider stays rendered (dividerIndex
|
||||||
// unchanged) so users see where new messages started; the clear on
|
// unchanged) so users see where new messages started; the clear on
|
||||||
// next submit/explicit scroll-to-bottom handles cleanup.
|
// next submit/explicit scroll-to-bottom handles cleanup.
|
||||||
handle.scrollToBottom()
|
handle.scrollToBottom();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Sync dividerYRef with dividerIndex. When onRepin fires (submit,
|
// Sync dividerYRef with dividerIndex. When onRepin fires (submit,
|
||||||
// scroll-to-bottom), it sets dividerIndex=null but leaves the ref
|
// 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.
|
// below the divider index, the divider would point at nothing.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dividerIndex === null) {
|
if (dividerIndex === null) {
|
||||||
dividerYRef.current = null
|
dividerYRef.current = null;
|
||||||
} else if (messageCount < dividerIndex) {
|
} else if (messageCount < dividerIndex) {
|
||||||
dividerYRef.current = null
|
dividerYRef.current = null;
|
||||||
setDividerIndex(null)
|
setDividerIndex(null);
|
||||||
}
|
}
|
||||||
}, [messageCount, dividerIndex])
|
}, [messageCount, dividerIndex]);
|
||||||
|
|
||||||
const shiftDivider = useCallback(
|
const shiftDivider = useCallback((indexDelta: number, heightDelta: number) => {
|
||||||
(indexDelta: number, heightDelta: number) => {
|
setDividerIndex(idx => (idx === null ? null : idx + indexDelta));
|
||||||
setDividerIndex(idx => (idx === null ? null : idx + indexDelta))
|
if (dividerYRef.current !== null) {
|
||||||
if (dividerYRef.current !== null) {
|
dividerYRef.current += heightDelta;
|
||||||
dividerYRef.current += heightDelta
|
}
|
||||||
}
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dividerIndex,
|
dividerIndex,
|
||||||
@@ -208,7 +202,7 @@ export function useUnseenDivider(messageCount: number): {
|
|||||||
onRepin,
|
onRepin,
|
||||||
jumpToNew,
|
jumpToNew,
|
||||||
shiftDivider,
|
shiftDivider,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,25 +213,22 @@ export function useUnseenDivider(messageCount: number): {
|
|||||||
* carry text — tool-use-only entries are skipped (like progress messages)
|
* carry text — tool-use-only entries are skipped (like progress messages)
|
||||||
* so "⏺ Searched for 13 patterns, read 6 files" doesn't tick the pill.
|
* so "⏺ Searched for 13 patterns, read 6 files" doesn't tick the pill.
|
||||||
*/
|
*/
|
||||||
export function countUnseenAssistantTurns(
|
export function countUnseenAssistantTurns(messages: readonly Message[], dividerIndex: number): number {
|
||||||
messages: readonly Message[],
|
let count = 0;
|
||||||
dividerIndex: number,
|
let prevWasAssistant = false;
|
||||||
): number {
|
|
||||||
let count = 0
|
|
||||||
let prevWasAssistant = false
|
|
||||||
for (let i = dividerIndex; i < messages.length; i++) {
|
for (let i = dividerIndex; i < messages.length; i++) {
|
||||||
const m = messages[i]!
|
const m = messages[i]!;
|
||||||
if (m.type === 'progress') continue
|
if (m.type === 'progress') continue;
|
||||||
// Tool-use-only assistant entries aren't "new messages" to the user —
|
// Tool-use-only assistant entries aren't "new messages" to the user —
|
||||||
// skip them the same way we skip progress. prevWasAssistant is NOT
|
// skip them the same way we skip progress. prevWasAssistant is NOT
|
||||||
// updated, so a text block immediately following still counts as the
|
// updated, so a text block immediately following still counts as the
|
||||||
// same turn (tool_use + text from one API response = 1).
|
// same turn (tool_use + text from one API response = 1).
|
||||||
if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue
|
if (m.type === 'assistant' && !assistantHasVisibleText(m)) continue;
|
||||||
const isAssistant = m.type === 'assistant'
|
const isAssistant = m.type === 'assistant';
|
||||||
if (isAssistant && !prevWasAssistant) count++
|
if (isAssistant && !prevWasAssistant) count++;
|
||||||
prevWasAssistant = isAssistant
|
prevWasAssistant = isAssistant;
|
||||||
}
|
}
|
||||||
return count
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assistantHasVisibleText(m: Message): boolean {
|
function assistantHasVisibleText(m: Message): boolean {
|
||||||
@@ -246,10 +237,10 @@ function assistantHasVisibleText(m: Message): boolean {
|
|||||||
for (const b of m.message!.content) {
|
for (const b of m.message!.content) {
|
||||||
if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true
|
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.
|
* Builds the unseenDivider object REPL passes to Messages + the pill.
|
||||||
@@ -265,23 +256,22 @@ export function computeUnseenDivider(
|
|||||||
messages: readonly Message[],
|
messages: readonly Message[],
|
||||||
dividerIndex: number | null,
|
dividerIndex: number | null,
|
||||||
): UnseenDivider | undefined {
|
): UnseenDivider | undefined {
|
||||||
if (dividerIndex === null) return undefined
|
if (dividerIndex === null) return undefined;
|
||||||
// Skip progress and null-rendering attachments when picking the divider
|
// Skip progress and null-rendering attachments when picking the divider
|
||||||
// anchor — Messages.tsx filters these out of renderableMessages before the
|
// anchor — Messages.tsx filters these out of renderableMessages before the
|
||||||
// dividerBeforeIndex search, so their UUID wouldn't be found (CC-724).
|
// dividerBeforeIndex search, so their UUID wouldn't be found (CC-724).
|
||||||
// Hook attachments use randomUUID() so nothing shares their 24-char prefix.
|
// Hook attachments use randomUUID() so nothing shares their 24-char prefix.
|
||||||
let anchorIdx = dividerIndex
|
let anchorIdx = dividerIndex;
|
||||||
while (
|
while (
|
||||||
anchorIdx < messages.length &&
|
anchorIdx < messages.length &&
|
||||||
(messages[anchorIdx]?.type === 'progress' ||
|
(messages[anchorIdx]?.type === 'progress' || isNullRenderingAttachment(messages[anchorIdx]!))
|
||||||
isNullRenderingAttachment(messages[anchorIdx]!))
|
|
||||||
) {
|
) {
|
||||||
anchorIdx++
|
anchorIdx++;
|
||||||
}
|
}
|
||||||
const uuid = messages[anchorIdx]?.uuid
|
const uuid = messages[anchorIdx]?.uuid;
|
||||||
if (!uuid) return undefined
|
if (!uuid) return undefined;
|
||||||
const count = countUnseenAssistantTurns(messages, dividerIndex)
|
const count = countUnseenAssistantTurns(messages, dividerIndex);
|
||||||
return { firstUnseenUuid: uuid, count: Math.max(1, count) }
|
return { firstUnseenUuid: uuid, count: Math.max(1, count) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -310,56 +300,53 @@ export function FullscreenLayout({
|
|||||||
newMessageCount = 0,
|
newMessageCount = 0,
|
||||||
onPillClick,
|
onPillClick,
|
||||||
}: Props): React.ReactNode {
|
}: Props): React.ReactNode {
|
||||||
const { rows: terminalRows, columns } = useTerminalSize()
|
const { rows: terminalRows, columns } = useTerminalSize();
|
||||||
// Scroll-derived chrome state lives HERE, not in REPL. StickyTracker
|
// Scroll-derived chrome state lives HERE, not in REPL. StickyTracker
|
||||||
// writes via ScrollChromeContext; pillVisible subscribes directly to
|
// writes via ScrollChromeContext; pillVisible subscribes directly to
|
||||||
// ScrollBox. Both change rarely (pill flips once per threshold crossing,
|
// ScrollBox. Both change rarely (pill flips once per threshold crossing,
|
||||||
// sticky changes ~5-20×/transcript) — re-rendering FullscreenLayout on
|
// sticky changes ~5-20×/transcript) — re-rendering FullscreenLayout on
|
||||||
// those is fine; re-rendering the 6966-line REPL + its 22+ useAppState
|
// those is fine; re-rendering the 6966-line REPL + its 22+ useAppState
|
||||||
// selectors per-scroll-frame was not.
|
// selectors per-scroll-frame was not.
|
||||||
const [stickyPrompt, setStickyPrompt] = useState<StickyPrompt | null>(null)
|
const [stickyPrompt, setStickyPrompt] = useState<StickyPrompt | null>(null);
|
||||||
const chromeCtx = useMemo(() => ({ setStickyPrompt }), [])
|
const chromeCtx = useMemo(() => ({ setStickyPrompt }), []);
|
||||||
// Boolean-quantized scroll subscription. Snapshot is "is viewport bottom
|
// Boolean-quantized scroll subscription. Snapshot is "is viewport bottom
|
||||||
// above the divider y?" — Object.is on a boolean → FullscreenLayout only
|
// above the divider y?" — Object.is on a boolean → FullscreenLayout only
|
||||||
// re-renders when the pill should actually flip, not per-frame.
|
// re-renders when the pill should actually flip, not per-frame.
|
||||||
const subscribe = useCallback(
|
const subscribe = useCallback(
|
||||||
(listener: () => void) =>
|
(listener: () => void) => scrollRef?.current?.subscribe(listener) ?? (() => {}),
|
||||||
scrollRef?.current?.subscribe(listener) ?? (() => {}),
|
|
||||||
[scrollRef],
|
[scrollRef],
|
||||||
)
|
);
|
||||||
const pillVisible = useSyncExternalStore(subscribe, () => {
|
const pillVisible = useSyncExternalStore(subscribe, () => {
|
||||||
const s = scrollRef?.current
|
const s = scrollRef?.current;
|
||||||
const dividerY = dividerYRef?.current
|
const dividerY = dividerYRef?.current;
|
||||||
if (!s || dividerY == null) return false
|
if (!s || dividerY == null) return false;
|
||||||
return (
|
return s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY;
|
||||||
s.getScrollTop() + s.getPendingDelta() + s.getViewportHeight() < dividerY
|
});
|
||||||
)
|
|
||||||
})
|
|
||||||
// Wire up hyperlink click handling — in fullscreen mode, mouse tracking
|
// Wire up hyperlink click handling — in fullscreen mode, mouse tracking
|
||||||
// intercepts clicks before the terminal can open OSC 8 links natively.
|
// intercepts clicks before the terminal can open OSC 8 links natively.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isFullscreenEnvEnabled()) return
|
if (!isFullscreenEnvEnabled()) return;
|
||||||
const ink = instances.get(process.stdout)
|
const ink = instances.get(process.stdout);
|
||||||
if (!ink) return
|
if (!ink) return;
|
||||||
ink.onHyperlinkClick = url => {
|
ink.onHyperlinkClick = url => {
|
||||||
// Most OSC 8 links emitted by Claude Code are file:// URLs from
|
// Most OSC 8 links emitted by Claude Code are file:// URLs from
|
||||||
// FilePathLink (FileEdit/FileWrite/FileRead tool output). openBrowser
|
// FilePathLink (FileEdit/FileWrite/FileRead tool output). openBrowser
|
||||||
// rejects non-http(s) protocols — route file: to openPath instead.
|
// rejects non-http(s) protocols — route file: to openPath instead.
|
||||||
if (url.startsWith('file:')) {
|
if (url.startsWith('file:')) {
|
||||||
try {
|
try {
|
||||||
void openPath(fileURLToPath(url))
|
void openPath(fileURLToPath(url));
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed file: URLs (e.g. file://host/path from plain-text
|
// Malformed file: URLs (e.g. file://host/path from plain-text
|
||||||
// detection) cause fileURLToPath to throw — ignore silently.
|
// detection) cause fileURLToPath to throw — ignore silently.
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
void openBrowser(url)
|
void openBrowser(url);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
return () => {
|
return () => {
|
||||||
ink.onHyperlinkClick = undefined
|
ink.onHyperlinkClick = undefined;
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
if (isFullscreenEnvEnabled()) {
|
if (isFullscreenEnvEnabled()) {
|
||||||
// Overlay renders BELOW messages inside the same ScrollBox — user can
|
// Overlay renders BELOW messages inside the same ScrollBox — user can
|
||||||
@@ -379,50 +366,41 @@ export function FullscreenLayout({
|
|||||||
// row 0. On next scroll the onChange fires with a fresh {text} and
|
// row 0. On next scroll the onChange fires with a fresh {text} and
|
||||||
// header comes back (viewportTop 0→1, a single 1-row shift —
|
// header comes back (viewportTop 0→1, a single 1-row shift —
|
||||||
// acceptable since user explicitly scrolled).
|
// acceptable since user explicitly scrolled).
|
||||||
const sticky = hideSticky ? null : stickyPrompt
|
const sticky = hideSticky ? null : stickyPrompt;
|
||||||
const headerPrompt =
|
const headerPrompt = sticky != null && sticky !== 'clicked' && overlay == null ? sticky : null;
|
||||||
sticky != null && sticky !== 'clicked' && overlay == null ? sticky : null
|
const padCollapsed = sticky != null && overlay == null;
|
||||||
const padCollapsed = sticky != null && overlay == null
|
|
||||||
return (
|
return (
|
||||||
<PromptOverlayProvider>
|
<PromptOverlayProvider>
|
||||||
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
<Box flexDirection="row" flexGrow={1} overflow="hidden" width="100%">
|
||||||
{headerPrompt && (
|
<Box flexDirection="column" flexGrow={1} width={columns} overflow="hidden">
|
||||||
<StickyPromptHeader
|
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
||||||
text={headerPrompt.text}
|
{headerPrompt && <StickyPromptHeader text={headerPrompt.text} onClick={headerPrompt.scrollTo} />}
|
||||||
onClick={headerPrompt.scrollTo}
|
<ScrollBox
|
||||||
/>
|
ref={scrollRef}
|
||||||
)}
|
flexGrow={1}
|
||||||
<ScrollBox
|
flexDirection="column"
|
||||||
ref={scrollRef}
|
paddingTop={padCollapsed ? 0 : 1}
|
||||||
flexGrow={1}
|
stickyScroll
|
||||||
flexDirection="column"
|
>
|
||||||
paddingTop={padCollapsed ? 0 : 1}
|
<ScrollChromeContext value={chromeCtx}>{scrollable}</ScrollChromeContext>
|
||||||
stickyScroll
|
{overlay}
|
||||||
>
|
</ScrollBox>
|
||||||
<ScrollChromeContext value={chromeCtx}>
|
{!hidePill && pillVisible && overlay == null && (
|
||||||
{scrollable}
|
<NewMessagesPill count={newMessageCount} onClick={onPillClick} />
|
||||||
</ScrollChromeContext>
|
)}
|
||||||
{overlay}
|
{bottomFloat != null && (
|
||||||
</ScrollBox>
|
<Box position="absolute" bottom={0} right={0} opaque>
|
||||||
{!hidePill && pillVisible && overlay == null && (
|
{bottomFloat}
|
||||||
<NewMessagesPill count={newMessageCount} onClick={onPillClick} />
|
</Box>
|
||||||
)}
|
)}
|
||||||
{bottomFloat != null && (
|
</Box>
|
||||||
<Box position="absolute" bottom={0} right={0} opaque>
|
<Box flexDirection="column" flexShrink={0} width="100%" maxHeight="50%">
|
||||||
{bottomFloat}
|
<SuggestionsOverlay />
|
||||||
|
<DialogOverlay />
|
||||||
|
<Box flexDirection="column" width="100%" flexGrow={1} overflowY="hidden">
|
||||||
|
{bottom}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box flexDirection="column" flexShrink={0} width="100%" maxHeight="50%">
|
|
||||||
<SuggestionsOverlay />
|
|
||||||
<DialogOverlay />
|
|
||||||
<Box
|
|
||||||
flexDirection="column"
|
|
||||||
width="100%"
|
|
||||||
flexGrow={1}
|
|
||||||
overflowY="hidden"
|
|
||||||
>
|
|
||||||
{bottom}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{modal != null && (
|
{modal != null && (
|
||||||
@@ -465,19 +443,14 @@ export function FullscreenLayout({
|
|||||||
<Box flexShrink={0}>
|
<Box flexShrink={0}>
|
||||||
<Text color="permission">{'▔'.repeat(columns)}</Text>
|
<Text color="permission">{'▔'.repeat(columns)}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box flexDirection="column" paddingX={2} flexShrink={0} overflow="hidden">
|
||||||
flexDirection="column"
|
|
||||||
paddingX={2}
|
|
||||||
flexShrink={0}
|
|
||||||
overflow="hidden"
|
|
||||||
>
|
|
||||||
{modal}
|
{modal}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</ModalContext>
|
</ModalContext>
|
||||||
)}
|
)}
|
||||||
</PromptOverlayProvider>
|
</PromptOverlayProvider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -487,7 +460,7 @@ export function FullscreenLayout({
|
|||||||
{overlay}
|
{overlay}
|
||||||
{modal}
|
{modal}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slack-style pill. Absolute overlay at bottom={0} of the scrollwrap — floats
|
// 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
|
// (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 —
|
// "Jump to bottom" when count is 0 (scrolled away but no new messages yet —
|
||||||
// the dead zone where users previously thought chat stalled).
|
// the dead zone where users previously thought chat stalled).
|
||||||
function NewMessagesPill({
|
function NewMessagesPill({ count, onClick }: { count: number; onClick?: () => void }): React.ReactNode {
|
||||||
count,
|
const [hover, setHover] = useState(false);
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
count: number
|
|
||||||
onClick?: () => void
|
|
||||||
}): React.ReactNode {
|
|
||||||
const [hover, setHover] = useState(false)
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box position="absolute" bottom={0} left={0} right={0} justifyContent="center">
|
||||||
position="absolute"
|
<Box onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
|
||||||
bottom={0}
|
<Text backgroundColor={hover ? 'userMessageBackgroundHover' : 'userMessageBackground'} dimColor>
|
||||||
left={0}
|
|
||||||
right={0}
|
|
||||||
justifyContent="center"
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
onClick={onClick}
|
|
||||||
onMouseEnter={() => setHover(true)}
|
|
||||||
onMouseLeave={() => setHover(false)}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
backgroundColor={
|
|
||||||
hover ? 'userMessageBackgroundHover' : 'userMessageBackground'
|
|
||||||
}
|
|
||||||
dimColor
|
|
||||||
>
|
|
||||||
{' '}
|
{' '}
|
||||||
{count > 0
|
{count > 0 ? `${count} new ${plural(count, 'message')}` : 'Jump to bottom'} {figures.arrowDown}{' '}
|
||||||
? `${count} new ${plural(count, 'message')}`
|
|
||||||
: 'Jump to bottom'}{' '}
|
|
||||||
{figures.arrowDown}{' '}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context breadcrumb: when scrolled up into history, pin the current
|
// 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
|
// even with scrollTop unchanged (the DECSTBM region top shifts with the
|
||||||
// ScrollBox, and the diff engine sees "everything moved"). Fixed height
|
// ScrollBox, and the diff engine sees "everything moved"). Fixed height
|
||||||
// keeps the ScrollBox anchored; only the header TEXT changes, not its box.
|
// keeps the ScrollBox anchored; only the header TEXT changes, not its box.
|
||||||
function StickyPromptHeader({
|
function StickyPromptHeader({ text, onClick }: { text: string; onClick: () => void }): React.ReactNode {
|
||||||
text,
|
const [hover, setHover] = useState(false);
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
text: string
|
|
||||||
onClick: () => void
|
|
||||||
}): React.ReactNode {
|
|
||||||
const [hover, setHover] = useState(false)
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
width="100%"
|
width="100%"
|
||||||
height={1}
|
height={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={
|
backgroundColor={hover ? 'userMessageBackgroundHover' : 'userMessageBackground'}
|
||||||
hover ? 'userMessageBackgroundHover' : 'userMessageBackground'
|
|
||||||
}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onMouseEnter={() => setHover(true)}
|
onMouseEnter={() => setHover(true)}
|
||||||
onMouseLeave={() => setHover(false)}
|
onMouseLeave={() => setHover(false)}
|
||||||
@@ -572,7 +513,7 @@ function StickyPromptHeader({
|
|||||||
{figures.pointer} {text}
|
{figures.pointer} {text}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slash-command suggestion overlay — see promptOverlayContext.tsx for why
|
// 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
|
// 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.
|
// items down into the prompt area when the list has fewer items than max.
|
||||||
function SuggestionsOverlay(): React.ReactNode {
|
function SuggestionsOverlay(): React.ReactNode {
|
||||||
const data = usePromptOverlay()
|
const data = usePromptOverlay();
|
||||||
if (!data || data.suggestions.length === 0) return null
|
if (!data || data.suggestions.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box position="absolute" bottom="100%" left={0} right={0} paddingX={2} paddingTop={1} flexDirection="column" opaque>
|
||||||
position="absolute"
|
|
||||||
bottom="100%"
|
|
||||||
left={0}
|
|
||||||
right={0}
|
|
||||||
paddingX={2}
|
|
||||||
paddingTop={1}
|
|
||||||
flexDirection="column"
|
|
||||||
opaque
|
|
||||||
>
|
|
||||||
<PromptInputFooterSuggestions
|
<PromptInputFooterSuggestions
|
||||||
suggestions={data.suggestions}
|
suggestions={data.suggestions}
|
||||||
selectedSuggestion={data.selectedSuggestion}
|
selectedSuggestion={data.selectedSuggestion}
|
||||||
@@ -604,18 +536,18 @@ function SuggestionsOverlay(): React.ReactNode {
|
|||||||
overlay
|
overlay
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dialog portaled from PromptInput (AutoModeOptInDialog) — same clip-escape
|
// Dialog portaled from PromptInput (AutoModeOptInDialog) — same clip-escape
|
||||||
// pattern as SuggestionsOverlay. Renders later in tree order so it paints
|
// pattern as SuggestionsOverlay. Renders later in tree order so it paints
|
||||||
// over suggestions if both are ever up (they shouldn't be).
|
// over suggestions if both are ever up (they shouldn't be).
|
||||||
function DialogOverlay(): React.ReactNode {
|
function DialogOverlay(): React.ReactNode {
|
||||||
const node = usePromptOverlayDialog()
|
const node = usePromptOverlayDialog();
|
||||||
if (!node) return null
|
if (!node) return null;
|
||||||
return (
|
return (
|
||||||
<Box position="absolute" bottom="100%" left={0} right={0} opaque>
|
<Box position="absolute" bottom="100%" left={0} right={0} opaque>
|
||||||
{node}
|
{node}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
import * as React from 'react'
|
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 { isBridgeEnabled } from '../../bridge/bridgeEnabled.js'
|
||||||
import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js'
|
import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js'
|
||||||
import { useSetPromptOverlay } from '../../context/promptOverlayContext.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 type { IDESelection } from '../../hooks/useIdeSelection.js'
|
||||||
import { useSettings } from '../../hooks/useSettings.js'
|
import { useSettings } from '../../hooks/useSettings.js'
|
||||||
import { useTerminalSize } from '../../hooks/useTerminalSize.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 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 { ToolPermissionContext } from '../../Tool.js'
|
||||||
import type { Message } from '../../types/message.js'
|
import type { Message } from '../../types/message.js'
|
||||||
import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js'
|
import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js'
|
||||||
import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'
|
import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'
|
||||||
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'
|
import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'
|
||||||
|
import { getPipeDisplayRole, isPipeControlled } from '../../utils/pipeTransport.js'
|
||||||
import { isUndercover } from '../../utils/undercover.js'
|
import { isUndercover } from '../../utils/undercover.js'
|
||||||
import {
|
import {
|
||||||
CoordinatorTaskPanel,
|
CoordinatorTaskPanel,
|
||||||
@@ -28,49 +30,48 @@ import {
|
|||||||
} from '../StatusLine.js'
|
} from '../StatusLine.js'
|
||||||
import { Notifications } from './Notifications.js'
|
import { Notifications } from './Notifications.js'
|
||||||
import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js'
|
import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js'
|
||||||
import {
|
|
||||||
PromptInputFooterSuggestions,
|
// Inline pipe status is shown only after /pipes sets pipeIpc.statusVisible.
|
||||||
type SuggestionItem,
|
import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js'
|
||||||
} from './PromptInputFooterSuggestions.js'
|
|
||||||
import { PromptInputHelpMenu } from './PromptInputHelpMenu.js'
|
import { PromptInputHelpMenu } from './PromptInputHelpMenu.js'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
apiKeyStatus: VerificationStatus
|
apiKeyStatus: VerificationStatus;
|
||||||
debug: boolean
|
debug: boolean;
|
||||||
exitMessage: {
|
exitMessage: {
|
||||||
show: boolean
|
show: boolean;
|
||||||
key?: string
|
key?: string;
|
||||||
}
|
};
|
||||||
vimMode: VimMode | undefined
|
vimMode: VimMode | undefined;
|
||||||
mode: PromptInputMode
|
mode: PromptInputMode;
|
||||||
autoUpdaterResult: AutoUpdaterResult | null
|
autoUpdaterResult: AutoUpdaterResult | null;
|
||||||
isAutoUpdating: boolean
|
isAutoUpdating: boolean;
|
||||||
verbose: boolean
|
verbose: boolean;
|
||||||
onAutoUpdaterResult: (result: AutoUpdaterResult) => void
|
onAutoUpdaterResult: (result: AutoUpdaterResult) => void;
|
||||||
onChangeIsUpdating: (isUpdating: boolean) => void
|
onChangeIsUpdating: (isUpdating: boolean) => void;
|
||||||
suggestions: SuggestionItem[]
|
suggestions: SuggestionItem[];
|
||||||
selectedSuggestion: number
|
selectedSuggestion: number;
|
||||||
maxColumnWidth?: number
|
maxColumnWidth?: number;
|
||||||
toolPermissionContext: ToolPermissionContext
|
toolPermissionContext: ToolPermissionContext;
|
||||||
helpOpen: boolean
|
helpOpen: boolean;
|
||||||
suppressHint: boolean
|
suppressHint: boolean;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
tasksSelected: boolean
|
tasksSelected: boolean;
|
||||||
teamsSelected: boolean
|
teamsSelected: boolean;
|
||||||
bridgeSelected: boolean
|
bridgeSelected: boolean;
|
||||||
tmuxSelected: boolean
|
tmuxSelected: boolean;
|
||||||
teammateFooterIndex?: number
|
teammateFooterIndex?: number;
|
||||||
ideSelection: IDESelection | undefined
|
ideSelection: IDESelection | undefined;
|
||||||
mcpClients?: MCPServerConnection[]
|
mcpClients?: MCPServerConnection[];
|
||||||
isPasting?: boolean
|
isPasting?: boolean;
|
||||||
isInputWrapped?: boolean
|
isInputWrapped?: boolean;
|
||||||
messages: Message[]
|
messages: Message[];
|
||||||
isSearching: boolean
|
isSearching: boolean;
|
||||||
historyQuery: string
|
historyQuery: string;
|
||||||
setHistoryQuery: (query: string) => void
|
setHistoryQuery: (query: string) => void;
|
||||||
historyFailedMatch: boolean
|
historyFailedMatch: boolean;
|
||||||
onOpenTasksDialog?: (taskId?: string) => void
|
onOpenTasksDialog?: (taskId?: string) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
function PromptInputFooter({
|
function PromptInputFooter({
|
||||||
apiKeyStatus,
|
apiKeyStatus,
|
||||||
@@ -106,43 +107,35 @@ function PromptInputFooter({
|
|||||||
historyFailedMatch,
|
historyFailedMatch,
|
||||||
onOpenTasksDialog,
|
onOpenTasksDialog,
|
||||||
}: Props): ReactNode {
|
}: Props): ReactNode {
|
||||||
const settings = useSettings()
|
const settings = useSettings();
|
||||||
const { columns, rows } = useTerminalSize()
|
const { columns, rows } = useTerminalSize();
|
||||||
const messagesRef = useRef(messages)
|
const messagesRef = useRef(messages);
|
||||||
messagesRef.current = messages
|
messagesRef.current = messages;
|
||||||
const lastAssistantMessageId = useMemo(
|
const lastAssistantMessageId = useMemo(() => getLastAssistantMessageId(messages), [messages]);
|
||||||
() => getLastAssistantMessageId(messages),
|
const isNarrow = columns < 80;
|
||||||
[messages],
|
|
||||||
)
|
|
||||||
const isNarrow = columns < 80
|
|
||||||
// In fullscreen the bottom slot is flexShrink:0, so every row here is a row
|
// 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
|
// stolen from the ScrollBox. Drop the optional StatusLine first. Non-fullscreen
|
||||||
// has terminal scrollback to absorb overflow, so we never hide StatusLine there.
|
// has terminal scrollback to absorb overflow, so we never hide StatusLine there.
|
||||||
const isFullscreen = isFullscreenEnvEnabled()
|
const isFullscreen = isFullscreenEnvEnabled();
|
||||||
const isShort = isFullscreen && rows < 24
|
const isShort = isFullscreen && rows < 24;
|
||||||
|
|
||||||
// Pill highlights when tasks is the active footer item AND no specific
|
// Pill highlights when tasks is the active footer item AND no specific
|
||||||
// agent row is selected. When coordinatorTaskIndex >= 0 the pointer has
|
// agent row is selected. When coordinatorTaskIndex >= 0 the pointer has
|
||||||
// moved into CoordinatorTaskPanel, so the pill should un-highlight.
|
// moved into CoordinatorTaskPanel, so the pill should un-highlight.
|
||||||
// coordinatorTaskCount === 0 covers the bash-only case (no agent rows
|
// coordinatorTaskCount === 0 covers the bash-only case (no agent rows
|
||||||
// exist, pill is the only selectable item).
|
// exist, pill is the only selectable item).
|
||||||
const coordinatorTaskCount = useCoordinatorTaskCount()
|
const coordinatorTaskCount = useCoordinatorTaskCount();
|
||||||
const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex)
|
const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex);
|
||||||
const pillSelected =
|
const pillSelected = tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0);
|
||||||
tasksSelected && (coordinatorTaskCount === 0 || coordinatorTaskIndex < 0)
|
|
||||||
|
|
||||||
// Hide `? for shortcuts` if the user has a custom status line, or during ctrl-r
|
// Hide `? for shortcuts` if the user has a custom status line, or during ctrl-r
|
||||||
const suppressHint =
|
const suppressHint = suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching;
|
||||||
suppressHintFromProps || statusLineShouldDisplay(settings) || isSearching
|
|
||||||
// Fullscreen: portal data to FullscreenLayout — see promptOverlayContext.tsx
|
// Fullscreen: portal data to FullscreenLayout — see promptOverlayContext.tsx
|
||||||
const overlayData = useMemo(
|
const overlayData = useMemo(
|
||||||
() =>
|
() => (isFullscreen && suggestions.length ? { suggestions, selectedSuggestion, maxColumnWidth } : null),
|
||||||
isFullscreen && suggestions.length
|
|
||||||
? { suggestions, selectedSuggestion, maxColumnWidth }
|
|
||||||
: null,
|
|
||||||
[isFullscreen, suggestions, selectedSuggestion, maxColumnWidth],
|
[isFullscreen, suggestions, selectedSuggestion, maxColumnWidth],
|
||||||
)
|
);
|
||||||
useSetPromptOverlay(overlayData)
|
useSetPromptOverlay(overlayData);
|
||||||
|
|
||||||
if (suggestions.length && !isFullscreen) {
|
if (suggestions.length && !isFullscreen) {
|
||||||
return (
|
return (
|
||||||
@@ -153,13 +146,11 @@ function PromptInputFooter({
|
|||||||
maxColumnWidth={maxColumnWidth}
|
maxColumnWidth={maxColumnWidth}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (helpOpen) {
|
if (helpOpen) {
|
||||||
return (
|
return <PromptInputHelpMenu dimColor={true} fixedWidth={true} paddingX={2} />;
|
||||||
<PromptInputHelpMenu dimColor={true} fixedWidth={true} paddingX={2} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -171,17 +162,10 @@ function PromptInputFooter({
|
|||||||
gap={isNarrow ? 0 : 1}
|
gap={isNarrow ? 0 : 1}
|
||||||
>
|
>
|
||||||
<Box flexDirection="column" flexShrink={isNarrow ? 0 : 1}>
|
<Box flexDirection="column" flexShrink={isNarrow ? 0 : 1}>
|
||||||
{mode === 'prompt' &&
|
{mode === 'prompt' && !isShort && !exitMessage.show && !isPasting && statusLineShouldDisplay(settings) && (
|
||||||
!isShort &&
|
<StatusLine messagesRef={messagesRef} lastAssistantMessageId={lastAssistantMessageId} vimMode={vimMode} />
|
||||||
!exitMessage.show &&
|
)}
|
||||||
!isPasting &&
|
<PipeStatusInline />
|
||||||
statusLineShouldDisplay(settings) && (
|
|
||||||
<StatusLine
|
|
||||||
messagesRef={messagesRef}
|
|
||||||
lastAssistantMessageId={lastAssistantMessageId}
|
|
||||||
vimMode={vimMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<PromptInputFooterLeftSide
|
<PromptInputFooterLeftSide
|
||||||
exitMessage={exitMessage}
|
exitMessage={exitMessage}
|
||||||
vimMode={vimMode}
|
vimMode={vimMode}
|
||||||
@@ -218,62 +202,215 @@ function PromptInputFooter({
|
|||||||
isNarrow={isNarrow}
|
isNarrow={isNarrow}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{process.env.USER_TYPE === 'ant' && isUndercover() && (
|
{process.env.USER_TYPE === 'ant' && isUndercover() && <Text dimColor>undercover</Text>}
|
||||||
<Text dimColor>undercover</Text>
|
|
||||||
)}
|
|
||||||
<BridgeStatusIndicator bridgeSelected={bridgeSelected} />
|
<BridgeStatusIndicator bridgeSelected={bridgeSelected} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{process.env.USER_TYPE === 'ant' && <CoordinatorTaskPanel />}
|
{process.env.USER_TYPE === 'ant' && <CoordinatorTaskPanel />}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(PromptInputFooter)
|
export default memo(PromptInputFooter);
|
||||||
|
|
||||||
type BridgeStatusProps = {
|
type BridgeStatusProps = {
|
||||||
bridgeSelected: boolean
|
bridgeSelected: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
function BridgeStatusIndicator({
|
function BridgeStatusIndicator({ bridgeSelected }: BridgeStatusProps): React.ReactNode {
|
||||||
bridgeSelected,
|
if (!feature('BRIDGE_MODE')) return null;
|
||||||
}: 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);
|
||||||
const enabled = useAppState(s => s.replBridgeEnabled)
|
const connected = useAppState(s => s.replBridgeConnected);
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
const sessionActive = useAppState(s => s.replBridgeSessionActive);
|
||||||
const connected = useAppState(s => s.replBridgeConnected)
|
const reconnecting = useAppState(s => s.replBridgeReconnecting);
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
const explicit = useAppState(s => s.replBridgeExplicit);
|
||||||
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)
|
|
||||||
|
|
||||||
// Failed state is surfaced via notification (useReplBridge), not a footer pill.
|
// Failed state is surfaced via notification (useReplBridge), not a footer pill.
|
||||||
if (!isBridgeEnabled() || !enabled) return null
|
if (!isBridgeEnabled() || !enabled) return null;
|
||||||
|
|
||||||
const status = getBridgeStatus({
|
const status = getBridgeStatus({
|
||||||
error: undefined,
|
error: undefined,
|
||||||
connected,
|
connected,
|
||||||
sessionActive,
|
sessionActive,
|
||||||
reconnecting,
|
reconnecting,
|
||||||
})
|
});
|
||||||
|
|
||||||
// For implicit (config-driven) remote, only show the reconnecting state
|
// For implicit (config-driven) remote, only show the reconnecting state
|
||||||
if (!explicit && status.label !== 'Remote Control reconnecting') {
|
if (!explicit && status.label !== 'Remote Control reconnecting') {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text color={bridgeSelected ? 'background' : status.color} inverse={bridgeSelected} wrap="truncate">
|
||||||
color={bridgeSelected ? 'background' : status.color}
|
|
||||||
inverse={bridgeSelected}
|
|
||||||
wrap="truncate"
|
|
||||||
>
|
|
||||||
{status.label}
|
{status.label}
|
||||||
{bridgeSelected && <Text dimColor> · Enter to view</Text>}
|
{bridgeSelected && <Text dimColor> · Enter to view</Text>}
|
||||||
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
|
||||||
export {};
|
|
||||||
export const MonitorPermissionRequest: (props: Record<string, unknown>) => null = () => null;
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
|
||||||
export {};
|
|
||||||
export const ReviewArtifactPermissionRequest: (props: Record<string, unknown>) => null = () => null;
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -528,7 +528,7 @@ export function BackgroundTasksDialog({
|
|||||||
return (
|
return (
|
||||||
<WorkflowDetailDialog
|
<WorkflowDetailDialog
|
||||||
workflow={task}
|
workflow={task}
|
||||||
onDone={onDone}
|
onDone={onDone as (message?: string, options?: { display?: string }) => void}
|
||||||
onKill={
|
onKill={
|
||||||
task.status === 'running' && killWorkflowTask
|
task.status === 'running' && killWorkflowTask
|
||||||
? () => killWorkflowTask(task.id, setAppState)
|
? () => killWorkflowTask(task.id, setAppState)
|
||||||
@@ -536,12 +536,12 @@ export function BackgroundTasksDialog({
|
|||||||
}
|
}
|
||||||
onSkipAgent={
|
onSkipAgent={
|
||||||
task.status === 'running' && skipWorkflowAgent
|
task.status === 'running' && skipWorkflowAgent
|
||||||
? (agentId: AgentId) => skipWorkflowAgent(task.id, agentId, setAppState)
|
? (agentId: string) => skipWorkflowAgent(task.id, agentId as AgentId, setAppState)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onRetryAgent={
|
onRetryAgent={
|
||||||
task.status === 'running' && retryWorkflowAgent
|
task.status === 'running' && retryWorkflowAgent
|
||||||
? (agentId: AgentId) => retryWorkflowAgent(task.id, agentId, setAppState)
|
? (agentId: string) => retryWorkflowAgent(task.id, agentId as AgentId, setAppState)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onBack={goBackToList}
|
onBack={goBackToList}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
|
||||||
export {};
|
|
||||||
export const MonitorMcpDetailDialog: (props: Record<string, unknown>) => null = () => null;
|
|
||||||
100
src/components/tasks/MonitorMcpDetailDialog.tsx
Normal file
100
src/components/tasks/MonitorMcpDetailDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
|
||||||
export {};
|
|
||||||
export const WorkflowDetailDialog: (props: Record<string, unknown>) => null = () => null;
|
|
||||||
115
src/components/tasks/WorkflowDetailDialog.tsx
Normal file
115
src/components/tasks/WorkflowDetailDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,67 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
/**
|
||||||
export {};
|
* Coordinator-mode worker agent definition.
|
||||||
import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js';
|
*
|
||||||
export const getCoordinatorAgents: () => AgentDefinition[] = () => [];
|
* 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]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
|
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||||
|
|
||||||
// Runtime fallback for MACRO.* when not injected by build/dev defines.
|
// 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/).
|
// 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
|
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
|
||||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
||||||
process.env.COREPACK_ENABLE_AUTO_PIN = '0'
|
process.env.COREPACK_ENABLE_AUTO_PIN = '0'
|
||||||
|
|||||||
116
src/hooks/__tests__/useMasterMonitor.test.ts
Normal file
116
src/hooks/__tests__/useMasterMonitor.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,6 +4,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { logForDebugging } from 'src/utils/debug.js'
|
import { logForDebugging } from 'src/utils/debug.js'
|
||||||
import { getAllowedChannels } from '../../../bootstrap/state.js'
|
import { getAllowedChannels } from '../../../bootstrap/state.js'
|
||||||
import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js'
|
import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js'
|
||||||
|
import type { ToolUseConfirm } from '../../../components/permissions/PermissionRequest.js'
|
||||||
import { getTerminalFocused } from '@anthropic/ink'
|
import { getTerminalFocused } from '@anthropic/ink'
|
||||||
import {
|
import {
|
||||||
CHANNEL_PERMISSION_REQUEST_METHOD,
|
CHANNEL_PERMISSION_REQUEST_METHOD,
|
||||||
@@ -25,6 +26,11 @@ import {
|
|||||||
setYoloClassifierApproval,
|
setYoloClassifierApproval,
|
||||||
} from '../../../utils/classifierApprovals.js'
|
} from '../../../utils/classifierApprovals.js'
|
||||||
import { errorMessage } from '../../../utils/errors.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 { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'
|
||||||
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
|
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
|
||||||
import { hasPermissionsToUseTool } from '../../../utils/permissions/permissions.js'
|
import { hasPermissionsToUseTool } from '../../../utils/permissions/permissions.js'
|
||||||
@@ -82,6 +88,18 @@ function handleInteractivePermission(
|
|||||||
|
|
||||||
const permissionPromptStartTimeMs = Date.now()
|
const permissionPromptStartTimeMs = Date.now()
|
||||||
const displayInput = result.updatedInput ?? ctx.input
|
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 {
|
function clearClassifierIndicator(): void {
|
||||||
if (feature('BASH_CLASSIFIER')) {
|
if (feature('BASH_CLASSIFIER')) {
|
||||||
@@ -89,7 +107,7 @@ function handleInteractivePermission(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.pushToQueue({
|
const toolUseConfirm: ToolUseConfirm = {
|
||||||
assistantMessage: ctx.assistantMessage,
|
assistantMessage: ctx.assistantMessage,
|
||||||
tool: ctx.tool,
|
tool: ctx.tool,
|
||||||
description,
|
description,
|
||||||
@@ -136,6 +154,7 @@ function handleInteractivePermission(
|
|||||||
},
|
},
|
||||||
onAbort() {
|
onAbort() {
|
||||||
if (!claim()) return
|
if (!claim()) return
|
||||||
|
forgetPipePermission('Permission request was aborted locally in sub.')
|
||||||
if (bridgeCallbacks && bridgeRequestId) {
|
if (bridgeCallbacks && bridgeRequestId) {
|
||||||
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
||||||
behavior: 'deny',
|
behavior: 'deny',
|
||||||
@@ -158,6 +177,7 @@ function handleInteractivePermission(
|
|||||||
contentBlocks?: ContentBlockParam[],
|
contentBlocks?: ContentBlockParam[],
|
||||||
) {
|
) {
|
||||||
if (!claim()) return // atomic check-and-mark before await
|
if (!claim()) return // atomic check-and-mark before await
|
||||||
|
forgetPipePermission('Permission request was approved locally in sub.')
|
||||||
|
|
||||||
if (bridgeCallbacks && bridgeRequestId) {
|
if (bridgeCallbacks && bridgeRequestId) {
|
||||||
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
||||||
@@ -182,6 +202,7 @@ function handleInteractivePermission(
|
|||||||
},
|
},
|
||||||
onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) {
|
onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) {
|
||||||
if (!claim()) return
|
if (!claim()) return
|
||||||
|
forgetPipePermission('Permission request was rejected locally in sub.')
|
||||||
|
|
||||||
if (bridgeCallbacks && bridgeRequestId) {
|
if (bridgeCallbacks && bridgeRequestId) {
|
||||||
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
bridgeCallbacks.sendResponse(bridgeRequestId, {
|
||||||
@@ -220,6 +241,7 @@ function handleInteractivePermission(
|
|||||||
// a CCR-initiated mode switch, the very case this callback exists
|
// a CCR-initiated mode switch, the very case this callback exists
|
||||||
// for after useReplBridge started calling it).
|
// for after useReplBridge started calling it).
|
||||||
if (!claim()) return
|
if (!claim()) return
|
||||||
|
forgetPipePermission('Permission request was resolved locally in sub.')
|
||||||
if (bridgeCallbacks && bridgeRequestId) {
|
if (bridgeCallbacks && bridgeRequestId) {
|
||||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||||
}
|
}
|
||||||
@@ -229,7 +251,65 @@ function handleInteractivePermission(
|
|||||||
resolveOnce(ctx.buildAllow(freshResult.updatedInput ?? ctx.input))
|
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)
|
// Race 4: Bridge permission response from CCR (claude.ai)
|
||||||
// When the bridge is connected, send the permission request to CCR and
|
// When the bridge is connected, send the permission request to CCR and
|
||||||
@@ -257,6 +337,9 @@ function handleInteractivePermission(
|
|||||||
bridgeRequestId,
|
bridgeRequestId,
|
||||||
response => {
|
response => {
|
||||||
if (!claim()) return // Local user/hook/classifier already responded
|
if (!claim()) return // Local user/hook/classifier already responded
|
||||||
|
forgetPipePermission(
|
||||||
|
'Permission request was resolved by bridge before pipe response.',
|
||||||
|
)
|
||||||
signal.removeEventListener('abort', unsubscribe)
|
signal.removeEventListener('abort', unsubscribe)
|
||||||
clearClassifierChecking(ctx.toolUseID)
|
clearClassifierChecking(ctx.toolUseID)
|
||||||
clearClassifierIndicator()
|
clearClassifierIndicator()
|
||||||
@@ -364,6 +447,9 @@ function handleInteractivePermission(
|
|||||||
channelRequestId,
|
channelRequestId,
|
||||||
response => {
|
response => {
|
||||||
if (!claim()) return // Another racer won
|
if (!claim()) return // Another racer won
|
||||||
|
forgetPipePermission(
|
||||||
|
'Permission request was resolved by channel before pipe response.',
|
||||||
|
)
|
||||||
channelUnsubscribe?.() // both: map delete + listener remove
|
channelUnsubscribe?.() // both: map delete + listener remove
|
||||||
clearClassifierChecking(ctx.toolUseID)
|
clearClassifierChecking(ctx.toolUseID)
|
||||||
clearClassifierIndicator()
|
clearClassifierIndicator()
|
||||||
@@ -421,6 +507,9 @@ function handleInteractivePermission(
|
|||||||
permissionPromptStartTimeMs,
|
permissionPromptStartTimeMs,
|
||||||
)
|
)
|
||||||
if (!hookDecision || !claim()) return
|
if (!hookDecision || !claim()) return
|
||||||
|
forgetPipePermission(
|
||||||
|
'Permission request was resolved by hook before pipe response.',
|
||||||
|
)
|
||||||
if (bridgeCallbacks && bridgeRequestId) {
|
if (bridgeCallbacks && bridgeRequestId) {
|
||||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||||
}
|
}
|
||||||
@@ -453,6 +542,9 @@ function handleInteractivePermission(
|
|||||||
},
|
},
|
||||||
onAllow: decisionReason => {
|
onAllow: decisionReason => {
|
||||||
if (!claim()) return
|
if (!claim()) return
|
||||||
|
forgetPipePermission(
|
||||||
|
'Permission request was auto-approved before pipe response.',
|
||||||
|
)
|
||||||
if (bridgeCallbacks && bridgeRequestId) {
|
if (bridgeCallbacks && bridgeRequestId) {
|
||||||
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
bridgeCallbacks.cancelRequest(bridgeRequestId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,15 +61,18 @@ function stepTeammateSelection(
|
|||||||
* Custom hook that handles Shift+Up/Down keyboard navigation for background tasks.
|
* Custom hook that handles Shift+Up/Down keyboard navigation for background tasks.
|
||||||
* When teammates (swarm) are present, navigates between leader and teammates.
|
* When teammates (swarm) are present, navigates between leader and teammates.
|
||||||
* When only non-teammate background tasks exist, opens the background tasks dialog.
|
* 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.
|
* Also handles Enter to confirm selection, 'f' to view transcript, and 'k' to kill.
|
||||||
*/
|
*/
|
||||||
export function useBackgroundTaskNavigation(options?: {
|
export function useBackgroundTaskNavigation(options?: {
|
||||||
onOpenBackgroundTasks?: () => void
|
onOpenBackgroundTasks?: () => void
|
||||||
|
onTogglePipeSelector?: () => void
|
||||||
}): { handleKeyDown: (e: KeyboardEvent) => void } {
|
}): { handleKeyDown: (e: KeyboardEvent) => void } {
|
||||||
const tasks = useAppState(s => s.tasks)
|
const tasks = useAppState(s => s.tasks)
|
||||||
const viewSelectionMode = useAppState(s => s.viewSelectionMode)
|
const viewSelectionMode = useAppState(s => s.viewSelectionMode)
|
||||||
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
|
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
|
||||||
const selectedIPAgentIndex = useAppState(s => s.selectedIPAgentIndex)
|
const selectedIPAgentIndex = useAppState(s => s.selectedIPAgentIndex)
|
||||||
|
const pipeIpc = useAppState(s => (s as any).pipeIpc)
|
||||||
const setAppState = useSetAppState()
|
const setAppState = useSetAppState()
|
||||||
|
|
||||||
// Filter to running teammates and sort alphabetically to match TeammateSpinnerTree display
|
// 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)
|
// Shift+Up/Down for teammate transcript switching (with wrapping)
|
||||||
// Index -1 represents the leader, 0+ are teammates
|
// Index -1 represents the leader, 0+ are teammates
|
||||||
// When showSpinnerTree is true, index === teammateCount is the "hide" row
|
// 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')) {
|
if (e.shift && (e.key === 'up' || e.key === 'down')) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (teammateCount > 0) {
|
if (teammateCount > 0) {
|
||||||
stepTeammateSelection(e.key === 'down' ? 1 : -1, setAppState)
|
stepTeammateSelection(e.key === 'down' ? 1 : -1, setAppState)
|
||||||
} else if (hasNonTeammateBackgroundTasks) {
|
} else if (hasNonTeammateBackgroundTasks) {
|
||||||
options?.onOpenBackgroundTasks?.()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
327
src/hooks/useMasterMonitor.ts
Normal file
327
src/hooks/useMasterMonitor.ts
Normal 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
623
src/hooks/usePipeIpc.ts
Normal 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
|
||||||
|
}
|
||||||
195
src/hooks/usePipePermissionForward.ts
Normal file
195
src/hooks/usePipePermissionForward.ts
Normal 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
39
src/hooks/usePipeRelay.ts
Normal 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
151
src/hooks/usePipeRouter.ts
Normal 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 }
|
||||||
|
}
|
||||||
122
src/hooks/useSlaveNotifications.ts
Normal file
122
src/hooks/useSlaveNotifications.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/main.tsx
17
src/main.tsx
@@ -1122,14 +1122,15 @@ export async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for -p/--print and --init-only flags early to set isInteractiveSession before init()
|
// Check for -p/--print and --init-only flags early to set isInteractiveSession before init()
|
||||||
// This is needed because telemetry initialization calls auth functions that need this flag
|
// This is needed because telemetry initialization calls auth functions that need this flag
|
||||||
const cliArgs = process.argv.slice(2);
|
const cliArgs = process.argv.slice(2);
|
||||||
const hasPrintFlag = cliArgs.includes("-p") || cliArgs.includes("--print");
|
const hasPrintFlag = cliArgs.includes("-p") || cliArgs.includes("--print");
|
||||||
const hasInitOnlyFlag = cliArgs.includes("--init-only");
|
const hasInitOnlyFlag = cliArgs.includes("--init-only");
|
||||||
const hasSdkUrl = cliArgs.some((arg) => arg.startsWith("--sdk-url"));
|
const hasSdkUrl = cliArgs.some((arg) => arg.startsWith("--sdk-url"));
|
||||||
const isNonInteractive =
|
const forceInteractive = isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE);
|
||||||
hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || !process.stdout.isTTY;
|
const isNonInteractive =
|
||||||
|
hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || (!forceInteractive && !process.stdout.isTTY);
|
||||||
|
|
||||||
// Stop capturing early input for non-interactive modes
|
// Stop capturing early input for non-interactive modes
|
||||||
if (isNonInteractive) {
|
if (isNonInteractive) {
|
||||||
|
|||||||
@@ -1,6 +1,135 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
/**
|
||||||
export {};
|
* Proactive mode — tick-driven autonomous agent.
|
||||||
export const isProactiveActive: () => boolean = () => false;
|
*
|
||||||
export const activateProactive: (source?: string) => void = () => {};
|
* State machine: inactive → active (→ paused → active) → inactive
|
||||||
export const isProactivePaused: () => boolean = () => false;
|
*
|
||||||
export const deactivateProactive: () => void = () => {};
|
* 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
|
||||||
|
}
|
||||||
|
|||||||
102
src/proactive/useProactive.ts
Normal file
102
src/proactive/useProactive.ts
Normal 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(),
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -258,6 +258,7 @@ import { useManagePlugins } from '../hooks/useManagePlugins.js';
|
|||||||
import { Messages } from '../components/Messages.js';
|
import { Messages } from '../components/Messages.js';
|
||||||
import { TaskListV2 } from '../components/TaskListV2.js';
|
import { TaskListV2 } from '../components/TaskListV2.js';
|
||||||
import { TeammateViewHeader } from '../components/TeammateViewHeader.js';
|
import { TeammateViewHeader } from '../components/TeammateViewHeader.js';
|
||||||
|
import { getPipeDisplayRole, getPipeIpc, isPipeControlled } from '../utils/pipeTransport.js';
|
||||||
import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js';
|
import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js';
|
||||||
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js';
|
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js';
|
||||||
import type { MCPServerConnection } from '../services/mcp/types.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 =
|
const useProactive =
|
||||||
feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
|
feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
|
||||||
const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : 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 */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js';
|
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js';
|
||||||
import { useTaskListWatcher } from '../hooks/useTaskListWatcher.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 disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []);
|
||||||
const disableMessageActions = feature('MESSAGE_ACTIONS')
|
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;
|
: false;
|
||||||
|
|
||||||
// Log REPL mount/unmount lifecycle
|
// Log REPL mount/unmount lifecycle
|
||||||
@@ -1478,7 +1494,6 @@ export function REPL({
|
|||||||
messages.length,
|
messages.length,
|
||||||
);
|
);
|
||||||
if (feature('AWAY_SUMMARY')) {
|
if (feature('AWAY_SUMMARY')) {
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
|
||||||
useAwaySummary(messages, setMessages, isLoading);
|
useAwaySummary(messages, setMessages, isLoading);
|
||||||
}
|
}
|
||||||
const [cursor, setCursor] = useState<MessageActionsState | null>(null);
|
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
|
// the branch is dead-code-eliminated in non-KAIROS builds (same pattern
|
||||||
// as useUnseenDivider above).
|
// as useUnseenDivider above).
|
||||||
const { maybeLoadOlder } = feature('KAIROS')
|
const { maybeLoadOlder } = feature('KAIROS')
|
||||||
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
? useAssistantHistory({
|
||||||
useAssistantHistory({
|
|
||||||
config: remoteSessionConfig,
|
config: remoteSessionConfig,
|
||||||
setMessages,
|
setMessages,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
@@ -3091,6 +3105,34 @@ export function REPL({
|
|||||||
proactiveModule?.setContextBlocked(false);
|
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 => {
|
newContent => {
|
||||||
// setResponseLength handles updating both responseLengthRef (for
|
// setResponseLength handles updating both responseLengthRef (for
|
||||||
@@ -3320,6 +3362,16 @@ export function REPL({
|
|||||||
|
|
||||||
queryCheckpoint('query_end');
|
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.
|
// Capture ant-only API metrics before resetLoadingState clears the ref.
|
||||||
// For multi-request turns (tool use loops), compute P50 across all requests.
|
// For multi-request turns (tool use loops), compute P50 across all requests.
|
||||||
if (process.env.USER_TYPE === 'ant' && apiMetricsRef.current.length > 0) {
|
if (process.env.USER_TYPE === 'ant' && apiMetricsRef.current.length > 0) {
|
||||||
@@ -3431,6 +3483,7 @@ export function REPL({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
pipeReturnHadErrorRef.current = false;
|
||||||
// isLoading is derived from queryGuard — tryStart() above already
|
// isLoading is derived from queryGuard — tryStart() above already
|
||||||
// transitioned dispatching→running, so no setter call needed here.
|
// transitioned dispatching→running, so no setter call needed here.
|
||||||
resetTimingRefs();
|
resetTimingRefs();
|
||||||
@@ -3463,15 +3516,26 @@ export function REPL({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await onQueryImpl(
|
try {
|
||||||
latestMessages,
|
await onQueryImpl(
|
||||||
newMessages,
|
latestMessages,
|
||||||
abortController,
|
newMessages,
|
||||||
shouldQuery,
|
abortController,
|
||||||
additionalAllowedTools,
|
shouldQuery,
|
||||||
mainLoopModelParam,
|
additionalAllowedTools,
|
||||||
effort,
|
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 {
|
} finally {
|
||||||
// queryGuard.end() atomically checks generation and transitions
|
// queryGuard.end() atomically checks generation and transitions
|
||||||
// running→idle. Returns false if a newer query owns the guard
|
// 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);
|
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
|
// Notify bridge clients that the turn is complete so mobile apps
|
||||||
// can stop the spark animation and show post-turn UI.
|
// can stop the spark animation and show post-turn UI.
|
||||||
sendBridgeResultRef.current();
|
sendBridgeResultRef.current();
|
||||||
@@ -3747,6 +3818,27 @@ export function REPL({
|
|||||||
proactiveModule?.resumeProactive();
|
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
|
// Handle immediate commands - these bypass the queue and execute right away
|
||||||
// even while Claude is processing. Commands opt-in via `immediate: true`.
|
// even while Claude is processing. Commands opt-in via `immediate: true`.
|
||||||
// Commands triggered via keybindings are always treated as immediate.
|
// Commands triggered via keybindings are always treated as immediate.
|
||||||
@@ -4739,10 +4831,11 @@ export function REPL({
|
|||||||
[onQuery, mainLoopModel, store],
|
[onQuery, mainLoopModel, store],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { relayPipeMessage, pipeReturnHadErrorRef } = usePipeRelay();
|
||||||
|
|
||||||
// Voice input integration (VOICE_MODE builds only)
|
// Voice input integration (VOICE_MODE builds only)
|
||||||
const voice = feature('VOICE_MODE')
|
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,
|
stripTrailing: () => 0,
|
||||||
handleKeyEvent: () => {},
|
handleKeyEvent: () => {},
|
||||||
@@ -4758,6 +4851,15 @@ export function REPL({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useMailboxBridge({ isLoading, onSubmitMessage: handleIncomingPrompt });
|
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)
|
// Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
|
||||||
if (feature('AGENT_TRIGGERS')) {
|
if (feature('AGENT_TRIGGERS')) {
|
||||||
@@ -4768,7 +4870,6 @@ export function REPL({
|
|||||||
// useScheduledTasks's effect (not here) since wrapping a hook call in a dynamic
|
// useScheduledTasks's effect (not here) since wrapping a hook call in a dynamic
|
||||||
// condition would break rules-of-hooks.
|
// condition would break rules-of-hooks.
|
||||||
const assistantMode = store.getState().kairosEnabled;
|
const assistantMode = store.getState().kairosEnabled;
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
|
|
||||||
useScheduledTasks!({ isLoading, assistantMode, setMessages });
|
useScheduledTasks!({ isLoading, assistantMode, setMessages });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4779,29 +4880,28 @@ export function REPL({
|
|||||||
if (process.env.USER_TYPE === 'ant') {
|
if (process.env.USER_TYPE === 'ant') {
|
||||||
// Tasks mode: watch for tasks and auto-process them
|
// Tasks mode: watch for tasks and auto-process them
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
// biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds
|
|
||||||
useTaskListWatcher({
|
useTaskListWatcher({
|
||||||
taskListId,
|
taskListId,
|
||||||
isLoading,
|
isLoading,
|
||||||
onSubmitTask: handleIncomingPrompt,
|
onSubmitTask: handleIncomingPrompt,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Loop mode: auto-tick when enabled (via /job command)
|
|
||||||
// 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
|
|
||||||
// race with it, causing concurrent-query enqueue of expanded skill text.
|
|
||||||
isLoading: isLoading || initialMessage !== null,
|
|
||||||
queuedCommandsLength: queuedCommands.length,
|
|
||||||
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
|
|
||||||
isInPlanMode: toolPermissionContext.mode === 'plan',
|
|
||||||
onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, { isMeta: true }),
|
|
||||||
onQueueTick: (prompt: string) => enqueue({ mode: 'prompt', value: prompt, isMeta: true }),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
useProactive?.({
|
||||||
|
// Suppress ticks while an initial message is pending — the initial
|
||||||
|
// message will be processed asynchronously and a premature tick would
|
||||||
|
// race with it, causing concurrent-query enqueue of expanded skill text.
|
||||||
|
isLoading: isLoading || initialMessage !== null,
|
||||||
|
queuedCommandsLength: queuedCommands.length,
|
||||||
|
hasActiveLocalJsxUI: isShowingLocalJSXCommand,
|
||||||
|
isInPlanMode: toolPermissionContext.mode === 'plan',
|
||||||
|
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
|
// Abort the current operation when a 'now' priority message arrives
|
||||||
// (e.g. from a chat UI client via UDS).
|
// (e.g. from a chat UI client via UDS).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -5119,8 +5219,15 @@ export function REPL({
|
|||||||
// Handle shift+down for teammate navigation and background task management.
|
// Handle shift+down for teammate navigation and background task management.
|
||||||
// Guard onOpenBackgroundTasks when a local-jsx dialog (e.g. /mcp) is open —
|
// Guard onOpenBackgroundTasks when a local-jsx dialog (e.g. /mcp) is open —
|
||||||
// otherwise Shift+Down stacks BackgroundTasksDialog on top and deadlocks input.
|
// 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({
|
useBackgroundTaskNavigation({
|
||||||
onOpenBackgroundTasks: isShowingLocalJSXCommand ? undefined : () => setShowBashesDialog(true),
|
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
|
// Auto-exit viewing mode when teammate completes or errors
|
||||||
useTeammateViewAutoExit();
|
useTeammateViewAutoExit();
|
||||||
@@ -5375,12 +5482,12 @@ export function REPL({
|
|||||||
// /config, /theme, /diff, ...) both go here now.
|
// /config, /theme, /diff, ...) both go here now.
|
||||||
const toolJsxCentered = isFullscreenEnvEnabled() && toolJSX?.isLocalJSXCommand === true;
|
const toolJsxCentered = isFullscreenEnvEnabled() && toolJSX?.isLocalJSXCommand === true;
|
||||||
const centeredModal: React.ReactNode = toolJsxCentered ? toolJSX!.jsx : null;
|
const centeredModal: React.ReactNode = toolJsxCentered ? toolJSX!.jsx : null;
|
||||||
|
|
||||||
// <AlternateScreen> at the root: everything below is inside its
|
// <AlternateScreen> at the root: everything below is inside its
|
||||||
// <Box height={rows}>. Handlers/contexts are zero-height so ScrollBox's
|
// <Box height={rows}>. Handlers/contexts are zero-height so ScrollBox's
|
||||||
// flexGrow in FullscreenLayout resolves against this Box. The transcript
|
// flexGrow in FullscreenLayout resolves against this Box. The transcript
|
||||||
// early return above wraps its virtual-scroll branch the same way; only
|
// early return above wraps its virtual-scroll branch the same way; only
|
||||||
// the 30-cap dump branch stays unwrapped for native terminal scrollback.
|
// the 30-cap dump branch stays unwrapped for native terminal scrollback.
|
||||||
|
|
||||||
const mainReturn = (
|
const mainReturn = (
|
||||||
<KeybindingSetup>
|
<KeybindingSetup>
|
||||||
<AnimatedTerminalTitle
|
<AnimatedTerminalTitle
|
||||||
@@ -5413,7 +5520,7 @@ export function REPL({
|
|||||||
isFullscreenEnvEnabled() &&
|
isFullscreenEnvEnabled() &&
|
||||||
(centeredModal != null || !focusedInputDialog || focusedInputDialog === 'tool-permission')
|
(centeredModal != null || !focusedInputDialog || focusedInputDialog === 'tool-permission')
|
||||||
}
|
}
|
||||||
onScroll={centeredModal || toolPermissionOverlay || viewedAgentTask ? undefined : composedOnScroll}
|
onScroll={composedOnScroll}
|
||||||
/>
|
/>
|
||||||
{feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? (
|
{feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? (
|
||||||
<MessageActionsKeybindings handlers={messageActionHandlers} isActive={cursor !== null} />
|
<MessageActionsKeybindings handlers={messageActionHandlers} isActive={cursor !== null} />
|
||||||
|
|||||||
@@ -1,7 +1,142 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
import {
|
||||||
export {};
|
type ListResourcesResult,
|
||||||
import type { Command } from 'src/types/command.js';
|
ListResourcesResultSchema,
|
||||||
export const fetchMcpSkillsForClient: ((...args: unknown[]) => Promise<Command[]>) & { cache: Map<string, unknown> } = Object.assign(
|
type ReadResourceResult,
|
||||||
(..._args: unknown[]) => Promise.resolve([] as Command[]),
|
ReadResourceResultSchema,
|
||||||
{ cache: new Map<string, unknown>() }
|
} 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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,207 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
// Background task entry for local workflow execution.
|
||||||
import type { TaskStateBase, SetAppState } from '../../Task.js'
|
// 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 & {
|
export type LocalWorkflowTaskState = TaskStateBase & {
|
||||||
type: 'local_workflow'
|
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
|
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 = (() => {});
|
|
||||||
|
|||||||
@@ -1,10 +1,139 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
// Background task entry for MCP resource monitoring.
|
||||||
import type { TaskStateBase, SetAppState } from '../../Task.js';
|
// Tracks a long-running subscription to an MCP server resource so the
|
||||||
import type { AppState } from '../../state/AppState.js';
|
// otherwise-invisible stream is visible in the footer pill and Shift+Down
|
||||||
import type { AgentId } from '../../types/ids.js';
|
// 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 & {
|
export type MonitorMcpTaskState = TaskStateBase & {
|
||||||
type: 'monitor_mcp';
|
type: 'monitor_mcp'
|
||||||
};
|
/** The MCP server name being monitored. */
|
||||||
export const killMonitorMcp: (taskId: string, setAppState: SetAppState) => void = (() => {});
|
serverName: string
|
||||||
export const killMonitorMcpTasksForAgent: (agentId: AgentId, getAppState: () => AppState, setAppState: SetAppState) => void = (() => {});
|
/** 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ const coordinatorModeModule = feature('COORDINATOR_MODE')
|
|||||||
const SnipTool = feature('HISTORY_SNIP')
|
const SnipTool = feature('HISTORY_SNIP')
|
||||||
? require('./tools/SnipTool/SnipTool.js').SnipTool
|
? require('./tools/SnipTool/SnipTool.js').SnipTool
|
||||||
: null
|
: null
|
||||||
|
const ReviewArtifactTool = feature('REVIEW_ARTIFACT')
|
||||||
|
? require('./tools/ReviewArtifactTool/ReviewArtifactTool.js')
|
||||||
|
.ReviewArtifactTool
|
||||||
|
: null
|
||||||
const ListPeersTool = feature('UDS_INBOX')
|
const ListPeersTool = feature('UDS_INBOX')
|
||||||
? require('./tools/ListPeersTool/ListPeersTool.js').ListPeersTool
|
? require('./tools/ListPeersTool/ListPeersTool.js').ListPeersTool
|
||||||
: null
|
: null
|
||||||
@@ -237,6 +241,7 @@ export function getAllBaseTools(): Tools {
|
|||||||
...(SendUserFileTool ? [SendUserFileTool] : []),
|
...(SendUserFileTool ? [SendUserFileTool] : []),
|
||||||
...(PushNotificationTool ? [PushNotificationTool] : []),
|
...(PushNotificationTool ? [PushNotificationTool] : []),
|
||||||
...(SubscribePRTool ? [SubscribePRTool] : []),
|
...(SubscribePRTool ? [SubscribePRTool] : []),
|
||||||
|
...(ReviewArtifactTool ? [ReviewArtifactTool] : []),
|
||||||
...(getPowerShellTool() ? [getPowerShellTool()] : []),
|
...(getPowerShellTool() ? [getPowerShellTool()] : []),
|
||||||
...(SnipTool ? [SnipTool] : []),
|
...(SnipTool ? [SnipTool] : []),
|
||||||
...(process.env.NODE_ENV === 'test' ? [TestingPermissionTool] : []),
|
...(process.env.NODE_ENV === 'test' ? [TestingPermissionTool] : []),
|
||||||
|
|||||||
80
src/tools/CtxInspectTool/CtxInspectTool.ts
Normal file
80
src/tools/CtxInspectTool/CtxInspectTool.ts
Normal 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.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
107
src/tools/ListPeersTool/ListPeersTool.ts
Normal file
107
src/tools/ListPeersTool/ListPeersTool.ts
Normal 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 },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
|
||||||
export {};
|
|
||||||
export const MonitorTool: Record<string, unknown> = {};
|
|
||||||
190
src/tools/MonitorTool/MonitorTool.tsx
Normal file
190
src/tools/MonitorTool/MonitorTool.tsx
Normal 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>
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -7,10 +7,6 @@ mock.module("src/utils/cwd.js", () => ({
|
|||||||
getCwd: () => mockCwd,
|
getCwd: () => mockCwd,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module("src/utils/powershell/parser.js", () => ({
|
|
||||||
PS_TOKENIZER_DASH_CHARS: new Set(["-", "\u2013", "\u2014", "\u2015"]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { isGitInternalPathPS, isDotGitPathPS } = await import("../gitSafety");
|
const { isGitInternalPathPS, isDotGitPathPS } = await import("../gitSafety");
|
||||||
|
|
||||||
describe("isGitInternalPathPS", () => {
|
describe("isGitInternalPathPS", () => {
|
||||||
|
|||||||
87
src/tools/PushNotificationTool/PushNotificationTool.ts
Normal file
87
src/tools/PushNotificationTool/PushNotificationTool.ts
Normal 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.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const REPLTool = { name: 'REPLTool', isEnabled: () => false }
|
|
||||||
89
src/tools/REPLTool/REPLTool.ts
Normal file
89
src/tools/REPLTool/REPLTool.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,3 +1,142 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
import { z } from 'zod/v4'
|
||||||
export {};
|
import { buildTool, type ToolDef } from '../../Tool.js'
|
||||||
export const ReviewArtifactTool: Record<string, unknown> = {};
|
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>)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const inputSchema = lazySchema(() =>
|
|||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
feature('UDS_INBOX')
|
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',
|
: 'Recipient: teammate name, or "*" for broadcast to all teammates',
|
||||||
),
|
),
|
||||||
summary: z
|
summary: z
|
||||||
@@ -587,9 +587,6 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
|||||||
return {
|
return {
|
||||||
behavior: 'ask' as const,
|
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.`,
|
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: {
|
decisionReason: {
|
||||||
type: 'safetyCheck',
|
type: 'safetyCheck',
|
||||||
reason:
|
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 }
|
return { behavior: 'allow' as const, updatedInput: input }
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -611,7 +619,9 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
|||||||
}
|
}
|
||||||
const addr = parseAddress(input.to)
|
const addr = parseAddress(input.to)
|
||||||
if (
|
if (
|
||||||
(addr.scheme === 'bridge' || addr.scheme === 'uds') &&
|
(addr.scheme === 'bridge' ||
|
||||||
|
addr.scheme === 'uds' ||
|
||||||
|
addr.scheme === 'tcp') &&
|
||||||
addr.target.trim().length === 0
|
addr.target.trim().length === 0
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
@@ -659,9 +669,13 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
|||||||
parseAddress(input.to).scheme === 'uds' &&
|
parseAddress(input.to).scheme === 'uds' &&
|
||||||
typeof input.message === 'string'
|
typeof input.message === 'string'
|
||||||
) {
|
) {
|
||||||
// UDS cross-session send: summary isn't rendered (UI.tsx returns null
|
return { result: true }
|
||||||
// for string messages), so don't require it. Structured messages fall
|
}
|
||||||
// through to the rejection below.
|
if (
|
||||||
|
feature('LAN_PIPES') &&
|
||||||
|
parseAddress(input.to).scheme === 'tcp' &&
|
||||||
|
typeof input.message === 'string'
|
||||||
|
) {
|
||||||
return { result: true }
|
return { result: true }
|
||||||
}
|
}
|
||||||
if (typeof input.message === 'string') {
|
if (typeof input.message === 'string') {
|
||||||
@@ -783,7 +797,7 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
|||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
success: true,
|
success: true,
|
||||||
message: `“${preview}” → ${input.to}`,
|
message: `”${preview}” → ${input.to}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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
|
// 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,
|
prompt: input.message,
|
||||||
toolUseContext: context,
|
toolUseContext: context,
|
||||||
canUseTool,
|
canUseTool,
|
||||||
invokingRequestId: assistantMessage?.requestId as string | undefined,
|
invokingRequestId: assistantMessage?.requestId as
|
||||||
|
| string
|
||||||
|
| undefined,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
@@ -853,7 +904,9 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
|
|||||||
prompt: input.message,
|
prompt: input.message,
|
||||||
toolUseContext: context,
|
toolUseContext: context,
|
||||||
canUseTool,
|
canUseTool,
|
||||||
invokingRequestId: assistantMessage?.requestId as string | undefined,
|
invokingRequestId: assistantMessage?.requestId as
|
||||||
|
| string
|
||||||
|
| undefined,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
84
src/tools/SendUserFileTool/SendUserFileTool.ts
Normal file
84
src/tools/SendUserFileTool/SendUserFileTool.ts
Normal 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.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,3 +1 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
export const SEND_USER_FILE_TOOL_NAME = 'SendUserFile'
|
||||||
export {};
|
|
||||||
export const SEND_USER_FILE_TOOL_NAME: string = '';
|
|
||||||
|
|||||||
134
src/tools/SleepTool/SleepTool.ts
Normal file
134
src/tools/SleepTool/SleepTool.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
92
src/tools/SnipTool/SnipTool.ts
Normal file
92
src/tools/SnipTool/SnipTool.ts
Normal 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`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,3 +1 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
export const SNIP_TOOL_NAME = 'Snip'
|
||||||
export {};
|
|
||||||
export const SNIP_TOOL_NAME: string = '';
|
|
||||||
|
|||||||
88
src/tools/SubscribePRTool/SubscribePRTool.ts
Normal file
88
src/tools/SubscribePRTool/SubscribePRTool.ts
Normal 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.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const SuggestBackgroundPRTool = { name: 'SuggestBackgroundPRTool', isEnabled: () => false }
|
|
||||||
84
src/tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool.ts
Normal file
84
src/tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool.ts
Normal 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.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
82
src/tools/TerminalCaptureTool/TerminalCaptureTool.ts
Normal file
82
src/tools/TerminalCaptureTool/TerminalCaptureTool.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,3 +1 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
export const TERMINAL_CAPTURE_TOOL_NAME = 'TerminalCapture'
|
||||||
export {};
|
|
||||||
export const TERMINAL_CAPTURE_TOOL_NAME: string = '';
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const VerifyPlanExecutionTool = { name: 'VerifyPlanExecutionTool', isEnabled: () => false }
|
|
||||||
93
src/tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.ts
Normal file
93
src/tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,3 +1 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
export const VERIFY_PLAN_EXECUTION_TOOL_NAME = 'VerifyPlanExecution'
|
||||||
export {};
|
|
||||||
export const VERIFY_PLAN_EXECUTION_TOOL_NAME: string = '';
|
|
||||||
|
|||||||
97
src/tools/WebBrowserTool/WebBrowserTool.ts
Normal file
97
src/tools/WebBrowserTool/WebBrowserTool.ts
Normal 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
Reference in New Issue
Block a user