openhermes 4.11.2 → 4.13.0

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.
Files changed (74) hide show
  1. package/CONTEXT.md +1 -1
  2. package/ETHOS.md +1 -1
  3. package/README.md +12 -18
  4. package/bootstrap.ts +73 -148
  5. package/docs/HOW-IT-WORKS.md +162 -0
  6. package/docs/adr/ADR-0001-rebuild-vs-increment.md +30 -0
  7. package/docs/adr/ADR-0002-routing-graph-vs-linear-chain.md +36 -0
  8. package/docs/adr/ADR-0003-per-directory-plan-storage.md +34 -0
  9. package/docs/adr/ADR-0004-composer-fragment-architecture.md +42 -0
  10. package/docs/adr/ADR-0005-hook-system-design.md +42 -0
  11. package/docs/adr/README.md +9 -0
  12. package/harness/codex/AUTOPILOT.md +30 -23
  13. package/harness/codex/CHARTER.md +3 -3
  14. package/harness/lib/composer/compose.test.ts +11 -0
  15. package/harness/lib/composer/fragments/02-delegation.md +2 -1
  16. package/harness/lib/composer/fragments/04-task-flow.md +42 -2
  17. package/harness/lib/composer/fragments/08-routing.md +1 -1
  18. package/harness/lib/composer/fragments/09-guardrails.md +17 -4
  19. package/harness/lib/composer/index.ts +1 -1
  20. package/harness/lib/guards/guard-config.ts +72 -0
  21. package/harness/lib/hooks/builtins/confidence-gate-hook.ts +2 -4
  22. package/harness/lib/hooks/builtins/delegation-depth-hook.ts +23 -4
  23. package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -0
  24. package/harness/lib/hooks/builtins/next-route-hook.ts +24 -0
  25. package/harness/lib/hooks/builtins/plan-check-hook.ts +2 -2
  26. package/harness/lib/hooks/builtins/route-tracking-hook.ts +79 -25
  27. package/harness/lib/hooks/hooks.test.ts +117 -205
  28. package/harness/lib/hooks/index.ts +38 -30
  29. package/harness/lib/hooks/registry.ts +309 -416
  30. package/harness/lib/hooks/types.ts +116 -71
  31. package/harness/lib/plans/plan-location.ts +134 -0
  32. package/harness/lib/routing/index.ts +21 -0
  33. package/harness/lib/routing/route-guidance.ts +147 -0
  34. package/harness/lib/routing/route-resolver.ts +58 -0
  35. package/harness/lib/routing/routing.test.ts +195 -0
  36. package/harness/lib/routing/skill-frontmatter.ts +125 -0
  37. package/harness/lib/routing/types.ts +52 -0
  38. package/harness/skills/oh-ascii/SKILL.md +1 -1
  39. package/harness/skills/oh-fusion/DEEP.md +56 -33
  40. package/harness/skills/oh-fusion/SKILL.md +30 -16
  41. package/harness/skills/oh-init/DEEP.md +2 -2
  42. package/harness/skills/oh-manifest/SKILL.md +1 -0
  43. package/harness/skills/oh-plan-review/DEEP.md +1 -1
  44. package/harness/skills/oh-planner/DEEP.md +3 -3
  45. package/harness/skills/oh-review/DEEP.md +2 -0
  46. package/harness/skills/oh-review/SKILL.md +1 -0
  47. package/package.json +56 -55
  48. package/harness/lib/background/background.test.ts +0 -197
  49. package/harness/lib/background/index.ts +0 -7
  50. package/harness/lib/background/interfaces.ts +0 -31
  51. package/harness/lib/background/manager.ts +0 -320
  52. package/harness/lib/hooks/builtins/error-recovery-hook.ts +0 -107
  53. package/harness/lib/hooks/builtins/memory-sync-hook.ts +0 -73
  54. package/harness/lib/hooks/builtins/sanity-check-hook.ts +0 -52
  55. package/harness/lib/memory/index.ts +0 -18
  56. package/harness/lib/memory/interfaces.ts +0 -53
  57. package/harness/lib/memory/memory-manager.ts +0 -205
  58. package/harness/lib/memory/memory.test.ts +0 -491
  59. package/harness/lib/memory/plan-store.ts +0 -366
  60. package/harness/lib/recovery/handler.ts +0 -243
  61. package/harness/lib/recovery/index.ts +0 -14
  62. package/harness/lib/recovery/interfaces.ts +0 -48
  63. package/harness/lib/recovery/patterns.ts +0 -149
  64. package/harness/lib/recovery/recovery.test.ts +0 -312
  65. package/harness/lib/sanity/anomaly-tracker.ts +0 -127
  66. package/harness/lib/sanity/checker.ts +0 -178
  67. package/harness/lib/sanity/index.ts +0 -13
  68. package/harness/lib/sanity/interfaces.ts +0 -24
  69. package/harness/lib/sanity/sanity.test.ts +0 -472
  70. package/harness/lib/sync/file-watcher.ts +0 -174
  71. package/harness/lib/sync/index.ts +0 -11
  72. package/harness/lib/sync/interfaces.ts +0 -27
  73. package/harness/lib/sync/plan-sync.ts +0 -536
  74. package/harness/lib/sync/sync.test.ts +0 -832
package/package.json CHANGED
@@ -1,57 +1,58 @@
1
1
  {
2
- "name": "openhermes",
3
- "version": "4.11.2",
4
- "description": "Autonomous agent orchestration for OpenCode.",
5
- "type": "module",
6
- "license": "MIT",
7
- "engines": {
8
- "bun": ">=1.0"
9
- },
10
- "main": "./index.ts",
11
- "dependencies": {
12
- "@opencode-ai/plugin": "1.15.0"
13
- },
14
- "exports": {
15
- ".": "./index.ts",
16
- "./bootstrap": "./bootstrap.ts"
17
- },
18
- "files": [
19
- "index.ts",
20
- "bootstrap.ts",
21
- "tsconfig.json",
22
- "ETHOS.md",
23
- "CONTEXT.md",
24
- "lib/",
25
- "scripts/",
26
- "harness/codex/",
27
- "harness/instructions/",
28
- "harness/skills/",
29
- "harness/commands/",
30
- "harness/agents/",
31
- "harness/lib/"
32
- ],
33
- "scripts": {
34
- "test": "bun test"
35
- },
36
- "keywords": [
37
- "opencode",
38
- "openhermes",
39
- "orchestrator",
40
- "skills",
41
- "commands",
42
- "agents",
43
- "rules"
44
- ],
45
- "repository": {
46
- "type": "git",
47
- "url": "git+https://github.com/nathwn12/openhermes.git"
48
- },
49
- "bugs": {
50
- "url": "https://github.com/nathwn12/openhermes/issues"
51
- },
52
- "homepage": "https://github.com/nathwn12/openhermes#readme",
53
- "author": "nathwn12",
54
- "devDependencies": {
55
- "@types/node": "^25.8.0"
56
- }
2
+ "name": "openhermes",
3
+ "version": "4.13.0",
4
+ "description": "Autonomous agent orchestration for OpenCode.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "bun": ">=1.0"
9
+ },
10
+ "main": "./index.ts",
11
+ "dependencies": {
12
+ "@opencode-ai/plugin": "1.15.0"
13
+ },
14
+ "exports": {
15
+ ".": "./index.ts",
16
+ "./bootstrap": "./bootstrap.ts"
17
+ },
18
+ "files": [
19
+ "index.ts",
20
+ "bootstrap.ts",
21
+ "tsconfig.json",
22
+ "ETHOS.md",
23
+ "CONTEXT.md",
24
+ "docs/",
25
+ "lib/",
26
+ "harness/codex/",
27
+ "harness/instructions/",
28
+ "harness/skills/",
29
+ "harness/agents/",
30
+ "harness/lib/"
31
+ ],
32
+ "scripts": {
33
+ "test": "bun test",
34
+ "typecheck": "bun tsc --noEmit"
35
+ },
36
+ "keywords": [
37
+ "opencode",
38
+ "openhermes",
39
+ "orchestrator",
40
+ "skills",
41
+ "commands",
42
+ "agents",
43
+ "rules"
44
+ ],
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/nathwn12/openhermes.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/nathwn12/openhermes/issues"
51
+ },
52
+ "homepage": "https://github.com/nathwn12/openhermes#readme",
53
+ "author": "nathwn12",
54
+ "devDependencies": {
55
+ "@types/node": "^25.8.0",
56
+ "typescript": "^6.0.3"
57
+ }
57
58
  }
@@ -1,197 +0,0 @@
1
- import { describe, it, afterEach } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { BackgroundManager } from "./manager.ts";
4
- import type { BackgroundTaskStatus } from "./interfaces.ts";
5
-
6
- // ---------------------------------------------------------------------------
7
- // Helpers
8
- // ---------------------------------------------------------------------------
9
-
10
- function delay(ms: number): Promise<void> {
11
- return new Promise((resolve) => setTimeout(resolve, ms));
12
- }
13
-
14
- /**
15
- * Poll check() until the task reaches one of the given statuses, or until
16
- * a generous timeout elapses (2.5 s).
17
- */
18
- async function waitForStatus(
19
- manager: BackgroundManager,
20
- id: string,
21
- ...expected: BackgroundTaskStatus[]
22
- ): Promise<void> {
23
- for (let i = 0; i < 50; i++) {
24
- const task = manager.check(id);
25
- if (task && expected.includes(task.status)) return;
26
- await delay(50);
27
- }
28
- const task = manager.check(id);
29
- const actual = task?.status ?? "(not found)";
30
- throw new Error(
31
- `Timed out waiting for status [${expected.join("/")}], got "${actual}"`,
32
- );
33
- }
34
-
35
- // Windows detection — some assertions differ per platform
36
- const IS_WIN = process.platform === "win32";
37
-
38
- // ---------------------------------------------------------------------------
39
- // Tests
40
- // ---------------------------------------------------------------------------
41
-
42
- describe("BackgroundManager", () => {
43
- afterEach(() => {
44
- BackgroundManager.resetInstance();
45
- });
46
-
47
- // ---- 1: run() returns ID immediately ----------------------------------
48
-
49
- it("run() returns a task ID immediately", () => {
50
- const mgr = BackgroundManager.getInstance();
51
- const id = mgr.run({ command: IS_WIN ? "echo" : "echo", args: ["hello"] });
52
- assert.ok(typeof id === "string");
53
- assert.ok(id.length > 0, "id must not be empty");
54
- });
55
-
56
- // ---- 2: check() shows pending → running → completed -------------------
57
-
58
- it("check() transitions pending -> running -> completed", async () => {
59
- const mgr = BackgroundManager.getInstance();
60
- const id = mgr.run({ command: IS_WIN ? "echo" : "echo", args: ["hello"] });
61
-
62
- // Immediately after run() the task should be "pending"
63
- // (spawn is deferred via setImmediate)
64
- const initial = mgr.check(id);
65
- assert.ok(initial, "task must exist immediately");
66
- assert.equal(initial!.status, "pending");
67
-
68
- // Wait for it to complete
69
- await waitForStatus(mgr, id, "completed");
70
- const done = mgr.check(id);
71
- assert.equal(done!.exitCode, 0);
72
- });
73
-
74
- // ---- 3: capture stdout -------------------------------------------------
75
-
76
- it("captures stdout from a simple command", async () => {
77
- const mgr = BackgroundManager.getInstance();
78
- const id = mgr.run({
79
- command: IS_WIN ? "echo" : "echo",
80
- args: ["hello-background"],
81
- });
82
-
83
- await waitForStatus(mgr, id, "completed");
84
- const task = mgr.check(id);
85
- assert.ok(task, "task must exist");
86
- assert.match(task!.output, /hello-background/);
87
- });
88
-
89
- // ---- 4: failed command (non-zero exit) ---------------------------------
90
-
91
- it("detects a failed command (non-zero exit)", async () => {
92
- const mgr = BackgroundManager.getInstance();
93
- const id = mgr.run({
94
- command: IS_WIN ? "cmd.exe" : "bash",
95
- args: IS_WIN ? ["/c", "exit", "1"] : ["-c", "exit 1"],
96
- });
97
-
98
- await waitForStatus(mgr, id, "failed");
99
- const task = mgr.check(id);
100
- assert.ok(task);
101
- assert.equal(task!.exitCode, 1);
102
- assert.equal(task!.status, "failed");
103
- });
104
-
105
- // ---- 5: timeout enforcement --------------------------------------------
106
-
107
- it("enforces timeout and marks task as timed_out", async () => {
108
- const mgr = BackgroundManager.getInstance();
109
-
110
- // Use a long-running command with a very short timeout (100 ms)
111
- const id = mgr.run({
112
- command: IS_WIN ? "powershell.exe" : "sleep",
113
- args: IS_WIN
114
- ? ["-NoProfile", "-Command", "Start-Sleep -Seconds 30"]
115
- : ["30"],
116
- timeout: 100,
117
- });
118
-
119
- await waitForStatus(mgr, id, "timed_out");
120
- const task = mgr.check(id);
121
- assert.ok(task);
122
- assert.equal(task!.status, "timed_out");
123
- });
124
-
125
- // ---- 6: kill() marks as cancelled --------------------------------------
126
-
127
- it("kill() marks a running task as cancelled", async () => {
128
- const mgr = BackgroundManager.getInstance();
129
-
130
- const id = mgr.run({
131
- command: IS_WIN ? "powershell.exe" : "sleep",
132
- args: IS_WIN
133
- ? ["-NoProfile", "-Command", "Start-Sleep -Seconds 30"]
134
- : ["30"],
135
- timeout: 0, // no timeout
136
- });
137
-
138
- // Wait for the task to enter "running"
139
- await waitForStatus(mgr, id, "running");
140
-
141
- // Kill it
142
- const killed = mgr.kill(id);
143
- assert.ok(killed, "kill() must return true");
144
-
145
- const task = mgr.check(id);
146
- assert.ok(task);
147
- assert.equal(task!.status, "cancelled");
148
- });
149
-
150
- // ---- 7: list() returns all tasks ---------------------------------------
151
-
152
- it("list() returns all tracked tasks", async () => {
153
- const mgr = BackgroundManager.getInstance();
154
- const id1 = mgr.run({ command: IS_WIN ? "echo" : "echo", args: ["a"] });
155
- const id2 = mgr.run({ command: IS_WIN ? "echo" : "echo", args: ["b"] });
156
-
157
- const tasks = mgr.list();
158
- const ids = tasks.map((t) => t.id);
159
- assert.ok(ids.includes(id1), "list must contain first task");
160
- assert.ok(ids.includes(id2), "list must contain second task");
161
- });
162
-
163
- // ---- 8: kill() on already-terminal task returns false ------------------
164
-
165
- it("kill() returns false for already-completed task", async () => {
166
- const mgr = BackgroundManager.getInstance();
167
- const id = mgr.run({ command: IS_WIN ? "echo" : "echo", args: ["quick"] });
168
-
169
- await waitForStatus(mgr, id, "completed");
170
- const result = mgr.kill(id);
171
- assert.equal(result, false, "kill() must return false on complete task");
172
- });
173
-
174
- // ---- 9: check() returns undefined for unknown ID -----------------------
175
-
176
- it("check() returns undefined for unknown task ID", () => {
177
- const mgr = BackgroundManager.getInstance();
178
- const result = mgr.check("nonexistent-id");
179
- assert.equal(result, undefined);
180
- });
181
-
182
- // ---- 10: error output captured on command-not-found --------------------
183
-
184
- it("captures error output when command does not exist", async () => {
185
- const mgr = BackgroundManager.getInstance();
186
- const id = mgr.run({ command: "this-command-does-not-exist-hopefully" });
187
-
188
- await waitForStatus(mgr, id, "failed");
189
- const task = mgr.check(id);
190
- assert.ok(task);
191
- // On Windows cmd.exe will emit an error; on Unix spawn error will fire
192
- assert.ok(
193
- task!.errorOutput.length > 0 || task!.output.length > 0,
194
- "should have some error output",
195
- );
196
- });
197
- });
@@ -1,7 +0,0 @@
1
- export type {
2
- BackgroundTask,
3
- BackgroundTaskStatus,
4
- BackgroundRunOptions,
5
- } from "./interfaces.ts";
6
-
7
- export { BackgroundManager } from "./manager.ts";
@@ -1,31 +0,0 @@
1
- export interface BackgroundTask {
2
- id: string;
3
- command: string;
4
- args: string[];
5
- cwd: string;
6
- status: BackgroundTaskStatus;
7
- output: string;
8
- errorOutput: string;
9
- exitCode: number | null;
10
- startTime: number;
11
- endTime: number | null;
12
- timeout: number; // ms, 0 = no timeout
13
- label?: string;
14
- }
15
-
16
- export type BackgroundTaskStatus =
17
- | "pending"
18
- | "running"
19
- | "completed"
20
- | "failed"
21
- | "timed_out"
22
- | "cancelled";
23
-
24
- export interface BackgroundRunOptions {
25
- command: string;
26
- args?: string[];
27
- cwd?: string;
28
- timeout?: number; // ms, default 30000
29
- label?: string;
30
- env?: Record<string, string>;
31
- }
@@ -1,320 +0,0 @@
1
- import { spawn, exec, type ChildProcess } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import type {
4
- BackgroundTask,
5
- BackgroundTaskStatus,
6
- BackgroundRunOptions,
7
- } from "./interfaces.ts";
8
-
9
- // ---------------------------------------------------------------------------
10
- // Constants
11
- // ---------------------------------------------------------------------------
12
-
13
- const CLEANUP_INTERVAL_MS = 60_000; // Check for stale tasks every 60s
14
- const TASK_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
15
-
16
- // ---------------------------------------------------------------------------
17
- // Internal entry
18
- // ---------------------------------------------------------------------------
19
-
20
- interface TaskEntry {
21
- task: BackgroundTask;
22
- process: ChildProcess | null;
23
- timeoutId?: ReturnType<typeof setTimeout>;
24
- }
25
-
26
- // ---------------------------------------------------------------------------
27
- // Manager
28
- // ---------------------------------------------------------------------------
29
-
30
- export class BackgroundManager {
31
- private static instance: BackgroundManager;
32
- private tasks = new Map<string, TaskEntry>();
33
- private cleanupTimer: ReturnType<typeof setInterval> | null = null;
34
-
35
- private constructor() {
36
- this.startCleanup();
37
- }
38
-
39
- // -----------------------------------------------------------------------
40
- // Singleton
41
- // -----------------------------------------------------------------------
42
-
43
- static getInstance(): BackgroundManager {
44
- if (!BackgroundManager.instance) {
45
- BackgroundManager.instance = new BackgroundManager();
46
- }
47
- return BackgroundManager.instance;
48
- }
49
-
50
- /** Reset singleton — used in tests to get a clean slate. */
51
- static resetInstance(): void {
52
- const inst = BackgroundManager.instance;
53
- if (inst) {
54
- inst.destroy();
55
- BackgroundManager.instance = null as unknown as BackgroundManager;
56
- }
57
- }
58
-
59
- // -----------------------------------------------------------------------
60
- // Public API
61
- // -----------------------------------------------------------------------
62
-
63
- /**
64
- * Spawn a background process and return its task ID immediately.
65
- * The task status starts as "pending" and transitions to "running" on
66
- * the next event-loop tick when the process actually spawns.
67
- */
68
- run(options: BackgroundRunOptions): string {
69
- const id = randomUUID();
70
- const cwd = options.cwd ?? process.cwd();
71
- const args = options.args ?? [];
72
- const timeout = options.timeout ?? 30_000;
73
-
74
- const task: BackgroundTask = {
75
- id,
76
- command: options.command,
77
- args,
78
- cwd,
79
- status: "pending",
80
- output: "",
81
- errorOutput: "",
82
- exitCode: null,
83
- startTime: Date.now(),
84
- endTime: null,
85
- timeout,
86
- label: options.label,
87
- };
88
-
89
- const entry: TaskEntry = { task, process: null };
90
- this.tasks.set(id, entry);
91
-
92
- // Defer to next tick so callers can observe "pending" immediately
93
- setImmediate(() => {
94
- this.spawnTask(id, options, entry);
95
- });
96
-
97
- return id;
98
- }
99
-
100
- /** Return current state of a tracked task, or undefined if not found. */
101
- check(id: string): BackgroundTask | undefined {
102
- this.sweepStale();
103
- return this.tasks.get(id)?.task;
104
- }
105
-
106
- /** Return all tracked tasks (including completed ones). */
107
- list(): BackgroundTask[] {
108
- this.sweepStale();
109
- return Array.from(this.tasks.values()).map((e) => e.task);
110
- }
111
-
112
- /**
113
- * Kill a running / pending task.
114
- * Returns true if the task existed and was killable, false otherwise.
115
- */
116
- kill(id: string): boolean {
117
- const entry = this.tasks.get(id);
118
- if (!entry) return false;
119
-
120
- const { task } = entry;
121
- if (isTerminal(task.status)) return false;
122
-
123
- this.killProcess(entry);
124
- task.status = "cancelled";
125
- task.endTime = Date.now();
126
- this.clearTimeout(entry);
127
- return true;
128
- }
129
-
130
- // -----------------------------------------------------------------------
131
- // Lifecycle (test cleanup / singleton teardown)
132
- // -----------------------------------------------------------------------
133
-
134
- /** Shut down the manager — kill all processes, clear state, stop timers. */
135
- destroy(): void {
136
- if (this.cleanupTimer) {
137
- clearInterval(this.cleanupTimer);
138
- this.cleanupTimer = null;
139
- }
140
- for (const [, entry] of this.tasks) {
141
- if (!isTerminal(entry.task.status)) {
142
- this.killProcess(entry);
143
- }
144
- this.clearTimeout(entry);
145
- }
146
- this.tasks.clear();
147
- }
148
-
149
- // -----------------------------------------------------------------------
150
- // Internals
151
- // -----------------------------------------------------------------------
152
-
153
- private spawnTask(
154
- id: string,
155
- options: BackgroundRunOptions,
156
- entry: TaskEntry,
157
- ): void {
158
- const { task } = entry;
159
- const args = options.args ?? [];
160
- const env = options.env;
161
- const timeout = options.timeout ?? 30_000;
162
-
163
- try {
164
- const isWindows = process.platform === "win32";
165
-
166
- // On Windows, wrap everything in cmd.exe /c so PATH and .exe
167
- // resolution work the way users expect.
168
- // Sanitize: strip shell metacharacters from the command to prevent
169
- // command injection via LLM-generated command strings.
170
- const command = isWindows ? "cmd.exe" : options.command;
171
- const sanitizedCommand = isWindows
172
- ? options.command.replace(/[&|;<>^%!]/g, "")
173
- : options.command;
174
- const sanitizedArgs = args.map((a) =>
175
- a.replace(/[&|;<>^%!]/g, ""),
176
- );
177
- const commandArgs = isWindows
178
- ? ["/d", "/c", sanitizedCommand, ...sanitizedArgs]
179
- : args;
180
-
181
- const child = spawn(command, commandArgs, {
182
- cwd: task.cwd,
183
- env: env ? { ...process.env, ...env } : undefined,
184
- stdio: ["ignore", "pipe", "pipe"],
185
- });
186
-
187
- entry.process = child;
188
- task.status = "running";
189
-
190
- child.stdout?.on("data", (data: Buffer) => {
191
- task.output += data.toString();
192
- });
193
-
194
- child.stderr?.on("data", (data: Buffer) => {
195
- task.errorOutput += data.toString();
196
- });
197
-
198
- child.on("error", (err: Error) => {
199
- task.status = "failed";
200
- task.errorOutput += `\n[spawn error] ${err.message}`;
201
- task.endTime = Date.now();
202
- this.clearTimeout(entry);
203
- });
204
-
205
- child.on("close", (code: number | null) => {
206
- task.exitCode = code;
207
- task.endTime = Date.now();
208
- if (task.status === "running" || task.status === "pending") {
209
- task.status = code === 0 ? "completed" : "failed";
210
- }
211
- this.clearTimeout(entry);
212
- });
213
-
214
- // Timeout enforcement
215
- if (timeout > 0) {
216
- entry.timeoutId = setTimeout(() => {
217
- if (!isTerminal(task.status)) {
218
- this.killProcess(entry);
219
- task.status = "timed_out";
220
- task.endTime = Date.now();
221
- }
222
- }, timeout);
223
- }
224
- } catch (err) {
225
- task.status = "failed";
226
- task.errorOutput = `[exception] ${String(err)}`;
227
- task.endTime = Date.now();
228
- }
229
- }
230
-
231
- private async killProcess(entry: TaskEntry): Promise<void> {
232
- if (!entry.process) return;
233
-
234
- if (process.platform === "win32") {
235
- // Forceful tree-kill via taskkill (more reliable than SIGTERM on Windows)
236
- try {
237
- await new Promise<void>((resolve, reject) => {
238
- exec(`taskkill /pid ${entry.process!.pid} /f /t`, (err) => {
239
- if (err) reject(err);
240
- else resolve();
241
- });
242
- });
243
- } catch {
244
- // taskkill failed — process may already be dead
245
- }
246
- // Also try SIGTERM as a graceful fallback
247
- try {
248
- entry.process.kill("SIGTERM");
249
- } catch {
250
- /* already dead */
251
- }
252
- } else {
253
- try {
254
- entry.process.kill("SIGTERM");
255
- } catch {
256
- /* already dead */
257
- }
258
- }
259
- }
260
-
261
- private clearTimeout(entry: TaskEntry): void {
262
- if (entry.timeoutId !== undefined) {
263
- clearTimeout(entry.timeoutId);
264
- entry.timeoutId = undefined;
265
- }
266
- }
267
-
268
- private startCleanup(): void {
269
- this.cleanupTimer = setInterval(() => this.sweepStale(), CLEANUP_INTERVAL_MS);
270
- this.cleanupTimer?.unref();
271
- }
272
-
273
- /**
274
- * Remove stale tasks:
275
- * - Completed/failed tasks older than TASK_MAX_AGE_MS
276
- * - Zombie processes (status "running" but process handle is dead)
277
- */
278
- private sweepStale(): void {
279
- const now = Date.now();
280
- for (const [id, entry] of this.tasks) {
281
- const { task } = entry;
282
-
283
- // Completed/failed tasks older than threshold
284
- if (task.endTime && now - task.endTime > TASK_MAX_AGE_MS) {
285
- this.clearTimeout(entry);
286
- this.tasks.delete(id);
287
- continue;
288
- }
289
-
290
- // Check for zombie processes: status "running" but process has exited
291
- // Use exitCode !== null as the reliable cross-platform check
292
- if (task.status === "running" && entry.process) {
293
- if (entry.process.exitCode !== null) {
294
- // Process exited but the close event wasn't processed (zombie)
295
- task.status = "failed";
296
- task.endTime = Date.now();
297
- this.tasks.delete(id);
298
- }
299
- } else if (task.status === "running" && !entry.process) {
300
- // Process reference is gone but status not updated
301
- task.status = "failed";
302
- task.endTime = Date.now();
303
- this.tasks.delete(id);
304
- }
305
- }
306
- }
307
- }
308
-
309
- // ---------------------------------------------------------------------------
310
- // Helpers
311
- // ---------------------------------------------------------------------------
312
-
313
- function isTerminal(status: BackgroundTaskStatus): boolean {
314
- return (
315
- status === "completed" ||
316
- status === "failed" ||
317
- status === "timed_out" ||
318
- status === "cancelled"
319
- );
320
- }