@aexol/spectral 0.9.115 → 0.9.117

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.
@@ -14,9 +14,32 @@ export interface BrowserConfig {
14
14
  width: number;
15
15
  height: number;
16
16
  };
17
- /** Whether to run the browser headless during recordings. Defaults to false
18
- * (headful) for higher-quality video capture, mirroring the demo recorder. */
19
17
  recordHeadless: boolean;
20
18
  }
19
+ /**
20
+ * Resolve a (possibly relative) output directory to an absolute path.
21
+ *
22
+ * - Absolute paths are returned as-is (normalized).
23
+ * - Relative paths are resolved against `cwd` when provided, otherwise against
24
+ * `$HOME` — matching the convention in `server/paths.ts` (the server process
25
+ * runs from `$HOME`, so relative-to-home matches the user's mental model).
26
+ *
27
+ * This prevents browser screenshots/recordings from leaking into
28
+ * `~/.spectral/browser-output/` when the extension is loaded from a server
29
+ * whose `process.cwd()` is the home directory rather than the project root.
30
+ */
31
+ /**
32
+ * Resolve a (possibly relative) output directory to an absolute path.
33
+ *
34
+ * - Absolute paths are returned as-is (normalized).
35
+ * - Relative paths are resolved against `cwd` when provided, otherwise against
36
+ * `$HOME` — matching the convention in `server/paths.ts` (the server process
37
+ * runs from `$HOME`, so relative-to-home matches the user's mental model).
38
+ *
39
+ * This prevents browser screenshots/recordings from leaking into
40
+ * `~/.spectral/browser-output/` when the extension is loaded from a server
41
+ * whose `process.cwd()` is the home directory rather than the project root.
42
+ */
43
+ export declare function resolveOutputDir(input: string, cwd?: string): string;
21
44
  export declare function getBrowserConfig(): BrowserConfig;
22
45
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC/C,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5C,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD;kFAC8E;IAC9E,cAAc,EAAE,OAAO,CAAC;CACzB;AACD,wBAAgB,gBAAgB,IAAI,aAAa,CAgBhD"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/config.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC/C,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5C,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,cAAc,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAIpE;AAED,wBAAgB,gBAAgB,IAAI,aAAa,CAgBhD"}
@@ -1,3 +1,35 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ /**
4
+ * Resolve a (possibly relative) output directory to an absolute path.
5
+ *
6
+ * - Absolute paths are returned as-is (normalized).
7
+ * - Relative paths are resolved against `cwd` when provided, otherwise against
8
+ * `$HOME` — matching the convention in `server/paths.ts` (the server process
9
+ * runs from `$HOME`, so relative-to-home matches the user's mental model).
10
+ *
11
+ * This prevents browser screenshots/recordings from leaking into
12
+ * `~/.spectral/browser-output/` when the extension is loaded from a server
13
+ * whose `process.cwd()` is the home directory rather than the project root.
14
+ */
15
+ /**
16
+ * Resolve a (possibly relative) output directory to an absolute path.
17
+ *
18
+ * - Absolute paths are returned as-is (normalized).
19
+ * - Relative paths are resolved against `cwd` when provided, otherwise against
20
+ * `$HOME` — matching the convention in `server/paths.ts` (the server process
21
+ * runs from `$HOME`, so relative-to-home matches the user's mental model).
22
+ *
23
+ * This prevents browser screenshots/recordings from leaking into
24
+ * `~/.spectral/browser-output/` when the extension is loaded from a server
25
+ * whose `process.cwd()` is the home directory rather than the project root.
26
+ */
27
+ export function resolveOutputDir(input, cwd) {
28
+ if (isAbsolute(input))
29
+ return resolve(input);
30
+ const base = cwd ?? homedir();
31
+ return resolve(base, input);
32
+ }
1
33
  export function getBrowserConfig() {
2
34
  return {
3
35
  browserName: process.env.BROWSER_NAME ?? "chromium",
@@ -8,9 +40,9 @@ export function getBrowserConfig() {
8
40
  },
9
41
  navigationTimeout: Number(process.env.BROWSER_NAVIGATION_TIMEOUT) || 60_000,
10
42
  actionTimeout: Number(process.env.BROWSER_ACTION_TIMEOUT) || 5_000,
11
- outputDir: process.env.BROWSER_OUTPUT_DIR ?? ".spectral/browser-output",
43
+ outputDir: resolveOutputDir(process.env.BROWSER_OUTPUT_DIR ?? ".spectral/browser-output"),
12
44
  debug: process.env.BROWSER_DEBUG === "true" ? true : false,
13
- recordOutputDir: process.env.BROWSER_RECORD_DIR ?? ".spectral/browser-recordings",
45
+ recordOutputDir: resolveOutputDir(process.env.BROWSER_RECORD_DIR ?? ".spectral/browser-recordings"),
14
46
  recordViewport: undefined, // defaults to viewport via code
15
47
  recordHeadless: process.env.BROWSER_RECORD_HEADLESS === "true" ? true : false,
16
48
  };
@@ -1 +1 @@
1
- {"version":3,"file":"scenario-tool.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/scenario/scenario-tool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAsC5D,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,YAAY,EAAE,EAAE,EAAE,cAAc,GAAG,IAAI,CA+VjF"}
1
+ {"version":3,"file":"scenario-tool.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/scenario/scenario-tool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAuC5D,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,YAAY,EAAE,EAAE,EAAE,cAAc,GAAG,IAAI,CAgWjF"}
@@ -1,3 +1,4 @@
1
+ import { resolveOutputDir } from "../config.js";
1
2
  import { parseMarkdownScenarioFile, } from "./markdown-parser.js";
2
3
  function sleep(ms) {
3
4
  return new Promise((r) => setTimeout(r, ms));
@@ -82,7 +83,7 @@ export function registerScenarioTools(ext, bs) {
82
83
  },
83
84
  required: ["url"],
84
85
  },
85
- async execute(_toolCallId, params) {
86
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
86
87
  const p = params;
87
88
  if (!p.url) {
88
89
  return {
@@ -272,9 +273,10 @@ export function registerScenarioTools(ext, bs) {
272
273
  case "screenshot": {
273
274
  const config = bs.getConfig();
274
275
  const { mkdirSync } = await import("node:fs");
275
- mkdirSync(config.outputDir, { recursive: true });
276
+ const outDir = resolveOutputDir(config.outputDir, ctx.cwd);
277
+ mkdirSync(outDir, { recursive: true });
276
278
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
277
- const filepath = `${config.outputDir}/scenario-step-${i + 1}-${ts}.png`;
279
+ const filepath = `${outDir}/scenario-step-${i + 1}-${ts}.png`;
278
280
  await page.screenshot({ path: filepath });
279
281
  detail = `Screenshot: ${filepath}`;
280
282
  break;
@@ -1,6 +1,3 @@
1
- /**
2
- * Screenshot & snapshot tools: browser_take_screenshot, browser_snapshot.
3
- */
4
1
  import type { ExtensionAPI } from "../../../sdk/coding-agent/index.js";
5
2
  import { BrowserService } from "../browser-service.js";
6
3
  export declare function registerScreenshotTools(ext: ExtensionAPI, bs: BrowserService): void;
@@ -1 +1 @@
1
- {"version":3,"file":"screenshot.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/tools/screenshot.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAEvD,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,YAAY,EAAE,EAAE,EAAE,cAAc,GAAG,IAAI,CAiKnF"}
1
+ {"version":3,"file":"screenshot.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/tools/screenshot.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AAEvF,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,YAAY,EAAE,EAAE,EAAE,cAAc,GAAG,IAAI,CA8InF"}
@@ -1,8 +1,6 @@
1
- /**
2
- * Screenshot & snapshot tools: browser_take_screenshot, browser_snapshot.
3
- */
4
1
  import { mkdirSync, writeFileSync } from "node:fs";
5
2
  import { join } from "node:path";
3
+ import { resolveOutputDir } from "../config.js";
6
4
  export function registerScreenshotTools(ext, bs) {
7
5
  const screenshotTool = {
8
6
  name: "browser_take_screenshot",
@@ -29,14 +27,15 @@ export function registerScreenshotTools(ext, bs) {
29
27
  },
30
28
  },
31
29
  },
32
- async execute(_toolCallId, params) {
30
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
33
31
  const p = params;
34
32
  try {
35
33
  const page = bs.getActivePage();
36
34
  const config = bs.getConfig();
37
- mkdirSync(config.outputDir, { recursive: true });
35
+ const outDir = resolveOutputDir(config.outputDir, ctx.cwd);
36
+ mkdirSync(outDir, { recursive: true });
38
37
  const filename = p.filename ?? `screenshot-${Date.now()}.${p.type ?? "png"}`;
39
- const filepath = join(config.outputDir, filename);
38
+ const filepath = join(outDir, filename);
40
39
  await page.screenshot({
41
40
  path: filepath,
42
41
  fullPage: p.fullPage ?? false,
@@ -73,12 +72,11 @@ export function registerScreenshotTools(ext, bs) {
73
72
  },
74
73
  },
75
74
  },
76
- async execute(_toolCallId, params) {
75
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
77
76
  const p = params;
78
77
  try {
79
78
  const page = bs.getActivePage();
80
79
  const config = bs.getConfig();
81
- // Inject ARIA snapshot helper
82
80
  const snapshot = await page.evaluate(() => {
83
81
  function buildSnapshot(element, depth) {
84
82
  if (depth > 20)
@@ -95,7 +93,6 @@ export function registerScreenshotTools(ext, bs) {
95
93
  const text = element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE
96
94
  ? ` "${element.textContent?.trim()}"`
97
95
  : "";
98
- // Build element description
99
96
  let desc = `${indent}${tag}${id}${className}`;
100
97
  if (role)
101
98
  desc += ` [role=${role}]`;
@@ -103,7 +100,6 @@ export function registerScreenshotTools(ext, bs) {
103
100
  desc += ` [aria-label="${ariaLabel}"]`;
104
101
  if (text && text.length < 100)
105
102
  desc += text;
106
- // Interactive hints
107
103
  const href = element.href;
108
104
  const placeholder = element.placeholder;
109
105
  const value = element.value;
@@ -121,9 +117,10 @@ export function registerScreenshotTools(ext, bs) {
121
117
  }
122
118
  return buildSnapshot(document.body ?? document.documentElement, 0);
123
119
  });
124
- mkdirSync(config.outputDir, { recursive: true });
120
+ const outDir = resolveOutputDir(config.outputDir, ctx.cwd);
121
+ mkdirSync(outDir, { recursive: true });
125
122
  const filename = p.filename ?? `snapshot-${Date.now()}.txt`;
126
- const filepath = join(config.outputDir, filename);
123
+ const filepath = join(outDir, filename);
127
124
  writeFileSync(filepath, snapshot, "utf-8");
128
125
  const truncated = snapshot.length > 50000
129
126
  ? snapshot.slice(0, 50000) + `\n\n[truncated — full snapshot saved to ${filepath}]`
@@ -54,13 +54,13 @@ export async function execCommand(command, args, cwd, options) {
54
54
  // Wait for process termination without hanging on inherited stdio handles
55
55
  // held open by detached descendants.
56
56
  waitForChildProcess(proc)
57
- .then((code) => {
57
+ .then(({ code, forcedKill }) => {
58
58
  if (timeoutId)
59
59
  clearTimeout(timeoutId);
60
60
  if (options?.signal) {
61
61
  options.signal.removeEventListener("abort", killProcess);
62
62
  }
63
- resolve({ stdout, stderr, code: code ?? 0, killed });
63
+ resolve({ stdout, stderr, code: forcedKill ? 1 : code ?? 0, killed: forcedKill || killed });
64
64
  })
65
65
  .catch((_err) => {
66
66
  if (timeoutId)
@@ -1 +1 @@
1
- {"version":3,"file":"bash.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/bash.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAE9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAU5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAI7D,OAAO,EAAoD,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAExG,QAAA,MAAM,UAAU;;;EAGd,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAEtD,MAAM,WAAW,eAAe;IAC/B,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B;;;;;;OAMG;IACH,IAAI,EAAE,CACL,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,EACX,OAAO,EAAE;QACR,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;QAC/B,MAAM,CAAC,EAAE,WAAW,CAAC;QACrB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;KACxB,KACG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC;CAC1C;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,CA+D1F;AAED,MAAM,WAAW,gBAAgB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;CACvB;AAED,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,EAAE,gBAAgB,KAAK,gBAAgB,CAAC;AAO5E,MAAM,WAAW,eAAe;IAC/B,oEAAoE;IACpE,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,mFAAmF;IACnF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,aAAa,CAAC;CAC1B;AAwFD,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,eAAe,GACvB,cAAc,CAAC,OAAO,UAAU,EAAE,eAAe,GAAG,SAAS,CAAC,CAsIhE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAEnG"}
1
+ {"version":3,"file":"bash.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/bash.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAE9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAU5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAI7D,OAAO,EAAoD,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAExG,QAAA,MAAM,UAAU;;;EAGd,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAEtD,MAAM,WAAW,eAAe;IAC/B,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B;;;;;;OAMG;IACH,IAAI,EAAE,CACL,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,EACX,OAAO,EAAE;QACR,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;QAC/B,MAAM,CAAC,EAAE,WAAW,CAAC;QACrB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;KACxB,KACG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC;CAC1C;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,CAkF1F;AAED,MAAM,WAAW,gBAAgB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;CACvB;AAED,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,EAAE,gBAAgB,KAAK,gBAAgB,CAAC;AAO5E,MAAM,WAAW,eAAe;IAC/B,oEAAoE;IACpE,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,mFAAmF;IACnF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,aAAa,CAAC;CAC1B;AAyFD,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,eAAe,GACvB,cAAc,CAAC,OAAO,UAAU,EAAE,eAAe,GAAG,SAAS,CAAC,CAsIhE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAEnG"}
@@ -41,8 +41,17 @@ export function createLocalBashOperations(options) {
41
41
  if (timeout !== undefined && timeout > 0) {
42
42
  timeoutHandle = setTimeout(() => {
43
43
  timedOut = true;
44
- if (child.pid)
45
- killProcessTree(child.pid);
44
+ if (child.pid) {
45
+ try {
46
+ killProcessTree(child.pid);
47
+ }
48
+ catch {
49
+ // ESRCH: process already exited. If it's truly gone, treat as normal exit.
50
+ if (child.exitCode !== null || child.signalCode) {
51
+ return;
52
+ }
53
+ }
54
+ }
46
55
  }, timeout * 1000);
47
56
  }
48
57
  // Stream stdout and stderr.
@@ -61,8 +70,13 @@ export function createLocalBashOperations(options) {
61
70
  }
62
71
  // Handle shell spawn errors and wait for the process to terminate without hanging
63
72
  // on inherited stdio handles held by detached descendants.
64
- waitForChildProcess(child)
65
- .then((code) => {
73
+ // Hard deadline = max(LLM timeout + 5s grace, 30m default). Guarantees the
74
+ // promise resolves even if a detached descendant keeps the pipe open.
75
+ const hardDeadlineMs = timeout !== undefined && timeout > 0
76
+ ? Math.max(timeout * 1000 + 5000, BASH_HARD_DEADLINE_MS)
77
+ : BASH_HARD_DEADLINE_MS;
78
+ waitForChildProcess(child, hardDeadlineMs)
79
+ .then(({ code, forcedKill }) => {
66
80
  if (child.pid)
67
81
  untrackDetachedChildPid(child.pid);
68
82
  if (timeoutHandle)
@@ -73,6 +87,10 @@ export function createLocalBashOperations(options) {
73
87
  reject(new Error("aborted"));
74
88
  return;
75
89
  }
90
+ if (forcedKill) {
91
+ reject(new Error(`Command force-killed after hard deadline (${Math.round(hardDeadlineMs / 60000)}m)`));
92
+ return;
93
+ }
76
94
  if (timedOut) {
77
95
  reject(new Error(`timeout:${timeout}`));
78
96
  return;
@@ -98,6 +116,7 @@ function resolveSpawnContext(command, cwd, spawnHook) {
98
116
  }
99
117
  const BASH_PREVIEW_LINES = 5;
100
118
  const BASH_UPDATE_THROTTLE_MS = 100;
119
+ const BASH_HARD_DEADLINE_MS = Number(process.env.SPECTRAL_BASH_HARD_DEADLINE_MS) || 30 * 60 * 1000;
101
120
  function formatDuration(ms) {
102
121
  return `${(ms / 1000).toFixed(1)}s`;
103
122
  }
@@ -1,5 +1,11 @@
1
1
  import { type ChildProcess, type ChildProcessByStdio, type SpawnOptions, type SpawnOptionsWithStdioTuple, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns, type StdioNull, type StdioPipe } from "node:child_process";
2
2
  import type { Readable } from "node:stream";
3
+ export interface WaitForChildProcessResult {
4
+ /** Process exit code, or null if the process did not exit cleanly. */
5
+ code: number | null;
6
+ /** True when the hard deadline fired and we force-killed the process tree. */
7
+ forcedKill: boolean;
8
+ }
3
9
  export declare function spawnProcess(command: string, args: string[], options: SpawnOptionsWithStdioTuple<StdioNull, StdioPipe, StdioPipe>): ChildProcessByStdio<null, Readable, Readable>;
4
10
  export declare function spawnProcess(command: string, args: string[], options: SpawnOptions): ChildProcess;
5
11
  export declare function spawnProcessSync(command: string, args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string>;
@@ -10,6 +16,11 @@ export declare function spawnProcessSync(command: string, args: string[], option
10
16
  * handles. In that case the child emits `exit`, but `close` can hang forever even
11
17
  * though the original process is already gone. We wait briefly for stdio to end,
12
18
  * then forcibly stop tracking the inherited handles.
19
+ *
20
+ * As an escape hatch, a hard deadline (default 30 minutes, env-overridable via
21
+ * SPECTRAL_BASH_HARD_DEADLINE_MS) force-kills the process tree and resolves the
22
+ * promise so callers can never hang indefinitely on a pipe held open by a
23
+ * detached descendant.
13
24
  */
14
- export declare function waitForChildProcess(child: ChildProcess): Promise<number | null>;
25
+ export declare function waitForChildProcess(child: ChildProcess, hardDeadlineMs?: number): Promise<WaitForChildProcessResult>;
15
26
  //# sourceMappingURL=child-process.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"child-process.d.ts","sourceRoot":"","sources":["../../../../src/sdk/coding-agent/utils/child-process.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,mBAAmB,EAGxB,KAAK,YAAY,EACjB,KAAK,0BAA0B,EAC/B,KAAK,kCAAkC,EACvC,KAAK,gBAAgB,EACrB,KAAK,SAAS,EACd,KAAK,SAAS,EACd,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAK5C,wBAAgB,YAAY,CAC3B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,0BAA0B,CAAC,SAAS,EAAE,SAAS,EAAE,SAAS,CAAC,GAClE,mBAAmB,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC;AACjD,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,YAAY,GAAG,YAAY,CAAC;AAKnG,wBAAgB,gBAAgB,CAC/B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,kCAAkC,GACzC,gBAAgB,CAAC,MAAM,CAAC,CAI1B;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAyE/E"}
1
+ {"version":3,"file":"child-process.d.ts","sourceRoot":"","sources":["../../../../src/sdk/coding-agent/utils/child-process.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,mBAAmB,EAGxB,KAAK,YAAY,EACjB,KAAK,0BAA0B,EAC/B,KAAK,kCAAkC,EACvC,KAAK,gBAAgB,EACrB,KAAK,SAAS,EACd,KAAK,SAAS,EACd,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAO5C,MAAM,WAAW,yBAAyB;IACzC,sEAAsE;IACtE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,8EAA8E;IAC9E,UAAU,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,YAAY,CAC3B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,0BAA0B,CAAC,SAAS,EAAE,SAAS,EAAE,SAAS,CAAC,GAClE,mBAAmB,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC;AACjD,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,YAAY,GAAG,YAAY,CAAC;AAKnG,wBAAgB,gBAAgB,CAC/B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,kCAAkC,GACzC,gBAAgB,CAAC,MAAM,CAAC,CAI1B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAClC,KAAK,EAAE,YAAY,EACnB,cAAc,GAAE,MAA8B,GAC5C,OAAO,CAAC,yBAAyB,CAAC,CAsFpC"}
@@ -1,6 +1,8 @@
1
1
  import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, } from "node:child_process";
2
2
  import crossSpawn from "cross-spawn";
3
+ import { killProcessTree } from "./shell.js";
3
4
  const EXIT_STDIO_GRACE_MS = 100;
5
+ const BASH_HARD_DEADLINE_MS = Number(process.env.SPECTRAL_BASH_HARD_DEADLINE_MS) || 30 * 60 * 1000;
4
6
  export function spawnProcess(command, args, options) {
5
7
  return process.platform === "win32" ? crossSpawn(command, args, options) : nodeSpawn(command, args, options);
6
8
  }
@@ -16,13 +18,19 @@ export function spawnProcessSync(command, args, options) {
16
18
  * handles. In that case the child emits `exit`, but `close` can hang forever even
17
19
  * though the original process is already gone. We wait briefly for stdio to end,
18
20
  * then forcibly stop tracking the inherited handles.
21
+ *
22
+ * As an escape hatch, a hard deadline (default 30 minutes, env-overridable via
23
+ * SPECTRAL_BASH_HARD_DEADLINE_MS) force-kills the process tree and resolves the
24
+ * promise so callers can never hang indefinitely on a pipe held open by a
25
+ * detached descendant.
19
26
  */
20
- export function waitForChildProcess(child) {
27
+ export function waitForChildProcess(child, hardDeadlineMs = BASH_HARD_DEADLINE_MS) {
21
28
  return new Promise((resolve, reject) => {
22
29
  let settled = false;
23
30
  let exited = false;
24
31
  let exitCode = null;
25
32
  let postExitTimer;
33
+ let hardDeadlineTimer;
26
34
  let stdoutEnded = child.stdout === null;
27
35
  let stderrEnded = child.stderr === null;
28
36
  const cleanup = () => {
@@ -30,20 +38,24 @@ export function waitForChildProcess(child) {
30
38
  clearTimeout(postExitTimer);
31
39
  postExitTimer = undefined;
32
40
  }
41
+ if (hardDeadlineTimer) {
42
+ clearTimeout(hardDeadlineTimer);
43
+ hardDeadlineTimer = undefined;
44
+ }
33
45
  child.removeListener("error", onError);
34
46
  child.removeListener("exit", onExit);
35
47
  child.removeListener("close", onClose);
36
48
  child.stdout?.removeListener("end", onStdoutEnd);
37
49
  child.stderr?.removeListener("end", onStderrEnd);
38
50
  };
39
- const finalize = (code) => {
51
+ const finalize = (code, forcedKill = false) => {
40
52
  if (settled)
41
53
  return;
42
54
  settled = true;
43
55
  cleanup();
44
56
  child.stdout?.destroy();
45
57
  child.stderr?.destroy();
46
- resolve(code);
58
+ resolve({ code, forcedKill });
47
59
  };
48
60
  const maybeFinalizeAfterExit = () => {
49
61
  if (!exited || settled)
@@ -83,5 +95,24 @@ export function waitForChildProcess(child) {
83
95
  child.once("error", onError);
84
96
  child.once("exit", onExit);
85
97
  child.once("close", onClose);
98
+ // Hard deadline escape hatch: if the child (or a grandchild) keeps the
99
+ // stdout/stderr pipe open indefinitely, force-kill the tree and resolve.
100
+ if (hardDeadlineMs > 0 && !settled) {
101
+ hardDeadlineTimer = setTimeout(() => {
102
+ if (settled)
103
+ return;
104
+ if (child.pid) {
105
+ try {
106
+ killProcessTree(child.pid);
107
+ }
108
+ catch {
109
+ // Process may already be gone; ignore.
110
+ }
111
+ }
112
+ child.stdout?.destroy();
113
+ child.stderr?.destroy();
114
+ finalize(null, true);
115
+ }, hardDeadlineMs);
116
+ }
86
117
  });
87
118
  }
@@ -168,6 +168,8 @@ export declare class AgentBridge {
168
168
  private lastAppliedModelId?;
169
169
  private promptActivityTimer?;
170
170
  private promptReject?;
171
+ /** Per-turn tool execution backstop timer (see TOOL_EXECUTION_TIMEOUT_MS). */
172
+ private toolExecutionTimer?;
171
173
  /** Current model's credit rates (from availableAgentModels), used for token_usage. */
172
174
  private activeCreditRates;
173
175
  private memoryPhase;
@@ -342,12 +344,35 @@ export declare class AgentBridge {
342
344
  */
343
345
  private withPromptTimeout;
344
346
  /**
345
- * Reset the inactivity watchdog. Called from `handleEvent` on EVERY session
346
- * event so a long-running prompt that is actively streaming / calling tools
347
- * is never killed by the inactivity timeout.
347
+ * Whether `ev` represents real agent progress and should reset the
348
+ * inactivity watchdog. High-frequency churn events that can stream
349
+ * indefinitely without progress are excluded:
350
+ * - `tool_execution_update` (e.g. bash stdout chunks from a grandchild)
351
+ * - `compaction_delta`
352
+ * - `token_usage` (wire-only; not a session event, excluded for clarity)
353
+ *
354
+ * Meaningful events map the task's allow-list (message_start, text/thinking
355
+ * deltas via message_update, message_end, agent_end, tool_execution_start,
356
+ * tool_execution_end → tool_result / subagent_end).
357
+ */
358
+ private isMeaningfulPromptActivity;
359
+ /**
360
+ * Reset the inactivity watchdog. Called from `handleEvent` ONLY for
361
+ * meaningful progress events (see `isMeaningfulPromptActivity`), so a
362
+ * long-running prompt that is actively streaming text / calling tools is
363
+ * never killed by the inactivity timeout — but churn (e.g. bash stdout
364
+ * chunks) no longer keeps a stuck session alive forever.
348
365
  */
349
366
  private bumpPromptActivity;
350
367
  private clearPromptTimers;
368
+ /**
369
+ * Start the per-turn tool execution backstop. Only the first
370
+ * `tool_execution_start` of a turn arms it; nested tool starts reuse the
371
+ * running timer. Cleared on `tool_execution_end` / `agent_end` and on
372
+ * teardown via `clearPromptTimers`.
373
+ */
374
+ private startToolExecutionTimer;
375
+ private clearToolExecutionTimer;
351
376
  compact(options?: string | AgentBridgeCompactionOptions): Promise<CompactionResult>;
352
377
  dispose(): void;
353
378
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"agent-bridge.d.ts","sourceRoot":"","sources":["../../src/server/agent-bridge.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"agent-bridge.d.ts","sourceRoot":"","sources":["../../src/server/agent-bridge.ts"],"names":[],"mappings":"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"}
@@ -535,16 +535,36 @@ function toRestoredMemorySnapshot(snapshot) {
535
535
  }
536
536
  /**
537
537
  * Inactivity-based prompt hang watchdog. Unlike a fixed wall-clock deadline,
538
- * this only fires when the agent session has emitted NO events (text deltas,
539
- * tool calls, tool results, thinking, message boundaries) for the configured
540
- * window. Long-running but active prompts (multi-turn tool use, builds,
541
- * subagents, streaming) are NOT killed — only truly stuck sessions are.
538
+ * this only fires when the agent session has emitted NO *meaningful* progress
539
+ * events (text deltas, tool calls, tool results, thinking, message boundaries)
540
+ * for the configured window. Long-running but active prompts (multi-turn tool
541
+ * use, builds, subagents, streaming) are NOT killed — only truly stuck
542
+ * sessions are.
542
543
  *
543
- * Reset on every `AgentSessionEvent` seen by `handleEvent`. Default 120s of
544
- * total silence. Hard ceiling removed — working agents (including
545
- * subagents) must never be killed by a wall-clock timeout.
544
+ * Meaningful events are an explicit allow-list defined by
545
+ * `isMeaningfulPromptActivity(ev)` in `handleEvent`. High-frequency churn
546
+ * events `tool_execution_update` (e.g. bash stdout chunks), `compaction_delta`,
547
+ * `token_usage` — do NOT reset the watchdog, because they can stream
548
+ * indefinitely without real agent progress (a hung grandchild writing bytes
549
+ * to stdout would otherwise keep the watchdog alive forever).
550
+ *
551
+ * Default 120s of total silence. Hard ceiling removed for meaningful work —
552
+ * working agents (including subagents) must never be killed by this watchdog.
546
553
  */
547
554
  const PROMPT_INACTIVITY_TIMEOUT_MS = Number(process.env.SPECTRAL_PROMPT_INACTIVITY_MS) || 2 * 60 * 1000;
555
+ /**
556
+ * Per-turn ceiling for a single tool execution. Started on the first
557
+ * `tool_execution_start` of a turn and cleared on `tool_execution_end` /
558
+ * `agent_end`. Protects against a single tool call (e.g. a bash grandchild
559
+ * streaming stdout chunks via `tool_execution_update`) running forever: even
560
+ * though those chunks no longer reset the inactivity watchdog, this timer
561
+ * provides an absolute backstop per tool invocation.
562
+ *
563
+ * Default 30 minutes, overridable via `SPECTRAL_TOOL_EXECUTION_TIMEOUT_MS`.
564
+ * On expiry it forces a prompt reject (same abort path as the inactivity
565
+ * watchdog) with a descriptive message.
566
+ */
567
+ const TOOL_EXECUTION_TIMEOUT_MS = Number(process.env.SPECTRAL_TOOL_EXECUTION_TIMEOUT_MS) || 30 * 60 * 1000;
548
568
  export class AgentBridge {
549
569
  session;
550
570
  suppressNextAgentEndForGeneration;
@@ -576,6 +596,8 @@ export class AgentBridge {
576
596
  lastAppliedModelId;
577
597
  promptActivityTimer;
578
598
  promptReject;
599
+ /** Per-turn tool execution backstop timer (see TOOL_EXECUTION_TIMEOUT_MS). */
600
+ toolExecutionTimer;
579
601
  /** Current model's credit rates (from availableAgentModels), used for token_usage. */
580
602
  activeCreditRates = null;
581
603
  memoryPhase = "idle";
@@ -720,6 +742,14 @@ export class AgentBridge {
720
742
  arguments: tc.arguments,
721
743
  });
722
744
  }
745
+ // Detect orphaned tool calls: CLI was killed mid-tool-call, so some
746
+ // tool_use blocks have no matching tool_result in the persisted events.
747
+ // Mark as "error" so transform-messages inserts synthetic "No result provided"
748
+ // + isError=true, letting the LLM retry the action after a CLI restart.
749
+ const orphanedToolCallIds = toolCalls
750
+ .filter((tc) => !toolResults.some((tr) => tr.id === tc.id))
751
+ .map((tc) => tc.id);
752
+ const hasOrphanedToolCalls = orphanedToolCallIds.length > 0;
723
753
  sessionManager.appendMessage({
724
754
  role: "assistant",
725
755
  content,
@@ -741,7 +771,9 @@ export class AgentBridge {
741
771
  },
742
772
  },
743
773
  stopReason: toolCalls.length > 0
744
- ? "toolUse"
774
+ ? (hasOrphanedToolCalls
775
+ ? "error"
776
+ : "toolUse")
745
777
  : "stop",
746
778
  timestamp: msg.createdAt,
747
779
  });
@@ -768,6 +800,9 @@ export class AgentBridge {
768
800
  timestamp: msg.createdAt,
769
801
  });
770
802
  }
803
+ if (hasOrphanedToolCalls) {
804
+ console.warn(`[AgentBridge] Rehydrated message ${msg.id} marked as error (orphaned tool calls after CLI restart: ${orphanedToolCallIds.join(", ")})`);
805
+ }
771
806
  }
772
807
  // system messages are informational only; skip for LLM context
773
808
  }
@@ -1384,10 +1419,10 @@ export class AgentBridge {
1384
1419
  * observations can stay relevant to the current task.
1385
1420
  */
1386
1421
  async withPromptTimeout(prompt) {
1387
- // Activity-based watchdog: reset on every session event via
1388
- // `bumpPromptActivity()`. Only triggers on total silence, NOT on long
1389
- // active prompts. Hard ceiling removed — working agents (including
1390
- // subagents) must never be killed by a wall-clock timeout.
1422
+ // Activity-based watchdog: reset on meaningful session events only via
1423
+ // `bumpPromptActivity()` (churn like tool_execution_update excluded). A
1424
+ // separate per-tool TOOL_EXECUTION_TIMEOUT_MS backstop guards single
1425
+ // tool calls. Triggers on total silence, NOT on long active prompts.
1391
1426
  const inactivityPromise = new Promise((_, reject) => {
1392
1427
  this.promptReject = reject;
1393
1428
  this.promptActivityTimer = setTimeout(() => {
@@ -1402,9 +1437,39 @@ export class AgentBridge {
1402
1437
  }
1403
1438
  }
1404
1439
  /**
1405
- * Reset the inactivity watchdog. Called from `handleEvent` on EVERY session
1406
- * event so a long-running prompt that is actively streaming / calling tools
1407
- * is never killed by the inactivity timeout.
1440
+ * Whether `ev` represents real agent progress and should reset the
1441
+ * inactivity watchdog. High-frequency churn events that can stream
1442
+ * indefinitely without progress are excluded:
1443
+ * - `tool_execution_update` (e.g. bash stdout chunks from a grandchild)
1444
+ * - `compaction_delta`
1445
+ * - `token_usage` (wire-only; not a session event, excluded for clarity)
1446
+ *
1447
+ * Meaningful events map the task's allow-list (message_start, text/thinking
1448
+ * deltas via message_update, message_end, agent_end, tool_execution_start,
1449
+ * tool_execution_end → tool_result / subagent_end).
1450
+ */
1451
+ isMeaningfulPromptActivity(ev) {
1452
+ switch (ev.type) {
1453
+ case "message_start":
1454
+ case "message_update":
1455
+ case "message_end":
1456
+ case "agent_end":
1457
+ case "tool_execution_start":
1458
+ case "tool_execution_end":
1459
+ return true;
1460
+ default:
1461
+ // tool_execution_update, compaction_*, prompt_mutation_status,
1462
+ // queue_update, auto_retry_*, length_continuation, session_info_changed,
1463
+ // thinking_level_changed — churn / non-progress, do NOT reset watchdog.
1464
+ return false;
1465
+ }
1466
+ }
1467
+ /**
1468
+ * Reset the inactivity watchdog. Called from `handleEvent` ONLY for
1469
+ * meaningful progress events (see `isMeaningfulPromptActivity`), so a
1470
+ * long-running prompt that is actively streaming text / calling tools is
1471
+ * never killed by the inactivity timeout — but churn (e.g. bash stdout
1472
+ * chunks) no longer keeps a stuck session alive forever.
1408
1473
  */
1409
1474
  bumpPromptActivity() {
1410
1475
  if (!this.promptActivityTimer)
@@ -1419,8 +1484,32 @@ export class AgentBridge {
1419
1484
  clearTimeout(this.promptActivityTimer);
1420
1485
  this.promptActivityTimer = undefined;
1421
1486
  }
1487
+ this.clearToolExecutionTimer();
1422
1488
  this.promptReject = undefined;
1423
1489
  }
1490
+ /**
1491
+ * Start the per-turn tool execution backstop. Only the first
1492
+ * `tool_execution_start` of a turn arms it; nested tool starts reuse the
1493
+ * running timer. Cleared on `tool_execution_end` / `agent_end` and on
1494
+ * teardown via `clearPromptTimers`.
1495
+ */
1496
+ startToolExecutionTimer() {
1497
+ if (this.toolExecutionTimer)
1498
+ return;
1499
+ this.toolExecutionTimer = setTimeout(() => {
1500
+ this.toolExecutionTimer = undefined;
1501
+ // Mirror bumpPromptActivity: reject the inactivity race so the existing
1502
+ // prompt() catch path runs session.abort() + emits error/agent_end.
1503
+ // The withPromptTimeout finally block clears all timers afterwards.
1504
+ this.promptReject?.(new Error(`Tool execution exceeded ${TOOL_EXECUTION_TIMEOUT_MS / 60000} minutes`));
1505
+ }, TOOL_EXECUTION_TIMEOUT_MS);
1506
+ }
1507
+ clearToolExecutionTimer() {
1508
+ if (this.toolExecutionTimer) {
1509
+ clearTimeout(this.toolExecutionTimer);
1510
+ this.toolExecutionTimer = undefined;
1511
+ }
1512
+ }
1424
1513
  async compact(options) {
1425
1514
  if (!this.session)
1426
1515
  throw new Error("AgentBridge.start() not called");
@@ -1546,9 +1635,16 @@ export class AgentBridge {
1546
1635
  handleEvent(ev) {
1547
1636
  if (this.disposed)
1548
1637
  return;
1549
- // Reset inactivity watchdog on every session event so long active
1550
- // prompts (multi-tool, streaming, subagents) are not killed.
1551
- this.bumpPromptActivity();
1638
+ // Reset inactivity watchdog ONLY on meaningful agent progress events
1639
+ // (text/thinking deltas, message boundaries, tool start/end, agent_end).
1640
+ // Churn events like tool_execution_update (bash stdout chunks) and
1641
+ // compaction_delta are intentionally excluded so a stuck grandchild
1642
+ // streaming bytes cannot keep the watchdog alive forever. A separate
1643
+ // per-tool TOOL_EXECUTION_TIMEOUT_MS (30 min) backstop covers a single
1644
+ // tool call running too long.
1645
+ if (this.isMeaningfulPromptActivity(ev)) {
1646
+ this.bumpPromptActivity();
1647
+ }
1552
1648
  switch (ev.type) {
1553
1649
  case "message_start": {
1554
1650
  // Only assistant messages get our message_start frame on the wire.
@@ -1613,6 +1709,7 @@ export class AgentBridge {
1613
1709
  return;
1614
1710
  }
1615
1711
  case "tool_execution_start": {
1712
+ this.startToolExecutionTimer();
1616
1713
  // Emit subagent_start for subagent tool invocations (Option B frontend panels).
1617
1714
  if (ev.toolName === "subagent") {
1618
1715
  const args = ev.args;
@@ -1699,6 +1796,7 @@ export class AgentBridge {
1699
1796
  return;
1700
1797
  }
1701
1798
  case "tool_execution_end": {
1799
+ this.clearToolExecutionTimer();
1702
1800
  // Emit subagent_end for subagent tool completions.
1703
1801
  if (ev.toolName === "subagent") {
1704
1802
  const result = ev.result;
@@ -1848,6 +1946,7 @@ export class AgentBridge {
1848
1946
  return;
1849
1947
  }
1850
1948
  case "agent_end": {
1949
+ this.clearToolExecutionTimer();
1851
1950
  if (this.suppressNextAgentEndForGeneration !== undefined) {
1852
1951
  const isSameTurn = this.suppressNextAgentEndForGeneration === this.turnGeneration;
1853
1952
  this.suppressNextAgentEndForGeneration = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.9.115",
3
+ "version": "0.9.117",
4
4
  "description": "AI coding agent for Aexol with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,