diff --git a/docs/rfc-003-agent-config-layer.md b/docs/rfc-003-agent-config-layer.md index d86a1eb..dc2fc83 100644 --- a/docs/rfc-003-agent-config-layer.md +++ b/docs/rfc-003-agent-config-layer.md @@ -103,6 +103,73 @@ nerve.yaml#extract → ExtractFn(string, schema) → T (typed meta) `AgentRegistry` reads config, instantiates adapters, and returns `AgentFn` by name. Role assembly is handled by the runtime — users never call Role factories directly. +### Adapter Packages + +Each agent adapter lives in its own package to avoid pulling unnecessary dependencies: + +``` +packages/ + adapter-cursor/ # @nerve/adapter-cursor — cursor-agent CLI + adapter-hermes/ # @nerve/adapter-hermes — hermes CLI subagent + adapter-claude/ # @nerve/adapter-claude — claude-code CLI (future) + adapter-codex/ # @nerve/adapter-codex — codex CLI (future) +``` + +Each adapter exports a single factory function: + +```ts +// @nerve/adapter-cursor +import type { AgentConfig, AgentFn } from "@nerve/core"; + +export function createCursorAdapter(config: AgentConfig): AgentFn; +``` + +The factory receives the full `AgentConfig` (type, model, timeout) and returns an `AgentFn` that spawns the CLI tool, passes the prompt, and returns raw output. + +**Registration** — `AgentRegistry` accepts adapter factories at construction: + +```ts +import { createCursorAdapter } from "@nerve/adapter-cursor"; +import { createHermesAdapter } from "@nerve/adapter-hermes"; + +const registry = createAgentRegistry(config.agents, { + cursor: createCursorAdapter, + hermes: createHermesAdapter, +}); +``` + +The daemon's entry point wires installed adapters; adapters not installed are not imported. `nerve validate` checks that referenced adapter types have a registered factory. + +**Workspace `package.json`** only lists the adapters it actually uses: + +```json +{ + "dependencies": { + "@nerve/adapter-cursor": "workspace:*", + "@nerve/adapter-hermes": "workspace:*" + } +} +``` + +**Migration from `workflow-utils`** — the existing `role-cursor.ts` / `shared/cursor-agent.ts` spawn logic moves to `@nerve/adapter-cursor`. `role-hermes.ts` / `shared/hermes-agent.ts` moves to `@nerve/adapter-hermes`. `workflow-utils` retains only extract, prompt utilities, and shared spawn infrastructure. + +### Dynamic Prompts + +`RoleSpec.prompt` supports both static strings and async functions: + +```ts +type PromptInput = string | ((start: StartStep, messages: WorkflowMessage[]) => Promise); + +type RoleSpec = { + agent: string; + prompt: PromptInput; + meta: Schema; + timeout: string | null; +}; +``` + +Static prompts cover simple cases. Dynamic prompts (functions) are needed when the prompt depends on thread context — e.g. reading issue content, injecting prior step results, or resolving repo paths at runtime. + ### Timeout Resolution Two-layer with role override: diff --git a/packages/adapter-cursor/package.json b/packages/adapter-cursor/package.json new file mode 100644 index 0000000..b42e498 --- /dev/null +++ b/packages/adapter-cursor/package.json @@ -0,0 +1,24 @@ +{ + "name": "@uncaged/nerve-adapter-cursor", + "version": "0.5.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepublishOnly": "bash ../../scripts/prepublish-check.sh", + "build": "rslib build", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@uncaged/nerve-core": "workspace:*" + }, + "devDependencies": { + "@rslib/core": "^0.21.3", + "@types/node": "^22.0.0", + "vitest": "^4.1.5" + } +} diff --git a/packages/adapter-cursor/rslib.config.ts b/packages/adapter-cursor/rslib.config.ts new file mode 100644 index 0000000..87bb8a3 --- /dev/null +++ b/packages/adapter-cursor/rslib.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "@rslib/core"; + +export default defineConfig({ + lib: [ + { + format: "esm", + dts: true, + }, + ], + source: { + entry: { + index: "src/index.ts", + }, + }, + output: { + target: "node", + cleanDistPath: true, + }, +}); diff --git a/packages/workflow-utils/src/shared/cursor-agent.ts b/packages/adapter-cursor/src/index.ts similarity index 51% rename from packages/workflow-utils/src/shared/cursor-agent.ts rename to packages/adapter-cursor/src/index.ts index 36aee10..ec026b6 100644 --- a/packages/workflow-utils/src/shared/cursor-agent.ts +++ b/packages/adapter-cursor/src/index.ts @@ -1,3 +1,4 @@ +import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core"; import { type Result, ok } from "@uncaged/nerve-core"; import { type SpawnEnv, type SpawnError, spawnSafe } from "./spawn-safe.js"; @@ -12,6 +13,7 @@ export type CursorAgentOptions = { env: SpawnEnv | null; timeoutMs: number | null; dryRun: boolean; + abortSignal: AbortSignal | null; }; type CursorAgentOptionsInput = CursorAgentOptions | Omit; @@ -20,6 +22,10 @@ function resolveCursorAgentDryRun(options: CursorAgentOptionsInput): boolean { return "dryRun" in options ? options.dryRun : false; } +function normalizeAbortSignal(options: CursorAgentOptionsInput): AbortSignal | null { + return "abortSignal" in options ? options.abortSignal : null; +} + /** * Invokes `cursor-agent` with the prompt passed as a single argv slot (`shell: false`). */ @@ -52,6 +58,7 @@ export async function cursorAgent( env: options.env, timeoutMs: options.timeoutMs, dryRun: false, + abortSignal: normalizeAbortSignal(options), }); if (!run.ok) { @@ -60,3 +67,40 @@ export async function cursorAgent( return ok(run.value.stdout); } + +function throwCursorSpawnError(error: SpawnError): never { + if (error.kind === "non_zero_exit") { + throw new Error( + `cursor-agent: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`, + ); + } + if (error.kind === "timeout") { + throw new Error("cursor-agent: timeout"); + } + if (error.kind === "aborted") { + throw new Error("cursor-agent: aborted"); + } + throw new Error(`cursor-agent: ${error.message}`); +} + +/** + * Factory for RFC-003 `AgentRegistry`: runs `cursor-agent` using config + per-invocation context. + */ +export function createCursorAdapter(config: AgentConfig): AgentFn { + return async (prompt: string, context: WorkflowContext): Promise => { + const run = await cursorAgent({ + prompt, + mode: "default", + model: config.model, + cwd: context.workdir, + env: null, + timeoutMs: null, + dryRun: context.start.meta.dryRun, + abortSignal: context.signal, + }); + if (!run.ok) { + throwCursorSpawnError(run.error); + } + return run.value; + }; +} diff --git a/packages/adapter-cursor/src/spawn-safe.ts b/packages/adapter-cursor/src/spawn-safe.ts new file mode 100644 index 0000000..dd86463 --- /dev/null +++ b/packages/adapter-cursor/src/spawn-safe.ts @@ -0,0 +1,186 @@ +import { spawn } from "node:child_process"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { type Result, err, ok } from "@uncaged/nerve-core"; + +/** Compatible with `process.env` for `child_process.spawn`. */ +export type SpawnEnv = Record; + +export type SpawnResult = { + stdout: string; + stderr: string; + exitCode: number; + /** OS signal name (e.g. `"SIGTERM"`) when terminated by signal; otherwise `null`. */ + signal: string | null; +}; + +export type SpawnError = + | { + kind: "non_zero_exit"; + stdout: string; + stderr: string; + exitCode: number; + signal: string | null; + } + | { kind: "timeout"; stdout: string; stderr: string } + | { kind: "spawn_failed"; message: string } + | { kind: "aborted" }; + +export type SpawnSafeOptions = { + cwd: string | null; + /** When null, merges {@link nerveCommandEnv} over `process.env`. When set, merges over that default. */ + env: SpawnEnv | null; + timeoutMs: number | null; + dryRun: boolean; + /** When non-null, child is terminated on abort; if `timeoutMs` is also null, no internal wall-clock timer is used. */ + abortSignal: AbortSignal | null; +}; + +type SpawnSafeOptionsInput = SpawnSafeOptions | Omit; + +const DEFAULT_TIMEOUT_MS = 300_000; + +export function nerveCommandEnv(): SpawnEnv { + const home = homedir(); + const pnpmHome = join(home, ".local/share/pnpm"); + return { + ...process.env, + PNPM_HOME: pnpmHome, + PATH: `${pnpmHome}:${process.env.PATH ?? ""}`, + }; +} + +function mergeEnv(user: SpawnEnv | null): SpawnEnv { + const base = nerveCommandEnv(); + if (user === null) { + return base; + } + return { ...base, ...user }; +} + +function resolveWallClockMs( + timeoutMs: number | null, + abortSignal: AbortSignal | null, +): number | null { + if (timeoutMs === null) { + if (abortSignal !== null) { + return null; + } + return DEFAULT_TIMEOUT_MS; + } + return timeoutMs; +} + +function resolveDryRun(options: SpawnSafeOptionsInput): boolean { + return "dryRun" in options ? options.dryRun : false; +} + +function normalizeAbortSignal(options: SpawnSafeOptionsInput): AbortSignal | null { + return "abortSignal" in options ? options.abortSignal : null; +} + +export function spawnSafe( + command: string, + args: ReadonlyArray, + options: SpawnSafeOptionsInput, +): Promise> { + const dryRun = resolveDryRun(options); + if (dryRun) { + return Promise.resolve( + ok({ + stdout: "[dryRun] skipped", + stderr: "", + exitCode: 0, + signal: null, + }), + ); + } + + const abortSignal = normalizeAbortSignal(options); + if (abortSignal?.aborted) { + return Promise.resolve(err({ kind: "aborted" })); + } + + return new Promise((resolve) => { + const cwd = options.cwd === null ? process.cwd() : options.cwd; + const env = mergeEnv(options.env); + const wallClockMs = resolveWallClockMs(options.timeoutMs, abortSignal); + + const child = spawn(command, args, { + cwd, + env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + + let timer: ReturnType | undefined; + const finish = (outcome: Result) => { + if (settled) { + return; + } + settled = true; + if (timer !== undefined) { + clearTimeout(timer); + } + if (abortSignal !== null) { + abortSignal.removeEventListener("abort", onAbort); + } + resolve(outcome); + }; + + function onAbort() { + child.kill("SIGTERM"); + finish(err({ kind: "aborted" })); + } + + if (abortSignal !== null) { + abortSignal.addEventListener("abort", onAbort); + } + + if (wallClockMs !== null) { + timer = setTimeout(() => { + child.kill("SIGTERM"); + finish(err({ kind: "timeout", stdout, stderr })); + }, wallClockMs); + } + + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8"); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8"); + }); + + child.on("error", (cause: Error) => { + finish(err({ kind: "spawn_failed", message: cause.message })); + }); + + child.on("close", (code, signal) => { + const exitCode = code ?? 1; + const sig = signal === undefined || signal === null ? null : String(signal); + const result: SpawnResult = { + stdout: stdout.trimEnd(), + stderr: stderr.trimEnd(), + exitCode, + signal: sig, + }; + if (exitCode !== 0) { + finish( + err({ + kind: "non_zero_exit", + stdout: result.stdout, + stderr: result.stderr, + exitCode, + signal: sig, + }), + ); + return; + } + finish(ok(result)); + }); + }); +} diff --git a/packages/adapter-cursor/tsconfig.json b/packages/adapter-cursor/tsconfig.json new file mode 100644 index 0000000..9036088 --- /dev/null +++ b/packages/adapter-cursor/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "composite": false + }, + "include": ["src"] +} diff --git a/packages/adapter-hermes/package.json b/packages/adapter-hermes/package.json new file mode 100644 index 0000000..f7cda3e --- /dev/null +++ b/packages/adapter-hermes/package.json @@ -0,0 +1,24 @@ +{ + "name": "@uncaged/nerve-adapter-hermes", + "version": "0.5.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepublishOnly": "bash ../../scripts/prepublish-check.sh", + "build": "rslib build", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@uncaged/nerve-core": "workspace:*" + }, + "devDependencies": { + "@rslib/core": "^0.21.3", + "@types/node": "^22.0.0", + "vitest": "^4.1.5" + } +} diff --git a/packages/adapter-hermes/rslib.config.ts b/packages/adapter-hermes/rslib.config.ts new file mode 100644 index 0000000..87bb8a3 --- /dev/null +++ b/packages/adapter-hermes/rslib.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "@rslib/core"; + +export default defineConfig({ + lib: [ + { + format: "esm", + dts: true, + }, + ], + source: { + entry: { + index: "src/index.ts", + }, + }, + output: { + target: "node", + cleanDistPath: true, + }, +}); diff --git a/packages/adapter-hermes/src/index.ts b/packages/adapter-hermes/src/index.ts new file mode 100644 index 0000000..d50a1ec --- /dev/null +++ b/packages/adapter-hermes/src/index.ts @@ -0,0 +1,115 @@ +import type { AgentConfig, AgentFn, WorkflowContext } from "@uncaged/nerve-core"; +import { type Result, ok } from "@uncaged/nerve-core"; + +import { type SpawnEnv, type SpawnError, spawnSafe } from "./spawn-safe.js"; + +/** + * Spawns a non-interactive `hermes chat` invocation with YOLO enabled, argv-only + * (shell: false) following the Nerve issue #208 contract. + */ +export type HermesAgentOptions = { + prompt: string; + model: string | null; + provider: string | null; + skills: string[]; + /** When true, suppresses interactive UI noise. */ + quiet: boolean; + maxTurns: number; + env: SpawnEnv | null; + timeoutMs: number | null; + dryRun: boolean; + abortSignal: AbortSignal | null; +}; + +type HermesAgentOptionsInput = HermesAgentOptions | Omit; + +function resolveHermesDryRun(options: HermesAgentOptionsInput): boolean { + return "dryRun" in options ? options.dryRun : false; +} + +function normalizeAbortSignal(options: HermesAgentOptionsInput): AbortSignal | null { + return "abortSignal" in options ? options.abortSignal : null; +} + +export async function hermesAgent( + options: HermesAgentOptionsInput, +): Promise> { + const dryRun = resolveHermesDryRun(options); + if (dryRun) { + return ok("[dryRun] hermes stub"); + } + const args: string[] = [ + "chat", + "-q", + options.prompt, + "--yolo", + "--max-turns", + String(options.maxTurns), + ]; + if (options.model) { + args.push("--model", options.model); + } + if (options.provider) { + args.push("--provider", options.provider); + } + for (const s of options.skills) { + args.push("-s", s); + } + if (options.quiet) { + args.push("--quiet"); + } + const run = await spawnSafe("hermes", args, { + cwd: null, + env: options.env, + timeoutMs: options.timeoutMs, + dryRun: false, + abortSignal: normalizeAbortSignal(options), + }); + if (!run.ok) { + return run; + } + return ok(run.value.stdout); +} + +function throwHermesSpawnError(error: SpawnError): never { + if (error.kind === "non_zero_exit") { + throw new Error( + `hermes: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`, + ); + } + if (error.kind === "timeout") { + throw new Error("hermes: timeout"); + } + if (error.kind === "aborted") { + throw new Error("hermes: aborted"); + } + throw new Error(`hermes: ${error.message}`); +} + +const HERMES_ADAPTER_DEFAULT_MAX_TURNS = 90; + +/** + * Factory for RFC-003 `AgentRegistry`: runs `hermes chat` using config + per-invocation context. + */ +export function createHermesAdapter(config: AgentConfig): AgentFn { + const modelFromConfig = config.model === "auto" ? null : config.model; + + return async (prompt: string, context: WorkflowContext): Promise => { + const run = await hermesAgent({ + prompt, + model: modelFromConfig, + provider: null, + skills: [], + quiet: true, + maxTurns: HERMES_ADAPTER_DEFAULT_MAX_TURNS, + env: null, + timeoutMs: null, + dryRun: context.start.meta.dryRun, + abortSignal: context.signal, + }); + if (!run.ok) { + throwHermesSpawnError(run.error); + } + return run.value; + }; +} diff --git a/packages/adapter-hermes/src/spawn-safe.ts b/packages/adapter-hermes/src/spawn-safe.ts new file mode 100644 index 0000000..dd86463 --- /dev/null +++ b/packages/adapter-hermes/src/spawn-safe.ts @@ -0,0 +1,186 @@ +import { spawn } from "node:child_process"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { type Result, err, ok } from "@uncaged/nerve-core"; + +/** Compatible with `process.env` for `child_process.spawn`. */ +export type SpawnEnv = Record; + +export type SpawnResult = { + stdout: string; + stderr: string; + exitCode: number; + /** OS signal name (e.g. `"SIGTERM"`) when terminated by signal; otherwise `null`. */ + signal: string | null; +}; + +export type SpawnError = + | { + kind: "non_zero_exit"; + stdout: string; + stderr: string; + exitCode: number; + signal: string | null; + } + | { kind: "timeout"; stdout: string; stderr: string } + | { kind: "spawn_failed"; message: string } + | { kind: "aborted" }; + +export type SpawnSafeOptions = { + cwd: string | null; + /** When null, merges {@link nerveCommandEnv} over `process.env`. When set, merges over that default. */ + env: SpawnEnv | null; + timeoutMs: number | null; + dryRun: boolean; + /** When non-null, child is terminated on abort; if `timeoutMs` is also null, no internal wall-clock timer is used. */ + abortSignal: AbortSignal | null; +}; + +type SpawnSafeOptionsInput = SpawnSafeOptions | Omit; + +const DEFAULT_TIMEOUT_MS = 300_000; + +export function nerveCommandEnv(): SpawnEnv { + const home = homedir(); + const pnpmHome = join(home, ".local/share/pnpm"); + return { + ...process.env, + PNPM_HOME: pnpmHome, + PATH: `${pnpmHome}:${process.env.PATH ?? ""}`, + }; +} + +function mergeEnv(user: SpawnEnv | null): SpawnEnv { + const base = nerveCommandEnv(); + if (user === null) { + return base; + } + return { ...base, ...user }; +} + +function resolveWallClockMs( + timeoutMs: number | null, + abortSignal: AbortSignal | null, +): number | null { + if (timeoutMs === null) { + if (abortSignal !== null) { + return null; + } + return DEFAULT_TIMEOUT_MS; + } + return timeoutMs; +} + +function resolveDryRun(options: SpawnSafeOptionsInput): boolean { + return "dryRun" in options ? options.dryRun : false; +} + +function normalizeAbortSignal(options: SpawnSafeOptionsInput): AbortSignal | null { + return "abortSignal" in options ? options.abortSignal : null; +} + +export function spawnSafe( + command: string, + args: ReadonlyArray, + options: SpawnSafeOptionsInput, +): Promise> { + const dryRun = resolveDryRun(options); + if (dryRun) { + return Promise.resolve( + ok({ + stdout: "[dryRun] skipped", + stderr: "", + exitCode: 0, + signal: null, + }), + ); + } + + const abortSignal = normalizeAbortSignal(options); + if (abortSignal?.aborted) { + return Promise.resolve(err({ kind: "aborted" })); + } + + return new Promise((resolve) => { + const cwd = options.cwd === null ? process.cwd() : options.cwd; + const env = mergeEnv(options.env); + const wallClockMs = resolveWallClockMs(options.timeoutMs, abortSignal); + + const child = spawn(command, args, { + cwd, + env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + + let timer: ReturnType | undefined; + const finish = (outcome: Result) => { + if (settled) { + return; + } + settled = true; + if (timer !== undefined) { + clearTimeout(timer); + } + if (abortSignal !== null) { + abortSignal.removeEventListener("abort", onAbort); + } + resolve(outcome); + }; + + function onAbort() { + child.kill("SIGTERM"); + finish(err({ kind: "aborted" })); + } + + if (abortSignal !== null) { + abortSignal.addEventListener("abort", onAbort); + } + + if (wallClockMs !== null) { + timer = setTimeout(() => { + child.kill("SIGTERM"); + finish(err({ kind: "timeout", stdout, stderr })); + }, wallClockMs); + } + + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8"); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8"); + }); + + child.on("error", (cause: Error) => { + finish(err({ kind: "spawn_failed", message: cause.message })); + }); + + child.on("close", (code, signal) => { + const exitCode = code ?? 1; + const sig = signal === undefined || signal === null ? null : String(signal); + const result: SpawnResult = { + stdout: stdout.trimEnd(), + stderr: stderr.trimEnd(), + exitCode, + signal: sig, + }; + if (exitCode !== 0) { + finish( + err({ + kind: "non_zero_exit", + stdout: result.stdout, + stderr: result.stderr, + exitCode, + signal: sig, + }), + ); + return; + } + finish(ok(result)); + }); + }); +} diff --git a/packages/adapter-hermes/tsconfig.json b/packages/adapter-hermes/tsconfig.json new file mode 100644 index 0000000..9036088 --- /dev/null +++ b/packages/adapter-hermes/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "composite": false + }, + "include": ["src"] +} diff --git a/packages/core/src/agent-adapter-ids.ts b/packages/core/src/agent-adapter-ids.ts index f10a9ac..d3fe1be 100644 --- a/packages/core/src/agent-adapter-ids.ts +++ b/packages/core/src/agent-adapter-ids.ts @@ -1,7 +1,6 @@ /** - * Agent adapter types that have a daemon implementation (RFC-003). - * Keep in sync with `packages/daemon/src/agent-registry.ts` adapter dispatch. - * When adding a new adapter (e.g. cursor, hermes, codex), add it here AND - * add the corresponding factory branch in `createAgentFnForConfig`. + * Agent adapter ids referenced by tooling / docs (RFC-003). + * Daemon wiring registers factories via `createAgentRegistry(..., adapterFactories)`; + * echo is built-in; others must be supplied for runtime use. */ export const KNOWN_AGENT_ADAPTER_IDS = ["echo", "cursor", "hermes", "codex"] as const; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b22a2b4..4e35ef2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,7 +27,7 @@ export type { WorkflowDefinition, } from "./workflow.js"; export { START, END, DEFAULT_ENGINE_MAX_ROUNDS } from "./workflow.js"; -export type { RoleSpec, WorkflowSpec } from "./workflow-spec.js"; +export type { PromptInput, RoleSpec, WorkflowSpec } from "./workflow-spec.js"; export { resolveRoleTimeoutMs } from "./workflow-spec.js"; export { parseDurationStringToMs } from "./duration.js"; export type { Schema, ExtractFn } from "./extract-layer.js"; diff --git a/packages/core/src/workflow-spec.ts b/packages/core/src/workflow-spec.ts index a15b753..efa259a 100644 --- a/packages/core/src/workflow-spec.ts +++ b/packages/core/src/workflow-spec.ts @@ -2,7 +2,12 @@ import { parseDurationStringToMs } from "./duration.js"; import type { Schema } from "./extract-layer.js"; import type { Result } from "./result.js"; import { ok } from "./result.js"; -import type { Moderator, RoleMeta } from "./workflow.js"; +import type { Moderator, RoleMeta, StartStep, WorkflowMessage } from "./workflow.js"; + +/** Static string or async prompt built from thread context (RFC-003 dynamic prompts). */ +export type PromptInput = + | string + | ((start: StartStep, messages: WorkflowMessage[]) => Promise); /** * Authoring-time role: references a named agent, prompt, extract schema, and optional timeout. @@ -10,7 +15,7 @@ import type { Moderator, RoleMeta } from "./workflow.js"; */ export type RoleSpec> = { agent: string; - prompt: string; + prompt: PromptInput; meta: Schema; /** Override agent default; `null` uses the agent's configured timeout from `nerve.yaml`. */ timeout: string | null; diff --git a/packages/daemon/package.json b/packages/daemon/package.json index bce6d47..979f9fa 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -22,10 +22,12 @@ "scripts": { "prepublishOnly": "bash ../../scripts/prepublish-check.sh", "build": "rslib build", - "pretest": "pnpm --filter @uncaged/nerve-core run build", + "pretest": "pnpm --filter @uncaged/nerve-core run build && pnpm --filter @uncaged/nerve-adapter-cursor run build && pnpm --filter @uncaged/nerve-adapter-hermes run build", "test": "vitest run" }, "dependencies": { + "@uncaged/nerve-adapter-cursor": "workspace:*", + "@uncaged/nerve-adapter-hermes": "workspace:*", "@uncaged/nerve-core": "workspace:*", "@uncaged/nerve-store": "workspace:*", "drizzle-orm": "1.0.0-beta.23-c10d10c", diff --git a/packages/daemon/src/__tests__/agent-registry.test.ts b/packages/daemon/src/__tests__/agent-registry.test.ts index 6dd4ec9..5f4104d 100644 --- a/packages/daemon/src/__tests__/agent-registry.test.ts +++ b/packages/daemon/src/__tests__/agent-registry.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import type { AgentConfig, AgentFn, StartStep, WorkflowContext } from "@uncaged/nerve-core"; import { START } from "@uncaged/nerve-core"; -import { createAgentRegistry } from "../agent-registry.js"; +import { type AgentAdapterFactories, createAgentRegistry } from "../agent-registry.js"; function makeContext(overrides: Partial = {}): WorkflowContext { const start: StartStep = { @@ -27,7 +27,7 @@ function echoAgent(model = "auto"): AgentConfig { describe("createAgentRegistry", () => { it("get() returns AgentFn for a defined agent", async () => { - const registry = createAgentRegistry({ dev: echoAgent() }); + const registry = createAgentRegistry({ dev: echoAgent() }, {}); const fn = registry.get("dev"); expect(typeof fn).toBe("function"); const out = await fn("hello", makeContext()); @@ -35,32 +35,35 @@ describe("createAgentRegistry", () => { }); it("get() throws for an undefined agent and the message includes the name", () => { - const registry = createAgentRegistry({ dev: echoAgent() }); + const registry = createAgentRegistry({ dev: echoAgent() }, {}); expect(() => registry.get("missing-agent")).toThrow(/missing-agent/); }); it("getAgentConfig returns the original AgentConfig", () => { const cfg = echoAgent(); - const registry = createAgentRegistry({ dev: cfg }); + const registry = createAgentRegistry({ dev: cfg }, {}); expect(registry.getAgentConfig("dev")).toEqual(cfg); }); it("getAgentConfig throws for an undefined agent", () => { - const registry = createAgentRegistry({ dev: echoAgent() }); + const registry = createAgentRegistry({ dev: echoAgent() }, {}); expect(() => registry.getAgentConfig("missing-agent")).toThrow(/missing-agent/); }); it("echo adapter returns the prompt unchanged", async () => { - const registry = createAgentRegistry({ e: echoAgent() }); + const registry = createAgentRegistry({ e: echoAgent() }, {}); const prompt = "exact copy\n\tunicode: 你好"; await expect(registry.get("e")(prompt, makeContext())).resolves.toBe(prompt); }); it("multiple agents have independent instances", async () => { - const registry = createAgentRegistry({ - "agent-a": echoAgent(), - "agent-b": echoAgent(), - }); + const registry = createAgentRegistry( + { + "agent-a": echoAgent(), + "agent-b": echoAgent(), + }, + {}, + ); const a = registry.get("agent-a"); const b = registry.get("agent-b"); expect(a).not.toBe(b); @@ -69,7 +72,7 @@ describe("createAgentRegistry", () => { }); it("AbortSignal is accessible in context", async () => { - const registry = createAgentRegistry({ dev: echoAgent() }); + const registry = createAgentRegistry({ dev: echoAgent() }, {}); const inner = registry.get("dev"); const seen: WorkflowContext[] = []; const trace: AgentFn = async (prompt, ctx) => { @@ -82,4 +85,21 @@ describe("createAgentRegistry", () => { expect(seen).toHaveLength(1); expect(seen[0].signal).toBe(ac.signal); }); + + it("invokes plugin adapter factories for non-echo types", async () => { + const factories: AgentAdapterFactories = { + mirror: (cfg) => async (prompt, _ctx) => `${cfg.type}:${prompt}`, + }; + const registry = createAgentRegistry( + { dev: { type: "mirror", model: "auto", timeout: null } }, + factories, + ); + await expect(registry.get("dev")("ping", makeContext())).resolves.toBe("mirror:ping"); + }); + + it("throws when adapter type is missing from factories (message lists available)", () => { + expect(() => + createAgentRegistry({ dev: { type: "codex", model: "auto", timeout: null } }, {}), + ).toThrow(/Unknown agent adapter type: "codex" \(available: echo\)/); + }); }); diff --git a/packages/daemon/src/__tests__/compile-workflow-spec.test.ts b/packages/daemon/src/__tests__/compile-workflow-spec.test.ts index 05e876f..24e1aca 100644 --- a/packages/daemon/src/__tests__/compile-workflow-spec.test.ts +++ b/packages/daemon/src/__tests__/compile-workflow-spec.test.ts @@ -14,7 +14,7 @@ import type { import { END, START } from "@uncaged/nerve-core"; import { createAgentRegistry } from "../agent-registry.js"; -import { compileWorkflowSpec } from "../compile-workflow-spec.js"; +import { type CompileWorkflowSpecDeps, compileWorkflowSpec } from "../compile-workflow-spec.js"; type DemoMeta = { n: number }; @@ -58,10 +58,10 @@ describe("compileWorkflowSpec", () => { moderator: (_ctx: ModeratorContext<{ main: DemoMeta }>) => END, }; - const registry = createAgentRegistry({ dev: echoAgent() }); + const registry = createAgentRegistry({ dev: echoAgent() }, {}); const def = compileWorkflowSpec(spec, { registry, - extractFn: async (raw, _s) => ({ n: raw.length }), + extractFn: async (raw: string, _s: Schema) => ({ n: raw.length }) as T, createContext: makeContext, }); @@ -76,13 +76,19 @@ describe("compileWorkflowSpec", () => { const order: string[] = []; - const registry = createAgentRegistry({ - dev: { type: "echo", model: "auto", timeout: null }, - }); + const registry = createAgentRegistry( + { + dev: { type: "echo", model: "auto", timeout: null }, + }, + {}, + ); - const extractFn = async (raw: string, _sch: Schema): Promise => { + const extractFn: CompileWorkflowSpecDeps["extractFn"] = async ( + raw: string, + _sch: Schema, + ) => { order.push("extract"); - return { n: raw.length }; + return { n: raw.length } as T; }; const orig = registry.get("dev"); @@ -130,9 +136,12 @@ describe("compileWorkflowSpec", () => { const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); - const registry = createAgentRegistry({ - slow: { type: "echo", model: "auto", timeout: 400_000 }, - }); + const registry = createAgentRegistry( + { + slow: { type: "echo", model: "auto", timeout: 400_000 }, + }, + {}, + ); const specDefault: WorkflowSpec<{ main: DemoMeta }> = { name: "def", @@ -149,7 +158,7 @@ describe("compileWorkflowSpec", () => { await compileWorkflowSpec(specDefault, { registry, - extractFn: async () => ({ n: 0 }), + extractFn: async (_raw: string, _s: Schema) => ({ n: 0 }) as T, createContext: makeContext, }).roles.main(makeStart(), []); @@ -172,13 +181,48 @@ describe("compileWorkflowSpec", () => { await compileWorkflowSpec(specOverride, { registry, - extractFn: async () => ({ n: 0 }), + extractFn: async (_raw: string, _s: Schema) => ({ n: 0 }) as T, createContext: makeContext, }).roles.main(makeStart(), []); expect(timeoutSpy).toHaveBeenCalledWith(60_000); timeoutSpy.mockRestore(); }); + + it("resolves dynamic prompt functions before AgentFn", async () => { + const witness: DemoMeta | null = null; + const schema: Schema = { witness }; + + const registry = createAgentRegistry( + { dev: { type: "echo", model: "auto", timeout: null } }, + {}, + ); + + const spec: WorkflowSpec<{ main: DemoMeta }> = { + name: "dyn", + roles: { + main: { + agent: "dev", + prompt: async (start, messages) => `tid=${start.meta.threadId} n=${messages.length}`, + meta: schema, + timeout: null, + }, + }, + moderator: () => END, + }; + + const def = compileWorkflowSpec(spec, { + registry, + extractFn: async (raw: string, _s: Schema) => ({ n: raw.length }) as T, + createContext: makeContext, + }); + + const start = makeStart("thread-x"); + const msgs: WorkflowMessage[] = [{ role: "a", content: "m", meta: {}, timestamp: 1 }]; + const out = await def.roles.main(start, msgs); + expect(out.content).toBe("tid=thread-x n=1"); + expect(out.meta.n).toBe(out.content.length); + }); }); describe("backward compatibility", () => { diff --git a/packages/daemon/src/__tests__/kernel-agent-registry-reload.test.ts b/packages/daemon/src/__tests__/kernel-agent-registry-reload.test.ts index f840315..62515c4 100644 --- a/packages/daemon/src/__tests__/kernel-agent-registry-reload.test.ts +++ b/packages/daemon/src/__tests__/kernel-agent-registry-reload.test.ts @@ -108,7 +108,14 @@ describe("kernel — AgentRegistry hot-reload", () => { await vi.runAllTimersAsync(); expect(mockCreateAgentRegistry).toHaveBeenCalledTimes(1); - expect(mockCreateAgentRegistry.mock.calls[0][0]).toEqual(a.agents); + expect(mockCreateAgentRegistry).toHaveBeenNthCalledWith( + 1, + a.agents, + expect.objectContaining({ + cursor: expect.any(Function), + hermes: expect.any(Function), + }), + ); const b = makeConfig({ dev: { type: "echo", model: "auto", timeout: null }, @@ -117,7 +124,14 @@ describe("kernel — AgentRegistry hot-reload", () => { kernel.reloadConfig(b); expect(mockCreateAgentRegistry).toHaveBeenCalledTimes(2); - expect(mockCreateAgentRegistry.mock.calls[1][0]).toEqual(b.agents); + expect(mockCreateAgentRegistry).toHaveBeenNthCalledWith( + 2, + b.agents, + expect.objectContaining({ + cursor: expect.any(Function), + hermes: expect.any(Function), + }), + ); const reloadLogs = logStore.query({ source: "system", type: "agent_registry_reload" }); expect(reloadLogs.length).toBe(1); diff --git a/packages/daemon/src/agent-registry.ts b/packages/daemon/src/agent-registry.ts index afff8cc..6386677 100644 --- a/packages/daemon/src/agent-registry.ts +++ b/packages/daemon/src/agent-registry.ts @@ -1,28 +1,46 @@ import type { AgentConfig, AgentFn } from "@uncaged/nerve-core"; -import { KNOWN_AGENT_ADAPTER_IDS } from "@uncaged/nerve-core"; import { createEchoAgent } from "./agent-adapters/echo.js"; +export type AgentAdapterFactory = (config: AgentConfig) => AgentFn; + +export type AgentAdapterFactories = Record; + export type AgentRegistry = { get(name: string): AgentFn; /** Resolved agent defaults from `nerve.yaml` (e.g. timeout for WorkflowSpec compile). */ getAgentConfig(name: string): AgentConfig; }; -function createAgentFnForConfig(config: AgentConfig): AgentFn { +function formatAvailableAdapters(adapterFactories: AgentAdapterFactories): string { + const pluginIds = Object.keys(adapterFactories).sort(); + return ["echo", ...pluginIds].join(", "); +} + +function createAgentFnForConfig( + config: AgentConfig, + adapterFactories: AgentAdapterFactories, +): AgentFn { if (config.type === "echo") { return createEchoAgent(config); } - throw new Error( - `Unknown agent adapter type: "${config.type}" (known: ${KNOWN_AGENT_ADAPTER_IDS.join(", ")})`, - ); + const factory = adapterFactories[config.type]; + if (factory === undefined) { + throw new Error( + `Unknown agent adapter type: "${config.type}" (available: ${formatAvailableAdapters(adapterFactories)})`, + ); + } + return factory(config); } -export function createAgentRegistry(agents: Record): AgentRegistry { +export function createAgentRegistry( + agents: Record, + adapterFactories: AgentAdapterFactories, +): AgentRegistry { const byName = new Map(); const configs = new Map(); for (const [name, config] of Object.entries(agents)) { - byName.set(name, createAgentFnForConfig(config)); + byName.set(name, createAgentFnForConfig(config, adapterFactories)); configs.set(name, config); } diff --git a/packages/daemon/src/compile-workflow-spec.ts b/packages/daemon/src/compile-workflow-spec.ts index 05cba38..650ee8a 100644 --- a/packages/daemon/src/compile-workflow-spec.ts +++ b/packages/daemon/src/compile-workflow-spec.ts @@ -51,7 +51,12 @@ function compileRoleForSpec>( signal, }; - const raw = await agentFn(roleSpec.prompt, ctx); + const promptText = + typeof roleSpec.prompt === "string" + ? roleSpec.prompt + : await roleSpec.prompt(start, messages); + + const raw = await agentFn(promptText, ctx); const meta = await deps.extractFn(raw, roleSpec.meta); return { content: raw, meta }; }; diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 98bacc3..cf8b06a 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -59,7 +59,11 @@ export { createWorkflowManager } from "./workflow-manager.js"; export type { WorkflowManager } from "./workflow-manager.js"; export { createAgentRegistry } from "./agent-registry.js"; -export type { AgentRegistry } from "./agent-registry.js"; +export type { + AgentAdapterFactories, + AgentAdapterFactory, + AgentRegistry, +} from "./agent-registry.js"; export { compileWorkflowSpec } from "./compile-workflow-spec.js"; export type { CompileWorkflowSpecDeps } from "./compile-workflow-spec.js"; export { createEchoAgent } from "./agent-adapters/echo.js"; diff --git a/packages/daemon/src/kernel.ts b/packages/daemon/src/kernel.ts index 0925889..9ade4c1 100644 --- a/packages/daemon/src/kernel.ts +++ b/packages/daemon/src/kernel.ts @@ -8,6 +8,8 @@ import { hostname } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { createCursorAdapter } from "@uncaged/nerve-adapter-cursor"; +import { createHermesAdapter } from "@uncaged/nerve-adapter-hermes"; import { type HealthInfo, type NerveConfig, @@ -44,6 +46,15 @@ import { createSenseWorkerPool, resolveWorkerScript } from "./worker-pool.js"; import { createWorkflowManager } from "./workflow-manager.js"; import type { WorkflowManager } from "./workflow-manager.js"; +import type { AgentAdapterFactories } from "./agent-registry.js"; + +function defaultAgentAdapterFactories(): AgentAdapterFactories { + return { + cursor: createCursorAdapter, + hermes: createHermesAdapter, + }; +} + export type KernelHealth = { uptime: number; activeSenses: number; @@ -130,7 +141,7 @@ export function createKernel( }); let config = initialConfig; - let agentRegistry = createAgentRegistry(config.agents); + let agentRegistry = createAgentRegistry(config.agents, defaultAgentAdapterFactories()); let _signalIdCounter = 0; function nextSignalId(): number { @@ -310,7 +321,7 @@ export function createKernel( const oldConfig = config; const oldWorkflows = config.workflows; config = newConfig; - agentRegistry = createAgentRegistry(newConfig.agents); + agentRegistry = createAgentRegistry(newConfig.agents, defaultAgentAdapterFactories()); logStore.append({ source: "system", type: "agent_registry_reload", diff --git a/packages/workflow-utils/package.json b/packages/workflow-utils/package.json index cf7f48e..1bce833 100644 --- a/packages/workflow-utils/package.json +++ b/packages/workflow-utils/package.json @@ -4,6 +4,12 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, "files": ["dist"], "publishConfig": { "access": "public" @@ -14,6 +20,8 @@ "test": "vitest run" }, "dependencies": { + "@uncaged/nerve-adapter-cursor": "workspace:*", + "@uncaged/nerve-adapter-hermes": "workspace:*", "@uncaged/nerve-core": "workspace:*", "zod": "^4.3.6" }, diff --git a/packages/workflow-utils/src/__tests__/spawn-safe.test.ts b/packages/workflow-utils/src/__tests__/spawn-safe.test.ts index 85ad1b2..9a6313d 100644 --- a/packages/workflow-utils/src/__tests__/spawn-safe.test.ts +++ b/packages/workflow-utils/src/__tests__/spawn-safe.test.ts @@ -8,7 +8,7 @@ describe("spawnSafe", () => { const result = await spawnSafe( process.execPath, ["-e", "process.stdout.write(process.argv[1] ?? '')", injection], - { cwd: null, env: null, timeoutMs: 10_000 }, + { cwd: null, env: null, timeoutMs: 10_000, abortSignal: null }, ); expect(result.ok).toBe(true); @@ -24,6 +24,7 @@ describe("spawnSafe", () => { cwd: null, env: null, timeoutMs: 10_000, + abortSignal: null, }); expect(result.ok).toBe(false); @@ -43,6 +44,7 @@ describe("spawnSafe", () => { env: null, timeoutMs: 10_000, dryRun: true, + abortSignal: null, }); expect(result.ok).toBe(true); diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index 32b22a4..478da45 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -3,7 +3,6 @@ export { createCursorRole } from "./role-cursor.js"; export { createHermesRole } from "./role-hermes.js"; export { createLlmRole } from "./role-llm.js"; export { createReActRole } from "./role-react.js"; -export { cursorAgent } from "./shared/cursor-agent.js"; export { llmExtract, llmExtractWithRetry } from "./shared/llm-extract.js"; export { mergeExtractConfig, type ExtractConfigLayer } from "./shared/merge-extract-config.js"; export { diff --git a/packages/workflow-utils/src/role-cursor.ts b/packages/workflow-utils/src/role-cursor.ts index 8522e58..9bee572 100644 --- a/packages/workflow-utils/src/role-cursor.ts +++ b/packages/workflow-utils/src/role-cursor.ts @@ -1,8 +1,8 @@ +import { type CursorAgentMode, cursorAgent } from "@uncaged/nerve-adapter-cursor"; import type { Role } from "@uncaged/nerve-core"; import type { CursorRoleDefaults, CursorRoleRequired } from "./role-types.js"; import { isDryRun } from "./role-types.js"; -import { type CursorAgentMode, cursorAgent } from "./shared/cursor-agent.js"; import { formatLlmError } from "./shared/format-error.js"; import { llmExtract } from "./shared/llm-extract.js"; import type { SpawnEnv } from "./shared/spawn-safe.js"; @@ -44,6 +44,7 @@ export function createCursorRole( env: Object.keys(env).length === 0 ? null : env, timeoutMs, dryRun: dry, + abortSignal: null, }); if (!run.ok) { const e = run.error; @@ -55,6 +56,9 @@ export function createCursorRole( if (e.kind === "timeout") { throw new Error("cursor-agent: timeout"); } + if (e.kind === "aborted") { + throw new Error("cursor-agent: aborted"); + } throw new Error(`cursor-agent: ${e.message}`); } const text = run.value; diff --git a/packages/workflow-utils/src/role-hermes.ts b/packages/workflow-utils/src/role-hermes.ts index 0e68ae5..1bd7531 100644 --- a/packages/workflow-utils/src/role-hermes.ts +++ b/packages/workflow-utils/src/role-hermes.ts @@ -23,6 +23,7 @@ export function createHermesRole( env: Object.keys(h.env).length === 0 ? null : h.env, timeoutMs: h.timeoutMs, dryRun: dry, + abortSignal: null, }); if (!run.ok) { const e = run.error; @@ -32,6 +33,9 @@ export function createHermesRole( if (e.kind === "timeout") { throw new Error("hermes: timeout"); } + if (e.kind === "aborted") { + throw new Error("hermes: aborted"); + } throw new Error(`hermes: ${e.message}`); } const text = run.value; diff --git a/packages/workflow-utils/src/shared/hermes-agent.ts b/packages/workflow-utils/src/shared/hermes-agent.ts index 2cf9862..759b928 100644 --- a/packages/workflow-utils/src/shared/hermes-agent.ts +++ b/packages/workflow-utils/src/shared/hermes-agent.ts @@ -1,70 +1,7 @@ -import { type Result, ok } from "@uncaged/nerve-core"; - import type { HermesRoleDefaults, HermesRoleRequired } from "../role-types.js"; -import { type SpawnEnv, type SpawnError, spawnSafe } from "./spawn-safe.js"; -/** - * Spawns a non-interactive `hermes chat` invocation with YOLO enabled, argv-only - * (shell: false) following the Nerve issue #208 contract. - * Adjust argv here if the upstream CLI surface changes. - */ -export type HermesAgentOptions = { - prompt: string; - model: string | null; - provider: string | null; - skills: string[]; - /** When true, suppresses interactive UI noise. */ - quiet: boolean; - maxTurns: number; - env: SpawnEnv | null; - timeoutMs: number | null; - dryRun: boolean; -}; - -type HermesAgentOptionsInput = HermesAgentOptions | Omit; - -function resolveHermesDryRun(options: HermesAgentOptionsInput): boolean { - return "dryRun" in options ? options.dryRun : false; -} - -export async function hermesAgent( - options: HermesAgentOptionsInput, -): Promise> { - const dryRun = resolveHermesDryRun(options); - if (dryRun) { - return ok("[dryRun] hermes stub"); - } - const args: string[] = [ - "chat", - "-q", - options.prompt, - "--yolo", - "--max-turns", - String(options.maxTurns), - ]; - if (options.model) { - args.push("--model", options.model); - } - if (options.provider) { - args.push("--provider", options.provider); - } - for (const s of options.skills) { - args.push("-s", s); - } - if (options.quiet) { - args.push("--quiet"); - } - const run = await spawnSafe("hermes", args, { - cwd: null, - env: options.env, - timeoutMs: options.timeoutMs, - dryRun: false, - }); - if (!run.ok) { - return run; - } - return ok(run.value.stdout); -} +export { hermesAgent } from "@uncaged/nerve-adapter-hermes"; +export type { HermesAgentOptions } from "@uncaged/nerve-adapter-hermes"; // --- Hermes options resolution (absorbed from hermes-options.ts) --- diff --git a/packages/workflow-utils/src/shared/spawn-safe.ts b/packages/workflow-utils/src/shared/spawn-safe.ts index 7360768..3451ad1 100644 --- a/packages/workflow-utils/src/shared/spawn-safe.ts +++ b/packages/workflow-utils/src/shared/spawn-safe.ts @@ -23,7 +23,8 @@ export type SpawnError = signal: string | null; } | { kind: "timeout"; stdout: string; stderr: string } - | { kind: "spawn_failed"; message: string }; + | { kind: "spawn_failed"; message: string } + | { kind: "aborted" }; export type SpawnSafeOptions = { cwd: string | null; @@ -31,6 +32,8 @@ export type SpawnSafeOptions = { env: SpawnEnv | null; timeoutMs: number | null; dryRun: boolean; + /** When non-null, child is terminated on abort; if `timeoutMs` is also null, no internal wall-clock timer is used. */ + abortSignal: AbortSignal | null; }; type SpawnSafeOptionsInput = SpawnSafeOptions | Omit; @@ -59,8 +62,15 @@ function mergeEnv(user: SpawnEnv | null): SpawnEnv { return { ...base, ...user }; } -function resolveTimeout(timeoutMs: number | null): number { +/** Internal timer duration; `null` means rely on abort or process exit only. */ +function resolveWallClockMs( + timeoutMs: number | null, + abortSignal: AbortSignal | null, +): number | null { if (timeoutMs === null) { + if (abortSignal !== null) { + return null; + } return DEFAULT_TIMEOUT_MS; } return timeoutMs; @@ -70,6 +80,10 @@ function resolveDryRun(options: SpawnSafeOptionsInput): boolean { return "dryRun" in options ? options.dryRun : false; } +function normalizeAbortSignal(options: SpawnSafeOptionsInput): AbortSignal | null { + return "abortSignal" in options ? options.abortSignal : null; +} + /** * Spawn a process with `shell: false` (argv only), default {@link nerveCommandEnv}, and optional timeout. * Returns `ok` only when the process exits with code 0. @@ -91,10 +105,15 @@ export function spawnSafe( ); } + const abortSignal = normalizeAbortSignal(options); + if (abortSignal?.aborted) { + return Promise.resolve(err({ kind: "aborted" })); + } + return new Promise((resolve) => { const cwd = options.cwd === null ? process.cwd() : options.cwd; const env = mergeEnv(options.env); - const timeoutMs = resolveTimeout(options.timeoutMs); + const wallClockMs = resolveWallClockMs(options.timeoutMs, abortSignal); const child = spawn(command, args, { cwd, @@ -107,19 +126,36 @@ export function spawnSafe( let stderr = ""; let settled = false; + let timer: ReturnType | undefined; const finish = (outcome: Result) => { if (settled) { return; } settled = true; - clearTimeout(timer); + if (timer !== undefined) { + clearTimeout(timer); + } + if (abortSignal !== null) { + abortSignal.removeEventListener("abort", onAbort); + } resolve(outcome); }; - const timer = setTimeout(() => { + function onAbort() { child.kill("SIGTERM"); - finish(err({ kind: "timeout", stdout, stderr })); - }, timeoutMs); + finish(err({ kind: "aborted" })); + } + + if (abortSignal !== null) { + abortSignal.addEventListener("abort", onAbort); + } + + if (wallClockMs !== null) { + timer = setTimeout(() => { + child.kill("SIGTERM"); + finish(err({ kind: "timeout", stdout, stderr })); + }, wallClockMs); + } child.stdout?.on("data", (chunk: Buffer | string) => { stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fc2fef..dbe1c0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,38 @@ importers: specifier: ^5.5.0 version: 5.9.3 + packages/adapter-cursor: + dependencies: + '@uncaged/nerve-core': + specifier: workspace:* + version: link:../core + devDependencies: + '@rslib/core': + specifier: ^0.21.3 + version: 0.21.3(typescript@5.9.3) + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3)) + + packages/adapter-hermes: + dependencies: + '@uncaged/nerve-core': + specifier: workspace:* + version: link:../core + devDependencies: + '@rslib/core': + specifier: ^0.21.3 + version: 0.21.3(typescript@5.9.3) + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3)) + packages/cli: dependencies: '@uncaged/nerve-core': @@ -70,6 +102,12 @@ importers: packages/daemon: dependencies: + '@uncaged/nerve-adapter-cursor': + specifier: workspace:* + version: link:../adapter-cursor + '@uncaged/nerve-adapter-hermes': + specifier: workspace:* + version: link:../adapter-hermes '@uncaged/nerve-core': specifier: workspace:* version: link:../core @@ -144,6 +182,12 @@ importers: packages/workflow-utils: dependencies: + '@uncaged/nerve-adapter-cursor': + specifier: workspace:* + version: link:../adapter-cursor + '@uncaged/nerve-adapter-hermes': + specifier: workspace:* + version: link:../adapter-hermes '@uncaged/nerve-core': specifier: workspace:* version: link:../core