clawspec 1.0.1 → 1.0.2

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.2",
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": {
@@ -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,
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import { ClawSpecNotifier } from "./watchers/notifier.ts";
18
18
  import { WatcherManager } from "./watchers/manager.ts";
19
19
  import { ensureOpenSpecCli } from "./dependencies/openspec.ts";
20
20
  import { ensureAcpxCli } from "./dependencies/acpx.ts";
21
+ import { getConfiguredDefaultWorkerAgent } from "./acp/openclaw-config.ts";
21
22
 
22
23
  const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
23
24
  const LOCAL_BIN_DIR = path.join(PLUGIN_ROOT, "node_modules", ".bin");
@@ -71,8 +72,9 @@ const plugin = {
71
72
  pluginRoot: PLUGIN_ROOT,
72
73
  logger: api.logger,
73
74
  });
75
+ const configuredDefaultWorkerAgent = getConfiguredDefaultWorkerAgent(api.config) ?? "codex";
74
76
  const acpClient = new AcpWorkerClient({
75
- agentId: config.workerAgentId,
77
+ agentId: configuredDefaultWorkerAgent,
76
78
  logger: api.logger,
77
79
  command: acpx.command,
78
80
  env: acpx.env,
@@ -96,7 +98,7 @@ const plugin = {
96
98
  archiveDirName: config.archiveDirName,
97
99
  allowedChannels: config.allowedChannels,
98
100
  defaultWorkspace: config.defaultWorkspace,
99
- defaultWorkerAgentId: config.workerAgentId,
101
+ defaultWorkerAgentId: undefined,
100
102
  workspaceStore,
101
103
  watcherManager,
102
104
  });
@@ -83,6 +83,12 @@ import { slugToTitle } from "../utils/slug.ts";
83
83
  import { WorkspaceStore } from "../workspace/store.ts";
84
84
  import { isExecutionTriggerText, readExecutionResult } from "../execution/state.ts";
85
85
  import { createWorkerSessionKey, matchesExecutionSession } from "../execution/session.ts";
86
+ import {
87
+ buildWorkerAgentSetupHint,
88
+ buildWorkerAgentSetupMessage,
89
+ getConfiguredDefaultWorkerAgent,
90
+ listConfiguredWorkerAgents,
91
+ } from "../acp/openclaw-config.ts";
86
92
  import {
87
93
  buildExecutionPrependContext,
88
94
  buildExecutionSystemContext,
@@ -130,7 +136,7 @@ type ClawSpecServiceOptions = {
130
136
  openSpec: OpenSpecClient;
131
137
  archiveDirName: string;
132
138
  defaultWorkspace: string;
133
- defaultWorkerAgentId: string;
139
+ defaultWorkerAgentId?: string;
134
140
  workspaceStore: WorkspaceStore;
135
141
  allowedChannels?: string[];
136
142
  maxAutoContinueTurns?: number;
@@ -155,7 +161,7 @@ export class ClawSpecService {
155
161
  readonly openSpec: OpenSpecClient;
156
162
  readonly archiveDirName: string;
157
163
  readonly defaultWorkspace: string;
158
- readonly defaultWorkerAgentId: string;
164
+ readonly defaultWorkerAgentId?: string;
159
165
  readonly workspaceStore: WorkspaceStore;
160
166
  readonly allowedChannels?: string[];
161
167
  readonly watcherManager?: WatcherManager;
@@ -1062,7 +1068,7 @@ export class ClawSpecService {
1062
1068
  ...base,
1063
1069
  contextMode: "attached",
1064
1070
  workspacePath: project.workspacePath ?? workspacePath,
1065
- workerAgentId: base.workerAgentId ?? current.workerAgentId ?? this.defaultWorkerAgentId,
1071
+ workerAgentId: base.workerAgentId ?? current.workerAgentId,
1066
1072
  repoPath,
1067
1073
  projectName,
1068
1074
  projectTitle: sameRepo ? base.projectTitle : projectName,
@@ -1325,7 +1331,8 @@ export class ClawSpecService {
1325
1331
  const project = await this.ensureSessionProject(channelKey, workspacePath);
1326
1332
  const requestedAgent = rawArgs.trim();
1327
1333
  const availableAgents = this.listAvailableWorkerAgents();
1328
- const currentAgent = project.workerAgentId ?? this.defaultWorkerAgentId;
1334
+ const currentAgent = project.workerAgentId ?? this.getDefaultWorkerAgentId();
1335
+ const defaultAgent = this.getDefaultWorkerAgentId();
1329
1336
 
1330
1337
  if (requestedAgent.toLowerCase() === "status") {
1331
1338
  return okReply(await this.buildWorkerStatusText(project, availableAgents));
@@ -1335,9 +1342,12 @@ export class ClawSpecService {
1335
1342
  const lines = [
1336
1343
  heading("Worker Agent"),
1337
1344
  "",
1338
- `Current worker agent: \`${currentAgent}\``,
1339
- `Default worker agent: \`${this.defaultWorkerAgentId}\``,
1345
+ `Current worker agent: ${formatWorkerAgent(currentAgent)}`,
1346
+ `Default worker agent: ${formatWorkerAgent(defaultAgent)}`,
1340
1347
  availableAgents.length > 0 ? `Available agents: ${availableAgents.map((agentId) => `\`${agentId}\``).join(", ")}` : "",
1348
+ !defaultAgent && !project.workerAgentId
1349
+ ? `OpenClaw ACP default is missing. ${buildWorkerAgentSetupHint("work")}`
1350
+ : "",
1341
1351
  "",
1342
1352
  "Use `/clawspec worker <agent-id>` to change the ACP worker agent for this channel/project context.",
1343
1353
  ].filter(Boolean);
@@ -1364,7 +1374,7 @@ export class ClawSpecService {
1364
1374
  [
1365
1375
  heading("Worker Agent Updated"),
1366
1376
  "",
1367
- `Worker agent: \`${updated.workerAgentId ?? this.defaultWorkerAgentId}\``,
1377
+ `Worker agent: ${formatWorkerAgent(updated.workerAgentId)}`,
1368
1378
  "Future background implementation turns will use this ACP agent.",
1369
1379
  ].join("\n"),
1370
1380
  );
@@ -1415,6 +1425,10 @@ export class ClawSpecService {
1415
1425
  if (hasBlockingExecution(project)) {
1416
1426
  return errorReply(BLOCKING_EXECUTION_MSG);
1417
1427
  }
1428
+ const workerConfig = this.validateWorkerAgentConfiguration(project, "work");
1429
+ if (!workerConfig.ok) {
1430
+ return workerConfig.result;
1431
+ }
1418
1432
 
1419
1433
  const repoStatePaths = getRepoStatePaths(project.repoPath, this.archiveDirName);
1420
1434
  const outputs: OpenSpecCommandResult[] = [];
@@ -1483,6 +1497,7 @@ export class ClawSpecService {
1483
1497
  }
1484
1498
 
1485
1499
  const armedAt = new Date().toISOString();
1500
+ const workerAgentId = workerConfig.agentId;
1486
1501
 
1487
1502
  await removeIfExists(repoStatePaths.executionResultFile);
1488
1503
  const remainingTasks = apply.tasks.filter((task) => !task.done);
@@ -1503,12 +1518,12 @@ export class ClawSpecService {
1503
1518
  mode,
1504
1519
  action: "work",
1505
1520
  state: "armed",
1506
- workerAgentId: current.workerAgentId ?? this.defaultWorkerAgentId,
1521
+ workerAgentId,
1507
1522
  workerSlot: "primary",
1508
1523
  armedAt,
1509
1524
  sessionKey: createWorkerSessionKey(current, {
1510
1525
  workerSlot: "primary",
1511
- workerAgentId: current.workerAgentId ?? this.defaultWorkerAgentId,
1526
+ workerAgentId,
1512
1527
  attemptKey: armedAt,
1513
1528
  }),
1514
1529
  },
@@ -1885,14 +1900,13 @@ export class ClawSpecService {
1885
1900
  private async ensureSessionProject(channelKey: string, workspacePath: string): Promise<ProjectState> {
1886
1901
  const existing = await this.stateStore.getActiveProject(channelKey);
1887
1902
  if (existing) {
1888
- if (existing.workspacePath && existing.workerAgentId) {
1903
+ if (existing.workspacePath) {
1889
1904
  return existing;
1890
1905
  }
1891
1906
  return await this.stateStore.updateProject(channelKey, (current) => ({
1892
1907
  ...current,
1893
1908
  contextMode: current.contextMode ?? "attached",
1894
1909
  workspacePath: current.workspacePath ?? workspacePath,
1895
- workerAgentId: current.workerAgentId ?? this.defaultWorkerAgentId,
1896
1910
  status: current.status || "idle",
1897
1911
  phase: current.phase || "init",
1898
1912
  }));
@@ -1904,29 +1918,22 @@ export class ClawSpecService {
1904
1918
  ...current,
1905
1919
  contextMode: current.contextMode ?? "attached",
1906
1920
  workspacePath,
1907
- workerAgentId: current.workerAgentId ?? this.defaultWorkerAgentId,
1908
1921
  status: "idle",
1909
1922
  phase: "init",
1910
1923
  }));
1911
1924
  }
1912
1925
 
1913
1926
  private listAvailableWorkerAgents(): string[] {
1914
- const config = this.config as Record<string, unknown>;
1915
- const acp = config.acp as Record<string, unknown> | undefined;
1916
- const allowed = Array.isArray(acp?.allowedAgents)
1917
- ? acp.allowedAgents.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
1918
- : [];
1919
- const agentsConfig = config.agents as Record<string, unknown> | undefined;
1920
- const listed = Array.isArray(agentsConfig?.list)
1921
- ? agentsConfig.list
1922
- .map((entry) => (entry && typeof entry === "object" ? (entry as Record<string, unknown>).id : undefined))
1923
- .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
1924
- : [];
1925
- return Array.from(new Set([...allowed, ...listed])).sort((left, right) => left.localeCompare(right));
1927
+ const configured = listConfiguredWorkerAgents(this.config);
1928
+ if (configured.length > 0) {
1929
+ return configured;
1930
+ }
1931
+ return this.defaultWorkerAgentId ? [this.defaultWorkerAgentId] : [];
1926
1932
  }
1927
1933
 
1928
1934
  private async buildWorkerStatusText(project: ProjectState, availableAgents: string[]): Promise<string> {
1929
- const configuredAgent = project.workerAgentId ?? this.defaultWorkerAgentId;
1935
+ const configuredAgent = this.getEffectiveWorkerAgentId(project);
1936
+ const defaultAgent = this.getDefaultWorkerAgentId();
1930
1937
  const execution = project.execution;
1931
1938
  const taskCounts = project.taskCounts;
1932
1939
  const runtimeStatus = this.watcherManager?.getWorkerRuntimeStatus
@@ -1961,9 +1968,10 @@ export class ClawSpecService {
1961
1968
  `Context: \`${isProjectContextAttached(project) ? "attached" : "detached"}\``,
1962
1969
  `Phase: \`${project.phase}\``,
1963
1970
  `Lifecycle: \`${project.status}\``,
1964
- `Configured worker agent: \`${configuredAgent}\``,
1965
- `Default worker agent: \`${this.defaultWorkerAgentId}\``,
1971
+ `Configured worker agent: ${formatWorkerAgent(configuredAgent)}`,
1972
+ `Default worker agent: ${formatWorkerAgent(defaultAgent)}`,
1966
1973
  availableAgents.length > 0 ? `Available agents: ${availableAgents.map((agentId) => `\`${agentId}\``).join(", ")}` : "",
1974
+ !configuredAgent ? `Worker setup: ${buildWorkerAgentSetupHint("work")}` : "",
1967
1975
  `Execution state: \`${execution?.state ?? "idle"}\``,
1968
1976
  `Worker transport: \`${transportMode}\``,
1969
1977
  `Action: \`${execution?.action ?? "none"}\``,
@@ -2667,7 +2675,7 @@ export class ClawSpecService {
2667
2675
  : project.status === "planning"
2668
2676
  ? "visible-chat"
2669
2677
  : "idle";
2670
- const workerAgent = project.execution?.workerAgentId ?? project.workerAgentId ?? this.defaultWorkerAgentId;
2678
+ const workerAgent = this.getEffectiveWorkerAgentId(project);
2671
2679
  const showLastExecution = !project.execution;
2672
2680
  const latestExecutionSummary = showLastExecution && project.lastExecution
2673
2681
  ? formatExecutionSummary(project.lastExecution)
@@ -2696,12 +2704,13 @@ export class ClawSpecService {
2696
2704
  `Repo path: ${project.repoPath ? `\`${project.repoPath}\`` : "_unset_"}`,
2697
2705
  `Change: ${project.changeName ? `\`${project.changeName}\`` : "_none_"}`,
2698
2706
  `Context: \`${isProjectContextAttached(project) ? "attached" : "detached"}\``,
2699
- `Worker agent: \`${workerAgent}\``,
2707
+ `Worker agent: ${formatWorkerAgent(workerAgent)}`,
2700
2708
  `Lifecycle: \`${project.status}\``,
2701
2709
  `Phase: \`${project.phase}\``,
2702
2710
  `Execution: \`${executionStatus}\``,
2703
2711
  formatProjectTaskCounts(project, taskCounts),
2704
2712
  `Planning journal: ${project.planningJournal?.dirty ? "dirty" : "clean"} (${project.planningJournal?.entryCount ?? 0} entries)`,
2713
+ !workerAgent ? `Worker setup: ${buildWorkerAgentSetupHint("work")}` : "",
2705
2714
  nextStepHint ? `Next step: ${nextStepHint}` : "",
2706
2715
  project.latestSummary ? `Latest summary: ${project.latestSummary}` : "Latest summary: _none_",
2707
2716
  project.blockedReason ? `Blocked reason: ${project.blockedReason}` : "",
@@ -2717,6 +2726,39 @@ export class ClawSpecService {
2717
2726
 
2718
2727
  return lines.filter((line, index, array) => !(line === "" && array[index - 1] === "")).join("\n");
2719
2728
  }
2729
+
2730
+ private getDefaultWorkerAgentId(): string | undefined {
2731
+ return getConfiguredDefaultWorkerAgent(this.config) ?? this.defaultWorkerAgentId;
2732
+ }
2733
+
2734
+ private getEffectiveWorkerAgentId(project: ProjectState): string | undefined {
2735
+ return project.execution?.workerAgentId ?? project.workerAgentId ?? this.getDefaultWorkerAgentId();
2736
+ }
2737
+
2738
+ private validateWorkerAgentConfiguration(
2739
+ project: ProjectState,
2740
+ action: "plan" | "work",
2741
+ ): { ok: true; agentId: string } | { ok: false; result: PluginCommandResult } {
2742
+ const workerAgentId = this.getEffectiveWorkerAgentId(project);
2743
+ if (workerAgentId) {
2744
+ return { ok: true, agentId: workerAgentId };
2745
+ }
2746
+ return {
2747
+ ok: false,
2748
+ result: errorReply(
2749
+ [
2750
+ heading("Worker Setup Required"),
2751
+ "",
2752
+ buildWorkerAgentSetupMessage(action),
2753
+ "ClawSpec manages the `acpx` command automatically; only the OpenClaw ACP agent selection is missing.",
2754
+ ].join("\n"),
2755
+ ),
2756
+ };
2757
+ }
2758
+ }
2759
+
2760
+ function formatWorkerAgent(agentId: string | undefined): string {
2761
+ return agentId ? `\`${agentId}\`` : "_not configured_";
2720
2762
  }
2721
2763
 
2722
2764
  function describeWorkerTransportMode(project: ProjectState): string {
@@ -7,6 +7,7 @@ export type RepoStatePaths = {
7
7
  executionControlFile: string;
8
8
  executionResultFile: string;
9
9
  workerProgressFile: string;
10
+ workerIoFile: string;
10
11
  progressFile: string;
11
12
  changedFilesFile: string;
12
13
  decisionLogFile: string;
@@ -66,6 +67,7 @@ export function getRepoStatePaths(repoPath: string, archiveDirName: string): Rep
66
67
  executionControlFile: path.join(root, "execution-control.json"),
67
68
  executionResultFile: path.join(root, "execution-result.json"),
68
69
  workerProgressFile: path.join(root, "worker-progress.jsonl"),
70
+ workerIoFile: path.join(root, "worker_io.mjs"),
69
71
  progressFile: path.join(root, "progress.md"),
70
72
  changedFilesFile: path.join(root, "changed-files.md"),
71
73
  decisionLogFile: path.join(root, "decision-log.md"),
@@ -30,8 +30,10 @@ import {
30
30
  } from "../utils/fs.ts";
31
31
  import { getChangeDir, getRepoStatePaths, getTasksPath, resolveProjectScopedPath } from "../utils/paths.ts";
32
32
  import { loadClawSpecSkillBundle } from "../worker/skills.ts";
33
+ import { ensureWorkerIoHelper } from "../worker/io-helper.ts";
33
34
  import { buildAcpImplementationTurnPrompt, buildAcpPlanningTurnPrompt } from "../worker/prompts.ts";
34
35
  import { AcpWorkerClient, type AcpWorkerEvent, type AcpWorkerStatus } from "../acp/client.ts";
36
+ import { buildWorkerAgentSetupHint } from "../acp/openclaw-config.ts";
35
37
  import { ClawSpecNotifier } from "./notifier.ts";
36
38
 
37
39
  type WatcherManagerOptions = {
@@ -228,7 +230,7 @@ export class WatcherManager {
228
230
 
229
231
  const action: ProjectExecutionState["action"] =
230
232
  project.phase === "planning_sync" || project.status === "planning" ? "plan" : "work";
231
- const workerAgentId = project.workerAgentId ?? this.acpClient.agentId;
233
+ const workerAgentId = project.execution?.workerAgentId ?? project.workerAgentId ?? this.acpClient.agentId;
232
234
  const armedAt = new Date().toISOString();
233
235
  const sessionKey = createWorkerSessionKey(project, {
234
236
  workerSlot: "primary",
@@ -2121,6 +2123,7 @@ function toRepoRelative(project: ProjectState, targetPath: string): string {
2121
2123
 
2122
2124
  async function ensureSupportFiles(repoStatePaths: ReturnType<typeof getRepoStatePaths>): Promise<void> {
2123
2125
  await ensureDir(repoStatePaths.root);
2126
+ await ensureWorkerIoHelper(repoStatePaths);
2124
2127
  if (!(await pathExists(repoStatePaths.progressFile))) {
2125
2128
  await writeUtf8(repoStatePaths.progressFile, "# Progress\n");
2126
2129
  }
@@ -2177,6 +2180,7 @@ async function resetRunSupportFiles(
2177
2180
  latestSummary: string,
2178
2181
  ): Promise<void> {
2179
2182
  await ensureDir(repoStatePaths.root);
2183
+ await ensureWorkerIoHelper(repoStatePaths);
2180
2184
  await writeUtf8(repoStatePaths.progressFile, "# Progress\n");
2181
2185
  await writeUtf8(repoStatePaths.workerProgressFile, "");
2182
2186
  await writeUtf8(repoStatePaths.changedFilesFile, "# Changed Files\n");
@@ -2329,9 +2333,7 @@ function isUnavailableAcpBackendFailure(message: string): boolean {
2329
2333
  }
2330
2334
 
2331
2335
  function buildAcpSetupHint(action: ProjectExecutionState["action"]): string {
2332
- return action === "plan"
2333
- ? "Enable `plugins.entries.acpx` + backend `acpx`; install/load `acpx` if needed; rerun `cs-plan`."
2334
- : "Enable `plugins.entries.acpx` + backend `acpx`; install/load `acpx` if needed; rerun `cs-work`.";
2336
+ return buildWorkerAgentSetupHint(action);
2335
2337
  }
2336
2338
 
2337
2339
  function buildBlockedNextStep(project: ProjectState, blocker: string): string {
@@ -2362,7 +2364,7 @@ function buildWorkerRestartMessage(params: {
2362
2364
  delayMs: number;
2363
2365
  }): string {
2364
2366
  if (isUnavailableAcpBackendFailure(params.failureMessage)) {
2365
- return `Restarting ACP worker (attempt ${params.restartCount}) failed because ACPX is unavailable. Next: enable \`plugins.entries.acpx\` and backend \`acpx\`, or install/load \`acpx\`.`;
2367
+ return `Restarting ACP worker (attempt ${params.restartCount}) failed because OpenClaw ACP is unavailable. Next: ${buildAcpSetupHint(params.action)}`;
2366
2368
  }
2367
2369
  const retryDelaySeconds = Math.ceil(params.delayMs / 1000);
2368
2370
  const retryTarget = formatWorkerRetryTarget(params.action, params.nextDetail);
@@ -2612,7 +2614,7 @@ const WORKER_STATUS_POLL_INTERVAL_MS = 1_000;
2612
2614
  const DEAD_SESSION_GRACE_MS = 2_000;
2613
2615
  const WORKER_STARTUP_GRACE_MS = 3_000;
2614
2616
  const WORKER_STARTUP_WAIT_NOTIFY_DELAY_MS = 8_000;
2615
- const WORKER_STARTUP_WAIT_NOTIFY_INTERVAL_MS = 20_000;
2617
+ const WORKER_STARTUP_WAIT_NOTIFY_INTERVAL_MS = 60_000;
2616
2618
  const QUEUE_OWNER_UNAVAILABLE_STARTUP_GRACE_MS = 4_500;
2617
2619
  const RUN_TURN_SETTLE_GRACE_MS = 1_500;
2618
2620
  const MAX_WORKER_RESTART_ATTEMPTS = 10;
@@ -0,0 +1,126 @@
1
+ import type { RepoStatePaths } from "../utils/paths.ts";
2
+ import { tryReadUtf8, writeUtf8 } from "../utils/fs.ts";
3
+
4
+ const WORKER_IO_HELPER_SOURCE = [
5
+ 'import fs from "node:fs";',
6
+ 'import path from "node:path";',
7
+ 'import { fileURLToPath } from "node:url";',
8
+ "",
9
+ 'const ROOT = path.dirname(fileURLToPath(import.meta.url));',
10
+ 'const PROGRESS_FILE = path.join(ROOT, "worker-progress.jsonl");',
11
+ 'const RESULT_FILE = path.join(ROOT, "execution-result.json");',
12
+ "",
13
+ "function nowIso() {",
14
+ " return new Date().toISOString();",
15
+ "}",
16
+ "",
17
+ "function usage(message) {",
18
+ " if (message) {",
19
+ " console.error(message);",
20
+ " }",
21
+ ' console.error("usage:");',
22
+ ' console.error(" worker_io.mjs event --kind <kind> --current <n> --total <n> --task-id <id> --message <text>");',
23
+ ' console.error(" worker_io.mjs result --file <json-file>");',
24
+ " process.exit(1);",
25
+ "}",
26
+ "",
27
+ "function parseFlags(argv) {",
28
+ " const flags = {};",
29
+ " for (let index = 0; index < argv.length; index += 1) {",
30
+ " const token = argv[index];",
31
+ ' if (!token || !token.startsWith("--")) {',
32
+ ' usage(`unexpected argument: ${token ?? "<missing>"}`);',
33
+ " }",
34
+ " const key = token.slice(2);",
35
+ " const value = argv[index + 1];",
36
+ " if (!key) {",
37
+ ' usage("flag name cannot be empty");',
38
+ " }",
39
+ " if (value === undefined) {",
40
+ ' usage(`missing value for --${key}`);',
41
+ " }",
42
+ " flags[key] = value;",
43
+ " index += 1;",
44
+ " }",
45
+ " return flags;",
46
+ "}",
47
+ "",
48
+ "function requireFlag(flags, key) {",
49
+ " const value = flags[key];",
50
+ ' if (typeof value !== "string" || value.trim() === "") {',
51
+ ' usage(`missing --${key}`);',
52
+ " }",
53
+ " return value;",
54
+ "}",
55
+ "",
56
+ "function requireInteger(flags, key) {",
57
+ " const raw = requireFlag(flags, key);",
58
+ " const parsed = Number.parseInt(raw, 10);",
59
+ " if (!Number.isFinite(parsed) || parsed < 0) {",
60
+ ' usage(`invalid integer for --${key}: ${raw}`);',
61
+ " }",
62
+ " return parsed;",
63
+ "}",
64
+ "",
65
+ "function appendEvent(flags) {",
66
+ " const payload = {",
67
+ " version: 1,",
68
+ " timestamp: nowIso(),",
69
+ ' kind: requireFlag(flags, "kind"),',
70
+ ' current: requireInteger(flags, "current"),',
71
+ ' total: requireInteger(flags, "total"),',
72
+ ' taskId: requireFlag(flags, "task-id"),',
73
+ ' message: requireFlag(flags, "message"),',
74
+ " };",
75
+ " fs.mkdirSync(path.dirname(PROGRESS_FILE), { recursive: true });",
76
+ ' fs.appendFileSync(PROGRESS_FILE, JSON.stringify(payload) + "\\n", "utf8");',
77
+ "}",
78
+ "",
79
+ "function writeResult(flags) {",
80
+ ' const inputFile = requireFlag(flags, "file");',
81
+ ' const payload = JSON.parse(fs.readFileSync(inputFile, "utf8"));',
82
+ ' if (!payload || typeof payload !== "object" || Array.isArray(payload)) {',
83
+ ' usage("result payload must be a JSON object");',
84
+ " }",
85
+ " if (!payload.version) {",
86
+ " payload.version = 1;",
87
+ " }",
88
+ " if (!payload.timestamp) {",
89
+ " payload.timestamp = nowIso();",
90
+ " }",
91
+ " fs.mkdirSync(path.dirname(RESULT_FILE), { recursive: true });",
92
+ ' fs.writeFileSync(RESULT_FILE, `${JSON.stringify(payload, null, 2)}\\n`, "utf8");',
93
+ "}",
94
+ "",
95
+ "function main(argv) {",
96
+ " const command = argv[2];",
97
+ ' if (command === "event") {',
98
+ " appendEvent(parseFlags(argv.slice(3)));",
99
+ " return;",
100
+ " }",
101
+ ' if (command === "result") {',
102
+ " writeResult(parseFlags(argv.slice(3)));",
103
+ " return;",
104
+ " }",
105
+ ' usage(command ? `unknown command: ${command}` : "missing command");',
106
+ "}",
107
+ "",
108
+ "main(process.argv);",
109
+ "",
110
+ ].join("\n");
111
+
112
+ export async function ensureWorkerIoHelper(repoStatePaths: RepoStatePaths): Promise<void> {
113
+ const existing = await tryReadUtf8(repoStatePaths.workerIoFile);
114
+ if (existing === WORKER_IO_HELPER_SOURCE) {
115
+ return;
116
+ }
117
+ await writeUtf8(repoStatePaths.workerIoFile, WORKER_IO_HELPER_SOURCE);
118
+ }
119
+
120
+ export function buildWorkerIoEventCommandPrefix(repoStatePaths: RepoStatePaths): string {
121
+ return `node ${quoteForShell(repoStatePaths.workerIoFile)} event`;
122
+ }
123
+
124
+ function quoteForShell(value: string): string {
125
+ return `"${value.replace(/"/g, '\\"')}"`;
126
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  } from "../types.ts";
8
8
  import type { RepoStatePaths } from "../utils/paths.ts";
9
9
  import { resolveProjectScopedPath } from "../utils/paths.ts";
10
+ import { buildWorkerIoEventCommandPrefix } from "./io-helper.ts";
10
11
 
11
12
  export function buildExecutionSystemContext(repoPath: string, importedSkills?: string): string {
12
13
  return [
@@ -368,6 +369,11 @@ export function buildAcpImplementationTurnPrompt(params: {
368
369
  const contextLabels = contextPaths.map((contextPath) => displayPath(contextPath));
369
370
  const firstContextLabel = contextLabels[0] ?? displayPath(tasksPath);
370
371
  const afterContextLabel = contextLabels[1] ?? `start task ${params.task.id}`;
372
+ const workerIoCommandPrefix = buildWorkerIoEventCommandPrefix(params.repoStatePaths);
373
+ const progressCommandTemplate =
374
+ `${workerIoCommandPrefix} --kind <status|task_start|task_done|blocked> --current <n> --total ${params.tasks.length} --task-id "<task-id>" --message "<message>"`;
375
+ const preparingCommandExample =
376
+ `${workerIoCommandPrefix} --kind status --current 1 --total ${params.tasks.length} --task-id "${params.task.id}" --message "Preparing ${params.task.id}: loading context. Next: read ${firstContextLabel}."`;
371
377
 
372
378
  const taskList = params.tasks.map((task) => `- ${task.id} ${task.description}`).join("\n");
373
379
 
@@ -388,7 +394,8 @@ export function buildAcpImplementationTurnPrompt(params: {
388
394
  "- Keep changes minimal and scoped to the active change.",
389
395
  "- Do not inspect or modify sibling `openspec/changes/<other-change>` directories.",
390
396
  `- Update ${tasksPath} by switching each task from \`- [ ]\` to \`- [x]\` as you complete it.`,
391
- `- Append short progress events to ${params.repoStatePaths.workerProgressFile} as valid JSON Lines.`,
397
+ `- Use the worker IO helper instead of editing ${params.repoStatePaths.workerProgressFile} directly.`,
398
+ `- Worker IO helper command template: ${progressCommandTemplate}`,
392
399
  "- Progress events must be human-readable, one line each, and must match the actual work completed.",
393
400
  `- Every progress event must include \`current\` (current task number) and \`total\` (total task count for this run).`,
394
401
  `- Do not stay silent until \`task_start\`. Emit context-loading progress first.`,
@@ -407,6 +414,9 @@ export function buildAcpImplementationTurnPrompt(params: {
407
414
  "Context files to read first:",
408
415
  ...contextPaths.map((contextPath) => `- ${contextPath}`),
409
416
  "",
417
+ "Worker IO helper example:",
418
+ fence(preparingCommandExample, "bash"),
419
+ "",
410
420
  "Worker progress JSONL event template:",
411
421
  fence(JSON.stringify({
412
422
  version: 1,
@@ -5,7 +5,7 @@ import { parsePluginConfig } from "../src/config.ts";
5
5
  test("parsePluginConfig returns defaults for empty input", () => {
6
6
  const config = parsePluginConfig(undefined);
7
7
  assert.equal(config.enabled, true);
8
- assert.equal(config.workerAgentId, "codex");
8
+ assert.equal(config.workerAgentId, undefined);
9
9
  assert.equal(config.archiveDirName, "archives");
10
10
  assert.equal(config.openSpecTimeoutMs, 120_000);
11
11
  assert.equal(config.watcherPollIntervalMs, 4_000);
@@ -48,7 +48,7 @@ test("parsePluginConfig ignores invalid types", () => {
48
48
  });
49
49
  assert.equal(config.enabled, true); // falls back to default
50
50
  assert.equal(config.maxAutoContinueTurns, 3); // falls back to default
51
- assert.equal(config.workerAgentId, "codex"); // falls back to default
51
+ assert.equal(config.workerAgentId, undefined); // deprecated and ignored unless explicitly set
52
52
  });
53
53
 
54
54
  test("parsePluginConfig filters allowedChannels", () => {
@@ -25,6 +25,7 @@ await withFileLock(lockPath, async () => {
25
25
  let childOutput = "";
26
26
  let childError = "";
27
27
  let child: ReturnType<typeof spawn> | undefined;
28
+ let childExit: Promise<number | null> | undefined;
28
29
 
29
30
  t.after(() => {
30
31
  if (child && !child.killed) {
@@ -41,6 +42,7 @@ await withFileLock(lockPath, async () => {
41
42
  cwd: process.cwd(),
42
43
  stdio: ["ignore", "pipe", "pipe"],
43
44
  });
45
+ childExit = waitForChildClose(child);
44
46
  child.stdout?.setEncoding("utf8");
45
47
  child.stderr?.setEncoding("utf8");
46
48
  child.stdout?.on("data", (chunk) => {
@@ -55,9 +57,7 @@ await withFileLock(lockPath, async () => {
55
57
  });
56
58
 
57
59
  await waitFor(() => childOutput.includes("acquired"));
58
- const exitCode = await new Promise<number | null>((resolve) => {
59
- child?.once("close", (code) => resolve(code));
60
- });
60
+ const exitCode = await childExit;
61
61
 
62
62
  assert.equal(exitCode, 0, childError || undefined);
63
63
  });
@@ -76,3 +76,13 @@ async function waitFor(check: () => boolean, timeoutMs = 2_000): Promise<void> {
76
76
  }
77
77
  throw new Error("timed out waiting for lock handoff");
78
78
  }
79
+
80
+ function waitForChildClose(child: ReturnType<typeof spawn>): Promise<number | null> {
81
+ if (child.exitCode !== null || child.signalCode !== null) {
82
+ return Promise.resolve(child.exitCode);
83
+ }
84
+
85
+ return new Promise<number | null>((resolve) => {
86
+ child.once("close", (code) => resolve(code));
87
+ });
88
+ }
@@ -155,17 +155,41 @@ export async function createServiceHarness(prefix: string): Promise<{
155
155
  const watcherManager = createFakeWatcherManager();
156
156
  const service = new ClawSpecService({
157
157
  api: {
158
- config: {},
158
+ config: {
159
+ acp: {
160
+ backend: "acpx",
161
+ defaultAgent: "codex",
162
+ allowedAgents: ["codex", "piper"],
163
+ },
164
+ agents: {
165
+ list: [
166
+ { id: "codex" },
167
+ { id: "piper" },
168
+ ],
169
+ },
170
+ },
159
171
  logger: createLogger(),
160
172
  } as any,
161
- config: {} as any,
173
+ config: {
174
+ acp: {
175
+ backend: "acpx",
176
+ defaultAgent: "codex",
177
+ allowedAgents: ["codex", "piper"],
178
+ },
179
+ agents: {
180
+ list: [
181
+ { id: "codex" },
182
+ { id: "piper" },
183
+ ],
184
+ },
185
+ } as any,
162
186
  logger: createLogger(),
163
187
  stateStore,
164
188
  memoryStore,
165
189
  openSpec: openSpec as any,
166
190
  archiveDirName: "archives",
167
191
  defaultWorkspace: workspacePath,
168
- defaultWorkerAgentId: "codex",
192
+ defaultWorkerAgentId: undefined,
169
193
  workspaceStore,
170
194
  watcherManager: watcherManager as any,
171
195
  });
@@ -108,3 +108,55 @@ test("main chat agent end does not clear a background worker run", async () => {
108
108
  assert.equal(project?.execution?.sessionKey, workerSessionKey);
109
109
  assert.equal(project?.latestSummary, "Worker is running in the background.");
110
110
  });
111
+
112
+ test("work requires OpenClaw ACP default agent when no project override is set", async () => {
113
+ const harness = await createServiceHarness("clawspec-work-missing-acp-");
114
+ const { service, stateStore, watcherManager, repoPath, workspacePath, changeDir } = harness;
115
+ const channelKey = "discord:work-missing-acp:default:main";
116
+ const tasksPath = path.join(changeDir, "tasks.md");
117
+ await writeUtf8(tasksPath, "- [ ] 1.1 Build the demo endpoint\n");
118
+
119
+ (service as any).config = {
120
+ acp: {
121
+ backend: "acpx",
122
+ },
123
+ };
124
+
125
+ harness.openSpec.instructionsApply = async (cwd: string, changeName: string) => ({
126
+ command: `openspec instructions apply --change ${changeName} --json`,
127
+ cwd,
128
+ stdout: "{}",
129
+ stderr: "",
130
+ durationMs: 1,
131
+ parsed: {
132
+ changeName,
133
+ changeDir,
134
+ schemaName: "spec-driven",
135
+ contextFiles: { tasks: tasksPath },
136
+ progress: { total: 1, complete: 0, remaining: 1 },
137
+ tasks: [{ id: "1.1", description: "Build the demo endpoint", done: false }],
138
+ state: "ready",
139
+ instruction: "Implement the remaining task.",
140
+ },
141
+ });
142
+
143
+ await seedPlanningProject(stateStore, channelKey, {
144
+ workspacePath,
145
+ repoPath,
146
+ projectName: "demo-app",
147
+ changeName: "queue-work",
148
+ changeDir,
149
+ phase: "tasks",
150
+ status: "ready",
151
+ planningDirty: false,
152
+ });
153
+
154
+ const result = await service.queueWorkProject(channelKey, "apply");
155
+ const project = await stateStore.getActiveProject(channelKey);
156
+
157
+ assert.match(result.text ?? "", /Worker Setup Required/);
158
+ assert.match(result.text ?? "", /openclaw config set acp\.backend acpx/);
159
+ assert.match(result.text ?? "", /openclaw config set acp\.defaultAgent codex/);
160
+ assert.equal(project?.status, "ready");
161
+ assert.deepEqual(watcherManager.wakeCalls, []);
162
+ });
@@ -57,14 +57,19 @@ test("service writes archive bundles from visible-execution state", async () =>
57
57
 
58
58
  const service = new ClawSpecService({
59
59
  api: fakeApi,
60
- config: {} as any,
60
+ config: {
61
+ acp: {
62
+ backend: "acpx",
63
+ defaultAgent: "codex",
64
+ },
65
+ } as any,
61
66
  logger: fakeApi.logger,
62
67
  stateStore,
63
68
  memoryStore,
64
69
  openSpec: {} as any,
65
70
  archiveDirName: "archives",
66
71
  defaultWorkspace: workspacePath,
67
- defaultWorkerAgentId: "codex",
72
+ defaultWorkerAgentId: undefined,
68
73
  workspaceStore,
69
74
  });
70
75
 
@@ -1462,7 +1462,7 @@ test("watcher blocked message includes ACPX setup guidance when backend stays un
1462
1462
  assert.equal(project?.status, "blocked");
1463
1463
  assert.equal(project?.blockedReason?.includes("Blocked after 10 ACP restart attempts"), true);
1464
1464
  assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-blocked", "Blocked: ACPX backend unavailable"), true);
1465
- assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-blocked", "plugins.entries.acpx"), true);
1465
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-blocked", "openclaw config set acp.defaultAgent codex"), true);
1466
1466
  assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-blocked", "cs-work"), true);
1467
1467
  });
1468
1468
 
@@ -1590,8 +1590,8 @@ test("watcher retries when ACP runtime backend is temporarily unavailable", asyn
1590
1590
  assert.equal(project?.status, "done");
1591
1591
  assert.equal(runCount, 2);
1592
1592
  assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "Restarting ACP worker"), true);
1593
- assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "ACPX is unavailable"), true);
1594
- assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "plugins.entries.acpx"), true);
1593
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "OpenClaw ACP is unavailable"), true);
1594
+ assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "openclaw config set acp.defaultAgent codex"), true);
1595
1595
  assert.equal(hasMessage(notifierMessages, "demo-app-watch-work-backend-unavailable", "All tasks complete"), true);
1596
1596
  });
1597
1597
 
@@ -0,0 +1,97 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
6
+ import { spawnSync } from "node:child_process";
7
+ import { ensureWorkerIoHelper } from "../src/worker/io-helper.ts";
8
+ import { buildAcpImplementationTurnPrompt } from "../src/worker/prompts.ts";
9
+ import { getRepoStatePaths } from "../src/utils/paths.ts";
10
+ import { readUtf8 } from "../src/utils/fs.ts";
11
+
12
+ test("worker io helper appends progress events via node command", async () => {
13
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-worker-io-helper-"));
14
+ const repoPath = path.join(tempRoot, "demo-app");
15
+ await mkdir(repoPath, { recursive: true });
16
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
17
+ await ensureWorkerIoHelper(repoStatePaths);
18
+
19
+ const result = spawnSync(
20
+ process.execPath,
21
+ [
22
+ repoStatePaths.workerIoFile,
23
+ "event",
24
+ "--kind",
25
+ "status",
26
+ "--current",
27
+ "1",
28
+ "--total",
29
+ "3",
30
+ "--task-id",
31
+ "1.1",
32
+ "--message",
33
+ "Preparing 1.1: loading context. Next: read proposal.md.",
34
+ ],
35
+ { encoding: "utf8" },
36
+ );
37
+
38
+ assert.equal(result.status, 0, result.stderr);
39
+ const raw = await readUtf8(repoStatePaths.workerProgressFile);
40
+ const lines = raw.trim().split(/\r?\n/);
41
+ assert.equal(lines.length, 1);
42
+ const payload = JSON.parse(lines[0]!);
43
+ assert.equal(payload.kind, "status");
44
+ assert.equal(payload.current, 1);
45
+ assert.equal(payload.total, 3);
46
+ assert.equal(payload.taskId, "1.1");
47
+ assert.equal(payload.message, "Preparing 1.1: loading context. Next: read proposal.md.");
48
+ });
49
+
50
+ test("implementation prompt instructs the worker to use the helper", async () => {
51
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-worker-io-prompt-"));
52
+ const repoPath = path.join(tempRoot, "demo-app");
53
+ const changeDir = path.join(repoPath, "openspec", "changes", "demo-change");
54
+ await mkdir(changeDir, { recursive: true });
55
+ await writeFile(path.join(changeDir, "proposal.md"), "# Proposal\n", "utf8");
56
+ await writeFile(path.join(changeDir, "tasks.md"), "- [ ] 1.1 Demo task\n", "utf8");
57
+
58
+ const repoStatePaths = getRepoStatePaths(repoPath, "archives");
59
+ const prompt = buildAcpImplementationTurnPrompt({
60
+ project: {
61
+ version: 1,
62
+ projectId: "project-1",
63
+ channelKey: "discord:test:default:main",
64
+ storagePath: path.join(repoStatePaths.root, "state.json"),
65
+ status: "running",
66
+ phase: "implementing",
67
+ createdAt: new Date().toISOString(),
68
+ updatedAt: new Date().toISOString(),
69
+ repoPath,
70
+ workspacePath: tempRoot,
71
+ projectName: "demo-app",
72
+ changeName: "demo-change",
73
+ changeDir,
74
+ pauseRequested: false,
75
+ },
76
+ repoStatePaths,
77
+ apply: {
78
+ changeName: "demo-change",
79
+ changeDir,
80
+ schemaName: "spec-driven",
81
+ contextFiles: {
82
+ proposal: path.join(changeDir, "proposal.md"),
83
+ tasks: path.join(changeDir, "tasks.md"),
84
+ },
85
+ progress: { total: 1, complete: 0, remaining: 1 },
86
+ tasks: [{ id: "1.1", description: "Demo task", done: false }],
87
+ state: "ready",
88
+ instruction: "Implement the remaining task.",
89
+ },
90
+ task: { id: "1.1", description: "Demo task" },
91
+ tasks: [{ id: "1.1", description: "Demo task" }],
92
+ mode: "apply",
93
+ });
94
+
95
+ assert.match(prompt, /Use the worker IO helper instead of editing .*worker-progress\.jsonl directly\./);
96
+ assert.match(prompt, /worker_io\.mjs" event --kind <status\|task_start\|task_done\|blocked>/);
97
+ });