@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.
- package/dist/extensions/browser/config.d.ts +25 -2
- package/dist/extensions/browser/config.d.ts.map +1 -1
- package/dist/extensions/browser/config.js +34 -2
- package/dist/extensions/browser/scenario/scenario-tool.d.ts.map +1 -1
- package/dist/extensions/browser/scenario/scenario-tool.js +5 -3
- package/dist/extensions/browser/tools/screenshot.d.ts +0 -3
- package/dist/extensions/browser/tools/screenshot.d.ts.map +1 -1
- package/dist/extensions/browser/tools/screenshot.js +9 -12
- package/dist/sdk/coding-agent/core/exec.js +2 -2
- package/dist/sdk/coding-agent/core/tools/bash.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/bash.js +23 -4
- package/dist/sdk/coding-agent/utils/child-process.d.ts +12 -1
- package/dist/sdk/coding-agent/utils/child-process.d.ts.map +1 -1
- package/dist/sdk/coding-agent/utils/child-process.js +34 -3
- package/dist/server/agent-bridge.d.ts +28 -3
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +131 -19
- package/package.json +1 -1
|
@@ -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":"
|
|
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;
|
|
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
|
-
|
|
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 = `${
|
|
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":"
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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<
|
|
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;
|
|
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
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAaH,OAAO,EAQL,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EAEtB,MAAM,8BAA8B,CAAC;AAYtC,OAAO,EAEL,KAAK,aAAa,EACnB,MAAM,8BAA8B,CAAC;AAatC,OAAO,EACL,kBAAkB,IAAI,yBAAyB,EAEhD,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,KAAK,EAAE,eAAe,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAG3E,UAAU,4BAA4B;IACpC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC;CACpC;AAkBD,sEAAsE;AACtE,MAAM,MAAM,oBAAoB,GAAG,OAAO,yBAAyB,CAAC;AAmCpE,MAAM,WAAW,kBAAkB;IACjC,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qFAAqF;IACrF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2EAA2E;IAC3E,GAAG,EAAE,MAAM,CAAC;IACZ,qFAAqF;IACrF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,IAAI,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACnC;;;;;OAKG;IACH,0BAA0B,EAAE,CAAC,GAAG,EAAE;QAChC,SAAS,EAAE,MAAM,CAAC;QAClB,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,6EAA6E;QAC7E,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC3B,KAAK,IAAI,CAAC;IACX;;;OAGG;IACH,yBAAyB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACxD;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAC;IAC/B;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;IACxB;;;;OAIG;IACH,cAAc,CAAC,EAAE,qBAAqB,GAAG,IAAI,CAAC;IAC9C;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,oBAAoB,CAAC;IAC1C;;;;;OAKG;IACH,aAAa,CAAC,EAAE,aAAa,CAAC;CAC/B;
|
|
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
|
|
539
|
-
* tool calls, tool results, thinking, message boundaries)
|
|
540
|
-
* window. Long-running but active prompts (multi-turn tool
|
|
541
|
-
* subagents, streaming) are NOT killed — only truly stuck
|
|
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
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
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) ||
|
|
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
|
-
?
|
|
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
|
|
1388
|
-
// `bumpPromptActivity()
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
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
|
-
*
|
|
1406
|
-
*
|
|
1407
|
-
*
|
|
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
|
|
1550
|
-
//
|
|
1551
|
-
|
|
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;
|