diff --git a/packages/pi-plugin/src/context-handler.ts b/packages/pi-plugin/src/context-handler.ts index c790049bf..12209aaff 100644 --- a/packages/pi-plugin/src/context-handler.ts +++ b/packages/pi-plugin/src/context-handler.ts @@ -219,6 +219,7 @@ import { resolvePiUsableContextLimit, resolvePiWindowGeometry, } from "./pi-context-limit"; +import { captureOmpHostContext } from "./omp-subagent-runner"; import { type PiHistorianDeps, runPiHistorian } from "./pi-historian-runner"; import { formatPiPressureForLog, @@ -3645,6 +3646,11 @@ function spawnPiHistorianRun(args: { } const renewal = startPiCompartmentLeaseRenewal(db, sessionId, holderId); try { + // Refresh the OMP runner's host snapshot from the live context right + // before spawning: session_start may not have fired for this session + // (resume paths), and /model switches mid-session would otherwise + // leave a stale registry/model. No-op on non-OMP runners. + captureOmpHostContext(historian.runner, ctx); await runPiHistorian({ db, sessionId, diff --git a/packages/pi-plugin/src/index.ts b/packages/pi-plugin/src/index.ts index e43eb5868..464124a56 100644 --- a/packages/pi-plugin/src/index.ts +++ b/packages/pi-plugin/src/index.ts @@ -166,9 +166,11 @@ import { registerStatusLine, updateStatusLine } from "./status-line"; import { stripTagPrefixFromAssistantMessage } from "./strip-tag-prefix"; import { configurePiSubagentExtensions, + isOmpHostProcess, MAGIC_CONTEXT_PI_SUBAGENT_ENV, PiSubagentRunner, } from "./subagent-runner"; +import { captureOmpHostContext, OmpSubagentRunner } from "./omp-subagent-runner"; import { buildMagicContextBlock, clearPiSystemPromptSession, @@ -675,6 +677,29 @@ export function resolveSidekickFromConfig( }; } + +/** + * Shared OMP-native historian runner, constructed at most once per process. + * `undefined` when this is not an OMP host — the caller then wires the + * subprocess runner. Construction is deliberately lazy and sync (the OMP + * surface load happens on first `run()`), so this never touches host modules + * at boot. + */ +let ompRunnerSingleton: OmpSubagentRunner | undefined; +let ompRunnerResolved = false; + +function getOmpHistorianRunner(): OmpSubagentRunner | undefined { + if (!ompRunnerResolved) { + ompRunnerResolved = true; + if (isOmpHostProcess()) { + ompRunnerSingleton = new OmpSubagentRunner(); + } + } + return ompRunnerSingleton; +} + +const resolveOmpRunner = getOmpHistorianRunner(); + export function resolveHistorianFromConfig( config: MagicContextConfig, ): PiHistorianOptions | undefined { @@ -699,7 +724,14 @@ export function resolveHistorianFromConfig( const fallbackModels = resolved.fallbacks; return { - runner: new PiSubagentRunner(), + // On OMP hosts, run the historian through OMP's native subagent + // machinery (visible in the subagent pane, yield-terminated) instead + // of a headless `--print` process. Falls back to the subprocess + // runner whenever the OMP surface is unavailable (cortexkit/magic-context#416). + runner: + isOmpHostProcess() && resolveOmpRunner !== undefined + ? resolveOmpRunner + : new PiSubagentRunner(), model, fallbackModels, historianChunkTokens, @@ -1291,6 +1323,11 @@ async function startPiMagicContextRuntime( const current = resolveCurrentProjectDeps(ctx); syncCtxMemoryToolEnabled(pi, current.config.memory.enabled); + // Give the OMP-native runner the live host context (parent registry so + // extension-registered runtime providers resolve in structured spawns, + // plus the active model ref and session ids). Best-effort. + captureOmpHostContext(resolveOmpRunner, ctx); + await handlePiCloneSessionStart(event, ctx, { db, signalPendingMarker: signalPiDeferredCompactionMarkerDrain, diff --git a/packages/pi-plugin/src/omp-host.ts b/packages/pi-plugin/src/omp-host.ts new file mode 100644 index 000000000..e87123e82 --- /dev/null +++ b/packages/pi-plugin/src/omp-host.ts @@ -0,0 +1,248 @@ +/** + * OMP host surface loader. + * + * Locates the running OMP host's structured-subagent spawn API + * (`runStructuredSubagent`) so `OmpSubagentRunner` can spawn MC subagents + * in-process through OMP's native task machinery instead of headless + * `--print` subprocesses (see `omp-subagent-runner.ts` and + * cortexkit/magic-context#416). + * + * Dynamic imports are REQUIRED here, not style drift (same pattern as + * `dreamer/pi-session-api.ts`'s module ladder): the module specifier is + * genuinely runtime-selected — it is derived from the running OMP host's + * install location, and a static import of `@oh-my-pi/pi-coding-agent` + * would execute host-native addons at extension-bundle load time even on + * non-OMP hosts where the package may be absent. Runtime loading also lets + * a failure resolve to `null` (fallback to the subprocess runner) instead + * of failing the plugin boot. + * + * OMP ships its `src/` tree raw and maps package subpaths via the + * `"./*": { import: "./src/*.ts" }` exports wildcard, so + * `@oh-my-pi/pi-coding-agent/task/structured-subagent` resolves to the same + * source module the running CLI executes. The module must be imported + * in-process (inside the OMP extension host) — standalone imports fail on + * native-addon loading outside the host process, which is fine: this loader + * only runs inside OMP where the addon is already loaded. + * + * Resolution order (memoized, never throws): + * 1. Walk up from the running entry (`process.argv[1]`, then + * `process.execPath`) to the `@oh-my-pi/pi-coding-agent` package root + * (same walking rules as `subagent-runner.ts`'s `isOmpHostProcess`), then + * import `task/structured-subagent` directly from its `src/` tree. + * 2. Bare ESM subpath import of + * `@oh-my-pi/pi-coding-agent/task/structured-subagent`. + * + * Any failure resolves to `null`; callers fall back to the subprocess + * runner. This module deliberately throws nothing. + */ + +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const OMP_PACKAGE_NAME = "@oh-my-pi/pi-coding-agent"; +const STRUCTURED_SUBAGENT_SUBPATH = "task/structured-subagent"; + +/** The in-process spawn surface MC needs from the OMP host. */ +export interface OmpSubagentSurface { + runStructuredSubagent: (request: Record) => Promise<{ + result: { + exitCode: number; + output: string; + stderr: string; + truncated: boolean; + error?: string; + aborted?: boolean; + durationMs: number; + tokens?: number; + usage?: { input?: number; output?: number; cacheWrite?: number; cacheRead?: number }; + resolvedModel?: string; + }; + }>; + /** OMP's `Settings` class — the synthetic ToolSession requires a live instance. */ + Settings: { + init: (options?: { cwd?: string }) => Promise<{ + reloadFromDisk: () => Promise; + get: (key: string) => unknown; + }>; + }; + /** OMP's credential storage resolver (`discoverAuthStorage` from ./sdk). */ + discoverAuthStorage: (agentDir?: string) => Promise; +} + +export interface OmpSurfaceLoadResult { + surface: OmpSubagentSurface | null; + /** Human-readable failure reason when `surface` is null (for logging). */ + reason?: string; +} + +let cachedResult: Promise | null = null; + +/** Reset the memoized loader (test seam). */ +export function clearOmpSurfaceCache(): void { + cachedResult = null; +} + +function readPackageName(packageJsonPath: string): string | null { + try { + const manifest = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { + name?: unknown; + }; + return typeof manifest.name === "string" ? manifest.name : null; + } catch { + return null; + } +} + +/** + * Walk up from `startDir` looking for the OMP package root. Mirrors the + * containment-safe walking in `subagent-runner.ts` (`isOmpHostProcess`). + */ +function findOmpPackageRoot(startDir: string): string | null { + let current = startDir; + // eslint-disable-next-line no-constant-condition + while (true) { + const manifestPath = join(current, "package.json"); + if (existsSync(manifestPath) && readPackageName(manifestPath) === OMP_PACKAGE_NAME) { + return current; + } + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +} + +async function importFromFile(filePath: string): Promise { + return await import(pathToFileURL(filePath).href); +} + +/** + * Resolve the structured-subagent module from the running OMP entry. The + * exports wildcard maps `./task/structured-subagent` → `./src/task/ + * structured-subagent.ts`, so importing the source file directly is the same + * module graph edge the host itself uses. + */ +async function loadFromRunningEntry(): Promise<{ surface: OmpSubagentSurface } | { error: string }> { + if (process.env.JITI_VIRTUAL_SCRIPT_PREFIX && process.argv[1]?.startsWith?.(process.env.JITI_VIRTUAL_SCRIPT_PREFIX)) { + return { error: "running entry is a Jiti virtual module" }; + } + const entryCandidates = [process.argv[1], process.execPath].filter( + (candidate): candidate is string => typeof candidate === "string" && candidate.length > 0, + ); + for (const candidate of entryCandidates) { + let startDir: string; + try { + const stat = statSync(candidate); + startDir = stat.isFile() ? dirname(resolve(candidate)) : resolve(candidate); + } catch { + continue; + } + const packageRoot = findOmpPackageRoot(startDir); + if (!packageRoot) continue; + const moduleEntry = join(packageRoot, "src", "task", "structured-subagent.ts"); + if (!existsSync(moduleEntry)) { + return { error: `${OMP_PACKAGE_NAME} found at ${packageRoot} but src/${STRUCTURED_SUBAGENT_SUBPATH}.ts is missing` }; + } + try { + const mod = (await importFromFile(moduleEntry)) as Record; + return await extractSurface(mod); + } catch (error) { + return { + error: `import of ${moduleEntry} failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + return { error: `no ${OMP_PACKAGE_NAME} package root found from running entry` }; +} + +async function extractSurface(mod: Record): Promise<{ surface: OmpSubagentSurface } | { error: string }> { + const run = mod.runStructuredSubagent; + if (typeof run !== "function") { + return { error: "module loaded but runStructuredSubagent is not exported" }; + } + // Settings lives at config/settings (re-exported from the package index); + // discoverAuthStorage at ./sdk. The structured-subagent module itself does + // not re-export them, so load them from the same package root the spawn + // surface came from — they must be the same copies the host runs. + let Settings: OmpSubagentSurface["Settings"] | undefined; + let discoverAuthStorage: OmpSubagentSurface["discoverAuthStorage"] | undefined; + const searchRoots: string[] = []; + if (process.argv[1]) { + const fromEntry = findOmpPackageRoot(dirname(resolve(process.argv[1]))); + if (fromEntry) searchRoots.push(fromEntry); + } + const fromModule = findOmpPackageRoot(dirname(fileURLToPath(import.meta.url))); + if (fromModule && !searchRoots.includes(fromModule)) searchRoots.push(fromModule); + for (const root of searchRoots) { + try { + const settingsMod = (await importFromFile(join(root, "src", "config", "settings.ts"))) as Record; + const sdkMod = (await importFromFile(join(root, "src", "sdk.ts"))) as Record; + const settingsClass = settingsMod.Settings as OmpSubagentSurface["Settings"] | undefined; + const authResolver = sdkMod.discoverAuthStorage as OmpSubagentSurface["discoverAuthStorage"] | undefined; + if (typeof settingsClass?.init === "function" && typeof authResolver === "function") { + Settings = settingsClass; + discoverAuthStorage = authResolver; + break; + } + } catch { + // Try the next root. + } + } + if (!Settings || !discoverAuthStorage) { + return { error: "module loaded but Settings/discoverAuthStorage could not be resolved from the OMP package" }; + } + return { surface: { runStructuredSubagent: run as OmpSubagentSurface["runStructuredSubagent"], Settings, discoverAuthStorage } }; +} + +/** + * Load the OMP structured-subagent surface. Memoized; never throws. Resolves + * to `{ surface: null, reason }` on any failure so callers fall back to the + * subprocess runner. + */ +export function loadOmpSubagentSurface(): Promise { + cachedResult ??= (async (): Promise => { + // 1. Running-entry walk (same OMP copy that owns the live session). + const fromEntry = await loadFromRunningEntry(); + if ("surface" in fromEntry) return fromEntry; + + // 2. Walk up from THIS module's own install location. Under a real + // OMP install the plugin lives at + // `~/.omp/plugins/node_modules/@cortexkit/pi-magic-context/`, whose + // ancestor `node_modules` also hosts `@oh-my-pi/pi-coding-agent` — + // importing the subpath from there loads the exact copy the host runs. + const here = fileURLToPath(import.meta.url); + const packageRoot = findOmpPackageRoot(dirname(here)); + if (packageRoot) { + const moduleEntry = join(packageRoot, "src", "task", "structured-subagent.ts"); + if (existsSync(moduleEntry)) { + try { + const mod = (await importFromFile(moduleEntry)) as Record; + const extracted = await extractSurface(mod); + if ("surface" in extracted) return extracted; + return { surface: null, reason: extracted.error }; + } catch (error) { + return { + surface: null, + reason: `${fromEntry.error}; sibling-package import failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + } + + // 3. Bare ESM subpath (covers non-standard launch shapes). + try { + const mod = (await import( + /* @vite-ignore */ `${OMP_PACKAGE_NAME}/${STRUCTURED_SUBAGENT_SUBPATH}` + )) as Record; + const extracted = await extractSurface(mod); + if ("surface" in extracted) return extracted; + return { surface: null, reason: extracted.error }; + } catch (error) { + return { + surface: null, + reason: `${fromEntry.error}; bare subpath import failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + })(); + return cachedResult; +} diff --git a/packages/pi-plugin/src/omp-subagent-runner.test.ts b/packages/pi-plugin/src/omp-subagent-runner.test.ts new file mode 100644 index 000000000..49b5a7875 --- /dev/null +++ b/packages/pi-plugin/src/omp-subagent-runner.test.ts @@ -0,0 +1,454 @@ +import { describe, expect, mock, test } from "bun:test"; + +import { isLiteralThinkingLevel, OmpSubagentRunner, taskEffortForLevel } from "./omp-subagent-runner"; + +/** + * Tests for the OMP-native subagent runner's fail-soft contract and result + * mapping. The OMP surface is injected (never resolved at runtime here) so + * the suite is pure — no host modules load. + */ + +/** Surface stub returning a canned `StructuredSubagentResult`. */ +const FAKE_SETTINGS = { + init: async () => ({ reloadFromDisk: async () => undefined, get: () => undefined }), +}; +const FAKE_AUTH = async () => ({}); + +function fakeSurface(outcome: { + result: Record; +}, capture?: { requests: Array> }) { + return { + runStructuredSubagent: mock(async (request: Record) => { + capture?.requests.push(request); + return outcome as never; + }), + Settings: FAKE_SETTINGS, + discoverAuthStorage: FAKE_AUTH, + }; +} + +const BASE_OPTIONS = { + agent: "historian", + systemPrompt: "… instructions …", + userMessage: "Summarize this chunk.", + model: "openai/gpt-5.4", +}; + +describe("OmpSubagentRunner", () => { + test("surface unavailable → delegates to the fallback runner (hermetic, no pi spawn)", async () => { + // cubic P2: a real PiSubagentRunner would spawn an actual `pi` child on + // hosts where Pi/OMP is installed — the test would run a live subagent + // (or hang, since BASE_OPTIONS carries no timeoutMs). The injected + // fallback stub makes the delegation hermetic: assert the runner handed + // the run off (progress marker + stub result) and never touched the + // native surface path. + let fallbackCalls = 0; + const fallbackRunner = { + harness: "pi", + run: async (opts: { agent: string }) => { + fallbackCalls += 1; + return { + ok: false as const, + reason: "spawn_failed" as const, + error: "stub fallback: pi spawn blocked in test", + durationMs: 0, + }; + }, + }; + const progress: Array<{ type: string; argv?: string[] }> = []; + const runner = new OmpSubagentRunner({ + surface: null, + fallbackRunner: fallbackRunner as never, + }); + const result = await runner.run({ + ...BASE_OPTIONS, + onProgress: (event: { type: string; argv?: string[] }) => progress.push(event), + }); + // Delegation happened exactly once, through the injected stub. + expect(fallbackCalls).toBe(1); + // The fallback marker announced the subprocess hand-off. + expect(progress.some(event => event.argv?.[0] === "omp:fallback-subprocess")).toBe(true); + expect(result.ok).toBe(false); + if (!result.ok) { + // The result came from the stub, not the native surface gate. + expect(result.error).toContain("stub fallback"); + expect(result.error).not.toContain("omp surface unavailable"); + expect(result.durationMs).toBeGreaterThanOrEqual(0); + } + }); + + test("exitCode 0 + output → ok:true with assistantText passthrough and meta", async () => { + const surface = fakeSurface({ + result: { + exitCode: 0, + output: " t ", + stderr: "", + truncated: false, + durationMs: 1234, + tokens: 4321, + usage: { input: 100, output: 50, cacheWrite: 0, cacheRead: 0 }, + resolvedModel: "openai/gpt-5.4", + }, + }); + const runner = new OmpSubagentRunner({ surface: surface as never }); + const result = await runner.run(BASE_OPTIONS); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.assistantText).toBe("t"); + expect(result.meta?.resolvedModel).toBe("openai/gpt-5.4"); + expect(result.meta?.tokens).toBe(4321); + expect(result.meta?.ompRunner).toBe(true); + } + }); + + test("non-zero exit with error → mapped failure, transient on rate-limit", async () => { + const rateLimited = fakeSurface({ + result: { + exitCode: 1, + output: "", + stderr: "429 rate limited", + truncated: false, + error: "429 rate limited by provider", + durationMs: 100, + }, + }); + const runner = new OmpSubagentRunner({ surface: rateLimited as never }); + const result = await runner.run(BASE_OPTIONS); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe("model_failed"); + expect(result.transient).toBe(true); + } + + const hardFail = fakeSurface({ + result: { + exitCode: 1, + output: "", + stderr: "boom", + truncated: false, + error: "boom", + durationMs: 100, + }, + }); + const runner2 = new OmpSubagentRunner({ surface: hardFail as never }); + const result2 = await runner2.run(BASE_OPTIONS); + expect(result2.ok).toBe(false); + if (!result2.ok) { + expect(result2.reason).toBe("non_zero_exit"); + expect(result2.transient).toBeUndefined(); + } + }); + + test("empty output with exit 0 → no_assistant so callers try fallback models", async () => { + const surface = fakeSurface({ + result: { exitCode: 0, output: "", stderr: "", truncated: false, durationMs: 5 }, + }); + const runner = new OmpSubagentRunner({ surface: surface as never }); + const result = await runner.run(BASE_OPTIONS); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("no_assistant"); + }); + + test("aborted outcome → abort reason", async () => { + const surface = fakeSurface({ + result: { + exitCode: 1, + output: "", + stderr: "", + truncated: false, + aborted: true, + error: "aborted by user", + durationMs: 10, + }, + }); + const runner = new OmpSubagentRunner({ surface: surface as never }); + const result = await runner.run(BASE_OPTIONS); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("abort"); + }); + + test("request shape: assignment carries system role + yield instruction, isolation flags set", async () => { + const capture: { requests: Array> } = { requests: [] }; + const surface = fakeSurface( + { result: { exitCode: 0, output: "ok", stderr: "", truncated: false, durationMs: 1 } }, + capture, + ); + const runner = new OmpSubagentRunner({ surface: surface as never }); + await runner.run(BASE_OPTIONS); + expect(capture.requests).toHaveLength(1); + const request = capture.requests[0]!; + expect(request.invocationKind).toBe("task"); + expect(request.agent).toBe("task"); + expect(request.enableIrc).toBe(false); + expect(request.enableLsp).toBe(false); + expect(request.keepAlive).toBe(false); + expect(request.detached).toBe(true); + const assignment = request.assignment as string; + expect(assignment).toContain(""); + expect(assignment).toContain(BASE_OPTIONS.userMessage); + expect(assignment).toContain("yield tool"); + // Session isolation knobs. + const session = request.session as Record; + expect(session.enableIrc).toBe(false); + expect(session.restrictToolNames).toBe(true); + expect(session.getSessionSpawns).toBeTypeOf("function"); + }); + test("hostContext.modelRegistry rides the session into the structured request", async () => { + const capture: { requests: Array> } = { requests: [] }; + const surface = fakeSurface( + { result: { exitCode: 0, output: "ok", stderr: "", truncated: false, durationMs: 1 } }, + capture, + ); + const fakeRegistry = { authStorage: {}, getAvailable: () => [] }; + const runner = new OmpSubagentRunner({ surface: surface as never }); + runner.setHostContext({ + model: "synthetic/hf:zai-org/GLM-5.3-Flash", + modelRegistry: fakeRegistry, + sessionId: "ses-live", + }); + await runner.run({ ...BASE_OPTIONS, model: "bai/glm-5.3-flash:high", thinkingLevel: "high" }); + const request = capture.requests[0]!; + const session = request.session as Record; + // The parent's registry must be forwarded so OMP's runSubprocess sets + // registryFromParent and reuses it instead of building a fresh bundled- + // catalog-only registry that cannot see runtime providers. + expect(session.modelRegistry).toBe(fakeRegistry); + expect(session.getSessionId?.()).toBe("ses-live"); + // The explicitly configured model wins over the host's ambient model; + // otherwise every historian run would use the parent's model. + expect(request.model).toBe("bai/glm-5.3-flash:high"); + }); + + test("a session change mid-run does not leak the new session's context into the in-flight run", async () => { + const capture: { requests: Array> } = { requests: [] }; + const surface = fakeSurface( + { result: { exitCode: 0, output: "ok", stderr: "", truncated: false, durationMs: 1 } }, + capture, + ); + const oldRegistry = { tag: "old-session" }; + const runner = new OmpSubagentRunner({ surface: surface as never }); + runner.setHostContext({ modelRegistry: oldRegistry, sessionId: "ses-old" }); + // Run starts (snapshots context), then hits the async settings/auth + // init — mutate mid-flight like a session_start would. + const pending = runner.run({ ...BASE_OPTIONS }); + runner.setHostContext({ modelRegistry: { tag: "new-session" }, sessionId: "ses-new" }); + await pending; + const request = capture.requests[0]!; + const session = request.session as Record; + expect(session.modelRegistry).toBe(oldRegistry); + expect(session.getSessionId?.()).toBe("ses-old"); + }); + test("timeoutMs elapsing maps to timeout (fake surface honors signal abort)", async () => { + const surface = { + Settings: FAKE_SETTINGS, + discoverAuthStorage: FAKE_AUTH, + runStructuredSubagent: mock( + (request: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + request.signal?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + }), + ), + }; + const runner = new OmpSubagentRunner({ surface: surface as never }); + const result = await runner.run({ ...BASE_OPTIONS, timeoutMs: 50 }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("timeout"); + }); + + test("caller abort → abort reason", async () => { + const surface = { + Settings: FAKE_SETTINGS, + discoverAuthStorage: FAKE_AUTH, + runStructuredSubagent: mock( + (request: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + if (request.signal?.aborted) { + reject(new DOMException("The operation was aborted.", "AbortError")); + return; + } + request.signal?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + }), + ), + }; + const runner = new OmpSubagentRunner({ surface: surface as never }); + const controller = new AbortController(); + const pending = runner.run({ ...BASE_OPTIONS, timeoutMs: 60_000, signal: controller.signal }); + controller.abort(); + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("abort"); + }); + + test("harness label is omp", () => { + const runner = new OmpSubagentRunner({ surface: null }); + expect(runner.harness).toBe("omp"); + }); +}); + +describe("thinking level → OMP spawn mapping", () => { + test("taskEffortForLevel maps literal levels onto positional selectors", () => { + expect(taskEffortForLevel("minimal")).toBe("lo"); + expect(taskEffortForLevel("low")).toBe("lo"); + expect(taskEffortForLevel("medium")).toBe("med"); + expect(taskEffortForLevel("high")).toBe("hi"); + expect(taskEffortForLevel("xhigh")).toBe("hi"); + expect(taskEffortForLevel("max")).toBe("hi"); + }); + + test("taskEffortForLevel omits sentinels and unknowns (OMP default applies)", () => { + expect(taskEffortForLevel("off")).toBeUndefined(); + expect(taskEffortForLevel("auto")).toBeUndefined(); + expect(taskEffortForLevel("inherit")).toBeUndefined(); + expect(taskEffortForLevel(undefined)).toBeUndefined(); + expect(taskEffortForLevel("garbage")).toBeUndefined(); + }); + + test("isLiteralThinkingLevel accepts the wire-level vocabulary plus off", () => { + for (const level of ["minimal", "low", "medium", "high", "xhigh", "max", "off"]) { + expect(isLiteralThinkingLevel(level)).toBe(true); + } + for (const sentinel of ["auto", "inherit", undefined, "garbage"]) { + expect(isLiteralThinkingLevel(sentinel as string | undefined)).toBe(false); + } + }); + + test("configured thinking_level rides both effort and the model-ref suffix", async () => { + const capture: { requests: Array> } = { requests: [] }; + const surface = fakeSurface( + { + result: { + exitCode: 0, + output: "t", + stderr: "", + truncated: false, + }, + }, + capture, + ); + const runner = new OmpSubagentRunner({ surface: surface as never }); + const result = await runner.run({ ...BASE_OPTIONS, thinkingLevel: "max" }); + expect(result.ok).toBe(true); + const request = capture.requests[0]!; + // hi: positional selector (validates against OMP's TASK_EFFORTS gate) + expect(request.effort).toBe("hi"); + // max: exact literal suffix on the ref for per-model precision + expect(request.model).toBe("openai-codex/gpt-5.4:max"); + }); + + test("off rides the model ref as :off (disableReasoning) with effort omitted", async () => { + const capture: { requests: Array> } = { requests: [] }; + const surface = fakeSurface( + { + result: { + exitCode: 0, + output: "t", + stderr: "", + truncated: false, + }, + }, + capture, + ); + const runner = new OmpSubagentRunner({ surface: surface as never }); + await runner.run({ ...BASE_OPTIONS, thinkingLevel: "off" }); + const request = capture.requests[0]!; + // effort stays omitted: lo/med/hi has no off rung + expect(request.effort).toBeUndefined(); + // :off parses as a ThinkingLevel suffix and lands as disableReasoning + expect(request.model).toBe("openai-codex/gpt-5.4:off"); + }); + + test("colon-bearing ref with a non-level suffix keeps the id and skips the literal append", async () => { + const capture: { requests: Array> } = { requests: [] }; + const surface = fakeSurface( + { + result: { + exitCode: 0, + output: "t", + stderr: "", + truncated: false, + }, + }, + capture, + ); + const runner = new OmpSubagentRunner({ surface: surface as never }); + // nous-portal/meituan/longcat-2.0:free — :free is part of the model id + await runner.run({ + ...BASE_OPTIONS, + model: "nous-portal/meituan/longcat-2.0:free", + thinkingLevel: "max", + }); + const request = capture.requests[0]!; + // effort carries the thinking intent instead + expect(request.effort).toBe("hi"); + // NO invalid :free:max grammar; the id is untouched + expect(request.model).toBe("nous-portal/meituan/longcat-2.0:free"); + }); + + test("ref already carrying a thinking-level suffix gets it replaced", async () => { + const capture: { requests: Array> } = { requests: [] }; + const surface = fakeSurface( + { + result: { + exitCode: 0, + output: "t", + stderr: "", + truncated: false, + }, + }, + capture, + ); + const runner = new OmpSubagentRunner({ surface: surface as never }); + // config entry written in selector form — the configured level wins + await runner.run({ + ...BASE_OPTIONS, + model: "bai/glm-5.3-flash:high", + thinkingLevel: "max", + }); + const request = capture.requests[0]!; + expect(request.effort).toBe("hi"); + expect(request.model).toBe("bai/glm-5.3-flash:max"); + }); + test("off on a :free ref delegates to the subprocess runner (structured path cannot express it)", async () => { + const capture: { requests: Array> } = { requests: [] }; + const surface = fakeSurface( + { + result: { + exitCode: 0, + output: "t", + stderr: "", + truncated: false, + }, + }, + capture, + ); + let fallbackCalls = 0; + const fallbackRunner = { + harness: "pi", + run: async () => { + fallbackCalls += 1; + return { ok: true as const, assistantText: "fallback-ok", durationMs: 1 }; + }, + }; + const runner = new OmpSubagentRunner({ + surface: surface as never, + fallbackRunner: fallbackRunner as never, + }); + const result = await runner.run({ + ...BASE_OPTIONS, + model: "nous-portal/meituan/longcat-2.0:free", + thinkingLevel: "off", + }); + // Delegated — the structured path has no disable-reasoning channel for + // a non-level-suffixed ref, so the subprocess runner (--thinking off) + // serves this spawn. + expect(fallbackCalls).toBe(1); + // The native surface was never invoked. + expect(capture.requests).toHaveLength(0); + expect(result.ok).toBe(true); + if (result.ok) expect(result.assistantText).toBe("fallback-ok"); + }); +}); diff --git a/packages/pi-plugin/src/omp-subagent-runner.ts b/packages/pi-plugin/src/omp-subagent-runner.ts new file mode 100644 index 000000000..7b7c21c20 --- /dev/null +++ b/packages/pi-plugin/src/omp-subagent-runner.ts @@ -0,0 +1,560 @@ +/** + * OMP-native subagent runner. + * + * Implements the harness-agnostic `SubagentRunner` contract on top of OMP's + * in-process structured-subagent machinery (`runStructuredSubagent`) instead + * of the headless `--print` subprocess path used on plain Pi. See + * cortexkit/magic-context#416: spawning through OMP's task machinery makes + * MC subagents visible in the OMP subagent pane / Agent Hub while keeping + * the main agent isolated (per-spawn `enableIrc: false` removes the child's + * hub tool entirely, and an MC-initiated spawn is neither an owner job nor a + * `parentId: Main` registry child the model can cancel). + * + * In-process by design — mirrors OMP's own Cleanse feature, which builds a + * synthetic `ToolSession` and calls `runStructuredSubagent` directly + * (`coding-agent/src/cleanse/agent.ts`). The `--print` subprocess model + * cannot reach the pane/event-bus surfaces. + * + * Result mapping follows the `SubagentRunner` fail-soft contract: transient + * errors, timeouts, aborts, and model failures surface as + * `{ ok: false, reason }`; throwing is reserved for programmer errors. + */ + +import { + resolveModelRefForOmp, +} from "@magic-context/core/shared/harness-provider-map"; +import type { + SubagentProgressEvent, + SubagentRunOptions, + SubagentRunResult, + SubagentRunner, +} from "@magic-context/core/shared/subagent-runner"; +import { recordChildInvocation } from "@magic-context/core/features/magic-context/subagent-token-capture"; +import { openDatabase } from "@magic-context/core/features/magic-context/storage"; +import { inferAccountingSubagent, PiSubagentRunner } from "./subagent-runner"; +import { loadOmpSubagentSurface, type OmpSubagentSurface } from "./omp-host"; + +/** + * Subprocess fallback for OMP hosts whose structured-subagent surface cannot + * load (cortexkit/magic-context#418 review: a detected OMP process must never + * dead-end the historian when the subprocess path is available). Constructed + * lazily on first need; `undefined` until then. + */ +let subprocessFallback: PiSubagentRunner | undefined; + +/** Terminator instruction appended to every OMP assignment (yield contract). */ +const OMP_YIELD_INSTRUCTION = + "When you have finished the task, call the yield tool with your complete final output verbatim — do not summarize or reformat it."; + +/** Default wall-clock cap when the caller does not set `timeoutMs`. */ +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; + +/** Live host pieces the caller captured from the OMP extension context. */ +export interface OmpRunnerHostContext { + /** Canonical `provider/model` id (ctx.model), when the session has one. */ + model?: string | undefined; + /** Live host session file, when known (falls back to a temp artifacts lease). */ + sessionFile?: string | null; + /** Live host session id, when known. */ + sessionId?: string | null; + /** + * The host session's live ModelRegistry. OMP's structured spawn path + * forwards `session.modelRegistry` into `runSubprocess` + * (structured-subagent.ts:433 → executor.ts `registryFromParent`), so + * passing the parent's registry here makes extension-registered runtime + * providers (bai, nous-portal, kiosapi, …) resolvable in the child. + * Without it, `runSubprocess` constructs a fresh registry that only knows + * bundled-catalog providers + models.yml discoverables — a configured + * historian model from a runtime provider then fails to resolve and the + * spawn silently falls back to the session default ("No model selected" + * when even that fails). + */ + modelRegistry?: unknown; +} + +export interface OmpSubagentRunnerOptions { + /** Test seam: inject a pre-loaded surface instead of runtime resolution. */ + surface?: OmpSubagentSurface | null; + /** Test seam: override the host context snapshot. */ + hostContext?: OmpRunnerHostContext; + /** + * Test seam: replace the subprocess fallback used when the OMP surface is + * unavailable. Production constructs a real PiSubagentRunner lazily; tests + * inject a stub so the delegation path is hermetic (no `pi` binary spawn, + * which on hosts with Pi installed would run a live subagent or hang). + */ + fallbackRunner?: SubagentRunner; +} + +/** + * Build the minimal synthetic ToolSession the structured spawn path needs. + * Only `cwd`, `hasUI`, `getSessionFile`, `getSessionSpawns`, and `settings` + * are required by OMP's type; the rest of the executor wiring reads the + * optional accessors it finds and degrades gracefully when absent. + */ +function buildToolSession( + cwd: string, + host: OmpRunnerHostContext, + settings: unknown, + authStorage: unknown, +): Record { + return { + cwd, + hasUI: false, + // Hard isolation: no hub tool on the child, no IRC roster, no steering. + enableIrc: false, + // The historian is a pure summarizer — no LSP, no MCP. + enableLsp: false, + enableMCP: false, + restrictToolNames: true, + taskDepth: 0, + // The spawn-policy gate (`assertDepthAndSpawnAllowed`) reads this string. + getSessionSpawns: () => "task", + getAgentId: () => null, + getSessionFile: () => host.sessionFile ?? null, + getSessionId: () => host.sessionId ?? null, + getModelString: () => host.model, + getActiveModelString: () => host.model, + // The historian is MC-internal, not a user-visible spawn announcement. + suppressSpawnAdvisory: true, + settings, + authStorage, + // Parent's live registry — lets the structured spawn resolve + // extension-registered (runtime) providers instead of falling back to + // a fresh registry that only knows bundled + models.yml providers. + modelRegistry: host.modelRegistry, + }; +} + +/** Compose the assignment: historian persona (as system_role) + task + yield contract. */ +function buildAssignment(systemPrompt: string, userMessage: string): string { + const sections: string[] = []; + if (systemPrompt.trim().length > 0) { + sections.push( + `\n${systemPrompt.trim()}\n`, + "Treat the block above as your complete operating instructions for this run.", + ); + } + sections.push(userMessage, OMP_YIELD_INSTRUCTION); + return sections.join("\n\n"); +} + +/** Map an OMP error message onto the shared runner failure taxonomy. */ +function mapFailureReason( + error: string, + aborted: boolean, + timedOut: boolean, +): SubagentRunResult extends { ok: false; reason: infer R } ? R : never { + if (timedOut) return "timeout" as never; + if (aborted) return "abort" as never; + if (/rate.?limit|429|quota|capacity|overloaded/i.test(error)) { + return "model_failed" as never; + } + return "non_zero_exit" as never; +} + +function sanitizeLabel(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/g, "").slice(0, 48) || "Subagent"; +} + +/** + * Thinking selectors OMP's model-pattern grammar accepts as `:level` + * suffixes (parseThinkingLevel vocabulary — the same table that backs + * `--thinking`). Includes `off`, which is a wire-real selector: it parses + * as a `:off` suffix and lands on the child as `disableReasoning: true` + * (shouldDisableReasoning). `auto`/`inherit` are NOT included — `auto` is + * gated out of suffixes by parseThinkingSuffix (only via allowAutoAlias) + * and `inherit` is a role-storage sentinel, not a spawn selector. + */ +const LITERAL_THINKING_LEVEL: Record = { + minimal: true, + low: true, + medium: true, + high: true, + xhigh: true, + max: true, + off: true, +}; + +/** True when the configured level can ride a model ref as an exact `:level` suffix. */ +export function isLiteralThinkingLevel(level: string | undefined): level is string { + return level !== undefined && LITERAL_THINKING_LEVEL[level] === true; +} + +/** + * Strip a trailing `:suffix` from a model ref when that suffix parses as a + * thinking level (e.g. a config entry already written in selector form, + * `bai/glm-5.3-flash:high`). A non-level suffix (`nous-portal/ + * meituan/longcat-2.0:free`) is left intact — it is part of the model id, + * and appending would produce the invalid grammar `:free:max`. + */ +function stripThinkingSuffixFromRef(ref: string): string { + const colonIdx = ref.lastIndexOf(":"); + if (colonIdx <= 0) return ref; + const suffix = ref.slice(colonIdx + 1); + // Reuse OMP's own level vocabulary: anything the selector table knows is + // a thinking suffix, anything else is part of the id. + if (LITERAL_THINKING_LEVEL[suffix] === true) return ref.slice(0, colonIdx); + return ref; +} + +/** + * Map a literal thinking level onto OMP's positional TaskEffort selector. + * OMP's task-spawn surface only accepts "lo" | "med" | "hi" + * (TASK_EFFORTS, thinking.ts:270) — validateEffort rejects anything else — + * and resolveTaskEffortLevel then maps the selector onto the target model's + * OWN supported ladder (lo = lowest supported, med = middle, hi = highest, + * which is xhigh or max on models that go that high). + * + * minimal/low -> "lo" medium -> "med" high/xhigh/max -> "hi" + * off/auto/inherit/undefined -> undefined (omit; OMP default applies) + * + * Long ladders lose precision here (e.g. "medium" on a 6-rung ladder lands + * between rungs), which is why the literal `:level` suffix rides the model + * ref alongside — the selector is the fallback, not the primary signal. + */ +export function taskEffortForLevel(level: string | undefined): "lo" | "med" | "hi" | undefined { + switch (level) { + case "minimal": + case "low": + return "lo"; + case "medium": + return "med"; + case "high": + case "xhigh": + case "max": + return "hi"; + default: + return undefined; + } +} + +export class OmpSubagentRunner implements SubagentRunner { + readonly harness = "omp"; + + private readonly injectedSurface: OmpSubagentSurface | null | undefined; + private hostContext: OmpRunnerHostContext; + private readonly injectedFallback: SubagentRunner | undefined; + + constructor(options: OmpSubagentRunnerOptions = {}) { + this.injectedSurface = options.surface; + this.hostContext = options.hostContext ?? {}; + this.injectedFallback = options.fallbackRunner; + } + + /** + * Update the live host context snapshot (model ref, session ids, and the + * parent's ModelRegistry). Called by the extension when it has a live + * `ExtensionContext` in scope — the singleton runner is constructed + * before any session exists, so construction-time capture would be empty. + */ + setHostContext(patch: Partial): void { + this.hostContext = { ...this.hostContext, ...patch }; + } + + /** + * Resolve the surface lazily on first run (construction must not touch + * host modules — the runner may be constructed on non-OMP hosts where the + * import would fail). + */ + private async resolveSurface(): Promise { + if (this.injectedSurface !== undefined) { + return this.injectedSurface ?? { error: "injected surface unavailable" }; + } + const loaded = await loadOmpSubagentSurface(); + if (loaded.surface) return loaded.surface; + return { error: loaded.reason ?? "omp surface unavailable" }; + } + + async run(options: SubagentRunOptions): Promise { + const startedAt = Date.now(); + const duration = () => Date.now() - startedAt; + // Snapshot the live host context for this run: session_start can fire + // while a run is awaiting settings/auth init, and the singleton must + // not build the in-flight run with the new session's registry and ids. + const host = { ...this.hostContext }; + // Hoisted so the outer catch can distinguish deadline aborts from + // caller aborts (the AbortError message alone cannot). + let timedOut = false; + // Accounting parity with PiSubagentRunner.runOnce: best-effort + // subagent_invocations row so /ctx-status and token totals see OMP + // runs the same way they see subprocess runs. Failure to record must + // never fail the run. + const recordAccounting = (result: SubagentRunResult, usage?: { input?: number; output?: number; cacheWrite?: number; cacheRead?: number }) => { + if (!options.accountingSessionId) return; + try { + recordChildInvocation({ + db: openDatabase(), + parentSessionId: options.accountingSessionId, + harness: "omp", + subagent: options.accountingSubagent ?? inferAccountingSubagent(options.agent), + task: options.accountingTask ?? null, + startedAt, + endedAt: Date.now(), + status: result.ok ? "completed" : result.reason === "abort" ? "aborted" : "failed", + providerId: typeof options.model === "string" ? options.model.split("/")[0] : null, + modelId: typeof options.model === "string" ? options.model.split("/").slice(1).join("/") : null, + tokens: usage + ? { + input: usage.input ?? 0, + output: usage.output ?? 0, + cacheRead: usage.cacheRead ?? 0, + cacheWrite: usage.cacheWrite ?? 0, + } + : undefined, + error: result.ok ? null : result.error, + parentInvocationId: options.accountingParentInvocationId ?? null, + }); + } catch { + // Best-effort: never fail the run on accounting. + } + }; + try { + const resolved = await this.resolveSurface(); + if (!("runStructuredSubagent" in resolved)) { + // Surface unavailable on a detected OMP host — delegate to the + // subprocess runner instead of failing the run. The fallback is + // sticky for the process: if the surface cannot load once (broken + // install, missing package), it will not load later. + // Tests inject `fallbackRunner` so this delegation is hermetic — + // a real PiSubagentRunner would spawn an actual `pi` child on + // hosts where Pi is installed (hangs without a timeout). + const fallback = this.injectedFallback ?? (subprocessFallback ??= new PiSubagentRunner()); + options.onProgress?.({ + type: "spawned", + argv: ["omp:fallback-subprocess", options.agent], + pid: undefined, + }); + return fallback.run(options); + } + const surface = resolved; + + const cwd = options.cwd ?? process.cwd(); + // OMP selects models by its own selector grammar; the canonical + // provider prefix may need translation (openai→openai-codex etc.), + // which resolveModelRefForOmp already implements for the subprocess + // path. The explicitly configured model (options.model) wins; the + // host's ambient session model is only the fallback when nothing is + // configured — reversing this would route every historian run to the + // parent's model instead of the configured historian model. + // `host` is the run-start snapshot (see top of run()). + const hostModel = options.model ?? host.model; + const modelRef = hostModel ? resolveModelRefForOmp(hostModel) : undefined; + + // Timeout: race the caller's signal against an internal deadline. + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const controller = new AbortController(); + const abortFromCaller = () => controller.abort(); + options.signal?.addEventListener("abort", abortFromCaller, { once: true }); + // A signal that aborted before this line never fires its event — + // honor the already-aborted state immediately. + if (options.signal?.aborted) abortFromCaller(); + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + try { + const settings = await surface.Settings.init({ cwd }); + const authStorage = await surface.discoverAuthStorage(); + const session = buildToolSession(cwd, host, settings, authStorage); + // Thinking for this spawn, honoring the config's `thinking_level` + // (`options.thinkingLevel`), which the subprocess path enforces + // via `--thinking`. Two OMP-sanctioned mechanisms: + // + // 1. `effort` — the positional TaskEffort selector ("lo" | "med" | + // "hi"). resolveTaskEffortLevel maps it onto the target model's + // OWN supported ladder (lo = lowest supported, hi = whatever the + // model tops out at: high/xhigh/max). Literal levels ("high", + // "max") are NOT valid here — OMP's validateEffort rejects + // anything outside lo/med/hi. + // 2. A `:level` suffix on the model ref — OMP's model-pattern + // grammar (splitThinkingSuffix) accepts exact literal levels + // (minimal/low/medium/high/xhigh/max, plus `off` for + // disableReasoning) and resolves them as explicitThinkingLevel. + // Exact per-model; effort is the positional fallback for long + // ladders where the selector is less precise. + const effort = taskEffortForLevel(options.thinkingLevel); + const literalLevel = isLiteralThinkingLevel(options.thinkingLevel) + ? options.thinkingLevel + : undefined; + let model: string | undefined; + // The one combination the structured path cannot express: `off` + // (disable reasoning) on a ref whose trailing suffix is part of + // the model id (`:free`). Appending is invalid grammar + // (`:free:off`) and there is no effort rung for "off" — so the + // subprocess runner, which passes `--thinking off`, serves this + // spawn instead. + const offBlockedByNonLevelSuffix = + options.thinkingLevel === "off" && + modelRef !== undefined && + stripThinkingSuffixFromRef(modelRef) === modelRef && + modelRef.lastIndexOf(":") > 0; + if (offBlockedByNonLevelSuffix) { + const fallback = this.injectedFallback ?? (subprocessFallback ??= new PiSubagentRunner()); + options.onProgress?.({ + type: "spawned", + argv: ["omp:fallback-subprocess", options.agent], + pid: undefined, + }); + return fallback.run(options); + } + if (modelRef !== undefined) { + if (literalLevel === undefined) { + model = modelRef; + } else { + const stripped = stripThinkingSuffixFromRef(modelRef); + const hadThinkingSuffix = stripped !== modelRef; + const refHasNonLevelSuffix = modelRef.lastIndexOf(":") > 0 && !hadThinkingSuffix; + if (refHasNonLevelSuffix) { + model = modelRef; + } else { + model = `${stripped}:${literalLevel}`; + } + } + } + const request = { + session, + invocationKind: "task" as const, + assignment: buildAssignment(options.systemPrompt, options.userMessage), + agent: "task", + ...(effort !== undefined ? { effort } : {}), + ...(model !== undefined ? { model } : {}), + identity: { label: `MagicContext-${sanitizeLabel(options.agent)}` }, + enableIrc: false, + enableLsp: false, + keepAlive: false, + detached: true, + signal: controller.signal, + }; + options.onProgress?.({ type: "spawned", argv: ["omp:structured-subagent", options.agent], pid: undefined }); + const outcome = await surface.runStructuredSubagent(request); + const single = outcome.result; + options.onProgress?.({ type: "first_event", eventType: "settled", ms: duration() }); + + const usage = single.usage; + const successResult: SubagentRunResult = + single.exitCode === 0 && !single.error && single.aborted !== true + ? { ok: true, assistantText: single.output.trim(), durationMs: duration() } + : (() => { + const reason = mapFailureReason( + single.error ?? "omp spawn failed", + single.aborted === true, + controller.signal.aborted && timedOut, + ); + return { + ok: false as const, + reason, + error: single.error ?? "omp spawn failed", + durationMs: duration(), + // Rate-limit/capacity classes are retryable — mirror the + // subprocess runner's transient hint for fallback chains. + ...(reason === "model_failed" ? { transient: true } : {}), + }; + })(); + // Record AFTER final classification: an exit-0 run with empty + // output is a failed attempt (no_assistant), not a completed + // invocation — recording the pre-classification result would log + // `completed` for a run that returns no_assistant. + if (!successResult.ok) { + recordAccounting(successResult, usage); + return successResult; + } + if (successResult.assistantText.length === 0) { + const finalResult: SubagentRunResult = { + ok: false, + reason: "no_assistant", + error: "omp structured spawn produced no output", + durationMs: duration(), + }; + recordAccounting(finalResult, usage); + return finalResult; + } + recordAccounting(successResult, usage); + return { + ok: true, + assistantText: successResult.assistantText, + durationMs: successResult.durationMs, + meta: { + resolvedModel: single.resolvedModel, + tokens: single.tokens, + truncated: single.truncated === true, + ompRunner: true, + }, + }; + } finally { + clearTimeout(timer); + options.signal?.removeEventListener("abort", abortFromCaller); + } + } catch (error) { + // StructuredSubagentError preflight failures (unknown agent, spawn + // policy, isolation) and any unexpected host error land here. + const message = error instanceof Error ? error.message : String(error); + const progress: SubagentProgressEvent = { + type: "child_exit", + code: 1, + signal: null, + ms: duration(), + }; + options.onProgress?.(progress); + // Every terminal failure records accounting — a rejected preflight + // or deadline abort is still a historian attempt the invocation + // history and token aggregates must not omit. + if (timedOut) { + const result: SubagentRunResult = { + ok: false, + reason: "timeout", + error: `historian run exceeded deadline: ${message}`, + durationMs: duration(), + }; + recordAccounting(result); + return result; + } + if (/aborted|AbortSignal/i.test(message)) { + const result: SubagentRunResult = { ok: false, reason: "abort", error: message, durationMs: duration() }; + recordAccounting(result); + return result; + } + const result: SubagentRunResult = { ok: false, reason: "spawn_failed", error: message, durationMs: duration() }; + recordAccounting(result); + return result; + } + } +} + +/** + * Minimal live-context surface needed to snapshot the host into the runner. + * Structural (not the Pi/OMP ExtensionContext types) so both harnesses' + * contexts satisfy it without importing host modules. + */ +export interface OmpHostSnapshotSource { + readonly model?: { readonly provider: string; readonly id: string } | undefined; + readonly modelRegistry?: unknown; + readonly sessionManager?: + | { + getSessionId?: () => string | undefined; + getSessionFile?: () => string | undefined; + } + | undefined; +} + +/** + * Snapshot a live extension context into an OMP runner's host context: the + * parent session's ModelRegistry (so extension-registered runtime providers + * resolve in structured spawns), plus the active model ref and session ids. + * No-op unless `runner` is an OMP runner; best-effort — a capture failure + * must never break session start or spawn. + */ +export function captureOmpHostContext(runner: SubagentRunner | undefined, ctx: OmpHostSnapshotSource): void { + if (!(runner instanceof OmpSubagentRunner)) return; + try { + runner.setHostContext({ + model: ctx.model !== undefined ? `${ctx.model.provider}/${ctx.model.id}` : undefined, + modelRegistry: ctx.modelRegistry, + sessionFile: ctx.sessionManager?.getSessionFile?.() ?? null, + sessionId: ctx.sessionManager?.getSessionId?.() ?? null, + }); + } catch { + // Best-effort capture; the run degrades to options.model + OMP + // default resolution when the snapshot is absent. + } +} diff --git a/packages/pi-plugin/src/subagent-runner.ts b/packages/pi-plugin/src/subagent-runner.ts index 43638eb80..5379fe991 100644 --- a/packages/pi-plugin/src/subagent-runner.ts +++ b/packages/pi-plugin/src/subagent-runner.ts @@ -500,7 +500,7 @@ function expandHomePath(value: string): string { * Positive OMP host identification. PI_CODING_AGENT_DIR alone is deliberately * insufficient because upstream Pi supports the same variable. */ -function isOmpHostProcess(): boolean { +export function isOmpHostProcess(): boolean { const execName = basename(process.execPath).toLowerCase(); if (/^omp(?:\.exe)?$/.test(execName)) return true; @@ -752,7 +752,7 @@ const KNOWN_PI_SUBAGENT_AGENTS = [ "magic-context-dreamer", ] as const; -function inferAccountingSubagent(agent: string): SubagentKind { +export function inferAccountingSubagent(agent: string): SubagentKind { if (agent.includes("sidekick")) return "sidekick"; if (agent.includes("retrospective")) return "dreamer"; if (agent.includes("dreamer")) return "dreamer"; diff --git a/packages/plugin/src/features/magic-context/storage-subagent-invocations.ts b/packages/plugin/src/features/magic-context/storage-subagent-invocations.ts index c25e6c576..3f069d6d8 100644 --- a/packages/plugin/src/features/magic-context/storage-subagent-invocations.ts +++ b/packages/plugin/src/features/magic-context/storage-subagent-invocations.ts @@ -9,11 +9,13 @@ export type SubagentKind = | "user_memory_review" | "recomp"; +export type SubagentInvocationHarness = "opencode" | "pi" | "omp"; + export type SubagentInvocationStatus = "completed" | "failed" | "aborted"; export interface SubagentInvocationInput { sessionId: string; - harness: "opencode" | "pi"; + harness: SubagentInvocationHarness; subagent: SubagentKind; task?: string | null; providerId?: string | null; @@ -32,7 +34,7 @@ export interface SubagentInvocationInput { export interface SubagentInvocationRow { id: number; sessionId: string; - harness: "opencode" | "pi"; + harness: SubagentInvocationHarness; subagent: SubagentKind; task: string | null; providerId: string | null; @@ -59,7 +61,7 @@ export interface SubagentTotals { interface SubagentInvocationDbRow { id: number; session_id: string; - harness: "opencode" | "pi"; + harness: SubagentInvocationHarness; subagent: SubagentKind; task: string | null; provider_id: string | null; diff --git a/packages/plugin/src/features/magic-context/subagent-token-capture.ts b/packages/plugin/src/features/magic-context/subagent-token-capture.ts index 74913cbdd..a2660dedc 100644 --- a/packages/plugin/src/features/magic-context/subagent-token-capture.ts +++ b/packages/plugin/src/features/magic-context/subagent-token-capture.ts @@ -3,6 +3,7 @@ import { sessionLog } from "../../shared/logger"; import type { Database } from "../../shared/sqlite"; import { recordSubagentInvocation, + type SubagentInvocationHarness, type SubagentInvocationStatus, type SubagentKind, } from "./storage-subagent-invocations"; @@ -22,7 +23,7 @@ export interface LastAssistantModel { export interface ChildInvocationRecordInput { db: Database | null; parentSessionId: string; - harness: "opencode" | "pi"; + harness: SubagentInvocationHarness; subagent: SubagentKind; startedAt: number; endedAt?: number;