clawspec 1.0.16 → 1.0.20

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.
Files changed (40) hide show
  1. package/README.md +16 -0
  2. package/README.zh-CN.md +16 -0
  3. package/package.json +2 -3
  4. package/src/acp/client.ts +17 -1
  5. package/src/orchestrator/helpers.ts +1 -0
  6. package/src/orchestrator/service.ts +143 -13
  7. package/src/watchers/manager.ts +89 -7
  8. package/src/worker/io-helper.ts +6 -5
  9. package/test/acp-client.test.ts +0 -309
  10. package/test/acpx-dependency.test.ts +0 -133
  11. package/test/assistant-journal.test.ts +0 -203
  12. package/test/command-surface.test.ts +0 -23
  13. package/test/config.test.ts +0 -77
  14. package/test/detach-attach.test.ts +0 -98
  15. package/test/file-lock.test.ts +0 -88
  16. package/test/fs-utils.test.ts +0 -22
  17. package/test/helpers/harness.ts +0 -301
  18. package/test/helpers.test.ts +0 -108
  19. package/test/keywords.test.ts +0 -92
  20. package/test/notifier.test.ts +0 -29
  21. package/test/openspec-dependency.test.ts +0 -68
  22. package/test/paths-utils.test.ts +0 -30
  23. package/test/pause-cancel.test.ts +0 -55
  24. package/test/planning-journal.test.ts +0 -155
  25. package/test/plugin-registration.test.ts +0 -35
  26. package/test/project-memory.test.ts +0 -42
  27. package/test/proposal.test.ts +0 -24
  28. package/test/queue-planning.test.ts +0 -322
  29. package/test/queue-work.test.ts +0 -220
  30. package/test/recovery.test.ts +0 -576
  31. package/test/service-archive.test.ts +0 -87
  32. package/test/shell-command.test.ts +0 -48
  33. package/test/state-store.test.ts +0 -74
  34. package/test/tasks-and-checkpoint.test.ts +0 -60
  35. package/test/use-project.test.ts +0 -67
  36. package/test/watcher-planning.test.ts +0 -504
  37. package/test/watcher-work.test.ts +0 -1741
  38. package/test/worker-command.test.ts +0 -66
  39. package/test/worker-io-helper.test.ts +0 -97
  40. package/test/worker-skills.test.ts +0 -12
package/README.md CHANGED
@@ -552,6 +552,8 @@ That loop is the normal operating model:
552
552
  | `/clawspec status` | Any time you want a reconciled project snapshot | Renders current workspace, project, change, lifecycle, task counts, journal status, and next step |
553
553
  | `/clawspec archive` | After all tasks are complete and you want to close the change | Validates and archives the finished OpenSpec change, then clears active change state |
554
554
  | `/clawspec cancel` | When you want to abandon the active change | Restores tracked files from snapshots, removes the change, stops execution state, and clears the active change |
555
+ | `/clawspec doctor` | When something isn't working as expected | Runs automated diagnostics on your environment and configuration, reports detected issues with suggested fixes |
556
+ | `/clawspec doctor fix` | After `/clawspec doctor` reports a fixable issue | Automatically applies safe fixes for all detected issues that support auto-repair |
555
557
 
556
558
  Auxiliary host CLI:
557
559
 
@@ -858,6 +860,20 @@ Use one of:
858
860
 
859
861
  That is by design. Cancel restores tracked files from the ClawSpec snapshot baseline for the active change. It is intentionally narrower than a blanket Git restore.
860
862
 
863
+ ### Worker fails with "stdin is not a terminal" or session creation returns no result
864
+
865
+ Cause:
866
+
867
+ - `~/.acpx/config.json` has a custom agent entry (e.g. `agents.codex` pointing to a local CLI binary)
868
+ - acpx cannot spawn the agent in a non-interactive environment when it is configured as a raw CLI command
869
+
870
+ What to do:
871
+
872
+ 1. Run `/clawspec doctor` to check for known configuration issues.
873
+ 2. If `/clawspec doctor` reports custom agent entries, run `/clawspec doctor fix` to clear them automatically.
874
+ 3. Alternatively, edit `~/.acpx/config.json` manually and set `"agents": {}`.
875
+ 4. Retry `cs-work` or `/clawspec continue`.
876
+
861
877
  ## Development And Validation
862
878
 
863
879
  Useful checks while editing the plugin:
package/README.zh-CN.md CHANGED
@@ -556,6 +556,8 @@ cs-work
556
556
  | `/clawspec status` | 任何时候想看一次统一状态快照时 | 输出当前 workspace、project、change、生命周期、任务计数、journal 状态和下一步建议 |
557
557
  | `/clawspec archive` | 所有任务完成,准备收尾时 | 校验并归档当前 OpenSpec change,同时清理活动 change 状态 |
558
558
  | `/clawspec cancel` | 想放弃当前 change 时 | 按快照恢复已跟踪文件、删除 change、停止执行状态,并清掉活动 change |
559
+ | `/clawspec doctor` | 遇到异常行为时 | 对环境和配置进行自动化诊断,报告检测到的问题并给出修复建议 |
560
+ | `/clawspec doctor fix` | `/clawspec doctor` 检测到可修复问题后 | 自动应用所有支持自动修复的安全修复 |
559
561
 
560
562
  辅助 host CLI:
561
563
 
@@ -869,6 +871,20 @@ cs-detach
869
871
 
870
872
  这是设计如此。Cancel 只会恢复 ClawSpec 为当前 change 跟踪的文件快照,而不是做整仓库级别的 Git 还原。
871
873
 
874
+ ### Worker 报错 "stdin is not a terminal" 或 session 创建无结果
875
+
876
+ 原因:
877
+
878
+ - `~/.acpx/config.json` 中的 `agents` 字段存在自定义配置(例如 `agents.codex` 指向了本地 CLI 路径)
879
+ - acpx 在非交互式环境下无法通过 raw CLI 方式启动 agent
880
+
881
+ 解决方法:
882
+
883
+ 1. 运行 `/clawspec doctor` 检查配置问题。
884
+ 2. 如果 doctor 报告了自定义 agent 配置,运行 `/clawspec doctor fix` 自动修复。
885
+ 3. 也可以手动编辑 `~/.acpx/config.json`,将 `"agents"` 设置为 `{}`。
886
+ 4. 然后重新运行 `cs-work` 或 `/clawspec continue`。
887
+
872
888
  ## 开发与验证
873
889
 
874
890
  开发时常用检查:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawspec",
3
- "version": "1.0.16",
3
+ "version": "1.0.20",
4
4
  "type": "module",
5
5
  "description": "OpenClaw plugin that orchestrates OpenSpec project workflows with visible main-agent execution.",
6
6
  "keywords": [
@@ -23,7 +23,6 @@
23
23
  "index.ts",
24
24
  "src",
25
25
  "skills",
26
- "test",
27
26
  "README.md",
28
27
  "README.zh-CN.md",
29
28
  "openclaw.plugin.json",
@@ -46,7 +45,7 @@
46
45
  "scripts": {
47
46
  "check": "node --experimental-strip-types -e \"import('./index.ts')\"",
48
47
  "test": "node --experimental-strip-types --test --test-reporter spec test/*.test.ts",
49
- "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/paths-utils.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",
48
+ "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/doctor.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/paths-utils.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",
50
49
  "test:slow": "node --experimental-strip-types --test --test-reporter spec test/watcher-planning.test.ts test/watcher-work.test.ts test/recovery.test.ts"
51
50
  },
52
51
  "engines": {
package/src/acp/client.ts CHANGED
@@ -141,7 +141,7 @@ export class AcpWorkerClient {
141
141
  allowErrorCodes: ["NO_SESSION"],
142
142
  });
143
143
 
144
- if (events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION")) {
144
+ if (events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION") || events.length === 0) {
145
145
  events = await this.runControlCommand({
146
146
  agentId: descriptor.agentId,
147
147
  cwd: descriptor.cwd,
@@ -149,6 +149,14 @@ export class AcpWorkerClient {
149
149
  });
150
150
  }
151
151
 
152
+ if (events.length === 0 || !extractSessionIdentifiers(events).backendSessionId) {
153
+ throw new Error(
154
+ `Failed to create acpx session for agent "${descriptor.agentId}".\n`
155
+ + `The session command returned no result. This usually means the agent is misconfigured.\n\n`
156
+ + `Run \`/clawspec doctor\` to diagnose and fix common acpx configuration issues.`,
157
+ );
158
+ }
159
+
152
160
  const identifiers = extractSessionIdentifiers(events);
153
161
  const handle: AcpWorkerHandle = {
154
162
  sessionKey: descriptor.sessionKey,
@@ -186,6 +194,13 @@ export class AcpWorkerClient {
186
194
  cwd: descriptor.cwd,
187
195
  env: this.env,
188
196
  });
197
+
198
+ const args = this.buildPromptArgs({
199
+ agentId: descriptor.agentId,
200
+ cwd: descriptor.cwd,
201
+ sessionKey: descriptor.sessionKey,
202
+ });
203
+
189
204
  child.stdout.setEncoding("utf8");
190
205
  child.stderr.setEncoding("utf8");
191
206
  child.stdin.end(params.text);
@@ -250,6 +265,7 @@ export class AcpWorkerClient {
250
265
 
251
266
  const exit = await waitForExit(child);
252
267
  this.recordSessionExit(params.sessionKey, descriptor, child.pid, exit.code, exit.signal, stderr);
268
+
253
269
  if (exit.error) {
254
270
  throw exit.error;
255
271
  }
@@ -125,6 +125,7 @@ export function buildHelpText(): string {
125
125
  "- `/clawspec status`",
126
126
  "- `/clawspec archive`",
127
127
  "- `/clawspec cancel`",
128
+ "- `/clawspec doctor`",
128
129
  "",
129
130
  "Visible chat keywords:",
130
131
  "- `cs-plan`",
@@ -1,3 +1,4 @@
1
+ import os from "node:os";
1
2
  import path from "node:path";
2
3
  import type {
3
4
  OpenClawConfig,
@@ -236,6 +237,8 @@ export class ClawSpecService {
236
237
  return await this.archiveProject(channelKey);
237
238
  case "cancel":
238
239
  return await this.cancelProject(channelKey);
240
+ case "doctor":
241
+ return await this.runDoctor(rest);
239
242
  default:
240
243
  return errorReply(`Unknown subcommand \`${subcommand}\`.\n\n${buildHelpText()}`);
241
244
  }
@@ -675,13 +678,6 @@ export class ClawSpecService {
675
678
  repoStatePaths.planningJournalSnapshotFile,
676
679
  project.planningJournal?.lastSyncedAt,
677
680
  );
678
- const snapshot = await journalStore.readSnapshot(repoStatePaths.planningJournalSnapshotFile);
679
- const digest = await journalStore.digest(project.changeName);
680
- this.logger.info(`[clawspec] cs-work check for ${project.changeName}:`);
681
- this.logger.info(` - hasUnsyncedChanges: ${hasUnsyncedChanges}`);
682
- this.logger.info(` - Snapshot: entryCount=${snapshot?.entryCount}, lastEntryAt=${snapshot?.lastEntryAt}, hash=${snapshot?.contentHash?.slice(0, 8)}`);
683
- this.logger.info(` - Current digest: entryCount=${digest.entryCount}, lastEntryAt=${digest.lastEntryAt}, hash=${digest.contentHash.slice(0, 8)}`);
684
- this.logger.info(` - fallbackLastSyncedAt: ${project.planningJournal?.lastSyncedAt}`);
685
681
  if (!hasUnsyncedChanges) {
686
682
  const isDetached = !isProjectContextAttached(project);
687
683
  if (isDetached) {
@@ -758,7 +754,6 @@ export class ClawSpecService {
758
754
  userPrompt: string,
759
755
  mode: ExecutionMode,
760
756
  ): Promise<{ prependContext?: string; prependSystemContext?: string } | PluginCommandResult> {
761
- this.logger.info(`[clawspec] startVisiblePlanningSync called for ${project.changeName}`);
762
757
  void project;
763
758
  void mode;
764
759
 
@@ -781,7 +776,6 @@ export class ClawSpecService {
781
776
  execution: undefined,
782
777
  lastExecutionAt: startedAt,
783
778
  }));
784
- this.logger.info(`[clawspec] Project state updated: status=planning, phase=planning_sync, boundSessionKey=${runningProject.boundSessionKey}`);
785
779
 
786
780
  return await this.buildPlanningSyncInjection(runningProject, userPrompt, prepared.instructionResults);
787
781
  }
@@ -964,7 +958,6 @@ export class ClawSpecService {
964
958
  const planningProject = await this.findPlanningProjectBySessionKey(ctx.sessionKey)
965
959
  ?? await this.findPlanningProjectByContext(ctx);
966
960
  if (planningProject) {
967
- this.logger.info(`[clawspec] agent_end: found planning project ${planningProject.changeName}, calling finalizePlanningTurn`);
968
961
  await this.finalizePlanningTurn(planningProject, event);
969
962
  return;
970
963
  }
@@ -1914,6 +1907,11 @@ export class ClawSpecService {
1914
1907
  );
1915
1908
  }
1916
1909
 
1910
+ async runDoctor(rawArgs?: string): Promise<PluginCommandResult> {
1911
+ const acpxConfigPath = path.join(os.homedir(), ".acpx", "config.json");
1912
+ return await runDoctorCommand(acpxConfigPath, rawArgs);
1913
+ }
1914
+
1917
1915
  private async captureIncomingMessage(channelKey: string, project: ProjectState, text: string): Promise<ProjectState> {
1918
1916
  const trimmed = sanitizePlanningMessageText(text).trim();
1919
1917
  if (!trimmed || trimmed.startsWith("/clawspec") || !project.repoPath || !project.changeName) {
@@ -2412,7 +2410,6 @@ export class ClawSpecService {
2412
2410
  }
2413
2411
 
2414
2412
  private async finalizePlanningTurn(project: ProjectState, event: AgentEndEvent): Promise<void> {
2415
- this.logger.info(`[clawspec] finalizePlanningTurn called for ${project.changeName}, success=${event.success}`);
2416
2413
  if (!project.repoPath || !project.changeName) {
2417
2414
  this.logger.warn(`[clawspec] finalizePlanningTurn skipped: missing repoPath or changeName`);
2418
2415
  return;
@@ -2474,8 +2471,6 @@ export class ClawSpecService {
2474
2471
  }
2475
2472
 
2476
2473
  const snapshot = await journalStore.writeSnapshot(repoStatePaths.planningJournalSnapshotFile, project.changeName, timestamp);
2477
- this.logger.info(`[clawspec] Planning snapshot written for ${project.changeName}: entryCount=${snapshot.entryCount}, lastEntryAt=${snapshot.lastEntryAt}`);
2478
- this.logger.info(`[clawspec] Updating project state: status=${status}, phase=${phase}, dirty=${journalDirty}`);
2479
2474
  await this.writeLatestSummary(repoStatePaths, latestSummary);
2480
2475
 
2481
2476
  const finalized = await this.stateStore.updateProject(project.channelKey, (current) => ({
@@ -3303,3 +3298,138 @@ function parseOptionalNumber(value: string): number | undefined {
3303
3298
  function isRecord(value: unknown): value is Record<string, unknown> {
3304
3299
  return typeof value === "object" && value !== null;
3305
3300
  }
3301
+
3302
+ type DoctorIssue = {
3303
+ id: string;
3304
+ message: string;
3305
+ fix: string;
3306
+ };
3307
+
3308
+ type DoctorContext = {
3309
+ acpxConfigPath: string;
3310
+ acpxConfig: Record<string, unknown> | null;
3311
+ acpxConfigError: boolean;
3312
+ };
3313
+
3314
+ type DoctorCheck = {
3315
+ id: string;
3316
+ check: (ctx: DoctorContext) => DoctorIssue | undefined;
3317
+ applyFix?: (ctx: DoctorContext) => Promise<void>;
3318
+ };
3319
+
3320
+ const DOCTOR_CHECKS: DoctorCheck[] = [
3321
+ {
3322
+ id: "acpx-config-json",
3323
+ check: (ctx) => {
3324
+ if (ctx.acpxConfigError) {
3325
+ return {
3326
+ id: "acpx-config-json",
3327
+ message: `\`${ctx.acpxConfigPath}\` contains invalid JSON. acpx may fail to start.`,
3328
+ fix: `Fix the JSON syntax in \`${ctx.acpxConfigPath}\`, or delete the file to use defaults.`,
3329
+ };
3330
+ }
3331
+ return undefined;
3332
+ },
3333
+ },
3334
+ {
3335
+ id: "acpx-custom-agents",
3336
+ check: (ctx) => {
3337
+ if (!ctx.acpxConfig) return undefined;
3338
+ const agents = ctx.acpxConfig.agents;
3339
+ if (agents && typeof agents === "object" && Object.keys(agents as object).length > 0) {
3340
+ const agentKeys = Object.keys(agents as object).map((k) => `\`${k}\``).join(", ");
3341
+ return {
3342
+ id: "acpx-custom-agents",
3343
+ message:
3344
+ `\`${ctx.acpxConfigPath}\` has custom agent entries: ${agentKeys}.\n`
3345
+ + ` Custom agent configurations (e.g. pointing to a local CLI) can cause session creation to fail with "stdin is not a terminal".`,
3346
+ fix:
3347
+ `Set \`"agents": {}\` in \`${ctx.acpxConfigPath}\` to use the default agent launcher.\n`
3348
+ + ` Run \`/clawspec doctor fix\` to apply this fix automatically.`,
3349
+ };
3350
+ }
3351
+ return undefined;
3352
+ },
3353
+ applyFix: async (ctx) => {
3354
+ if (ctx.acpxConfig) {
3355
+ ctx.acpxConfig.agents = {};
3356
+ await writeJsonFile(ctx.acpxConfigPath, ctx.acpxConfig);
3357
+ }
3358
+ },
3359
+ },
3360
+ ];
3361
+
3362
+ export async function runDoctorCommand(acpxConfigPath: string, rawArgs?: string): Promise<PluginCommandResult> {
3363
+ const action = rawArgs?.trim().toLowerCase();
3364
+
3365
+ let acpxConfig: Record<string, unknown> | null = null;
3366
+ let acpxConfigError = false;
3367
+ const raw = await tryReadUtf8(acpxConfigPath);
3368
+ if (raw !== undefined && raw.trim() !== "") {
3369
+ try {
3370
+ acpxConfig = JSON.parse(raw) as Record<string, unknown>;
3371
+ } catch {
3372
+ acpxConfigError = true;
3373
+ }
3374
+ }
3375
+
3376
+ const ctx: DoctorContext = { acpxConfigPath, acpxConfig, acpxConfigError };
3377
+ const issues: DoctorIssue[] = [];
3378
+ for (const check of DOCTOR_CHECKS) {
3379
+ const issue = check.check(ctx);
3380
+ if (issue) {
3381
+ issues.push(issue);
3382
+ }
3383
+ }
3384
+
3385
+ if (action === "fix") {
3386
+ if (issues.length === 0) {
3387
+ return okReply("Nothing to fix. acpx configuration is already clean.");
3388
+ }
3389
+ const fixable = DOCTOR_CHECKS.filter((c) => c.applyFix && issues.some((i) => i.id === c.id));
3390
+ if (fixable.length === 0) {
3391
+ return errorReply(
3392
+ "Cannot auto-fix the detected issues. Please fix them manually:\n\n"
3393
+ + issues.map((i) => `- ${i.fix}`).join("\n"),
3394
+ );
3395
+ }
3396
+ for (const check of fixable) {
3397
+ await check.applyFix!(ctx);
3398
+ }
3399
+ return okReply(
3400
+ [
3401
+ heading("Doctor Fix Applied"),
3402
+ "",
3403
+ ...fixable.map((c) => {
3404
+ const issue = issues.find((i) => i.id === c.id);
3405
+ return `Fixed: ${issue?.message.split("\n")[0] ?? c.id}`;
3406
+ }),
3407
+ "",
3408
+ "Please retry your previous command.",
3409
+ ].join("\n"),
3410
+ );
3411
+ }
3412
+
3413
+ if (issues.length === 0) {
3414
+ return okReply(
3415
+ [
3416
+ heading("Doctor"),
3417
+ "",
3418
+ "No issues found. acpx configuration looks healthy.",
3419
+ ].join("\n"),
3420
+ );
3421
+ }
3422
+
3423
+ return okReply(
3424
+ [
3425
+ heading("Doctor"),
3426
+ "",
3427
+ `Found ${issues.length} issue${issues.length > 1 ? "s" : ""}:`,
3428
+ "",
3429
+ ...issues.flatMap((issue, i) => [`**${i + 1}.** ${issue.message}`, ""]),
3430
+ "Suggested fixes:",
3431
+ "",
3432
+ ...issues.flatMap((issue, i) => [`**${i + 1}.** ${issue.fix}`, ""]),
3433
+ ].join("\n"),
3434
+ );
3435
+ }
@@ -823,7 +823,7 @@ class ExecutionWatcher {
823
823
  },
824
824
  };
825
825
  });
826
- await this.writeExecutionControl(this.channelKey);
826
+ await this.writeExecutionControl(queued);
827
827
  const nextPlanningStep = nextForcedArtifactId
828
828
  ? (nextPlanningArtifactId(forcedArtifactIds, selectedArtifactId) ?? await this.describeNextPlanningStep(queued))
829
829
  : await this.describeNextPlanningStep(queued);
@@ -906,6 +906,7 @@ class ExecutionWatcher {
906
906
  await writeUtf8(repoStatePaths.workerProgressFile, "");
907
907
 
908
908
  const importedSkills = await loadClawSpecSkillBundle(["apply"]);
909
+
909
910
  ({ runError } = await this.runAcpTurnWithTracking(
910
911
  runningProject,
911
912
  repoStatePaths,
@@ -1023,7 +1024,7 @@ class ExecutionWatcher {
1023
1024
  },
1024
1025
  };
1025
1026
  });
1026
- await this.writeExecutionControl(this.channelKey);
1027
+ await this.writeExecutionControl(queued);
1027
1028
  return true;
1028
1029
  }
1029
1030
 
@@ -1205,6 +1206,17 @@ class ExecutionWatcher {
1205
1206
  await flushProgress();
1206
1207
  return { runError };
1207
1208
  }
1209
+
1210
+ this.logger.debug?.(
1211
+ `[clawspec] calling acpClient.runTurn: ${JSON.stringify({
1212
+ sessionKey,
1213
+ cwd: project.repoPath,
1214
+ agentId: workerAgentId,
1215
+ promptLength: prompt.length,
1216
+ acpxCommand: this.acpClient.command,
1217
+ })}`,
1218
+ );
1219
+
1208
1220
  const runTurnPromise = this.acpClient.runTurn({
1209
1221
  sessionKey,
1210
1222
  cwd: project.repoPath!,
@@ -1637,7 +1649,7 @@ class ExecutionWatcher {
1637
1649
  },
1638
1650
  }));
1639
1651
 
1640
- await this.writeExecutionControl(this.channelKey);
1652
+ await this.writeExecutionControl(recovered);
1641
1653
  await this.notify(
1642
1654
  recovered,
1643
1655
  buildWatcherStatusMessage(
@@ -1698,7 +1710,7 @@ class ExecutionWatcher {
1698
1710
  lastFailure: current.execution?.lastFailure,
1699
1711
  },
1700
1712
  }));
1701
- await this.writeExecutionControl(this.channelKey);
1713
+ await this.writeExecutionControl(updated);
1702
1714
  return updated;
1703
1715
  }
1704
1716
 
@@ -1942,8 +1954,10 @@ class ExecutionWatcher {
1942
1954
  };
1943
1955
  }
1944
1956
 
1945
- private async writeExecutionControl(channelKey: string): Promise<void> {
1946
- const project = await this.stateStore.getActiveProject(channelKey);
1957
+ private async writeExecutionControl(projectOrChannelKey: string | ProjectState): Promise<void> {
1958
+ const project = typeof projectOrChannelKey === "string"
1959
+ ? await this.stateStore.getActiveProject(projectOrChannelKey)
1960
+ : projectOrChannelKey;
1947
1961
  if (!project?.repoPath || !project.changeName || !project.execution) {
1948
1962
  return;
1949
1963
  }
@@ -2781,7 +2795,7 @@ function parseWorkerProgressEvent(line: string): WorkerProgressEvent | undefined
2781
2795
 
2782
2796
  function formatWorkerProgressMessage(project: ProjectState, event: WorkerProgressEvent): string | undefined {
2783
2797
  const rawMessage = typeof event.message === "string" ? event.message : "";
2784
- const message = shortenActivityText(rawMessage, 120);
2798
+ const message = shortenActivityText(compactWorkerProgressDisplayPaths(project, rawMessage), 120);
2785
2799
  if (!message) {
2786
2800
  return undefined;
2787
2801
  }
@@ -2902,6 +2916,74 @@ function compactProjectLabel(project: ProjectState): string {
2902
2916
  return `${projectName}-${changeName}`;
2903
2917
  }
2904
2918
 
2919
+ function compactWorkerProgressDisplayPaths(project: ProjectState, text: string): string {
2920
+ try {
2921
+ const compactRoot = compactWorkerProgressRoot(project);
2922
+ if (!compactRoot) {
2923
+ return text;
2924
+ }
2925
+
2926
+ let compacted = text;
2927
+ if (project.changeDir) {
2928
+ compacted = replaceDisplayPathPrefix(compacted, project.changeDir, `${compactRoot}:`);
2929
+ }
2930
+ if (project.repoPath) {
2931
+ compacted = replaceDisplayPathPrefix(compacted, project.repoPath, `${compactRoot}:`);
2932
+ }
2933
+ compacted = normalizeCompactedDisplayPaths(compacted, compactRoot);
2934
+ return compacted.length < text.length ? compacted : text;
2935
+ } catch {
2936
+ return text;
2937
+ }
2938
+ }
2939
+
2940
+ function compactWorkerProgressRoot(project: ProjectState): string | undefined {
2941
+ const changeName = project.changeName?.trim();
2942
+ if (!changeName) {
2943
+ return undefined;
2944
+ }
2945
+ const projectName = project.projectName?.trim()
2946
+ || (project.repoPath ? path.basename(project.repoPath) : undefined)
2947
+ || "project";
2948
+ return `${projectName}@${changeName}`;
2949
+ }
2950
+
2951
+ function replaceDisplayPathPrefix(text: string, targetPath: string, replacement: string): string {
2952
+ const pattern = buildDisplayPathPrefixPattern(targetPath);
2953
+ if (!pattern) {
2954
+ return text;
2955
+ }
2956
+ return text.replace(pattern, replacement);
2957
+ }
2958
+
2959
+ function normalizeCompactedDisplayPaths(text: string, compactRoot: string): string {
2960
+ const prefix = escapeRegExp(`${compactRoot}:`);
2961
+ const pattern = new RegExp("(" + prefix + ")([^\\s\"'`,)\\]}]+)", "g");
2962
+ return text.replace(pattern, (_match, prefix: string, suffix: string) => `${prefix}${suffix.replace(/\\/g, "/")}`);
2963
+ }
2964
+
2965
+ function buildDisplayPathPrefixPattern(targetPath: string): RegExp | undefined {
2966
+ const normalized = normalizeSlashes(targetPath).replace(/\/+$/, "");
2967
+ if (!normalized) {
2968
+ return undefined;
2969
+ }
2970
+
2971
+ const escaped = normalized
2972
+ .split("/")
2973
+ .map((segment) => escapeRegExp(segment))
2974
+ .join("[/\\\\]+");
2975
+ if (!escaped) {
2976
+ return undefined;
2977
+ }
2978
+
2979
+ const flags = /^[A-Za-z]:/.test(normalized) ? "gi" : "g";
2980
+ return new RegExp(`${escaped}(?:[/\\\\]+)?`, flags);
2981
+ }
2982
+
2983
+ function escapeRegExp(value: string): string {
2984
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2985
+ }
2986
+
2905
2987
  function compactProgressMarker(current?: number, total?: number): string {
2906
2988
  if (!total || total <= 0 || !current || current <= 0) {
2907
2989
  return "";
@@ -118,9 +118,10 @@ export async function ensureWorkerIoHelper(repoStatePaths: RepoStatePaths): Prom
118
118
  }
119
119
 
120
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, '\\"')}"`;
121
+ const ioPath = repoStatePaths.workerIoFile;
122
+ // Use single quotes on POSIX, double quotes on Windows for cross-platform compatibility
123
+ if (process.platform === "win32") {
124
+ return `node "${ioPath.replace(/"/g, '""')}" event`;
125
+ }
126
+ return `node '${ioPath.replace(/'/g, `'\\''`)}' event`;
126
127
  }