open-multi-agent-kit 0.91.0 → 0.93.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.
- package/CHANGELOG.md +32 -1
- package/README.md +2 -2
- package/dist/core/agent-session.d.ts +29 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +89 -21
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/command-safety.d.ts.map +1 -1
- package/dist/core/command-safety.js +0 -50
- package/dist/core/command-safety.js.map +1 -1
- package/dist/core/compaction/branch-summarization.d.ts +5 -1
- package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
- package/dist/core/compaction/branch-summarization.js +5 -7
- package/dist/core/compaction/branch-summarization.js.map +1 -1
- package/dist/core/compaction/compaction.d.ts +12 -3
- package/dist/core/compaction/compaction.d.ts.map +1 -1
- package/dist/core/compaction/compaction.js +28 -15
- package/dist/core/compaction/compaction.js.map +1 -1
- package/dist/core/compaction/transaction.d.ts.map +1 -1
- package/dist/core/compaction/transaction.js +7 -3
- package/dist/core/compaction/transaction.js.map +1 -1
- package/dist/core/context-budget-headroom-candidates.d.ts.map +1 -1
- package/dist/core/context-budget-headroom-candidates.js +21 -11
- package/dist/core/context-budget-headroom-candidates.js.map +1 -1
- package/dist/core/context-budget-system-prompt-items.d.ts +1 -0
- package/dist/core/context-budget-system-prompt-items.d.ts.map +1 -1
- package/dist/core/context-budget-system-prompt-items.js +7 -8
- package/dist/core/context-budget-system-prompt-items.js.map +1 -1
- package/dist/core/extensions/builtin/command-safety-gate.d.ts +8 -0
- package/dist/core/extensions/builtin/command-safety-gate.d.ts.map +1 -1
- package/dist/core/extensions/builtin/command-safety-gate.js +23 -0
- package/dist/core/extensions/builtin/command-safety-gate.js.map +1 -1
- package/dist/core/extensions/runner.d.ts.map +1 -1
- package/dist/core/extensions/runner.js +4 -0
- package/dist/core/extensions/runner.js.map +1 -1
- package/dist/core/extensions/types.d.ts +2 -0
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/keybindings.d.ts +5 -0
- package/dist/core/keybindings.d.ts.map +1 -1
- package/dist/core/keybindings.js +5 -0
- package/dist/core/keybindings.js.map +1 -1
- package/dist/core/loadouts.d.ts.map +1 -1
- package/dist/core/loadouts.js +27 -48
- package/dist/core/loadouts.js.map +1 -1
- package/dist/core/model-resolver.d.ts +41 -3
- package/dist/core/model-resolver.d.ts.map +1 -1
- package/dist/core/model-resolver.js +17 -9
- package/dist/core/model-resolver.js.map +1 -1
- package/dist/core/provider-resilience.d.ts.map +1 -1
- package/dist/core/provider-resilience.js +3 -1
- package/dist/core/provider-resilience.js.map +1 -1
- package/dist/core/reasoning-router-v4-weights.d.ts.map +1 -1
- package/dist/core/reasoning-router-v4-weights.js +9 -3
- package/dist/core/reasoning-router-v4-weights.js.map +1 -1
- package/dist/core/reasoning-router-v4.d.ts.map +1 -1
- package/dist/core/reasoning-router-v4.js +94 -24
- package/dist/core/reasoning-router-v4.js.map +1 -1
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +11 -5
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/session-file-compaction.d.ts.map +1 -1
- package/dist/core/session-file-compaction.js +39 -5
- package/dist/core/session-file-compaction.js.map +1 -1
- package/dist/core/session-termination.d.ts.map +1 -1
- package/dist/core/session-termination.js +1 -1
- package/dist/core/session-termination.js.map +1 -1
- package/dist/core/settings-manager.d.ts +3 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +8 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +36 -31
- package/dist/core/system-prompt.js.map +1 -1
- package/dist/core/tools/bash.d.ts +2 -0
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +38 -11
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +112 -11
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/control-panel-box.d.ts +27 -0
- package/dist/modes/interactive/components/control-panel-box.d.ts.map +1 -1
- package/dist/modes/interactive/components/control-panel-box.js +60 -8
- package/dist/modes/interactive/components/control-panel-box.js.map +1 -1
- package/dist/modes/interactive/components/control-panel-layout.d.ts.map +1 -1
- package/dist/modes/interactive/components/control-panel-layout.js +100 -45
- package/dist/modes/interactive/components/control-panel-layout.js.map +1 -1
- package/dist/modes/interactive/components/control-panel-runtime-status.d.ts +9 -0
- package/dist/modes/interactive/components/control-panel-runtime-status.d.ts.map +1 -1
- package/dist/modes/interactive/components/control-panel-runtime-status.js +10 -8
- package/dist/modes/interactive/components/control-panel-runtime-status.js.map +1 -1
- package/dist/modes/interactive/components/control-panel-sparkles.d.ts +5 -0
- package/dist/modes/interactive/components/control-panel-sparkles.d.ts.map +1 -1
- package/dist/modes/interactive/components/control-panel-sparkles.js +14 -9
- package/dist/modes/interactive/components/control-panel-sparkles.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts +10 -0
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +3 -3
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/status-sidebar.d.ts +49 -0
- package/dist/modes/interactive/components/status-sidebar.d.ts.map +1 -0
- package/dist/modes/interactive/components/status-sidebar.js +253 -0
- package/dist/modes/interactive/components/status-sidebar.js.map +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts +9 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +94 -20
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/theme/omk-neon-control.json +13 -13
- package/dist/modes/print-mode.d.ts.map +1 -1
- package/dist/modes/print-mode.js +9 -0
- package/dist/modes/print-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +1 -0
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/utils/clipboard.d.ts.map +1 -1
- package/dist/utils/clipboard.js +19 -8
- package/dist/utils/clipboard.js.map +1 -1
- package/docs/adaptorch-preview-spec.md +174 -0
- package/docs/adaptorch-preview.md +1 -1
- package/docs/compaction.md +20 -13
- package/docs/correctness-wall.md +13 -5
- package/docs/custom-provider.md +8 -8
- package/docs/development.md +2 -2
- package/docs/docs.json +4 -0
- package/docs/environment-variables.md +90 -0
- package/docs/extensions.md +8 -8
- package/docs/index.md +1 -0
- package/docs/json.md +4 -4
- package/docs/providers.md +1 -1
- package/docs/rpc.md +5 -2
- package/docs/session-format.md +5 -5
- package/docs/settings.md +1 -0
- package/docs/skills.md +2 -0
- package/docs/themes.md +2 -2
- package/docs/tui.md +4 -4
- package/docs/usage.md +4 -0
- package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/gondolin/package-lock.json +2 -2
- package/examples/extensions/gondolin/package.json +1 -1
- package/examples/extensions/sandbox/package-lock.json +2 -2
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/subagent/README.md +35 -10
- package/examples/extensions/subagent/adaptive-agent-runtime.test.ts +234 -0
- package/examples/extensions/subagent/adaptive-agent-runtime.ts +240 -0
- package/examples/extensions/subagent/adaptive-result.ts +112 -0
- package/examples/extensions/subagent/agents.test.ts +86 -0
- package/examples/extensions/subagent/agents.ts +18 -4
- package/examples/extensions/subagent/capabilities.ts +15 -1
- package/examples/extensions/subagent/checkpoint-runtime.test.ts +125 -0
- package/examples/extensions/subagent/checkpoint-runtime.ts +198 -0
- package/examples/extensions/subagent/deadline-budget.test.ts +135 -0
- package/examples/extensions/subagent/deadline-budget.ts +242 -0
- package/examples/extensions/subagent/deadline-profile-store.test.ts +72 -0
- package/examples/extensions/subagent/deadline-profile-store.ts +172 -0
- package/examples/extensions/subagent/domain-profiles.ts +272 -0
- package/examples/extensions/subagent/fixtures/fake-omk-json.mjs +13 -0
- package/examples/extensions/subagent/fixtures/hanging-process-tree.mjs +40 -0
- package/examples/extensions/subagent/index.ts +253 -137
- package/examples/extensions/subagent/managed-process.test.ts +178 -0
- package/examples/extensions/subagent/managed-process.ts +236 -0
- package/examples/extensions/subagent/subagent-extension-smoke.test.ts +112 -0
- package/examples/extensions/subagent/subagent-runtime-types.ts +76 -0
- package/examples/extensions/with-deps/package-lock.json +2 -2
- package/examples/extensions/with-deps/package.json +1 -1
- package/npm-shrinkwrap.json +21 -21
- package/package.json +10 -7
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { runManagedProcess } from "./managed-process.ts";
|
|
5
|
+
|
|
6
|
+
const fixturePath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "hanging-process-tree.mjs");
|
|
7
|
+
|
|
8
|
+
async function processExists(pid: number): Promise<boolean> {
|
|
9
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
10
|
+
try {
|
|
11
|
+
process.kill(pid, 0);
|
|
12
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("managed subagent process lifecycle", () => {
|
|
21
|
+
it("escalates a cutoff from SIGTERM to SIGKILL and reaps the process tree", async () => {
|
|
22
|
+
let stdout = "";
|
|
23
|
+
const result = await runManagedProcess({
|
|
24
|
+
command: process.execPath,
|
|
25
|
+
args: [fixturePath],
|
|
26
|
+
cwd: process.cwd(),
|
|
27
|
+
// Generous cutoff: the fixture is a fresh `node` process that needs
|
|
28
|
+
// ~50-100ms+ (more under parallel load) to start and write its stdout
|
|
29
|
+
// handshake before the cutoff may fire, otherwise stdout is empty.
|
|
30
|
+
cutoffMs: 2_500,
|
|
31
|
+
terminationGraceMs: 40,
|
|
32
|
+
forceSettleMs: 1_000,
|
|
33
|
+
onStdout: (chunk) => {
|
|
34
|
+
stdout += chunk;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const event = JSON.parse(stdout.trim()) as { grandchildPid: number };
|
|
38
|
+
|
|
39
|
+
expect(result.reason).toBe("cutoff");
|
|
40
|
+
expect(result.cleanup.termSent).toBe(true);
|
|
41
|
+
expect(result.cleanup.killSent).toBe(true);
|
|
42
|
+
expect(result.elapsedMs).toBeLessThan(8_000);
|
|
43
|
+
expect(await processExists(result.pid)).toBe(false);
|
|
44
|
+
if (process.platform !== "win32") {
|
|
45
|
+
expect(await processExists(event.grandchildPid)).toBe(false);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("kills resistant descendants even when the direct child exits on SIGTERM", async () => {
|
|
50
|
+
let stdout = "";
|
|
51
|
+
const result = await runManagedProcess({
|
|
52
|
+
command: process.execPath,
|
|
53
|
+
args: [fixturePath, "--parent-exits"],
|
|
54
|
+
cwd: process.cwd(),
|
|
55
|
+
// Generous cutoff so the fixture's stdout handshake lands before the
|
|
56
|
+
// cutoff fires (see note above).
|
|
57
|
+
cutoffMs: 2_500,
|
|
58
|
+
terminationGraceMs: 500,
|
|
59
|
+
forceSettleMs: 1_000,
|
|
60
|
+
onStdout: (chunk) => {
|
|
61
|
+
stdout += chunk;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const event = JSON.parse(stdout.trim()) as { grandchildPid: number };
|
|
65
|
+
|
|
66
|
+
expect(result.reason).toBe("cutoff");
|
|
67
|
+
expect(result.cleanup).toMatchObject({ termSent: true, killSent: true });
|
|
68
|
+
if (process.platform !== "win32") {
|
|
69
|
+
expect(await processExists(event.grandchildPid)).toBe(false);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it.skipIf(process.platform === "win32")("reaps resistant descendants left by a normally exiting child", async () => {
|
|
74
|
+
let stdout = "";
|
|
75
|
+
const result = await runManagedProcess({
|
|
76
|
+
command: process.execPath,
|
|
77
|
+
args: [fixturePath, "--normal-orphan"],
|
|
78
|
+
cwd: process.cwd(),
|
|
79
|
+
cutoffMs: 4_000,
|
|
80
|
+
// Wider grace gives the orphaned, SIGTERM-resistant grandchild a stable
|
|
81
|
+
// window so the process-group existence check (and SIGKILL escalation)
|
|
82
|
+
// is deterministic under parallel load.
|
|
83
|
+
terminationGraceMs: 300,
|
|
84
|
+
forceSettleMs: 500,
|
|
85
|
+
onStdout: (chunk) => {
|
|
86
|
+
stdout += chunk;
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const event = JSON.parse(stdout.trim()) as { grandchildPid: number };
|
|
90
|
+
try {
|
|
91
|
+
expect(result).toMatchObject({
|
|
92
|
+
exitCode: 0,
|
|
93
|
+
reason: "completed",
|
|
94
|
+
cleanup: { termSent: true, killSent: true, processGroup: true },
|
|
95
|
+
});
|
|
96
|
+
expect(await processExists(event.grandchildPid)).toBe(false);
|
|
97
|
+
} finally {
|
|
98
|
+
try {
|
|
99
|
+
process.kill(event.grandchildPid, "SIGKILL");
|
|
100
|
+
} catch {
|
|
101
|
+
// Expected when managed cleanup reaped the descendant.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("distinguishes parent cancellation from an internal cutoff", async () => {
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
setTimeout(() => controller.abort(new Error("caller canceled")), 40);
|
|
109
|
+
const result = await runManagedProcess({
|
|
110
|
+
command: process.execPath,
|
|
111
|
+
args: [fixturePath],
|
|
112
|
+
cwd: process.cwd(),
|
|
113
|
+
cutoffMs: 5_000,
|
|
114
|
+
terminationGraceMs: 20,
|
|
115
|
+
forceSettleMs: 1_000,
|
|
116
|
+
signal: controller.signal,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(result.reason).toBe("aborted");
|
|
120
|
+
expect(result.cleanup.termSent).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns typed spawn failures instead of rejecting past cleanup boundaries", async () => {
|
|
124
|
+
const result = await runManagedProcess({
|
|
125
|
+
command: "missing",
|
|
126
|
+
args: [],
|
|
127
|
+
cwd: process.cwd(),
|
|
128
|
+
cutoffMs: 1_000,
|
|
129
|
+
spawnProcess: () => {
|
|
130
|
+
throw new Error("fixture spawn failure");
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result).toMatchObject({
|
|
135
|
+
pid: -1,
|
|
136
|
+
exitCode: 1,
|
|
137
|
+
reason: "spawn-error",
|
|
138
|
+
errorMessage: "fixture spawn failure",
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("does not spawn when the parent signal is already aborted", async () => {
|
|
143
|
+
const controller = new AbortController();
|
|
144
|
+
controller.abort(new Error("already canceled"));
|
|
145
|
+
let spawned = false;
|
|
146
|
+
const result = await runManagedProcess({
|
|
147
|
+
command: process.execPath,
|
|
148
|
+
args: [fixturePath],
|
|
149
|
+
cwd: process.cwd(),
|
|
150
|
+
cutoffMs: 1_000,
|
|
151
|
+
signal: controller.signal,
|
|
152
|
+
spawnProcess: () => {
|
|
153
|
+
spawned = true;
|
|
154
|
+
throw new Error("must not spawn");
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(spawned).toBe(false);
|
|
159
|
+
expect(result).toMatchObject({ pid: -1, exitCode: 130, reason: "aborted" });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("leaves a normally completed process untouched", async () => {
|
|
163
|
+
let stdout = "";
|
|
164
|
+
const result = await runManagedProcess({
|
|
165
|
+
command: process.execPath,
|
|
166
|
+
args: ["-e", "process.stdout.write('done')"],
|
|
167
|
+
cwd: process.cwd(),
|
|
168
|
+
cutoffMs: 1_000,
|
|
169
|
+
onStdout: (chunk) => {
|
|
170
|
+
stdout += chunk;
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(result).toMatchObject({ reason: "completed", exitCode: 0 });
|
|
175
|
+
expect(result.cleanup).toEqual({ termSent: false, killSent: false, processGroup: process.platform !== "win32" });
|
|
176
|
+
expect(stdout).toBe("done");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChildProcessByStdio,
|
|
3
|
+
type SpawnOptionsWithStdioTuple,
|
|
4
|
+
type StdioNull,
|
|
5
|
+
type StdioPipe,
|
|
6
|
+
spawn,
|
|
7
|
+
} from "node:child_process";
|
|
8
|
+
import type { Readable } from "node:stream";
|
|
9
|
+
|
|
10
|
+
type ManagedChild = ChildProcessByStdio<null, Readable, Readable>;
|
|
11
|
+
type ManagedSpawnOptions = SpawnOptionsWithStdioTuple<StdioNull, StdioPipe, StdioPipe>;
|
|
12
|
+
|
|
13
|
+
export type ManagedProcessReason = "completed" | "cutoff" | "aborted" | "spawn-error";
|
|
14
|
+
|
|
15
|
+
export interface ManagedProcessCleanup {
|
|
16
|
+
readonly termSent: boolean;
|
|
17
|
+
readonly killSent: boolean;
|
|
18
|
+
readonly processGroup: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ManagedProcessResult {
|
|
22
|
+
readonly pid: number;
|
|
23
|
+
readonly exitCode: number;
|
|
24
|
+
readonly signal: NodeJS.Signals | null;
|
|
25
|
+
readonly reason: ManagedProcessReason;
|
|
26
|
+
readonly elapsedMs: number;
|
|
27
|
+
readonly cleanup: ManagedProcessCleanup;
|
|
28
|
+
readonly errorMessage?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RunManagedProcessOptions {
|
|
32
|
+
readonly command: string;
|
|
33
|
+
readonly args: readonly string[];
|
|
34
|
+
readonly cwd: string;
|
|
35
|
+
readonly cutoffMs: number;
|
|
36
|
+
readonly terminationGraceMs?: number;
|
|
37
|
+
readonly forceSettleMs?: number;
|
|
38
|
+
readonly signal?: AbortSignal;
|
|
39
|
+
readonly onStdout?: (chunk: string) => void;
|
|
40
|
+
readonly onStderr?: (chunk: string) => void;
|
|
41
|
+
readonly spawnProcess?: (command: string, args: readonly string[], options: ManagedSpawnOptions) => ManagedChild;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_TERMINATION_GRACE_MS = 1_500;
|
|
45
|
+
const DEFAULT_FORCE_SETTLE_MS = 2_000;
|
|
46
|
+
|
|
47
|
+
export async function runManagedProcess(options: RunManagedProcessOptions): Promise<ManagedProcessResult> {
|
|
48
|
+
const startedAtMs = Date.now();
|
|
49
|
+
const processGroup = process.platform !== "win32";
|
|
50
|
+
if (options.signal?.aborted) return withoutChild("aborted", startedAtMs, processGroup);
|
|
51
|
+
const spawnProcess = options.spawnProcess ?? defaultSpawn;
|
|
52
|
+
let child: ManagedChild;
|
|
53
|
+
try {
|
|
54
|
+
child = spawnProcess(options.command, options.args, {
|
|
55
|
+
cwd: options.cwd,
|
|
56
|
+
shell: false,
|
|
57
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
58
|
+
detached: processGroup,
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return withoutChild(
|
|
63
|
+
"spawn-error",
|
|
64
|
+
startedAtMs,
|
|
65
|
+
processGroup,
|
|
66
|
+
error instanceof Error ? error.message : String(error),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const pid = child.pid ?? -1;
|
|
70
|
+
|
|
71
|
+
return await new Promise<ManagedProcessResult>((resolve) => {
|
|
72
|
+
let settled = false;
|
|
73
|
+
let reason: ManagedProcessReason = "completed";
|
|
74
|
+
let errorMessage: string | undefined;
|
|
75
|
+
let termSent = false;
|
|
76
|
+
let killSent = false;
|
|
77
|
+
let cutoffTimer: NodeJS.Timeout | undefined;
|
|
78
|
+
let escalationTimer: NodeJS.Timeout | undefined;
|
|
79
|
+
let forceSettleTimer: NodeJS.Timeout | undefined;
|
|
80
|
+
|
|
81
|
+
const cleanup = (): void => {
|
|
82
|
+
if (cutoffTimer !== undefined) clearTimeout(cutoffTimer);
|
|
83
|
+
if (escalationTimer !== undefined) clearTimeout(escalationTimer);
|
|
84
|
+
if (forceSettleTimer !== undefined) clearTimeout(forceSettleTimer);
|
|
85
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const settle = (exitCode: number | null, processSignal: NodeJS.Signals | null): void => {
|
|
89
|
+
if (settled) return;
|
|
90
|
+
settled = true;
|
|
91
|
+
cleanup();
|
|
92
|
+
child.stdout.destroy();
|
|
93
|
+
child.stderr.destroy();
|
|
94
|
+
resolve({
|
|
95
|
+
pid,
|
|
96
|
+
exitCode: normalizeExitCode(exitCode, reason),
|
|
97
|
+
signal: processSignal,
|
|
98
|
+
reason,
|
|
99
|
+
elapsedMs: Date.now() - startedAtMs,
|
|
100
|
+
cleanup: { termSent, killSent, processGroup },
|
|
101
|
+
...(errorMessage === undefined ? {} : { errorMessage }),
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const requestTermination = (nextReason: "cutoff" | "aborted"): void => {
|
|
106
|
+
if (settled || reason !== "completed") return;
|
|
107
|
+
reason = nextReason;
|
|
108
|
+
termSent = true;
|
|
109
|
+
signalProcessTree(child, "SIGTERM", processGroup);
|
|
110
|
+
const graceMs = Math.max(0, options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS);
|
|
111
|
+
escalationTimer = setTimeout(() => {
|
|
112
|
+
if (settled) return;
|
|
113
|
+
killSent = true;
|
|
114
|
+
signalProcessTree(child, "SIGKILL", processGroup);
|
|
115
|
+
}, graceMs);
|
|
116
|
+
forceSettleTimer = setTimeout(
|
|
117
|
+
() => settle(null, null),
|
|
118
|
+
graceMs + Math.max(1, options.forceSettleMs ?? DEFAULT_FORCE_SETTLE_MS),
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function onAbort(): void {
|
|
123
|
+
requestTermination("aborted");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
child.stdout.setEncoding("utf8");
|
|
127
|
+
child.stderr.setEncoding("utf8");
|
|
128
|
+
child.stdout.on("data", (chunk: string) => options.onStdout?.(chunk));
|
|
129
|
+
child.stderr.on("data", (chunk: string) => options.onStderr?.(chunk));
|
|
130
|
+
child.once("error", (error: Error) => {
|
|
131
|
+
if (reason === "completed") reason = "spawn-error";
|
|
132
|
+
errorMessage = error.message;
|
|
133
|
+
settle(1, null);
|
|
134
|
+
});
|
|
135
|
+
child.once("close", (code: number | null, processSignal: NodeJS.Signals | null) => {
|
|
136
|
+
if ((reason === "cutoff" || reason === "aborted") && termSent && !killSent) {
|
|
137
|
+
killSent = true;
|
|
138
|
+
signalProcessTree(child, "SIGKILL", processGroup);
|
|
139
|
+
settle(code, processSignal);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (reason === "completed" && processGroupExists(pid)) {
|
|
143
|
+
if (cutoffTimer !== undefined) clearTimeout(cutoffTimer);
|
|
144
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
145
|
+
termSent = true;
|
|
146
|
+
signalProcessTree(child, "SIGTERM", processGroup);
|
|
147
|
+
const graceMs = Math.max(0, options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS);
|
|
148
|
+
escalationTimer = setTimeout(() => {
|
|
149
|
+
if (processGroupExists(pid)) {
|
|
150
|
+
killSent = true;
|
|
151
|
+
signalProcessTree(child, "SIGKILL", processGroup);
|
|
152
|
+
}
|
|
153
|
+
settle(code, processSignal);
|
|
154
|
+
}, graceMs);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
settle(code, processSignal);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (options.cutoffMs > 0) {
|
|
161
|
+
cutoffTimer = setTimeout(() => requestTermination("cutoff"), options.cutoffMs);
|
|
162
|
+
}
|
|
163
|
+
if (options.signal?.aborted) onAbort();
|
|
164
|
+
else options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function withoutChild(
|
|
169
|
+
reason: "aborted" | "spawn-error",
|
|
170
|
+
startedAtMs: number,
|
|
171
|
+
processGroup: boolean,
|
|
172
|
+
errorMessage?: string,
|
|
173
|
+
): ManagedProcessResult {
|
|
174
|
+
return {
|
|
175
|
+
pid: -1,
|
|
176
|
+
exitCode: reason === "aborted" ? 130 : 1,
|
|
177
|
+
signal: null,
|
|
178
|
+
reason,
|
|
179
|
+
elapsedMs: Date.now() - startedAtMs,
|
|
180
|
+
cleanup: { termSent: false, killSent: false, processGroup },
|
|
181
|
+
...(errorMessage === undefined ? {} : { errorMessage }),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function defaultSpawn(command: string, args: readonly string[], options: ManagedSpawnOptions): ManagedChild {
|
|
186
|
+
return spawn(command, [...args], options);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function processGroupExists(pid: number): boolean {
|
|
190
|
+
if (process.platform === "win32" || pid < 1) return false;
|
|
191
|
+
try {
|
|
192
|
+
process.kill(-pid, 0);
|
|
193
|
+
return true;
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function signalProcessTree(child: ManagedChild, signal: "SIGTERM" | "SIGKILL", processGroup: boolean): void {
|
|
200
|
+
const pid = child.pid;
|
|
201
|
+
if (pid === undefined) return;
|
|
202
|
+
if (process.platform === "win32") {
|
|
203
|
+
if (signal === "SIGKILL") {
|
|
204
|
+
try {
|
|
205
|
+
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
|
206
|
+
detached: true,
|
|
207
|
+
stdio: "ignore",
|
|
208
|
+
windowsHide: true,
|
|
209
|
+
}).unref();
|
|
210
|
+
return;
|
|
211
|
+
} catch {
|
|
212
|
+
child.kill(signal);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
child.kill(signal);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
process.kill(processGroup ? -pid : pid, signal);
|
|
221
|
+
} catch {
|
|
222
|
+
try {
|
|
223
|
+
child.kill(signal);
|
|
224
|
+
} catch {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function normalizeExitCode(exitCode: number | null, reason: ManagedProcessReason): number {
|
|
231
|
+
if (exitCode !== null) return exitCode;
|
|
232
|
+
if (reason === "completed") return 0;
|
|
233
|
+
if (reason === "cutoff") return 124;
|
|
234
|
+
if (reason === "aborted") return 130;
|
|
235
|
+
return 1;
|
|
236
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { ExtensionAPI } from "open-multi-agent-kit";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
interface SmokeToolResult {
|
|
8
|
+
readonly content: readonly { readonly type: string; readonly text?: string }[];
|
|
9
|
+
readonly details?: unknown;
|
|
10
|
+
readonly isError?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SmokeTool {
|
|
14
|
+
execute(
|
|
15
|
+
toolCallId: string,
|
|
16
|
+
params: unknown,
|
|
17
|
+
signal: AbortSignal,
|
|
18
|
+
onUpdate: undefined,
|
|
19
|
+
context: unknown,
|
|
20
|
+
): Promise<SmokeToolResult>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SmokeDetails {
|
|
24
|
+
readonly mode: string;
|
|
25
|
+
readonly results: readonly {
|
|
26
|
+
readonly output?: string;
|
|
27
|
+
readonly exitCode: number;
|
|
28
|
+
readonly deadline?: { readonly outcome: string; readonly attempts: readonly unknown[] };
|
|
29
|
+
}[];
|
|
30
|
+
readonly executionBudget: {
|
|
31
|
+
readonly hardDeadlineMs: number;
|
|
32
|
+
readonly plannedShards: number;
|
|
33
|
+
readonly completedShards: number;
|
|
34
|
+
readonly resumeCount: number;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const cleanupPaths: string[] = [];
|
|
39
|
+
const originalArgvEntry = process.argv[1];
|
|
40
|
+
const originalAgentDir = process.env.OMK_CODING_AGENT_DIR;
|
|
41
|
+
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
process.argv[1] = originalArgvEntry;
|
|
44
|
+
if (originalAgentDir === undefined) delete process.env.OMK_CODING_AGENT_DIR;
|
|
45
|
+
else process.env.OMK_CODING_AGENT_DIR = originalAgentDir;
|
|
46
|
+
await Promise.all(cleanupPaths.splice(0).map((entry) => fs.promises.rm(entry, { recursive: true, force: true })));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("subagent extension registration and spawn smoke", () => {
|
|
50
|
+
it("runs the registered tool through the managed process boundary without a provider API", async () => {
|
|
51
|
+
const cwd = await fs.promises.mkdtemp(path.join(os.tmpdir(), "omk-subagent-extension-smoke-"));
|
|
52
|
+
cleanupPaths.push(cwd);
|
|
53
|
+
const projectAgentsDir = path.join(cwd, ".omk", "agents");
|
|
54
|
+
const agentHome = path.join(cwd, "agent-home");
|
|
55
|
+
await fs.promises.mkdir(projectAgentsDir, { recursive: true });
|
|
56
|
+
await fs.promises.mkdir(agentHome, { recursive: true });
|
|
57
|
+
await fs.promises.writeFile(
|
|
58
|
+
path.join(projectAgentsDir, "fixture-agent.md"),
|
|
59
|
+
"---\nname: fixture-agent\ndescription: Offline extension smoke fixture\n---\nReturn the fixture result.\n",
|
|
60
|
+
"utf8",
|
|
61
|
+
);
|
|
62
|
+
process.argv[1] = path.join(import.meta.dirname, "fixtures", "fake-omk-json.mjs");
|
|
63
|
+
process.env.OMK_CODING_AGENT_DIR = agentHome;
|
|
64
|
+
|
|
65
|
+
let registered: unknown;
|
|
66
|
+
const api = {
|
|
67
|
+
registerTool(tool: unknown) {
|
|
68
|
+
registered = tool;
|
|
69
|
+
},
|
|
70
|
+
} as unknown as ExtensionAPI;
|
|
71
|
+
const { default: registerSubagent } = await import("./index.ts");
|
|
72
|
+
registerSubagent(api);
|
|
73
|
+
if (!isSmokeTool(registered)) throw new Error("subagent tool was not registered");
|
|
74
|
+
|
|
75
|
+
const result = await registered.execute(
|
|
76
|
+
"fixture-call",
|
|
77
|
+
{
|
|
78
|
+
agent: "fixture-agent",
|
|
79
|
+
task: "Run the offline fixture.",
|
|
80
|
+
agentScope: "project",
|
|
81
|
+
confirmProjectAgents: false,
|
|
82
|
+
executionBudgetMs: 120_000,
|
|
83
|
+
maxResumeAttempts: 1,
|
|
84
|
+
},
|
|
85
|
+
new AbortController().signal,
|
|
86
|
+
undefined,
|
|
87
|
+
{ cwd, hasUI: false },
|
|
88
|
+
);
|
|
89
|
+
const details = result.details as SmokeDetails;
|
|
90
|
+
|
|
91
|
+
expect(result.isError).not.toBe(true);
|
|
92
|
+
expect(result.content[0]?.text).toContain("fixture subagent completed");
|
|
93
|
+
expect(details.mode).toBe("single");
|
|
94
|
+
expect(details.results[0]).toMatchObject({
|
|
95
|
+
exitCode: 0,
|
|
96
|
+
output: "fixture subagent completed",
|
|
97
|
+
deadline: { outcome: "completed" },
|
|
98
|
+
});
|
|
99
|
+
expect(details.results[0]?.deadline?.attempts).toHaveLength(1);
|
|
100
|
+
expect(details.executionBudget).toMatchObject({
|
|
101
|
+
hardDeadlineMs: 120_000,
|
|
102
|
+
plannedShards: 1,
|
|
103
|
+
completedShards: 1,
|
|
104
|
+
resumeCount: 0,
|
|
105
|
+
});
|
|
106
|
+
expect(await fs.promises.stat(path.join(agentHome, "state", "subagent-deadline-profiles.json"))).toBeDefined();
|
|
107
|
+
}, 120_000);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
function isSmokeTool(value: unknown): value is SmokeTool {
|
|
111
|
+
return typeof value === "object" && value !== null && "execute" in value && typeof value.execute === "function";
|
|
112
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Message } from "omk-ai";
|
|
2
|
+
import type { AgentCheckpoint } from "./checkpoint-runtime.ts";
|
|
3
|
+
import type { ManagedProcessCleanup, ManagedProcessReason } from "./managed-process.ts";
|
|
4
|
+
|
|
5
|
+
export interface UsageStats {
|
|
6
|
+
input: number;
|
|
7
|
+
output: number;
|
|
8
|
+
cacheRead: number;
|
|
9
|
+
cacheWrite: number;
|
|
10
|
+
cost: number;
|
|
11
|
+
contextTokens: number;
|
|
12
|
+
turns: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type DeadlineOutcome = "completed" | "cutoff" | "aborted" | "failed" | "budget-exhausted";
|
|
16
|
+
|
|
17
|
+
export interface DeadlineAttemptMetadata {
|
|
18
|
+
readonly attempt: number;
|
|
19
|
+
readonly shardId: string;
|
|
20
|
+
readonly cutoffMs: number;
|
|
21
|
+
readonly elapsedMs: number;
|
|
22
|
+
readonly outcome: Exclude<DeadlineOutcome, "budget-exhausted">;
|
|
23
|
+
readonly exitCode: number;
|
|
24
|
+
readonly messageCount: number;
|
|
25
|
+
readonly processReason: ManagedProcessReason;
|
|
26
|
+
readonly cleanup: ManagedProcessCleanup;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AgentDeadlineMetadata {
|
|
30
|
+
readonly outcome: DeadlineOutcome;
|
|
31
|
+
readonly startedAtMs: number;
|
|
32
|
+
readonly elapsedMs: number;
|
|
33
|
+
readonly hardDeadlineMs: number;
|
|
34
|
+
readonly estimatedTokens: number;
|
|
35
|
+
readonly workUnits: number;
|
|
36
|
+
readonly predictedMs: number;
|
|
37
|
+
readonly plannedShardIds: readonly string[];
|
|
38
|
+
readonly completedShardIds: readonly string[];
|
|
39
|
+
readonly remainingShardIds: readonly string[];
|
|
40
|
+
readonly resumeCount: number;
|
|
41
|
+
readonly duplicateResumeBlocked: boolean;
|
|
42
|
+
readonly provider: string;
|
|
43
|
+
readonly model: string;
|
|
44
|
+
readonly profileSamples: number;
|
|
45
|
+
readonly attempts: readonly DeadlineAttemptMetadata[];
|
|
46
|
+
readonly checkpoint?: AgentCheckpoint;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SingleResult {
|
|
50
|
+
agent: string;
|
|
51
|
+
agentSource: "user" | "project" | "unknown";
|
|
52
|
+
task: string;
|
|
53
|
+
exitCode: number;
|
|
54
|
+
messages: Message[];
|
|
55
|
+
stderr: string;
|
|
56
|
+
usage: UsageStats;
|
|
57
|
+
output?: string;
|
|
58
|
+
model?: string;
|
|
59
|
+
stopReason?: string;
|
|
60
|
+
errorMessage?: string;
|
|
61
|
+
step?: number;
|
|
62
|
+
deadline?: AgentDeadlineMetadata;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SubagentAttemptResult {
|
|
66
|
+
readonly result: SingleResult;
|
|
67
|
+
readonly process: {
|
|
68
|
+
readonly reason: ManagedProcessReason;
|
|
69
|
+
readonly elapsedMs: number;
|
|
70
|
+
readonly cleanup: ManagedProcessCleanup;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function emptyUsage(): UsageStats {
|
|
75
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
76
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omk-extension-with-deps",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "omk-extension-with-deps",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.93.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"ms": "2.1.3"
|
|
12
12
|
},
|