@aexol/spectral 0.9.116 → 0.9.118

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,37 @@ 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
- const PROMPT_INACTIVITY_TIMEOUT_MS = Number(process.env.SPECTRAL_PROMPT_INACTIVITY_MS) || 2 * 60 * 1000;
554
+ const PROMPT_INACTIVITY_TIMEOUT_MS = Number(process.env.SPECTRAL_PROMPT_INACTIVITY_MS) || 10 * 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 60 minutes (raised from 30 — long builds/subagents were hitting it),
564
+ * overridable via `SPECTRAL_TOOL_EXECUTION_TIMEOUT_MS`.
565
+ * On expiry it forces a prompt reject (same abort path as the inactivity
566
+ * watchdog) with a descriptive message.
567
+ */
568
+ const TOOL_EXECUTION_TIMEOUT_MS = Number(process.env.SPECTRAL_TOOL_EXECUTION_TIMEOUT_MS) || 60 * 60 * 1000;
548
569
  export class AgentBridge {
549
570
  session;
550
571
  suppressNextAgentEndForGeneration;
@@ -576,6 +597,8 @@ export class AgentBridge {
576
597
  lastAppliedModelId;
577
598
  promptActivityTimer;
578
599
  promptReject;
600
+ /** Per-turn tool execution backstop timer (see TOOL_EXECUTION_TIMEOUT_MS). */
601
+ toolExecutionTimer;
579
602
  /** Current model's credit rates (from availableAgentModels), used for token_usage. */
580
603
  activeCreditRates = null;
581
604
  memoryPhase = "idle";
@@ -720,6 +743,14 @@ export class AgentBridge {
720
743
  arguments: tc.arguments,
721
744
  });
722
745
  }
746
+ // Detect orphaned tool calls: CLI was killed mid-tool-call, so some
747
+ // tool_use blocks have no matching tool_result in the persisted events.
748
+ // Mark as "error" so transform-messages inserts synthetic "No result provided"
749
+ // + isError=true, letting the LLM retry the action after a CLI restart.
750
+ const orphanedToolCallIds = toolCalls
751
+ .filter((tc) => !toolResults.some((tr) => tr.id === tc.id))
752
+ .map((tc) => tc.id);
753
+ const hasOrphanedToolCalls = orphanedToolCallIds.length > 0;
723
754
  sessionManager.appendMessage({
724
755
  role: "assistant",
725
756
  content,
@@ -741,7 +772,9 @@ export class AgentBridge {
741
772
  },
742
773
  },
743
774
  stopReason: toolCalls.length > 0
744
- ? "toolUse"
775
+ ? (hasOrphanedToolCalls
776
+ ? "error"
777
+ : "toolUse")
745
778
  : "stop",
746
779
  timestamp: msg.createdAt,
747
780
  });
@@ -768,6 +801,9 @@ export class AgentBridge {
768
801
  timestamp: msg.createdAt,
769
802
  });
770
803
  }
804
+ if (hasOrphanedToolCalls) {
805
+ console.warn(`[AgentBridge] Rehydrated message ${msg.id} marked as error (orphaned tool calls after CLI restart: ${orphanedToolCallIds.join(", ")})`);
806
+ }
771
807
  }
772
808
  // system messages are informational only; skip for LLM context
773
809
  }
@@ -1384,10 +1420,10 @@ export class AgentBridge {
1384
1420
  * observations can stay relevant to the current task.
1385
1421
  */
1386
1422
  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.
1423
+ // Activity-based watchdog: reset on meaningful session events only via
1424
+ // `bumpPromptActivity()` (churn like tool_execution_update excluded). A
1425
+ // separate per-tool TOOL_EXECUTION_TIMEOUT_MS backstop guards single
1426
+ // tool calls. Triggers on total silence, NOT on long active prompts.
1391
1427
  const inactivityPromise = new Promise((_, reject) => {
1392
1428
  this.promptReject = reject;
1393
1429
  this.promptActivityTimer = setTimeout(() => {
@@ -1402,9 +1438,39 @@ export class AgentBridge {
1402
1438
  }
1403
1439
  }
1404
1440
  /**
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.
1441
+ * Whether `ev` represents real agent progress and should reset the
1442
+ * inactivity watchdog. High-frequency churn events that can stream
1443
+ * indefinitely without progress are excluded:
1444
+ * - `tool_execution_update` (e.g. bash stdout chunks from a grandchild)
1445
+ * - `compaction_delta`
1446
+ * - `token_usage` (wire-only; not a session event, excluded for clarity)
1447
+ *
1448
+ * Meaningful events map the task's allow-list (message_start, text/thinking
1449
+ * deltas via message_update, message_end, agent_end, tool_execution_start,
1450
+ * tool_execution_end → tool_result / subagent_end).
1451
+ */
1452
+ isMeaningfulPromptActivity(ev) {
1453
+ switch (ev.type) {
1454
+ case "message_start":
1455
+ case "message_update":
1456
+ case "message_end":
1457
+ case "agent_end":
1458
+ case "tool_execution_start":
1459
+ case "tool_execution_end":
1460
+ return true;
1461
+ default:
1462
+ // tool_execution_update, compaction_*, prompt_mutation_status,
1463
+ // queue_update, auto_retry_*, length_continuation, session_info_changed,
1464
+ // thinking_level_changed — churn / non-progress, do NOT reset watchdog.
1465
+ return false;
1466
+ }
1467
+ }
1468
+ /**
1469
+ * Reset the inactivity watchdog. Called from `handleEvent` ONLY for
1470
+ * meaningful progress events (see `isMeaningfulPromptActivity`), so a
1471
+ * long-running prompt that is actively streaming text / calling tools is
1472
+ * never killed by the inactivity timeout — but churn (e.g. bash stdout
1473
+ * chunks) no longer keeps a stuck session alive forever.
1408
1474
  */
1409
1475
  bumpPromptActivity() {
1410
1476
  if (!this.promptActivityTimer)
@@ -1419,8 +1485,32 @@ export class AgentBridge {
1419
1485
  clearTimeout(this.promptActivityTimer);
1420
1486
  this.promptActivityTimer = undefined;
1421
1487
  }
1488
+ this.clearToolExecutionTimer();
1422
1489
  this.promptReject = undefined;
1423
1490
  }
1491
+ /**
1492
+ * Start the per-turn tool execution backstop. Only the first
1493
+ * `tool_execution_start` of a turn arms it; nested tool starts reuse the
1494
+ * running timer. Cleared on `tool_execution_end` / `agent_end` and on
1495
+ * teardown via `clearPromptTimers`.
1496
+ */
1497
+ startToolExecutionTimer() {
1498
+ if (this.toolExecutionTimer)
1499
+ return;
1500
+ this.toolExecutionTimer = setTimeout(() => {
1501
+ this.toolExecutionTimer = undefined;
1502
+ // Mirror bumpPromptActivity: reject the inactivity race so the existing
1503
+ // prompt() catch path runs session.abort() + emits error/agent_end.
1504
+ // The withPromptTimeout finally block clears all timers afterwards.
1505
+ this.promptReject?.(new Error(`Tool execution exceeded ${TOOL_EXECUTION_TIMEOUT_MS / 60000} minutes`));
1506
+ }, TOOL_EXECUTION_TIMEOUT_MS);
1507
+ }
1508
+ clearToolExecutionTimer() {
1509
+ if (this.toolExecutionTimer) {
1510
+ clearTimeout(this.toolExecutionTimer);
1511
+ this.toolExecutionTimer = undefined;
1512
+ }
1513
+ }
1424
1514
  async compact(options) {
1425
1515
  if (!this.session)
1426
1516
  throw new Error("AgentBridge.start() not called");
@@ -1472,6 +1562,18 @@ export class AgentBridge {
1472
1562
  this.memoryPhase = "compacting";
1473
1563
  if (event.type === "compaction_end")
1474
1564
  this.memoryPhase = "idle";
1565
+ // Subagent progress events flow through emitAndBuffer (not handleEvent),
1566
+ // so they must bump the inactivity watchdog here — otherwise a working
1567
+ // subagent that streams progress for >PROMPT_INACTIVITY_TIMEOUT_MS would
1568
+ // be killed mid-work. These events represent real subagent work
1569
+ // (tool starts/updates/ends inside the child agent), unlike the parent's
1570
+ // high-frequency `tool_execution_update` churn which is intentionally
1571
+ // excluded from the watchdog.
1572
+ if (event.type === "subagent_progress" ||
1573
+ event.type === "subagent_tool_progress" ||
1574
+ event.type === "subagent_start") {
1575
+ this.bumpPromptActivity();
1576
+ }
1475
1577
  if (this.pending)
1476
1578
  this.pending.wireEvents.push(event);
1477
1579
  this.opts.emit(event);
@@ -1546,9 +1648,16 @@ export class AgentBridge {
1546
1648
  handleEvent(ev) {
1547
1649
  if (this.disposed)
1548
1650
  return;
1549
- // Reset inactivity watchdog on every session event so long active
1550
- // prompts (multi-tool, streaming, subagents) are not killed.
1551
- this.bumpPromptActivity();
1651
+ // Reset inactivity watchdog ONLY on meaningful agent progress events
1652
+ // (text/thinking deltas, message boundaries, tool start/end, agent_end).
1653
+ // Churn events like tool_execution_update (bash stdout chunks) and
1654
+ // compaction_delta are intentionally excluded so a stuck grandchild
1655
+ // streaming bytes cannot keep the watchdog alive forever. A separate
1656
+ // per-tool TOOL_EXECUTION_TIMEOUT_MS (30 min) backstop covers a single
1657
+ // tool call running too long.
1658
+ if (this.isMeaningfulPromptActivity(ev)) {
1659
+ this.bumpPromptActivity();
1660
+ }
1552
1661
  switch (ev.type) {
1553
1662
  case "message_start": {
1554
1663
  // Only assistant messages get our message_start frame on the wire.
@@ -1613,6 +1722,7 @@ export class AgentBridge {
1613
1722
  return;
1614
1723
  }
1615
1724
  case "tool_execution_start": {
1725
+ this.startToolExecutionTimer();
1616
1726
  // Emit subagent_start for subagent tool invocations (Option B frontend panels).
1617
1727
  if (ev.toolName === "subagent") {
1618
1728
  const args = ev.args;
@@ -1699,6 +1809,7 @@ export class AgentBridge {
1699
1809
  return;
1700
1810
  }
1701
1811
  case "tool_execution_end": {
1812
+ this.clearToolExecutionTimer();
1702
1813
  // Emit subagent_end for subagent tool completions.
1703
1814
  if (ev.toolName === "subagent") {
1704
1815
  const result = ev.result;
@@ -1848,6 +1959,7 @@ export class AgentBridge {
1848
1959
  return;
1849
1960
  }
1850
1961
  case "agent_end": {
1962
+ this.clearToolExecutionTimer();
1851
1963
  if (this.suppressNextAgentEndForGeneration !== undefined) {
1852
1964
  const isSameTurn = this.suppressNextAgentEndForGeneration === this.turnGeneration;
1853
1965
  this.suppressNextAgentEndForGeneration = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.9.116",
3
+ "version": "0.9.118",
4
4
  "description": "AI coding agent for Aexol with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,