clawspec 1.0.1 → 1.0.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 +19 -38
- package/README.zh-CN.md +18 -38
- package/package.json +2 -2
- package/src/acp/client.ts +171 -5
- package/src/acp/openclaw-config.ts +66 -0
- package/src/config.ts +4 -5
- package/src/dependencies/acpx.ts +76 -2
- package/src/index.ts +4 -2
- package/src/orchestrator/service.ts +71 -29
- package/src/utils/paths.ts +2 -0
- package/src/watchers/manager.ts +8 -6
- package/src/worker/io-helper.ts +126 -0
- package/src/worker/prompts.ts +11 -1
- package/test/acp-client.test.ts +78 -3
- package/test/acpx-dependency.test.ts +21 -0
- package/test/config.test.ts +2 -2
- package/test/file-lock.test.ts +13 -3
- package/test/helpers/harness.ts +27 -3
- package/test/queue-work.test.ts +52 -0
- package/test/service-archive.test.ts +7 -2
- package/test/watcher-work.test.ts +3 -3
- package/test/worker-io-helper.test.ts +97 -0
package/README.md
CHANGED
|
@@ -236,7 +236,7 @@ Current OpenClaw builds do not accept raw GitHub URLs as ordinary plugin install
|
|
|
236
236
|
|
|
237
237
|
If you want an unreleased commit before npm publish, clone the repository and install from the local checkout or a downloaded `.tgz` archive instead.
|
|
238
238
|
|
|
239
|
-
### 2. Enable ACP
|
|
239
|
+
### 2. Enable ACP in OpenClaw
|
|
240
240
|
|
|
241
241
|
Example `~/.openclaw/openclaw.json`:
|
|
242
242
|
|
|
@@ -249,18 +249,10 @@ Example `~/.openclaw/openclaw.json`:
|
|
|
249
249
|
},
|
|
250
250
|
"plugins": {
|
|
251
251
|
"entries": {
|
|
252
|
-
"acpx": {
|
|
253
|
-
"enabled": true,
|
|
254
|
-
"config": {
|
|
255
|
-
"permissionMode": "approve-all",
|
|
256
|
-
"expectedVersion": "any"
|
|
257
|
-
}
|
|
258
|
-
},
|
|
259
252
|
"clawspec": {
|
|
260
253
|
"enabled": true,
|
|
261
254
|
"config": {
|
|
262
255
|
"defaultWorkspace": "~/clawspec/workspace",
|
|
263
|
-
"workerAgentId": "codex",
|
|
264
256
|
"openSpecTimeoutMs": 120000,
|
|
265
257
|
"watcherPollIntervalMs": 4000
|
|
266
258
|
}
|
|
@@ -273,21 +265,21 @@ Example `~/.openclaw/openclaw.json`:
|
|
|
273
265
|
Important notes:
|
|
274
266
|
|
|
275
267
|
- Recent OpenClaw builds often bundle `acpx` under the host install. ClawSpec checks that builtin copy before falling back to `acpx` on `PATH` or a plugin-local install.
|
|
276
|
-
- If your OpenClaw build does not bundle `acpx`,
|
|
277
|
-
- ClawSpec itself
|
|
278
|
-
-
|
|
279
|
-
-
|
|
280
|
-
-
|
|
281
|
-
- In other words, ClawSpec does not currently inherit `acp.defaultAgent` automatically. If you want both to use the same agent, set both values explicitly.
|
|
268
|
+
- If your OpenClaw build does not bundle `acpx`, ClawSpec can fall back to a plugin-local install automatically.
|
|
269
|
+
- ClawSpec manages the `acpx` command path itself. You do not need to hardcode `plugins.entries.acpx.config.command`.
|
|
270
|
+
- `acp.defaultAgent` is now the global default used by ClawSpec background workers.
|
|
271
|
+
- `/clawspec worker <agent-id>` is still available as an explicit channel/project override.
|
|
272
|
+
- When ACP setup is incomplete, ClawSpec now shows ready-to-run commands such as `openclaw config set acp.backend acpx` and `openclaw config set acp.defaultAgent codex`.
|
|
282
273
|
|
|
283
274
|
### 2.5. Choose the default worker agent
|
|
284
275
|
|
|
285
|
-
ClawSpec can run background work with different ACP agents such as `codex` or `claude
|
|
276
|
+
ClawSpec can run background work with different ACP agents such as `codex` or `claude`.
|
|
277
|
+
There is now one global default source plus an optional per-channel override:
|
|
286
278
|
|
|
287
|
-
- `acp.defaultAgent`: the OpenClaw ACP default used by
|
|
288
|
-
-
|
|
279
|
+
- `acp.defaultAgent`: the OpenClaw ACP default used by ClawSpec workers
|
|
280
|
+
- `/clawspec worker <agent-id>`: an explicit override persisted for the current channel/project context
|
|
289
281
|
|
|
290
|
-
Recommended
|
|
282
|
+
Recommended global setup:
|
|
291
283
|
|
|
292
284
|
```json
|
|
293
285
|
{
|
|
@@ -295,16 +287,6 @@ Recommended if you want both layers aligned:
|
|
|
295
287
|
"enabled": true,
|
|
296
288
|
"backend": "acpx",
|
|
297
289
|
"defaultAgent": "claude"
|
|
298
|
-
},
|
|
299
|
-
"plugins": {
|
|
300
|
-
"entries": {
|
|
301
|
-
"clawspec": {
|
|
302
|
-
"enabled": true,
|
|
303
|
-
"config": {
|
|
304
|
-
"workerAgentId": "claude"
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
290
|
}
|
|
309
291
|
}
|
|
310
292
|
```
|
|
@@ -320,7 +302,7 @@ Notes:
|
|
|
320
302
|
|
|
321
303
|
- `/clawspec worker <agent-id>` is persisted at the current channel/project scope
|
|
322
304
|
- the chosen agent must exist in your OpenClaw agent list or ACP allowlist
|
|
323
|
-
- if you want a global default, set `
|
|
305
|
+
- if you want a global default, set `acp.defaultAgent` in OpenClaw config
|
|
324
306
|
- if you want a one-off override for the active project conversation, use `/clawspec worker <agent-id>`
|
|
325
307
|
|
|
326
308
|
### 2.6. ClawSpec plugin config reference
|
|
@@ -330,7 +312,6 @@ Common `plugins.entries.clawspec.config` fields:
|
|
|
330
312
|
| Key | Purpose | Notes |
|
|
331
313
|
| --- | --- | --- |
|
|
332
314
|
| `defaultWorkspace` | Default base directory used by `/clawspec workspace` and `/clawspec use` | Channel-specific workspace selection overrides this after first use |
|
|
333
|
-
| `workerAgentId` | Default ACP agent used by background workers | Can be overridden per channel/project with `/clawspec worker <agent-id>` |
|
|
334
315
|
| `openSpecTimeoutMs` | Timeout for each OpenSpec CLI invocation | Increase this if your repo or host is slow |
|
|
335
316
|
| `watcherPollIntervalMs` | Background watcher recovery poll interval | Controls how quickly recovery scans and replay checks run |
|
|
336
317
|
| `archiveDirName` | Directory name under `.openclaw/clawspec/` for archived bundles | Keep the default unless you need a different archive layout |
|
|
@@ -340,6 +321,7 @@ Backward-compatibility keys still accepted but currently treated as no-ops:
|
|
|
340
321
|
|
|
341
322
|
- `maxAutoContinueTurns`
|
|
342
323
|
- `maxNoProgressTurns`
|
|
324
|
+
- `workerAgentId`
|
|
343
325
|
- `workerBackendId`
|
|
344
326
|
- `workerWaitTimeoutMs`
|
|
345
327
|
- `subagentLane`
|
|
@@ -823,16 +805,15 @@ Cause:
|
|
|
823
805
|
|
|
824
806
|
- `acp.enabled` is false
|
|
825
807
|
- `acp.backend` is not `acpx`
|
|
826
|
-
- `
|
|
827
|
-
- your OpenClaw build does not bundle `acpx` and
|
|
808
|
+
- `acp.defaultAgent` is missing
|
|
809
|
+
- your OpenClaw build does not bundle `acpx` and ClawSpec could not find or install a usable copy
|
|
828
810
|
|
|
829
811
|
What to do:
|
|
830
812
|
|
|
831
|
-
1.
|
|
832
|
-
2. set
|
|
833
|
-
3.
|
|
834
|
-
4.
|
|
835
|
-
5. rerun `cs-work` or `/clawspec continue`
|
|
813
|
+
1. run `openclaw config set acp.backend acpx`
|
|
814
|
+
2. run `openclaw config set acp.defaultAgent codex`
|
|
815
|
+
3. if your host does not bundle ACPX, let ClawSpec install a plugin-local copy or install `acpx` manually
|
|
816
|
+
4. rerun `cs-work` or `/clawspec continue`
|
|
836
817
|
|
|
837
818
|
### Ordinary chat is polluting the planning journal
|
|
838
819
|
|
package/README.zh-CN.md
CHANGED
|
@@ -241,7 +241,7 @@ openclaw plugins install clawspec@latest
|
|
|
241
241
|
|
|
242
242
|
如果你要安装一个还没发布到 npm 的提交,请改用本地 checkout 或下载好的 `.tgz` 包安装。
|
|
243
243
|
|
|
244
|
-
### 2. 在 OpenClaw 里启用 ACP
|
|
244
|
+
### 2. 在 OpenClaw 里启用 ACP
|
|
245
245
|
|
|
246
246
|
示例 `~/.openclaw/openclaw.json`:
|
|
247
247
|
|
|
@@ -254,18 +254,10 @@ openclaw plugins install clawspec@latest
|
|
|
254
254
|
},
|
|
255
255
|
"plugins": {
|
|
256
256
|
"entries": {
|
|
257
|
-
"acpx": {
|
|
258
|
-
"enabled": true,
|
|
259
|
-
"config": {
|
|
260
|
-
"permissionMode": "approve-all",
|
|
261
|
-
"expectedVersion": "any"
|
|
262
|
-
}
|
|
263
|
-
},
|
|
264
257
|
"clawspec": {
|
|
265
258
|
"enabled": true,
|
|
266
259
|
"config": {
|
|
267
260
|
"defaultWorkspace": "~/clawspec/workspace",
|
|
268
|
-
"workerAgentId": "codex",
|
|
269
261
|
"openSpecTimeoutMs": 120000,
|
|
270
262
|
"watcherPollIntervalMs": 4000
|
|
271
263
|
}
|
|
@@ -278,21 +270,20 @@ openclaw plugins install clawspec@latest
|
|
|
278
270
|
这里要注意:
|
|
279
271
|
|
|
280
272
|
- 新版 OpenClaw 往往已经自带 `acpx`,ClawSpec 现在会先检查这份 builtin ACPX,再回退到系统 `PATH` 或插件本地安装。
|
|
281
|
-
- 如果你的 OpenClaw 没有自带 `acpx
|
|
282
|
-
-
|
|
283
|
-
-
|
|
284
|
-
-
|
|
285
|
-
-
|
|
286
|
-
- 也就是说,ClawSpec 现在不会自动继承 `acp.defaultAgent`。如果你希望两边都用同一个 agent,需要把两处都显式配成一样。
|
|
273
|
+
- 如果你的 OpenClaw 没有自带 `acpx`,ClawSpec 会优先尝试自动落一份插件本地可用的 `acpx`。
|
|
274
|
+
- `acpx` 的命令路径现在由 ClawSpec 自己管理,不需要你再手工写 `plugins.entries.acpx.config.command`。
|
|
275
|
+
- `acp.defaultAgent` 现在就是 ClawSpec 后台 worker 的全局默认 agent。
|
|
276
|
+
- `/clawspec worker <agent-id>` 仍然可以作为当前 channel/project 的显式覆盖。
|
|
277
|
+
- 如果 ACP 配置不完整,ClawSpec 会直接提示可执行命令,比如 `openclaw config set acp.backend acpx` 和 `openclaw config set acp.defaultAgent codex`。
|
|
287
278
|
|
|
288
279
|
### 2.5. 选择默认 worker agent
|
|
289
280
|
|
|
290
|
-
ClawSpec 可以让后台任务跑在不同的 ACP agent 上,比如 `codex` 或 `claude
|
|
281
|
+
ClawSpec 可以让后台任务跑在不同的 ACP agent 上,比如 `codex` 或 `claude`。现在默认值来源只有一层,再加一个可选覆盖:
|
|
291
282
|
|
|
292
|
-
- `acp.defaultAgent`:
|
|
293
|
-
-
|
|
283
|
+
- `acp.defaultAgent`:ClawSpec 后台 worker 读取的全局默认 ACP agent
|
|
284
|
+
- `/clawspec worker <agent-id>`:当前 channel/project 的显式覆盖
|
|
294
285
|
|
|
295
|
-
|
|
286
|
+
推荐的全局配置:
|
|
296
287
|
|
|
297
288
|
```json
|
|
298
289
|
{
|
|
@@ -300,16 +291,6 @@ ClawSpec 可以让后台任务跑在不同的 ACP agent 上,比如 `codex` 或
|
|
|
300
291
|
"enabled": true,
|
|
301
292
|
"backend": "acpx",
|
|
302
293
|
"defaultAgent": "claude"
|
|
303
|
-
},
|
|
304
|
-
"plugins": {
|
|
305
|
-
"entries": {
|
|
306
|
-
"clawspec": {
|
|
307
|
-
"enabled": true,
|
|
308
|
-
"config": {
|
|
309
|
-
"workerAgentId": "claude"
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
294
|
}
|
|
314
295
|
}
|
|
315
296
|
```
|
|
@@ -325,7 +306,7 @@ ClawSpec 可以让后台任务跑在不同的 ACP agent 上,比如 `codex` 或
|
|
|
325
306
|
|
|
326
307
|
- `/clawspec worker <agent-id>` 的覆盖范围是当前 channel/project
|
|
327
308
|
- 你填写的 agent id 必须已经存在于 OpenClaw 的 agent 列表或 ACP allowlist 中
|
|
328
|
-
- 如果你想配置“全局默认”,改的是 `
|
|
309
|
+
- 如果你想配置“全局默认”,改的是 `acp.defaultAgent`
|
|
329
310
|
- 如果你想临时对当前项目改用另一个 agent,就直接执行 `/clawspec worker <agent-id>`
|
|
330
311
|
|
|
331
312
|
### 2.6. ClawSpec 插件配置项参考
|
|
@@ -335,7 +316,6 @@ ClawSpec 可以让后台任务跑在不同的 ACP agent 上,比如 `codex` 或
|
|
|
335
316
|
| Key | 用途 | 说明 |
|
|
336
317
|
| --- | --- | --- |
|
|
337
318
|
| `defaultWorkspace` | `/clawspec workspace` 和 `/clawspec use` 的默认 workspace | 某个 channel 第一次选定 workspace 后,会优先使用该 channel 自己记住的值 |
|
|
338
|
-
| `workerAgentId` | 后台 worker 默认使用的 ACP agent | 可以被 `/clawspec worker <agent-id>` 在当前 channel/project 覆盖 |
|
|
339
319
|
| `openSpecTimeoutMs` | 每次 OpenSpec CLI 调用的超时时间 | repo 较大或主机较慢时可以调大 |
|
|
340
320
|
| `watcherPollIntervalMs` | watcher 的后台恢复扫描周期 | 影响恢复检查和进度补发的灵敏度 |
|
|
341
321
|
| `archiveDirName` | `.openclaw/clawspec/` 下归档目录名称 | 除非你要调整归档布局,否则保持默认即可 |
|
|
@@ -345,6 +325,7 @@ ClawSpec 可以让后台任务跑在不同的 ACP agent 上,比如 `codex` 或
|
|
|
345
325
|
|
|
346
326
|
- `maxAutoContinueTurns`
|
|
347
327
|
- `maxNoProgressTurns`
|
|
328
|
+
- `workerAgentId`
|
|
348
329
|
- `workerBackendId`
|
|
349
330
|
- `workerWaitTimeoutMs`
|
|
350
331
|
- `subagentLane`
|
|
@@ -835,16 +816,15 @@ OpenSpec change 本身仍然放在标准目录:
|
|
|
835
816
|
|
|
836
817
|
- `acp.enabled` 没开
|
|
837
818
|
- `acp.backend` 不是 `acpx`
|
|
838
|
-
- `
|
|
839
|
-
- 你的 OpenClaw 没有自带 ACPX
|
|
819
|
+
- `acp.defaultAgent` 没配
|
|
820
|
+
- 你的 OpenClaw 没有自带 ACPX,且 ClawSpec 也没能找到或安装可用副本
|
|
840
821
|
|
|
841
822
|
处理方式:
|
|
842
823
|
|
|
843
|
-
1.
|
|
844
|
-
2.
|
|
845
|
-
3.
|
|
846
|
-
4.
|
|
847
|
-
5. 再运行 `cs-work` 或 `/clawspec continue`
|
|
824
|
+
1. 运行 `openclaw config set acp.backend acpx`
|
|
825
|
+
2. 运行 `openclaw config set acp.defaultAgent codex`
|
|
826
|
+
3. 如果宿主没自带 ACPX,就让 ClawSpec 自动安装插件本地副本,或手工安装 `acpx`
|
|
827
|
+
4. 再运行 `cs-work` 或 `/clawspec continue`
|
|
848
828
|
|
|
849
829
|
### 普通聊天污染了 planning journal
|
|
850
830
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawspec",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw plugin that orchestrates OpenSpec project workflows with visible main-agent execution.",
|
|
6
6
|
"keywords": [
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"check": "node --experimental-strip-types -e \"import('./index.ts')\"",
|
|
45
45
|
"test": "node --experimental-strip-types --test --test-reporter spec test/*.test.ts",
|
|
46
|
-
"test:fast": "node --experimental-strip-types --test --test-reporter spec test/acp-client.test.ts test/acpx-dependency.test.ts test/assistant-journal.test.ts test/command-surface.test.ts test/config.test.ts test/detach-attach.test.ts test/file-lock.test.ts test/fs-utils.test.ts test/helpers.test.ts test/keywords.test.ts test/notifier.test.ts test/openspec-dependency.test.ts test/pause-cancel.test.ts test/planning-journal.test.ts test/plugin-registration.test.ts test/project-memory.test.ts test/proposal.test.ts test/queue-planning.test.ts test/queue-work.test.ts test/service-archive.test.ts test/shell-command.test.ts test/state-store.test.ts test/tasks-and-checkpoint.test.ts test/use-project.test.ts test/worker-command.test.ts test/worker-skills.test.ts",
|
|
46
|
+
"test:fast": "node --experimental-strip-types --test --test-reporter spec test/acp-client.test.ts test/acpx-dependency.test.ts test/assistant-journal.test.ts test/command-surface.test.ts test/config.test.ts test/detach-attach.test.ts test/file-lock.test.ts test/fs-utils.test.ts test/helpers.test.ts test/keywords.test.ts test/notifier.test.ts test/openspec-dependency.test.ts test/pause-cancel.test.ts test/planning-journal.test.ts test/plugin-registration.test.ts test/project-memory.test.ts test/proposal.test.ts test/queue-planning.test.ts test/queue-work.test.ts test/service-archive.test.ts test/shell-command.test.ts test/state-store.test.ts test/tasks-and-checkpoint.test.ts test/use-project.test.ts test/worker-command.test.ts test/worker-io-helper.test.ts test/worker-skills.test.ts",
|
|
47
47
|
"test:slow": "node --experimental-strip-types --test --test-reporter spec test/watcher-planning.test.ts test/watcher-work.test.ts test/recovery.test.ts"
|
|
48
48
|
},
|
|
49
49
|
"engines": {
|
package/src/acp/client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
1
2
|
import { createInterface } from "node:readline";
|
|
2
|
-
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
3
3
|
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
4
4
|
import { runShellCommand, spawnShellCommand, terminateChildProcess } from "../utils/shell-command.ts";
|
|
5
5
|
|
|
@@ -54,6 +54,8 @@ type AcpWorkerClientOptions = {
|
|
|
54
54
|
env?: NodeJS.ProcessEnv;
|
|
55
55
|
permissionMode?: "approve-all" | "approve-reads" | "deny-all";
|
|
56
56
|
queueOwnerTtlSeconds?: number;
|
|
57
|
+
gatewayPid?: number;
|
|
58
|
+
gatewayWatchdogPollMs?: number;
|
|
57
59
|
};
|
|
58
60
|
|
|
59
61
|
type EnsureSessionParams = {
|
|
@@ -81,6 +83,7 @@ type SessionDescriptor = {
|
|
|
81
83
|
type ActiveSessionProcess = {
|
|
82
84
|
sessionKey: string;
|
|
83
85
|
child: ChildProcessWithoutNullStreams;
|
|
86
|
+
watchdog?: ChildProcess;
|
|
84
87
|
cwd: string;
|
|
85
88
|
agentId: string;
|
|
86
89
|
startedAt: string;
|
|
@@ -93,6 +96,7 @@ type SessionExitState = {
|
|
|
93
96
|
|
|
94
97
|
const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 30;
|
|
95
98
|
const DEFAULT_PERMISSION_MODE = "approve-all";
|
|
99
|
+
const DEFAULT_GATEWAY_WATCHDOG_POLL_MS = 1_000;
|
|
96
100
|
|
|
97
101
|
export class AcpWorkerClient {
|
|
98
102
|
readonly agentId: string;
|
|
@@ -101,6 +105,8 @@ export class AcpWorkerClient {
|
|
|
101
105
|
readonly env?: NodeJS.ProcessEnv;
|
|
102
106
|
readonly permissionMode: "approve-all" | "approve-reads" | "deny-all";
|
|
103
107
|
readonly queueOwnerTtlSeconds: number;
|
|
108
|
+
readonly gatewayPid: number;
|
|
109
|
+
readonly gatewayWatchdogPollMs: number;
|
|
104
110
|
readonly handles = new Map<string, AcpWorkerHandle>();
|
|
105
111
|
readonly sessionDescriptors = new Map<string, SessionDescriptor>();
|
|
106
112
|
readonly activeProcesses = new Map<string, ActiveSessionProcess>();
|
|
@@ -113,6 +119,8 @@ export class AcpWorkerClient {
|
|
|
113
119
|
this.env = options.env;
|
|
114
120
|
this.permissionMode = options.permissionMode ?? DEFAULT_PERMISSION_MODE;
|
|
115
121
|
this.queueOwnerTtlSeconds = options.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS;
|
|
122
|
+
this.gatewayPid = normalizePid(options.gatewayPid) ?? process.pid;
|
|
123
|
+
this.gatewayWatchdogPollMs = normalizeWatchdogPollMs(options.gatewayWatchdogPollMs);
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
async ensureSession(params: EnsureSessionParams): Promise<{
|
|
@@ -183,9 +191,11 @@ export class AcpWorkerClient {
|
|
|
183
191
|
child.stdin.end(params.text);
|
|
184
192
|
|
|
185
193
|
const startedAt = new Date().toISOString();
|
|
194
|
+
const watchdog = this.startGatewayWatchdog(params.sessionKey, child);
|
|
186
195
|
this.activeProcesses.set(params.sessionKey, {
|
|
187
196
|
sessionKey: params.sessionKey,
|
|
188
197
|
child,
|
|
198
|
+
watchdog,
|
|
189
199
|
cwd: descriptor.cwd,
|
|
190
200
|
agentId: descriptor.agentId,
|
|
191
201
|
startedAt,
|
|
@@ -243,14 +253,21 @@ export class AcpWorkerClient {
|
|
|
243
253
|
if (exit.error) {
|
|
244
254
|
throw exit.error;
|
|
245
255
|
}
|
|
256
|
+
if (exit.signal && !sawError) {
|
|
257
|
+
throw new Error(formatAcpxExitMessage(stderr, exit.code, exit.signal));
|
|
258
|
+
}
|
|
246
259
|
if ((exit.code ?? 0) !== 0 && !sawError) {
|
|
247
|
-
throw new Error(formatAcpxExitMessage(stderr, exit.code));
|
|
260
|
+
throw new Error(formatAcpxExitMessage(stderr, exit.code, exit.signal));
|
|
248
261
|
}
|
|
249
262
|
if (!sawDone && !sawError) {
|
|
250
263
|
await params.onEvent?.({ type: "done" });
|
|
251
264
|
}
|
|
252
265
|
return ensured;
|
|
253
266
|
} finally {
|
|
267
|
+
const active = this.activeProcesses.get(params.sessionKey);
|
|
268
|
+
if (active?.watchdog) {
|
|
269
|
+
safeKill(active.watchdog);
|
|
270
|
+
}
|
|
254
271
|
this.activeProcesses.delete(params.sessionKey);
|
|
255
272
|
lines.close();
|
|
256
273
|
if (params.signal) {
|
|
@@ -360,6 +377,7 @@ export class AcpWorkerClient {
|
|
|
360
377
|
|
|
361
378
|
const active = this.activeProcesses.get(sessionKey);
|
|
362
379
|
if (active) {
|
|
380
|
+
safeKill(active.watchdog);
|
|
363
381
|
safeKill(active.child);
|
|
364
382
|
this.activeProcesses.delete(sessionKey);
|
|
365
383
|
this.recordSessionExit(sessionKey, active, active.child.pid, null, "SIGTERM", reason);
|
|
@@ -385,6 +403,7 @@ export class AcpWorkerClient {
|
|
|
385
403
|
|
|
386
404
|
const active = this.activeProcesses.get(sessionKey);
|
|
387
405
|
if (active) {
|
|
406
|
+
safeKill(active.watchdog);
|
|
388
407
|
safeKill(active.child);
|
|
389
408
|
this.activeProcesses.delete(sessionKey);
|
|
390
409
|
this.recordSessionExit(sessionKey, active, active.child.pid, null, "SIGTERM", reason);
|
|
@@ -513,6 +532,40 @@ export class AcpWorkerClient {
|
|
|
513
532
|
`[clawspec] acpx worker exited: session=${sessionKey} pid=${pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`,
|
|
514
533
|
);
|
|
515
534
|
}
|
|
535
|
+
|
|
536
|
+
private startGatewayWatchdog(
|
|
537
|
+
sessionKey: string,
|
|
538
|
+
child: ChildProcessWithoutNullStreams,
|
|
539
|
+
): ChildProcess | undefined {
|
|
540
|
+
const workerPid = normalizePid(child.pid);
|
|
541
|
+
if (!workerPid) {
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const watchdog = spawn(process.execPath, [
|
|
547
|
+
"-e",
|
|
548
|
+
GATEWAY_WATCHDOG_SOURCE,
|
|
549
|
+
String(this.gatewayPid),
|
|
550
|
+
String(workerPid),
|
|
551
|
+
String(this.gatewayWatchdogPollMs),
|
|
552
|
+
], {
|
|
553
|
+
stdio: "ignore",
|
|
554
|
+
windowsHide: true,
|
|
555
|
+
detached: true,
|
|
556
|
+
});
|
|
557
|
+
watchdog.unref();
|
|
558
|
+
this.logger.debug?.(
|
|
559
|
+
`[clawspec] gateway watchdog armed: session=${sessionKey} gatewayPid=${this.gatewayPid} workerPid=${workerPid}`,
|
|
560
|
+
);
|
|
561
|
+
return watchdog;
|
|
562
|
+
} catch (error) {
|
|
563
|
+
this.logger.warn(
|
|
564
|
+
`[clawspec] failed to start gateway watchdog for ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`,
|
|
565
|
+
);
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
516
569
|
}
|
|
517
570
|
|
|
518
571
|
function parseJsonLines(value: string): Array<Record<string, unknown>> {
|
|
@@ -660,7 +713,10 @@ async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<{
|
|
|
660
713
|
});
|
|
661
714
|
}
|
|
662
715
|
|
|
663
|
-
function safeKill(child:
|
|
716
|
+
function safeKill(child: Pick<ChildProcess, "pid" | "killed" | "kill"> | undefined): void {
|
|
717
|
+
if (!child) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
664
720
|
terminateChildProcess(child);
|
|
665
721
|
}
|
|
666
722
|
|
|
@@ -674,9 +730,19 @@ function buildPermissionArgs(mode: "approve-all" | "approve-reads" | "deny-all")
|
|
|
674
730
|
return ["--approve-all"];
|
|
675
731
|
}
|
|
676
732
|
|
|
677
|
-
function formatAcpxExitMessage(
|
|
733
|
+
function formatAcpxExitMessage(
|
|
734
|
+
stderr: string,
|
|
735
|
+
exitCode: number | null | undefined,
|
|
736
|
+
signal?: NodeJS.Signals | null,
|
|
737
|
+
): string {
|
|
678
738
|
const detail = stderr.trim();
|
|
679
|
-
|
|
739
|
+
if (detail) {
|
|
740
|
+
return detail;
|
|
741
|
+
}
|
|
742
|
+
if (signal) {
|
|
743
|
+
return `acpx terminated by signal ${signal}`;
|
|
744
|
+
}
|
|
745
|
+
return `acpx exited with code ${exitCode ?? "unknown"}`;
|
|
680
746
|
}
|
|
681
747
|
|
|
682
748
|
function asTrimmedString(value: unknown): string {
|
|
@@ -691,3 +757,103 @@ function asOptionalString(value: unknown): string | undefined {
|
|
|
691
757
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
692
758
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
693
759
|
}
|
|
760
|
+
|
|
761
|
+
function normalizePid(value: number | undefined): number | undefined {
|
|
762
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
763
|
+
? Math.trunc(value)
|
|
764
|
+
: undefined;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function normalizeWatchdogPollMs(value: number | undefined): number {
|
|
768
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 50) {
|
|
769
|
+
return DEFAULT_GATEWAY_WATCHDOG_POLL_MS;
|
|
770
|
+
}
|
|
771
|
+
return Math.trunc(value);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const GATEWAY_WATCHDOG_SOURCE = String.raw`
|
|
775
|
+
const { spawn } = require("node:child_process");
|
|
776
|
+
|
|
777
|
+
const gatewayPid = Number(process.argv[1]);
|
|
778
|
+
const workerPid = Number(process.argv[2]);
|
|
779
|
+
const pollMs = Number(process.argv[3]);
|
|
780
|
+
|
|
781
|
+
function normalizePid(value) {
|
|
782
|
+
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : undefined;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function isAlive(pid) {
|
|
786
|
+
const normalized = normalizePid(pid);
|
|
787
|
+
if (!normalized) {
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
try {
|
|
791
|
+
process.kill(normalized, 0);
|
|
792
|
+
return true;
|
|
793
|
+
} catch (error) {
|
|
794
|
+
const code = error && typeof error === "object" ? error.code : undefined;
|
|
795
|
+
return code === "EPERM";
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function killWorkerTree(pid) {
|
|
800
|
+
const normalized = normalizePid(pid);
|
|
801
|
+
if (!normalized) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (process.platform === "win32") {
|
|
805
|
+
try {
|
|
806
|
+
const killer = spawn("taskkill", ["/PID", String(normalized), "/T", "/F"], {
|
|
807
|
+
stdio: "ignore",
|
|
808
|
+
windowsHide: true,
|
|
809
|
+
detached: true,
|
|
810
|
+
});
|
|
811
|
+
killer.unref();
|
|
812
|
+
} catch {}
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
try {
|
|
816
|
+
process.kill(-normalized, "SIGTERM");
|
|
817
|
+
} catch {
|
|
818
|
+
try {
|
|
819
|
+
process.kill(normalized, "SIGTERM");
|
|
820
|
+
} catch {}
|
|
821
|
+
}
|
|
822
|
+
const escalator = setTimeout(() => {
|
|
823
|
+
try {
|
|
824
|
+
process.kill(-normalized, "SIGKILL");
|
|
825
|
+
} catch {
|
|
826
|
+
try {
|
|
827
|
+
process.kill(normalized, "SIGKILL");
|
|
828
|
+
} catch {}
|
|
829
|
+
}
|
|
830
|
+
}, 1000);
|
|
831
|
+
escalator.unref?.();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const safeGatewayPid = normalizePid(gatewayPid);
|
|
835
|
+
const safeWorkerPid = normalizePid(workerPid);
|
|
836
|
+
const safePollMs = Number.isFinite(pollMs) && pollMs >= 50 ? Math.trunc(pollMs) : 1000;
|
|
837
|
+
|
|
838
|
+
if (!safeGatewayPid || !safeWorkerPid) {
|
|
839
|
+
process.exit(0);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (!isAlive(safeWorkerPid)) {
|
|
843
|
+
process.exit(0);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const timer = setInterval(() => {
|
|
847
|
+
if (!isAlive(safeWorkerPid)) {
|
|
848
|
+
clearInterval(timer);
|
|
849
|
+
process.exit(0);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (!isAlive(safeGatewayPid)) {
|
|
853
|
+
clearInterval(timer);
|
|
854
|
+
killWorkerTree(safeWorkerPid);
|
|
855
|
+
const exitTimer = setTimeout(() => process.exit(0), 250);
|
|
856
|
+
exitTimer.unref?.();
|
|
857
|
+
}
|
|
858
|
+
}, safePollMs);
|
|
859
|
+
`;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
type ConfigLike = OpenClawConfig | Record<string, unknown> | undefined;
|
|
4
|
+
|
|
5
|
+
export function getConfiguredDefaultWorkerAgent(config: ConfigLike): string | undefined {
|
|
6
|
+
const acp = getAcpConfig(config);
|
|
7
|
+
return asOptionalString(acp?.defaultAgent);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function listConfiguredWorkerAgents(config: ConfigLike): string[] {
|
|
11
|
+
const acp = getAcpConfig(config);
|
|
12
|
+
const allowed = Array.isArray(acp?.allowedAgents)
|
|
13
|
+
? acp.allowedAgents.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
14
|
+
: [];
|
|
15
|
+
const agentsConfig = getAgentsConfig(config);
|
|
16
|
+
const listed = Array.isArray(agentsConfig?.list)
|
|
17
|
+
? agentsConfig.list
|
|
18
|
+
.map((entry) => (entry && typeof entry === "object" ? (entry as Record<string, unknown>).id : undefined))
|
|
19
|
+
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
20
|
+
: [];
|
|
21
|
+
const defaultAgent = getConfiguredDefaultWorkerAgent(config);
|
|
22
|
+
return Array.from(new Set([
|
|
23
|
+
...(defaultAgent ? [defaultAgent] : []),
|
|
24
|
+
...allowed,
|
|
25
|
+
...listed,
|
|
26
|
+
])).sort((left, right) => left.localeCompare(right));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildWorkerAgentSetupHint(action: "plan" | "work"): string {
|
|
30
|
+
const rerunCommand = action === "plan" ? "`cs-plan`" : "`cs-work`";
|
|
31
|
+
return `Run \`openclaw config set acp.defaultAgent codex\`, then rerun ${rerunCommand}.`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildWorkerAgentSetupMessage(action: "plan" | "work"): string {
|
|
35
|
+
const rerunCommand = action === "plan" ? "`cs-plan`" : "`cs-work`";
|
|
36
|
+
const scope = action === "plan" ? "planning" : "workers";
|
|
37
|
+
return [
|
|
38
|
+
`OpenClaw ACP is not configured for ClawSpec ${scope}.`,
|
|
39
|
+
"Run:",
|
|
40
|
+
"- `openclaw config set acp.backend acpx`",
|
|
41
|
+
"- `openclaw config set acp.defaultAgent codex`",
|
|
42
|
+
`Then rerun ${rerunCommand}. Replace \`codex\` with another ACP agent id if needed.`,
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getAcpConfig(config: ConfigLike): Record<string, unknown> | undefined {
|
|
47
|
+
const record = asRecord(config);
|
|
48
|
+
return record?.acp && typeof record.acp === "object"
|
|
49
|
+
? record.acp as Record<string, unknown>
|
|
50
|
+
: undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getAgentsConfig(config: ConfigLike): Record<string, unknown> | undefined {
|
|
54
|
+
const record = asRecord(config);
|
|
55
|
+
return record?.agents && typeof record.agents === "object"
|
|
56
|
+
? record.agents as Record<string, unknown>
|
|
57
|
+
: undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function asRecord(value: ConfigLike): Record<string, unknown> | undefined {
|
|
61
|
+
return value && typeof value === "object" ? value as Record<string, unknown> : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function asOptionalString(value: unknown): string | undefined {
|
|
65
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
66
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type ClawSpecPluginConfig = {
|
|
|
11
11
|
maxAutoContinueTurns: number;
|
|
12
12
|
maxNoProgressTurns: number;
|
|
13
13
|
workerWaitTimeoutMs: number;
|
|
14
|
-
workerAgentId
|
|
14
|
+
workerAgentId?: string;
|
|
15
15
|
workerBackendId?: string;
|
|
16
16
|
watcherPollIntervalMs: number;
|
|
17
17
|
subagentLane?: string;
|
|
@@ -105,8 +105,8 @@ export const clawspecPluginConfigSchema: OpenClawPluginConfigSchema = {
|
|
|
105
105
|
advanced: true,
|
|
106
106
|
},
|
|
107
107
|
workerAgentId: {
|
|
108
|
-
label: "Worker Agent",
|
|
109
|
-
help: "
|
|
108
|
+
label: "Deprecated Worker Agent",
|
|
109
|
+
help: "Deprecated no-op. Configure `acp.defaultAgent` in OpenClaw instead.",
|
|
110
110
|
advanced: true,
|
|
111
111
|
},
|
|
112
112
|
workerBackendId: {
|
|
@@ -142,7 +142,6 @@ const DEFAULT_CONFIG: ClawSpecPluginConfig = {
|
|
|
142
142
|
maxNoProgressTurns: 2,
|
|
143
143
|
openSpecTimeoutMs: 120_000,
|
|
144
144
|
workerWaitTimeoutMs: 300_000,
|
|
145
|
-
workerAgentId: "codex",
|
|
146
145
|
watcherPollIntervalMs: 4_000,
|
|
147
146
|
archiveDirName: "archives",
|
|
148
147
|
defaultWorkspace: getDefaultWorkspacePath(),
|
|
@@ -181,7 +180,7 @@ export function parsePluginConfig(
|
|
|
181
180
|
10_000,
|
|
182
181
|
3_600_000,
|
|
183
182
|
),
|
|
184
|
-
workerAgentId: asOptionalString(config.workerAgentId)
|
|
183
|
+
workerAgentId: asOptionalString(config.workerAgentId),
|
|
185
184
|
workerBackendId: asOptionalString(config.workerBackendId),
|
|
186
185
|
watcherPollIntervalMs: asInt(
|
|
187
186
|
config.watcherPollIntervalMs,
|