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 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 and ACPX in OpenClaw
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`, install or load it separately before relying on `cs-work`.
277
- - ClawSpec itself does not ship its own ACP runtime backend.
278
- - When ACPX is unavailable, the watcher now reports a short recovery hint in chat telling the user to enable `plugins.entries.acpx` and backend `acpx`.
279
- - `acp.defaultAgent` is the OpenClaw ACP default. ClawSpec background workers use `plugins.entries.clawspec.config.workerAgentId` by default.
280
- - For ClawSpec worker selection, precedence is: `/clawspec worker <agent-id>` for the current channel/project, then `clawspec.config.workerAgentId`, then the built-in ClawSpec fallback.
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`, but there are two separate defaults to understand:
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 the host ACP system
288
- - `plugins.entries.clawspec.config.workerAgentId`: the ClawSpec default used by `cs-work`
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 if you want both layers aligned:
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 `workerAgentId` in OpenClaw config
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
- - `plugins.entries.acpx.enabled` is false
827
- - your OpenClaw build does not bundle `acpx` and it was never installed/loaded
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. enable ACP
832
- 2. set backend to `acpx`
833
- 3. enable `plugins.entries.acpx`
834
- 4. if your host does not bundle ACPX, install/load it first
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 和 ACPX
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`,那就要先单独安装或加载 `acpx`。
282
- - ClawSpec 自己不携带 ACP runtime backend。
283
- - ACPX 不可用时,watcher 会在聊天里发一条简短提示,告诉用户去启用 `plugins.entries.acpx` backend `acpx`。
284
- - `acp.defaultAgent` OpenClaw 自己的 ACP 默认 agent;ClawSpec 的后台 worker 默认看的是 `plugins.entries.clawspec.config.workerAgentId`。
285
- - ClawSpec 来说,worker 选择优先级是:当前 channel/project 上的 `/clawspec worker <agent-id>` 覆盖,其次是 `clawspec.config.workerAgentId`,最后才是 ClawSpec 自己的内置默认值。
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`:OpenClaw 自己 ACP 系统的默认 agent
293
- - `plugins.entries.clawspec.config.workerAgentId`:ClawSpec 执行 `cs-work` 时使用的默认 worker agent
283
+ - `acp.defaultAgent`:ClawSpec 后台 worker 读取的全局默认 ACP agent
284
+ - `/clawspec worker <agent-id>`:当前 channel/project 的显式覆盖
294
285
 
295
- 如果你希望两层都统一成同一个 agent,建议同时这样配置:
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
- - 如果你想配置“全局默认”,改的是 `clawspec.config.workerAgentId`
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
- - `plugins.entries.acpx.enabled` 没开
839
- - 你的 OpenClaw 没有自带 ACPX,也没有单独安装/加载
819
+ - `acp.defaultAgent` 没配
820
+ - 你的 OpenClaw 没有自带 ACPX,且 ClawSpec 也没能找到或安装可用副本
840
821
 
841
822
  处理方式:
842
823
 
843
- 1. 打开 ACP
844
- 2. backend 设成 `acpx`
845
- 3. 启用 `plugins.entries.acpx`
846
- 4. 如果宿主没自带 ACPX,就先安装或加载它
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.1",
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: ChildProcessWithoutNullStreams): void {
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(stderr: string, exitCode: number | null | undefined): string {
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
- return detail || `acpx exited with code ${exitCode ?? "unknown"}`;
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: string;
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: "Agent id used for background ACP planning and implementation turns",
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) ?? DEFAULT_CONFIG.workerAgentId,
183
+ workerAgentId: asOptionalString(config.workerAgentId),
185
184
  workerBackendId: asOptionalString(config.workerBackendId),
186
185
  watcherPollIntervalMs: asInt(
187
186
  config.watcherPollIntervalMs,