clawspec 1.0.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawspec",
3
- "version": "1.0.2",
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": [
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
+ `;
@@ -178,10 +178,10 @@ async function checkAcpxVersion(
178
178
  message: "acpx --version did not return a parseable version",
179
179
  };
180
180
  }
181
- if (version !== params.expectedVersion) {
181
+ if (compareSemver(version, params.expectedVersion) < 0) {
182
182
  return {
183
183
  ok: false,
184
- message: `acpx version mismatch: found ${version}, expected ${params.expectedVersion}`,
184
+ message: `acpx version too old: found ${version}, require >= ${params.expectedVersion}`,
185
185
  };
186
186
  }
187
187
  return { ok: true, version };
@@ -219,3 +219,77 @@ function getBuiltInAcpxCommand(runtimeEntrypoint: string | undefined): string |
219
219
  process.platform === "win32" ? "acpx.cmd" : "acpx",
220
220
  );
221
221
  }
222
+
223
+ function compareSemver(left: string, right: string): number {
224
+ const parsedLeft = parseSemver(left);
225
+ const parsedRight = parseSemver(right);
226
+ if (!parsedLeft || !parsedRight) {
227
+ return left.localeCompare(right);
228
+ }
229
+
230
+ for (let index = 0; index < 3; index += 1) {
231
+ const delta = parsedLeft.core[index] - parsedRight.core[index];
232
+ if (delta !== 0) {
233
+ return delta;
234
+ }
235
+ }
236
+
237
+ const leftPre = parsedLeft.prerelease;
238
+ const rightPre = parsedRight.prerelease;
239
+ if (leftPre.length === 0 && rightPre.length === 0) {
240
+ return 0;
241
+ }
242
+ if (leftPre.length === 0) {
243
+ return 1;
244
+ }
245
+ if (rightPre.length === 0) {
246
+ return -1;
247
+ }
248
+
249
+ const maxLength = Math.max(leftPre.length, rightPre.length);
250
+ for (let index = 0; index < maxLength; index += 1) {
251
+ const leftToken = leftPre[index];
252
+ const rightToken = rightPre[index];
253
+ if (leftToken === undefined) {
254
+ return -1;
255
+ }
256
+ if (rightToken === undefined) {
257
+ return 1;
258
+ }
259
+ const delta = comparePrereleaseToken(leftToken, rightToken);
260
+ if (delta !== 0) {
261
+ return delta;
262
+ }
263
+ }
264
+ return 0;
265
+ }
266
+
267
+ function parseSemver(value: string): { core: [number, number, number]; prerelease: string[] } | null {
268
+ const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
269
+ if (!match) {
270
+ return null;
271
+ }
272
+ return {
273
+ core: [
274
+ Number.parseInt(match[1]!, 10),
275
+ Number.parseInt(match[2]!, 10),
276
+ Number.parseInt(match[3]!, 10),
277
+ ],
278
+ prerelease: match[4] ? match[4].split(".") : [],
279
+ };
280
+ }
281
+
282
+ function comparePrereleaseToken(left: string, right: string): number {
283
+ const leftNumeric = /^\d+$/.test(left);
284
+ const rightNumeric = /^\d+$/.test(right);
285
+ if (leftNumeric && rightNumeric) {
286
+ return Number.parseInt(left, 10) - Number.parseInt(right, 10);
287
+ }
288
+ if (leftNumeric) {
289
+ return -1;
290
+ }
291
+ if (rightNumeric) {
292
+ return 1;
293
+ }
294
+ return left.localeCompare(right);
295
+ }
@@ -2,8 +2,10 @@ import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { spawn } from "node:child_process";
5
6
  import { chmod, mkdtemp, writeFile } from "node:fs/promises";
6
7
  import { AcpWorkerClient } from "../src/acp/client.ts";
8
+ import { terminateChildProcess } from "../src/utils/shell-command.ts";
7
9
 
8
10
  test("AcpWorkerClient tracks active worker lifecycle through acpx CLI", async () => {
9
11
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-acpx-client-"));
@@ -46,6 +48,63 @@ test("AcpWorkerClient tracks active worker lifecycle through acpx CLI", async ()
46
48
  assert.match(finalStatus?.summary ?? "", /status=dead/);
47
49
  });
48
50
 
51
+ test("AcpWorkerClient stops the worker when the gateway process disappears", async () => {
52
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-acpx-watchdog-"));
53
+ const fake = await createFakeAcpx(tempRoot);
54
+ const gateway = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
55
+ stdio: "ignore",
56
+ windowsHide: true,
57
+ detached: true,
58
+ });
59
+ gateway.unref();
60
+ const client = new AcpWorkerClient({
61
+ agentId: "codex",
62
+ logger: createLogger(),
63
+ command: fake.command,
64
+ env: fake.env,
65
+ gatewayPid: gateway.pid,
66
+ gatewayWatchdogPollMs: 50,
67
+ });
68
+
69
+ const startedAt = Date.now();
70
+ let sawStarted = false;
71
+ const runPromise = client.runTurn({
72
+ sessionKey: "session-watchdog",
73
+ cwd: tempRoot,
74
+ text: "stay alive",
75
+ onEvent: async (event) => {
76
+ if (event.type === "text_delta" && event.text?.includes("Working on stay alive")) {
77
+ sawStarted = true;
78
+ }
79
+ },
80
+ });
81
+ const observedRunPromise = runPromise.catch((error) => {
82
+ throw error;
83
+ });
84
+
85
+ try {
86
+ await waitFor(async () => sawStarted === true);
87
+
88
+ terminateChildProcess(gateway, { force: true });
89
+
90
+ await assert.rejects(
91
+ observedRunPromise,
92
+ /acpx exited with code|signal=SIGTERM|terminated/i,
93
+ );
94
+ } finally {
95
+ terminateChildProcess(gateway, { force: true });
96
+ await observedRunPromise.catch(() => undefined);
97
+ }
98
+
99
+ assert.ok(Date.now() - startedAt < 4_000, "watchdog should stop the worker quickly");
100
+ const finalStatus = await client.getSessionStatus({
101
+ sessionKey: "session-watchdog",
102
+ cwd: tempRoot,
103
+ agentId: "codex",
104
+ });
105
+ assert.match(finalStatus?.summary ?? "", /status=dead/);
106
+ });
107
+
49
108
  async function createFakeAcpx(tempRoot: string): Promise<{ command: string; env: NodeJS.ProcessEnv }> {
50
109
  const scriptPath = path.join(tempRoot, "fake-acpx.js");
51
110
  const wrapperPath = path.join(tempRoot, process.platform === "win32" ? "fake-acpx.cmd" : "fake-acpx");
@@ -93,6 +152,18 @@ async function readStdin() {
93
152
  return Buffer.concat(chunks).toString("utf8");
94
153
  }
95
154
 
155
+ function isAlive(pid) {
156
+ if (!Number.isFinite(pid) || pid <= 0) {
157
+ return false;
158
+ }
159
+ try {
160
+ process.kill(pid, 0);
161
+ return true;
162
+ } catch (error) {
163
+ return error && typeof error === "object" && error.code === "EPERM";
164
+ }
165
+ }
166
+
96
167
  async function writeJsonLine(value) {
97
168
  process.stdout.write(JSON.stringify(value) + "\\n");
98
169
  }
@@ -122,8 +193,8 @@ async function main() {
122
193
 
123
194
  async function runningExists() {
124
195
  try {
125
- await fs.access(runningFile);
126
- return true;
196
+ const payload = JSON.parse(await fs.readFile(runningFile, "utf8"));
197
+ return isAlive(payload && payload.pid);
127
198
  } catch {
128
199
  return false;
129
200
  }
@@ -167,13 +238,17 @@ async function main() {
167
238
 
168
239
  if (verb === "prompt") {
169
240
  const text = (await readStdin()).trim();
170
- await fs.writeFile(runningFile, "running", "utf8");
241
+ await fs.writeFile(runningFile, JSON.stringify({ pid: process.pid }), "utf8");
171
242
  process.on("SIGTERM", async () => {
172
243
  await fs.rm(runningFile, { force: true });
173
244
  process.exit(143);
174
245
  });
175
246
  await new Promise((resolve) => setTimeout(resolve, 60));
176
247
  await writeJsonLine({ text: "Working on " + text });
248
+ if (sessionName === "session-watchdog" || text.includes("stay alive")) {
249
+ setInterval(() => {}, 1_000);
250
+ return;
251
+ }
177
252
  await new Promise((resolve) => setTimeout(resolve, 80));
178
253
  await fs.rm(runningFile, { force: true });
179
254
  await writeJsonLine({ type: "done" });
@@ -52,6 +52,27 @@ test("ensureAcpxCli uses the global acpx command when available", async () => {
52
52
  assert.equal(calls.some((call) => call.command === "npm"), false);
53
53
  });
54
54
 
55
+ test("ensureAcpxCli accepts a newer global acpx version", async () => {
56
+ const calls: Array<{ command: string; args: string[] }> = [];
57
+ const result = await ensureAcpxCli({
58
+ pluginRoot: ROOT_PREFIX,
59
+ runner: async ({ command, args }) => {
60
+ calls.push({ command, args });
61
+ if (command === LOCAL_COMMAND) {
62
+ return { code: 1, stdout: "", stderr: "not found" };
63
+ }
64
+ if (command === "acpx") {
65
+ return { code: 0, stdout: "0.3.2\n", stderr: "" };
66
+ }
67
+ return { code: 1, stdout: "", stderr: "unexpected command" };
68
+ },
69
+ });
70
+
71
+ assert.equal(result.source, "global");
72
+ assert.equal(result.version, "0.3.2");
73
+ assert.equal(calls.some((call) => call.command === "npm"), false);
74
+ });
75
+
55
76
  test("ensureAcpxCli prefers the OpenClaw builtin acpx over an incompatible PATH acpx", async () => {
56
77
  const calls: Array<{ command: string; args: string[] }> = [];
57
78
  const result = await ensureAcpxCli({