ccqa 1.40.0 → 1.40.1

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/dist/bin/ccqa.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { HubApiError, createHubClient, hubRequest } from "../hub-client/index.mjs";
3
3
  import { t as EVIDENCE_DIR_ENV } from "../evidence-constants-C425F7ZG.mjs";
4
- import { a as formatAgentBrowserUnavailableMessage, i as assertAgentBrowserAvailable, n as spawnAB, o as pathWithAgentBrowserShim, r as AgentBrowserUnavailableError, s as resolveAgentBrowserBin$1, t as sleepSync } from "../spawn-ab-CRIVfWpw.mjs";
4
+ import { a as formatAgentBrowserUnavailableMessage, i as assertAgentBrowserAvailable, n as spawnAB, o as pathWithAgentBrowserShim, r as AgentBrowserUnavailableError, s as resolveAgentBrowserBin$1, t as sleepSync } from "../spawn-ab-Bm34WBui.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import { Command } from "commander";
7
7
  import { accessSync, appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -19,6 +19,7 @@ import { promisify } from "node:util";
19
19
  import { createInterface } from "node:readline/promises";
20
20
  import { createServer } from "node:http";
21
21
  import { gunzipSync, gzipSync } from "node:zlib";
22
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
22
23
  import { createInterface as createInterface$1 } from "node:readline";
23
24
  import { createServer as createServer$1 } from "node:net";
24
25
  //#region src/run/report-constants.ts
@@ -1615,10 +1616,17 @@ function warnOnceIfNativeBinaryMissing() {
1615
1616
  const missing = missingNativeBinaryPackage();
1616
1617
  if (missing) warn(missingNativeBinaryMessage(missing));
1617
1618
  }
1619
+ /** Whole minutes read best, but the ceiling is set in ms and may be seconds. */
1620
+ function formatDuration$1(ms) {
1621
+ if (ms < 6e4 || ms % 6e4 !== 0) return `${Math.round(ms / 1e3)}s`;
1622
+ const minutes = ms / 6e4;
1623
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
1624
+ }
1618
1625
  async function invokeClaudeStreaming(options, onEvent) {
1619
- const { prompt, systemPrompt, allowedTools, disableBuiltinTools = false, disableThinking = false, mcpServers, maxTurns, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false, envScrubMap = [], relaxAbConstraints = false } = options;
1626
+ const { prompt, systemPrompt, allowedTools, disableBuiltinTools = false, disableThinking = false, mcpServers, maxTurns, timeoutMs, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false, envScrubMap = [], relaxAbConstraints = false } = options;
1620
1627
  const resolvedModel = resolveModel(model);
1621
1628
  const mergedEnv = buildInvocationEnv(env);
1629
+ const abortController = new AbortController();
1622
1630
  let lastAbToolUseId = null;
1623
1631
  const claimAbToolUse = (toolUseId) => {
1624
1632
  if (toolUseId !== lastAbToolUseId) return false;
@@ -1631,6 +1639,7 @@ async function invokeClaudeStreaming(options, onEvent) {
1631
1639
  allowedTools: allowedTools ?? ["Bash(*)"],
1632
1640
  permissionMode: "bypassPermissions",
1633
1641
  allowDangerouslySkipPermissions: true,
1642
+ abortController,
1634
1643
  ...resolvedModel ? { model: resolvedModel } : {},
1635
1644
  ...cwd ? { cwd } : {},
1636
1645
  ...mergedEnv ? { env: mergedEnv } : {},
@@ -1695,7 +1704,10 @@ async function invokeClaudeStreaming(options, onEvent) {
1695
1704
  } : void 0
1696
1705
  };
1697
1706
  warnOnceIfNativeBinaryMissing();
1707
+ const capTimer = timeoutMs === void 0 ? null : setTimeout(() => abortController.abort(), timeoutMs);
1708
+ capTimer?.unref?.();
1698
1709
  let result = "";
1710
+ let answered = false;
1699
1711
  let isError = false;
1700
1712
  let errorDetail = null;
1701
1713
  let cost = {
@@ -1720,6 +1732,7 @@ async function invokeClaudeStreaming(options, onEvent) {
1720
1732
  }
1721
1733
  }
1722
1734
  if (msg.type === "result") {
1735
+ answered = true;
1723
1736
  isError = msg.is_error ?? false;
1724
1737
  if (msg.subtype === "success") result = msg.result;
1725
1738
  else {
@@ -1733,6 +1746,13 @@ async function invokeClaudeStreaming(options, onEvent) {
1733
1746
  isError = true;
1734
1747
  errorDetail = err instanceof Error ? err.message : String(err);
1735
1748
  if (!result) result = errorDetail;
1749
+ } finally {
1750
+ if (capTimer) clearTimeout(capTimer);
1751
+ }
1752
+ if (abortController.signal.aborted && timeoutMs !== void 0 && !answered) {
1753
+ isError = true;
1754
+ errorDetail = `stopped after ${formatDuration$1(timeoutMs)} (host time limit)`;
1755
+ result = errorDetail;
1736
1756
  }
1737
1757
  tallyInvocation(cost);
1738
1758
  return {
@@ -10490,16 +10510,8 @@ async function removeTempStateDir(statePath) {
10490
10510
  * than throwing; the caller decides whether an un-restored session is fatal.
10491
10511
  */
10492
10512
  function loadStateIntoSession(sessionName, statePath) {
10493
- const boot = spawnAB([
10494
- "--session",
10495
- sessionName,
10496
- "open",
10497
- "about:blank"
10498
- ]);
10499
- if (boot.status !== 0) return {
10500
- ok: false,
10501
- error: (boot.stderr || boot.stdout || `open exited ${boot.status}`).trim()
10502
- };
10513
+ const boot = bootSession(sessionName);
10514
+ if (!boot.ok) return boot;
10503
10515
  const load = spawnAB([
10504
10516
  "--session",
10505
10517
  sessionName,
@@ -10509,7 +10521,26 @@ function loadStateIntoSession(sessionName, statePath) {
10509
10521
  ]);
10510
10522
  if (load.status !== 0) return {
10511
10523
  ok: false,
10512
- error: (load.stderr || load.stdout || `state load exited ${load.status}`).trim()
10524
+ error: (load.stderr || load.stdout || `state load exited ${load.status}`).trim(),
10525
+ wedged: load.wedged
10526
+ };
10527
+ return { ok: true };
10528
+ }
10529
+ /**
10530
+ * Bring up the session's daemon and browser without navigating, so whatever
10531
+ * the caller attaches next lands on the session rather than racing a page load.
10532
+ */
10533
+ function bootSession(sessionName) {
10534
+ const boot = spawnAB([
10535
+ "--session",
10536
+ sessionName,
10537
+ "open",
10538
+ "about:blank"
10539
+ ]);
10540
+ if (boot.status !== 0) return {
10541
+ ok: false,
10542
+ error: (boot.stderr || boot.stdout || `open exited ${boot.status}`).trim(),
10543
+ wedged: boot.wedged
10513
10544
  };
10514
10545
  return { ok: true };
10515
10546
  }
@@ -10636,8 +10667,7 @@ function verifySessionRestores(statePath, verifyUrl) {
10636
10667
  * false "unhealthy" would re-inject the saved state and wipe auth the spec
10637
10668
  * acquired live — breaking a spec that was fine. Missing a same-origin-ish
10638
10669
  * sign-in wall just leaves that step failing as before (no regression), so the
10639
- * asymmetry favours only firing on a provably dead daemon. `verifyUrl` is still
10640
- * required (it's the re-anchor target for recovery) but no longer compared here.
10670
+ * asymmetry favours only firing on a provably dead daemon.
10641
10671
  */
10642
10672
  function checkLiveSessionHealth(sessionName) {
10643
10673
  const probe = spawnAB([
@@ -10648,30 +10678,30 @@ function checkLiveSessionHealth(sessionName) {
10648
10678
  ]);
10649
10679
  if (probe.status !== 0) return {
10650
10680
  healthy: false,
10681
+ kind: probe.wedged === true ? "unresponsive" : "errored",
10651
10682
  reason: (probe.stderr || probe.stdout || `probe exited ${probe.status}`).trim()
10652
10683
  };
10653
10684
  const href = unwrapEvalString(probe.stdout);
10654
10685
  if (!href || href === "about:blank" || href.startsWith("chrome://") || href.startsWith("chrome-error://")) return {
10655
10686
  healthy: false,
10687
+ kind: "blank",
10656
10688
  reason: `blank/absent page (${href || "empty"})`
10657
10689
  };
10658
10690
  return { healthy: true };
10659
10691
  }
10660
10692
  /**
10661
- * Recover a live session whose daemon was replaced mid-run (detected by
10662
- * {@link checkLiveSessionHealth}). The restart drops the in-memory auth-state
10663
- * injected at run start, so the session fell to a sign-in wall. Re-boot +
10664
- * re-attach the saved state ({@link loadStateIntoSession} is idempotent —
10665
- * `state load` is load-only, never writes back) and then navigate to
10666
- * `verifyUrl`, a known signed-in page, so the retrying model has an
10667
- * authenticated anchor to continue from instead of a login screen. Returns the
10668
- * injection result; the trailing `open` is best-effort (a failed nav still
10669
- * leaves the state attached for the model's own next navigation).
10693
+ * Put a live session back on its feet after {@link checkLiveSessionHealth}
10694
+ * found it broken: boot its browser, re-attach the saved state if it has one,
10695
+ * and anchor on `verifyUrl` so a retrying model resumes from a signed-in page.
10696
+ *
10697
+ * An unresponsive daemon must already have been killed by the caller — every
10698
+ * command below goes through the socket it is ignoring. The trailing `open` is
10699
+ * best-effort; a failed nav still leaves a usable session behind.
10670
10700
  */
10671
10701
  function recoverLiveSession(sessionName, statePath, verifyUrl) {
10672
- const injected = loadStateIntoSession(sessionName, statePath);
10673
- if (!injected.ok) return injected;
10674
- spawnAB([
10702
+ const restored = statePath ? loadStateIntoSession(sessionName, statePath) : bootSession(sessionName);
10703
+ if (!restored.ok) return restored;
10704
+ if (verifyUrl) spawnAB([
10675
10705
  "--session",
10676
10706
  sessionName,
10677
10707
  "open",
@@ -12051,6 +12081,146 @@ function buildLiveUserPrompt(step) {
12051
12081
  return `Execute step ${step.id} and emit your STEP_RESULT verdict as instructed in the system prompt.`;
12052
12082
  }
12053
12083
  //#endregion
12084
+ //#region src/runtime/agent-browser-daemon.ts
12085
+ /**
12086
+ * Forcing a session's agent-browser daemon out when it has stopped answering.
12087
+ *
12088
+ * Every other way ccqa reaches a daemon is a command over that daemon's socket
12089
+ * — `close`, the only shutdown the CLI offers, included — so none of them work
12090
+ * on one that no longer reads it. The per-session pid file does not.
12091
+ */
12092
+ /** Measured against agent-browser 0.26-0.34. */
12093
+ function agentBrowserRuntimeDir() {
12094
+ const explicit = process.env["AGENT_BROWSER_SOCKET_DIR"];
12095
+ const xdg = process.env["XDG_RUNTIME_DIR"];
12096
+ const base = explicit ?? (xdg ? join(xdg, "agent-browser") : join(homedir(), ".agent-browser"));
12097
+ const namespace = process.env["AGENT_BROWSER_NAMESPACE"];
12098
+ return namespace ? join(base, "namespaces", namespace, "run") : base;
12099
+ }
12100
+ /** The daemon pid agent-browser recorded for `sessionName`, if it wrote one. */
12101
+ function readDaemonPid(sessionName) {
12102
+ try {
12103
+ const pid = Number(readFileSync(join(agentBrowserRuntimeDir(), `${sessionName}.pid`), "utf8").trim());
12104
+ return Number.isInteger(pid) && pid > 1 ? pid : null;
12105
+ } catch {
12106
+ return null;
12107
+ }
12108
+ }
12109
+ function isAlive(pid) {
12110
+ try {
12111
+ process.kill(pid, 0);
12112
+ return true;
12113
+ } catch (e) {
12114
+ return e.code === "EPERM";
12115
+ }
12116
+ }
12117
+ /**
12118
+ * A pid file outlives an unclean exit, so its number may since have been handed
12119
+ * to something else. Where `ps` cannot answer — Windows has none — decline
12120
+ * rather than guess, which makes the kill a no-op instead of a hazard.
12121
+ *
12122
+ * Residual risk this cannot cover: a pid recycled onto *another session's*
12123
+ * daemon has identical argv and passes.
12124
+ */
12125
+ function looksLikeAgentBrowser(pid) {
12126
+ const ps = spawnSync("ps", [
12127
+ "-p",
12128
+ String(pid),
12129
+ "-o",
12130
+ "args="
12131
+ ], { encoding: "utf8" });
12132
+ return ps.status === 0 && ps.stdout.includes("agent-browser");
12133
+ }
12134
+ function childPids(parent) {
12135
+ const ps = spawnSync("ps", ["-eo", "pid=,ppid="], { encoding: "utf8" });
12136
+ if (ps.status !== 0) return [];
12137
+ const children = [];
12138
+ for (const line of ps.stdout.split("\n")) {
12139
+ const [pid, ppid] = line.trim().split(/\s+/).map(Number);
12140
+ if (pid && ppid === parent) children.push(pid);
12141
+ }
12142
+ return children;
12143
+ }
12144
+ /** Ramped so the common case — a daemon that exits at once — is not taxed. */
12145
+ const TERM_POLL_MS = [
12146
+ 25,
12147
+ 50,
12148
+ 100,
12149
+ 250,
12150
+ 250,
12151
+ 250,
12152
+ 500,
12153
+ 500,
12154
+ 1e3,
12155
+ 1e3,
12156
+ 1e3
12157
+ ];
12158
+ /** One phrase covering both outcomes, so callers log a single line. */
12159
+ function describeKill(kill) {
12160
+ return kill.killed ? `killed daemon pid ${kill.pid}` : `could not kill the daemon (${kill.reason})`;
12161
+ }
12162
+ /**
12163
+ * Stop the daemon serving `sessionName`. The session stays reusable —
12164
+ * agent-browser boots a fresh daemon under the same name and clears the stale
12165
+ * socket itself.
12166
+ *
12167
+ * SIGTERM is given time because a daemon that exits on its own signal takes its
12168
+ * browser down with it, while SIGKILL leaves that browser running with no owner.
12169
+ */
12170
+ async function killSessionDaemon(sessionName) {
12171
+ const pid = readDaemonPid(sessionName);
12172
+ if (pid === null) return {
12173
+ killed: false,
12174
+ reason: "no pid file for this session"
12175
+ };
12176
+ if (!isAlive(pid)) return {
12177
+ killed: false,
12178
+ reason: `pid ${pid} is not running`
12179
+ };
12180
+ if (!looksLikeAgentBrowser(pid)) return {
12181
+ killed: false,
12182
+ reason: `pid ${pid} is not an agent-browser process`
12183
+ };
12184
+ try {
12185
+ process.kill(pid, "SIGTERM");
12186
+ } catch {
12187
+ return {
12188
+ killed: false,
12189
+ reason: `pid ${pid} could not be signalled`
12190
+ };
12191
+ }
12192
+ for (const wait of TERM_POLL_MS) {
12193
+ if (!isAlive(pid)) return {
12194
+ killed: true,
12195
+ pid
12196
+ };
12197
+ await setTimeout$1(wait);
12198
+ }
12199
+ if (!isAlive(pid)) return {
12200
+ killed: true,
12201
+ pid
12202
+ };
12203
+ const owned = childPids(pid);
12204
+ try {
12205
+ process.kill(pid, "SIGKILL");
12206
+ } catch {}
12207
+ for (const wait of TERM_POLL_MS) {
12208
+ if (!isAlive(pid)) break;
12209
+ await setTimeout$1(wait);
12210
+ }
12211
+ if (isAlive(pid)) return {
12212
+ killed: false,
12213
+ reason: `pid ${pid} survived SIGKILL`
12214
+ };
12215
+ for (const child of owned) if (isAlive(child)) try {
12216
+ process.kill(child, "SIGTERM");
12217
+ } catch {}
12218
+ return {
12219
+ killed: true,
12220
+ pid
12221
+ };
12222
+ }
12223
+ //#endregion
12054
12224
  //#region src/runtime/live-result-parse.ts
12055
12225
  const MAX_REASON_LEN = 2e3;
12056
12226
  /** Parse a single STEP_RESULT line. Returns null on malformed input. */
@@ -12142,7 +12312,14 @@ async function runLiveExecutor(input) {
12142
12312
  const retries = Math.max(0, input.retries ?? 0);
12143
12313
  if (statePath) {
12144
12314
  const injected = loadStateIntoSession(input.sessionName, statePath);
12145
- if (!injected.ok) warn(`session state restore failed: ${injected.error}`);
12315
+ if (!injected.ok && injected.wedged) {
12316
+ const kill = await killSessionDaemon(input.sessionName);
12317
+ warn(`session state restore failed: ${injected.error}; ${describeKill(kill)}`);
12318
+ if (kill.killed) {
12319
+ const retried = loadStateIntoSession(input.sessionName, statePath);
12320
+ if (!retried.ok) warn(`session state restore failed again: ${retried.error}`);
12321
+ }
12322
+ } else if (!injected.ok) warn(`session state restore failed: ${injected.error}`);
12146
12323
  }
12147
12324
  for (let i = 0; i < input.steps.length; i++) {
12148
12325
  const step$1 = input.steps[i];
@@ -12163,15 +12340,18 @@ async function runLiveExecutor(input) {
12163
12340
  for (;;) {
12164
12341
  lastOutcome = await executeStepAttempt(step$1, paths, systemPrompt, userPrompt);
12165
12342
  if (lastOutcome.status === "passed") break;
12166
- if (!recoveredOnce && statePath && verifyUrl) {
12343
+ if (!recoveredOnce) {
12344
+ recoveredOnce = true;
12167
12345
  const health = checkLiveSessionHealth(input.sessionName);
12168
- if (!health.healthy) {
12169
- warn(`session lost mid-step for ${step$1.id} (${health.reason}); re-injecting auth-state and retrying`);
12170
- const rec = recoverLiveSession(input.sessionName, statePath, verifyUrl);
12171
- if (!rec.ok) warn(`session recovery failed: ${rec.error}`);
12172
- recoveredOnce = true;
12173
- attempt++;
12174
- continue;
12346
+ if (!health.healthy && (health.kind !== "blank" || statePath)) {
12347
+ const kill = health.kind === "unresponsive" ? await killSessionDaemon(input.sessionName) : null;
12348
+ warn(`session broken during ${step$1.id} (${health.reason}); ` + (kill ? describeKill(kill) : "re-injecting auth-state"));
12349
+ if (!kill || kill.killed) {
12350
+ const rec = recoverLiveSession(input.sessionName, statePath, verifyUrl);
12351
+ if (!rec.ok) warn(`session recovery failed: ${rec.error}`);
12352
+ attempt++;
12353
+ continue;
12354
+ }
12175
12355
  }
12176
12356
  }
12177
12357
  if (attempt >= retries) break;
@@ -12215,7 +12395,8 @@ async function runLiveExecutor(input) {
12215
12395
  systemPrompt,
12216
12396
  model: input.model,
12217
12397
  envScrubMap: input.envScrubMap,
12218
- relaxAbConstraints: true
12398
+ relaxAbConstraints: true,
12399
+ timeoutMs: stepAttemptTimeoutMs()
12219
12400
  }, (msg) => {
12220
12401
  if (msg.type !== "assistant") return;
12221
12402
  for (const block of msg.message.content ?? []) {
@@ -12271,6 +12452,18 @@ async function runLiveExecutor(input) {
12271
12452
  * are the winning path — exactly the shortcut a later run should reuse.
12272
12453
  */
12273
12454
  const MAX_LEARNED_COMMANDS = 15;
12455
+ /**
12456
+ * Wall-clock ceiling on one step attempt. The prompt's own ~3 minute wait
12457
+ * budget cannot end a step, because a model parked on a notification never
12458
+ * comes back to read it. Sized off measured runs: the longest passing step was
12459
+ * 4 minutes, against a wedged one that ran 15.
12460
+ */
12461
+ const STEP_ATTEMPT_TIMEOUT_MS = 8 * 6e4;
12462
+ /** Env override so a slow environment can be tuned without a release. */
12463
+ function stepAttemptTimeoutMs() {
12464
+ const raw = Number(process.env["CCQA_LIVE_STEP_TIMEOUT_MS"]);
12465
+ return Number.isFinite(raw) && raw > 0 ? raw : STEP_ATTEMPT_TIMEOUT_MS;
12466
+ }
12274
12467
  function emptyStepCost() {
12275
12468
  return {
12276
12469
  totalCostUsd: null,
@@ -12581,13 +12774,14 @@ function truncate(s, maxBytes) {
12581
12774
  * Close an agent-browser session by name. Used before/after a `ccqa generate`
12582
12775
  * run so a wedged daemon from a previous attempt can't hang the next one.
12583
12776
  *
12584
- * Always resolves; never throws. If the binary is missing, the session
12585
- * doesn't exist, or the call exceeds {@link CLOSE_TIMEOUT_MS}, we silently
12586
- * return — close is best-effort cleanup, not a precondition.
12777
+ * Always resolves; never throws. If the binary is missing or the session
12778
+ * doesn't exist, we silently return close is best-effort cleanup, not a
12779
+ * precondition.
12587
12780
  */
12588
12781
  async function closeSession(sessionName) {
12589
12782
  const abBin = resolveAgentBrowserBin();
12590
12783
  if (!abBin) return;
12784
+ let unanswered = false;
12591
12785
  await new Promise((resolve) => {
12592
12786
  const child = spawn(process.execPath, [abBin, "close"], {
12593
12787
  env: {
@@ -12597,6 +12791,7 @@ async function closeSession(sessionName) {
12597
12791
  stdio: "ignore"
12598
12792
  });
12599
12793
  const timer = setTimeout(() => {
12794
+ unanswered = true;
12600
12795
  child.kill("SIGTERM");
12601
12796
  }, CLOSE_TIMEOUT_MS);
12602
12797
  const finish = () => {
@@ -12606,6 +12801,7 @@ async function closeSession(sessionName) {
12606
12801
  child.on("error", finish);
12607
12802
  child.on("exit", finish);
12608
12803
  });
12804
+ if (unanswered) await killSessionDaemon(sessionName);
12609
12805
  }
12610
12806
  //#endregion
12611
12807
  //#region src/cli/run-live.ts
@@ -17279,7 +17475,7 @@ function runValidationAction(action, sessionName, envOverrides = {}) {
17279
17475
  };
17280
17476
  }
17281
17477
  let result = spawnAB(built);
17282
- if (result.status !== 0 && looksLikeHardTimeout(result)) result = spawnAB(built);
17478
+ if (result.status !== 0 && result.wedged === true) result = spawnAB(built);
17283
17479
  if (result.status === 0) return {
17284
17480
  skipped: false,
17285
17481
  ok: true,
@@ -17410,10 +17606,6 @@ function rescueLostSteps(actions, kept, dropped, opts) {
17410
17606
  rescuedSteps
17411
17607
  };
17412
17608
  }
17413
- /** Did this agent-browser invocation get SIGTERM'd by the ccqa hard-timeout watchdog? */
17414
- function looksLikeHardTimeout(result) {
17415
- return result.stderr.includes("agent-browser killed after hard timeout");
17416
- }
17417
17609
  /**
17418
17610
  * Passive (read-only) actions whose only effect is observation. When a
17419
17611
  * preceding action fails, dropping these too is the right move because
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.40.0",
3
+ "version": "1.40.1",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
@@ -1,5 +1,5 @@
1
1
  import { i as sanitizeStepId, n as FAILURE_SOURCE, r as FAILURE_STEP_ID, t as EVIDENCE_DIR_ENV } from "../evidence-constants-C425F7ZG.mjs";
2
- import { n as spawnAB, t as sleepSync } from "../spawn-ab-CRIVfWpw.mjs";
2
+ import { n as spawnAB, t as sleepSync } from "../spawn-ab-Bm34WBui.mjs";
3
3
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
  //#region src/runtime/test-helpers.ts
@@ -163,10 +163,12 @@ function spawnABOnce(args) {
163
163
  stdio: "pipe",
164
164
  timeout: PROCESS_HARD_TIMEOUT_MS
165
165
  });
166
+ const wedged = result.error?.code === "ETIMEDOUT";
166
167
  return {
167
168
  status: result.status,
168
169
  stdout: result.stdout?.toString() ?? "",
169
- stderr: (result.stderr?.toString() ?? "") + (result.signal === "SIGTERM" ? "\n[ccqa] agent-browser killed after hard timeout" : "")
170
+ stderr: (result.stderr?.toString() ?? "") + (wedged ? "\n[ccqa] agent-browser killed after hard timeout" : ""),
171
+ wedged
170
172
  };
171
173
  }
172
174
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.40.0",
3
+ "version": "1.40.1",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {