@kolisachint/hoocode-agent 0.5.20 → 0.5.22
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/CHANGELOG.md +38 -0
- package/dist/core/agent-log.d.ts +44 -0
- package/dist/core/agent-log.d.ts.map +1 -0
- package/dist/core/agent-log.js +74 -0
- package/dist/core/agent-log.js.map +1 -0
- package/dist/core/extensions/plugins/hooks-bridge.d.ts.map +1 -1
- package/dist/core/extensions/plugins/hooks-bridge.js +5 -1
- package/dist/core/extensions/plugins/hooks-bridge.js.map +1 -1
- package/dist/core/lifeguard.d.ts.map +1 -1
- package/dist/core/lifeguard.js +2 -1
- package/dist/core/lifeguard.js.map +1 -1
- package/dist/core/subagent-pool.d.ts.map +1 -1
- package/dist/core/subagent-pool.js +6 -4
- package/dist/core/subagent-pool.js.map +1 -1
- package/dist/core/tools/subagent.d.ts.map +1 -1
- package/dist/core/tools/subagent.js +3 -2
- package/dist/core/tools/subagent.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +7 -0
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/docs/routing.md +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.22] - 2026-08-18
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Dispatching a subagent no longer duplicates rows in the TUI. `beginDispatch`
|
|
8
|
+
wrote its `[DISPATCH]` line straight to the terminal with `console.error`;
|
|
9
|
+
stdout is reserved for the event stream, but stderr is not free either while
|
|
10
|
+
the TUI owns the screen. The renderer is differential and repositions by
|
|
11
|
+
relative row deltas from its own bookkeeping (`hardwareCursorRow`), so a write
|
|
12
|
+
it did not make scrolled the terminal underneath it and every later partial
|
|
13
|
+
repaint landed a row off — painting a second `Agent [explore]` row for a
|
|
14
|
+
single dispatch, wedging fragments of the log line into the transcript, and
|
|
15
|
+
scrambling the frame outright with two or more concurrent spawns. The damage
|
|
16
|
+
persisted until something forced a full repaint, which is why expanding and
|
|
17
|
+
collapsing tool output (`ctrl+O`) appeared to "fix" it.
|
|
18
|
+
|
|
19
|
+
Operational log lines now go through a small `agentLog` sink (`core/agent-log.ts`)
|
|
20
|
+
that stays silent while a TUI is attached and is unchanged in every other mode
|
|
21
|
+
(`--print`, RPC, CI). Nothing is lost interactively: the dispatch is already on
|
|
22
|
+
screen as its tool row and task-panel row, every field of the `[DISPATCH]` line
|
|
23
|
+
is persisted to `dispatch-log.json`, and warm/inherited-model fallbacks already
|
|
24
|
+
surface as a `⚠` note on the task row. `HOOCODE_DEBUG_AGENT_LOG=1` tees the
|
|
25
|
+
suppressed lines to `hoocode-debug.log`. The same hazard is closed for the
|
|
26
|
+
`[WARM]` cold-spawn fallbacks, the inherited-model retry notice, and the
|
|
27
|
+
lifeguard's stall report, which could corrupt the frame the same way.
|
|
28
|
+
|
|
29
|
+
## [0.5.21] - 2026-08-16
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Plugin hooks no longer crash the process when a hook exits without reading its
|
|
34
|
+
stdin. `runHookCommand` wrote the JSON payload inside a `try`, which catches
|
|
35
|
+
only synchronous throws: a hook that exits first (`exit 2`, any script
|
|
36
|
+
ignoring its input) made the write fail asynchronously, and `child.stdin`
|
|
37
|
+
emitted an unhandled `EPIPE`. Intermittently fatal in real sessions, and the
|
|
38
|
+
cause of the `bun-test (coding-agent)` failure on `main` where every test file
|
|
39
|
+
passed but the run still exited 1.
|
|
40
|
+
|
|
3
41
|
## [0.5.20] - 2026-08-16
|
|
4
42
|
|
|
5
43
|
## [0.5.19] - 2026-08-16
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sink for the agent's operational log lines — subagent dispatch, warm-worker
|
|
3
|
+
* fallback, lifeguard kills. These describe what the agent is doing to its own
|
|
4
|
+
* machinery, not what the user asked for.
|
|
5
|
+
*
|
|
6
|
+
* They used to go straight to `console.error`. stdout is reserved for the JSON
|
|
7
|
+
* event stream, so stderr looked free — but when the interactive TUI owns the
|
|
8
|
+
* screen it is not. The TUI is a differential renderer: it tracks the frame it
|
|
9
|
+
* painted (`previousLines`) and repositions by *relative* row deltas from its
|
|
10
|
+
* own bookkeeping (`hardwareCursorRow`). A write it did not make scrolls the
|
|
11
|
+
* terminal underneath it, and every later partial repaint lands one row off —
|
|
12
|
+
* the visible result being duplicated rows (two `Agent [explore]` lines for one
|
|
13
|
+
* dispatch) plus fragments of the log line wedged into the transcript, which
|
|
14
|
+
* survive until something forces a full repaint. The TUI cannot defend itself
|
|
15
|
+
* here; it has no way to learn that another writer touched the terminal. So the
|
|
16
|
+
* writer has to hold back.
|
|
17
|
+
*
|
|
18
|
+
* Hence: silent while a TUI is attached, unchanged everywhere else (`--print`,
|
|
19
|
+
* RPC, CI), where these lines are the only trace of a dispatch and where
|
|
20
|
+
* `docs/routing.md`'s `[DISPATCH] … depth=2 …` check still has to work.
|
|
21
|
+
*
|
|
22
|
+
* Nothing is lost in the interactive case. A dispatch is already on screen as
|
|
23
|
+
* the `Agent [explore]` tool row and its task-panel row, and every field of the
|
|
24
|
+
* `[DISPATCH]` line is persisted to `dispatch-log.json`; warm-worker and
|
|
25
|
+
* inherited-model fallbacks already surface as a `⚠` note on the task row.
|
|
26
|
+
* Set `HOOCODE_DEBUG_AGENT_LOG=1` to tee the suppressed lines to
|
|
27
|
+
* `~/.hoocode/agent/hoocode-debug.log` (mirrors HOOCODE_DEBUG_REDRAW).
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Declare whether a TUI currently owns the terminal. Interactive mode sets this
|
|
31
|
+
* around the UI's lifetime; every other run mode leaves it false.
|
|
32
|
+
*/
|
|
33
|
+
export declare function setTerminalOwnedByTui(owned: boolean): void;
|
|
34
|
+
/** Whether operational logging is currently being withheld from the terminal. */
|
|
35
|
+
export declare function isTerminalOwnedByTui(): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Emit one operational log line, unless a TUI owns the terminal.
|
|
38
|
+
*
|
|
39
|
+
* Callers must not assume this reaches anyone: it is diagnostics, not a user
|
|
40
|
+
* surface. Anything a user needs to see belongs in the transcript or the task
|
|
41
|
+
* panel.
|
|
42
|
+
*/
|
|
43
|
+
export declare function agentLog(message: string): void;
|
|
44
|
+
//# sourceMappingURL=agent-log.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-log.d.ts","sourceRoot":"","sources":["../../src/core/agent-log.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AASH;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAE1D;AAED,iFAAiF;AACjF,wBAAgB,oBAAoB,IAAI,OAAO,CAE9C;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAS9C","sourcesContent":["/**\n * Sink for the agent's operational log lines — subagent dispatch, warm-worker\n * fallback, lifeguard kills. These describe what the agent is doing to its own\n * machinery, not what the user asked for.\n *\n * They used to go straight to `console.error`. stdout is reserved for the JSON\n * event stream, so stderr looked free — but when the interactive TUI owns the\n * screen it is not. The TUI is a differential renderer: it tracks the frame it\n * painted (`previousLines`) and repositions by *relative* row deltas from its\n * own bookkeeping (`hardwareCursorRow`). A write it did not make scrolls the\n * terminal underneath it, and every later partial repaint lands one row off —\n * the visible result being duplicated rows (two `Agent [explore]` lines for one\n * dispatch) plus fragments of the log line wedged into the transcript, which\n * survive until something forces a full repaint. The TUI cannot defend itself\n * here; it has no way to learn that another writer touched the terminal. So the\n * writer has to hold back.\n *\n * Hence: silent while a TUI is attached, unchanged everywhere else (`--print`,\n * RPC, CI), where these lines are the only trace of a dispatch and where\n * `docs/routing.md`'s `[DISPATCH] … depth=2 …` check still has to work.\n *\n * Nothing is lost in the interactive case. A dispatch is already on screen as\n * the `Agent [explore]` tool row and its task-panel row, and every field of the\n * `[DISPATCH]` line is persisted to `dispatch-log.json`; warm-worker and\n * inherited-model fallbacks already surface as a `⚠` note on the task row.\n * Set `HOOCODE_DEBUG_AGENT_LOG=1` to tee the suppressed lines to\n * `~/.hoocode/agent/hoocode-debug.log` (mirrors HOOCODE_DEBUG_REDRAW).\n */\n\nimport { appendFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n\n/** True once an interactive TUI owns the terminal (see InteractiveMode.start/stop). */\nlet ownedByTui = false;\n\n/**\n * Declare whether a TUI currently owns the terminal. Interactive mode sets this\n * around the UI's lifetime; every other run mode leaves it false.\n */\nexport function setTerminalOwnedByTui(owned: boolean): void {\n\townedByTui = owned;\n}\n\n/** Whether operational logging is currently being withheld from the terminal. */\nexport function isTerminalOwnedByTui(): boolean {\n\treturn ownedByTui;\n}\n\n/**\n * Emit one operational log line, unless a TUI owns the terminal.\n *\n * Callers must not assume this reaches anyone: it is diagnostics, not a user\n * surface. Anything a user needs to see belongs in the transcript or the task\n * panel.\n */\nexport function agentLog(message: string): void {\n\tif (ownedByTui) {\n\t\tdebugTee(message);\n\t\treturn;\n\t}\n\t// Straight to fd 2 rather than console.error: this is explicitly a write to\n\t// the terminal, and test/extension hosts that swap `console` out from under\n\t// us would otherwise hide exactly the behavior that matters here.\n\tprocess.stderr.write(`${message}\\n`);\n}\n\n/** Best-effort file tee for suppressed lines, off unless explicitly enabled. */\nfunction debugTee(message: string): void {\n\tif (process.env.HOOCODE_DEBUG_AGENT_LOG !== \"1\") return;\n\ttry {\n\t\tconst agentDir = process.env.HOOCODE_CODING_AGENT_DIR ?? join(homedir(), \".hoocode\", \"agent\");\n\t\tappendFileSync(join(agentDir, \"hoocode-debug.log\"), `[${new Date().toISOString()}] ${message}\\n`);\n\t} catch {\n\t\t// Diagnostics only — never let logging break a dispatch.\n\t}\n}\n"]}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sink for the agent's operational log lines — subagent dispatch, warm-worker
|
|
3
|
+
* fallback, lifeguard kills. These describe what the agent is doing to its own
|
|
4
|
+
* machinery, not what the user asked for.
|
|
5
|
+
*
|
|
6
|
+
* They used to go straight to `console.error`. stdout is reserved for the JSON
|
|
7
|
+
* event stream, so stderr looked free — but when the interactive TUI owns the
|
|
8
|
+
* screen it is not. The TUI is a differential renderer: it tracks the frame it
|
|
9
|
+
* painted (`previousLines`) and repositions by *relative* row deltas from its
|
|
10
|
+
* own bookkeeping (`hardwareCursorRow`). A write it did not make scrolls the
|
|
11
|
+
* terminal underneath it, and every later partial repaint lands one row off —
|
|
12
|
+
* the visible result being duplicated rows (two `Agent [explore]` lines for one
|
|
13
|
+
* dispatch) plus fragments of the log line wedged into the transcript, which
|
|
14
|
+
* survive until something forces a full repaint. The TUI cannot defend itself
|
|
15
|
+
* here; it has no way to learn that another writer touched the terminal. So the
|
|
16
|
+
* writer has to hold back.
|
|
17
|
+
*
|
|
18
|
+
* Hence: silent while a TUI is attached, unchanged everywhere else (`--print`,
|
|
19
|
+
* RPC, CI), where these lines are the only trace of a dispatch and where
|
|
20
|
+
* `docs/routing.md`'s `[DISPATCH] … depth=2 …` check still has to work.
|
|
21
|
+
*
|
|
22
|
+
* Nothing is lost in the interactive case. A dispatch is already on screen as
|
|
23
|
+
* the `Agent [explore]` tool row and its task-panel row, and every field of the
|
|
24
|
+
* `[DISPATCH]` line is persisted to `dispatch-log.json`; warm-worker and
|
|
25
|
+
* inherited-model fallbacks already surface as a `⚠` note on the task row.
|
|
26
|
+
* Set `HOOCODE_DEBUG_AGENT_LOG=1` to tee the suppressed lines to
|
|
27
|
+
* `~/.hoocode/agent/hoocode-debug.log` (mirrors HOOCODE_DEBUG_REDRAW).
|
|
28
|
+
*/
|
|
29
|
+
import { appendFileSync } from "node:fs";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
/** True once an interactive TUI owns the terminal (see InteractiveMode.start/stop). */
|
|
33
|
+
let ownedByTui = false;
|
|
34
|
+
/**
|
|
35
|
+
* Declare whether a TUI currently owns the terminal. Interactive mode sets this
|
|
36
|
+
* around the UI's lifetime; every other run mode leaves it false.
|
|
37
|
+
*/
|
|
38
|
+
export function setTerminalOwnedByTui(owned) {
|
|
39
|
+
ownedByTui = owned;
|
|
40
|
+
}
|
|
41
|
+
/** Whether operational logging is currently being withheld from the terminal. */
|
|
42
|
+
export function isTerminalOwnedByTui() {
|
|
43
|
+
return ownedByTui;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Emit one operational log line, unless a TUI owns the terminal.
|
|
47
|
+
*
|
|
48
|
+
* Callers must not assume this reaches anyone: it is diagnostics, not a user
|
|
49
|
+
* surface. Anything a user needs to see belongs in the transcript or the task
|
|
50
|
+
* panel.
|
|
51
|
+
*/
|
|
52
|
+
export function agentLog(message) {
|
|
53
|
+
if (ownedByTui) {
|
|
54
|
+
debugTee(message);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Straight to fd 2 rather than console.error: this is explicitly a write to
|
|
58
|
+
// the terminal, and test/extension hosts that swap `console` out from under
|
|
59
|
+
// us would otherwise hide exactly the behavior that matters here.
|
|
60
|
+
process.stderr.write(`${message}\n`);
|
|
61
|
+
}
|
|
62
|
+
/** Best-effort file tee for suppressed lines, off unless explicitly enabled. */
|
|
63
|
+
function debugTee(message) {
|
|
64
|
+
if (process.env.HOOCODE_DEBUG_AGENT_LOG !== "1")
|
|
65
|
+
return;
|
|
66
|
+
try {
|
|
67
|
+
const agentDir = process.env.HOOCODE_CODING_AGENT_DIR ?? join(homedir(), ".hoocode", "agent");
|
|
68
|
+
appendFileSync(join(agentDir, "hoocode-debug.log"), `[${new Date().toISOString()}] ${message}\n`);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Diagnostics only — never let logging break a dispatch.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=agent-log.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-log.js","sourceRoot":"","sources":["../../src/core/agent-log.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AACzC,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,uFAAuF;AACvF,IAAI,UAAU,GAAG,KAAK,CAAC;AAEvB;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CAAC,KAAc,EAAQ;IAC3D,UAAU,GAAG,KAAK,CAAC;AAAA,CACnB;AAED,iFAAiF;AACjF,MAAM,UAAU,oBAAoB,GAAY;IAC/C,OAAO,UAAU,CAAC;AAAA,CAClB;AAED;;;;;;GAMG;AACH,MAAM,UAAU,QAAQ,CAAC,OAAe,EAAQ;IAC/C,IAAI,UAAU,EAAE,CAAC;QAChB,QAAQ,CAAC,OAAO,CAAC,CAAC;QAClB,OAAO;IACR,CAAC;IACD,4EAA4E;IAC5E,4EAA4E;IAC5E,kEAAkE;IAClE,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,OAAO,IAAI,CAAC,CAAC;AAAA,CACrC;AAED,gFAAgF;AAChF,SAAS,QAAQ,CAAC,OAAe,EAAQ;IACxC,IAAI,OAAO,CAAC,GAAG,CAAC,uBAAuB,KAAK,GAAG;QAAE,OAAO;IACxD,IAAI,CAAC;QACJ,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,wBAAwB,IAAI,IAAI,CAAC,OAAO,EAAE,EAAE,UAAU,EAAE,OAAO,CAAC,CAAC;QAC9F,cAAc,CAAC,IAAI,CAAC,QAAQ,EAAE,mBAAmB,CAAC,EAAE,IAAI,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,KAAK,OAAO,IAAI,CAAC,CAAC;IACnG,CAAC;IAAC,MAAM,CAAC;QACR,2DAAyD;IAC1D,CAAC;AAAA,CACD","sourcesContent":["/**\n * Sink for the agent's operational log lines — subagent dispatch, warm-worker\n * fallback, lifeguard kills. These describe what the agent is doing to its own\n * machinery, not what the user asked for.\n *\n * They used to go straight to `console.error`. stdout is reserved for the JSON\n * event stream, so stderr looked free — but when the interactive TUI owns the\n * screen it is not. The TUI is a differential renderer: it tracks the frame it\n * painted (`previousLines`) and repositions by *relative* row deltas from its\n * own bookkeeping (`hardwareCursorRow`). A write it did not make scrolls the\n * terminal underneath it, and every later partial repaint lands one row off —\n * the visible result being duplicated rows (two `Agent [explore]` lines for one\n * dispatch) plus fragments of the log line wedged into the transcript, which\n * survive until something forces a full repaint. The TUI cannot defend itself\n * here; it has no way to learn that another writer touched the terminal. So the\n * writer has to hold back.\n *\n * Hence: silent while a TUI is attached, unchanged everywhere else (`--print`,\n * RPC, CI), where these lines are the only trace of a dispatch and where\n * `docs/routing.md`'s `[DISPATCH] … depth=2 …` check still has to work.\n *\n * Nothing is lost in the interactive case. A dispatch is already on screen as\n * the `Agent [explore]` tool row and its task-panel row, and every field of the\n * `[DISPATCH]` line is persisted to `dispatch-log.json`; warm-worker and\n * inherited-model fallbacks already surface as a `⚠` note on the task row.\n * Set `HOOCODE_DEBUG_AGENT_LOG=1` to tee the suppressed lines to\n * `~/.hoocode/agent/hoocode-debug.log` (mirrors HOOCODE_DEBUG_REDRAW).\n */\n\nimport { appendFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n\n/** True once an interactive TUI owns the terminal (see InteractiveMode.start/stop). */\nlet ownedByTui = false;\n\n/**\n * Declare whether a TUI currently owns the terminal. Interactive mode sets this\n * around the UI's lifetime; every other run mode leaves it false.\n */\nexport function setTerminalOwnedByTui(owned: boolean): void {\n\townedByTui = owned;\n}\n\n/** Whether operational logging is currently being withheld from the terminal. */\nexport function isTerminalOwnedByTui(): boolean {\n\treturn ownedByTui;\n}\n\n/**\n * Emit one operational log line, unless a TUI owns the terminal.\n *\n * Callers must not assume this reaches anyone: it is diagnostics, not a user\n * surface. Anything a user needs to see belongs in the transcript or the task\n * panel.\n */\nexport function agentLog(message: string): void {\n\tif (ownedByTui) {\n\t\tdebugTee(message);\n\t\treturn;\n\t}\n\t// Straight to fd 2 rather than console.error: this is explicitly a write to\n\t// the terminal, and test/extension hosts that swap `console` out from under\n\t// us would otherwise hide exactly the behavior that matters here.\n\tprocess.stderr.write(`${message}\\n`);\n}\n\n/** Best-effort file tee for suppressed lines, off unless explicitly enabled. */\nfunction debugTee(message: string): void {\n\tif (process.env.HOOCODE_DEBUG_AGENT_LOG !== \"1\") return;\n\ttry {\n\t\tconst agentDir = process.env.HOOCODE_CODING_AGENT_DIR ?? join(homedir(), \".hoocode\", \"agent\");\n\t\tappendFileSync(join(agentDir, \"hoocode-debug.log\"), `[${new Date().toISOString()}] ${message}\\n`);\n\t} catch {\n\t\t// Diagnostics only — never let logging break a dispatch.\n\t}\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hooks-bridge.d.ts","sourceRoot":"","sources":["../../../../src/core/extensions/plugins/hooks-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAkC,MAAM,aAAa,CAAC;AAChF,OAAO,KAAK,EAA6C,iBAAiB,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"hooks-bridge.d.ts","sourceRoot":"","sources":["../../../../src/core/extensions/plugins/hooks-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAkC,MAAM,aAAa,CAAC;AAChF,OAAO,KAAK,EAA6C,iBAAiB,EAAE,MAAM,eAAe,CAAC;AA4FlG;;;GAGG;AACH,wBAAgB,kBAAkB,CACjC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,iBAAiB,EACxB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC5B,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAChC,IAAI,CAoGN","sourcesContent":["/**\n * Hooks bridge — runs Claude Code / native-plugin shell hooks against hoocode events.\n *\n * Claude Code hooks are shell commands wired to named events and matched by tool\n * name. hoocode hooks are TypeScript handlers on the {@link ExtensionEvent} union.\n * This bridge registers handlers that shell out per the hook protocol and translate\n * stdin JSON + exit codes + stdout JSON back into hoocode result objects.\n *\n * Protocol (faithful to Claude Code):\n * - Input: a JSON object on stdin describing the event.\n * - Exit 0: success. stdout may carry a JSON decision; for prompt/session events\n * plain stdout is treated as additional context.\n * - Exit 2: blocking error. stderr (or JSON `reason`) is the block reason.\n * - Other non-zero: non-blocking error (logged, not surfaced to the model).\n * - Optional stdout JSON: `{ decision: \"block\"|\"approve\", reason, permissionDecision }`.\n */\n\nimport { spawn } from \"node:child_process\";\nimport type { ExtensionAPI, ToolCallEvent, ToolResultEvent } from \"../types.js\";\nimport type { PluginHookCommand, PluginHookMatcherGroup, PluginHooksConfig } from \"./manifest.js\";\n\nconst DEFAULT_TIMEOUT_MS = 60_000;\n\ninterface HookRunResult {\n\texitCode: number;\n\tstdout: string;\n\tstderr: string;\n\tjson: { decision?: string; reason?: string; permissionDecision?: string; continue?: boolean } | undefined;\n}\n\n/** Run one shell hook command, piping `input` as JSON on stdin. */\nfunction runHookCommand(\n\tcmd: PluginHookCommand,\n\tinput: unknown,\n\troot: string,\n\tvars: Record<string, string>,\n): Promise<HookRunResult> {\n\treturn new Promise((resolve) => {\n\t\tconst child = spawn(cmd.command, {\n\t\t\tshell: true,\n\t\t\t// Every vendor spelling, so a hook written for either agent resolves:\n\t\t\t// the shell expands these, which is the hook-side equivalent of the\n\t\t\t// string substitution MCP configs get.\n\t\t\tenv: { ...process.env, ...vars },\n\t\t\tcwd: root,\n\t\t});\n\n\t\tlet stdout = \"\";\n\t\tlet stderr = \"\";\n\t\tconst timer = setTimeout(() => child.kill(\"SIGTERM\"), (cmd.timeout ?? DEFAULT_TIMEOUT_MS / 1000) * 1000);\n\t\ttimer.unref?.();\n\n\t\tchild.stdout.on(\"data\", (d) => {\n\t\t\tstdout += d.toString();\n\t\t});\n\t\tchild.stderr.on(\"data\", (d) => {\n\t\t\tstderr += d.toString();\n\t\t});\n\t\t// A hook that exits without draining stdin (block.sh, `exit 2`, any script\n\t\t// that ignores its input) makes the payload write below fail asynchronously.\n\t\t// stdin then emits EPIPE, which is fatal to the process if unhandled.\n\t\tchild.stdin.on(\"error\", () => {});\n\t\tchild.on(\"error\", () => {\n\t\t\tclearTimeout(timer);\n\t\t\tresolve({ exitCode: 1, stdout, stderr, json: undefined });\n\t\t});\n\t\tchild.on(\"close\", (code) => {\n\t\t\tclearTimeout(timer);\n\t\t\tlet json: HookRunResult[\"json\"];\n\t\t\tconst trimmed = stdout.trim();\n\t\t\tif (trimmed.startsWith(\"{\")) {\n\t\t\t\ttry {\n\t\t\t\t\tjson = JSON.parse(trimmed);\n\t\t\t\t} catch {\n\t\t\t\t\tjson = undefined;\n\t\t\t\t}\n\t\t\t}\n\t\t\tresolve({ exitCode: code ?? 0, stdout, stderr, json });\n\t\t});\n\n\t\ttry {\n\t\t\tchild.stdin.write(JSON.stringify(input));\n\t\t\tchild.stdin.end();\n\t\t} catch {\n\t\t\t/* child already exited: the payload is best-effort, the exit code is not */\n\t\t}\n\t});\n}\n\n/** Empty / \"*\" matcher matches everything; otherwise treat as an anchored regex on the tool name. */\nfunction matcherMatches(matcher: string | undefined, toolName: string): boolean {\n\tif (!matcher || matcher === \"*\") return true;\n\ttry {\n\t\treturn new RegExp(`^(?:${matcher})$`).test(toolName);\n\t} catch {\n\t\treturn matcher === toolName;\n\t}\n}\n\nfunction groupsForTool(groups: PluginHookMatcherGroup[], toolName: string): PluginHookCommand[] {\n\tconst cmds: PluginHookCommand[] = [];\n\tfor (const g of groups) {\n\t\tif (matcherMatches(g.matcher, toolName)) cmds.push(...g.hooks);\n\t}\n\treturn cmds;\n}\n\nfunction allCommands(groups: PluginHookMatcherGroup[]): PluginHookCommand[] {\n\treturn groups.flatMap((g) => g.hooks);\n}\n\n/**\n * Register all hook events for one plugin against the ExtensionAPI.\n * `onError` reports non-blocking failures (kept off the model's path).\n */\nexport function installPluginHooks(\n\tpi: ExtensionAPI,\n\thooks: PluginHooksConfig,\n\troot: string,\n\tvars: Record<string, string>,\n\tonError: (message: string) => void,\n): void {\n\t// ── PreToolUse → tool_call (blocking) ────────────────────────────────────\n\tconst preGroups = hooks.PreToolUse;\n\tif (preGroups?.length) {\n\t\tpi.on(\"tool_call\", async (event: ToolCallEvent) => {\n\t\t\tconst cmds = groupsForTool(preGroups, event.toolName);\n\t\t\tfor (const cmd of cmds) {\n\t\t\t\tconst res = await runHookCommand(\n\t\t\t\t\tcmd,\n\t\t\t\t\t{ hook_event_name: \"PreToolUse\", tool_name: event.toolName, tool_input: event.input },\n\t\t\t\t\troot,\n\t\t\t\t\tvars,\n\t\t\t\t);\n\t\t\t\tconst decision = res.json?.decision ?? res.json?.permissionDecision;\n\t\t\t\tif (res.exitCode === 2 || decision === \"block\" || decision === \"deny\") {\n\t\t\t\t\treturn { block: true, reason: res.json?.reason || res.stderr.trim() || \"Blocked by plugin hook\" };\n\t\t\t\t}\n\t\t\t\tif (res.exitCode !== 0) onError(`PreToolUse hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t}\n\t\t});\n\t}\n\n\t// ── PostToolUse → tool_result (best-effort) ──────────────────────────────\n\tconst postGroups = hooks.PostToolUse;\n\tif (postGroups?.length) {\n\t\tpi.on(\"tool_result\", async (event: ToolResultEvent) => {\n\t\t\tconst cmds = groupsForTool(postGroups, event.toolName);\n\t\t\tfor (const cmd of cmds) {\n\t\t\t\tconst res = await runHookCommand(\n\t\t\t\t\tcmd,\n\t\t\t\t\t{\n\t\t\t\t\t\thook_event_name: \"PostToolUse\",\n\t\t\t\t\t\ttool_name: event.toolName,\n\t\t\t\t\t\ttool_input: event.input,\n\t\t\t\t\t\ttool_response: event.content,\n\t\t\t\t\t},\n\t\t\t\t\troot,\n\t\t\t\t\tvars,\n\t\t\t\t);\n\t\t\t\tif (res.exitCode === 2 || res.json?.decision === \"block\") {\n\t\t\t\t\tconst reason = res.json?.reason || res.stderr.trim() || \"Flagged by plugin hook\";\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [...event.content, { type: \"text\" as const, text: `\\n[plugin hook] ${reason}` }],\n\t\t\t\t\t\tisError: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tif (res.exitCode !== 0) onError(`PostToolUse hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t}\n\t\t});\n\t}\n\n\t// ── UserPromptSubmit → before_agent_start (adds context) ─────────────────\n\tconst promptGroups = hooks.UserPromptSubmit;\n\tif (promptGroups?.length) {\n\t\tpi.on(\"before_agent_start\", async (event) => {\n\t\t\tlet systemPrompt = event.systemPrompt;\n\t\t\tfor (const cmd of allCommands(promptGroups)) {\n\t\t\t\tconst res = await runHookCommand(\n\t\t\t\t\tcmd,\n\t\t\t\t\t{ hook_event_name: \"UserPromptSubmit\", prompt: event.prompt },\n\t\t\t\t\troot,\n\t\t\t\t\tvars,\n\t\t\t\t);\n\t\t\t\tif (res.exitCode !== 0 && res.exitCode !== 2) {\n\t\t\t\t\tonError(`UserPromptSubmit hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tconst extra = res.exitCode === 2 ? res.stderr.trim() : res.json?.reason || res.stdout.trim();\n\t\t\t\tif (extra) systemPrompt = `${systemPrompt}\\n\\n<!-- plugin hook -->\\n${extra}`;\n\t\t\t}\n\t\t\treturn systemPrompt === event.systemPrompt ? undefined : { systemPrompt };\n\t\t});\n\t}\n\n\t// ── SessionStart → session_start (side effects) ──────────────────────────\n\tconst sessionGroups = hooks.SessionStart;\n\tif (sessionGroups?.length) {\n\t\tpi.on(\"session_start\", async (event) => {\n\t\t\tfor (const cmd of allCommands(sessionGroups)) {\n\t\t\t\tconst res = await runHookCommand(\n\t\t\t\t\tcmd,\n\t\t\t\t\t{ hook_event_name: \"SessionStart\", source: event.reason },\n\t\t\t\t\troot,\n\t\t\t\t\tvars,\n\t\t\t\t);\n\t\t\t\tif (res.exitCode !== 0) onError(`SessionStart hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t}\n\t\t});\n\t}\n\n\t// ── Stop → agent_end (side effects) ──────────────────────────────────────\n\tconst stopGroups = hooks.Stop;\n\tif (stopGroups?.length) {\n\t\tpi.on(\"agent_end\", async () => {\n\t\t\tfor (const cmd of allCommands(stopGroups)) {\n\t\t\t\tconst res = await runHookCommand(cmd, { hook_event_name: \"Stop\" }, root, vars);\n\t\t\t\tif (res.exitCode !== 0) onError(`Stop hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t}\n\t\t});\n\t}\n}\n"]}
|
|
@@ -37,6 +37,10 @@ function runHookCommand(cmd, input, root, vars) {
|
|
|
37
37
|
child.stderr.on("data", (d) => {
|
|
38
38
|
stderr += d.toString();
|
|
39
39
|
});
|
|
40
|
+
// A hook that exits without draining stdin (block.sh, `exit 2`, any script
|
|
41
|
+
// that ignores its input) makes the payload write below fail asynchronously.
|
|
42
|
+
// stdin then emits EPIPE, which is fatal to the process if unhandled.
|
|
43
|
+
child.stdin.on("error", () => { });
|
|
40
44
|
child.on("error", () => {
|
|
41
45
|
clearTimeout(timer);
|
|
42
46
|
resolve({ exitCode: 1, stdout, stderr, json: undefined });
|
|
@@ -60,7 +64,7 @@ function runHookCommand(cmd, input, root, vars) {
|
|
|
60
64
|
child.stdin.end();
|
|
61
65
|
}
|
|
62
66
|
catch {
|
|
63
|
-
/* child
|
|
67
|
+
/* child already exited: the payload is best-effort, the exit code is not */
|
|
64
68
|
}
|
|
65
69
|
});
|
|
66
70
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hooks-bridge.js","sourceRoot":"","sources":["../../../../src/core/extensions/plugins/hooks-bridge.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Hooks bridge — runs Claude Code / native-plugin shell hooks against hoocode events.\n *\n * Claude Code hooks are shell commands wired to named events and matched by tool\n * name. hoocode hooks are TypeScript handlers on the {@link ExtensionEvent} union.\n * This bridge registers handlers that shell out per the hook protocol and translate\n * stdin JSON + exit codes + stdout JSON back into hoocode result objects.\n *\n * Protocol (faithful to Claude Code):\n * - Input: a JSON object on stdin describing the event.\n * - Exit 0: success. stdout may carry a JSON decision; for prompt/session events\n * plain stdout is treated as additional context.\n * - Exit 2: blocking error. stderr (or JSON `reason`) is the block reason.\n * - Other non-zero: non-blocking error (logged, not surfaced to the model).\n * - Optional stdout JSON: `{ decision: \"block\"|\"approve\", reason, permissionDecision }`.\n */\n\nimport { spawn } from \"node:child_process\";\nimport type { ExtensionAPI, ToolCallEvent, ToolResultEvent } from \"../types.js\";\nimport type { PluginHookCommand, PluginHookMatcherGroup, PluginHooksConfig } from \"./manifest.js\";\n\nconst DEFAULT_TIMEOUT_MS = 60_000;\n\ninterface HookRunResult {\n\texitCode: number;\n\tstdout: string;\n\tstderr: string;\n\tjson: { decision?: string; reason?: string; permissionDecision?: string; continue?: boolean } | undefined;\n}\n\n/** Run one shell hook command, piping `input` as JSON on stdin. */\nfunction runHookCommand(\n\tcmd: PluginHookCommand,\n\tinput: unknown,\n\troot: string,\n\tvars: Record<string, string>,\n): Promise<HookRunResult> {\n\treturn new Promise((resolve) => {\n\t\tconst child = spawn(cmd.command, {\n\t\t\tshell: true,\n\t\t\t// Every vendor spelling, so a hook written for either agent resolves:\n\t\t\t// the shell expands these, which is the hook-side equivalent of the\n\t\t\t// string substitution MCP configs get.\n\t\t\tenv: { ...process.env, ...vars },\n\t\t\tcwd: root,\n\t\t});\n\n\t\tlet stdout = \"\";\n\t\tlet stderr = \"\";\n\t\tconst timer = setTimeout(() => child.kill(\"SIGTERM\"), (cmd.timeout ?? DEFAULT_TIMEOUT_MS / 1000) * 1000);\n\t\ttimer.unref?.();\n\n\t\tchild.stdout.on(\"data\", (d) => {\n\t\t\tstdout += d.toString();\n\t\t});\n\t\tchild.stderr.on(\"data\", (d) => {\n\t\t\tstderr += d.toString();\n\t\t});\n\t\tchild.on(\"error\", () => {\n\t\t\tclearTimeout(timer);\n\t\t\tresolve({ exitCode: 1, stdout, stderr, json: undefined });\n\t\t});\n\t\tchild.on(\"close\", (code) => {\n\t\t\tclearTimeout(timer);\n\t\t\tlet json: HookRunResult[\"json\"];\n\t\t\tconst trimmed = stdout.trim();\n\t\t\tif (trimmed.startsWith(\"{\")) {\n\t\t\t\ttry {\n\t\t\t\t\tjson = JSON.parse(trimmed);\n\t\t\t\t} catch {\n\t\t\t\t\tjson = undefined;\n\t\t\t\t}\n\t\t\t}\n\t\t\tresolve({ exitCode: code ?? 0, stdout, stderr, json });\n\t\t});\n\n\t\ttry {\n\t\t\tchild.stdin.write(JSON.stringify(input));\n\t\t\tchild.stdin.end();\n\t\t} catch {\n\t\t\t/* child may have already exited */\n\t\t}\n\t});\n}\n\n/** Empty / \"*\" matcher matches everything; otherwise treat as an anchored regex on the tool name. */\nfunction matcherMatches(matcher: string | undefined, toolName: string): boolean {\n\tif (!matcher || matcher === \"*\") return true;\n\ttry {\n\t\treturn new RegExp(`^(?:${matcher})$`).test(toolName);\n\t} catch {\n\t\treturn matcher === toolName;\n\t}\n}\n\nfunction groupsForTool(groups: PluginHookMatcherGroup[], toolName: string): PluginHookCommand[] {\n\tconst cmds: PluginHookCommand[] = [];\n\tfor (const g of groups) {\n\t\tif (matcherMatches(g.matcher, toolName)) cmds.push(...g.hooks);\n\t}\n\treturn cmds;\n}\n\nfunction allCommands(groups: PluginHookMatcherGroup[]): PluginHookCommand[] {\n\treturn groups.flatMap((g) => g.hooks);\n}\n\n/**\n * Register all hook events for one plugin against the ExtensionAPI.\n * `onError` reports non-blocking failures (kept off the model's path).\n */\nexport function installPluginHooks(\n\tpi: ExtensionAPI,\n\thooks: PluginHooksConfig,\n\troot: string,\n\tvars: Record<string, string>,\n\tonError: (message: string) => void,\n): void {\n\t// ── PreToolUse → tool_call (blocking) ────────────────────────────────────\n\tconst preGroups = hooks.PreToolUse;\n\tif (preGroups?.length) {\n\t\tpi.on(\"tool_call\", async (event: ToolCallEvent) => {\n\t\t\tconst cmds = groupsForTool(preGroups, event.toolName);\n\t\t\tfor (const cmd of cmds) {\n\t\t\t\tconst res = await runHookCommand(\n\t\t\t\t\tcmd,\n\t\t\t\t\t{ hook_event_name: \"PreToolUse\", tool_name: event.toolName, tool_input: event.input },\n\t\t\t\t\troot,\n\t\t\t\t\tvars,\n\t\t\t\t);\n\t\t\t\tconst decision = res.json?.decision ?? res.json?.permissionDecision;\n\t\t\t\tif (res.exitCode === 2 || decision === \"block\" || decision === \"deny\") {\n\t\t\t\t\treturn { block: true, reason: res.json?.reason || res.stderr.trim() || \"Blocked by plugin hook\" };\n\t\t\t\t}\n\t\t\t\tif (res.exitCode !== 0) onError(`PreToolUse hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t}\n\t\t});\n\t}\n\n\t// ── PostToolUse → tool_result (best-effort) ──────────────────────────────\n\tconst postGroups = hooks.PostToolUse;\n\tif (postGroups?.length) {\n\t\tpi.on(\"tool_result\", async (event: ToolResultEvent) => {\n\t\t\tconst cmds = groupsForTool(postGroups, event.toolName);\n\t\t\tfor (const cmd of cmds) {\n\t\t\t\tconst res = await runHookCommand(\n\t\t\t\t\tcmd,\n\t\t\t\t\t{\n\t\t\t\t\t\thook_event_name: \"PostToolUse\",\n\t\t\t\t\t\ttool_name: event.toolName,\n\t\t\t\t\t\ttool_input: event.input,\n\t\t\t\t\t\ttool_response: event.content,\n\t\t\t\t\t},\n\t\t\t\t\troot,\n\t\t\t\t\tvars,\n\t\t\t\t);\n\t\t\t\tif (res.exitCode === 2 || res.json?.decision === \"block\") {\n\t\t\t\t\tconst reason = res.json?.reason || res.stderr.trim() || \"Flagged by plugin hook\";\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [...event.content, { type: \"text\" as const, text: `\\n[plugin hook] ${reason}` }],\n\t\t\t\t\t\tisError: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tif (res.exitCode !== 0) onError(`PostToolUse hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t}\n\t\t});\n\t}\n\n\t// ── UserPromptSubmit → before_agent_start (adds context) ─────────────────\n\tconst promptGroups = hooks.UserPromptSubmit;\n\tif (promptGroups?.length) {\n\t\tpi.on(\"before_agent_start\", async (event) => {\n\t\t\tlet systemPrompt = event.systemPrompt;\n\t\t\tfor (const cmd of allCommands(promptGroups)) {\n\t\t\t\tconst res = await runHookCommand(\n\t\t\t\t\tcmd,\n\t\t\t\t\t{ hook_event_name: \"UserPromptSubmit\", prompt: event.prompt },\n\t\t\t\t\troot,\n\t\t\t\t\tvars,\n\t\t\t\t);\n\t\t\t\tif (res.exitCode !== 0 && res.exitCode !== 2) {\n\t\t\t\t\tonError(`UserPromptSubmit hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tconst extra = res.exitCode === 2 ? res.stderr.trim() : res.json?.reason || res.stdout.trim();\n\t\t\t\tif (extra) systemPrompt = `${systemPrompt}\\n\\n<!-- plugin hook -->\\n${extra}`;\n\t\t\t}\n\t\t\treturn systemPrompt === event.systemPrompt ? undefined : { systemPrompt };\n\t\t});\n\t}\n\n\t// ── SessionStart → session_start (side effects) ──────────────────────────\n\tconst sessionGroups = hooks.SessionStart;\n\tif (sessionGroups?.length) {\n\t\tpi.on(\"session_start\", async (event) => {\n\t\t\tfor (const cmd of allCommands(sessionGroups)) {\n\t\t\t\tconst res = await runHookCommand(\n\t\t\t\t\tcmd,\n\t\t\t\t\t{ hook_event_name: \"SessionStart\", source: event.reason },\n\t\t\t\t\troot,\n\t\t\t\t\tvars,\n\t\t\t\t);\n\t\t\t\tif (res.exitCode !== 0) onError(`SessionStart hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t}\n\t\t});\n\t}\n\n\t// ── Stop → agent_end (side effects) ──────────────────────────────────────\n\tconst stopGroups = hooks.Stop;\n\tif (stopGroups?.length) {\n\t\tpi.on(\"agent_end\", async () => {\n\t\t\tfor (const cmd of allCommands(stopGroups)) {\n\t\t\t\tconst res = await runHookCommand(cmd, { hook_event_name: \"Stop\" }, root, vars);\n\t\t\t\tif (res.exitCode !== 0) onError(`Stop hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t}\n\t\t});\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"hooks-bridge.js","sourceRoot":"","sources":["../../../../src/core/extensions/plugins/hooks-bridge.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Hooks bridge — runs Claude Code / native-plugin shell hooks against hoocode events.\n *\n * Claude Code hooks are shell commands wired to named events and matched by tool\n * name. hoocode hooks are TypeScript handlers on the {@link ExtensionEvent} union.\n * This bridge registers handlers that shell out per the hook protocol and translate\n * stdin JSON + exit codes + stdout JSON back into hoocode result objects.\n *\n * Protocol (faithful to Claude Code):\n * - Input: a JSON object on stdin describing the event.\n * - Exit 0: success. stdout may carry a JSON decision; for prompt/session events\n * plain stdout is treated as additional context.\n * - Exit 2: blocking error. stderr (or JSON `reason`) is the block reason.\n * - Other non-zero: non-blocking error (logged, not surfaced to the model).\n * - Optional stdout JSON: `{ decision: \"block\"|\"approve\", reason, permissionDecision }`.\n */\n\nimport { spawn } from \"node:child_process\";\nimport type { ExtensionAPI, ToolCallEvent, ToolResultEvent } from \"../types.js\";\nimport type { PluginHookCommand, PluginHookMatcherGroup, PluginHooksConfig } from \"./manifest.js\";\n\nconst DEFAULT_TIMEOUT_MS = 60_000;\n\ninterface HookRunResult {\n\texitCode: number;\n\tstdout: string;\n\tstderr: string;\n\tjson: { decision?: string; reason?: string; permissionDecision?: string; continue?: boolean } | undefined;\n}\n\n/** Run one shell hook command, piping `input` as JSON on stdin. */\nfunction runHookCommand(\n\tcmd: PluginHookCommand,\n\tinput: unknown,\n\troot: string,\n\tvars: Record<string, string>,\n): Promise<HookRunResult> {\n\treturn new Promise((resolve) => {\n\t\tconst child = spawn(cmd.command, {\n\t\t\tshell: true,\n\t\t\t// Every vendor spelling, so a hook written for either agent resolves:\n\t\t\t// the shell expands these, which is the hook-side equivalent of the\n\t\t\t// string substitution MCP configs get.\n\t\t\tenv: { ...process.env, ...vars },\n\t\t\tcwd: root,\n\t\t});\n\n\t\tlet stdout = \"\";\n\t\tlet stderr = \"\";\n\t\tconst timer = setTimeout(() => child.kill(\"SIGTERM\"), (cmd.timeout ?? DEFAULT_TIMEOUT_MS / 1000) * 1000);\n\t\ttimer.unref?.();\n\n\t\tchild.stdout.on(\"data\", (d) => {\n\t\t\tstdout += d.toString();\n\t\t});\n\t\tchild.stderr.on(\"data\", (d) => {\n\t\t\tstderr += d.toString();\n\t\t});\n\t\t// A hook that exits without draining stdin (block.sh, `exit 2`, any script\n\t\t// that ignores its input) makes the payload write below fail asynchronously.\n\t\t// stdin then emits EPIPE, which is fatal to the process if unhandled.\n\t\tchild.stdin.on(\"error\", () => {});\n\t\tchild.on(\"error\", () => {\n\t\t\tclearTimeout(timer);\n\t\t\tresolve({ exitCode: 1, stdout, stderr, json: undefined });\n\t\t});\n\t\tchild.on(\"close\", (code) => {\n\t\t\tclearTimeout(timer);\n\t\t\tlet json: HookRunResult[\"json\"];\n\t\t\tconst trimmed = stdout.trim();\n\t\t\tif (trimmed.startsWith(\"{\")) {\n\t\t\t\ttry {\n\t\t\t\t\tjson = JSON.parse(trimmed);\n\t\t\t\t} catch {\n\t\t\t\t\tjson = undefined;\n\t\t\t\t}\n\t\t\t}\n\t\t\tresolve({ exitCode: code ?? 0, stdout, stderr, json });\n\t\t});\n\n\t\ttry {\n\t\t\tchild.stdin.write(JSON.stringify(input));\n\t\t\tchild.stdin.end();\n\t\t} catch {\n\t\t\t/* child already exited: the payload is best-effort, the exit code is not */\n\t\t}\n\t});\n}\n\n/** Empty / \"*\" matcher matches everything; otherwise treat as an anchored regex on the tool name. */\nfunction matcherMatches(matcher: string | undefined, toolName: string): boolean {\n\tif (!matcher || matcher === \"*\") return true;\n\ttry {\n\t\treturn new RegExp(`^(?:${matcher})$`).test(toolName);\n\t} catch {\n\t\treturn matcher === toolName;\n\t}\n}\n\nfunction groupsForTool(groups: PluginHookMatcherGroup[], toolName: string): PluginHookCommand[] {\n\tconst cmds: PluginHookCommand[] = [];\n\tfor (const g of groups) {\n\t\tif (matcherMatches(g.matcher, toolName)) cmds.push(...g.hooks);\n\t}\n\treturn cmds;\n}\n\nfunction allCommands(groups: PluginHookMatcherGroup[]): PluginHookCommand[] {\n\treturn groups.flatMap((g) => g.hooks);\n}\n\n/**\n * Register all hook events for one plugin against the ExtensionAPI.\n * `onError` reports non-blocking failures (kept off the model's path).\n */\nexport function installPluginHooks(\n\tpi: ExtensionAPI,\n\thooks: PluginHooksConfig,\n\troot: string,\n\tvars: Record<string, string>,\n\tonError: (message: string) => void,\n): void {\n\t// ── PreToolUse → tool_call (blocking) ────────────────────────────────────\n\tconst preGroups = hooks.PreToolUse;\n\tif (preGroups?.length) {\n\t\tpi.on(\"tool_call\", async (event: ToolCallEvent) => {\n\t\t\tconst cmds = groupsForTool(preGroups, event.toolName);\n\t\t\tfor (const cmd of cmds) {\n\t\t\t\tconst res = await runHookCommand(\n\t\t\t\t\tcmd,\n\t\t\t\t\t{ hook_event_name: \"PreToolUse\", tool_name: event.toolName, tool_input: event.input },\n\t\t\t\t\troot,\n\t\t\t\t\tvars,\n\t\t\t\t);\n\t\t\t\tconst decision = res.json?.decision ?? res.json?.permissionDecision;\n\t\t\t\tif (res.exitCode === 2 || decision === \"block\" || decision === \"deny\") {\n\t\t\t\t\treturn { block: true, reason: res.json?.reason || res.stderr.trim() || \"Blocked by plugin hook\" };\n\t\t\t\t}\n\t\t\t\tif (res.exitCode !== 0) onError(`PreToolUse hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t}\n\t\t});\n\t}\n\n\t// ── PostToolUse → tool_result (best-effort) ──────────────────────────────\n\tconst postGroups = hooks.PostToolUse;\n\tif (postGroups?.length) {\n\t\tpi.on(\"tool_result\", async (event: ToolResultEvent) => {\n\t\t\tconst cmds = groupsForTool(postGroups, event.toolName);\n\t\t\tfor (const cmd of cmds) {\n\t\t\t\tconst res = await runHookCommand(\n\t\t\t\t\tcmd,\n\t\t\t\t\t{\n\t\t\t\t\t\thook_event_name: \"PostToolUse\",\n\t\t\t\t\t\ttool_name: event.toolName,\n\t\t\t\t\t\ttool_input: event.input,\n\t\t\t\t\t\ttool_response: event.content,\n\t\t\t\t\t},\n\t\t\t\t\troot,\n\t\t\t\t\tvars,\n\t\t\t\t);\n\t\t\t\tif (res.exitCode === 2 || res.json?.decision === \"block\") {\n\t\t\t\t\tconst reason = res.json?.reason || res.stderr.trim() || \"Flagged by plugin hook\";\n\t\t\t\t\treturn {\n\t\t\t\t\t\tcontent: [...event.content, { type: \"text\" as const, text: `\\n[plugin hook] ${reason}` }],\n\t\t\t\t\t\tisError: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tif (res.exitCode !== 0) onError(`PostToolUse hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t}\n\t\t});\n\t}\n\n\t// ── UserPromptSubmit → before_agent_start (adds context) ─────────────────\n\tconst promptGroups = hooks.UserPromptSubmit;\n\tif (promptGroups?.length) {\n\t\tpi.on(\"before_agent_start\", async (event) => {\n\t\t\tlet systemPrompt = event.systemPrompt;\n\t\t\tfor (const cmd of allCommands(promptGroups)) {\n\t\t\t\tconst res = await runHookCommand(\n\t\t\t\t\tcmd,\n\t\t\t\t\t{ hook_event_name: \"UserPromptSubmit\", prompt: event.prompt },\n\t\t\t\t\troot,\n\t\t\t\t\tvars,\n\t\t\t\t);\n\t\t\t\tif (res.exitCode !== 0 && res.exitCode !== 2) {\n\t\t\t\t\tonError(`UserPromptSubmit hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tconst extra = res.exitCode === 2 ? res.stderr.trim() : res.json?.reason || res.stdout.trim();\n\t\t\t\tif (extra) systemPrompt = `${systemPrompt}\\n\\n<!-- plugin hook -->\\n${extra}`;\n\t\t\t}\n\t\t\treturn systemPrompt === event.systemPrompt ? undefined : { systemPrompt };\n\t\t});\n\t}\n\n\t// ── SessionStart → session_start (side effects) ──────────────────────────\n\tconst sessionGroups = hooks.SessionStart;\n\tif (sessionGroups?.length) {\n\t\tpi.on(\"session_start\", async (event) => {\n\t\t\tfor (const cmd of allCommands(sessionGroups)) {\n\t\t\t\tconst res = await runHookCommand(\n\t\t\t\t\tcmd,\n\t\t\t\t\t{ hook_event_name: \"SessionStart\", source: event.reason },\n\t\t\t\t\troot,\n\t\t\t\t\tvars,\n\t\t\t\t);\n\t\t\t\tif (res.exitCode !== 0) onError(`SessionStart hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t}\n\t\t});\n\t}\n\n\t// ── Stop → agent_end (side effects) ──────────────────────────────────────\n\tconst stopGroups = hooks.Stop;\n\tif (stopGroups?.length) {\n\t\tpi.on(\"agent_end\", async () => {\n\t\t\tfor (const cmd of allCommands(stopGroups)) {\n\t\t\t\tconst res = await runHookCommand(cmd, { hook_event_name: \"Stop\" }, root, vars);\n\t\t\t\tif (res.exitCode !== 0) onError(`Stop hook failed (${res.exitCode}): ${res.stderr.trim()}`);\n\t\t\t}\n\t\t});\n\t}\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lifeguard.d.ts","sourceRoot":"","sources":["../../src/core/lifeguard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAqC3C,MAAM,WAAW,gBAAgB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,YAAY,CAAC;CACtB;AAED;;;;GAIG;AACH,qBAAa,iBAAkB,SAAQ,YAAY;IAClD,OAAO,CAAC,SAAS,CAAuC;IACxD,OAAO,CAAC,aAAa,CAA6B;IAClD,OAAO,CAAC,QAAQ,CAAqC;IACrD,4EAA4E;IAC5E,OAAO,CAAC,SAAS,CAA6B;IAC9C,gFAAgF;IAChF,OAAO,CAAC,aAAa,CAA6B;IAClD,OAAO,CAAC,aAAa,CAA+B;IACpD,+EAA+E;IAC/E,OAAO,CAAC,WAAW,CAAc;IACjC;;;;;OAKG;IACH,OAAO,CAAC,YAAY,CAAK;IACzB;;;;OAIG;IACH,OAAO,CAAC,OAAO,CAAqB;IACpC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,qBAAqB,CAAC,CAAa;IAE3C,YAAY,GAAG,EAAE,MAAM,EAOtB;IAED;;;;;OAKG;IACH,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAEnC;IAED;;;;;OAKG;IACH,OAAO,CAAC,cAAc;IAMtB;;;OAGG;IACH,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAwBrE;IAED,+CAA+C;IAC/C,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAIrC;IAED,0DAA0D;IAC1D,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE9C;IAED,qDAAqD;IACrD,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAErC;IAED,iDAAiD;IACjD,OAAO,IAAI,IAAI,CA4Bd;IAED,OAAO,CAAC,eAAe;IAqBvB,OAAO,CAAC,aAAa;IAsBrB,OAAO,CAAC,aAAa;IAiCrB;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ;IAQhB,OAAO,CAAC,OAAO;IAaf,OAAO,CAAC,uBAAuB;IAQ/B,OAAO,CAAC,gBAAgB;IAuBxB,OAAO,CAAC,cAAc;IAyBtB,OAAO,CAAC,aAAa;IAcrB,OAAO,CAAC,IAAI;CAoBZ","sourcesContent":["import type { ChildProcess } from \"node:child_process\";\nimport { EventEmitter } from \"node:events\";\nimport { existsSync, readdirSync, readFileSync, rmdirSync, statSync, unlinkSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { getDispatchRoot } from \"../config.js\";\nimport { killProcessTree } from \"../utils/shell.js\";\n\nconst TIMEOUTS_MS: Record<string, number> = {\n\texplore: 5 * 60 * 1000,\n\tedit: 10 * 60 * 1000,\n\ttest: 10 * 60 * 1000,\n\treview: 8 * 60 * 1000,\n\tdoc: 5 * 60 * 1000,\n};\n\nconst HEARTBEAT_MISS_THRESHOLD_MS = 60000;\nconst HEARTBEAT_CHECK_INTERVAL_MS = 5000;\nconst PARENT_SHUTDOWN_GRACE_MS = 5000;\n\n/**\n * Concurrency tolerance. Each *additional* concurrently-monitored subagent adds\n * this fraction to the heartbeat-miss and hard-timeout budgets.\n *\n * When several subagents run at once (plus background MCP tools), they saturate\n * the CPU and starve the parent's event loop: its `setInterval` heartbeat check\n * fires late, and it cannot read a child's `{\"ping\":true}` line in time even\n * though the child is healthy and still working. Scaling the budgets by load\n * stops that contention from false-positive SIGKILLing healthy subagents — the\n * failure the demo hit when running many agents + MCP tools in the background.\n */\nconst LOAD_TOLERANCE_PER_PROCESS = 0.5;\n\n/**\n * Hard ceiling on the load tolerance multiplier, so a genuinely stuck subagent\n * is still eventually reaped no matter how busy the pool is.\n */\nconst MAX_LOAD_MULTIPLIER = 4;\n\nexport interface LifeguardProcess {\n\tpid: number;\n\ttask_id: string;\n\tagent_type: string;\n\tprocess: ChildProcess;\n}\n\n/**\n * Monitors running subagent processes for heartbeats, hard timeouts,\n * and parent-exit cleanup. Emits \"stalled\" and \"timeout\" events when\n * processes are terminated.\n */\nexport class SubagentLifeguard extends EventEmitter {\n\tprivate processes = new Map<string, LifeguardProcess>();\n\tprivate lastHeartbeat = new Map<string, number>();\n\tprivate timeouts = new Map<string, NodeJS.Timeout>();\n\t/** When each task started, used to compute the load-scaled hard timeout. */\n\tprivate startedAt = new Map<string, number>();\n\t/** Per-agent base hard timeout (before load scaling), captured at monitor(). */\n\tprivate baseTimeoutMs = new Map<string, number>();\n\tprivate checkInterval: NodeJS.Timeout | null = null;\n\t/** Wall-clock time the heartbeat check last ran, to measure event-loop lag. */\n\tprivate lastCheckAt = Date.now();\n\t/**\n\t * Count of external in-process tasks (e.g. background MCP tools) running in the\n\t * parent alongside the monitored subagents. These don't show up in `processes`\n\t * but still saturate the parent's CPU/event loop, so they're folded into the\n\t * load multiplier. Updated by the pool via setExternalLoad().\n\t */\n\tprivate externalLoad = 0;\n\t/**\n\t * Tasks already reaped (stalled/timeout kill sent) but whose `exit` has not\n\t * fired yet. The heartbeat check runs every 5s, so without this a stalled task\n\t * would re-emit \"stalled\" (and re-kill) on every tick until the process exits.\n\t */\n\tprivate reaping = new Set<string>();\n\tprivate disposed = false;\n\tprivate readonly cwd: string;\n\tprivate parentShutdownHandler?: () => void;\n\n\tconstructor(cwd: string) {\n\t\tsuper();\n\t\tthis.cwd = cwd;\n\t\tthis.setupParentExitHandlers();\n\t\tthis.sweepOldAgents();\n\t\tthis.lastCheckAt = Date.now();\n\t\tthis.checkInterval = setInterval(() => this.checkHeartbeats(), HEARTBEAT_CHECK_INTERVAL_MS);\n\t}\n\n\t/**\n\t * Set the count of external in-process tasks (background MCP tools) sharing the\n\t * parent's CPU/event loop. Folded into loadMultiplier() so concurrent MCP work\n\t * widens the heartbeat/timeout budgets just like extra monitored subagents do.\n\t * Negative values are clamped to 0.\n\t */\n\tsetExternalLoad(count: number): void {\n\t\tthis.externalLoad = Math.max(0, Math.floor(count));\n\t}\n\n\t/**\n\t * Tolerance multiplier for the current load. 1 concurrent task → 1x; each extra\n\t * concurrent task adds LOAD_TOLERANCE_PER_PROCESS, capped at MAX_LOAD_MULTIPLIER.\n\t * Concurrency counts both monitored subagents and external load (background MCP\n\t * tools), since both contend for the same parent event loop.\n\t */\n\tprivate loadMultiplier(): number {\n\t\tconst concurrent = this.processes.size + this.externalLoad;\n\t\tconst mult = 1 + Math.max(0, concurrent - 1) * LOAD_TOLERANCE_PER_PROCESS;\n\t\treturn Math.min(mult, MAX_LOAD_MULTIPLIER);\n\t}\n\n\t/**\n\t * Begin monitoring a child process. The process must emit a\n\t * `{\"ping\":true}` JSON line on stdout every 30 seconds.\n\t */\n\tmonitor(task_id: string, agent_type: string, proc: ChildProcess): void {\n\t\tif (this.disposed) return;\n\n\t\tconst pid = proc.pid ?? 0;\n\t\tthis.processes.set(task_id, { pid, task_id, agent_type, process: proc });\n\t\tthis.lastHeartbeat.set(task_id, Date.now());\n\n\t\tconst timeoutMs = TIMEOUTS_MS[agent_type] ?? TIMEOUTS_MS.explore;\n\t\tthis.startedAt.set(task_id, Date.now());\n\t\tthis.baseTimeoutMs.set(task_id, timeoutMs);\n\t\t// Arm the hard timeout scaled by current load. When it fires, handleTimeout\n\t\t// re-checks load and re-arms (up to MAX_LOAD_MULTIPLIER) if the pool is still\n\t\t// busy, so a slow-but-progressing subagent isn't killed for CPU contention.\n\t\tconst timeout = setTimeout(\n\t\t\t() => {\n\t\t\t\tthis.handleTimeout(task_id);\n\t\t\t},\n\t\t\tMath.round(timeoutMs * this.loadMultiplier()),\n\t\t);\n\t\tthis.timeouts.set(task_id, timeout);\n\n\t\tproc.once(\"exit\", () => {\n\t\t\tthis.untrack(task_id);\n\t\t});\n\t}\n\n\t/** Record a heartbeat for a monitored task. */\n\trecordHeartbeat(task_id: string): void {\n\t\tif (this.processes.has(task_id)) {\n\t\t\tthis.lastHeartbeat.set(task_id, Date.now());\n\t\t}\n\t}\n\n\t/** Get the last recorded heartbeat timestamp, or null. */\n\tlastHeartbeatAt(task_id: string): number | null {\n\t\treturn this.lastHeartbeat.get(task_id) ?? null;\n\t}\n\n\t/** True if the task is currently being monitored. */\n\tisMonitoring(task_id: string): boolean {\n\t\treturn this.processes.has(task_id);\n\t}\n\n\t/** Kill all monitored processes and clean up. */\n\tdispose(): void {\n\t\tif (this.disposed) return;\n\t\tthis.disposed = true;\n\n\t\tif (this.checkInterval) {\n\t\t\tclearInterval(this.checkInterval);\n\t\t\tthis.checkInterval = null;\n\t\t}\n\n\t\tfor (const timeout of this.timeouts.values()) {\n\t\t\tclearTimeout(timeout);\n\t\t}\n\t\tthis.timeouts.clear();\n\n\t\tfor (const monitored of this.processes.values()) {\n\t\t\tthis.killTree(monitored);\n\t\t}\n\t\tthis.processes.clear();\n\t\tthis.lastHeartbeat.clear();\n\t\tthis.startedAt.clear();\n\t\tthis.baseTimeoutMs.clear();\n\t\tthis.reaping.clear();\n\t\tthis.removeAllListeners();\n\n\t\tif (this.parentShutdownHandler) {\n\t\t\tprocess.removeListener(\"SIGINT\", this.parentShutdownHandler);\n\t\t\tprocess.removeListener(\"SIGTERM\", this.parentShutdownHandler);\n\t\t}\n\t}\n\n\tprivate checkHeartbeats(): void {\n\t\tconst now = Date.now();\n\t\t// Event-loop lag: how much later than scheduled this check actually ran.\n\t\t// Heavy CPU load (many concurrent subagents + background MCP tools) starves\n\t\t// the loop, delaying both this check *and* our reading of child heartbeats.\n\t\t// Forgive that gap so the parent's own starvation isn't charged against the\n\t\t// children as a missed heartbeat.\n\t\tconst loopLag = Math.max(0, now - this.lastCheckAt - HEARTBEAT_CHECK_INTERVAL_MS);\n\t\tthis.lastCheckAt = now;\n\n\t\tconst threshold = HEARTBEAT_MISS_THRESHOLD_MS * this.loadMultiplier() + loopLag;\n\t\tfor (const [task_id] of this.processes) {\n\t\t\tif (this.reaping.has(task_id)) continue;\n\t\t\tconst last = this.lastHeartbeat.get(task_id);\n\t\t\tif (last === undefined) continue;\n\t\t\tif (now - last > threshold) {\n\t\t\t\tthis.handleStalled(task_id);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate handleStalled(task_id: string): void {\n\t\tconst monitored = this.processes.get(task_id);\n\t\tif (!monitored || this.reaping.has(task_id)) return;\n\t\tthis.reaping.add(task_id);\n\n\t\t// Record why we reaped this child so a recurrence is diagnosable rather than\n\t\t// just \"stalled\": how long since the last heartbeat, and the load factors\n\t\t// (concurrent monitored subagents, threshold) that fed the decision.\n\t\tconst last = this.lastHeartbeat.get(task_id);\n\t\tconst silentMs = last === undefined ? -1 : Date.now() - last;\n\t\tconsole.error(\n\t\t\t`[LIFEGUARD] stalled task_id=${task_id} agent=${monitored.agent_type} ` +\n\t\t\t\t`silent_ms=${silentMs} concurrent=${this.processes.size} ` +\n\t\t\t\t`load_mult=${this.loadMultiplier().toFixed(2)} base_threshold_ms=${HEARTBEAT_MISS_THRESHOLD_MS}`,\n\t\t);\n\n\t\tthis.killTree(monitored);\n\n\t\tthis.emit(\"stalled\", { task_id, pid: monitored.pid });\n\t\t// Process exit handler will call untrack()\n\t}\n\n\tprivate handleTimeout(task_id: string): void {\n\t\tconst monitored = this.processes.get(task_id);\n\t\tif (!monitored || this.reaping.has(task_id)) return;\n\n\t\t// Under load the wall-clock timer can fire while the subagent is still doing\n\t\t// real work — its turns are just slow because the CPU is shared. Re-arm rather\n\t\t// than kill, up to a hard ceiling (base * MAX_LOAD_MULTIPLIER) so a genuinely\n\t\t// stuck agent still terminates.\n\t\tconst started = this.startedAt.get(task_id) ?? Date.now();\n\t\tconst base = this.baseTimeoutMs.get(task_id) ?? TIMEOUTS_MS[monitored.agent_type] ?? TIMEOUTS_MS.explore;\n\t\tconst elapsed = Date.now() - started;\n\t\tconst ceiling = base * MAX_LOAD_MULTIPLIER;\n\t\tif (this.loadMultiplier() > 1 && elapsed < ceiling) {\n\t\t\tconst remaining = ceiling - elapsed;\n\t\t\tconst next = Math.min(\n\t\t\t\tMath.round(base * this.loadMultiplier()),\n\t\t\t\tMath.max(HEARTBEAT_CHECK_INTERVAL_MS, remaining),\n\t\t\t);\n\t\t\tthis.timeouts.set(\n\t\t\t\ttask_id,\n\t\t\t\tsetTimeout(() => this.handleTimeout(task_id), next),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.reaping.add(task_id);\n\t\tthis.killTree(monitored);\n\n\t\tthis.emit(\"timeout\", { task_id, pid: monitored.pid });\n\t\tthis.timeouts.delete(task_id);\n\t\t// Process exit handler will call untrack()\n\t}\n\n\t/**\n\t * Kill a monitored subagent and everything it spawned. The pool spawns\n\t * children detached on POSIX (each leads its own process group), so the\n\t * group/tree kill reaches nested grandchildren (the subagent's bash commands,\n\t * its own subagents) that a single-PID SIGKILL would orphan. A pid of 0 means\n\t * the spawn never produced a process — nothing to kill (and `kill(-0)` would\n\t * signal our own process group).\n\t */\n\tprivate killTree(monitored: LifeguardProcess): void {\n\t\tif (monitored.pid > 0) {\n\t\t\tkillProcessTree(monitored.pid);\n\t\t} else if (!monitored.process.killed) {\n\t\t\tmonitored.process.kill(\"SIGKILL\");\n\t\t}\n\t}\n\n\tprivate untrack(task_id: string): void {\n\t\tconst timeout = this.timeouts.get(task_id);\n\t\tif (timeout) {\n\t\t\tclearTimeout(timeout);\n\t\t\tthis.timeouts.delete(task_id);\n\t\t}\n\t\tthis.processes.delete(task_id);\n\t\tthis.lastHeartbeat.delete(task_id);\n\t\tthis.startedAt.delete(task_id);\n\t\tthis.baseTimeoutMs.delete(task_id);\n\t\tthis.reaping.delete(task_id);\n\t}\n\n\tprivate setupParentExitHandlers(): void {\n\t\tconst shutdown = () => this.gracefulShutdown();\n\t\tthis.parentShutdownHandler = shutdown;\n\t\tprocess.setMaxListeners(Math.max(process.getMaxListeners(), 20));\n\t\tprocess.once(\"SIGINT\", shutdown);\n\t\tprocess.once(\"SIGTERM\", shutdown);\n\t}\n\n\tprivate gracefulShutdown(): void {\n\t\t// SIGTERM each child's process group (POSIX; the pool spawns them detached\n\t\t// as group leaders) so their own descendants get the graceful signal too.\n\t\tfor (const monitored of this.processes.values()) {\n\t\t\ttry {\n\t\t\t\tif (process.platform !== \"win32\" && monitored.pid > 0) {\n\t\t\t\t\tprocess.kill(-monitored.pid, \"SIGTERM\");\n\t\t\t\t} else if (!monitored.process.killed) {\n\t\t\t\t\tmonitored.process.kill(\"SIGTERM\");\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Already gone.\n\t\t\t}\n\t\t}\n\n\t\t// Kill whole trees after the grace period\n\t\tsetTimeout(() => {\n\t\t\tfor (const monitored of this.processes.values()) {\n\t\t\t\tthis.killTree(monitored);\n\t\t\t}\n\t\t}, PARENT_SHUTDOWN_GRACE_MS).unref();\n\t}\n\n\tprivate sweepOldAgents(): void {\n\t\tconst dispatchDir = getDispatchRoot(this.cwd);\n\t\tif (!existsSync(dispatchDir)) return;\n\n\t\tconst now = Date.now();\n\t\tconst cutoff = 24 * 60 * 60 * 1000; // 24 hours\n\n\t\tfor (const entry of readdirSync(dispatchDir)) {\n\t\t\tconst entryPath = join(dispatchDir, entry);\n\t\t\ttry {\n\t\t\t\tconst stats = statSync(entryPath);\n\t\t\t\tif (!stats.isDirectory()) continue;\n\n\t\t\t\tif (now - stats.mtimeMs > cutoff) {\n\t\t\t\t\tconst hasRunningPid = this.hasRunningPid(entryPath);\n\t\t\t\t\tif (!hasRunningPid) {\n\t\t\t\t\t\tthis.rmrf(entryPath);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Ignore errors for individual entries\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate hasRunningPid(dir: string): boolean {\n\t\tconst pidFile = join(dir, \"pid\");\n\t\tif (!existsSync(pidFile)) return false;\n\n\t\ttry {\n\t\t\tconst pid = Number.parseInt(readFileSync(pidFile, \"utf-8\"), 10);\n\t\t\tif (Number.isNaN(pid)) return false;\n\t\t\tprocess.kill(pid, 0); // Check if process exists\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate rmrf(dir: string): void {\n\t\tfor (const entry of readdirSync(dir)) {\n\t\t\tconst entryPath = join(dir, entry);\n\t\t\ttry {\n\t\t\t\tconst stats = statSync(entryPath);\n\t\t\t\tif (stats.isDirectory()) {\n\t\t\t\t\tthis.rmrf(entryPath);\n\t\t\t\t} else {\n\t\t\t\t\tunlinkSync(entryPath);\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Ignore\n\t\t\t}\n\t\t}\n\t\ttry {\n\t\t\trmdirSync(dir);\n\t\t} catch {\n\t\t\t// Ignore\n\t\t}\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"lifeguard.d.ts","sourceRoot":"","sources":["../../src/core/lifeguard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAsC3C,MAAM,WAAW,gBAAgB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,YAAY,CAAC;CACtB;AAED;;;;GAIG;AACH,qBAAa,iBAAkB,SAAQ,YAAY;IAClD,OAAO,CAAC,SAAS,CAAuC;IACxD,OAAO,CAAC,aAAa,CAA6B;IAClD,OAAO,CAAC,QAAQ,CAAqC;IACrD,4EAA4E;IAC5E,OAAO,CAAC,SAAS,CAA6B;IAC9C,gFAAgF;IAChF,OAAO,CAAC,aAAa,CAA6B;IAClD,OAAO,CAAC,aAAa,CAA+B;IACpD,+EAA+E;IAC/E,OAAO,CAAC,WAAW,CAAc;IACjC;;;;;OAKG;IACH,OAAO,CAAC,YAAY,CAAK;IACzB;;;;OAIG;IACH,OAAO,CAAC,OAAO,CAAqB;IACpC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,qBAAqB,CAAC,CAAa;IAE3C,YAAY,GAAG,EAAE,MAAM,EAOtB;IAED;;;;;OAKG;IACH,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAEnC;IAED;;;;;OAKG;IACH,OAAO,CAAC,cAAc;IAMtB;;;OAGG;IACH,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAwBrE;IAED,+CAA+C;IAC/C,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAIrC;IAED,0DAA0D;IAC1D,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE9C;IAED,qDAAqD;IACrD,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAErC;IAED,iDAAiD;IACjD,OAAO,IAAI,IAAI,CA4Bd;IAED,OAAO,CAAC,eAAe;IAqBvB,OAAO,CAAC,aAAa;IAsBrB,OAAO,CAAC,aAAa;IAiCrB;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ;IAQhB,OAAO,CAAC,OAAO;IAaf,OAAO,CAAC,uBAAuB;IAQ/B,OAAO,CAAC,gBAAgB;IAuBxB,OAAO,CAAC,cAAc;IAyBtB,OAAO,CAAC,aAAa;IAcrB,OAAO,CAAC,IAAI;CAoBZ","sourcesContent":["import type { ChildProcess } from \"node:child_process\";\nimport { EventEmitter } from \"node:events\";\nimport { existsSync, readdirSync, readFileSync, rmdirSync, statSync, unlinkSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { getDispatchRoot } from \"../config.js\";\nimport { killProcessTree } from \"../utils/shell.js\";\nimport { agentLog } from \"./agent-log.js\";\n\nconst TIMEOUTS_MS: Record<string, number> = {\n\texplore: 5 * 60 * 1000,\n\tedit: 10 * 60 * 1000,\n\ttest: 10 * 60 * 1000,\n\treview: 8 * 60 * 1000,\n\tdoc: 5 * 60 * 1000,\n};\n\nconst HEARTBEAT_MISS_THRESHOLD_MS = 60000;\nconst HEARTBEAT_CHECK_INTERVAL_MS = 5000;\nconst PARENT_SHUTDOWN_GRACE_MS = 5000;\n\n/**\n * Concurrency tolerance. Each *additional* concurrently-monitored subagent adds\n * this fraction to the heartbeat-miss and hard-timeout budgets.\n *\n * When several subagents run at once (plus background MCP tools), they saturate\n * the CPU and starve the parent's event loop: its `setInterval` heartbeat check\n * fires late, and it cannot read a child's `{\"ping\":true}` line in time even\n * though the child is healthy and still working. Scaling the budgets by load\n * stops that contention from false-positive SIGKILLing healthy subagents — the\n * failure the demo hit when running many agents + MCP tools in the background.\n */\nconst LOAD_TOLERANCE_PER_PROCESS = 0.5;\n\n/**\n * Hard ceiling on the load tolerance multiplier, so a genuinely stuck subagent\n * is still eventually reaped no matter how busy the pool is.\n */\nconst MAX_LOAD_MULTIPLIER = 4;\n\nexport interface LifeguardProcess {\n\tpid: number;\n\ttask_id: string;\n\tagent_type: string;\n\tprocess: ChildProcess;\n}\n\n/**\n * Monitors running subagent processes for heartbeats, hard timeouts,\n * and parent-exit cleanup. Emits \"stalled\" and \"timeout\" events when\n * processes are terminated.\n */\nexport class SubagentLifeguard extends EventEmitter {\n\tprivate processes = new Map<string, LifeguardProcess>();\n\tprivate lastHeartbeat = new Map<string, number>();\n\tprivate timeouts = new Map<string, NodeJS.Timeout>();\n\t/** When each task started, used to compute the load-scaled hard timeout. */\n\tprivate startedAt = new Map<string, number>();\n\t/** Per-agent base hard timeout (before load scaling), captured at monitor(). */\n\tprivate baseTimeoutMs = new Map<string, number>();\n\tprivate checkInterval: NodeJS.Timeout | null = null;\n\t/** Wall-clock time the heartbeat check last ran, to measure event-loop lag. */\n\tprivate lastCheckAt = Date.now();\n\t/**\n\t * Count of external in-process tasks (e.g. background MCP tools) running in the\n\t * parent alongside the monitored subagents. These don't show up in `processes`\n\t * but still saturate the parent's CPU/event loop, so they're folded into the\n\t * load multiplier. Updated by the pool via setExternalLoad().\n\t */\n\tprivate externalLoad = 0;\n\t/**\n\t * Tasks already reaped (stalled/timeout kill sent) but whose `exit` has not\n\t * fired yet. The heartbeat check runs every 5s, so without this a stalled task\n\t * would re-emit \"stalled\" (and re-kill) on every tick until the process exits.\n\t */\n\tprivate reaping = new Set<string>();\n\tprivate disposed = false;\n\tprivate readonly cwd: string;\n\tprivate parentShutdownHandler?: () => void;\n\n\tconstructor(cwd: string) {\n\t\tsuper();\n\t\tthis.cwd = cwd;\n\t\tthis.setupParentExitHandlers();\n\t\tthis.sweepOldAgents();\n\t\tthis.lastCheckAt = Date.now();\n\t\tthis.checkInterval = setInterval(() => this.checkHeartbeats(), HEARTBEAT_CHECK_INTERVAL_MS);\n\t}\n\n\t/**\n\t * Set the count of external in-process tasks (background MCP tools) sharing the\n\t * parent's CPU/event loop. Folded into loadMultiplier() so concurrent MCP work\n\t * widens the heartbeat/timeout budgets just like extra monitored subagents do.\n\t * Negative values are clamped to 0.\n\t */\n\tsetExternalLoad(count: number): void {\n\t\tthis.externalLoad = Math.max(0, Math.floor(count));\n\t}\n\n\t/**\n\t * Tolerance multiplier for the current load. 1 concurrent task → 1x; each extra\n\t * concurrent task adds LOAD_TOLERANCE_PER_PROCESS, capped at MAX_LOAD_MULTIPLIER.\n\t * Concurrency counts both monitored subagents and external load (background MCP\n\t * tools), since both contend for the same parent event loop.\n\t */\n\tprivate loadMultiplier(): number {\n\t\tconst concurrent = this.processes.size + this.externalLoad;\n\t\tconst mult = 1 + Math.max(0, concurrent - 1) * LOAD_TOLERANCE_PER_PROCESS;\n\t\treturn Math.min(mult, MAX_LOAD_MULTIPLIER);\n\t}\n\n\t/**\n\t * Begin monitoring a child process. The process must emit a\n\t * `{\"ping\":true}` JSON line on stdout every 30 seconds.\n\t */\n\tmonitor(task_id: string, agent_type: string, proc: ChildProcess): void {\n\t\tif (this.disposed) return;\n\n\t\tconst pid = proc.pid ?? 0;\n\t\tthis.processes.set(task_id, { pid, task_id, agent_type, process: proc });\n\t\tthis.lastHeartbeat.set(task_id, Date.now());\n\n\t\tconst timeoutMs = TIMEOUTS_MS[agent_type] ?? TIMEOUTS_MS.explore;\n\t\tthis.startedAt.set(task_id, Date.now());\n\t\tthis.baseTimeoutMs.set(task_id, timeoutMs);\n\t\t// Arm the hard timeout scaled by current load. When it fires, handleTimeout\n\t\t// re-checks load and re-arms (up to MAX_LOAD_MULTIPLIER) if the pool is still\n\t\t// busy, so a slow-but-progressing subagent isn't killed for CPU contention.\n\t\tconst timeout = setTimeout(\n\t\t\t() => {\n\t\t\t\tthis.handleTimeout(task_id);\n\t\t\t},\n\t\t\tMath.round(timeoutMs * this.loadMultiplier()),\n\t\t);\n\t\tthis.timeouts.set(task_id, timeout);\n\n\t\tproc.once(\"exit\", () => {\n\t\t\tthis.untrack(task_id);\n\t\t});\n\t}\n\n\t/** Record a heartbeat for a monitored task. */\n\trecordHeartbeat(task_id: string): void {\n\t\tif (this.processes.has(task_id)) {\n\t\t\tthis.lastHeartbeat.set(task_id, Date.now());\n\t\t}\n\t}\n\n\t/** Get the last recorded heartbeat timestamp, or null. */\n\tlastHeartbeatAt(task_id: string): number | null {\n\t\treturn this.lastHeartbeat.get(task_id) ?? null;\n\t}\n\n\t/** True if the task is currently being monitored. */\n\tisMonitoring(task_id: string): boolean {\n\t\treturn this.processes.has(task_id);\n\t}\n\n\t/** Kill all monitored processes and clean up. */\n\tdispose(): void {\n\t\tif (this.disposed) return;\n\t\tthis.disposed = true;\n\n\t\tif (this.checkInterval) {\n\t\t\tclearInterval(this.checkInterval);\n\t\t\tthis.checkInterval = null;\n\t\t}\n\n\t\tfor (const timeout of this.timeouts.values()) {\n\t\t\tclearTimeout(timeout);\n\t\t}\n\t\tthis.timeouts.clear();\n\n\t\tfor (const monitored of this.processes.values()) {\n\t\t\tthis.killTree(monitored);\n\t\t}\n\t\tthis.processes.clear();\n\t\tthis.lastHeartbeat.clear();\n\t\tthis.startedAt.clear();\n\t\tthis.baseTimeoutMs.clear();\n\t\tthis.reaping.clear();\n\t\tthis.removeAllListeners();\n\n\t\tif (this.parentShutdownHandler) {\n\t\t\tprocess.removeListener(\"SIGINT\", this.parentShutdownHandler);\n\t\t\tprocess.removeListener(\"SIGTERM\", this.parentShutdownHandler);\n\t\t}\n\t}\n\n\tprivate checkHeartbeats(): void {\n\t\tconst now = Date.now();\n\t\t// Event-loop lag: how much later than scheduled this check actually ran.\n\t\t// Heavy CPU load (many concurrent subagents + background MCP tools) starves\n\t\t// the loop, delaying both this check *and* our reading of child heartbeats.\n\t\t// Forgive that gap so the parent's own starvation isn't charged against the\n\t\t// children as a missed heartbeat.\n\t\tconst loopLag = Math.max(0, now - this.lastCheckAt - HEARTBEAT_CHECK_INTERVAL_MS);\n\t\tthis.lastCheckAt = now;\n\n\t\tconst threshold = HEARTBEAT_MISS_THRESHOLD_MS * this.loadMultiplier() + loopLag;\n\t\tfor (const [task_id] of this.processes) {\n\t\t\tif (this.reaping.has(task_id)) continue;\n\t\t\tconst last = this.lastHeartbeat.get(task_id);\n\t\t\tif (last === undefined) continue;\n\t\t\tif (now - last > threshold) {\n\t\t\t\tthis.handleStalled(task_id);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate handleStalled(task_id: string): void {\n\t\tconst monitored = this.processes.get(task_id);\n\t\tif (!monitored || this.reaping.has(task_id)) return;\n\t\tthis.reaping.add(task_id);\n\n\t\t// Record why we reaped this child so a recurrence is diagnosable rather than\n\t\t// just \"stalled\": how long since the last heartbeat, and the load factors\n\t\t// (concurrent monitored subagents, threshold) that fed the decision.\n\t\tconst last = this.lastHeartbeat.get(task_id);\n\t\tconst silentMs = last === undefined ? -1 : Date.now() - last;\n\t\tagentLog(\n\t\t\t`[LIFEGUARD] stalled task_id=${task_id} agent=${monitored.agent_type} ` +\n\t\t\t\t`silent_ms=${silentMs} concurrent=${this.processes.size} ` +\n\t\t\t\t`load_mult=${this.loadMultiplier().toFixed(2)} base_threshold_ms=${HEARTBEAT_MISS_THRESHOLD_MS}`,\n\t\t);\n\n\t\tthis.killTree(monitored);\n\n\t\tthis.emit(\"stalled\", { task_id, pid: monitored.pid });\n\t\t// Process exit handler will call untrack()\n\t}\n\n\tprivate handleTimeout(task_id: string): void {\n\t\tconst monitored = this.processes.get(task_id);\n\t\tif (!monitored || this.reaping.has(task_id)) return;\n\n\t\t// Under load the wall-clock timer can fire while the subagent is still doing\n\t\t// real work — its turns are just slow because the CPU is shared. Re-arm rather\n\t\t// than kill, up to a hard ceiling (base * MAX_LOAD_MULTIPLIER) so a genuinely\n\t\t// stuck agent still terminates.\n\t\tconst started = this.startedAt.get(task_id) ?? Date.now();\n\t\tconst base = this.baseTimeoutMs.get(task_id) ?? TIMEOUTS_MS[monitored.agent_type] ?? TIMEOUTS_MS.explore;\n\t\tconst elapsed = Date.now() - started;\n\t\tconst ceiling = base * MAX_LOAD_MULTIPLIER;\n\t\tif (this.loadMultiplier() > 1 && elapsed < ceiling) {\n\t\t\tconst remaining = ceiling - elapsed;\n\t\t\tconst next = Math.min(\n\t\t\t\tMath.round(base * this.loadMultiplier()),\n\t\t\t\tMath.max(HEARTBEAT_CHECK_INTERVAL_MS, remaining),\n\t\t\t);\n\t\t\tthis.timeouts.set(\n\t\t\t\ttask_id,\n\t\t\t\tsetTimeout(() => this.handleTimeout(task_id), next),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.reaping.add(task_id);\n\t\tthis.killTree(monitored);\n\n\t\tthis.emit(\"timeout\", { task_id, pid: monitored.pid });\n\t\tthis.timeouts.delete(task_id);\n\t\t// Process exit handler will call untrack()\n\t}\n\n\t/**\n\t * Kill a monitored subagent and everything it spawned. The pool spawns\n\t * children detached on POSIX (each leads its own process group), so the\n\t * group/tree kill reaches nested grandchildren (the subagent's bash commands,\n\t * its own subagents) that a single-PID SIGKILL would orphan. A pid of 0 means\n\t * the spawn never produced a process — nothing to kill (and `kill(-0)` would\n\t * signal our own process group).\n\t */\n\tprivate killTree(monitored: LifeguardProcess): void {\n\t\tif (monitored.pid > 0) {\n\t\t\tkillProcessTree(monitored.pid);\n\t\t} else if (!monitored.process.killed) {\n\t\t\tmonitored.process.kill(\"SIGKILL\");\n\t\t}\n\t}\n\n\tprivate untrack(task_id: string): void {\n\t\tconst timeout = this.timeouts.get(task_id);\n\t\tif (timeout) {\n\t\t\tclearTimeout(timeout);\n\t\t\tthis.timeouts.delete(task_id);\n\t\t}\n\t\tthis.processes.delete(task_id);\n\t\tthis.lastHeartbeat.delete(task_id);\n\t\tthis.startedAt.delete(task_id);\n\t\tthis.baseTimeoutMs.delete(task_id);\n\t\tthis.reaping.delete(task_id);\n\t}\n\n\tprivate setupParentExitHandlers(): void {\n\t\tconst shutdown = () => this.gracefulShutdown();\n\t\tthis.parentShutdownHandler = shutdown;\n\t\tprocess.setMaxListeners(Math.max(process.getMaxListeners(), 20));\n\t\tprocess.once(\"SIGINT\", shutdown);\n\t\tprocess.once(\"SIGTERM\", shutdown);\n\t}\n\n\tprivate gracefulShutdown(): void {\n\t\t// SIGTERM each child's process group (POSIX; the pool spawns them detached\n\t\t// as group leaders) so their own descendants get the graceful signal too.\n\t\tfor (const monitored of this.processes.values()) {\n\t\t\ttry {\n\t\t\t\tif (process.platform !== \"win32\" && monitored.pid > 0) {\n\t\t\t\t\tprocess.kill(-monitored.pid, \"SIGTERM\");\n\t\t\t\t} else if (!monitored.process.killed) {\n\t\t\t\t\tmonitored.process.kill(\"SIGTERM\");\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Already gone.\n\t\t\t}\n\t\t}\n\n\t\t// Kill whole trees after the grace period\n\t\tsetTimeout(() => {\n\t\t\tfor (const monitored of this.processes.values()) {\n\t\t\t\tthis.killTree(monitored);\n\t\t\t}\n\t\t}, PARENT_SHUTDOWN_GRACE_MS).unref();\n\t}\n\n\tprivate sweepOldAgents(): void {\n\t\tconst dispatchDir = getDispatchRoot(this.cwd);\n\t\tif (!existsSync(dispatchDir)) return;\n\n\t\tconst now = Date.now();\n\t\tconst cutoff = 24 * 60 * 60 * 1000; // 24 hours\n\n\t\tfor (const entry of readdirSync(dispatchDir)) {\n\t\t\tconst entryPath = join(dispatchDir, entry);\n\t\t\ttry {\n\t\t\t\tconst stats = statSync(entryPath);\n\t\t\t\tif (!stats.isDirectory()) continue;\n\n\t\t\t\tif (now - stats.mtimeMs > cutoff) {\n\t\t\t\t\tconst hasRunningPid = this.hasRunningPid(entryPath);\n\t\t\t\t\tif (!hasRunningPid) {\n\t\t\t\t\t\tthis.rmrf(entryPath);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Ignore errors for individual entries\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate hasRunningPid(dir: string): boolean {\n\t\tconst pidFile = join(dir, \"pid\");\n\t\tif (!existsSync(pidFile)) return false;\n\n\t\ttry {\n\t\t\tconst pid = Number.parseInt(readFileSync(pidFile, \"utf-8\"), 10);\n\t\t\tif (Number.isNaN(pid)) return false;\n\t\t\tprocess.kill(pid, 0); // Check if process exists\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate rmrf(dir: string): void {\n\t\tfor (const entry of readdirSync(dir)) {\n\t\t\tconst entryPath = join(dir, entry);\n\t\t\ttry {\n\t\t\t\tconst stats = statSync(entryPath);\n\t\t\t\tif (stats.isDirectory()) {\n\t\t\t\t\tthis.rmrf(entryPath);\n\t\t\t\t} else {\n\t\t\t\t\tunlinkSync(entryPath);\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Ignore\n\t\t\t}\n\t\t}\n\t\ttry {\n\t\t\trmdirSync(dir);\n\t\t} catch {\n\t\t\t// Ignore\n\t\t}\n\t}\n}\n"]}
|
package/dist/core/lifeguard.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync, rmdirSync, statSync, unlinkSync
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { getDispatchRoot } from "../config.js";
|
|
5
5
|
import { killProcessTree } from "../utils/shell.js";
|
|
6
|
+
import { agentLog } from "./agent-log.js";
|
|
6
7
|
const TIMEOUTS_MS = {
|
|
7
8
|
explore: 5 * 60 * 1000,
|
|
8
9
|
edit: 10 * 60 * 1000,
|
|
@@ -186,7 +187,7 @@ export class SubagentLifeguard extends EventEmitter {
|
|
|
186
187
|
// (concurrent monitored subagents, threshold) that fed the decision.
|
|
187
188
|
const last = this.lastHeartbeat.get(task_id);
|
|
188
189
|
const silentMs = last === undefined ? -1 : Date.now() - last;
|
|
189
|
-
|
|
190
|
+
agentLog(`[LIFEGUARD] stalled task_id=${task_id} agent=${monitored.agent_type} ` +
|
|
190
191
|
`silent_ms=${silentMs} concurrent=${this.processes.size} ` +
|
|
191
192
|
`load_mult=${this.loadMultiplier().toFixed(2)} base_threshold_ms=${HEARTBEAT_MISS_THRESHOLD_MS}`);
|
|
192
193
|
this.killTree(monitored);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"lifeguard.js","sourceRoot":"","sources":["../../src/core/lifeguard.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ChildProcess } from \"node:child_process\";\nimport { EventEmitter } from \"node:events\";\nimport { existsSync, readdirSync, readFileSync, rmdirSync, statSync, unlinkSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { getDispatchRoot } from \"../config.js\";\nimport { killProcessTree } from \"../utils/shell.js\";\n\nconst TIMEOUTS_MS: Record<string, number> = {\n\texplore: 5 * 60 * 1000,\n\tedit: 10 * 60 * 1000,\n\ttest: 10 * 60 * 1000,\n\treview: 8 * 60 * 1000,\n\tdoc: 5 * 60 * 1000,\n};\n\nconst HEARTBEAT_MISS_THRESHOLD_MS = 60000;\nconst HEARTBEAT_CHECK_INTERVAL_MS = 5000;\nconst PARENT_SHUTDOWN_GRACE_MS = 5000;\n\n/**\n * Concurrency tolerance. Each *additional* concurrently-monitored subagent adds\n * this fraction to the heartbeat-miss and hard-timeout budgets.\n *\n * When several subagents run at once (plus background MCP tools), they saturate\n * the CPU and starve the parent's event loop: its `setInterval` heartbeat check\n * fires late, and it cannot read a child's `{\"ping\":true}` line in time even\n * though the child is healthy and still working. Scaling the budgets by load\n * stops that contention from false-positive SIGKILLing healthy subagents — the\n * failure the demo hit when running many agents + MCP tools in the background.\n */\nconst LOAD_TOLERANCE_PER_PROCESS = 0.5;\n\n/**\n * Hard ceiling on the load tolerance multiplier, so a genuinely stuck subagent\n * is still eventually reaped no matter how busy the pool is.\n */\nconst MAX_LOAD_MULTIPLIER = 4;\n\nexport interface LifeguardProcess {\n\tpid: number;\n\ttask_id: string;\n\tagent_type: string;\n\tprocess: ChildProcess;\n}\n\n/**\n * Monitors running subagent processes for heartbeats, hard timeouts,\n * and parent-exit cleanup. Emits \"stalled\" and \"timeout\" events when\n * processes are terminated.\n */\nexport class SubagentLifeguard extends EventEmitter {\n\tprivate processes = new Map<string, LifeguardProcess>();\n\tprivate lastHeartbeat = new Map<string, number>();\n\tprivate timeouts = new Map<string, NodeJS.Timeout>();\n\t/** When each task started, used to compute the load-scaled hard timeout. */\n\tprivate startedAt = new Map<string, number>();\n\t/** Per-agent base hard timeout (before load scaling), captured at monitor(). */\n\tprivate baseTimeoutMs = new Map<string, number>();\n\tprivate checkInterval: NodeJS.Timeout | null = null;\n\t/** Wall-clock time the heartbeat check last ran, to measure event-loop lag. */\n\tprivate lastCheckAt = Date.now();\n\t/**\n\t * Count of external in-process tasks (e.g. background MCP tools) running in the\n\t * parent alongside the monitored subagents. These don't show up in `processes`\n\t * but still saturate the parent's CPU/event loop, so they're folded into the\n\t * load multiplier. Updated by the pool via setExternalLoad().\n\t */\n\tprivate externalLoad = 0;\n\t/**\n\t * Tasks already reaped (stalled/timeout kill sent) but whose `exit` has not\n\t * fired yet. The heartbeat check runs every 5s, so without this a stalled task\n\t * would re-emit \"stalled\" (and re-kill) on every tick until the process exits.\n\t */\n\tprivate reaping = new Set<string>();\n\tprivate disposed = false;\n\tprivate readonly cwd: string;\n\tprivate parentShutdownHandler?: () => void;\n\n\tconstructor(cwd: string) {\n\t\tsuper();\n\t\tthis.cwd = cwd;\n\t\tthis.setupParentExitHandlers();\n\t\tthis.sweepOldAgents();\n\t\tthis.lastCheckAt = Date.now();\n\t\tthis.checkInterval = setInterval(() => this.checkHeartbeats(), HEARTBEAT_CHECK_INTERVAL_MS);\n\t}\n\n\t/**\n\t * Set the count of external in-process tasks (background MCP tools) sharing the\n\t * parent's CPU/event loop. Folded into loadMultiplier() so concurrent MCP work\n\t * widens the heartbeat/timeout budgets just like extra monitored subagents do.\n\t * Negative values are clamped to 0.\n\t */\n\tsetExternalLoad(count: number): void {\n\t\tthis.externalLoad = Math.max(0, Math.floor(count));\n\t}\n\n\t/**\n\t * Tolerance multiplier for the current load. 1 concurrent task → 1x; each extra\n\t * concurrent task adds LOAD_TOLERANCE_PER_PROCESS, capped at MAX_LOAD_MULTIPLIER.\n\t * Concurrency counts both monitored subagents and external load (background MCP\n\t * tools), since both contend for the same parent event loop.\n\t */\n\tprivate loadMultiplier(): number {\n\t\tconst concurrent = this.processes.size + this.externalLoad;\n\t\tconst mult = 1 + Math.max(0, concurrent - 1) * LOAD_TOLERANCE_PER_PROCESS;\n\t\treturn Math.min(mult, MAX_LOAD_MULTIPLIER);\n\t}\n\n\t/**\n\t * Begin monitoring a child process. The process must emit a\n\t * `{\"ping\":true}` JSON line on stdout every 30 seconds.\n\t */\n\tmonitor(task_id: string, agent_type: string, proc: ChildProcess): void {\n\t\tif (this.disposed) return;\n\n\t\tconst pid = proc.pid ?? 0;\n\t\tthis.processes.set(task_id, { pid, task_id, agent_type, process: proc });\n\t\tthis.lastHeartbeat.set(task_id, Date.now());\n\n\t\tconst timeoutMs = TIMEOUTS_MS[agent_type] ?? TIMEOUTS_MS.explore;\n\t\tthis.startedAt.set(task_id, Date.now());\n\t\tthis.baseTimeoutMs.set(task_id, timeoutMs);\n\t\t// Arm the hard timeout scaled by current load. When it fires, handleTimeout\n\t\t// re-checks load and re-arms (up to MAX_LOAD_MULTIPLIER) if the pool is still\n\t\t// busy, so a slow-but-progressing subagent isn't killed for CPU contention.\n\t\tconst timeout = setTimeout(\n\t\t\t() => {\n\t\t\t\tthis.handleTimeout(task_id);\n\t\t\t},\n\t\t\tMath.round(timeoutMs * this.loadMultiplier()),\n\t\t);\n\t\tthis.timeouts.set(task_id, timeout);\n\n\t\tproc.once(\"exit\", () => {\n\t\t\tthis.untrack(task_id);\n\t\t});\n\t}\n\n\t/** Record a heartbeat for a monitored task. */\n\trecordHeartbeat(task_id: string): void {\n\t\tif (this.processes.has(task_id)) {\n\t\t\tthis.lastHeartbeat.set(task_id, Date.now());\n\t\t}\n\t}\n\n\t/** Get the last recorded heartbeat timestamp, or null. */\n\tlastHeartbeatAt(task_id: string): number | null {\n\t\treturn this.lastHeartbeat.get(task_id) ?? null;\n\t}\n\n\t/** True if the task is currently being monitored. */\n\tisMonitoring(task_id: string): boolean {\n\t\treturn this.processes.has(task_id);\n\t}\n\n\t/** Kill all monitored processes and clean up. */\n\tdispose(): void {\n\t\tif (this.disposed) return;\n\t\tthis.disposed = true;\n\n\t\tif (this.checkInterval) {\n\t\t\tclearInterval(this.checkInterval);\n\t\t\tthis.checkInterval = null;\n\t\t}\n\n\t\tfor (const timeout of this.timeouts.values()) {\n\t\t\tclearTimeout(timeout);\n\t\t}\n\t\tthis.timeouts.clear();\n\n\t\tfor (const monitored of this.processes.values()) {\n\t\t\tthis.killTree(monitored);\n\t\t}\n\t\tthis.processes.clear();\n\t\tthis.lastHeartbeat.clear();\n\t\tthis.startedAt.clear();\n\t\tthis.baseTimeoutMs.clear();\n\t\tthis.reaping.clear();\n\t\tthis.removeAllListeners();\n\n\t\tif (this.parentShutdownHandler) {\n\t\t\tprocess.removeListener(\"SIGINT\", this.parentShutdownHandler);\n\t\t\tprocess.removeListener(\"SIGTERM\", this.parentShutdownHandler);\n\t\t}\n\t}\n\n\tprivate checkHeartbeats(): void {\n\t\tconst now = Date.now();\n\t\t// Event-loop lag: how much later than scheduled this check actually ran.\n\t\t// Heavy CPU load (many concurrent subagents + background MCP tools) starves\n\t\t// the loop, delaying both this check *and* our reading of child heartbeats.\n\t\t// Forgive that gap so the parent's own starvation isn't charged against the\n\t\t// children as a missed heartbeat.\n\t\tconst loopLag = Math.max(0, now - this.lastCheckAt - HEARTBEAT_CHECK_INTERVAL_MS);\n\t\tthis.lastCheckAt = now;\n\n\t\tconst threshold = HEARTBEAT_MISS_THRESHOLD_MS * this.loadMultiplier() + loopLag;\n\t\tfor (const [task_id] of this.processes) {\n\t\t\tif (this.reaping.has(task_id)) continue;\n\t\t\tconst last = this.lastHeartbeat.get(task_id);\n\t\t\tif (last === undefined) continue;\n\t\t\tif (now - last > threshold) {\n\t\t\t\tthis.handleStalled(task_id);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate handleStalled(task_id: string): void {\n\t\tconst monitored = this.processes.get(task_id);\n\t\tif (!monitored || this.reaping.has(task_id)) return;\n\t\tthis.reaping.add(task_id);\n\n\t\t// Record why we reaped this child so a recurrence is diagnosable rather than\n\t\t// just \"stalled\": how long since the last heartbeat, and the load factors\n\t\t// (concurrent monitored subagents, threshold) that fed the decision.\n\t\tconst last = this.lastHeartbeat.get(task_id);\n\t\tconst silentMs = last === undefined ? -1 : Date.now() - last;\n\t\tconsole.error(\n\t\t\t`[LIFEGUARD] stalled task_id=${task_id} agent=${monitored.agent_type} ` +\n\t\t\t\t`silent_ms=${silentMs} concurrent=${this.processes.size} ` +\n\t\t\t\t`load_mult=${this.loadMultiplier().toFixed(2)} base_threshold_ms=${HEARTBEAT_MISS_THRESHOLD_MS}`,\n\t\t);\n\n\t\tthis.killTree(monitored);\n\n\t\tthis.emit(\"stalled\", { task_id, pid: monitored.pid });\n\t\t// Process exit handler will call untrack()\n\t}\n\n\tprivate handleTimeout(task_id: string): void {\n\t\tconst monitored = this.processes.get(task_id);\n\t\tif (!monitored || this.reaping.has(task_id)) return;\n\n\t\t// Under load the wall-clock timer can fire while the subagent is still doing\n\t\t// real work — its turns are just slow because the CPU is shared. Re-arm rather\n\t\t// than kill, up to a hard ceiling (base * MAX_LOAD_MULTIPLIER) so a genuinely\n\t\t// stuck agent still terminates.\n\t\tconst started = this.startedAt.get(task_id) ?? Date.now();\n\t\tconst base = this.baseTimeoutMs.get(task_id) ?? TIMEOUTS_MS[monitored.agent_type] ?? TIMEOUTS_MS.explore;\n\t\tconst elapsed = Date.now() - started;\n\t\tconst ceiling = base * MAX_LOAD_MULTIPLIER;\n\t\tif (this.loadMultiplier() > 1 && elapsed < ceiling) {\n\t\t\tconst remaining = ceiling - elapsed;\n\t\t\tconst next = Math.min(\n\t\t\t\tMath.round(base * this.loadMultiplier()),\n\t\t\t\tMath.max(HEARTBEAT_CHECK_INTERVAL_MS, remaining),\n\t\t\t);\n\t\t\tthis.timeouts.set(\n\t\t\t\ttask_id,\n\t\t\t\tsetTimeout(() => this.handleTimeout(task_id), next),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.reaping.add(task_id);\n\t\tthis.killTree(monitored);\n\n\t\tthis.emit(\"timeout\", { task_id, pid: monitored.pid });\n\t\tthis.timeouts.delete(task_id);\n\t\t// Process exit handler will call untrack()\n\t}\n\n\t/**\n\t * Kill a monitored subagent and everything it spawned. The pool spawns\n\t * children detached on POSIX (each leads its own process group), so the\n\t * group/tree kill reaches nested grandchildren (the subagent's bash commands,\n\t * its own subagents) that a single-PID SIGKILL would orphan. A pid of 0 means\n\t * the spawn never produced a process — nothing to kill (and `kill(-0)` would\n\t * signal our own process group).\n\t */\n\tprivate killTree(monitored: LifeguardProcess): void {\n\t\tif (monitored.pid > 0) {\n\t\t\tkillProcessTree(monitored.pid);\n\t\t} else if (!monitored.process.killed) {\n\t\t\tmonitored.process.kill(\"SIGKILL\");\n\t\t}\n\t}\n\n\tprivate untrack(task_id: string): void {\n\t\tconst timeout = this.timeouts.get(task_id);\n\t\tif (timeout) {\n\t\t\tclearTimeout(timeout);\n\t\t\tthis.timeouts.delete(task_id);\n\t\t}\n\t\tthis.processes.delete(task_id);\n\t\tthis.lastHeartbeat.delete(task_id);\n\t\tthis.startedAt.delete(task_id);\n\t\tthis.baseTimeoutMs.delete(task_id);\n\t\tthis.reaping.delete(task_id);\n\t}\n\n\tprivate setupParentExitHandlers(): void {\n\t\tconst shutdown = () => this.gracefulShutdown();\n\t\tthis.parentShutdownHandler = shutdown;\n\t\tprocess.setMaxListeners(Math.max(process.getMaxListeners(), 20));\n\t\tprocess.once(\"SIGINT\", shutdown);\n\t\tprocess.once(\"SIGTERM\", shutdown);\n\t}\n\n\tprivate gracefulShutdown(): void {\n\t\t// SIGTERM each child's process group (POSIX; the pool spawns them detached\n\t\t// as group leaders) so their own descendants get the graceful signal too.\n\t\tfor (const monitored of this.processes.values()) {\n\t\t\ttry {\n\t\t\t\tif (process.platform !== \"win32\" && monitored.pid > 0) {\n\t\t\t\t\tprocess.kill(-monitored.pid, \"SIGTERM\");\n\t\t\t\t} else if (!monitored.process.killed) {\n\t\t\t\t\tmonitored.process.kill(\"SIGTERM\");\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Already gone.\n\t\t\t}\n\t\t}\n\n\t\t// Kill whole trees after the grace period\n\t\tsetTimeout(() => {\n\t\t\tfor (const monitored of this.processes.values()) {\n\t\t\t\tthis.killTree(monitored);\n\t\t\t}\n\t\t}, PARENT_SHUTDOWN_GRACE_MS).unref();\n\t}\n\n\tprivate sweepOldAgents(): void {\n\t\tconst dispatchDir = getDispatchRoot(this.cwd);\n\t\tif (!existsSync(dispatchDir)) return;\n\n\t\tconst now = Date.now();\n\t\tconst cutoff = 24 * 60 * 60 * 1000; // 24 hours\n\n\t\tfor (const entry of readdirSync(dispatchDir)) {\n\t\t\tconst entryPath = join(dispatchDir, entry);\n\t\t\ttry {\n\t\t\t\tconst stats = statSync(entryPath);\n\t\t\t\tif (!stats.isDirectory()) continue;\n\n\t\t\t\tif (now - stats.mtimeMs > cutoff) {\n\t\t\t\t\tconst hasRunningPid = this.hasRunningPid(entryPath);\n\t\t\t\t\tif (!hasRunningPid) {\n\t\t\t\t\t\tthis.rmrf(entryPath);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Ignore errors for individual entries\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate hasRunningPid(dir: string): boolean {\n\t\tconst pidFile = join(dir, \"pid\");\n\t\tif (!existsSync(pidFile)) return false;\n\n\t\ttry {\n\t\t\tconst pid = Number.parseInt(readFileSync(pidFile, \"utf-8\"), 10);\n\t\t\tif (Number.isNaN(pid)) return false;\n\t\t\tprocess.kill(pid, 0); // Check if process exists\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate rmrf(dir: string): void {\n\t\tfor (const entry of readdirSync(dir)) {\n\t\t\tconst entryPath = join(dir, entry);\n\t\t\ttry {\n\t\t\t\tconst stats = statSync(entryPath);\n\t\t\t\tif (stats.isDirectory()) {\n\t\t\t\t\tthis.rmrf(entryPath);\n\t\t\t\t} else {\n\t\t\t\t\tunlinkSync(entryPath);\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Ignore\n\t\t\t}\n\t\t}\n\t\ttry {\n\t\t\trmdirSync(dir);\n\t\t} catch {\n\t\t\t// Ignore\n\t\t}\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"lifeguard.js","sourceRoot":"","sources":["../../src/core/lifeguard.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ChildProcess } from \"node:child_process\";\nimport { EventEmitter } from \"node:events\";\nimport { existsSync, readdirSync, readFileSync, rmdirSync, statSync, unlinkSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { getDispatchRoot } from \"../config.js\";\nimport { killProcessTree } from \"../utils/shell.js\";\nimport { agentLog } from \"./agent-log.js\";\n\nconst TIMEOUTS_MS: Record<string, number> = {\n\texplore: 5 * 60 * 1000,\n\tedit: 10 * 60 * 1000,\n\ttest: 10 * 60 * 1000,\n\treview: 8 * 60 * 1000,\n\tdoc: 5 * 60 * 1000,\n};\n\nconst HEARTBEAT_MISS_THRESHOLD_MS = 60000;\nconst HEARTBEAT_CHECK_INTERVAL_MS = 5000;\nconst PARENT_SHUTDOWN_GRACE_MS = 5000;\n\n/**\n * Concurrency tolerance. Each *additional* concurrently-monitored subagent adds\n * this fraction to the heartbeat-miss and hard-timeout budgets.\n *\n * When several subagents run at once (plus background MCP tools), they saturate\n * the CPU and starve the parent's event loop: its `setInterval` heartbeat check\n * fires late, and it cannot read a child's `{\"ping\":true}` line in time even\n * though the child is healthy and still working. Scaling the budgets by load\n * stops that contention from false-positive SIGKILLing healthy subagents — the\n * failure the demo hit when running many agents + MCP tools in the background.\n */\nconst LOAD_TOLERANCE_PER_PROCESS = 0.5;\n\n/**\n * Hard ceiling on the load tolerance multiplier, so a genuinely stuck subagent\n * is still eventually reaped no matter how busy the pool is.\n */\nconst MAX_LOAD_MULTIPLIER = 4;\n\nexport interface LifeguardProcess {\n\tpid: number;\n\ttask_id: string;\n\tagent_type: string;\n\tprocess: ChildProcess;\n}\n\n/**\n * Monitors running subagent processes for heartbeats, hard timeouts,\n * and parent-exit cleanup. Emits \"stalled\" and \"timeout\" events when\n * processes are terminated.\n */\nexport class SubagentLifeguard extends EventEmitter {\n\tprivate processes = new Map<string, LifeguardProcess>();\n\tprivate lastHeartbeat = new Map<string, number>();\n\tprivate timeouts = new Map<string, NodeJS.Timeout>();\n\t/** When each task started, used to compute the load-scaled hard timeout. */\n\tprivate startedAt = new Map<string, number>();\n\t/** Per-agent base hard timeout (before load scaling), captured at monitor(). */\n\tprivate baseTimeoutMs = new Map<string, number>();\n\tprivate checkInterval: NodeJS.Timeout | null = null;\n\t/** Wall-clock time the heartbeat check last ran, to measure event-loop lag. */\n\tprivate lastCheckAt = Date.now();\n\t/**\n\t * Count of external in-process tasks (e.g. background MCP tools) running in the\n\t * parent alongside the monitored subagents. These don't show up in `processes`\n\t * but still saturate the parent's CPU/event loop, so they're folded into the\n\t * load multiplier. Updated by the pool via setExternalLoad().\n\t */\n\tprivate externalLoad = 0;\n\t/**\n\t * Tasks already reaped (stalled/timeout kill sent) but whose `exit` has not\n\t * fired yet. The heartbeat check runs every 5s, so without this a stalled task\n\t * would re-emit \"stalled\" (and re-kill) on every tick until the process exits.\n\t */\n\tprivate reaping = new Set<string>();\n\tprivate disposed = false;\n\tprivate readonly cwd: string;\n\tprivate parentShutdownHandler?: () => void;\n\n\tconstructor(cwd: string) {\n\t\tsuper();\n\t\tthis.cwd = cwd;\n\t\tthis.setupParentExitHandlers();\n\t\tthis.sweepOldAgents();\n\t\tthis.lastCheckAt = Date.now();\n\t\tthis.checkInterval = setInterval(() => this.checkHeartbeats(), HEARTBEAT_CHECK_INTERVAL_MS);\n\t}\n\n\t/**\n\t * Set the count of external in-process tasks (background MCP tools) sharing the\n\t * parent's CPU/event loop. Folded into loadMultiplier() so concurrent MCP work\n\t * widens the heartbeat/timeout budgets just like extra monitored subagents do.\n\t * Negative values are clamped to 0.\n\t */\n\tsetExternalLoad(count: number): void {\n\t\tthis.externalLoad = Math.max(0, Math.floor(count));\n\t}\n\n\t/**\n\t * Tolerance multiplier for the current load. 1 concurrent task → 1x; each extra\n\t * concurrent task adds LOAD_TOLERANCE_PER_PROCESS, capped at MAX_LOAD_MULTIPLIER.\n\t * Concurrency counts both monitored subagents and external load (background MCP\n\t * tools), since both contend for the same parent event loop.\n\t */\n\tprivate loadMultiplier(): number {\n\t\tconst concurrent = this.processes.size + this.externalLoad;\n\t\tconst mult = 1 + Math.max(0, concurrent - 1) * LOAD_TOLERANCE_PER_PROCESS;\n\t\treturn Math.min(mult, MAX_LOAD_MULTIPLIER);\n\t}\n\n\t/**\n\t * Begin monitoring a child process. The process must emit a\n\t * `{\"ping\":true}` JSON line on stdout every 30 seconds.\n\t */\n\tmonitor(task_id: string, agent_type: string, proc: ChildProcess): void {\n\t\tif (this.disposed) return;\n\n\t\tconst pid = proc.pid ?? 0;\n\t\tthis.processes.set(task_id, { pid, task_id, agent_type, process: proc });\n\t\tthis.lastHeartbeat.set(task_id, Date.now());\n\n\t\tconst timeoutMs = TIMEOUTS_MS[agent_type] ?? TIMEOUTS_MS.explore;\n\t\tthis.startedAt.set(task_id, Date.now());\n\t\tthis.baseTimeoutMs.set(task_id, timeoutMs);\n\t\t// Arm the hard timeout scaled by current load. When it fires, handleTimeout\n\t\t// re-checks load and re-arms (up to MAX_LOAD_MULTIPLIER) if the pool is still\n\t\t// busy, so a slow-but-progressing subagent isn't killed for CPU contention.\n\t\tconst timeout = setTimeout(\n\t\t\t() => {\n\t\t\t\tthis.handleTimeout(task_id);\n\t\t\t},\n\t\t\tMath.round(timeoutMs * this.loadMultiplier()),\n\t\t);\n\t\tthis.timeouts.set(task_id, timeout);\n\n\t\tproc.once(\"exit\", () => {\n\t\t\tthis.untrack(task_id);\n\t\t});\n\t}\n\n\t/** Record a heartbeat for a monitored task. */\n\trecordHeartbeat(task_id: string): void {\n\t\tif (this.processes.has(task_id)) {\n\t\t\tthis.lastHeartbeat.set(task_id, Date.now());\n\t\t}\n\t}\n\n\t/** Get the last recorded heartbeat timestamp, or null. */\n\tlastHeartbeatAt(task_id: string): number | null {\n\t\treturn this.lastHeartbeat.get(task_id) ?? null;\n\t}\n\n\t/** True if the task is currently being monitored. */\n\tisMonitoring(task_id: string): boolean {\n\t\treturn this.processes.has(task_id);\n\t}\n\n\t/** Kill all monitored processes and clean up. */\n\tdispose(): void {\n\t\tif (this.disposed) return;\n\t\tthis.disposed = true;\n\n\t\tif (this.checkInterval) {\n\t\t\tclearInterval(this.checkInterval);\n\t\t\tthis.checkInterval = null;\n\t\t}\n\n\t\tfor (const timeout of this.timeouts.values()) {\n\t\t\tclearTimeout(timeout);\n\t\t}\n\t\tthis.timeouts.clear();\n\n\t\tfor (const monitored of this.processes.values()) {\n\t\t\tthis.killTree(monitored);\n\t\t}\n\t\tthis.processes.clear();\n\t\tthis.lastHeartbeat.clear();\n\t\tthis.startedAt.clear();\n\t\tthis.baseTimeoutMs.clear();\n\t\tthis.reaping.clear();\n\t\tthis.removeAllListeners();\n\n\t\tif (this.parentShutdownHandler) {\n\t\t\tprocess.removeListener(\"SIGINT\", this.parentShutdownHandler);\n\t\t\tprocess.removeListener(\"SIGTERM\", this.parentShutdownHandler);\n\t\t}\n\t}\n\n\tprivate checkHeartbeats(): void {\n\t\tconst now = Date.now();\n\t\t// Event-loop lag: how much later than scheduled this check actually ran.\n\t\t// Heavy CPU load (many concurrent subagents + background MCP tools) starves\n\t\t// the loop, delaying both this check *and* our reading of child heartbeats.\n\t\t// Forgive that gap so the parent's own starvation isn't charged against the\n\t\t// children as a missed heartbeat.\n\t\tconst loopLag = Math.max(0, now - this.lastCheckAt - HEARTBEAT_CHECK_INTERVAL_MS);\n\t\tthis.lastCheckAt = now;\n\n\t\tconst threshold = HEARTBEAT_MISS_THRESHOLD_MS * this.loadMultiplier() + loopLag;\n\t\tfor (const [task_id] of this.processes) {\n\t\t\tif (this.reaping.has(task_id)) continue;\n\t\t\tconst last = this.lastHeartbeat.get(task_id);\n\t\t\tif (last === undefined) continue;\n\t\t\tif (now - last > threshold) {\n\t\t\t\tthis.handleStalled(task_id);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate handleStalled(task_id: string): void {\n\t\tconst monitored = this.processes.get(task_id);\n\t\tif (!monitored || this.reaping.has(task_id)) return;\n\t\tthis.reaping.add(task_id);\n\n\t\t// Record why we reaped this child so a recurrence is diagnosable rather than\n\t\t// just \"stalled\": how long since the last heartbeat, and the load factors\n\t\t// (concurrent monitored subagents, threshold) that fed the decision.\n\t\tconst last = this.lastHeartbeat.get(task_id);\n\t\tconst silentMs = last === undefined ? -1 : Date.now() - last;\n\t\tagentLog(\n\t\t\t`[LIFEGUARD] stalled task_id=${task_id} agent=${monitored.agent_type} ` +\n\t\t\t\t`silent_ms=${silentMs} concurrent=${this.processes.size} ` +\n\t\t\t\t`load_mult=${this.loadMultiplier().toFixed(2)} base_threshold_ms=${HEARTBEAT_MISS_THRESHOLD_MS}`,\n\t\t);\n\n\t\tthis.killTree(monitored);\n\n\t\tthis.emit(\"stalled\", { task_id, pid: monitored.pid });\n\t\t// Process exit handler will call untrack()\n\t}\n\n\tprivate handleTimeout(task_id: string): void {\n\t\tconst monitored = this.processes.get(task_id);\n\t\tif (!monitored || this.reaping.has(task_id)) return;\n\n\t\t// Under load the wall-clock timer can fire while the subagent is still doing\n\t\t// real work — its turns are just slow because the CPU is shared. Re-arm rather\n\t\t// than kill, up to a hard ceiling (base * MAX_LOAD_MULTIPLIER) so a genuinely\n\t\t// stuck agent still terminates.\n\t\tconst started = this.startedAt.get(task_id) ?? Date.now();\n\t\tconst base = this.baseTimeoutMs.get(task_id) ?? TIMEOUTS_MS[monitored.agent_type] ?? TIMEOUTS_MS.explore;\n\t\tconst elapsed = Date.now() - started;\n\t\tconst ceiling = base * MAX_LOAD_MULTIPLIER;\n\t\tif (this.loadMultiplier() > 1 && elapsed < ceiling) {\n\t\t\tconst remaining = ceiling - elapsed;\n\t\t\tconst next = Math.min(\n\t\t\t\tMath.round(base * this.loadMultiplier()),\n\t\t\t\tMath.max(HEARTBEAT_CHECK_INTERVAL_MS, remaining),\n\t\t\t);\n\t\t\tthis.timeouts.set(\n\t\t\t\ttask_id,\n\t\t\t\tsetTimeout(() => this.handleTimeout(task_id), next),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.reaping.add(task_id);\n\t\tthis.killTree(monitored);\n\n\t\tthis.emit(\"timeout\", { task_id, pid: monitored.pid });\n\t\tthis.timeouts.delete(task_id);\n\t\t// Process exit handler will call untrack()\n\t}\n\n\t/**\n\t * Kill a monitored subagent and everything it spawned. The pool spawns\n\t * children detached on POSIX (each leads its own process group), so the\n\t * group/tree kill reaches nested grandchildren (the subagent's bash commands,\n\t * its own subagents) that a single-PID SIGKILL would orphan. A pid of 0 means\n\t * the spawn never produced a process — nothing to kill (and `kill(-0)` would\n\t * signal our own process group).\n\t */\n\tprivate killTree(monitored: LifeguardProcess): void {\n\t\tif (monitored.pid > 0) {\n\t\t\tkillProcessTree(monitored.pid);\n\t\t} else if (!monitored.process.killed) {\n\t\t\tmonitored.process.kill(\"SIGKILL\");\n\t\t}\n\t}\n\n\tprivate untrack(task_id: string): void {\n\t\tconst timeout = this.timeouts.get(task_id);\n\t\tif (timeout) {\n\t\t\tclearTimeout(timeout);\n\t\t\tthis.timeouts.delete(task_id);\n\t\t}\n\t\tthis.processes.delete(task_id);\n\t\tthis.lastHeartbeat.delete(task_id);\n\t\tthis.startedAt.delete(task_id);\n\t\tthis.baseTimeoutMs.delete(task_id);\n\t\tthis.reaping.delete(task_id);\n\t}\n\n\tprivate setupParentExitHandlers(): void {\n\t\tconst shutdown = () => this.gracefulShutdown();\n\t\tthis.parentShutdownHandler = shutdown;\n\t\tprocess.setMaxListeners(Math.max(process.getMaxListeners(), 20));\n\t\tprocess.once(\"SIGINT\", shutdown);\n\t\tprocess.once(\"SIGTERM\", shutdown);\n\t}\n\n\tprivate gracefulShutdown(): void {\n\t\t// SIGTERM each child's process group (POSIX; the pool spawns them detached\n\t\t// as group leaders) so their own descendants get the graceful signal too.\n\t\tfor (const monitored of this.processes.values()) {\n\t\t\ttry {\n\t\t\t\tif (process.platform !== \"win32\" && monitored.pid > 0) {\n\t\t\t\t\tprocess.kill(-monitored.pid, \"SIGTERM\");\n\t\t\t\t} else if (!monitored.process.killed) {\n\t\t\t\t\tmonitored.process.kill(\"SIGTERM\");\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Already gone.\n\t\t\t}\n\t\t}\n\n\t\t// Kill whole trees after the grace period\n\t\tsetTimeout(() => {\n\t\t\tfor (const monitored of this.processes.values()) {\n\t\t\t\tthis.killTree(monitored);\n\t\t\t}\n\t\t}, PARENT_SHUTDOWN_GRACE_MS).unref();\n\t}\n\n\tprivate sweepOldAgents(): void {\n\t\tconst dispatchDir = getDispatchRoot(this.cwd);\n\t\tif (!existsSync(dispatchDir)) return;\n\n\t\tconst now = Date.now();\n\t\tconst cutoff = 24 * 60 * 60 * 1000; // 24 hours\n\n\t\tfor (const entry of readdirSync(dispatchDir)) {\n\t\t\tconst entryPath = join(dispatchDir, entry);\n\t\t\ttry {\n\t\t\t\tconst stats = statSync(entryPath);\n\t\t\t\tif (!stats.isDirectory()) continue;\n\n\t\t\t\tif (now - stats.mtimeMs > cutoff) {\n\t\t\t\t\tconst hasRunningPid = this.hasRunningPid(entryPath);\n\t\t\t\t\tif (!hasRunningPid) {\n\t\t\t\t\t\tthis.rmrf(entryPath);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Ignore errors for individual entries\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate hasRunningPid(dir: string): boolean {\n\t\tconst pidFile = join(dir, \"pid\");\n\t\tif (!existsSync(pidFile)) return false;\n\n\t\ttry {\n\t\t\tconst pid = Number.parseInt(readFileSync(pidFile, \"utf-8\"), 10);\n\t\t\tif (Number.isNaN(pid)) return false;\n\t\t\tprocess.kill(pid, 0); // Check if process exists\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate rmrf(dir: string): void {\n\t\tfor (const entry of readdirSync(dir)) {\n\t\t\tconst entryPath = join(dir, entry);\n\t\t\ttry {\n\t\t\t\tconst stats = statSync(entryPath);\n\t\t\t\tif (stats.isDirectory()) {\n\t\t\t\t\tthis.rmrf(entryPath);\n\t\t\t\t} else {\n\t\t\t\t\tunlinkSync(entryPath);\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Ignore\n\t\t\t}\n\t\t}\n\t\ttry {\n\t\t\trmdirSync(dir);\n\t\t} catch {\n\t\t\t// Ignore\n\t\t}\n\t}\n}\n"]}
|