ferrycat 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,8 @@
5
5
  <a href="README_zh.md">中文</a>
6
6
  </p>
7
7
 
8
+ [![npm version](https://img.shields.io/npm/v/ferrycat.svg?style=flat&color=cb3837&logo=npm)](https://www.npmjs.com/package/ferrycat)
9
+ [![MCP Registry](https://img.shields.io/badge/MCP_Registry-Active-10b981?style=flat)](https://registry.modelcontextprotocol.io/v0/servers?search=ferrycat)
8
10
  [![Node.js Version](https://img.shields.io/badge/Node.js-22.5%2B-339933?style=flat&logo=nodedotjs)](https://nodejs.org/)
9
11
  [![Native Addons](https://img.shields.io/badge/Native_Addons-0_(node%3Asqlite)-success?style=flat)]()
10
12
  [![Protocol](https://img.shields.io/badge/Protocol-MCP_Stdio-orange?style=flat)](https://modelcontextprotocol.io/)
@@ -15,7 +17,7 @@
15
17
  > Purpose-built to safely bridge isolated desktop IDE agent hosts (such as Google Antigravity, WorkBuddy, Claude Code, and Cursor) across separate OS processes without context drift or blind reviews on stale files.
16
18
 
17
19
  <p align="center">
18
- <img src="https://guobug.github.io/assets/images/flowchart-ferrycat-cross-host-demand-and-workflow.svg" alt="FerryCat Architecture and Workflow" width="960" />
20
+ <img src="https://guobug.github.io/assets/images/flowchart-ferrycat-cross-host-demand-and-workflow-en.svg" alt="FerryCat Architecture and Workflow" width="960" />
19
21
  </p>
20
22
 
21
23
  > 📖 **Deep Dive**: [Why I Started Building FerryCat: When Two AI Coding Hosts Live on the Same Desktop](https://guobug.github.io/posts/2026/10/22/why-we-built-ferrycat-cross-host-agent-review-bus/)
@@ -118,6 +120,7 @@ Holder-side lifecycle controls on a `CLAIMED` contract (all require `agent_id ==
118
120
  ### 6. Threat Model & Security Assumptions (Local Workstation IPC)
119
121
  - **Assumed Trust Environment**: FerryCat is intentionally designed as a **Trusted Local Workstation Bus** bridging isolated IDE hosts operating under the **same OS user account**. Callers and workers share the local file system (`workspace_root`) and SQLite database (`~/.ferrycat/mesh.db`).
120
122
  - **Defense Scope**: Path containment (`assertSafePointerPath`) and sensitive file pattern filters prevent unintentional workspace escapes, symlink traversals, or accidental leakage of credentials/secret files during handoffs.
123
+ - **Daemon Execution Surface**: In daemon mode (`ferrycat-cli daemon`), handler scripts are invoked directly via `execFile`. Write permissions to `ferrycat.config.json` or handler script paths are equivalent to host execution privileges (ADR-0005 §2.2). Keep configurations local and version-controlled.
121
124
  - **Out of Scope (Non-Goals)**: FerryCat does **not** provide adversarial sandboxing, multi-tenant isolation, or network authorization against malicious local processes or adversarial agents spoofing `AGENT_NAME`.
122
125
 
123
126
  ---
@@ -126,32 +129,61 @@ Holder-side lifecycle controls on a `CLAIMED` contract (all require `agent_id ==
126
129
 
127
130
  1. **Disjoint File Systems**: FerryCat offloads context by file reference; both hosts must share access to the same local `workspace_root`.
128
131
  2. **Trivial Single-Turn Queries**: Each cross-host handoff involves 3 explicit human window switches (~15–25s overhead).
129
- 3. **Unattended Background Pipelines**: Desktop IDE hosts follow a request-response model and cannot be woken up asynchronously by an idle MCP server. For unattended multi-step pipelines, use a standalone DAG workflow engine such as [PatchCat](https://github.com/GuoBug/ai-prompt-orchestrator).
132
+ 3. **Multi-Step Dynamic DAG Pipelines**: FerryCat's background daemon (`ferrycat-cli daemon`, ADR-0005) manages single-step contract dispatch and supervised worker lifecycles; for complex multi-step branching DAG orchestrations, use dedicated DAG workflow engines such as [PatchCat](https://github.com/GuoBug/ai-prompt-orchestrator).
130
133
 
131
134
  ---
132
135
 
133
- ## Quickstart
136
+ ## Controlled Daemon & Declarative Handlers (ADR-0005)
137
+
138
+ FerryCat provides a controlled daemon mode governed by SQLite microsecond CAS leases on `daemon_locks` and declarative scripts defined in `ferrycat.config.json`:
134
139
 
135
- ### 1. Build & Verify
136
140
  ```bash
137
- git clone https://github.com/GuoBug/FerryCat.git
138
- cd FerryCat
139
- npm install
140
- npm run build
141
- npm test
141
+ # Run a single dispatch cycle with declared handlers
142
+ ferrycat-cli daemon --config ./ferrycat.config.json --once
143
+
144
+ # Start continuous daemon loop
145
+ ferrycat-cli daemon --config ./ferrycat.config.json
146
+ ```
147
+
148
+ Example `ferrycat.config.json`:
149
+ ```json
150
+ {
151
+ "version": 1,
152
+ "daemon": {
153
+ "poll_interval_ms": 1000,
154
+ "lease_ttl_sec": 30,
155
+ "heartbeat_interval_ms": 10000,
156
+ "notify": true
157
+ },
158
+ "agents": {
159
+ "workbuddy": {
160
+ "handlers": {
161
+ "code_review": {
162
+ "exec_file": "./scripts/review-handler.js",
163
+ "pass_env": ["CUSTOM_ANALYZER_PATH"]
164
+ }
165
+ }
166
+ }
167
+ }
168
+ }
142
169
  ```
143
170
 
144
- ### 2. Mount in Antigravity
171
+ ---
172
+
173
+ ## Quickstart
174
+
175
+ ### 1. Recommended: Zero-Install MCP Mounting (via npx)
176
+
177
+ No local clone or manual compilation required. Mount directly into your IDE's MCP configuration using `npx -y ferrycat`:
178
+
179
+ #### Antigravity Configuration
145
180
  Edit `%USERPROFILE%\.gemini\antigravity\mcp_config.json` (or `~/.gemini/antigravity/mcp_config.json` on macOS/Linux):
146
181
  ```json
147
182
  {
148
183
  "mcpServers": {
149
184
  "ferrycat": {
150
- "command": "node",
151
- "args": [
152
- "--experimental-sqlite",
153
- "F:/git/FerryCat/dist/index.js"
154
- ],
185
+ "command": "npx",
186
+ "args": ["-y", "ferrycat"],
155
187
  "env": {
156
188
  "AGENT_NAME": "antigravity"
157
189
  }
@@ -160,17 +192,14 @@ Edit `%USERPROFILE%\.gemini\antigravity\mcp_config.json` (or `~/.gemini/antigrav
160
192
  }
161
193
  ```
162
194
 
163
- ### 3. Mount in WorkBuddy / Claude Code / Cursor
195
+ #### WorkBuddy / Claude Code / Cursor / Codex Configuration
164
196
  Configure the target host's MCP settings with its respective `AGENT_NAME`:
165
197
  ```json
166
198
  {
167
199
  "mcpServers": {
168
200
  "ferrycat": {
169
- "command": "node",
170
- "args": [
171
- "--experimental-sqlite",
172
- "F:/git/FerryCat/dist/index.js"
173
- ],
201
+ "command": "npx",
202
+ "args": ["-y", "ferrycat"],
174
203
  "env": {
175
204
  "AGENT_NAME": "workbuddy"
176
205
  }
@@ -178,7 +207,53 @@ Configure the target host's MCP settings with its respective `AGENT_NAME`:
178
207
  }
179
208
  }
180
209
  ```
181
- > **Note**: Both MCP Stdio and direct CLI invocation (`ferrycat-cli <dispatch|poll|verify|fulfill|release|extend|fail|inspect|worker>`) share the same SQLite WAL database at `~/.ferrycat/mesh.db` and the same Zod validation schemas. CLI supports `--help` tree, stdin payloads (`--file -` or `-`), `verify` pre-flight check, `inspect` ASCII table formatting (`--target <agent>`), `worker --mode=watch|claim|exec` and direct script execution via `--exec-file <script>`. In `worker --exec` mode, a non-zero exit of the child command closes the contract as `FAILED` instead of leaving it `CLAIMED` until the TTL sweep.
210
+
211
+ ### 2. Global Installation & Source Build
212
+
213
+ - **Global CLI Installation**:
214
+ ```bash
215
+ npm install -g ferrycat
216
+ ```
217
+ Then simply set `"command": "ferrycat"` in your MCP config.
218
+ - **Local Source Build (For Contributors)**:
219
+ ```bash
220
+ git clone https://github.com/GuoBug/FerryCat.git
221
+ cd FerryCat
222
+ npm install
223
+ npm run build
224
+ npm test
225
+ ```
226
+ Mount using your local path:
227
+ `"command": "node"`, `"args": ["--experimental-sqlite", "/path/to/FerryCat/dist/index.js"]`.
228
+
229
+ ---
230
+
231
+ ## First-Class CLI Experience (`ferrycat-cli`)
232
+
233
+ Beyond serving as an in-IDE MCP Server, FerryCat provides a first-class CLI utility (`ferrycat-cli` or `npx ferrycat-cli`). Sharing the same `~/.ferrycat/mesh.db` SQLite WAL database and Zod validation contracts, the CLI allows developers to inspect mesh health, verify working tree safety, and hook into automated CI/Git scripts without leaving their terminal:
234
+
235
+ ### 1. Terminal Visual Inspection (ASCII Status Table)
236
+ Inspect all cross-host handoffs and error reasons formatted as an ASCII table:
237
+ ```bash
238
+ # View all contracts in an ASCII table
239
+ npx ferrycat-cli inspect
240
+
241
+ # Filter by target agent (e.g., only tasks for antigravity)
242
+ npx ferrycat-cli inspect --target antigravity
243
+ ```
244
+
245
+ ### 2. Pre-Flight Verification (Auditing Before Mutation)
246
+ Audit on-disk pointer file integrity against the contract's baseline SHA-256 before committing review work (ADR-0004), eliminating false-positive deliveries:
247
+ ```bash
248
+ npx ferrycat-cli verify <CONTRACT_ID>
249
+ ```
250
+
251
+ ### 3. Local CI & Headless Worker Execution
252
+ Poll the queue, claim tasks, and execute local review/test scripts with automatic failure closure:
253
+ ```bash
254
+ # Safely run a script upon task arrival (no shell quotation hazards)
255
+ npx ferrycat-cli worker --agent codex --mode=exec --exec-file ./scripts/review.sh
256
+ ```
182
257
 
183
258
  ---
184
259
 
package/README_zh.md CHANGED
@@ -5,6 +5,8 @@
5
5
  <b>中文</b>
6
6
  </p>
7
7
 
8
+ [![npm version](https://img.shields.io/npm/v/ferrycat.svg?style=flat&color=cb3837&logo=npm)](https://www.npmjs.com/package/ferrycat)
9
+ [![MCP Registry](https://img.shields.io/badge/MCP_Registry-Active-10b981?style=flat)](https://registry.modelcontextprotocol.io/v0/servers?search=ferrycat)
8
10
  [![Node.js Version](https://img.shields.io/badge/Node.js-22.5%2B-339933?style=flat&logo=nodedotjs)](https://nodejs.org/)
9
11
  [![Native Addons](https://img.shields.io/badge/Native_Addons-0_(node%3Asqlite)-success?style=flat)]()
10
12
  [![Protocol](https://img.shields.io/badge/Protocol-MCP_Stdio-orange?style=flat)](https://modelcontextprotocol.io/)
@@ -118,6 +120,7 @@ MCP 官方的 `Tasks` 规范(2025-11 / 2026-07)解决的是**单条 `Client
118
120
  ### 6. 威胁模型与安全边界声明 (同机同用户受信 IPC)
119
121
  - **环境信任假设**:FerryCat 定位为**面向同机同操作系统用户的跨宿主 AI 开发者协作总线**。所有 IDE 宿主与 Worker 均运行在同一本地用户权限下,共享本地工作区(`workspace_root`)与本地 SQLite 文件(`~/.ferrycat/mesh.db`)。
120
122
  - **防护范畴**:双端路径防逃逸(`assertSafePointerPath`)与敏感文件模式黑名单旨在防范跨宿主误触敏感配置(如 `.env`、SSH 密钥)、软链接越界与无意识的目录逃逸。
123
+ - **守护进程执行面**:在守护进程模式(`ferrycat-cli daemon`)下,处理脚本通过 `execFile` 直接执行。`ferrycat.config.json` 或处理脚本所在目录的写权限等价于宿主代码执行权限(详见 ADR-0005 §2.2),请确保配置文件置于本地受信任路径。
121
124
  - **非防护目标**:FerryCat **不作为**面向不受信外部网络、多租户强隔离或恶意本地进程伪造 `AGENT_NAME` 的零信任安全网关。
122
125
 
123
126
  ---
@@ -126,34 +129,61 @@ MCP 官方的 `Tasks` 规范(2025-11 / 2026-07)解决的是**单条 `Client
126
129
 
127
130
  1. **两端不共享同一本地文件系统**:FerryCat 依赖按引用传递文件指针(Context Offloading by Reference),要求双方能访问同一份 `workspace_root`。
128
131
  2. **单轮简单问答即可解决的任务**:每次跨宿主交接需要 3 次人工窗口切换(约 15~25 秒操作开销),简单问题直接在单个 IDE 内完成即可。
129
- 3. **需要无人值守全自动流水线的场景**:桌面 IDE 宿主受限于请求-响应模型,无法在空闲时被 MCP 服务端主动推流唤醒;若需无人值守编排,请使用独立的 DAG 状态机工作流引擎(如 [PatchCat](https://github.com/GuoBug/ai-prompt-orchestrator))。
132
+ 3. **跨步骤复杂有向图编排**:FerryCat 的守护进程(`ferrycat-cli daemon`)负责同机多 Agent 之间的单步契约分发与进程生命周期托管;若需跨多步骤的复杂动态 DAG 有向无环图分支编排,请使用专门的工作流引擎(如 [PatchCat](https://github.com/GuoBug/ai-prompt-orchestrator))。
130
133
 
131
134
  ---
132
135
 
133
- ## 快速上手
136
+ ## 守护进程与声明式处理 (Controlled Daemon, ADR-0005)
137
+
138
+ FerryCat 支持受控后台守护进程,通过 SQLite 微事务 CAS 租约锁实现多进程安全抢占与故障转移,并强制通过 `ferrycat.config.json` 静态绑定脚本处理器:
134
139
 
135
- ### 1. 安装与编译测试
136
140
  ```bash
137
- git clone https://github.com/GuoBug/FerryCat.git
138
- cd FerryCat
139
- npm install
140
- npm run build
141
- npm test
141
+ # 单次轮询分发并执行已声明的处理脚本
142
+ ferrycat-cli daemon --config ./ferrycat.config.json --once
143
+
144
+ # 启动后台守护主循环
145
+ ferrycat-cli daemon --config ./ferrycat.config.json
142
146
  ```
143
147
 
144
- > **关于 `--experimental-sqlite`**:启动参数中恒定携带 `--experimental-sqlite`,以同时兼容 Node 22.12 LTS(需 flag)与 Node 22.22+ / Node 24+(原生免 flag,携带亦不报错),实现零原生 C++ 编译依赖。
148
+ 配置文件形态示例(`ferrycat.config.json`):
149
+ ```json
150
+ {
151
+ "version": 1,
152
+ "daemon": {
153
+ "poll_interval_ms": 1000,
154
+ "lease_ttl_sec": 30,
155
+ "heartbeat_interval_ms": 10000,
156
+ "notify": true
157
+ },
158
+ "agents": {
159
+ "workbuddy": {
160
+ "handlers": {
161
+ "code_review": {
162
+ "exec_file": "./scripts/review-handler.js",
163
+ "pass_env": ["CUSTOM_ANALYZER_PATH"]
164
+ }
165
+ }
166
+ }
167
+ }
168
+ }
169
+ ```
170
+
171
+ ---
172
+
173
+ ## 快速上手 (Quickstart)
145
174
 
146
- ### 2. Antigravity 侧挂载
175
+ ### 1. 推荐:零配置免安装挂载 (npx)
176
+
177
+ 无需手动克隆仓库或本地编译,直接在各 IDE 宿主的 MCP 配置文件中声明(基于全局或 `npx -y ferrycat`):
178
+
179
+ #### Antigravity 宿主配置
147
180
  编辑 `%USERPROFILE%\.gemini\antigravity\mcp_config.json`(macOS/Linux 为 `~/.gemini/antigravity/mcp_config.json`):
148
181
  ```json
149
182
  {
150
183
  "mcpServers": {
151
184
  "ferrycat": {
152
- "command": "node",
153
- "args": [
154
- "--experimental-sqlite",
155
- "F:/git/FerryCat/dist/index.js"
156
- ],
185
+ "command": "npx",
186
+ "args": ["-y", "ferrycat"],
157
187
  "env": {
158
188
  "AGENT_NAME": "antigravity"
159
189
  }
@@ -162,17 +192,14 @@ npm test
162
192
  }
163
193
  ```
164
194
 
165
- ### 3. WorkBuddy / Claude Code / Cursor 侧挂载
166
- 在目标宿主的 MCP 配置文件中添加对应 `AGENT_NAME`:
195
+ #### WorkBuddy / Claude Code / Cursor / Codex 宿主配置
196
+ 在目标宿主的 MCP 配置文件中指定对应 `AGENT_NAME` 即可:
167
197
  ```json
168
198
  {
169
199
  "mcpServers": {
170
200
  "ferrycat": {
171
- "command": "node",
172
- "args": [
173
- "--experimental-sqlite",
174
- "F:/git/FerryCat/dist/index.js"
175
- ],
201
+ "command": "npx",
202
+ "args": ["-y", "ferrycat"],
176
203
  "env": {
177
204
  "AGENT_NAME": "workbuddy"
178
205
  }
@@ -180,7 +207,53 @@ npm test
180
207
  }
181
208
  }
182
209
  ```
183
- > **注**:MCP `stdio` 模式与 CLI 直接调用模式(`ferrycat-cli <dispatch|poll|verify|fulfill|release|extend|fail|inspect|worker>`)共享同一份 `~/.ferrycat/mesh.db` 数据库与同一套 Zod 参数校验契约。CLI 提供全量 `--help` 树、stdin 管道输入(`--file -` 或 `-`)、`verify` 只读预检、`inspect` ASCII 表格输出(支持 `--target <agent>` 过滤)、`worker --mode=watch|claim|exec` 以及 `--exec-file <script>` 无转义直接执行脚本。在 `worker --exec` 模式下子进程非零退出时,工单会被直接置为 `FAILED`,不再挂在 `CLAIMED` 等超时。
210
+
211
+ ### 2. 全局安装或本地源码模式
212
+
213
+ - **全局 CLI 安装**:
214
+ ```bash
215
+ npm install -g ferrycat
216
+ ```
217
+ 在 MCP 配置文件中直接指定 `"command": "ferrycat"` 即可。
218
+ - **本地源码编译(适合贡献者二次开发)**:
219
+ ```bash
220
+ git clone https://github.com/GuoBug/FerryCat.git
221
+ cd FerryCat
222
+ npm install
223
+ npm run build
224
+ npm test
225
+ ```
226
+ 本地调试时可将配置中的 `command` 与 `args` 设为:
227
+ `"command": "node"`, `"args": ["--experimental-sqlite", "/path/to/FerryCat/dist/index.js"]`。
228
+
229
+ ---
230
+
231
+ ## 一级入口:CLI 极速总线与自动化调度 (`ferrycat-cli`)
232
+
233
+ 除了作为 MCP Server 嵌入在 IDE 内部供大模型自主调用外,FerryCat 原生提供一级命令行工具 **`ferrycat-cli`**(可通过全局安装或直接运行 `npx ferrycat-cli`)。它与 MCP 共享同一份 `~/.ferrycat/mesh.db` 数据库与 Zod 校验契约,无需切换 IDE 窗口即可在终端直观排查链路、接入 Git Hook 或自动化 CI:
234
+
235
+ ### 1. 终端可视化监控(ASCII 状态总线)
236
+ 实时打印工单流转状态、执行租约与报错信息,无需打开数据库查看:
237
+ ```bash
238
+ # 查看全局工单 ASCII 终端表格
239
+ npx ferrycat-cli inspect
240
+
241
+ # 按接收方过滤(例如仅查看 target 为 antigravity 的工单)
242
+ npx ferrycat-cli inspect --target antigravity
243
+ ```
244
+
245
+ ### 2. 交付前只读预检(Pre-Flight Verify)
246
+ 在正式交付前,无需变更契约状态即可安全核验磁盘文件指针一致性,杜绝因未落盘修改导致的假阳性交付:
247
+ ```bash
248
+ npx ferrycat-cli verify <CONTRACT_ID>
249
+ ```
250
+
251
+ ### 3. 本地 CI 与自动化 Worker 执行
252
+ 监听契约队列并直接拉起本地脚本执行(支持安全脚本执行与非零退出自动熔断为 `FAILED`):
253
+ ```bash
254
+ # 启动常驻 Worker 监听并自动执行指定脚本(无 Shell 注入风险)
255
+ npx ferrycat-cli worker --agent codex --mode=exec --exec-file ./scripts/review.sh
256
+ ```
184
257
 
185
258
  ---
186
259
 
package/dist/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { exec, execFile } from 'node:child_process';
3
2
  import fs from 'node:fs';
4
3
  import path from 'node:path';
5
4
  import { fileURLToPath } from 'node:url';
6
5
  import { ZodError } from 'zod';
7
6
  import { expandTilde, FerryCatEngine } from './engine.js';
7
+ import { FerryCatDaemon } from './daemon.js';
8
+ import { executeTaskAndFulfill, terminateActiveChildrenSync, } from './supervisor.js';
8
9
  import { DispatchContractSchema, ExtendTtlSchema, FailContractSchema, FulfillContractSchema, InspectMeshStateSchema, PollNextTaskSchema, ReleaseContractSchema, VerifyContractSchema, WorkerCommandSchema, } from './schemas.js';
9
10
  function loadPackageVersion() {
10
11
  try {
@@ -33,6 +34,7 @@ Commands:
33
34
  fail Explicitly mark a CLAIMED contract you hold as terminal FAILED with reason
34
35
  inspect Query contracts (renders concise table by default; pass --json for raw JSON)
35
36
  worker Run background watcher, single-shot claim, or automated script execution
37
+ daemon Run multi-tenant production daemon with CAS micro-lease and declarative handlers (ADR-0005)
36
38
 
37
39
  Common Options:
38
40
  --agent <id> Agent identity (overrides AGENT_NAME env var)
@@ -174,6 +176,22 @@ Examples:
174
176
  ferrycat-cli worker --agent workbuddy --mode=watch
175
177
  ferrycat-cli worker --agent workbuddy --mode=claim
176
178
  ferrycat-cli worker --agent workbuddy --mode=exec --once --exec-file ./worker-handler.js`,
179
+ daemon: `Usage: ferrycat-cli daemon [--config <path>] [--agent <id>] [--once] [--lease-ttl <sec>] [--interval <ms>]
180
+
181
+ Runs the FerryCat production daemon (ADR-0005). Coordinates background workers using short-transaction
182
+ CAS leases on the daemon_locks table and executes declarative scripts defined in ferrycat.config.json.
183
+
184
+ Options:
185
+ --config <path> Path to ferrycat.config.json (defaults to ./ferrycat.config.json or ~/.ferrycat/config.json)
186
+ --agent <id> Optional target agent filter (overrides config defaults)
187
+ --once Run a single dispatch cycle and exit
188
+ --lease-ttl <sec> Daemon CAS lease TTL in seconds (default: 30)
189
+ --interval <ms> Poll interval in milliseconds (default: 1000)
190
+ --no-notify Disable console notices
191
+
192
+ Examples:
193
+ ferrycat-cli daemon --config ./ferrycat.config.json
194
+ ferrycat-cli daemon --agent workbuddy --once`,
177
195
  };
178
196
  function stripBom(content) {
179
197
  return content.replace(/^\uFEFF/, '');
@@ -384,6 +402,28 @@ function parseArgs(argv) {
384
402
  }
385
403
  flagOverrides.exec_file = scriptPath;
386
404
  }
405
+ else if (token === '--pass-env') {
406
+ const varName = requireFlagValue('--pass-env', rest[++i]).trim();
407
+ if (!varName) {
408
+ throw new Error('Invalid --pass-env value: variable name cannot be empty.');
409
+ }
410
+ const existing = flagOverrides.pass_env ?? [];
411
+ existing.push(varName);
412
+ flagOverrides.pass_env = existing;
413
+ }
414
+ else if (token === '--config' || token === '-c') {
415
+ flagOverrides.config = requireFlagValue(token, rest[++i]);
416
+ }
417
+ else if (token === '--lease-ttl') {
418
+ const ttl = Number(requireFlagValue('--lease-ttl', rest[++i]));
419
+ if (!Number.isInteger(ttl) || ttl < 5) {
420
+ throw new Error('Invalid --lease-ttl: must be an integer >= 5 seconds.');
421
+ }
422
+ flagOverrides.lease_ttl_sec = ttl;
423
+ }
424
+ else if (token === '--no-notify') {
425
+ flagOverrides.notify = false;
426
+ }
387
427
  else if (token === '--format') {
388
428
  const fmt = requireFlagValue('--format', rest[++i]).trim().toLowerCase();
389
429
  if (fmt !== 'table' && fmt !== 'json') {
@@ -461,149 +501,6 @@ function interruptibleSleep(ms, signal) {
461
501
  signal.addEventListener('abort', onAbort, { once: true });
462
502
  });
463
503
  }
464
- function spawnExecTarget(spec, options, callback) {
465
- if (spec.execFile) {
466
- const resolvedScript = path.resolve(expandTilde(spec.execFile));
467
- const ext = path.extname(resolvedScript).toLowerCase();
468
- if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
469
- return execFile(process.execPath, [resolvedScript], options, callback);
470
- }
471
- if (ext === '.ts') {
472
- return execFile(process.execPath, ['--import', 'tsx', resolvedScript], options, callback);
473
- }
474
- if (ext === '.ps1') {
475
- return execFile('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', resolvedScript], options, callback);
476
- }
477
- if (ext === '.sh') {
478
- return execFile('sh', [resolvedScript], options, callback);
479
- }
480
- return execFile(resolvedScript, [], options, callback);
481
- }
482
- return exec(spec.execCmd, options, callback);
483
- }
484
- async function executeTaskAndFulfill(engine, contract, spec) {
485
- return new Promise((resolve) => {
486
- const childEnv = {
487
- ...process.env,
488
- FERRYCAT_CONTRACT_ID: contract.contract_id,
489
- FERRYCAT_TASK_TYPE: contract.task_type,
490
- FERRYCAT_WORKSPACE_ROOT: contract.workspace_root,
491
- FERRYCAT_TARGET_AGENT: contract.target_agent,
492
- FERRYCAT_CLAIM_TOKEN: contract.claim_token ?? '',
493
- };
494
- const timeoutMs = Math.max(10000, contract.exec_ttl_sec * 1000);
495
- const maxBuffer = 10 * 1024 * 1024;
496
- const label = spec.execFile ?? spec.execCmd ?? 'exec';
497
- const child = spawnExecTarget(spec, { env: childEnv, timeout: timeoutMs, maxBuffer }, (err, stdout, stderr) => {
498
- if (err) {
499
- const execErr = err;
500
- const execError = `External exec command failed with code ${execErr.code ?? 1}${execErr.killed ? ' (killed after exec timeout)' : ''}: ${stderr || execErr.message}`;
501
- let contractStatus;
502
- try {
503
- contractStatus = engine.failContract({
504
- contract_id: contract.contract_id,
505
- agent_id: contract.target_agent,
506
- claim_token: contract.claim_token,
507
- reason: execError.slice(0, 2000),
508
- }).status;
509
- }
510
- catch (failErr) {
511
- contractStatus = `UNCHANGED (${failErr instanceof Error ? failErr.message : String(failErr)})`;
512
- }
513
- return resolve({
514
- ok: false,
515
- status: 'EXEC_FAILED',
516
- error: execError,
517
- contract_status: contractStatus,
518
- });
519
- }
520
- let structuredOutput;
521
- let resultSummary = stripBom(stdout).trim();
522
- try {
523
- const parsed = JSON.parse(resultSummary);
524
- if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
525
- if (parsed.structured_output && typeof parsed.structured_output === 'object') {
526
- structuredOutput = parsed.structured_output;
527
- }
528
- else {
529
- structuredOutput = parsed;
530
- }
531
- if (parsed.result_summary && typeof parsed.result_summary === 'string') {
532
- resultSummary = parsed.result_summary;
533
- }
534
- }
535
- }
536
- catch {
537
- // Plain text stdout, fallback to result_summary
538
- }
539
- const boundedSummary = (resultSummary || `Completed via automated worker exec: ${label}`).slice(0, 20000);
540
- try {
541
- const validatedFulfillInput = FulfillContractSchema.parse({
542
- contract_id: contract.contract_id,
543
- agent_id: contract.target_agent,
544
- claim_token: contract.claim_token,
545
- result_summary: boundedSummary,
546
- structured_output: structuredOutput,
547
- });
548
- let fulfillRes = engine.fulfillContract(validatedFulfillInput);
549
- // If RETRY_NEEDED occurs in automated exec (schema retry vs TOCTOU strike 1):
550
- if (fulfillRes.status === 'RETRY_NEEDED') {
551
- // RETRY_NEEDED has two distinct causes with incompatible retry semantics:
552
- // (a) schema validation failure -> retry_count increments, so retrying the
553
- // same payload until the breaker trips is the intended behavior.
554
- // (b) TOCTOU dirty pointers -> retry_count does NOT move. The on-disk
555
- // baseline cannot heal between two consecutive calls, so retrying only
556
- // burns the single ADR-0004 strike and forces a terminal POINTER_STALE
557
- // with nobody in the loop to restore the files. Never auto-retry it.
558
- const isToctouRejection = (fulfillRes.toctou_strike ?? 0) > 0 && (fulfillRes.retry_count ?? 0) === 0;
559
- if (isToctouRejection) {
560
- resolve({
561
- ok: false,
562
- status: 'TOCTOU_RESCUE_REQUIRED',
563
- fulfill_result: fulfillRes,
564
- error: 'Automated exec mutated pointer files. The contract stays CLAIMED with a renewed execution window. Restore the files (git stash / git checkout) and re-run, or deliver edits via structured_output.fixes instead of writing to the workspace.',
565
- });
566
- return;
567
- }
568
- const maxAttempts = (fulfillRes.max_retries ?? 2) + 1;
569
- for (let i = fulfillRes.retry_count ?? 1; i < maxAttempts; i++) {
570
- fulfillRes = engine.fulfillContract(validatedFulfillInput);
571
- if (fulfillRes.status !== 'RETRY_NEEDED')
572
- break;
573
- }
574
- }
575
- resolve({ ok: fulfillRes.ok, status: fulfillRes.status, fulfill_result: fulfillRes });
576
- }
577
- catch (fulfillErr) {
578
- const fulfillError = fulfillErr instanceof Error ? fulfillErr.message : String(fulfillErr);
579
- let contractStatus;
580
- try {
581
- contractStatus = engine.failContract({
582
- contract_id: contract.contract_id,
583
- agent_id: contract.target_agent,
584
- reason: `FULFILL_ERROR: ${fulfillError}`.slice(0, 2000),
585
- }).status;
586
- }
587
- catch {
588
- contractStatus = undefined;
589
- }
590
- resolve({
591
- ok: false,
592
- status: 'FULFILL_ERROR',
593
- error: fulfillError,
594
- contract_status: contractStatus,
595
- });
596
- }
597
- });
598
- if (child.stdin) {
599
- child.stdin.on('error', () => {
600
- // Child process exited without consuming stdin, ignore EPIPE
601
- });
602
- child.stdin.write(JSON.stringify(contract));
603
- child.stdin.end();
604
- }
605
- });
606
- }
607
504
  async function runWorkerLoop(engine, agentId, intervalMs, spec) {
608
505
  const resolvedAgent = resolveWorkerIdentity(agentId);
609
506
  const abortController = new AbortController();
@@ -625,9 +522,10 @@ async function runWorkerLoop(engine, agentId, intervalMs, spec) {
625
522
  const stopHandler = () => {
626
523
  sigintCount++;
627
524
  if (sigintCount >= 2) {
525
+ terminateActiveChildrenSync();
628
526
  console.error(JSON.stringify({
629
527
  status: 'WORKER_FORCE_EXIT',
630
- message: 'Second interrupt received, exiting immediately.',
528
+ message: 'Second interrupt received, terminated active child process trees and exiting immediately.',
631
529
  }));
632
530
  process.exit(130);
633
531
  }
@@ -957,6 +855,7 @@ async function main() {
957
855
  const execSpec = {
958
856
  execCmd: validated.exec,
959
857
  execFile: validated.exec_file,
858
+ passEnv: validated.pass_env,
960
859
  };
961
860
  engine = new FerryCatEngine();
962
861
  if (effectiveOnce) {
@@ -996,8 +895,36 @@ async function main() {
996
895
  }
997
896
  break;
998
897
  }
898
+ case 'daemon': {
899
+ const configPath = data.config;
900
+ const targetAgent = data.target_agent ?? data.agent_id;
901
+ const once = Boolean(data.once);
902
+ const leaseTtl = data.lease_ttl_sec ? Number(data.lease_ttl_sec) : undefined;
903
+ const intervalMs = data.interval_ms ? Number(data.interval_ms) : undefined;
904
+ const notify = data.notify !== false;
905
+ const daemon = new FerryCatDaemon({
906
+ configPath,
907
+ targetAgent,
908
+ leaseTtlSec: leaseTtl,
909
+ pollIntervalMs: intervalMs,
910
+ notify,
911
+ });
912
+ if (once) {
913
+ const res = await daemon.pollAndDispatchOnce();
914
+ daemon.releaseLease();
915
+ daemon.engine.close();
916
+ output = { ok: true, daemon_once: res };
917
+ console.log(JSON.stringify(output, null, 2));
918
+ }
919
+ else {
920
+ await daemon.start();
921
+ // Keep active process alive until signal
922
+ await new Promise(() => { });
923
+ }
924
+ break;
925
+ }
999
926
  default:
1000
- throw new Error(`Unknown subcommand "${command}". Supported commands: dispatch, poll, verify, fulfill, release, extend, fail, inspect, worker. Run "ferrycat-cli --help" for usage.`);
927
+ throw new Error(`Unknown subcommand "${command}". Supported commands: dispatch, poll, verify, fulfill, release, extend, fail, inspect, worker, daemon. Run "ferrycat-cli --help" for usage.`);
1001
928
  }
1002
929
  }
1003
930
  catch (err) {