@fusionkit/tools 0.1.6

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.
@@ -0,0 +1,17 @@
1
+ import type { JsonValue } from "@fusionkit/protocol";
2
+ import type { EnsembleDescriptor, EnsembleModel, HarnessCandidateOutput } from "@fusionkit/ensemble";
3
+ /**
4
+ * Build the standard "skipped" candidate output shared by the per-tool harness
5
+ * adapters (a capability gate failed, the binary was missing, the runner threw,
6
+ * ...). Each adapter supplies its lowercase `adapter` id, the human transcript,
7
+ * and any adapter-specific metadata.
8
+ */
9
+ export declare function buildSkippedCandidate(input: {
10
+ descriptor: EnsembleDescriptor;
11
+ model: EnsembleModel;
12
+ ordinal: number;
13
+ reason: string;
14
+ adapter: string;
15
+ transcript: string;
16
+ metadata?: Record<string, JsonValue>;
17
+ }): HarnessCandidateOutput;
@@ -0,0 +1,40 @@
1
+ import { artifactHash } from "@fusionkit/protocol";
2
+ /**
3
+ * Build the standard "skipped" candidate output shared by the per-tool harness
4
+ * adapters (a capability gate failed, the binary was missing, the runner threw,
5
+ * ...). Each adapter supplies its lowercase `adapter` id, the human transcript,
6
+ * and any adapter-specific metadata.
7
+ */
8
+ export function buildSkippedCandidate(input) {
9
+ const { descriptor, model, ordinal, reason, adapter, transcript } = input;
10
+ const hash = artifactHash(transcript);
11
+ return {
12
+ candidateId: `${descriptor.id}_${model.id}_${ordinal}`,
13
+ model,
14
+ status: "skipped",
15
+ transcript,
16
+ log: transcript,
17
+ artifacts: [
18
+ {
19
+ artifact_id: `artifact_${descriptor.id}_${model.id}_${adapter}_skip`,
20
+ kind: "log",
21
+ hash,
22
+ redaction_status: "synthetic"
23
+ }
24
+ ],
25
+ verification: {
26
+ status: "skipped",
27
+ evidence: [reason]
28
+ },
29
+ error: {
30
+ kind: "capability_missing",
31
+ message: reason,
32
+ retryable: false
33
+ },
34
+ metadata: {
35
+ adapter,
36
+ ...input.metadata,
37
+ skip_reason: reason
38
+ }
39
+ };
40
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Shared brand/model constants used by the launchers and the Cursor bridge, kept
3
+ * in one place so the value is defined once rather than copied per tool package.
4
+ */
5
+ /** Provider/model label a tool advertises for the gateway-backed local model. */
6
+ export declare const LOCAL_MODEL_LABEL = "fusionkit-local";
7
+ /** The model name the Cursor bridge exposes to cursor-agent. */
8
+ export declare const CURSOR_BRIDGE_MODEL_NAME = "local-fusion";
9
+ /** The model label the fusion panel is fronted under. */
10
+ export declare const FUSION_PANEL_MODEL = "fusion-panel";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Shared brand/model constants used by the launchers and the Cursor bridge, kept
3
+ * in one place so the value is defined once rather than copied per tool package.
4
+ */
5
+ /** Provider/model label a tool advertises for the gateway-backed local model. */
6
+ export const LOCAL_MODEL_LABEL = "fusionkit-local";
7
+ /** The model name the Cursor bridge exposes to cursor-agent. */
8
+ export const CURSOR_BRIDGE_MODEL_NAME = "local-fusion";
9
+ /** The model label the fusion panel is fronted under. */
10
+ export const FUSION_PANEL_MODEL = "fusion-panel";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Naming compatibility: the project is standardizing on `FUSIONKIT_*` env vars
3
+ * but still accepts the legacy `WARRANT_*` names. Read canonical names through
4
+ * these helpers so both keep working; prefer the canonical name in new code,
5
+ * docs, and help text.
6
+ */
7
+ type Env = Record<string, string | undefined>;
8
+ /** The legacy `WARRANT_*` spelling of a canonical `FUSIONKIT_*` name, if any. */
9
+ export declare function legacyEnvName(name: string): string | undefined;
10
+ /** Read a canonical `FUSIONKIT_*` env var, falling back to the legacy `WARRANT_*` name. */
11
+ export declare function readEnv(env: Env, name: string): string | undefined;
12
+ /** True when the canonical or legacy env flag is set to `1`/`true`. */
13
+ export declare function envFlagEnabled(env: Env, name: string): boolean;
14
+ export {};
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Naming compatibility: the project is standardizing on `FUSIONKIT_*` env vars
3
+ * but still accepts the legacy `WARRANT_*` names. Read canonical names through
4
+ * these helpers so both keep working; prefer the canonical name in new code,
5
+ * docs, and help text.
6
+ */
7
+ const CANONICAL_ENV_PREFIX = "FUSIONKIT_";
8
+ const LEGACY_ENV_PREFIX = "WARRANT_";
9
+ /** The legacy `WARRANT_*` spelling of a canonical `FUSIONKIT_*` name, if any. */
10
+ export function legacyEnvName(name) {
11
+ return name.startsWith(CANONICAL_ENV_PREFIX)
12
+ ? LEGACY_ENV_PREFIX + name.slice(CANONICAL_ENV_PREFIX.length)
13
+ : undefined;
14
+ }
15
+ /** Read a canonical `FUSIONKIT_*` env var, falling back to the legacy `WARRANT_*` name. */
16
+ export function readEnv(env, name) {
17
+ const direct = env[name];
18
+ if (direct !== undefined)
19
+ return direct;
20
+ const legacy = legacyEnvName(name);
21
+ return legacy !== undefined ? env[legacy] : undefined;
22
+ }
23
+ /** True when the canonical or legacy env flag is set to `1`/`true`. */
24
+ export function envFlagEnabled(env, name) {
25
+ const value = readEnv(env, name);
26
+ return value === "1" || value?.toLowerCase() === "true";
27
+ }
package/dist/env.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /** Shared environment helpers used by the per-tool harness/launch packages. */
2
+ type ToolEnv = Record<string, string | undefined>;
3
+ /** Drop `undefined` values so the result is a concrete `Record<string,string>`. */
4
+ export declare function definedEnv(env: ToolEnv): Record<string, string>;
5
+ /** Ensure an OpenAI-style base URL ends in `/v1` (trimming trailing slashes). */
6
+ export declare function normalizeApiBaseUrl(baseUrl: string): string;
7
+ /** Default env prefixes scrubbed before spawning a Cursorkit bridge. */
8
+ export declare const DEFAULT_BRIDGE_SCRUB_PREFIXES: readonly ["BRIDGE_", "MODEL_", "CURSOR_UPSTREAM"];
9
+ /**
10
+ * Copy `env` dropping `undefined` values and any key starting with one of
11
+ * `prefixes`, so a parent's leftover bridge/model config never leaks into a
12
+ * freshly spawned bridge process.
13
+ */
14
+ export declare function scrubBridgeEnv(env: ToolEnv, prefixes?: readonly string[]): Record<string, string>;
15
+ export {};
package/dist/env.js ADDED
@@ -0,0 +1,37 @@
1
+ /** Shared environment helpers used by the per-tool harness/launch packages. */
2
+ /** Drop `undefined` values so the result is a concrete `Record<string,string>`. */
3
+ export function definedEnv(env) {
4
+ const result = {};
5
+ for (const [key, value] of Object.entries(env)) {
6
+ if (value !== undefined)
7
+ result[key] = value;
8
+ }
9
+ return result;
10
+ }
11
+ /** Ensure an OpenAI-style base URL ends in `/v1` (trimming trailing slashes). */
12
+ export function normalizeApiBaseUrl(baseUrl) {
13
+ const trimmed = baseUrl.replace(/\/+$/, "");
14
+ return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
15
+ }
16
+ /** Default env prefixes scrubbed before spawning a Cursorkit bridge. */
17
+ export const DEFAULT_BRIDGE_SCRUB_PREFIXES = [
18
+ "BRIDGE_",
19
+ "MODEL_",
20
+ "CURSOR_UPSTREAM"
21
+ ];
22
+ /**
23
+ * Copy `env` dropping `undefined` values and any key starting with one of
24
+ * `prefixes`, so a parent's leftover bridge/model config never leaks into a
25
+ * freshly spawned bridge process.
26
+ */
27
+ export function scrubBridgeEnv(env, prefixes = DEFAULT_BRIDGE_SCRUB_PREFIXES) {
28
+ const result = {};
29
+ for (const [key, value] of Object.entries(env)) {
30
+ if (value === undefined)
31
+ continue;
32
+ if (prefixes.some((prefix) => key.startsWith(prefix)))
33
+ continue;
34
+ result[key] = value;
35
+ }
36
+ return result;
37
+ }
@@ -0,0 +1,9 @@
1
+ export { distillLog, freePort, sleep, spawnLogged, spawnTool, terminate, waitForHttp, waitForOutput } from "./proc.js";
2
+ export type { LoggedChild, LoggedSpawnOptions } from "./proc.js";
3
+ export type { ToolDashboardLiveSmoke, ToolDashboardMetadata, ToolDashboardSmoke, ToolHarnessMetadata, ToolIntegration, ToolLaunchContext, ToolLaunchMode } from "./types.js";
4
+ export { createToolRegistry } from "./registry.js";
5
+ export type { ToolRegistry } from "./registry.js";
6
+ export { CURSOR_BRIDGE_MODEL_NAME, FUSION_PANEL_MODEL, LOCAL_MODEL_LABEL } from "./constants.js";
7
+ export { envFlagEnabled, legacyEnvName, readEnv } from "./env-compat.js";
8
+ export { DEFAULT_BRIDGE_SCRUB_PREFIXES, definedEnv, normalizeApiBaseUrl, scrubBridgeEnv } from "./env.js";
9
+ export { buildSkippedCandidate } from "./candidate.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { distillLog, freePort, sleep, spawnLogged, spawnTool, terminate, waitForHttp, waitForOutput } from "./proc.js";
2
+ export { createToolRegistry } from "./registry.js";
3
+ export { CURSOR_BRIDGE_MODEL_NAME, FUSION_PANEL_MODEL, LOCAL_MODEL_LABEL } from "./constants.js";
4
+ export { envFlagEnabled, legacyEnvName, readEnv } from "./env-compat.js";
5
+ export { DEFAULT_BRIDGE_SCRUB_PREFIXES, definedEnv, normalizeApiBaseUrl, scrubBridgeEnv } from "./env.js";
6
+ export { buildSkippedCandidate } from "./candidate.js";
package/dist/proc.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { ChildProcess, SpawnOptions } from "node:child_process";
2
+ /** Shared process helpers for the CLI's launcher/gateway flows and tool packages. */
3
+ export declare function sleep(ms: number): Promise<void>;
4
+ /**
5
+ * Reserve an ephemeral loopback port and return it. The probe socket is closed
6
+ * before returning (children bind it themselves), but the number is held out of
7
+ * circulation briefly so concurrent callers do not collide. Retries a bounded
8
+ * number of times if the OS hands back a number we just reserved.
9
+ */
10
+ export declare function freePort(): Promise<number>;
11
+ /**
12
+ * Spawn a foreground tool with inherited stdio and resolve with its exit code.
13
+ * A spawn failure (e.g. binary not on PATH) rejects rather than emitting an
14
+ * unhandled `error` event.
15
+ */
16
+ export declare function spawnTool(command: string, args: string[], env: Record<string, string>, cwd?: string): Promise<number>;
17
+ export type LoggedSpawnOptions = SpawnOptions & {
18
+ /** Tee the child's full stdout+stderr to this path for post-mortem. */
19
+ logFile?: string;
20
+ /** Cap the in-memory ring buffer (default 256 KiB). */
21
+ maxLogBytes?: number;
22
+ };
23
+ /**
24
+ * A spawned background child with captured output and a recorded spawn error.
25
+ * Always attaches an `'error'` listener so a missing binary surfaces as a clear
26
+ * message via {@link waitForHttp} instead of crashing the process. The captured
27
+ * log is a bounded ring buffer (so long sessions cannot leak memory); the full,
28
+ * untruncated output is written to `logFile` when one is provided.
29
+ */
30
+ export type LoggedChild = {
31
+ child: ChildProcess;
32
+ /** The most recent captured stdout+stderr, up to the ring-buffer cap. */
33
+ log: () => string;
34
+ /** The spawn `'error'` (e.g. ENOENT), if one was emitted. */
35
+ spawnError: () => Error | undefined;
36
+ /** The full log file path, when teeing was requested. */
37
+ logFile: () => string | undefined;
38
+ /** Flush and close the log file stream (best-effort). */
39
+ closeLog: () => void;
40
+ };
41
+ export declare function spawnLogged(command: string, args: string[], options?: LoggedSpawnOptions): LoggedChild;
42
+ /**
43
+ * Distill the most useful slice of captured output for an error message. Prefers
44
+ * lines that look like errors (so the root cause is not buried under `uvx`
45
+ * resolve/build noise), then falls back to the head and tail of the log. The
46
+ * full log lives in the child's `logFile` when one was provided.
47
+ */
48
+ export declare function distillLog(raw: string, options?: {
49
+ maxLines?: number;
50
+ }): string;
51
+ /**
52
+ * Poll `probeUrl` until it answers (optionally requiring a 2xx), the child fails
53
+ * to spawn, the child exits, or the timeout elapses. Distinguishes a failed
54
+ * spawn ("uv: not found") from a slow start.
55
+ */
56
+ export declare function waitForHttp(probeUrl: string, proc: LoggedChild, options: {
57
+ timeoutMs: number;
58
+ label: string;
59
+ requireOk?: boolean;
60
+ }): Promise<void>;
61
+ /** Resolve once `pattern` is seen on the child's output, or reject on exit/timeout. */
62
+ export declare function waitForOutput(proc: LoggedChild, pattern: RegExp, options: {
63
+ timeoutMs: number;
64
+ label: string;
65
+ }): Promise<void>;
66
+ /**
67
+ * SIGTERM a child's whole process group, escalating to SIGKILL if it ignores the
68
+ * grace period. Killing the group (`process.kill(-pid, ...)`) tears down wrapper
69
+ * trees like `uvx -> uv -> python`; if the child was not spawned detached (no
70
+ * group), it falls back to signalling the child directly.
71
+ */
72
+ export declare function terminate(child: ChildProcess, graceMs?: number): void;
package/dist/proc.js ADDED
@@ -0,0 +1,230 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createWriteStream } from "node:fs";
3
+ import { createServer } from "node:net";
4
+ /** Shared process helpers for the CLI's launcher/gateway flows and tool packages. */
5
+ export function sleep(ms) {
6
+ return new Promise((resolve) => setTimeout(resolve, ms));
7
+ }
8
+ // Ports we have handed out very recently but whose child may not have bound
9
+ // yet. Holding them out of circulation for a short window closes the race where
10
+ // two concurrent `freePort()` callers (parallel server startup) receive the
11
+ // same number between the probe socket closing and the child binding.
12
+ const recentlyReserved = new Map();
13
+ const RESERVATION_MS = 5000;
14
+ function reserve(port) {
15
+ const existing = recentlyReserved.get(port);
16
+ if (existing !== undefined)
17
+ clearTimeout(existing);
18
+ const timer = setTimeout(() => recentlyReserved.delete(port), RESERVATION_MS);
19
+ timer.unref();
20
+ recentlyReserved.set(port, timer);
21
+ }
22
+ /**
23
+ * Reserve an ephemeral loopback port and return it. The probe socket is closed
24
+ * before returning (children bind it themselves), but the number is held out of
25
+ * circulation briefly so concurrent callers do not collide. Retries a bounded
26
+ * number of times if the OS hands back a number we just reserved.
27
+ */
28
+ export async function freePort() {
29
+ for (let attempt = 0; attempt < 20; attempt++) {
30
+ const port = await probeEphemeralPort();
31
+ if (!recentlyReserved.has(port)) {
32
+ reserve(port);
33
+ return port;
34
+ }
35
+ }
36
+ // Extremely unlikely; fall back to whatever the OS last offered.
37
+ const port = await probeEphemeralPort();
38
+ reserve(port);
39
+ return port;
40
+ }
41
+ function probeEphemeralPort() {
42
+ return new Promise((resolve, reject) => {
43
+ const server = createServer();
44
+ server.on("error", reject);
45
+ server.listen(0, "127.0.0.1", () => {
46
+ const address = server.address();
47
+ const port = typeof address === "object" && address !== null ? address.port : 0;
48
+ server.close(() => resolve(port));
49
+ });
50
+ });
51
+ }
52
+ /**
53
+ * Spawn a foreground tool with inherited stdio and resolve with its exit code.
54
+ * A spawn failure (e.g. binary not on PATH) rejects rather than emitting an
55
+ * unhandled `error` event.
56
+ */
57
+ export function spawnTool(command, args, env, cwd) {
58
+ return new Promise((resolveExit, reject) => {
59
+ const child = spawn(command, args, {
60
+ stdio: "inherit",
61
+ env: { ...process.env, ...env },
62
+ ...(cwd !== undefined ? { cwd } : {})
63
+ });
64
+ child.on("error", reject);
65
+ child.on("exit", (code) => resolveExit(code ?? 0));
66
+ });
67
+ }
68
+ /** Keep at most this many bytes of a child's captured output in memory. */
69
+ const DEFAULT_MAX_LOG_BYTES = 256 * 1024;
70
+ export function spawnLogged(command, args, options = {}) {
71
+ const { logFile, maxLogBytes, ...spawnOptions } = options;
72
+ const cap = maxLogBytes ?? DEFAULT_MAX_LOG_BYTES;
73
+ // `detached: true` makes the child its own process-group leader so that
74
+ // `terminate()` can signal the whole tree. This matters for wrappers like
75
+ // `uvx` (uvx -> uv -> python): signalling only the immediate child would
76
+ // orphan the grandchildren. Output is still piped; we never `unref()`, so the
77
+ // parent keeps managing the child's lifecycle.
78
+ const child = spawn(command, args, { ...spawnOptions, detached: true, stdio: ["ignore", "pipe", "pipe"] });
79
+ let buffer = "";
80
+ let spawnError;
81
+ let file;
82
+ if (logFile !== undefined) {
83
+ try {
84
+ file = createWriteStream(logFile, { flags: "a" });
85
+ // A broken log sink must never crash the run.
86
+ file.on("error", () => { });
87
+ }
88
+ catch {
89
+ file = undefined;
90
+ }
91
+ }
92
+ const onChunk = (chunk) => {
93
+ const text = chunk.toString("utf8");
94
+ file?.write(text);
95
+ buffer += text;
96
+ if (buffer.length > cap)
97
+ buffer = buffer.slice(buffer.length - cap);
98
+ };
99
+ child.stdout?.on("data", onChunk);
100
+ child.stderr?.on("data", onChunk);
101
+ child.on("error", (error) => (spawnError = error));
102
+ return {
103
+ child,
104
+ log: () => buffer,
105
+ spawnError: () => spawnError,
106
+ logFile: () => logFile,
107
+ closeLog: () => {
108
+ try {
109
+ file?.end();
110
+ }
111
+ catch {
112
+ // already closed
113
+ }
114
+ }
115
+ };
116
+ }
117
+ /**
118
+ * Distill the most useful slice of captured output for an error message. Prefers
119
+ * lines that look like errors (so the root cause is not buried under `uvx`
120
+ * resolve/build noise), then falls back to the head and tail of the log. The
121
+ * full log lives in the child's `logFile` when one was provided.
122
+ */
123
+ export function distillLog(raw, options = {}) {
124
+ const maxLines = options.maxLines ?? 16;
125
+ const lines = raw.split("\n").filter((line) => line.trim().length > 0);
126
+ if (lines.length === 0)
127
+ return "";
128
+ const errorPattern = /error|exception|traceback|fatal|denied|unauthorized|forbidden|invalid|not found|refused|timed? ?out|missing|failed|panic|429|401|403|500/i;
129
+ const errorLines = lines.filter((line) => errorPattern.test(line));
130
+ if (errorLines.length > 0) {
131
+ return errorLines.slice(-maxLines).join("\n");
132
+ }
133
+ if (lines.length <= maxLines)
134
+ return lines.join("\n");
135
+ const head = lines.slice(0, Math.ceil(maxLines / 2));
136
+ const tail = lines.slice(-Math.floor(maxLines / 2));
137
+ return [...head, "...", ...tail].join("\n");
138
+ }
139
+ function failureDetail(proc) {
140
+ const distilled = distillLog(proc.log());
141
+ const logPath = proc.logFile();
142
+ const pathNote = logPath !== undefined ? `\n(full log: ${logPath})` : "";
143
+ return `${distilled}${pathNote}`;
144
+ }
145
+ /**
146
+ * Poll `probeUrl` until it answers (optionally requiring a 2xx), the child fails
147
+ * to spawn, the child exits, or the timeout elapses. Distinguishes a failed
148
+ * spawn ("uv: not found") from a slow start.
149
+ */
150
+ export async function waitForHttp(probeUrl, proc, options) {
151
+ const deadline = Date.now() + options.timeoutMs;
152
+ let lastError = "";
153
+ while (Date.now() < deadline) {
154
+ const spawnError = proc.spawnError();
155
+ if (spawnError !== undefined) {
156
+ throw new Error(`${options.label} failed to start: ${spawnError.message}\n${failureDetail(proc)}`);
157
+ }
158
+ if (proc.child.exitCode !== null) {
159
+ throw new Error(`${options.label} exited (code ${proc.child.exitCode}) before becoming ready\n${failureDetail(proc)}`);
160
+ }
161
+ try {
162
+ const response = await fetch(probeUrl);
163
+ if (options.requireOk !== true || response.ok)
164
+ return;
165
+ lastError = `status ${response.status}`;
166
+ }
167
+ catch (error) {
168
+ lastError = error instanceof Error ? error.message : String(error);
169
+ }
170
+ await sleep(400);
171
+ }
172
+ throw new Error(`${options.label} did not become ready within ${options.timeoutMs}ms (${lastError})\n${failureDetail(proc)}`);
173
+ }
174
+ /** Resolve once `pattern` is seen on the child's output, or reject on exit/timeout. */
175
+ export function waitForOutput(proc, pattern, options) {
176
+ return new Promise((resolve, reject) => {
177
+ const deadline = setTimeout(() => {
178
+ cleanup();
179
+ reject(new Error(`${options.label} did not start within ${options.timeoutMs}ms:\n${failureDetail(proc)}`));
180
+ }, options.timeoutMs);
181
+ const poll = setInterval(() => {
182
+ if (proc.spawnError() !== undefined) {
183
+ cleanup();
184
+ reject(new Error(`${options.label} failed to start: ${proc.spawnError()?.message}\n${failureDetail(proc)}`));
185
+ }
186
+ else if (pattern.test(proc.log())) {
187
+ cleanup();
188
+ resolve();
189
+ }
190
+ }, 100);
191
+ const onExit = () => {
192
+ cleanup();
193
+ reject(new Error(`${options.label} exited before becoming ready:\n${failureDetail(proc)}`));
194
+ };
195
+ proc.child.once("exit", onExit);
196
+ function cleanup() {
197
+ clearTimeout(deadline);
198
+ clearInterval(poll);
199
+ proc.child.off("exit", onExit);
200
+ }
201
+ });
202
+ }
203
+ /**
204
+ * SIGTERM a child's whole process group, escalating to SIGKILL if it ignores the
205
+ * grace period. Killing the group (`process.kill(-pid, ...)`) tears down wrapper
206
+ * trees like `uvx -> uv -> python`; if the child was not spawned detached (no
207
+ * group), it falls back to signalling the child directly.
208
+ */
209
+ export function terminate(child, graceMs = 5000) {
210
+ if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null)
211
+ return;
212
+ const pid = child.pid;
213
+ const signal = (sig) => {
214
+ try {
215
+ process.kill(-pid, sig);
216
+ }
217
+ catch {
218
+ try {
219
+ child.kill(sig);
220
+ }
221
+ catch {
222
+ // already gone
223
+ }
224
+ }
225
+ };
226
+ signal("SIGTERM");
227
+ const timer = setTimeout(() => signal("SIGKILL"), graceMs);
228
+ timer.unref();
229
+ child.once("exit", () => clearTimeout(timer));
230
+ }
@@ -0,0 +1,31 @@
1
+ import type { HarnessAdapter, ToolHarnessResolveOptions, UnifiedHarnessKind } from "@fusionkit/ensemble";
2
+ import type { ModelFusionSideEffects } from "@fusionkit/protocol";
3
+ import type { ToolDashboardMetadata, ToolIntegration } from "./types.js";
4
+ export type ToolRegistry = {
5
+ /** Resolve a tool by id or alias. */
6
+ get(idOrAlias: string): ToolIntegration | undefined;
7
+ /** All registered tools, in registration order. */
8
+ list(): ToolIntegration[];
9
+ /** Tools that can be launched behind the fusion panel. */
10
+ launchableFusion(): ToolIntegration[];
11
+ /** Tools that can be launched against a single local model. */
12
+ launchableLocal(): ToolIntegration[];
13
+ /** Build the ensemble harness adapter for a unified harness kind. */
14
+ harnessForKind(kind: UnifiedHarnessKind, options: ToolHarnessResolveOptions): HarnessAdapter;
15
+ /** Policy side-effects for a tool-backed harness kind. */
16
+ sideEffectsForKind(kind: UnifiedHarnessKind): ModelFusionSideEffects;
17
+ /** Judge response-shape hint for a tool-backed harness kind. */
18
+ responseShapeForKind(kind: UnifiedHarnessKind): string;
19
+ /** All unified harness kinds answered by a registered tool. */
20
+ harnessKinds(): UnifiedHarnessKind[];
21
+ /** Dashboard metadata for tools that provide it, in registration order. */
22
+ dashboardTools(): ToolDashboardMetadata[];
23
+ };
24
+ /**
25
+ * Assemble a tool registry from a fixed list of integrations. The CLI is the
26
+ * single place that knows every tool package, so it builds the registry here and
27
+ * wires it into both the launchers and (via `setToolAdapterResolver`) the
28
+ * ensemble harness gateway. Adding a tool is one new package plus one entry in
29
+ * the list the CLI passes here.
30
+ */
31
+ export declare function createToolRegistry(integrations: readonly ToolIntegration[]): ToolRegistry;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Assemble a tool registry from a fixed list of integrations. The CLI is the
3
+ * single place that knows every tool package, so it builds the registry here and
4
+ * wires it into both the launchers and (via `setToolAdapterResolver`) the
5
+ * ensemble harness gateway. Adding a tool is one new package plus one entry in
6
+ * the list the CLI passes here.
7
+ */
8
+ export function createToolRegistry(integrations) {
9
+ const byKey = new Map();
10
+ for (const integration of integrations) {
11
+ byKey.set(integration.id, integration);
12
+ for (const alias of integration.aliases ?? []) {
13
+ byKey.set(alias, integration);
14
+ }
15
+ }
16
+ const harnessIndex = new Map();
17
+ for (const integration of integrations) {
18
+ for (const kind of integration.harnessKinds) {
19
+ harnessIndex.set(kind, integration);
20
+ }
21
+ }
22
+ const toolForKind = (kind) => {
23
+ const integration = harnessIndex.get(kind);
24
+ if (integration === undefined) {
25
+ throw new Error(`no tool integration provides a harness for kind "${kind}"`);
26
+ }
27
+ return integration;
28
+ };
29
+ return {
30
+ get: (idOrAlias) => byKey.get(idOrAlias),
31
+ list: () => [...integrations],
32
+ launchableFusion: () => integrations.filter((tool) => tool.modes.includes("fusion")),
33
+ launchableLocal: () => integrations.filter((tool) => tool.modes.includes("local")),
34
+ harnessForKind: (kind, options) => {
35
+ const integration = toolForKind(kind);
36
+ if (integration.createHarness === undefined) {
37
+ throw new Error(`tool "${integration.id}" has no harness factory for kind "${kind}"`);
38
+ }
39
+ return integration.createHarness(kind, options);
40
+ },
41
+ sideEffectsForKind: (kind) => {
42
+ const harness = toolForKind(kind).harness;
43
+ if (harness === undefined) {
44
+ throw new Error(`tool for kind "${kind}" has no harness metadata`);
45
+ }
46
+ return harness.sideEffects;
47
+ },
48
+ responseShapeForKind: (kind) => {
49
+ const harness = toolForKind(kind).harness;
50
+ if (harness === undefined) {
51
+ throw new Error(`tool for kind "${kind}" has no harness metadata`);
52
+ }
53
+ return harness.responseShape;
54
+ },
55
+ harnessKinds: () => integrations.flatMap((tool) => [...tool.harnessKinds]),
56
+ dashboardTools: () => integrations
57
+ .map((tool) => tool.dashboard)
58
+ .filter((dashboard) => dashboard !== undefined)
59
+ };
60
+ }
@@ -0,0 +1,133 @@
1
+ import type { EnsembleModel, HarnessAdapter, HarnessCapabilities, ToolHarnessResolveOptions, UnifiedHarnessKind } from "@fusionkit/ensemble";
2
+ import type { ModelFusionHarnessKind, ModelFusionSideEffects } from "@fusionkit/protocol";
3
+ type ToolEnv = Record<string, string | undefined>;
4
+ /**
5
+ * How a tool is being launched: behind the real fusion panel (`fusion`) or
6
+ * backed by a single local model (`local`). The same `ToolIntegration.launch`
7
+ * handles both; the context's `mode` lets a tool branch where the two differ
8
+ * (e.g. Cursor spawns a bridge in fusion mode but prints tunnel setup locally).
9
+ */
10
+ export type ToolLaunchMode = "fusion" | "local";
11
+ /**
12
+ * Everything a tool needs from the host to launch its real binary, injected so
13
+ * tool packages never import the CLI (which would be a dependency cycle). The
14
+ * host wires these to its process/portless/teardown machinery.
15
+ */
16
+ export type ToolLaunchContext = {
17
+ mode: ToolLaunchMode;
18
+ /** Gateway base URL the tool should point its model provider at. */
19
+ gatewayUrl: string;
20
+ /** The model name/label the launched tool advertises in its own UI. */
21
+ modelLabel: string;
22
+ /** Arguments forwarded verbatim to the tool binary. */
23
+ toolArgs: string[];
24
+ /** The repository the tool runs in (defaults to the process cwd). */
25
+ repo?: string;
26
+ /** Bearer token the gateway requires, when set. */
27
+ authToken?: string;
28
+ /** Portless CA path so spawned children trust the proxy's HTTPS routes. */
29
+ caCertPath?: string;
30
+ /** Directory for per-tool log files (e.g. the Cursor bridge log). */
31
+ logsDir?: string;
32
+ /** Public tunnel URL for tools that cannot reach loopback (local Cursor). */
33
+ publicUrl?: string;
34
+ /** Line logger. */
35
+ log: (line: string) => void;
36
+ /**
37
+ * Quiesce host UI (live checklist, cursor) before the tool inherits the
38
+ * terminal. A no-op in non-interactive/local flows.
39
+ */
40
+ prepareForPassthrough: () => void;
41
+ /** Register a named port with the host (e.g. portless) and return its URL. */
42
+ registerPort: (name: string, port: number) => string;
43
+ /** Release a previously registered named port. */
44
+ unregisterPort: (name: string) => void;
45
+ /** Register a teardown callback run on shutdown (reverse order). */
46
+ registerDisposer: (dispose: () => void | Promise<void>) => void;
47
+ };
48
+ /**
49
+ * Harness-level metadata for a tool, used by the ensemble harness gateway/e2e
50
+ * matrix so it never has to `switch` over tool names. Applies to every
51
+ * `harnessKind` the tool answers for.
52
+ */
53
+ export type ToolHarnessMetadata = {
54
+ /** Protocol harness kind stamped on records. */
55
+ harnessKind: ModelFusionHarnessKind;
56
+ /** Policy side-effects the harness needs. */
57
+ sideEffects: ModelFusionSideEffects;
58
+ /** Judge response-shape hint for synthesis. */
59
+ responseShape: string;
60
+ };
61
+ /** The credential-skip-style smoke run for the dashboard's `credential-skip` task. */
62
+ export type ToolDashboardSmoke = {
63
+ taskId: string;
64
+ model: EnsembleModel;
65
+ sideEffects: ModelFusionSideEffects;
66
+ allowedTools: string[];
67
+ /** Build the harness used for the (empty-env) credential-skip smoke. */
68
+ makeHarness: () => HarnessAdapter;
69
+ };
70
+ /** The optional env-gated live smoke for the dashboard's `live` task. */
71
+ export type ToolDashboardLiveSmoke = {
72
+ taskId: string;
73
+ /** Per-tool env flag that enables this live smoke. */
74
+ envName: string;
75
+ prompt: string;
76
+ /** Env var holding a model override, and the default when unset. */
77
+ modelEnvName: string;
78
+ defaultModel: string;
79
+ /** Build the live harness (real credentials) for the given env. */
80
+ makeHarness: (env: ToolEnv) => HarnessAdapter;
81
+ };
82
+ /**
83
+ * Dashboard metadata for a tool, used by `fusionkit ensemble dashboard` to build
84
+ * the capability matrix, smoke records, and readiness rows from the registry
85
+ * instead of a hardcoded per-tool table.
86
+ */
87
+ export type ToolDashboardMetadata = {
88
+ /** Dashboard target id (e.g. "claude-code"), may differ from the tool id. */
89
+ id: string;
90
+ harnessKind: ModelFusionHarnessKind;
91
+ displayName: string;
92
+ availability: "available" | "credential_gated" | "missing";
93
+ /** Capability overlay merged onto the adapter's own capabilities. */
94
+ capabilities: HarnessCapabilities;
95
+ notes: string[];
96
+ /** Build the harness whose capabilities seed the matrix row. */
97
+ makeMatrixHarness: (env: ToolEnv) => HarnessAdapter;
98
+ /** Why the tool would skip for lack of credentials (undefined = ready). */
99
+ credentialSkipReason: (env: ToolEnv) => string | undefined;
100
+ smoke: ToolDashboardSmoke;
101
+ liveSmoke?: ToolDashboardLiveSmoke;
102
+ };
103
+ /**
104
+ * A single tool integration: its launcher (used by `fusionkit <tool>` and
105
+ * `fusionkit local <tool>`) plus, optionally, its ensemble harness factory (used
106
+ * by the harness gateway / e2e matrix). One package implements one of these and
107
+ * the CLI registers it.
108
+ */
109
+ export type ToolIntegration = {
110
+ /** Stable id (e.g. "codex"). */
111
+ id: string;
112
+ /** Alternate selectors that resolve to this tool. */
113
+ aliases?: readonly string[];
114
+ /** Human-facing name for pickers and dashboards. */
115
+ displayName: string;
116
+ /** One-line hint shown in the interactive picker. */
117
+ pickerHint: string;
118
+ /** The PATH binary launched, when the tool spawns one. */
119
+ binary?: string;
120
+ /** Launch modes this tool supports. */
121
+ modes: readonly ToolLaunchMode[];
122
+ /** The unified harness kinds this tool's adapter answers for. */
123
+ harnessKinds: readonly UnifiedHarnessKind[];
124
+ /** Boot the tool against the host context; resolves with its exit code. */
125
+ launch(ctx: ToolLaunchContext): Promise<number>;
126
+ /** Build the ensemble harness adapter for one of this tool's kinds. */
127
+ createHarness?(kind: UnifiedHarnessKind, options: ToolHarnessResolveOptions): HarnessAdapter;
128
+ /** Harness metadata for the gateway/e2e matrix (set when `createHarness` is). */
129
+ harness?: ToolHarnessMetadata;
130
+ /** Dashboard metadata for `ensemble dashboard` (set when the tool has a harness). */
131
+ dashboard?: ToolDashboardMetadata;
132
+ };
133
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@fusionkit/tools",
3
+ "private": false,
4
+ "version": "0.1.6",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/velum-labs/handoffkit.git",
8
+ "directory": "packages/tools"
9
+ },
10
+ "description": "Tool integration contract (launcher + harness adapter) and registry that the fusionkit CLI wires per-tool packages into.",
11
+ "license": "UNLICENSED",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org",
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "dependencies": {
28
+ "@fusionkit/ensemble": "0.1.6",
29
+ "@fusionkit/protocol": "0.1.6"
30
+ }
31
+ }